Пишем нейросеть на Go с нуля

нейросеть на Go

В этой статье мы шаг за шагом создадим нейронную сеть на Go для решения задачи классификации. Несмотря на то, что здесь рассматривается только одна архитектура сети, наш код будет достаточно гибким для удобного изменения количества узлов на каждом слое при необходимости.

Архитектура сети

Базовая архитектура сети, которую мы будем использовать, включает в себя входной слой, один скрытый слой и выходной слой:

нейросеть на Go

Хотя этот тип нейросетей не очень «глубокий», он хорошо себя показал при выполнении множества простых классификационных задач. В нашем случае мы будем тренировать модель классифицировать цветы ириса, основываясь на известном датасете Фишера. Этого должно быть более чем достаточно, чтобы решить нашу задачу с большой точностью.

Каждый узел в сети будет принимать один вход или более, линейно их объединять (с использованием весов и смещения) и затем применять нелинейную активационную функцию. Оптимизируя веса и смещения с помощью метода обратного распространения ошибки, мы сможем найти отношение между входами (данные измерений цветов) и тем, что мы пытаемся предсказать (разновидность цветов). Затем мы сможем передать новые входные данные через оптимизированную сеть с помощью прямого распространения, чтобы предсказать соответствующий выход.

Если вы плохо знакомы с нейронными сетями, то можете посмотреть нашу подборку материалов по нейросетям для новичков. Также пригодятся шпаргалки по разновидностям нейронных сетей: часть 1 и часть 2.

Определяем полезные функции и типы

Прежде чем углубляться в метод обратного распространения ошибки и сети прямого распространения, давайте определим несколько типов, которые будут полезны в работе с нашей моделью:

// neuralNet содержит всю информацию,
// которая определяет обученную сеть.
type neuralNet struct {  
        config  neuralNetConfig
        wHidden *mat.Dense
        bHidden *mat.Dense
        wOut    *mat.Dense
        bOut    *mat.Dense
}

// neuralNetConfig определяет архитектуру
// и параметры обучения нашей сети.
type neuralNetConfig struct {  
        inputNeurons  int
        outputNeurons int
        hiddenNeurons int
        numEpochs     int
        learningRate  float64
}

// newNetwork инициализирует новую нейронную сеть.
func newNetwork(config neuralNetConfig) *neuralNet {  
        return &neuralNet{config: config}
}

Также нам нужно определить активационную функцию и её производную, которую мы будем использовать во время обратного распространения. Есть много активационных функций на выбор, но мы будем использовать сигмоиду. У этой функции множество преимуществ, включая вероятностные интерпретации и удобное выражение для её производной.

// sigmoid является реализацией сигмоиды,
// используемой для активации.
func sigmoid(x float64) float64 {  
        return 1.0 / (1.0 + math.Exp(-x))
}

// sigmoidPrime является реализацией производной
// сигмоиды для обратного распространения.
func sigmoidPrime(x float64) float64 {  
        return x * (1.0 - x)
}

Реализуем обратное распространение для обучения

Определив всё, что нужно, мы можем написать реализацию метода обратного распространения ошибки для обучения или оптимизации весов и смещений нашей сети. Разберём метод по шагам:

  1. Инициализируем веса и смещения.
  2. Подаём обучающие данные на вход, чтобы получить выход.
  3. Сравниваем полученный результат с ожидаемым для определения ошибок.
  4. Изменяем веса и смещения исходя из полученных ошибок.
  5. Распространяем изменения обратно по сети.
  6. Повторяем шаги 2–5 в течение определенного количества эпох или пока не будет выполнено условие для остановки.

В шагах 3–5 мы используем метод стохастического градиентного спуска для определения изменений весов и смещений.

Для реализации обучения сети давайте создадим функцию для neuralNet, которая будет принимать указатели на матрицы x и y в качестве входных данных. Матрица x будет отражать признаки наших данных (независимые переменные) и y будет представлять то, что мы пытаемся предсказать (зависимая переменная).

В функции ниже мы случайным образом инициализируем веса и смещения, а затем используем обратное распространение для их оптимизации:

// train обучает нейронную сеть, используя обратное распространение.
func (nn *neuralNet) train(x, y *mat.Dense) error {

    // Инициализируем смещения/веса.
    randSource := rand.NewSource(time.Now().UnixNano())
    randGen := rand.New(randSource)

    wHidden := mat.NewDense(nn.config.inputNeurons, nn.config.hiddenNeurons, nil)
    bHidden := mat.NewDense(1, nn.config.hiddenNeurons, nil)
    wOut := mat.NewDense(nn.config.hiddenNeurons, nn.config.outputNeurons, nil)
    bOut := mat.NewDense(1, nn.config.outputNeurons, nil)

    wHiddenRaw := wHidden.RawMatrix().Data
    bHiddenRaw := bHidden.RawMatrix().Data
    wOutRaw := wOut.RawMatrix().Data
    bOutRaw := bOut.RawMatrix().Data

    for _, param := range [][]float64{
        wHiddenRaw,
        bHiddenRaw,
        wOutRaw,
        bOutRaw,
    } {
        for i := range param {
            param[i] = randGen.Float64()
        }
    }

    // Определяем выход сети.
    output := new(mat.Dense)

    // Используем обратное распространение для регулировки весов и смещений.
    if err := nn.backpropagate(x, y, wHidden, bHidden, wOut, bOut, output); err != nil {
        return err
    }

    // Определяем обученную сеть.
    nn.wHidden = wHidden
    nn.bHidden = bHidden
    nn.wOut = wOut
    nn.bOut = bOut

    return nil
}

Ниже показана реализация обратного распространения.

Примечание Для простоты и удобства здесь используется несколько матриц, поскольку мы применяем метод прямого распространения. Для больших наборов данных вам, возможно, потребуется оптимизировать код, чтобы уменьшить количество матриц в памяти.

