Введение
У нас есть матрицы. Пора превратить их в сеть. В этой главе мы напишем полносвязный слой, наберём библиотеку функций активации, разберёмся с численной устойчивостью 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; нули ломают симметрию нейронов, а произвольный разброс — дисперсию сигнала.
Сеть уже умеет считать предсказания, но пока они бессмысленны: веса случайны. Чтобы их исправлять, нужно измерить ошибку и понять, в какую сторону двигать каждый параметр. Этим займётся следующая глава — о функциях потерь и градиентах.
Комментарии 0
Пока нет комментариев. Станьте первым!