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

Глава 3. Прямой проход: слои, активации и инициализация весов

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

Введение

У нас есть матрицы. Пора превратить их в сеть. В этой главе мы напишем полносвязный слой, наберём библиотеку функций активации, разберёмся с численной устойчивостью softmax и — самое недооценённое — научимся правильно инициализировать веса. Инициализация выглядит технической мелочью ровно до первого раза, когда сеть отказывается обучаться из-за неудачных стартовых чисел, а loss стоит колом на одном значении.

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

Полносвязный слой

Полносвязный (dense, fully connected) слой — это матрица весов плюс вектор смещений. Каждый вход соединён с каждым выходом; отсюда и название. Вся математика слоя умещается в одну формулу:

Y = X·W + b

где X — батч размера (batch × in), W — веса (in × out), b — смещения (1 × out), а Y — выход (batch × out). Батч идёт по строкам: одна строка — один пример. Это соглашение стоит запомнить и не нарушать, иначе транспонирования в обратном проходе превратятся в кашу.

package nn

import (
    "example.com/gonn/internal/matrix"
)

// Dense — полносвязный слой: Y = X·W + b.
type Dense struct {
    W *matrix.Matrix // (in × out)
    B *matrix.Matrix // (1 × out)

    // Сохранённый вход последнего прямого прохода.
    // Понадобится в обратном проходе (глава 5).
    lastInput *matrix.Matrix
}

// NewDense создаёт слой с in входами и out выходами.
func NewDense(in, out int, init Initializer) *Dense {
    w := matrix.New(in, out)
    for i := range w.Data {
        w.Data[i] = init(in, out)
    }
    return &Dense{W: w, B: matrix.New(1, out)}
}

// Forward прогоняет батч через слой.
func (d *Dense) Forward(x *matrix.Matrix) *matrix.Matrix {
    if x.Cols != d.W.Rows {
        panic("Dense.Forward: число признаков не совпадает с числом входов слоя")
    }
    d.lastInput = x
    return matrix.AddRowVector(matrix.Dot(x, d.W), d.B)
}

Смещения инициализируются нулями — и это правильно: ассиметрию в сеть вносят веса, а нулевой bias ничему не мешает и стартует ровно посередине. Веса нулями инициализировать нельзя, и почему — ниже.

Функции активации

Активация — поэлементная нелинейная функция, применяемая к выходу слоя. Их много, и выбор не вкусовой: у каждой своя область применимости и свои болезни.

Sigmoid

// Sigmoid сжимает вход в интервал (0, 1).
func Sigmoid(z float64) float64 {
    return 1 / (1 + math.Exp(-z))
}

Историческая активация и до сих пор естественный выбор для выхода бинарного классификатора: результат читается как вероятность. Но внутри глубокой сети сигмоида плоха. При |z| > 5 её производная почти ноль — говорят, нейрон «насыщается». Градиент, проходя сквозь несколько таких слоёв, умножается на околонулевые числа и исчезает: нижние слои перестают обучаться. Это классическая проблема затухающего градиента.

Tanh

// Tanh — гиперболический тангенс, вход сжимается в (-1, 1).
func Tanh(z float64) float64 {
    return math.Tanh(z)
}

Похож на сигмоиду, но симметричен относительно нуля, поэтому выходы слоя центрированы и обучение идёт стабильнее. Насыщение никуда не делось, но tanh почти всегда лучше сигмоиды в скрытых слоях, если по каким-то причинам не подходит ReLU.

ReLU и её родня

// ReLU обнуляет отрицательные значения — самая ходовая активация.
func ReLU(z float64) float64 {
    if z < 0 {
        return 0
    }
    return z
}

// LeakyReLU оставляет отрицательным значениям маленький наклон,
// чтобы нейрон не «умирал» насовсем.
func LeakyReLU(alpha float64) func(float64) float64 {
    return func(z float64) float64 {
        if z < 0 {
            return alpha * z
        }
        return z
    }
}

