Нейросеть на Go с нуля · Глава 5 из 10

Глава 5. Обратное распространение и первое обучение

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

Формулы градиентов выведены, численная проверка написана. Осталось соединить всё в работающий механизм: прогнать батч вперёд, посчитать потери, пустить градиент назад через все слои, обновить веса и повторить. Это и есть обучение нейросети — и после этой главы у тебя будет сеть, которая учится по-настоящему.

Обратное распространение (backpropagation) звучит внушительно, но по сути это аккуратная бухгалтерия: каждый слой должен уметь ответить на два вопроса — «как надо изменить мои параметры» и «какой градиент передать соседу слева». Всё остальное — цикл.

Как устроен обратный проход

Ключевая идея: прямой проход идёт слева направо и по дороге запоминает промежуточные значения, а обратный идёт справа налево и использует их.

Почему нужен кеш: чтобы посчитать dW = Xᵀ · dZ, слою нужен его собственный вход X. Он был известен на прямом проходе, а к моменту обратного давно «уехал» — если его не сохранить, вычислить градиент нечем. То же с активациями: производная ReLU зависит от того, каким был вход. Отсюда правило: каждый слой на прямом проходе сохраняет ровно то, что понадобится ему на обратном, и не больше.

Прямой:   X → [Dense] → Z → [ReLU] → A → [Dense] → логиты → потери
Обратный: dX ← [Dense] ← dZ ← [ReLU] ← dA ← [Dense] ← (p - y)

Градиент, приходящий в слой справа, называется «градиентом по выходу»; слой обязан вернуть «градиент по входу», который для соседа слева станет градиентом по его выходу. Так эстафета доходит до самого начала сети.

Расширяем интерфейс слоя

// Layer — слой сети: умеет прямой и обратный проход,
// а также отдавать свои параметры и градиенты оптимизатору.
type Layer interface {
    // Forward прогоняет батч и запоминает всё нужное для Backward.
    Forward(x *matrix.Matrix) *matrix.Matrix

    // Backward принимает градиент по выходу и возвращает градиент по входу.
    Backward(gradOut *matrix.Matrix) *matrix.Matrix

    // Params возвращает параметры и параллельный им срез градиентов.
    // Слои без параметров (активации) возвращают nil, nil.
    Params() (params, grads []*matrix.Matrix)
}

Метод Params сознательно возвращает два параллельных среза, а не структуру: оптимизатору из главы 8 нужно ровно это — пары «параметр, его градиент», — и любая другая форма превратилась бы в лишний слой преобразований в горячем коде.

Dense: обратный проход

type Dense struct {
    W, B   *matrix.Matrix // параметры
    dW, dB *matrix.Matrix // градиенты, заполняются в Backward

    lastInput *matrix.Matrix // кеш прямого прохода
}

func (d *Dense) Forward(x *matrix.Matrix) *matrix.Matrix {
    d.lastInput = x
    return matrix.AddRowVector(matrix.Dot(x, d.W), d.B)
}

// Backward: gradOut — dL/dZ размера (batch × out).
func (d *Dense) Backward(gradOut *matrix.Matrix) *matrix.Matrix {
    if d.lastInput == nil {
        panic("Dense.Backward вызван раньше Forward")
    }
    // Градиент по весам: вклад каждого входа в каждую ошибку.
    d.dW = matrix.Dot(d.lastInput.T(), gradOut) // (in × out)
    // Градиент по смещениям: сумма по примерам батча.
    d.dB = matrix.SumRows(gradOut) // (1 × out)
    // Градиент, уходящий влево.
    return matrix.Dot(gradOut, d.W.T()) // (batch × in)
}

func (d *Dense) Params() ([]*matrix.Matrix, []*matrix.Matrix) {
    return []*matrix.Matrix{d.W, d.B}, []*matrix.Matrix{d.dW, d.dB}
}

Три строки — весь обратный проход полносвязного слоя. Проверка lastInput == nil выглядит избыточной, но однажды спасёт: перепутанный порядок вызовов в цикле обучения иначе даст загадочную панику по индексу глубоко внутри Dot.

Активация: обратный проход

