← Росток Своя LLM с нуля Словарик Код EN

Глава 4 из 14 40 мин

Как ошибка течёт назад

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

В этой главе

  • проследить, как градиент течёт назад по графу вычислений, и проверить его на деле
  • написать свой автоград на Python и обучить им маленькую сеть
  • понять, из чего сделан нейрон и зачем сети изгибы
  • увидеть, как сеть с окном в три буквы обходит биграмму

В прошлой главе спуск обучил биграмму и пришёл ровно туда же, куда подсчёт. Но для этого понадобилась формула градиента $p - \text{onehot}$, и мы вывели её руками. Это было нетрудно: биграмма — одна таблица, и между числом и ошибкой всего два действия, softmax и логарифм.

Теперь мы хотим модель поумнее — такую, которая смотрит на три предыдущие буквы. Таблица для трёх букв заняла бы 97⁴ ≈ 88,5 миллиона клеток, и большинство из них остались бы пустыми. Вместо таблицы возьмём небольшую нейросеть из 39 025 чисел. Но между каждым её числом и ошибкой уже не два действия, а длинная цепочка: умножить, сложить, изогнуть, снова умножить… У Ростка таких цепочек тысячи, а чисел — 17 миллионов. Выводить градиент для каждого из них на бумаге немыслимо.

Нужен алгоритм, который по любой цепочке вычислений сам найдёт, насколько каждое число виновато в ошибке. Он называется обратным распространением ошибки (backpropagation), и это без преувеличения мотор всего глубокого обучения. Устроен он проще, чем кажется: к концу главы вы напишете его сами, в пятьдесят строк.

Кто виноват?

Любое вычисление можно нарисовать графом: входы слева, результат справа, а между ними простые операции, каждая в своём узле. Вот совсем маленький пример: $L = (a \cdot b + c) \cdot f$. Представьте, что $L$ — ошибка модели, а $a$, $b$, $c$, $f$ — её параметры. Про каждый параметр мы хотим знать одно: если его чуть-чуть увеличить, насколько изменится $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}$.

«Δ ≈» — сколько предсказывают местные наклоны, «на деле» — что получится, если честно пересчитать цепочку от $x + \Delta 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. Вперёд. Вычисляем узлы от входов к выходу и запоминаем каждое промежуточное значение — они понадобятся для местных производных.
  2. Назад. Выход получает вину 1. Дальше идём по узлам в обратном порядке, так чтобы каждый узел обрабатывался только после всех, кто им пользуется. Каждый умножает свою вину на местные производные и прибавляет результат к вине своих входов.

Порядок «каждый узел после всех своих пользователей» называется обратным топологическим. Он гарантирует, что к моменту, когда узел передаёт вину дальше, он уже собрал её со всех путей.

Самое замечательное здесь — цена. Обратный проход стоит примерно столько же, сколько прямой (на практике раза в два дороже), и за это время находит градиент сразу по всем параметрам. Сравните с лобовым способом из прошлой главы: пошевелить каждый параметр по отдельности и посмотреть, как изменится ошибка. Для Ростка это 17 миллионов прогонов модели на один шаг обучения. Обратный проход обходится одним.

Как проверить, что обратный проход посчитан правильно?

Лобовой способ никуда не делся — он превратился в проверку. Возьмите несколько параметров, сдвиньте каждый на крошечное $h$ в обе стороны и посчитайте $\frac{L(w + h) - L(w - h)}{2h}$. Эта «центральная разность» точнее односторонней, которой мы пользовались в прошлой главе. Если она совпадает с градиентом из обратного прохода до нескольких знаков, всё в порядке; если нет — где-то забыт множитель или +=. Исследователи, которые пишут новые слои вручную, делают такую проверку (gradient check) почти всегда: ошибки в обратном проходе коварны, потому что сеть с неправильным градиентом часто всё равно как-то учится, просто хуже.

Обратное распространение — это цепное правило, применённое с конца: каждый узел умножает пришедшую вину на свою местную производную и отдаёт её входам, а вина, пришедшая по разным путям, складывается.

Автоград на 50 строк

Напишем всё это на Python. Идея такая: вместо обычных чисел использовать объекты Value, которые помнят, из чего получились. Каждая операция создаёт новый Value и заодно записывает маленькую функцию _backward — как передать вину своим входам. Метод backward() выстраивает узлы в топологическом порядке и вызывает эти функции с конца.

import math class Value: """A number that remembers where it came from, so it can pass blame back.""" def __init__(self, data, children=(), op=''): self.data = data self.grad = 0.0 # dL/d(this value), filled in by backward() self._backward = lambda: None # how to pass my grad on to my inputs self._prev = set(children) self._op = op def __add__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data + other.data, (self, other), '+') def _backward(): self.grad += out.grad # a sum passes the blame on unchanged other.grad += out.grad out._backward = _backward return out def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data * other.data, (self, other), '*') def _backward(): self.grad += other.data * out.grad # a product: each factor gets other.grad += self.data * out.grad # the blame times the other one out._backward = _backward return out def tanh(self): t = math.tanh(self.data) out = Value(t, (self,), 'tanh') def _backward(): self.grad += (1 - t * t) * out.grad out._backward = _backward return out def backward(self): order, seen = [], set() def visit(v): # inputs before outputs: a topological order if v not in seen: seen.add(v) for child in v._prev: visit(child) order.append(v) visit(self) self.grad = 1.0 # dL/dL = 1 for v in reversed(order): # from the end back to the start v._backward() __radd__ = __add__ __rmul__ = __mul__ def __neg__(self): return self * -1 def __sub__(self, other): return self + (-other) def __repr__(self): return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

Обратите внимание на += в каждой функции _backward: это то самое «вклады складываются». Проверим класс на графе из виджета. Ячейки на странице работают как тетрадка: если запустить следующую, ячейка с классом выполнится сама.

a, b, c, f = Value(2.0), Value(-3.0), Value(10.0), Value(-2.0) e = a * b d = e + c L = d * f L.backward() for name, v in zip('abcfedL', [a, b, c, f, e, d, L]): print(name, v)

Ровно те числа, что показывал виджет. Этот класс — упрощённый micrograd Андрея Карпатого (2020): в оригинале есть ещё степень, ReLU и пара удобств, но сердце то же. PyTorch устроен по тому же принципу, только его «значения» — целые тензоры, а местные производные написаны для сотен операций.

Зачем перед каждым шагом обнулять grad?

Из-за того же +=. Градиенты копятся, и если их не обнулить, вина с прошлого шага сложится с новой. В PyTorch для этого есть optimizer.zero_grad(), и забытый вызов — классическая ошибка новичка. Иногда накопление, наоборот, используют нарочно: считают градиенты по нескольким порциям данных подряд и только потом делают шаг. Так имитируют большой батч, когда он не помещается в память.

Нейрон

Алгоритм, который умеет обучать любую цепочку операций, у нас есть. Из каких кирпичиков строить саму модель? Главный кирпичик нейросети — нейрон. Он делает три вещи: умножает каждый вход на свой вес, складывает всё вместе со сдвигом $b$ и пропускает сумму через изгибающую функцию — функцию активации:

$$y = \tanh(w_1 x_1 + w_2 x_2 + b).$$

Веса и сдвиг — параметры, их подбирает спуск. Кстати, третий вариант графа выше, «нейрон», — ровно эта формула, разобранная на узлы.

У нейрона два входа, поэтому его ответ можно раскрасить на плоскости. Пунктир — линия, где сумма $z$ равна нулю. Под ползунками — функция активации и наклон в выбранной точке.

Поиграйте с весами. Сумма $w_1 x_1 + w_2 x_2 + b$ постоянна вдоль прямых, поэтому один нейрон всегда делит плоскость прямой линией: по одну сторону «да», по другую «нет». Веса поворачивают эту прямую, сдвиг её двигает, а чем веса больше, тем резче переход.

Функций активации много; вот две самые важные.

  • tanh — плавная ступенька от −1 до 1. Она стоит в сети mlp-char-3, которую мы разберём в конце главы.
  • ReLU, $\max(0, z)$, — «положительное пропустить, отрицательное отрезать». Проще некуда, а в глубоких сетях работает отлично; в начале 2010-х она вытеснила плавные функции (Наир и Хинтон, 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).$$

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

Сеть 2 → N → N → 1 учится прямо сейчас: на каждом шаге — прямой проход по всем 200 точкам, обратный проход и шаг спуска. Фон — ответ сети в каждой точке, линия — граница, где сеть колеблется пятьдесят на пятьдесят. В углу — кривая ошибки.

Попробуйте все четыре набора. Кольца и крест сеть обычно раскладывает за несколько десятков шагов, луны — за пару сотен, спираль — за тысячу с лишним, а если в слое всего два или четыре нейрона, спираль ей так и не даётся. Потом выключите изгиб («нет»): граница выпрямится и больше не согнётся, сколько ни учи. Для лун прямая почти годится (на своих местах около 93% точек), а для колец и креста она не лучше монетки.

Код этой сети — те же прямой и обратный проходы, только записанные сразу для целого слоя: вместо отдельных узлов $a \cdot b$ — умножение матрицы на вектор. А вот та же идея на нашем Value: сеть из трёх слоёв учится выдавать нужные ответы на четыре примера.

import random random.seed(1) class Neuron: def __init__(self, n_in): self.w = [Value(random.uniform(-1, 1)) for _ in range(n_in)] self.b = Value(0.0) def __call__(self, x): z = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) return z.tanh() def parameters(self): return self.w + [self.b] class Layer: def __init__(self, n_in, n_out): self.neurons = [Neuron(n_in) for _ in range(n_out)] def __call__(self, x): out = [n(x) for n in self.neurons] return out[0] if len(out) == 1 else out def parameters(self): return [p for n in self.neurons for p in n.parameters()] class MLP: def __init__(self, n_in, sizes): dims = [n_in] + sizes self.layers = [Layer(a, b) for a, b in zip(dims, dims[1:])] def __call__(self, x): for layer in self.layers: x = layer(x) return x def parameters(self): return [p for layer in self.layers for p in layer.parameters()] xs = [[2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -1.0]] ys = [1.0, -1.0, -1.0, 1.0] # the answers we want net = MLP(3, [4, 4, 1]) print(len(net.parameters()), "parameters") for step in range(41): preds = [net(x) for x in xs] loss = sum(((p - y) * (p - y) for p, y in zip(preds, ys)), Value(0.0)) for p in net.parameters(): p.grad = 0.0 # forget the blame from the last step loss.backward() for p in net.parameters(): p.data -= 0.05 * p.grad # one step of gradient descent if step % 5 == 0: print(f"step {step:2d} loss {loss.data:.4f}") print("answers:", [round(net(x).data, 2) for x in xs])

41 параметр, 40 шагов спуска, ошибка падает с 4,5 до 0,02 — и ни одной производной, выведенной руками. Всё сделали Value и цепное правило.

То же на PyTorch

В PyTorch всё это уже встроено. Тензор с requires_grad=True — это наш Value, а backward() — наш обратный проход (этот код для вашего компьютера: в браузере PyTorch не запустится):

import torch a = torch.tensor(2.0, requires_grad=True) b = torch.tensor(-3.0, requires_grad=True) c = torch.tensor(10.0, requires_grad=True) f = torch.tensor(-2.0, requires_grad=True) L = (a * b + c) * f L.backward() # the same backward pass, done by PyTorch print(L.item(), a.grad.item(), b.grad.item(), c.grad.item(), f.grad.item())

А вот настоящий код, которым обучена сеть из следующего раздела. Слой эмбеддингов, скрытые слои с tanh, выходной слой — и ни одной производной: их найдёт loss.backward().

class MLP(nn.Module): def __init__(self, vocab, context, d_embed, hidden): super().__init__() self.context = context self.embed = nn.Embedding(vocab, d_embed) dims = [context * d_embed] + hidden self.hidden = nn.ModuleList(nn.Linear(a, b) for a, b in zip(dims, dims[1:])) self.out = nn.Linear(dims[-1], vocab) def forward(self, x): # x: (batch, context) h = self.embed(x).flatten(1) for layer in self.hidden: h = torch.tanh(layer(h)) return self.out(h)

Сеть 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). У них были слова, у нас буквы, но устройство то же.

  1. Каждая из трёх последних букв превращается в список из 16 чисел — свой эмбеддинг (от англ. embedding, «вложение»). Это просто строка обучаемой таблицы 97 × 16: у каждой буквы своя.
  2. Три эмбеддинга склеиваются в 48 чисел и идут в скрытый слой из 256 нейронов с tanh.
  3. Выходной слой превращает 256 чисел в 97 логитов, а softmax — в вероятности следующей буквы.
Настоящие веса mlp-char-3; все вычисления идут у вас в браузере. Цвет клеток — знак и величина числа: бирюзовый — плюс, терракотовый — минус. Печатайте в поле или добавляйте буквы кнопками.

Сравните столбцы. После «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 × 161 552
скрытый слой48 × 256 + 25612 544
выходной слой256 × 97 + 9724 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 миллионов.

Как именно устроены эмбеддинги и почему похожие буквы в них оказываются рядом, разберём в следующей главе.

Росток сейчас

Росток впервые стал настоящей нейросетью: эмбеддинги, скрытый слой, выходной слой — и всё обучено обратным распространением ошибки. Три буквы памяти заметно меняют его лепет: слова стали длиннее, настоящие попадаются чаще, пробелы и знаки препинания встают на свои места. Но фразы по-прежнему не складываются: к концу длинного слова сеть уже не помнит, с чего оно началось.

Дальше — больше памяти. В следующей главе мы посмотрим, что на самом деле выучили эмбеддинги, научимся писать такие сети на PyTorch как следует и расширим окно до восьми букв.

Главы

  1. 0 Знакомство
  2. 1 Считаем буквы
  3. 2 Мера удивления
  4. 3 Градиентный спуск
  5. 4 Обратное распространение
    1. Кто виноват?
    2. Цепное правило
    3. Обратный проход целиком
    4. Автоград на 50 строк
    5. Нейрон
    6. Слои и изгибы
    7. То же на PyTorch
    8. Окно в три буквы
    9. Росток сейчас
  6. 5 Эмбеддинги
  7. 6 Токены
  8. 7 Внимание
  9. 8 Трансформер
  10. 9 Корпус
  11. 10 Обучение
  12. 11 Выборка
  13. 12 Разговор
  14. 13 LoRA
  15. 14 Что дальше