Глава 4 из 17 40 мин
Как ошибка течёт назад
Как вычислить градиент, если до ошибки ведёт длинная цепочка операций? Разберём обратное распространение и напишем маленькую систему автоматического дифференцирования.
В этой главе
- проследить, как градиент течёт назад по графу вычислений, и проверить его на деле
- написать свой автоград на Python и обучить им маленькую сеть
- понять, из чего сделан нейрон и зачем сети изгибы
- увидеть, как сеть с окном в три буквы обходит биграмму
Для биграммы мы вывели градиент вручную: из softmax и кросс-энтропии получилось короткое выражение $p - \text{onehot}$. Оно позволило постепенно приблизить вероятности к частотам из корпуса. Но что произойдёт, если между входом модели и её прогнозом появится ещё несколько слоёв вычислений?
Теперь мы хотим модель поумнее — такую, которая смотрит на три предыдущие буквы. Таблица для трёх букв заняла бы 97⁴ ≈ 88,5 миллиона клеток, и большинство из них остались бы пустыми. Вместо таблицы возьмём небольшую нейросеть из 39 025 чисел. Но между каждым её числом и ошибкой уже не два действия, а длинная цепочка: умножить, сложить, изогнуть, снова умножить… У Ростка таких цепочек тысячи, а чисел — 17 миллионов. Выводить градиент для каждого из них на бумаге немыслимо.
Нам нужен способ вычислять производные по всем параметрам, не выводя для каждого отдельную формулу. Обратное распространение ошибки, или backpropagation, делает это, обходя цепочку операций от результата к входам. Алгоритм не выбирает новые веса сам: он находит градиенты, которыми затем пользуется оптимизатор. Ниже мы разберём его на маленьком графе и напишем учебную реализацию на Python.
Как параметр влияет на результат
Вычисление удобно изобразить графом: входные числа слева, результат справа, между ними узлы операций. Возьмём $L = (a \cdot b + c) \cdot f$. Для каждого входа нас интересует, как изменится $L$, если немного увеличить только этот вход. Ответ даёт частная производная; собранные вместе, эти производные образуют градиент. Иногда это описывают как поиск «виновника» ошибки, но точнее говорить о чувствительности результата к параметрам. Наш $L$ — учебная функция и может быть отрицательным, в отличие от кросс-энтропии.
Пройдите путь назад по шагам и присмотритесь к тому, что делает каждый узел. Никто не смотрит на граф целиком. Узел получает «сверху», со стороны результата, одно число — градиент результата по этому узлу, — умножает его на свою местную производную и передаёт своим входам. Сумма передаёт градиент без изменений. Произведение отдаёт каждому множителю градиент, умноженный на другой множитель. Вот и весь секрет.
После обратного прохода выберите вход и подвигайте его. Под графом появятся две величины: изменение $L$, предсказанное градиентом для сдвига на 0,01, и изменение при полном пересчёте. В этом простом примере они совпадают. В общем случае производная даёт локальное приближение: чем меньше сдвиг и чем плавнее функция, тем ближе прогноз к результату.
Цепное правило
Почему градиент нужно именно умножать? Представьте три шестерёнки. Первая вращается втрое быстрее второй, вторая вдвое быстрее третьей. Во сколько раз первая быстрее третьей? В шесть: $3 \cdot 2$. Производная и есть такое «во сколько раз»: насколько сильно выход откликается, если пошевелить вход. Когда величины сцеплены в цепочку, отклики перемножаются.
На языке формул: если $L$ зависит от $d$, а $d$ — от $e$, то
$$\frac{\partial L}{\partial e} = \frac{\partial L}{\partial d} \cdot \frac{\partial d}{\partial e}.$$Это цепное правило. Первый множитель — градиент, пришедший сверху; второй — местная производная узла. Местных производных нам понадобится совсем немного, и их можно разобрать по одной:
- сумма $d = e + c$: $\frac{\partial d}{\partial e} = \frac{\partial d}{\partial c} = 1$ — градиент проходит насквозь и целиком достаётся обоим слагаемым;
- произведение $e = a \cdot b$: $\frac{\partial e}{\partial a} = b$, $\frac{\partial e}{\partial b} = a$ — множители «меняются местами»;
- tanh $y = \tanh(z)$: $\frac{\partial y}{\partial z} = 1 - y^2$ — удобно, что производная считается из уже известного выхода.
Цепное правило проще всего почувствовать на толчке. Ниже цепочка из трёх звеньев: $u = 1{,}5x$, потом $y = \tanh u$, потом $L = y^2$. Толкните $x$ на $\Delta x$ — и толчок побежит по цепочке, в каждом звене умножаясь на местный наклон. До $L$ он доберётся умноженным на произведение всех наклонов, а это и есть $\frac{\partial L}{\partial x}$.
Проверим на нашем графе: $a = 2$, $b = -3$, $c = 10$, $f = -2$. Вперёд: $e = -6$, $d = 4$, $L = -8$. Назад: $\frac{\partial L}{\partial d} = f = -2$. Сумма пропускает градиент как есть, поэтому $\frac{\partial L}{\partial e} = \frac{\partial L}{\partial c} = -2$. Произведение меняет множители местами: $\frac{\partial L}{\partial a} = -2 \cdot b = 6$ и $\frac{\partial L}{\partial b} = -2 \cdot a = -4$. Осталось $\frac{\partial L}{\partial f} = d = 4$. Ровно те числа, что показал виджет.
Одна тонкость. Что, если одно и то же число используется в двух местах? Переключите виджет на «a дважды»: там $L = a \cdot b + a$. Шевельнёте $a$ — изменятся оба слагаемых, и их изменения сложатся. Поэтому и градиент, пришедший к $a$ по двум путям, складывается: $\frac{\partial L}{\partial a} = b + 1 = -1$. В нейросетях так бывает сплошь и рядом: один вход читают сотни нейронов, одну и ту же матрицу применяют к каждой позиции текста. Правило всегда одно — сумма по всем путям.
Пусть $L = a \cdot a$ и $a = 3$. Какой градиент $\partial L / \partial a$ насчитает обратный проход?
Узел умножения получил на оба входа одно и то же число. По каждому пути приходит градиент $1 \cdot 3 = 3$, вместе $6$ — ровно производная $a^2$, то есть $2a$. Если забыть сложить вклады, выйдет 3: это самая частая ошибка у тех, кто пишет свой автоград.
Обратный проход целиком
Соберём алгоритм. В нём два прохода по графу.
- Вперёд. Вычисляем узлы от входов к выходу и запоминаем каждое промежуточное значение — они понадобятся для местных производных.
- Назад. Выход получает градиент 1. Дальше идём по узлам в обратном порядке, так чтобы каждый узел обрабатывался только после всех, кто им пользуется. Каждый умножает свой градиент на местные производные и прибавляет результат к градиенту своих входов.
Порядок «каждый узел после всех своих пользователей» называется обратным топологическим. Он гарантирует, что к моменту, когда узел передаёт градиент дальше, он уже собрал его со всех путей.
Главное преимущество — количество вычислений. Для обычных слоёв обратный проход требует того же порядка работы, что и прямой; часто он примерно вдвое дороже, хотя точное соотношение зависит от операций. При этом мы получаем производные сразу по всем параметрам. Если бы мы сдвигали каждый параметр Ростка по отдельности, пришлось бы выполнять около 17 миллионов дополнительных прямых проходов на шаг. Здесь достаточно одного прямого прохода и одного обратного.
Как проверить, что обратный проход посчитан правильно?
Лобовой способ никуда не делся — он превратился в проверку. Возьмите несколько параметров, сдвиньте каждый на крошечное $h$ в обе стороны и посчитайте $\frac{L(w + h) - L(w - h)}{2h}$. Эта «центральная разность» точнее односторонней, которой мы пользовались в прошлой главе. Если она совпадает с градиентом из обратного прохода до нескольких знаков, всё в порядке; если нет — где-то забыт множитель или +=. Исследователи, которые пишут новые слои вручную, делают такую проверку (gradient check) почти всегда: ошибки в обратном проходе коварны, потому что сеть с неправильным градиентом часто всё равно как-то учится, просто хуже.
Обратное распространение — это цепное правило, применённое с конца: каждый узел умножает пришедший градиент на свою местную производную и передаёт результат входам, а градиент, пришедший по разным путям, складывается.
Автоград на 50 строк
Напишем всё это на Python. Идея такая: вместо обычных чисел использовать объекты Value, которые помнят, из чего получились. Каждая операция создаёт новый Value и заодно записывает маленькую функцию _backward — как передать градиент своим входам. Метод backward() выстраивает узлы в топологическом порядке и вызывает эти функции с конца.
Обратите внимание на += в каждой функции _backward: это то самое «вклады складываются». Проверим класс на графе из виджета. Ячейки на странице работают как тетрадка: если запустить следующую, ячейка с классом выполнится сама.
Ровно те числа, что показывал виджет. Этот класс — упрощённый micrograd Андрея Карпатого (2020): в оригинале есть ещё степень, ReLU и пара удобств, но сердце то же. PyTorch устроен по тому же принципу, только его «значения» — целые тензоры, а местные производные написаны для сотен операций.
Зачем перед каждым шагом обнулять grad?
Из-за того же +=. Градиенты копятся, и если их не обнулить, градиент с прошлого шага сложится с новым. В PyTorch для этого есть optimizer.zero_grad(), и забытый вызов — классическая ошибка новичка. Иногда накопление, наоборот, используют нарочно: считают градиенты по нескольким порциям данных подряд и только потом делают шаг. Так имитируют большой батч, когда он не помещается в память.
Нейрон
Теперь мы умеем находить градиент для цепочки поддерживаемых операций. Построим из них модель. Один из её базовых элементов — искусственный нейрон: он умножает входы на веса, складывает результаты со сдвигом $b$ и применяет функцию активации. Нелинейная активация меняет форму зависимости между входом и выходом. В нашем примере это гиперболический тангенс:
$$y = \tanh(w_1 x_1 + w_2 x_2 + b).$$Веса и сдвиг — параметры, их подбирает спуск. Кстати, третий вариант графа выше, «нейрон», — ровно эта формула, разобранная на узлы.
Поиграйте с весами. Сумма $w_1 x_1 + w_2 x_2 + b$ постоянна вдоль прямых, поэтому один нейрон всегда делит плоскость прямой линией: по одну сторону «да», по другую «нет». Веса поворачивают эту прямую, сдвиг её двигает, а чем веса больше, тем резче переход.
Функций активации много; начнём с двух распространённых.
- tanh — плавная ступенька от −1 до 1. Она стоит в сети mlp-char-3, которую мы разберём в конце главы.
- ReLU, $\max(0, z)$, — «положительное пропустить, отрицательное отрезать». Проще некуда, а в глубоких сетях работает отлично; в начале 2010-х она стала популярной альтернативой sigmoid и tanh (Наир и Хинтон, 2010; Глоро, Борд и Бенджио, 2011). В трансформерах, включая Ростка, используют её гладких родственников — вернёмся к ним в главе 8.
Теперь посмотрите на наклон под графиком активации. Для обратного прохода это и есть местная производная нейрона: сколько градиента он пропустит назад. У tanh наклон наибольший около нуля и почти исчезает по краям: при $|z| = 3$ он уже меньше 0,01. Нейрон, который «упёрся» в ±1, почти не пропускает градиент и почти не учится — это то самое плато из прошлой главы. У ReLU наклон равен 1 для любого положительного $z$ и 0 для отрицательного: либо всё, либо ничего.
Слои и изгибы
Один нейрон рисует только прямую. Поставим несколько нейронов рядом — получится слой: каждый нейрон смотрит на все входы, у каждого свои веса, и слой выдаёт столько чисел, сколько в нём нейронов. Выходы одного слоя становятся входами следующего. Такая сеть называется многослойным перцептроном, MLP.
А зачем изгиб? Попробуем без него. Слой без активации — это просто $W x + b$. Два таких слоя подряд:
$$W_2 (W_1 x + b_1) + b_2 = (W_2 W_1)\, x + (W_2 b_1 + b_2).$$Снова одна матрица и один сдвиг — то есть снова один слой. Сколько линейных слоёв ни ставь, они схлопываются в один и рисуют одну прямую. Изгиб между слоями не даёт им схлопнуться: каждый следующий слой гнёт и складывает пространство, которое получилось после предыдущего.
Попробуйте все четыре набора. Кольца и крест сеть обычно раскладывает за несколько десятков шагов, луны — за пару сотен, спираль — за тысячу с лишним, а если в слое всего два или четыре нейрона, спираль ей так и не даётся. Потом выключите изгиб («нет»): граница выпрямится и больше не согнётся, сколько ни учи. Для лун прямая почти годится (на своих местах около 93% точек), а для колец и креста она не лучше монетки.
Код этой сети — те же прямой и обратный проходы, только записанные сразу для целого слоя: вместо отдельных узлов $a \cdot b$ — умножение матрицы на вектор. А вот та же идея на нашем Value: сеть из трёх слоёв учится выдавать нужные ответы на четыре примера.
41 параметр, 40 шагов спуска, ошибка падает с 4,5 до 0,02 — и ни одной производной, выведенной руками. Всё сделали Value и цепное правило.
То же на PyTorch
В PyTorch всё это уже встроено. Тензор с requires_grad=True — это наш Value, а backward() — наш обратный проход (этот код для вашего компьютера: в браузере PyTorch не запустится):
А вот настоящий код, которым обучена сеть из следующего раздела. Слой эмбеддингов, скрытые слои с tanh, выходной слой — и ни одной производной: их найдёт loss.backward().
Сеть mlp-char-3 — это MLP(97, 3, 16, [256]): алфавит из 97 символов, окно в 3 буквы, эмбеддинги по 16 чисел, один скрытый слой из 256 нейронов. Её учили 20 000 шагов по 512 окон за шаг оптимизатором AdamW — с той самой ручкой затухания весов (weight decay) из прошлой главы, выставленной на 0,01.
Окно в три буквы
Теперь у нас есть всё, чтобы разобрать первую настоящую нейросетевую языковую модель — ту, что описали Бенджио, Дюшарм, Винсент и Жовен в статье «A Neural Probabilistic Language Model» (2003). У них были слова, у нас буквы, но устройство то же.
- Каждая из трёх последних букв превращается в список из 16 чисел — свой эмбеддинг (от англ. embedding, «вложение»). Это просто строка обучаемой таблицы 97 × 16: у каждой буквы своя.
- Три эмбеддинга склеиваются в 48 чисел и идут в скрытый слой из 256 нейронов с tanh.
- Выходной слой превращает 256 чисел в 97 логитов, а softmax — в вероятности следующей буквы.
Сравните столбцы. После «ti» биграмма видит только «i» и гадает широко: на первом месте «n» с 27%. Сеть видит « ti» с пробелом впереди и на 73% уверена, что дальше «m», — это же «time». Попробуйте «Lily wanted to pl»: сеть на 94% ставит на «a», а биграмма после «l» колеблется между «e», «l» и «i». Или «Tom and his mo»: сеть разрывается между «r» и «m» — «mother» или «mom», — и это честное сомнение, а не гадание.
Сколько всё это стоит? Посчитаем параметры:
| часть | размер | чисел |
|---|---|---|
| эмбеддинги | 97 × 16 | 1 552 |
| скрытый слой | 48 × 256 + 256 | 12 544 |
| выходной слой | 256 × 97 + 97 | 24 929 |
| всего | 39 025 |
39 тысяч чисел вместо 88,5 миллиона клеток таблицы — в две с лишним тысячи раз меньше. И сеть не просто экономнее, она лучше. На одном и том же отложенном тексте, которого не видела ни одна из моделей, биграмма удивляется на 2,364 ната на букву, а сеть с окном в три буквы — на 1,479. В пересчёте на «число равновероятных вариантов» из главы 2 это $e^{2{,}364} \approx 10{,}6$ против $e^{1{,}479} \approx 4{,}4$: сеть выбирает следующую букву так, будто вариантов у неё вдвое с лишним меньше.
Откуда такая экономия? Таблица хранит каждую тройку отдельно и ничего не знает о сходстве между ними. Сеть делит знания: одни и те же 256 нейронов обслуживают все тройки, и то, что она выучила про «th», может пригодиться и для «Th», если эмбеддинги «t» и «T» окажутся похожими. Такой перенос знаний на похожие случаи и называется обобщением.
Сеть не заводит отдельную клетку на каждый случай, а делит знания между случаями. Поэтому 39 тысяч обученных чисел обходят таблицу, которой понадобились бы 88 миллионов.
Как именно устроены эмбеддинги и почему похожие буквы в них оказываются рядом, разберём в следующей главе.
Росток сейчас
У Ростка появилась нейросеть со скрытым слоем: эмбеддинги, 256 скрытых нейронов и выходной слой. Обратное распространение вычисляло градиенты, а AdamW обновлял параметры. Три символа контекста заметно улучшают предсказания по сравнению с биграммой; в генерации чаще встречаются знакомые фрагменты слов. Но длинную фразу такая сеть всё ещё не удерживает: к концу длинного слова его начало уже вышло из окна.
Дальше — больше памяти. В следующей главе мы посмотрим, что на самом деле выучили эмбеддинги, научимся писать такие сети на PyTorch как следует и расширим окно до восьми букв.