ReLU выглядит до неприличия просто, но именно она сделала возможным обучение глубоких сетей. Её производная равна 1 для положительных значений — градиент проходит насквозь без затухания, — и вычисляется одним сравнением. Плата за это — «умирающие нейроны»: если веса сдвинулись так, что на всех примерах вход отрицателен, производная всегда 0 и нейрон больше никогда не обновится. LeakyReLU с наклоном 0,01 страхует от этого.

Softmax: превращаем оценки в распределение

Для классификации на 10 классов выходной слой даёт 10 чисел — «оценок» (логитов). Softmax превращает их в вероятности: все положительные, в сумме единица. И тут появляется главная численная ловушка курса.

// НАИВНАЯ реализация — переполняется. Так делать нельзя.
func softmaxNaive(row []float64) []float64 {
    var sum float64
    out := make([]float64, len(row))
    for i, v := range row {
        out[i] = math.Exp(v) // при v = 800 это +Inf
        sum += out[i]
    }
    for i := range out {
        out[i] /= sum // +Inf / +Inf = NaN
    }
    return out
}

math.Exp(800) — это бесконечность в float64, а деление бесконечности на бесконечность даёт NaN, который потом расползается по всей сети. Логиты растут в ходе обучения естественным образом, так что это не экзотика, а вопрос времени.

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

// Softmax применяет softmax к каждой строке матрицы (строка = один пример).
// Вычитание максимума — обязательный приём против переполнения exp.
func Softmax(m *matrix.Matrix) *matrix.Matrix {
    out := matrix.New(m.Rows, m.Cols)
    for i := 0; i < m.Rows; i++ {
        row := m.Data[i*m.Cols : (i+1)*m.Cols]
        dst := out.Data[i*m.Cols : (i+1)*m.Cols]

        maxV := row[0]
        for _, v := range row[1:] {
            if v > maxV {
                maxV = v
            }
        }

        var sum float64
        for j, v := range row {
            e := math.Exp(v - maxV) // максимум показателя ровно 0
            dst[j] = e
            sum += e
        }
        for j := range dst {
            dst[j] /= sum
        }
    }
    return out
}

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

Инициализация весов

Теперь о том, что кажется мелочью и не является ей.

Почему нельзя нулями

Если все веса слоя равны нулю (или вообще одинаковы), все нейроны слоя считают одно и то же число, получают одинаковый градиент и обновляются одинаково. Они останутся идентичными навсегда: слой из 128 нейронов будет вести себя как один. Это называется проблемой симметрии, и ломается она только случайной инициализацией. В главе 1 нули сработали лишь потому, что нейрон был один и ломать было нечего.

Почему нельзя «просто случайными»

Возьмём числа из равномерного распределения на [-1, 1]. На слое с 784 входами каждый выход — сумма 784 слагаемых, то есть по порядку величины десятки. Такой вход мгновенно насыщает сигмоиду и tanh, а с ReLU даёт огромные активации, которые на следующем слое становятся ещё больше — до переполнения. Слишком маленькие веса дают обратную беду: сигнал затухает от слоя к слою и до выхода доходит шум.

Правильная идея: подобрать разброс так, чтобы дисперсия сигнала сохранялась при проходе через слой. Отсюда два стандартных рецепта.

// Initializer возвращает одно стартовое значение веса
// для слоя с заданным числом входов и выходов.
type Initializer func(in, out int) float64

// XavierInit — для sigmoid и tanh: дисперсия 2/(in+out).
func XavierInit(rng *rand.Rand) Initializer {
    return func(in, out int) float64 {
        limit := math.Sqrt(6.0 / float64(in+out))
        return (rng.Float64()*2 - 1) * limit
    }
}

// HeInit — для ReLU: та же идея, но с поправкой на то,
// что ReLU обнуляет половину значений, отсюда множитель 2.
func HeInit(rng *rand.Rand) Initializer {
    return func(in, out int) float64 {
        std := math.Sqrt(2.0 / float64(in))
        return rng.NormFloat64() * std
    }
}

Практическое правило простое: ReLU — He, sigmoid и tanh — Xavier. Разница в множителе выглядит несущественной, но на сети из четырёх-пяти слоёв неправильный выбор виден невооружённым глазом: активации либо гаснут к последнему слою, либо взрываются.

Воспроизводимость: свой генератор, а не глобальный

Начиная с Go 1.22 пакет math/rand/v2 — стандартный выбор. Ключевое требование к обучению: возможность повторить эксперимент. Если два прогона отличаются и сидом, и гиперпараметрами, сравнивать их бессмысленно.

import "math/rand/v2"

// NewRNG создаёт генератор с явным сидом — обучение воспроизводимо.
func NewRNG(seed uint64) *rand.Rand {
    return rand.New(rand.NewPCG(seed, seed+1))
}

Глобальные функции rand.Float64() без явного источника использовать не стоит: их состояние общее на всю программу, и стоит добавить горутину, которая тоже дёргает генератор, — воспроизводимость исчезнет. Свой *rand.Rand на модель, сид — в лог и в имя файла с весами.

Сеть как последовательность слоёв

Слой и активация — два кирпича, из которых собирается сеть. Чтобы прямой проход не превращался в лапшу вызовов, введём общий интерфейс:

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

// Activation — слой без параметров, применяющий функцию поэлементно.
type Activation struct {
    fn   func(float64) float64
    name string
}

func NewActivation(name string, fn func(float64) float64) *Activation {
    return &Activation{fn: fn, name: name}
}

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

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

// Forward прогоняет батч через все слои по порядку.
func (n *Network) Forward(x *matrix.Matrix) *matrix.Matrix {
    out := x
    for _, l := range n.Layers {
        out = l.Forward(out)
    }
    return out
}

Интерфейс Layer — тот самый случай, когда абстракция оправдана: у слоёв разная внутренняя природа (одни с параметрами, другие без), но одинаковая роль в конвейере. Обрати внимание, что активация здесь — полноценный слой, а не поле Dense. Так гибче: между линейным преобразованием и нелинейностью в главе 8 встанет dropout, и переделывать ничего не придётся.

Кейс: XOR вручную

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

func main() {
    // Скрытый слой из двух нейронов:
    // h1 срабатывает, если хотя бы один вход равен 1 (OR),
    // h2 срабатывает, только если оба (AND).
    hidden := nn.NewDense(2, 2, nn.ZeroInit)
    hidden.W = matrix.NewFrom(2, 2, []float64{
        1, 1, // веса первого входа к h1 и h2
        1, 1, // веса второго входа к h1 и h2
    })
    hidden.B = matrix.NewFrom(1, 2, []float64{-0.5, -1.5})

    // Выходной слой: XOR = OR и НЕ AND.
    out := nn.NewDense(2, 1, nn.ZeroInit)
    out.W = matrix.NewFrom(2, 1, []float64{1, -2})
    out.B = matrix.NewFrom(1, 1, []float64{0})

    net := &nn.Network{Layers: []nn.Layer{
        hidden,
        nn.NewActivation("relu", nn.ReLU),
        out,
    }}

    x := matrix.NewFrom(4, 2, []float64{
        0, 0,
        0, 1,
        1, 0,
        1, 1,
    })

    y := net.Forward(x)
    for i := 0; i < y.Rows; i++ {
        fmt.Printf("%v XOR %v = %.1f\n", x.At(i, 0), x.At(i, 1), y.At(i, 0))
    }
}

Выход: 0, 1, 1, 0 — ровно таблица истинности XOR. Разберись, как это работает: первый скрытый нейрон со смещением -0,5 включается при любом единичном входе, второй со смещением -1,5 — только когда оба входа единичные; выходной нейрон складывает первый и вычитает второй с удвоенным весом. Мы фактически руками нашли то решение, которое в главе 5 сеть найдёт сама градиентным спуском.

Убери из сети активацию — оставь два Dense подряд — и запусти снова. Получишь 0, 1, 1, 2: без нелинейности сеть схлопнулась в линейную функцию и XOR ей недоступен. Это тот самый факт из первой главы, увиденный своими глазами.

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

1. Softmax без вычитания максимума

Работает на игрушечных данных и разваливается в NaN на реальном обучении через несколько эпох, когда логиты подрастут. Вычитание максимума стоит одного прохода по строке — вставляй его сразу.

2. Softmax или sigmoid в скрытых слоях

Softmax — это функция выходного слоя классификатора, а не активация общего назначения: она смешивает нейроны между собой и в середине сети просто мешает. Sigmoid в глубине сети душит градиент. Правило по умолчанию: скрытые слои — ReLU, выход — softmax (много классов) или sigmoid (бинарная классификация), либо ничего (регрессия).

3. Нулевая или одинаковая инициализация весов

Внешне ничего не ломается: программа считает, loss печатается. Но он застревает на одном значении, потому что все нейроны слоя — клоны. Если сеть не учится вовсе, инициализацию проверяют первой.

4. Инициализация без учёта числа входов

w.Data[i] = rng.Float64()*2 - 1 // одинаково для слоя на 2 и на 784 входа

На маленьком слое сработает, на большом — взорвётся. Множитель обязан зависеть от in: это и есть суть He и Xavier.

5. ReLU на выходе регрессии, где ответ бывает отрицательным

Сеть физически не сможет предсказать отрицательное число и упрётся в ноль. Для регрессии выходной слой обычно оставляют вовсе без активации.

6. Глобальный генератор случайных чисел

Без явного сида эксперимент невоспроизводим, и сравнивать два прогона нельзя: разница может быть эффектом другой инициализации, а не твоей правки.

Практика

Задание 1. Пакет активаций

Создай internal/nn/activation.go с Sigmoid, Tanh, ReLU, LeakyReLU и матричным Softmax. Для каждой сразу напиши и производную (SigmoidPrime, ReLUPrime и так далее) — они понадобятся в главе 5, а выводить их удобнее рядом с самой функцией.

Задание 2. Тест на численную устойчивость

Напиши тест, подающий в Softmax строку с логитами [1000, 1001, 1002]. Требования: сумма результата равна единице с точностью 1e-9, ни одного NaN, наибольшая вероятность у последнего элемента. Затем проверь свойство сдвига: Softmax([1,2,3]) и Softmax([101,102,103]) должны давать одинаковые вероятности.

Задание 3. Инициализаторы

Реализуй XavierInit, HeInit и ZeroInit (последний — только для тестов и ручной подстановки весов). Напиши статистический тест: создай слой 512×512 с HeInit, посчитай выборочное стандартное отклонение весов и убедись, что оно близко к sqrt(2/512) с разумной погрешностью.

Задание 4. Наблюдение за дисперсией активаций

Собери сеть из пяти слоёв по 128 нейронов с ReLU, подай на вход случайную матрицу 64×128 и напечатай стандартное отклонение активаций после каждого слоя. Проделай это трижды: с HeInit, с XavierInit и с инициализацией rng.Float64()*2-1. Сравни: у He отклонение держится примерно на одном уровне, у последней — растёт от слоя к слою на порядки. Это самый убедительный аргумент в пользу правильной инициализации.

Задание 5. XOR и линейный коллапс

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

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

  • Softmax на логитах порядка 1000 возвращает корректные вероятности без NaN и Inf.
  • Softmax инвариантен к сдвигу всех логитов на константу.
  • Сумма вероятностей в каждой строке равна 1 с точностью 1e-9.
  • Стандартное отклонение весов He-инициализации совпадает с теоретическим.
  • Дисперсия активаций по слоям стабильна при He и растёт при наивной инициализации.
  • Сеть с ручными весами воспроизводит таблицу XOR, а без активации — нет.
  • Два запуска с одним сидом дают побитово одинаковые веса.

Итог

Полносвязный слой — это Y = X·W + b, где батч идёт по строкам; активация — отдельный слой в конвейере, а не поле внутри Dense. Скрытые слои по умолчанию берут ReLU, выход классификатора — softmax, обязательно с вычитанием максимума, иначе переполнение экспоненты рано или поздно превратит обучение в NaN. Веса инициализируются случайно и с оглядкой на число входов: He для ReLU, Xavier для sigmoid и tanh; нули ломают симметрию нейронов, а произвольный разброс — дисперсию сигнала.

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

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