Глава 4. Функции потерь и градиенты: математика без магии

294 просмотров
0 лайков
0 в избранном
Войдите, чтобы поставить лайк. Лайков:

Введение

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

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

Функция потерь: одно число вместо предсказаний

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

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. Идём справа налево:

  1. dL/da = 2(a - y) — производная квадрата.
  2. da/dz = 1, если z > 0, иначе 0 — производная ReLU.
  3. 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, а у центральной — порядка . На отладке градиентов эта разница означает «нашёл ошибку» против «не понял, ошибка это или погрешность метода».

Практика

Задание 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

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!