// Activation — слой без параметров: применяет функцию поэлементно.
type Activation struct {
    fn    func(float64) float64
    prime func(float64) float64 // производная
    input *matrix.Matrix        // кеш входа: производная считается от него
}

func (a *Activation) Forward(x *matrix.Matrix) *matrix.Matrix {
    a.input = x
    return x.Apply(a.fn)
}

// Backward: поэлементно умножаем пришедший градиент
// на производную активации в точке входа — это цепное правило.
func (a *Activation) Backward(gradOut *matrix.Matrix) *matrix.Matrix {
    return matrix.Hadamard(gradOut, a.input.Apply(a.prime))
}

func (a *Activation) Params() ([]*matrix.Matrix, []*matrix.Matrix) {
    return nil, nil
}

// ReLUPrime — производная ReLU: 1 для положительных, 0 для остальных.
func ReLUPrime(z float64) float64 {
    if z > 0 {
        return 1
    }
    return 0
}

// SigmoidPrime выражается через саму сигмоиду: s*(1-s).
func SigmoidPrime(z float64) float64 {
    s := Sigmoid(z)
    return s * (1 - s)
}

// TanhPrime: 1 - tanh²(z).
func TanhPrime(z float64) float64 {
    t := math.Tanh(z)
    return 1 - t*t
}

Тонкость, на которой спотыкаются: производную считаем от входа активации, а не от её выхода. Для ReLU оба варианта случайно совпадают по знаку, а для сигмоиды — нет, и ошибка проявится как «сеть учится, но плохо». Кешируй вход.

Цикл обучения

Соберём всё вместе. Сеть получает методы прямого и обратного прохода, а обучение — цикл по эпохам и батчам.

// Network — последовательность слоёв.
type Network struct {
    Layers []Layer
}

func (n *Network) Forward(x *matrix.Matrix) *matrix.Matrix {
    out := x
    for _, l := range n.Layers {
        out = l.Forward(out)
    }
    return out
}

// Backward пропускает градиент в обратном порядке слоёв.
func (n *Network) Backward(grad *matrix.Matrix) {
    for i := len(n.Layers) - 1; i >= 0; i-- {
        grad = n.Layers[i].Backward(grad)
    }
}

// SGDStep обновляет все параметры сети: w = w - lr * dw.
func (n *Network) SGDStep(lr float64) {
    for _, l := range n.Layers {
        params, grads := l.Params()
        for i, p := range params {
            g := grads[i]
            for j := range p.Data {
                p.Data[j] -= lr * g.Data[j]
            }
        }
    }
}

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

Батчи, эпохи и перемешивание

// TrainConfig — гиперпараметры обучения.
type TrainConfig struct {
    Epochs    int
    BatchSize int
    LR        float64
    Seed      uint64
}

// Train обучает сеть на выборке (x, y) и возвращает историю потерь по эпохам.
func Train(net *Network, loss Loss, x, y *matrix.Matrix, cfg TrainConfig) []float64 {
    rng := rand.New(rand.NewPCG(cfg.Seed, cfg.Seed+1))
    n := x.Rows
    idx := make([]int, n)
    for i := range idx {
        idx[i] = i
    }

    history := make([]float64, 0, cfg.Epochs)

    for epoch := 0; epoch < cfg.Epochs; epoch++ {
        // Перемешиваем ИНДЕКСЫ — так признаки и метки гарантированно
        // остаются в паре. Перемешивать x и y по отдельности нельзя.
        rng.Shuffle(n, func(i, j int) { idx[i], idx[j] = idx[j], idx[i] })

        var epochLoss float64
        batches := 0

        for start := 0; start < n; start += cfg.BatchSize {
            end := min(start+cfg.BatchSize, n)
            bx := takeRows(x, idx[start:end])
            by := takeRows(y, idx[start:end])

            pred := net.Forward(bx)
            epochLoss += loss.Value(pred, by)
            net.Backward(loss.Grad(pred, by))
            net.SGDStep(cfg.LR)
            batches++
        }

        history = append(history, epochLoss/float64(batches))
    }
    return history
}