// backpropagate завершает метод прямого распространения.
func (nn *neuralNet) backpropagate(x, y, wHidden, bHidden, wOut, bOut, output *mat.Dense) error {

    // Обучаем нашу модель в течение определенного
    // количества эпох, используя обратное распространение.
    for i := 0; i < nn.config.numEpochs; i++ {

        // Завершаем процесс прямого распространения.
        hiddenLayerInput := new(mat.Dense)
        hiddenLayerInput.Mul(x, wHidden)
        addBHidden := func(_, col int, v float64) float64 { return v + bHidden.At(0, col) }
        hiddenLayerInput.Apply(addBHidden, hiddenLayerInput)

        hiddenLayerActivations := new(mat.Dense)
        applySigmoid := func(_, _ int, v float64) float64 { return sigmoid(v) }
        hiddenLayerActivations.Apply(applySigmoid, hiddenLayerInput)

        outputLayerInput := new(mat.Dense)
        outputLayerInput.Mul(hiddenLayerActivations, wOut)
        addBOut := func(_, col int, v float64) float64 { return v + bOut.At(0, col) }
        outputLayerInput.Apply(addBOut, outputLayerInput)
        output.Apply(applySigmoid, outputLayerInput)

        // Завершаем обратное расространение.
        networkError := new(mat.Dense)
        networkError.Sub(y, output)

        slopeOutputLayer := new(mat.Dense)
        applySigmoidPrime := func(_, _ int, v float64) float64 { return sigmoidPrime(v) }
        slopeOutputLayer.Apply(applySigmoidPrime, output)
        slopeHiddenLayer := new(mat.Dense)
        slopeHiddenLayer.Apply(applySigmoidPrime, hiddenLayerActivations)

        dOutput := new(mat.Dense)
        dOutput.MulElem(networkError, slopeOutputLayer)
        errorAtHiddenLayer := new(mat.Dense)
        errorAtHiddenLayer.Mul(dOutput, wOut.T())

        dHiddenLayer := new(mat.Dense)
        dHiddenLayer.MulElem(errorAtHiddenLayer, slopeHiddenLayer)

        // Регулируем параметры.
        wOutAdj := new(mat.Dense)
        wOutAdj.Mul(hiddenLayerActivations.T(), dOutput)
        wOutAdj.Scale(nn.config.learningRate, wOutAdj)
        wOut.Add(wOut, wOutAdj)

        bOutAdj, err := sumAlongAxis(0, dOutput)
        if err != nil {
            return err
        }
        bOutAdj.Scale(nn.config.learningRate, bOutAdj)
        bOut.Add(bOut, bOutAdj)

        wHiddenAdj := new(mat.Dense)
        wHiddenAdj.Mul(x.T(), dHiddenLayer)
        wHiddenAdj.Scale(nn.config.learningRate, wHiddenAdj)
        wHidden.Add(wHidden, wHiddenAdj)

        bHiddenAdj, err := sumAlongAxis(0, dHiddenLayer)
        if err != nil {
            return err
        }
        bHiddenAdj.Scale(nn.config.learningRate, bHiddenAdj)
        bHidden.Add(bHidden, bHiddenAdj)
    }
  

Выше мы использовали вспомогательную функцию sumAlongAxis, которая позволяет нам складывать значения только по столбцам или только по строкам матрицы:

func sumAlongAxis(axis int, m *mat.Dense) (*mat.Dense, error) {

        numRows, numCols := m.Dims()

        var output *mat.Dense

        switch axis {
        case 0:
                data := make([]float64, numCols)
                for i := 0; i < numCols; i++ {
                        col := mat.Col(nil, i, m)
                        data[i] = floats.Sum(col)
                }
                output = mat.NewDense(1, numCols, data)
        case 1:
                data := make([]float64, numRows)
                for i := 0; i < numRows; i++ {
                        row := mat.Row(nil, i, m)
                        data[i] = floats.Sum(row)
                }
                output = mat.NewDense(numRows, 1, data)
        default:
                return nil, errors.New("invalid axis, must be 0 or 1")
        }

        return output, nil
}

Реализуем прямое распространение

После обучения нашей сети мы захотим использовать её для предсказаний. Для этого нам всего лишь нужно подать на вход определенные значения x, чтобы получить выходные данные. Это очень похоже на первую часть обратного распространения, за исключением того, что мы будем возвращать полученный результат:

// predict делает предсказание с помощью
// обученной нейронной сети.
func (nn *neuralNet) predict(x *mat.Dense) (*mat.Dense, error) {

    // Проверяем, представляет ли значение neuralNet
    // обученную модель.
    if nn.wHidden == nil || nn.wOut == nil {
        return nil, errors.New("the supplied weights are empty")
    }
    if nn.bHidden == nil || nn.bOut == nil {
        return nil, errors.New("the supplied biases are empty")
    }

    // Определяем выход сети.
    output := new(mat.Dense)

    // Завершаем процесс прямого распространения.
    hiddenLayerInput := new(mat.Dense)
    hiddenLayerInput.Mul(x, nn.wHidden)
    addBHidden := func(_, col int, v float64) float64 { return v + nn.bHidden.At(0, col) }
    hiddenLayerInput.Apply(addBHidden, hiddenLayerInput)

    hiddenLayerActivations := new(mat.Dense)
    applySigmoid := func(_, _ int, v float64) float64 { return sigmoid(v) }
    hiddenLayerActivations.Apply(applySigmoid, hiddenLayerInput)

    outputLayerInput := new(mat.Dense)
    outputLayerInput.Mul(hiddenLayerActivations, nn.wOut)
    addBOut := func(_, col int, v float64) float64 { return v + nn.bOut.At(0, col) }
    outputLayerInput.Apply(addBOut, outputLayerInput)
    output.Apply(applySigmoid, outputLayerInput)

    return output, nil
}

Данные

Итак, мы написали весь необходимый код для обучения и тестирования нашей нейронной сети. Однако давайте сначала взглянем на данные, которые мы будем использовать.

Наш датасет является слегка изменённой версией ирисов Фишера. Этот датасет включает в себя четыре набора измерений цветов ириса (значения нашего x) и соответствующие указания видов (значения нашего y). Для использования этих данных в нашей нейронной сети, они были изменены таким образом, что значения видов представлены тремя бинарными столбцами (1 — если строка соответствует этому виду, в противном случае — 0). Также был добавлен случайный шум, чтобы попытаться запутать нейронную сеть (иначе эта задача была бы слишком простой):

$ head train.csv
sepal_length,sepal_width,petal_length,petal_width,setosa,virginica,versicolor  
0.0833333333333,0.666666666667,0.0,0.0416666666667,1.0,0.0,0.0  
0.722222222222,0.458333333333,0.694915254237,0.916666666667,0.0,1.0,0.0  
0.666666666667,0.416666666667,0.677966101695,0.666666666667,0.0,0.0,1.0  
0.777777777778,0.416666666667,0.830508474576,0.833333333333,0.0,1.0,0.0  
0.666666666667,0.458333333333,0.779661016949,0.958333333333,0.0,1.0,0.0  
0.388888888889,0.416666666667,0.542372881356,0.458333333333,0.0,0.0,1.0  
0.666666666667,0.541666666667,0.796610169492,0.833333333333,0.0,1.0,0.0  
0.305555555556,0.583333333333,0.0847457627119,0.125,1.0,0.0,0.0  
0.416666666667,0.291666666667,0.525423728814,0.375,0.0,0.0,1.0  

Данные были разбиты на тренировочную и проверочную части train.csv и test.csv соответственно.

Собираем всё вместе

Давайте заставим сеть работать. Для этого мы сначала должны прочитать тренировочные данные, инициализировать значение neuralNet и вызвать метод train():

package main

import (  
        "encoding/csv"
        "errors"
        "fmt"
        "log"
        "math"
        "math/rand"
        "os"
        "strconv"
        "time"

        "gonum.org/v1/gonum/floats"
        "gonum.org/v1/gonum/mat"
)

func main() {

        // Открываем файл с обучающими данными.
        f, err := os.Open("data/train.csv")
        if err != nil {
                log.Fatal(err)
        }
        defer f.Close()

        // Создаём новый CSV-парсер для открытого файла.
        reader := csv.NewReader(f)
        reader.FieldsPerRecord = 7

        // Считываем все записи.
        rawCSVData, err := reader.ReadAll()
        if err != nil {
                log.Fatal(err)
        }

        // inputsData и labelsData будут содержать все
        // числа, которые будут использованы
        // для формирования наших матриц.
        inputsData := make([]float64, 4*len(rawCSVData))
        labelsData := make([]float64, 3*len(rawCSVData))

        // inputsIndex отслеживает текущий индекс
        // значений входных матриц.
        var inputsIndex int
        var labelsIndex int

        // Последовательно помещаем строки в inputsData.
        for idx, record := range rawCSVData {

                // Пропускаем строку заголовков.
                if idx == 0 {
                        continue
                }

                // Проходимся в цикле по столбцам.
                for i, val := range record {

                        // Преобразуем значение в float.
                        parsedVal, err := strconv.ParseFloat(val, 64)
                        if err != nil {
                                log.Fatal(err)
                        }

                        // Добавляем значение в labelsData при необходимости.
                        if i == 4 || i == 5 || i == 6 {
                                labelsData[labelsIndex] = parsedVal
                                labelsIndex++
                                continue
                        }

                        // Добавляем значение в inputsData.
                        inputsData[inputsIndex] = parsedVal
                        inputsIndex++
                }
        }

        // Формируем матрицы.
        inputs := mat.NewDense(len(rawCSVData), 4, inputsData)
        labels := mat.NewDense(len(rawCSVData), 3, labelsData)

        // Определяем архитектуру нашей сети
        // и параметры обучения.
        config := neuralNetConfig{
                inputNeurons:  4,
                outputNeurons: 3,
                hiddenNeurons: 3,
                numEpochs:     5000,
                learningRate:  0.3,
        }

        // Обучаем сеть.
        network := newNetwork(config)
        if err := network.train(inputs, labels); err != nil {
                log.Fatal(err)
        }

        ...

        // Проверка будет показана ниже.

        ...

}

После обучения сети мы можем поместить проверочные данные в матрицы testInputs и testLabels, использовать метод predict() для предсказания вида цветка и затем сравнить полученный результат с ожидаемым. Расчёт предсказаний и точности выглядит таким образом:

func main() {

        ...

        // Обучение показано выше.

        ...

        // Считываем данные для проверки в testInputs и testLabels.

        ...

        // Делаем предсказание с помощью обученной модели.
        predictions, err := network.predict(testInputs)
        if err != nil {
                log.Fatal(err)
        }

        // Рассчитываем точность модели.
        var truePosNeg int
        numPreds, _ := predictions.Dims()
        for i := 0; i < numPreds; i++ {

                // Получаем вид.
                labelRow := mat.Row(nil, i, testLabels)
                var species int
                for idx, label := range labelRow {
                        if label == 1.0 {
                                species = idx
                                break
                        }
                }

                // Считаем количество верных предсказаний.
                if predictions.At(i, species) == floats.Max(mat.Row(nil, i, predictions)) {
                        truePosNeg++
                }
        }

        // Подсчитываем точность предсказаний.
        accuracy := float64(truePosNeg) / float64(numPreds)

        // Выводим точность.
        fmt.Printf("\nAccuracy = %0.2f\n\n", accuracy)
}

Результат

После запуска нашей программы мы получаем что-то вроде этого:

$ go build
$ ./gophernet

Accuracy = 0.97  

Весь код, а также данные для обучения и проверки, можно найти на GitHub-странице автора.

Перевод статьи «Building a Neural Net from Scratch in Go»

Подобрали три теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.
Сложный тест по C# — проверьте свои знания.

Также рекомендуем: