Введение
Сеть из предыдущей главы умеет считать предсказания, но веса в ней случайны, значит и предсказания случайны. Чтобы их улучшать, нужны две вещи: способ измерить, насколько мы ошиблись (функция потерь), и способ понять, в какую сторону подкрутить каждый вес (градиент).
Это самая математическая глава курса, и обойти её нельзя: без автоматического дифференцирования каждую производную мы выводим сами. Хорошая новость — вся необходимая математика сводится к одному правилу школьного уровня (производная сложной функции) и одному приёму отладки (численная проверка градиента), который позволяет никогда не сомневаться в правильности вывода.
Функция потерь: одно число вместо предсказаний
Функция потерь сводит все предсказания на батче к одному числу: чем оно меньше, тем лучше модель. Обучение — это минимизация этого числа по весам.
MSE — для регрессии
Среднеквадратичная ошибка: берём разницу предсказания и правильного ответа, возводим в квадрат, усредняем.
// MSE возвращает среднеквадратичную ошибку по всему батчу.
func MSE(pred, target *matrix.Matrix) float64 {
mustSameShape(pred, target)
var sum float64
for i, p := range pred.Data {
d := p - target.Data[i]
sum += d * d
}
return sum / float64(len(pred.Data))
}
// MSEGrad возвращает dL/dPred — градиент потерь по предсказаниям.
func MSEGrad(pred, target *matrix.Matrix) *matrix.Matrix {
out := matrix.New(pred.Rows, pred.Cols)
n := float64(len(pred.Data))
for i, p := range pred.Data {
out.Data[i] = 2 * (p - target.Data[i]) / n
}
return out
}
Квадрат нужен по двум причинам: он убирает знак (ошибка -5 не должна компенсировать ошибку +5) и наказывает крупные промахи сильнее мелких. Обрати внимание на деление на n в градиенте: если потери усреднены, то и градиент должен быть усреднён, иначе размер шага начнёт зависеть от размера батча.
Кросс-энтропия — для классификации
Для классификации MSE плоха: она слабо наказывает уверенно неверные ответы. Кросс-энтропия смотрит только на вероятность, которую модель присвоила правильному классу, и берёт минус логарифм этой вероятности.
L = -log(p_правильного_класса)
Модель дала правильному классу 0,99 — потери 0,01, почти ничего. Дала 0,5 — потери 0,69. Дала 0,01 — потери 4,6. А если 0,000001, то штраф вырастет до 13,8. Логарифм устроен так, что уверенная ошибка стоит очень дорого, и это именно то поведение, которое нужно при обучении классификатора.
// CrossEntropy: pred — вероятности после softmax, target — one-hot.
func CrossEntropy(pred, target *matrix.Matrix) float64 {
const eps = 1e-12 // защита от log(0)
var sum float64
for i, t := range target.Data {
if t != 0 {
sum -= t * math.Log(pred.Data[i]+eps)
}
}
return sum / float64(pred.Rows) // среднее по примерам батча
}
Константа eps — не косметика. Softmax теоретически не выдаёт ровно ноль, но при достаточно больших логитах math.Exp округляется до нуля, а math.Log(0) — это -Inf, который отравит всё обучение.
Градиент и градиентный спуск
Производная функции по переменной отвечает на вопрос: «если увеличить переменную на крошечную величину, насколько изменится результат?» Положительная производная — результат растёт, отрицательная — падает.
Нас интересует производная потерь по каждому весу: dL/dw. Если она положительна, увеличение веса увеличит ошибку — значит, вес надо уменьшить. Отсюда правило обновления, ядро всего обучения:
w = w - lr * dL/dw
Минус — потому что мы идём против градиента, в сторону уменьшения ошибки. lr (learning rate) — длина шага. Градиент — это вектор всех частных производных: он указывает направление самого быстрого роста ошибки в пространстве весов, а мы шагаем ровно в противоположную сторону.
Полезная аналогия: ты стоишь на склоне в тумане и хочешь спуститься. Видимости нет, но ты чувствуешь наклон под ногами — это градиент. Делаешь шаг вниз по склону, снова ощупываешь наклон, снова шаг. Слишком мелкие шаги — спуск займёт вечность; слишком крупные — перепрыгнешь долину и окажешься на другом склоне выше, чем был.
Цепное правило
Сеть — это композиция функций: вход проходит через слой, активацию, снова слой, потом функцию потерь. Производную композиции даёт цепное правило: производные перемножаются по цепочке.
Разберём на конкретном примере. Пусть слой считает z = x*w + b, активация a = ReLU(z), потери L = (a - y)². Нужна dL/dw. Идём справа налево:
dL/da = 2(a - y)— производная квадрата.da/dz = 1, еслиz > 0, иначе0— производная ReLU.dz/dw = x— производная взвешенной суммы по весу.
Перемножаем: dL/dw = 2(a - y) · ReLU'(z) · x. Всё. Никакой другой математики в обратном распространении нет — просто эта цепочка, аккуратно проведённая через все слои и записанная в матричном виде.
Матричная форма для Dense-слоя
Пусть на слой пришёл градиент по его выходу — обозначим его dZ (размер batch × out). Тогда:
dW = Xᵀ · dZ (in × out) — градиент по весам
dB = сумма строк dZ (1 × out) — градиент по смещениям
dX = dZ · Wᵀ (batch × in) — градиент, уходящий в предыдущий слой
Проверять эти формулы удобно по размерностям: dW обязан совпадать по форме с W, иначе обновить веса будет нечем. Это дешёвая, но исключительно надёжная самопроверка — если формы сходятся, формула почти наверняка верна.
Почему dB — сумма, а не среднее? Потому что смещение участвует в каждом примере батча одинаково, и вклад ошибок складывается. Усреднение по батчу уже сделано внутри функции потерь (мы поделили на n в MSEGrad), так что делить ещё раз нельзя — это молча уменьшит эффективный learning rate во столько раз, каков размер батча.
Softmax + кросс-энтропия: красивое сокращение
Производная softmax сама по себе неприятна: выход каждого нейрона зависит от всех остальных, и получается целая матрица частных производных (якобиан). Производная кросс-энтропии по вероятностям тоже не подарок — там деление на p.
Но если считать их не по отдельности, а вместе — как производную потерь по логитам (входу softmax), — почти всё сокращается, и остаётся поразительно простая формула:
dL/dz = (p - y) / batch_size
где p — вероятности после softmax, y — one-hot метки. Та же формула, что мы использовали в первой главе для логистической регрессии, — и это не совпадение: сигмоида с бинарной кросс-энтропией является частным случаем той же пары.
// SoftmaxCrossEntropyGrad возвращает градиент потерь по ЛОГИТАМ
// (не по вероятностям): (p - y) / batch.
// Именно поэтому softmax и кросс-энтропия объединены в один слой:
// раздельно считать якобиан softmax дорого и незачем.
func SoftmaxCrossEntropyGrad(probs, target *matrix.Matrix) *matrix.Matrix {
out := matrix.New(probs.Rows, probs.Cols)
n := float64(probs.Rows)
for i, p := range probs.Data {
out.Data[i] = (p - target.Data[i]) / n
}
return out
}
Практический вывод, который делают все фреймворки: softmax и кросс-энтропию объединяют в один блок. Так и быстрее, и численно устойчивее, и код проще. Мы поступим так же.
Численная проверка градиента
Здесь начинается самое ценное для практики. Вывод градиента руками — источник ошибок: забытый множитель, перепутанный знак, транспонирование не с той стороны. Такая ошибка не вызывает падения — сеть просто учится хуже или не учится вовсе, и найти причину среди сотни строк невозможно... если не знать один приём.
Производную можно оценить численно, по определению: сдвинуть параметр на крошечную величину и посмотреть, как изменились потери. Центральная разность даёт заметно более точную оценку, чем односторонняя:
численный градиент ≈ (L(w + h) - L(w - h)) / (2h)
// numericalGrad оценивает dL/dw для одного параметра численно.
// loss — замыкание, считающее потери при текущих весах.
func numericalGrad(w *matrix.Matrix, idx int, loss func() float64) float64 {
const h = 1e-5
orig := w.Data[idx]
w.Data[idx] = orig + h
lossPlus := loss()
w.Data[idx] = orig - h
lossMinus := loss()
w.Data[idx] = orig // обязательно восстановить!
return (lossPlus - lossMinus) / (2 * h)
}
// GradCheck сравнивает аналитический градиент с численным.
// Возвращает максимальную относительную погрешность по всем параметрам.
func GradCheck(w, analytic *matrix.Matrix, loss func() float64) float64 {
var worst float64
for i := range w.Data {
num := numericalGrad(w, i, loss)
ana := analytic.Data[i]
denom := math.Max(1e-8, math.Abs(num)+math.Abs(ana))
rel := math.Abs(num-ana) / denom
if rel > worst {
worst = rel
}
}
return worst
}
Как читать результат: относительная погрешность меньше 1e-7 — градиент почти наверняка верен; до 1e-4 — подозрительно, но возможно при активациях с изломом; больше 1e-2 — в выводе ошибка, ищи её.
Почему относительная, а не абсолютная погрешность: если градиент по модулю равен 1e-8, разница в 1e-9 огромна относительно него, а если градиент равен 1000 — разница в 0,001 несущественна. Абсолютное сравнение врёт в обе стороны.
Численный градиент чудовищно медленный: два полных прямых прохода на каждый параметр. Для сети с 100 000 весов это 200 000 прогонов. Поэтому он не заменяет обратное распространение, а служит инструментом отладки: проверяют на крошечной сети (3 входа, 4 нейрона, 2 выхода) один раз, убеждаются, что формулы верны, и дальше используют быструю аналитическую версию.
Кейс: как проверка градиента находит опечатку
Типичная ситуация: сеть обучается, но подозрительно медленно и застревает на 60% точности вместо ожидаемых 95%. Падений нет, NaN нет, всё «работает». Запускаем GradCheck на маленькой сети и получаем относительную погрешность 0,97 у весов второго слоя — при норме 1e-7.
Смотрим на код обратного прохода второго слоя и находим:
// Было — тихая ошибка:
dX := matrix.Dot(dZ, d.W) // формы совпали случайно, потому что слой квадратный
// Стало:
dX := matrix.Dot(dZ, d.W.T()) // градиент уходит вниз через транспонированные веса
Слой был 128×128, поэтому проверка размерностей ничего не заметила: обе формы одинаковы. Именно такие ошибки ловятся только численной проверкой. Отсюда практическое правило: проверяй градиенты на слоях с разными входом и выходом (например, 3→5), тогда часть ошибок отсеется ещё на размерностях.
Типичные ошибки
1. log(0) в кросс-энтропии
Получаем -Inf, дальше Inf - Inf = NaN, и все веса становятся NaN за один шаг. Добавляй eps внутрь логарифма, а не к результату.
2. Забыть восстановить параметр в численной проверке
w.Data[idx] = orig + h
lossPlus := loss()
// и пошли дальше, не вернув orig — веса тихо испорчены
Проверка «портит» модель по ходу дела, а результаты становятся бессмысленными. Восстановление обязано быть в той же функции, лучше сразу после второго замера.
3. Смешивать сумму и среднее
Потери усреднены по батчу, а градиент просуммирован (или наоборот) — обучение всё ещё «работает», но эффективный learning rate отличается в batch_size раз. При батче 64 это разница между «учится» и «взрывается». Определись один раз — мы усредняем по батчу — и следуй этому везде.
4. Слишком маленькое h в численной проверке
h = 1e-12 кажется точнее, но разность двух почти равных float64 теряет значащие цифры: результат — шум. Оптимум для float64 — около 1e-5, и это не подгонка, а следствие компромисса между ошибкой аппроксимации и ошибкой округления.
5. Проверять градиент на ReLU в точке излома
В нуле у ReLU производной нет, и численная проверка честно покажет расхождение. Это не баг: используй для проверки tanh или сигмоиду, либо подбирай входы так, чтобы ни одно значение z не оказалось близко к нулю.
6. Односторонняя разность
(L(w+h) - L(w)) / h проще, но её погрешность порядка h, а у центральной — порядка h². На отладке градиентов эта разница означает «нашёл ошибку» против «не понял, ошибка это или погрешность метода».
Практика
Задание 1. Пакет функций потерь
Создай internal/nn/loss.go с интерфейсом Loss (методы Value(pred, target) float64 и Grad(pred, target) *matrix.Matrix) и реализациями MSE и SoftmaxCrossEntropy. У второй Grad должен возвращать градиент по логитам, а не по вероятностям, — и это стоит явно написать в докстринге, потому что перепутать легко.
Задание 2. Тесты на потери
Проверь ключевые свойства: MSE(pred, pred) равна нулю; кросс-энтропия при вероятности правильного класса 1,0 близка к нулю, а при 0,001 — примерно 6,9; функция не возвращает NaN при нулевой вероятности правильного класса.
Задание 3. GradCheck
Реализуй numericalGrad и GradCheck. Проверь их на функции, производная которой известна точно: возьми L(w) = w² и убедись, что численный градиент в точке 3 близок к 6 с погрешностью меньше 1e-6.
Задание 4. Проверка градиента одного слоя
Собери минимальную конструкцию: Dense(3, 5) + Tanh + MSE, вход 2×3 со случайными фиксированными значениями. Посчитай градиент по весам аналитически (формулы из раздела про цепное правило) и сравни с численным. Добейся относительной погрешности меньше 1e-7. Это ключевое задание главы: пока оно не сходится, дальше идти нет смысла.
Задание 5. Внести ошибку намеренно
Убери транспонирование в формуле dW или потеряй деление на размер батча — и посмотри, что покажет GradCheck. Запомни, как выглядит расхождение: в главе 5 это умение сэкономит тебе часы.
Задание 6. Сравнение MSE и кросс-энтропии
Для задачи из трёх классов посчитай обе функции потерь на предсказаниях [0.34, 0.33, 0.33] и [0.01, 0.98, 0.01] при правильном классе 0. Сравни, во сколько раз выросли потери в каждом случае, и объясни себе, почему кросс-энтропия наказывает уверенную ошибку намного жёстче.
Чек-лист самопроверки
GradCheckнаL(w) = w²сходится с погрешностью меньше 1e-6.- Проверка градиента слоя Dense+Tanh+MSE даёт погрешность меньше 1e-7.
- Намеренно внесённая ошибка в формуле градиента детектируется (погрешность больше 1e-2).
- Кросс-энтропия не возвращает
NaNни при какой вероятности, включая ноль. - Формы градиентов совпадают с формами соответствующих параметров:
dWкакW,dBкакB. - Ты можешь на бумаге вывести
dL/dwдля цепочки «слой → tanh → MSE» без подглядывания.
Итог
Функция потерь превращает предсказания в одно число, а градиент говорит, куда двигать каждый вес, чтобы это число уменьшилось. Для регрессии берут MSE, для классификации — кросс-энтропию, потому что она жёстко наказывает уверенные ошибки. Цепное правило раскладывает производную по слоям: градиент идёт справа налево, перемножаясь на локальные производные. Softmax и кросс-энтропия объединяются в один блок, и их совместный градиент по логитам сводится к (p - y) / batch.
Численная проверка градиента — инструмент, который отличает «кажется, работает» от «точно верно». Пользуйся им каждый раз, когда добавляешь новый слой. В следующей главе мы применим все эти формулы: напишем обратный проход в слоях и обучим первую настоящую многослойную сеть.
Комментарии 0
Пока нет комментариев. Станьте первым!