// takeRows собирает подматрицу из строк с заданными индексами.
func takeRows(m *matrix.Matrix, rows []int) *matrix.Matrix {
    out := matrix.New(len(rows), m.Cols)
    for i, r := range rows {
        copy(out.Data[i*m.Cols:(i+1)*m.Cols], m.Data[r*m.Cols:(r+1)*m.Cols])
    }
    return out
}

Три понятия, которые важно не путать:

  • Батч — порция примеров, обрабатываемая за один шаг обновления весов. Больше батч — устойчивее градиент, но реже обновления.
  • Шаг (итерация) — одно обновление весов, то есть один батч.
  • Эпоха — полный проход по всей обучающей выборке.

Перемешивание нужно, чтобы сеть не запоминала порядок примеров. Если данные отсортированы по классам (сначала все нули, потом все единицы), то без перемешивания каждый батч содержит один класс, и сеть на каждом шаге «забывает» предыдущий, вместо того чтобы учиться различать.

Кейс: две спирали

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

// makeSpirals генерирует n точек на класс: две спирали, закрученные навстречу.
func makeSpirals(nPerClass int, rng *rand.Rand) (*matrix.Matrix, *matrix.Matrix) {
    total := nPerClass * 2
    x := matrix.New(total, 2)
    y := matrix.New(total, 2) // one-hot на 2 класса

    row := 0
    for class := 0; class < 2; class++ {
        for i := 0; i < nPerClass; i++ {
            r := float64(i) / float64(nPerClass) * 5
            t := float64(class)*math.Pi + float64(i)/float64(nPerClass)*4 +
                rng.NormFloat64()*0.1 // шум, чтобы задача не была тривиальной

            x.Set(row, 0, r*math.Sin(t))
            x.Set(row, 1, r*math.Cos(t))
            y.Set(row, class, 1)
            row++
        }
    }
    return x, y
}

func main() {
    rng := rand.New(rand.NewPCG(42, 43))
    x, y := makeSpirals(300, rng)

    net := &nn.Network{Layers: []nn.Layer{
        nn.NewDense(2, 64, nn.HeInit(rng)),
        nn.NewReLU(),
        nn.NewDense(64, 64, nn.HeInit(rng)),
        nn.NewReLU(),
        nn.NewDense(64, 2, nn.XavierInit(rng)),
    }}

    history := nn.Train(net, nn.NewSoftmaxCrossEntropy(), x, y, nn.TrainConfig{
        Epochs:    300,
        BatchSize: 32,
        LR:        0.5,
        Seed:      7,
    })

    for i, l := range history {
        if i%50 == 0 {
            fmt.Printf("эпоха %3d  loss = %.4f\n", i, l)
        }
    }
    fmt.Printf("точность: %.2f%%\n", accuracy(net, x, y)*100)
}

// accuracy считает долю примеров, где argmax предсказания совпал с меткой.
func accuracy(net *nn.Network, x, y *matrix.Matrix) float64 {
    pred := nn.Softmax(net.Forward(x))
    correct := 0
    for i := 0; i < pred.Rows; i++ {
        if argmaxRow(pred, i) == argmaxRow(y, i) {
            correct++
        }
    }
    return float64(correct) / float64(pred.Rows)
}

Ожидаемое поведение: loss стартует около 0,69 (это -log(0.5) — сеть угадывает наугад из двух классов) и за пару сотен эпох падает ниже 0,1, а точность выходит на 97–99%. Если loss завис ровно на 0,69 и не двигается — сеть не учится вовсе, и первое, что нужно проверить, это инициализацию и знак в шаге обновления весов.

Обязательно посмотри на кривую потерь глазами. Плавное падение с выполаживанием — норма. Пила с большим размахом — слишком большой learning rate. Прямая горизонтальная линия — градиент не доходит: ошибка в обратном проходе.

Типичные ошибки

1. Забыть, что градиенты копятся между шагами

В нашей реализации Backward каждый раз перезаписывает dW целиком, поэтому проблемы нет. Но стоит переписать код на аккумулирование (d.dW = Add(d.dW, ...)) ради экономии аллокаций — и обнуление между шагами станет обязательным, иначе градиент растёт от шага к шагу и обучение взрывается. Если решишь оптимизировать, добавь метод ZeroGrad и вызывай его в начале каждого шага.

