Zero to Hero, лекция 5: становимся ниндзя обратного распространения (makemore, часть 4)
Подробный конспект лекции-упражнения Карпатого: обратный проход через MLP с BatchNorm пишется руками на уровне тензоров — от cross-entropy до таблицы эмбеддингов. Короткие формулы для cross-entropy и BatchNorm, правило «сумма ↔ растягивание» и обучение сети без loss.backward().
Коротко
Заголовок раздела «Коротко»- Карпатый берёт двухслойную MLP с BatchNorm из прошлой лекции и убирает строку
loss.backward(). Весь обратный проход пишется руками — уже не для отдельных чисел, как в micrograd, а для тензоров: через cross-entropy, второй линейный слой, tanh, BatchNorm, первый слой и таблицу эмбеддингов. - Зачем: backpropобратное распространение ошибкиEN backpropagation — «дырявая абстракция». Насыщение, мёртвые нейроны, взрывающиеся градиенты и тонкие баги вроде обрезки loss вместо градиента не видны, пока не понимаешь, что происходит внутри. Лет десять назад обратный проход и так писали руками все.
- Четыре упражнения. Первое — пройти назад по каждой элементарной операции и сверить с PyTorch. Второе и третье — вывести на бумаге короткие формулы для cross-entropy и BatchNorm целиком. Четвёртое — обучить сеть только на своих градиентах.
- Два правила, на которых держится всё: где в прямом проходе было растягивание (broadcasting), в обратном — сумма по той же оси, и наоборот. А формулы для матричного умножения не нужно помнить — их однозначно задают формы тензоров.
- Градиент cross-entropy по логитам — . Его Карпатый объясняет как систему сил: неправильные ответы тянутся вниз, правильный вверх, и силы уравновешены.
Как смотреть вместе с курсом
Заголовок раздела «Как смотреть вместе с курсом»Это видео не для пассивного просмотра. Карпатый прямо советует открыть ноутбук-упражнение (есть версия в Google Colab), пытаться вывести каждый градиент самому и включать видео, только когда застряли.
В курсе ту же работу, но без BatchNorm, делает раздел «Backprop руками» урока
4.1.4: cross-entropy, линейные слои, tanh и
scatter-add для таблицы эмбеддингов. Его стоит пройти до лекции — тогда в видео новыми будут только BatchNorm
и техника работы с broadcasting. Формулы градиента линейного слоя выведены в разделе «Градиент матричного умножения»
урока 1.2.3, обратный проход «по кирпичикам» — в уроке
3.1.2, правило += — в уроке
3.2.2.
Новичку можно сначала пройти только упражнения 1 и 2, а вывод формулы BatchNorm (упражнение 3) оставить на второй заход. Ноутбук лекции — makemore_part4_backprop.ipynb (MIT); в репозитории он с решениями, в Colab — с пропусками. Китайская учебная версия — 005_makemore_BackpropNinja.
Карта лекции
Заголовок раздела «Карта лекции»Таймкоды — из глав в описании видео.
| Время | Глава в оригинале | О чём |
|---|---|---|
| 0:00 | intro: why you should care & fun history | зачем это нужно и как было раньше |
| 7:26 | starter code | стартовый код, функция сверки cmp |
| 13:03 | exercise 1: backproping the atomic compute graph | упражнение 1: назад по каждой операции |
| 1:05:20 | brief digression: bessel’s correction in batchnorm | отступление: или в дисперсии |
| 1:26:31 | exercise 2: cross entropy loss backward pass | упражнение 2: градиент cross-entropy одной формулой |
| 1:36:42 | exercise 3: batch norm layer backward pass | упражнение 3: BatchNorm одной формулой |
| 1:50:05 | exercise 4: putting it all together | упражнение 4: обучение на своих градиентах |
| 1:54:25 | outro | итоги |
1. Зачем писать обратный проход руками
Заголовок раздела «1. Зачем писать обратный проход руками»У Карпатого есть пост 2016 года об этом — «Yes you should understand backprop». Главная мысль: backprop — дырявая абстракция. Нельзя сложить произвольные дифференцируемые «кубики», скрестить пальцы и ждать, что всё обучится. Примеры знакомы по прошлым лекциям: плоские хвосты нелинейностей, мёртвые нейроны, взрывающиеся и исчезающие градиенты в рекуррентных сетях (о них речь впереди).
Ещё один пример — фрагмент кода, найденный «в дикой природе». Автор хотел ограничить градиенты, а вместо этого обрезал loss сверху. У примеров-выбросов с обрезанным loss градиент становится нулём, и сеть их молча игнорирует. Такой баг не найти, если не понимать обратный проход.
Исторический экскурс. Сейчас обратный проход руками никто не пишет, кроме как в учебных целях. Но лет десять назад это было нормой. Карпатый показывает статью Хинтона и Салахутдинова 2006 года в Science (автокодировщики на ограниченных машинах Больцмана) и свой код 2010 года на Matlab. Тогда Python в глубоком обучении почти не использовали, а градиенты оценивали методом contrastive divergence и вписывали в код прямо в строке. Ещё пример — код его статьи 2014 года о «фрагментных эмбеддингах» (сопоставление объектов на картинках и кусочков фраз, что-то вроде CLIP). Там уже NumPy, но обратный проход написан руками и проверен численным сравнением градиентовпроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward.. Его вывод: сейчас всё делает автоград, и кое-что мы потеряли.
2. Стартовый код
Заголовок раздела «2. Стартовый код»Данные и сеть те же, что в прошлой лекции: окно из трёх символов, эмбеддинги длины 10, скрытый слой из 64 нейронов, BatchNorm, tanh, выходной слой — 4137 параметров. Три изменения:
- Функция сверки
cmp(name, dt, t). Она печатает, совпал ли наш градиентdtсt.gradот PyTorch бит в бит (exact), с точностью до округлений (torch.allclose) и какова максимальная разница. - Инициализация маленькими случайными числами вместо нулей, даже у смещений. Нули упрощают выражения
и могут скрыть ошибку в формуле градиента.
b1оставлен перед BatchNorm, хотя он бесполезен, — «для веселья»: проверить, что его градиент тоже считается правильно. - Прямой проход разбит на мелкие шаги. Вместо
F.cross_entropysoftmax и NLL выписаны явно, BatchNorm тоже разобран на части. У каждого промежуточного тензора будет свой градиент с приставкойd: дляbnraw—dbnraw, дляlogprobs—dlogprobs.
Код из ноутбука лекции (MIT), комментарии наши:
emb = C[Xb] # (32, 3, 10): эмбеддинги символов окнаembcat = emb.view(emb.shape[0], -1) # (32, 30): склейкаhprebn = embcat @ W1 + b1 # (32, 64): первый линейный слой# BatchNorm по шагамbnmeani = 1/n * hprebn.sum(0, keepdim=True) # (1, 64): среднее по батчуbndiff = hprebn - bnmeani # (32, 64): отклоненияbndiff2 = bndiff**2bnvar = 1/(n-1) * bndiff2.sum(0, keepdim=True) # (1, 64): дисперсия с поправкой Бесселяbnvar_inv = (bnvar + 1e-5)**-0.5 # 1/sqrt(дисперсия + eps)bnraw = bndiff * bnvar_inv # нормированные значенияhpreact = bngain * bnraw + bnbias # масштаб и сдвигh = torch.tanh(hpreact) # (32, 64): нелинейностьlogits = h @ W2 + b2 # (32, 27): второй линейный слой# cross-entropy по шагам (то же, что F.cross_entropy)logit_maxes = logits.max(1, keepdim=True).valuesnorm_logits = logits - logit_maxes # вычитаем максимум ради устойчивостиcounts = norm_logits.exp()counts_sum = counts.sum(1, keepdims=True)counts_sum_inv = counts_sum**-1 # с 1.0/counts_sum не выходит совпадение бит в битprobs = counts * counts_sum_invlogprobs = probs.log()loss = -logprobs[range(n), Yb].mean()Перед loss.backward() для всех этих тензоров вызывается retain_grad(). Иначе PyTorch не сохранит градиенты
промежуточных значений, и сверять будет не с чем.
3. Упражнение 1: назад по каждой операции
Заголовок раздела «3. Упражнение 1: назад по каждой операции»Идём снизу вверх: от loss к C. Ниже — главные шаги и приёмы, которые Карпатый на них показывает. Общий рецепт
он повторяет много раз. Сначала посмотреть на формы тензоров. Потом взять игрушечный пример
или и выписать, какой элемент от какого зависит. Потом обобщить.
logprobs. Loss — минус среднее 32 выбранных элементов. Если , то
. Значит, у выбранных элементов градиент , а у остальных 27·32 − 32 —
ноль: они в loss не участвуют.
dlogprobs = torch.zeros_like(logprobs)dlogprobs[range(n), Yb] = -1.0 / nprobs. Логарифм поэлементный, : dprobs = (1.0 / probs) * dlogprobs. Интуиция Карпатого:
если правильному символу дана маленькая вероятность, деление на неё усиливает градиент такого примера.
counts_sum_inv — первый broadcasting. probs = counts * counts_sum_inv, где формы и .
Здесь на самом деле две операции: сначала столбец копируется 27 раз по горизонтали, потом поэлементное умножение.
Назад через умножение — «умножить на соседа», как в micrograd. Назад через копирование — сложить градиенты всех
копий, потому что это одна переменная, использованная много раз:
dcounts_sum_inv = (counts * dprobs).sum(1, keepdim=True) # (32, 1): сумма по растянутой осиdcounts = counts_sum_inv * dprobs # первая из двух веток для countsВетки и +=. counts участвует дважды: прямо в probs и через counts_sum. Поэтому dcounts сначала
неполный, а вторая ветка потом прибавляется через +=. Пока ветка не дописана, cmp честно показывает
несовпадение — заодно видно, что проверка не всегда отвечает «всё хорошо».
counts_sum — сумма. counts_sum = counts.sum(1): каждый элемент суммы зависит от своей строки
с коэффициентом 1. Значит, градиент суммы копируется во все элементы строки:
dcounts += torch.ones_like(counts) * dcounts_sum.
logit_maxes — градиент, который должен быть нулём. Вычитание максимума не меняет softmax, а значит, и loss.
Поэтому градиент по logit_maxes должен быть нулевым. Так и выходит, с точностью до округлений: числа порядка
. Через сам max градиент попадает только в ту позицию, где был максимум. Её удобно отметить через
one-hot по logits.max(1).indices.
Линейный слой: формулы по формам. Для logits = h @ W2 + b2 Карпатый выписывает на бумаге пример
с матрицами . Каждый элемент — сумма произведений, а это micrograd: сложения и
умножения. Если собрать результаты в матрицы, получится , , — сумма
по строкам. Дальше его признание: он никогда не помнит эти формулы. Это и не нужно. Форма dh должна совпадать
с формой h, . Получить её из dlogits и W2 можно единственным способом:
dlogits @ W2.T. Так же однозначно находятся dW2 = h.T @ dlogits и db2 = dlogits.sum(0).
tanh, gain, bias. dhpreact = (1.0 - h**2) * dh — производная tanh через его выход, как в micrograd.
bngain и bnbias имеют форму и растягиваются на 32 строки, значит, их градиенты — суммы по оси 0
с keepdim=True.
Внутри BatchNorm. Тот же набор приёмов: bndiff участвует в двух ветках (bnraw и через дисперсию), степень
даёт множитель . Отсюда Карпатый формулирует двойственность: сумма
по оси в прямом проходе становится растягиванием по той же оси в обратном. А растягивание (повторное использование
переменной) в прямом становится суммой в обратном.
Последние шаги. Первый линейный слой — те же три формулы. view назад — снова view в исходную форму
. Выборка C[Xb] назад: градиент каждой позиции окна нужно положить в «свою» строку таблицы,
складывая, если символ встречался несколько раз. Векторного способа Карпатый в видео не нашёл и пишет цикл:
dC = torch.zeros_like(C)for k in range(Xb.shape[0]): # по примерам батча for j in range(Xb.shape[1]): # по позициям окна ix = Xb[k, j] # какой символ здесь стоял dC[ix] += demb[k, j] # += : одна строка C могла использоваться много разСводка правил, которые понадобились:
| Операция в прямом проходе | Что делать в обратном |
|---|---|
| поэлементная функция | (для exp, log, tanh, степени) |
| поэлементное умножение на растянутый тензор | умножить на соседа, затем сложить по растянутой оси с keepdim=True |
| сумма по оси | растянуть градиент обратно: torch.ones_like(x) * dy |
| выбор элементов по индексам | положить градиенты на их места, остальное — нули |
max по строке | градиент только в позицию максимума |
| матричное умножение | , , — сумма по строкам; проверять формами |
view | view обратно в исходную форму |
| переменная используется в нескольких местах | += от каждой ветки |
выборка строк таблицы C[X] | scatter-add: сложить градиенты в строки таблицы |
4. Отступление: поправка Бесселя
Заголовок раздела «4. Отступление: поправка Бесселя»В прямом проходе дисперсия делится на , а не на , хотя в статье о BatchNorm стоит . Есть две оценки дисперсии. Деление на даёт смещённую оценку: на маленьких выборках она почти всегда занижает настоящую дисперсию. Деление на даёт несмещённую. Мини-батч из 32 примеров — как раз маленькая выборка из всего обучающего набора.
Путаница в том, что статья при обучении использует смещённую оценку, а для скользящих средних, которые работают
на инференсе, — несмещённую. Получается расхождение между обучением и применением (train/test mismatch).
nn.BatchNorm1d в PyTorch повторяет статью. По словам Карпатого, документация здесь неточна, на это не раз
указывали в обсуждениях на GitHub, а выбрать одну оценку для обоих режимов нельзя. Сам он считает расхождение
багом. Поэтому он последовательно использует — при больших батчах разница становится малой.
5. Упражнение 2: cross-entropy одной формулой
Заголовок раздела «5. Упражнение 2: cross-entropy одной формулой»Разбирать cross-entropy на восемь шагов было полезно для тренировки, но так не делают. Если продифференцировать формулу loss целиком, почти всё сокращается. Для одного примера с логитами и правильным классом :
Читается так: градиент по каждому логиту — вероятность этого класса, а у правильного класса из неё ещё вычитается единица. Для батча loss усредняется, поэтому всё делится на . Вся цепочка из упражнения 1 заменяется тремя строками:
dlogits = F.softmax(logits, 1) # pdlogits[range(n), Yb] -= 1 # p - 1 на месте правильного символаdlogits /= n # loss — среднее по батчуС PyTorch совпадает приблизительно: максимальная разница около , это округления float32.
Теперь интуиция, которую Карпатый считает очень красивой. Строка dlogits для одного примера — это вероятности,
у которых в позиции правильного ответа вычли единицу. Сумма строки — ноль. Думайте о градиенте как о силе.
Неправильные символы тянутся вниз, каждый пропорционально тому, насколько модель в него верила. Правильный
тянется вверх ровно с той же суммарной силой. Если модель уже права с вероятностью 1, строка — одни нули, и тянуть
нечего. Если модель уверенно ошиблась, ошибочный символ тянется вниз очень сильно. Вся сеть — как система блоков
и тросов: на логиты действуют силы, они передаются назад до весов, и с каждым шагом параметры понемногу уступают.
Курс выводит ту же формулу в уроке 1.3.2 (раздел «Почему именно cross-entropy: градиент “p минус ответ”») и использует её в 4.1.4.
6. Упражнение 3: BatchNorm одной формулой
Заголовок раздела «6. Упражнение 3: BatchNorm одной формулой»То же для BatchNorm: вход (hprebn), выход (hpreact), нужен по известному . Градиенты и
простые, их сейчас опускаем. Карпатый выводит формулу на двух листах бумаги. Записываем по формулам
статьи (но с ):
Порядок — как топологическая сортировка в micrograd, только на бумаге: сначала , потом , потом , потом . Главная ловушка — помнить, что , и — векторы из чисел, а и — по одному числу. Поэтому:
- влияет на все , и в её градиенте сумма по ;
- в сходятся 32 стрелки от и одна от . Но производная по равна , а когда — среднее, эта сумма равна нулю. Второе слагаемое исчезает;
- из каждого выходят три стрелки: в , в и в свой . Их вклады складываются. При подстановке нужно переименовать индекс суммирования в , чтобы не спутать его с «нашим» .
После сокращений, если обозначить :
В коде это одна строка. Сложность в том, что формула выведена для одного нейрона, а нейронов 64. Все суммы должны идти по оси батча и правильно растягиваться обратно на :
dhprebn = bngain * bnvar_inv / n * (n * dhpreact - dhpreact.sum(0) - n / (n - 1) * bnraw * (dhpreact * bnraw).sum(0))Совпадение с PyTorch — до . Карпатый честно говорит, что перейти от формулы на бумаге к этой строке было нетривиально, и это отдельное упражнение: проверить формы и понять, почему всё растягивается правильно.
7. Упражнение 4: обучение без loss.backward()
Заголовок раздела «7. Упражнение 4: обучение без loss.backward()»Последний шаг — собрать всё вместе. Сеть та же (200 нейронов в скрытом слое), обучение то же (200 тысяч шагов), но градиенты наши. Весь обратный проход — около 20 строк. Ноутбук лекции (MIT), комментарии наши:
with torch.no_grad(): # autograd больше не нужен: граф не строим for i in range(max_steps): # ...мини-батч и прямой проход как раньше, loss = F.cross_entropy(logits, Yb)
# cross-entropy dlogits = F.softmax(logits, 1) dlogits[range(n), Yb] -= 1 dlogits /= n # второй линейный слой dh = dlogits @ W2.T dW2 = h.T @ dlogits db2 = dlogits.sum(0) # tanh dhpreact = (1.0 - h**2) * dh # BatchNorm dbngain = (bnraw * dhpreact).sum(0, keepdim=True) dbnbias = dhpreact.sum(0, keepdim=True) dhprebn = bngain * bnvar_inv / n * (n * dhpreact - dhpreact.sum(0) - n / (n - 1) * bnraw * (dhpreact * bnraw).sum(0)) # первый линейный слой dembcat = dhprebn @ W1.T dW1 = embcat.T @ dhprebn db1 = dhprebn.sum(0) # склейка и таблица эмбеддингов demb = dembcat.view(emb.shape) dC = torch.zeros_like(C) for k in range(Xb.shape[0]): for j in range(Xb.shape[1]): dC[Xb[k, j]] += demb[k, j] grads = [dC, dW1, db1, dW2, db2, dbngain, dbnbias]
lr = 0.1 if i < 100000 else 0.01 for p, grad in zip(parameters, grads): p.data += -lr * grad # шаг по нашим градиентам, а не p.gradСначала цикл запускается на 100 шагов вместе с loss.backward(), и градиенты сверяются: они совпадают
приблизительно, расхождения около . Откуда именно они берутся, Карпатый не знает. Потом
loss.backward() удаляется, и весь блок оборачивается в torch.no_grad(). Скользящие средние BatchNorm в этом цикле
не копятся, поэтому после обучения статистика считается отдельно, калибровкой по всему train. Итог в ноутбуке —
train 2.07, val 2.11: столько же, сколько с автоградом. Иначе и быть не могло — градиенты те же, только теперь
видно всё, что происходит внутри.
8. Итоги
Заголовок раздела «8. Итоги»Каждый слой — несколько строк, и только BatchNorm заметно сложнее. Карпатый надеется, что после лекции градиенты перестанут быть чёрным ящиком: понятно, как они текут от loss через все промежуточные значения. На практике обратный проход руками писать не нужно, но кто прошёл упражнения, может, по его шутке, считать себя «накачанным псом» из мема (swole doge). В конце он анонсирует рекуррентные сети. На деле следующей вышла лекция о WaveNet, а потом сразу GPT.
flowchart RL L["loss"] --> Z["dlogits = (p − onehot)/n"] Z --> H["dh = dlogits·W2ᵀ<br/>dW2, db2"] H --> T["dhpreact = (1 − h²)·dh"] T --> B["dhprebn — формула BatchNorm<br/>dγ, dβ"] B --> E["dembcat = dhprebn·W1ᵀ<br/>dW1, db1"] E --> C["dC — scatter-add<br/>по строкам таблицы"]
Где курс объясняет то же самое и что есть сверх курса
Заголовок раздела «Где курс объясняет то же самое и что есть сверх курса»| Тема лекции | Где в курсе | Что в лекции сверх курса |
|---|---|---|
| Ручной backprop через MLP-модель имён | 4.1.4, раздел «Backprop руками» | разбор по каждой атомарной операции и сверка с PyTorch через retain_grad |
| Градиент линейного слоя | 1.2.3 | вывод на примере и приём «формулы по формам» |
Обратный проход по кирпичикам, ветки и += | 3.1.2, 3.2.2 | двойственность «сумма ↔ растягивание» для тензоров |
| Градиент cross-entropy | 1.3.2, 4.1.4 | вывод через атомарный граф; нулевой градиент logit_maxes; образ «блоков и тросов» |
| Scatter-add для таблицы эмбеддингов | 4.1.4 (ловушка dC[X] += dE в NumPy) | — |
| BatchNorm | 3.4.1 | вывод обратного прохода BatchNorm на бумаге |
| Несмещённая дисперсия | — | поправка Бесселя и расхождение train/test в BatchNorm |
| Проверка градиентов | 1.2.3 | функция cmp: точное и приближённое совпадение |
Что сделать руками
Заголовок раздела «Что сделать руками»- Откройте ноутбук-упражнение в Colab (ссылка выше) и пройдите упражнения 1–4 сами, подглядывая в видео только при затруднениях. Карпатый настаивает именно на таком порядке.
- В упражнении 1 посмотрите на
dlogit_maxes. Почему он почти нулевой и почему не ровно ноль? - Замените цикл для
dCвекторной операцией:torch.zeros_like(C).index_add_(0, Xb.view(-1), demb.view(-1, n_embd)). У нас результат совпал с циклом точно (разница 0.0). А обычноеdC[Xb] += dembдаст ошибку при повторах. Объясните, почему (подсказка — в уроке 4.1.4). - Выведите формулу обратного прохода BatchNorm с делением на вместо . Что изменится в строке кода?
- Замените tanh на ReLU и перепишите одну строку обратного прохода. Сверьте с PyTorch.
- Инициализируйте смещения нулями и найдите формулу градиента, которая «случайно» проходит проверку только при нулях. Так вы увидите, зачем Карпатый инициализирует всё маленькими случайными числами.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| backprop ninja | «ниндзя» обратного распространения |
| leaky abstraction | дырявая абстракция |
| manual backward pass | обратный проход руками |
| atomic compute graph | граф из элементарных операций |
retain_grad | сохранить градиент промежуточного тензора |
| exact / approximate match | точное / приближённое совпадение |
| broadcasting ↔ sum (duality) | растягивание ↔ сумма (двойственность) |
| replication, variable reuse | копирование, повторное использование переменной |
| scatter-add | scatter-add: сложение градиентов по индексам |
| Bessel’s correction | поправка Бесселя |
| biased / unbiased variance estimate | смещённая / несмещённая оценка дисперсии |
| train/test mismatch | расхождение обучения и применения |
| contrastive divergence | контрастивная дивергенция |
| gradient checker | проверка градиентов |