2. Перемешать признаки отдельно от меток

rng.Shuffle(n, func(i, j int) { swapRows(x, i, j) })
rng.Shuffle(n, func(i, j int) { swapRows(y, i, j) }) // катастрофа

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

3. Обратный проход в прямом порядке

Цикл for i := 0; i < len(layers); i++ вместо обратного даёт работающий, но бессмысленный код: градиент выходного слоя применяется к входному. Никаких сообщений об ошибке — только сеть, которая не учится.

4. Оценивать качество на обучающей выборке

Точность 100% на данных, которые сеть видела, ничего не говорит о её полезности: она могла их просто запомнить. Нужна отложенная выборка — этим мы займёмся в главах 6 и 7. Уже сейчас привыкай считать точность отдельно на обучении и на отложенных данных.

5. Менять сразу несколько гиперпараметров

Сеть не учится, ты одновременно увеличил слой, поменял активацию и уменьшил lr — стало лучше. Что именно помогло? Неизвестно. Меняй по одному, фиксируй сид, записывай результаты. Это скучно ровно до первого раза, когда экономит день.

6. Сохранять кеш прямого прохода в общем для горутин месте

Поле lastInput делает слой непригодным для параллельного использования: два прохода одновременно затрут кеш друг друга. Пока мы однопоточны, это нормально; в главе 9 мы явно разберём, как распараллеливать, ничего не ломая.

Практика

Задание 1. Backward во всех слоях

Реализуй Backward и Params в Dense и Activation, конструкторы NewReLU, NewSigmoid, NewTanh (активация вместе со своей производной). Добавь панику с внятным текстом, если Backward вызван до Forward.

Задание 2. Проверка градиента всей сети

Возьми GradCheck из главы 4 и проверь им сеть целиком: Dense(3,5) → Tanh → Dense(5,2) с MSE, вход 4×3. Проверяй веса обоих слоёв. Пока относительная погрешность не станет меньше 1e-7, к обучению не переходи — иначе будешь отлаживать не тот слой.

Задание 3. Обучаем XOR

Обучи сеть Dense(2,4) → ReLU → Dense(4,1) → Sigmoid на четырёх точках XOR с MSE. Сравни найденные веса с теми, что мы подставляли руками в главе 3: решение будет другим, но таблица истинности воспроизведётся. Полезное наблюдение: с некоторыми сидами сеть застревает — попробуй пять разных и посмотри, как часто это происходит.

Задание 4. Спирали

Реализуй генератор спиралей и обучи на нём сеть из примера. Добейся точности выше 97%. Сохраняй кривую потерь в CSV и смотри на неё после каждого эксперимента.

Задание 5. Влияние размера батча

Обучи одну и ту же сеть с батчами 1, 8, 32, 128 при одинаковом числе эпох и сиде. Сравни кривые потерь и время работы (time.Since). Объясни себе, почему при батче 1 кривая самая шумная, а при 128 — самая гладкая, но по эпохам сходится медленнее.

Задание 6. Визуализация границы решения

Пройди сеткой по области значений признаков с шагом 0,1, для каждой точки получи предсказание и нарисуй результат ASCII-символами (. и #) прямо в терминале. Ты увидишь, как сеть «вырезала» две спиральные области — это самый наглядный момент курса.

Чек-лист самопроверки

  • GradCheck для всей сети даёт погрешность меньше 1e-7.
  • XOR обучается: после обучения предсказания близки к 0, 1, 1, 0.
  • На спиралях точность выше 97%, а loss упал с ~0,69 ниже 0,1.
  • Кривая потерь падает плавно, без пилы; при увеличении lr в десять раз ты видел пилу и понимаешь причину.
  • Перемешивание не рвёт пары «признаки — метка»: тест на маленькой выборке это подтверждает.
  • Два запуска с одним сидом дают идентичную историю потерь.
  • Граница решения на картинке в терминале выглядит осмысленно.

Итог

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

У нас есть сеть, которая учится на синтетических данных. Пора перейти к настоящим: в следующей главе мы напишем загрузчик MNIST — разберём двоичный формат IDX, распакуем gzip и подготовим данные так, чтобы их можно было подать в сеть.

Содержание серии (10)