Перейти к содержимому
Ступень 44.1 Языковое моделирование

Нейросетевая языковая модель: MLP по Бенджио (2003)

Даём модели на русских именах контекст из трёх символов — таблица эмбеддингов, склейка окна, скрытый слой и softmax по словарю. Учимся подбирать learning rate, читать кривые обучения и считать backprop руками вплоть до градиента таблицы эмбеддингов.

УРОКstage-4/4-1-language-modeling/04-mlp-language-model.mdx2 ч 30 минУрок 4 из 5
После урока вы сможете
  • Объяснить, как таблица эмбеддингов, склейка окна, скрытый слой с tanh и softmax превращают несколько символов контекста в распределение следующего символа
  • Обучить MLP-модель мини-батчами, подобрать learning rate по графику и распознать недо- и переобучение по кривым train/val
  • Вывести backprop через cross-entropy, tanh и выборку из таблицы эмбеддингов (scatter-add) и проверить его численным градиентом
  • Сравнить MLP с биграммой по NLL и объяснить, почему окно фиксированной длины — главная слабость модели

Биграммная модель из урока 4.1.2 видит одну предыдущую букву и поэтому выдаёт «ленидашарья»: каждая пара соседних букв правдоподобна, а в целом получается каша. Хочется дать модели больше контекста, но подсчёт этого не позволяет. Таблица для трёх предыдущих символов — это 313=29 79131^3 = 29\,791 строк по 31 числу, почти миллион клеток. Из наших 1627 примеров почти все клетки останутся нулями, и разреженность из урока 4.1.2 станет катастрофой.

В 2003 году Йошуа Бенджио и соавторы предложили выход. Каждому токену сопоставляется короткий обучаемый вектор, векторы нескольких предыдущих токенов склеиваются и подаются в обычную нейросеть, которая выдаёт распределение следующего токена. Такая нейросетевая языковая модельнейросетевая языковая модельEN neural language model (NNLM)Языковая модель, в которой вероятности следующего токена считает нейросеть по эмбеддингам контекста, а не таблица счётчиков. Первая известная схема с эмбеддингами и MLP — Бенджио и соавторы, 2003. не хранит таблицу всех контекстов, а обобщает: если «а» и «я» получили похожие векторы, то знание о контексте «..а» переносится на «..я».

Эта схема — прямой предок GPT. Первые шаги любой современной LLM те же: достать эмбеддингиэмбеддинг, векторное представлениеEN embedding токенов из таблицы, обработать их нейросетью и превратить последний вектор в softmax по словарю. Трансформер меняет только середину — как именно смешивается контекст.

Урок повторяет вторую лекцию makemore Андрея Карпатого (ссылка в источниках; видео удобно смотреть параллельно, русские субтитры — автоперевод YouTube), но со своим кодом и на тех же 242 русских именах, что и биграмма.

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

  1. Выборка эмбеддингов. У модели есть таблица CC размера V×dV \times d: строка на каждый из 31 символа, в строке d=10d = 10 чисел. Для окна «а», «н», «н» достаём три строки C[а],C[н],C[н]C[\text{а}], C[\text{н}], C[\text{н}]. Это тот же приём, что в шаге 6 урока 4.1.2 (one-hot × матрица = выбор строки), только строки теперь короткие и обучаемые.
  2. Склейка окна. Три вектора по 10 чисел ставим подряд и получаем один вектор xx длины 30.
  3. Скрытый слой. h=tanh⁡(xW1+b1)h = \tanh(x W_1 + b_1) — обычный полносвязный слой с нелинейностью из урока 3.1.1, у нас 64 нейрона.
  4. Логиты и softmax. logits=hW2+b2\text{logits} = h W_2 + b_2 — 31 число, softmax превращает их в распределение следующего символа. Loss — средний NLL правильного символа, то есть перекрёстная энтропияперекрёстная энтропияEN cross-entropy, как в биграмме.
flowchart LR
A["окно: а н н"] --> X["C[а], C[н], C[н]<br/>склейка: 30 чисел"]
X --> H["tanh(x·W1 + b1)<br/>64 числа"] --> S["softmax(h·W2 + b2)<br/>P(следующий)"]
MLP-модель Бенджио с окном 3: эмбеддинги из общей таблицы C, склейка, скрытый слой, softmax по словарю

Формулой для одного примера:

P(⋅∣c1,c2,c3)=softmax(tanh⁡([Cc1,Cc2,Cc3] W1+b1) W2+b2).P(\cdot \mid c_1, c_2, c_3) = \mathrm{softmax}\Big(\tanh\big([C_{c_1}, C_{c_2}, C_{c_3}]\, W_1 + b_1\big)\, W_2 + b_2\Big).

Читается изнутри наружу: достали из таблицы строки трёх символов контекста, склеили (квадратные скобки), прогнали через скрытый слой и выходной слой, нормировали softmax. Таблица CC одна на все позиции: символ «н» на первом и на третьем месте окна получает один и тот же вектор.

Посчитаем параметры. Таблица 31⋅10=31031 \cdot 10 = 310, скрытый слой 30⋅64+64=198430 \cdot 64 + 64 = 1984, выходной 64⋅31+31=201564 \cdot 31 + 31 = 2015. Всего 4309 чисел — в двести с лишним раз меньше, чем клеток в таблице 4-грамм (314=923 52131^4 = 923\,521), и количество почти не зависит от того, сколько контекстов встречается в данных.

В статье Бенджио токенами были слова: словарь около 17 тысяч слов, векторы длины от 30 до 100, окно до пяти предыдущих слов. По перплексияперплексияEN perplexity модель обошла лучшие n-граммные модели того времени примерно на 24% на корпусе Brown и на 8% на AP News. В статье была ещё необязательная прямая связь от эмбеддингов к выходу, мы её опустим.

Каждая позиция каждого имени даёт пример «три предыдущих символа → следующий». В начале имени контекст дополняется точками. Код ниже — начало скрипта mlp_lm.py; все блоки урока запускаются подряд, файл names.py — из урока 4.1.2.

import math
import random
import time
import torch
import torch.nn.functional as F
from names import NAMES # 242 имени из урока 4.1.2
torch.set_num_threads(1) # маленькой модели много потоков только мешают (см. «Практика»)
chars = sorted(set("".join(NAMES)))
stoi = {ch: i + 1 for i, ch in enumerate(chars)}
stoi["."] = 0
itos = {i: ch for ch, i in stoi.items()}
V = len(stoi) # 31 символ, как в биграмме
def build_dataset(names, block_size=3):
"""Каждая позиция каждого имени → пара (block_size предыдущих символов, следующий символ)."""
X, Y = [], []
for name in names:
context = [0] * block_size # в начале имени контекст — одни точки
for ch in name + ".": # точка в конце: модель учится заканчивать имя
ix = stoi[ch]
X.append(context)
Y.append(ix)
context = context[1:] + [ix] # окно сдвигается на один символ вправо
return torch.tensor(X), torch.tensor(Y)
names = NAMES[:]
random.Random(0).shuffle(names) # тот же seed, что в уроке 4.1.2
val_names, test_names, train_names = names[:24], names[24:48], names[48:]
Xtr, Ytr = build_dataset(train_names)
Xva, Yva = build_dataset(val_names)
Xte, Yte = build_dataset(test_names)
print("train", tuple(Xtr.shape), " val", tuple(Xva.shape), " test", tuple(Xte.shape))
X, Y = build_dataset(["анна"])
for x, y in zip(X.tolist(), Y.tolist()):
print("".join(itos[i] for i in x), "→", itos[y])
train (1320, 3) val (152, 3) test (155, 3)
... → а
..а → н
.ан → н
анн → а
нна → .

Имена делим на три части, а не на две, как в 4.1.2:

  • train (194 имени) — на нём обучаются веса;
  • valвалидационная выборка (val, dev)EN validation setНужна для выбора гиперпараметров и ранней остановки. Тест при этом не трогаем. (24 имени) — по нему выбираем гиперпараметры: learning rate, размер окна, силу регуляризации. Валидация — те же 24 имени, что в уроке 4.1.2;
  • testтестовая выборка (test)EN test setСмотрим на неё один раз — в самом конце, чтобы честно оценить качество. (24 имени) — смотрим один раз, в самом конце. Если подбирать гиперпараметры по test, он перестаёт быть честной оценкой: мы незаметно подгоним модель и под него.

Делим именно имена, а не готовые окна. Иначе окна одного имени попали бы и в train, и в val, и модель видела бы кусочки «проверочных» имён во время обучения.

def init_params(n_embd=10, n_hidden=64, block_size=3, seed=2026):
g = torch.Generator().manual_seed(seed)
fan_in = block_size * n_embd
C = torch.randn((V, n_embd), generator=g) # таблица эмбеддингов: строка на символ
W1 = torch.randn((fan_in, n_hidden), generator=g) / fan_in ** 0.5 # масштаб 1/√n_in (урок 3.4.1)
b1 = torch.zeros(n_hidden)
W2 = torch.randn((n_hidden, V), generator=g) * 0.1 # маленькие логиты → на старте loss ≈ ln V
b2 = torch.zeros(V)
params = [C, W1, b1, W2, b2]
for p in params:
p.requires_grad = True
return params
def forward(params, X):
C, W1, b1, W2, b2 = params
emb = C[X] # (B, 3, 10): для каждого примера — три строки таблицы
x = emb.view(X.shape[0], -1) # (B, 30): склеиваем окно в один вектор
h = torch.tanh(x @ W1 + b1) # (B, 64): скрытый слой
return h @ W2 + b2 # (B, 31): логиты следующего символа
params = init_params()
print("параметров:", sum(p.numel() for p in params))
with torch.no_grad():
loss0 = F.cross_entropy(forward(params, Xtr), Ytr).item()
print(f"loss до обучения: {loss0:.3f} ln V = {math.log(V):.3f}")
параметров: 4309
loss до обучения: 3.411 ln V = 3.434

Две строчки, которые стоит понять. C[X] — индексация массивом индексовиндексация массивом индексов (fancy indexing)EN fancy indexingE[ids] — выбрать строки по списку индексов. Именно так работает таблица эмбеддингов. (модуль 0.3): X имеет форму (B, 3), и на место каждого номера встаёт строка таблицы длины 10, получается (B, 3, 10). view(B, -1) склеивает три вектора подряд без копирования данных: -1 значит «посчитай размер сам», здесь 30.

Loss до обучения почти равен ln⁡31=3.434\ln 31 = 3.434: модель пока даёт всем символам примерно равные шансы. Это хорошая проверка, которую стоит делать перед любым обучением. Если бы мы взяли W2W_2 без множителя 0.1, начальные логиты были бы большими, модель была бы уверенно неправа, а loss на старте был бы заметно выше ln⁡V\ln V.

На каждом шаге считаем градиент не по всем 1320 примерам, а по случайному мини-батчумини-батчEN mini-batchНебольшая случайная порция примеров, по которой считается одна оценка градиента и делается один шаг. из 32. Такой градиент шумный, но в 40 раз дешевле. Много неточных шагов почти всегда лучше, чем мало точных: это стохастический градиентный спуск из урока 1.4.

Главный гиперпараметр SGD — скорость обученияскорость обученияEN learning rate. Гадать не будем. Запустим 1000 шагов, на каждом увеличивая lr в одно и то же число раз, от 0.001 до 10, и посмотрим, при каком lr loss падает быстрее всего. Это поиск learning rate по графикупоиск learning rate по графику (lr finder)EN learning rate finder (LR range test)Короткий прогон, в котором learning rate растёт экспоненциально от шага к шагу. По графику «loss от lr» берут значение в несколько раз меньше того, где loss начинает расти. из лекции Карпатого.

params = init_params()
g = torch.Generator().manual_seed(0)
lrs = 10 ** torch.linspace(-3, 1, 1000) # 1000 шагов: lr растёт от 0.001 до 10, равномерно по логарифму
losses = []
for lr in lrs:
ix = torch.randint(0, len(Xtr), (32,), generator=g) # мини-батч: 32 случайных примера из train
loss = F.cross_entropy(forward(params, Xtr[ix]), Ytr[ix])
for p in params:
p.grad = None
loss.backward()
for p in params:
p.data -= lr.item() * p.grad
losses.append(loss.item())
losses = torch.tensor(losses)
for k in range(0, 1000, 100): # 10 участков по 100 шагов
print(f"lr {lrs[k]:6.3f}…{lrs[k + 99]:6.3f}: средний loss {losses[k:k + 100].mean():6.3f}")
lr 0.001… 0.002: средний loss 3.399
lr 0.003… 0.006: средний loss 3.302
lr 0.006… 0.016: средний loss 3.121
lr 0.016… 0.040: средний loss 2.882
lr 0.040… 0.100: средний loss 2.606
lr 0.100… 0.250: средний loss 2.329
lr 0.253… 0.629: средний loss 2.188
lr 0.635… 1.582: средний loss 2.281
lr 1.597… 3.977: средний loss 5.849
lr 4.014…10.000: средний loss 25.586

Постройте plt.plot(torch.log10(lrs), losses) — получится характерная ложка. При lr меньше 0.01 модель почти не учится, до 0.6 loss падает всё быстрее, после 1 начинает расти, а после 1.6 обучение разваливается. Правило: берём lr в несколько раз меньше точки, где loss начинает расти. Мы возьмём 0.1, как Карпатый, и в последней четверти обучения уменьшим его в 10 раз, чтобы спокойно «досесть» в минимум.

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

Кривые обучения: переобучение и недообучение

Заголовок раздела «Кривые обучения: переобучение и недообучение»

Обучим модель 6000 шагов и будем каждые 1000 шагов печатать NLL на train и val:

@torch.no_grad()
def nll(params, X, Y):
return F.cross_entropy(forward(params, X), Y).item()
def train(params, X, Y, steps=6000, lr=0.1, weight_decay=0.0, batch_size=32, seed=1, log_every=0):
g = torch.Generator().manual_seed(seed)
for step in range(steps + 1):
ix = torch.randint(0, len(X), (batch_size,), generator=g)
loss = F.cross_entropy(forward(params, X[ix]), Y[ix])
for p in params:
p.grad = None
loss.backward()
cur_lr = lr if step < 0.75 * steps else lr / 10 # последние 25% шагов — шаг в 10 раз меньше
for p in params:
p.data -= cur_lr * (p.grad + weight_decay * p.data) # weight decay — L2-штраф (модуль 2.1)
if log_every and step % log_every == 0:
print(f"шаг {step:5}: train {nll(params, Xtr, Ytr):.3f} val {nll(params, Xva, Yva):.3f}")
return params
t0 = time.time()
params = train(init_params(), Xtr, Ytr, log_every=1000)
print(f"время: {time.time() - t0:.1f} с")
шаг 0: train 3.366 val 3.406
шаг 1000: train 1.849 val 2.526
шаг 2000: train 1.526 val 2.551
шаг 3000: train 1.300 val 2.631
шаг 4000: train 1.171 val 2.760
шаг 5000: train 1.083 val 2.815
шаг 6000: train 1.071 val 2.824
время: 5.9 с

Время зависит от процессора, остальные числа должны совпасть с точностью до последнего знака (на другой версии PyTorch возможны небольшие отличия).

Это учебник по переобучениюпереобучениеEN overfitting, только теперь видно, как оно развивается во времени. Train NLL падает до 1.07, а val NLL после ~1500 шагов (там было около 2.50) растёт до 2.82 — хуже биграммы с её 2.51. Модель с 4309 параметрами запоминает 194 обучающих имени. Признаки по кривым обучениякривая обученияEN learning curve:

Что видно на кривыхДиагнозЧто делать
train и val высокие и близки друг к другунедообучениенедообучениеEN underfitting: модели не хватает ёмкости или шаговбольше модель, дольше обучение, слабее регуляризация
train падает, val сначала падает, потом растётпереобучениерегуляризация, больше данных, остановиться раньше
train и val низкие, разрыв небольшойнормаможно пробовать модель больше

Остановить обучение в минимуме val — это ранняя остановкаранняя остановкаEN early stopping. Второй способ — штраф за большие веса, затухание весовзатухание весов (weight decay)EN weight decayНа каждом шаге веса умножаются на (1 − ηλ) — «притягиваются» к нулю. Для обычного SGD равносильно L2-регуляризации, для Adam — нет.: к градиенту каждого параметра добавляем λp\lambda p, и веса всё время понемногу тянутся к нулю. Это та же L2-регуляризация из модуля 2.1 и то же сглаживание из урока 4.1.2 в новой одежде: нулевые логиты — равномерное распределение. Ради простоты мы штрафуем все параметры; в больших моделях смещения и нормализации обычно не штрафуют.

for wd in [0.0, 0.003, 0.01, 0.03]:
p = train(init_params(), Xtr, Ytr, weight_decay=wd)
print(f"weight_decay {wd:<6} train {nll(p, Xtr, Ytr):.3f} val {nll(p, Xva, Yva):.3f}")
weight_decay 0.0 train 1.071 val 2.824
weight_decay 0.003 train 1.643 val 2.410
weight_decay 0.01 train 2.144 val 2.398
weight_decay 0.03 train 2.493 val 2.596

Вся таблица диагнозов в четырёх строках. Без штрафа — переобучение (разрыв 1.75). При λ=0.003\lambda = 0.003 и 0.010.01 val лучше биграммы на 0.1. При λ=0.03\lambda = 0.03 модель зажата: train 2.49 почти как val — недообучение, и она снова проигрывает биграмме. Дальше берём λ=0.003\lambda = 0.003.

Гиперпараметры: окно, эмбеддинг, скрытый слой

Заголовок раздела «Гиперпараметры: окно, эмбеддинг, скрытый слой»

Теперь разберём, как три размера модели влияют на качество. Каждая строка — отдельная модель, обученная заново:

def experiment(block_size=3, n_embd=10, n_hidden=64, wd=0.003):
Xtr_, Ytr_ = build_dataset(train_names, block_size)
Xva_, Yva_ = build_dataset(val_names, block_size)
p = train(init_params(n_embd, n_hidden, block_size), Xtr_, Ytr_, weight_decay=wd)
n = sum(q.numel() for q in p)
print(f"окно {block_size}, эмбеддинг {n_embd:2}, скрытый {n_hidden:3}: {n:5} параметров, "
f"train {nll(p, Xtr_, Ytr_):.3f} val {nll(p, Xva_, Yva_):.3f}")
for b in [1, 2, 3, 5, 8]:
experiment(block_size=b)
for e in [2, 24]:
experiment(n_embd=e)
for h in [16, 256]:
experiment(n_hidden=h)
окно 1, эмбеддинг 10, скрытый 64: 3029 параметров, train 2.207 val 2.488
окно 2, эмбеддинг 10, скрытый 64: 3669 параметров, train 1.903 val 2.451
окно 3, эмбеддинг 10, скрытый 64: 4309 параметров, train 1.643 val 2.410
окно 5, эмбеддинг 10, скрытый 64: 5589 параметров, train 1.391 val 2.387
окно 8, эмбеддинг 10, скрытый 64: 7509 параметров, train 1.367 val 2.448
окно 3, эмбеддинг 2, скрытый 64: 2525 параметров, train 2.108 val 2.395
окно 3, эмбеддинг 24, скрытый 64: 7431 параметров, train 1.429 val 2.408
окно 3, эмбеддинг 10, скрытый 16: 1333 параметров, train 1.853 val 2.375
окно 3, эмбеддинг 10, скрытый 256: 16213 параметров, train 1.533 val 2.448

Как читать эту таблицу:

  • Окно 1 — это сглаженная биграмма. Val 2.49 против 2.51 у подсчёта: с одним символом контекста нейросети нечего добавить. Выигрыш появляется только с окном 2 и больше — его дают именно дальние символы.
  • Чем больше окно и модель, тем ниже train. Val сначала тоже падает, а потом снова растёт. На 194 именах у нас просто нет данных, чтобы научить модель пользоваться восемью символами контекста.
  • Не верьте разнице в сотые доли. В val всего 152 примера. Мы перезапустили конфигурацию «3, 10, 64» с тремя другими seed и получили val 2.358–2.382. Значит, разница между 2.375 и 2.410 — шум, а между 2.40 и 2.51 — настоящий эффект. Честное сравнение — среднее по нескольким seed. На маленьких данных об этом забывают чаще всего.

С эмбеддингом из двух чисел каждый символ — точка на плоскости, и можно посмотреть, что выучила таблица CC (val 2.395, почти не хуже, чем с десятью числами):

p2 = train(init_params(n_embd=2), Xtr, Ytr, weight_decay=0.003) # эмбеддинги из двух чисел — их можно нарисовать
C2 = p2[0].detach()
for ch in "аеиоя" + "нрстк" + ".йь":
print(ch, [round(v, 2) for v in C2[stoi[ch]].tolist()])
# Картинка: import matplotlib.pyplot as plt
# plt.scatter(C2[:, 0], C2[:, 1]); [plt.text(*C2[i].tolist(), itos[i]) for i in range(V)]; plt.show()
а [1.74, 0.53]
е [1.15, -0.49]
и [1.39, -1.72]
о [1.17, -0.42]
я [1.3, 1.21]
н [-0.53, 0.12]
р [-0.4, 0.29]
с [-0.23, 0.26]
т [-0.51, 0.11]
к [-0.23, 0.07]
. [0.52, 2.52]
й [0.14, 1.08]
ь [0.34, -0.0]
координата 1координата 2д г р б с к н т ф з ж п«.»авеийлмоухчшыьэюяё● гласные● согласные, «й», «ь»● начало/конец имени
Двумерные эмбеддинги всех 31 символа после обучения. Никто не говорил модели, что такое гласные, а они собрались справа; согласные — слева; точка (начало и конец имени) стоит отдельно.

Модель сама разделила гласные и согласные: по первой координате все гласные справа (больше 0.2), почти все согласные слева. Логика простая: после гласной обычно идёт согласная и наоборот, поэтому символам с похожей «ролью» выгодно иметь похожие векторы. «А» и «я» сдвинуты вверх, к точке: на них заканчивается большинство женских и уменьшительных имён. Это и есть обобщение, которого нет у таблицы: всё, что модель узнала про контексты с «а», частично переносится на контексты с «я».

То же самое Бенджио получил для слов, а в модуле 4.3 вы увидите знаменитое «король − мужчина + женщина ≈ королева» — ту же идею в масштабе словаря.

PyTorch посчитал градиенты сам. Посчитаем их и мы: это лучший способ понять, что происходит внутри, и это задание урока. Обозначения для батча из BB примеров: E=C[X]E = C[X] формы (B,3,10)(B, 3, 10), xx — склейка формы (B,30)(B, 30), h=tanh⁡(xW1+b1)h = \tanh(x W_1 + b_1), z=hW2+b2z = h W_2 + b_2 — логиты, P=softmax(z)P = \mathrm{softmax}(z), loss =−1B∑ilog⁡Pi,yi= -\frac{1}{B}\sum_i \log P_{i, y_i}. Идём от конца к началу по цепному правилу, как в уроке 3.1.2.

1. Через softmax и cross-entropy. Самая красивая формула курса:

∂ loss∂z=1B(P−onehot(y)).\frac{\partial\, \text{loss}}{\partial z} = \frac{1}{B}\big(P - \mathrm{onehot}(y)\big).

Словами: из вероятностей вычитаем единицу на месте правильного символа. Пример: логиты (2,1,0)(2, 1, 0), правильный класс 1. Softmax даёт (0.665,0.245,0.090)(0.665, 0.245, 0.090), градиент (0.665,−0.755,0.090)(0.665, -0.755, 0.090). Неправильные классы толкаются вниз тем сильнее, чем больше модель в них верила; правильный — вверх на 1−py1 - p_y. Сумма градиента всегда 0.

2. Выходной слой. Как у любого линейного слоя: dW2=h⊤dzdW_2 = h^\top dz, db2=∑idzidb_2 = \sum_i dz_i, dh=dz W2⊤dh = dz\, W_2^\top.

3. Через tanh. tanh⁡′(a)=1−tanh⁡2(a)\tanh'(a) = 1 - \tanh^2(a), а tanh⁡(a)\tanh(a) у нас уже есть — это hh. Поэтому d(pre)=dh⊙(1−h2)d(\text{pre}) = dh \odot (1 - h^2). Если нейрон насытился (h=0.99h = 0.99), множитель 1−0.98=0.021 - 0.98 = 0.02 почти глушит градиент — то самое насыщение из урока 3.4.1.

4. Скрытый слой. dW1=x⊤d(pre)dW_1 = x^\top d(\text{pre}), db1=∑id(pre)idb_1 = \sum_i d(\text{pre})_i, dx=d(pre) W1⊤dx = d(\text{pre})\, W_1^\top. Склейка — просто перекладка чисел, поэтому dEdE получается из dxdx обратным reshape в форму (B,3,10)(B, 3, 10).

5. Через выборку из таблицы. Строка C[j]C[j] участвовала в прямом проходе везде, где в окне стоял символ jj. По правилу накопления градиентов (+= из урока 3.2.2) её градиент — сумма всех этих вкладов:

dC[j]=∑(i,k) : Xik=jdEik.dC[j] = \sum_{(i, k)\,:\, X_{ik} = j} dE_{ik}.

Такая операция «разложить градиенты по строкам, складывая совпадения» называется scatter-addscatter-add (сложение по индексам)EN scatter-addОперация «разложить значения по строкам массива, складывая совпадающие индексы». Так собирается градиент таблицы эмбеддингов. В NumPy — np.add.at, в PyTorch — index_add_; запись dC[X] += dE в NumPy теряет повторы.. И тут NumPy ставит ловушку:

import numpy as np
dC = np.zeros(4) # градиент таблицы из 4 строк (для простоты — по одному числу в строке)
X = np.array([0, 0, 2]) # символ 0 встретился в окне дважды
dE = np.array([1.0, 1.0, 1.0]) # градиенты, пришедшие к трём позициям окна
dC[X] += dE
print("dC[X] += dE ->", dC)
dC = np.zeros(4)
np.add.at(dC, X, dE)
print("np.add.at ->", dC)
dC[X] += dE -> [1. 0. 1. 0.]
np.add.at -> [2. 0. 1. 0.]

dC[X] += dE читает строки, прибавляет и записывает обратно, поэтому для повторяющегося индекса выживает только последняя запись. Окно «…» в начале каждого имени содержит символ 0 трижды, так что ошибка не редкая, а систематическая. В PyTorch обратный проход через C[X] и через nn.Embedding складывает вклады правильно — так же, как index_add_.

Как проверить себя. Численный градиентпроверка градиентаEN gradient checkСравнение аналитического градиента с численным (центральные разности) по относительной ошибке. Главный способ найти баг в backward.: чуть сдвигаем один параметр на ±ε\pm\varepsilon и смотрим, как изменился loss, L(p+ε)−L(p−ε)2ε\frac{L(p + \varepsilon) - L(p - \varepsilon)}{2\varepsilon}. Это медленно (два прямых прохода на каждое число), но надёжно. Если относительная ошибка между аналитическим и численным градиентом порядка 10−710^{-7}, всё верно; если 10−210^{-2} — где-то ошибка. Тесты задания проверяют все пять градиентов именно так.

Итоговая модель — окно 3, эмбеддинг 10, слой 64, λ=0.003\lambda = 0.003. Наконец открываем test:

params = train(init_params(), Xtr, Ytr, weight_decay=0.003) # итоговая модель: окно 3, эмбеддинг 10, слой 64
# биграмма из урока 4.1.2 на тех же 194 обучающих именах; alpha = 0.2 — почти оптимум на val
N = torch.zeros((V, V))
for name in train_names:
for a, b in zip("." + name, name + "."):
N[stoi[a], stoi[b]] += 1
P = (N + 0.2) / (N + 0.2).sum(dim=1, keepdim=True)
def bigram_nll(names):
lp = [torch.log(P[stoi[a], stoi[b]]) for n in names for a, b in zip("." + n, n + ".")]
return -torch.stack(lp).mean().item()
print(f"биграмма: val {bigram_nll(val_names):.3f} test {bigram_nll(test_names):.3f}")
print(f"MLP: val {nll(params, Xva, Yva):.3f} test {nll(params, Xte, Yte):.3f}")
g = torch.Generator().manual_seed(42)
samples = []
for _ in range(12):
context, name = [0, 0, 0], ""
while True:
with torch.no_grad():
probs = F.softmax(forward(params, torch.tensor([context])), dim=1)
ix = torch.multinomial(probs, 1, generator=g).item()
if ix == 0:
break
name += itos[ix]
context = context[1:] + [ix] # сдвигаем окно, как при построении датасета
samples.append(name)
print(" ".join(samples))
print("из них есть в обучающих именах:", [s for s in samples if s in train_names])
биграмма: val 2.514 test 2.321
MLP: val 2.410 test 2.140
вем дартя люза олина валена марисилёфов настина ладилььилисн елестина янисона холёгнарозя тама
из них есть в обучающих именах: []

MLP лучше биграммы и на val (на 0.10), и на test (на 0.18). Разница в NLL выглядит скромной, но в perplexity это e2.32≈10.2e^{2.32} \approx 10.2 против e2.14≈8.5e^{2.14} \approx 8.5 равновероятных вариантов на символ. На test NLL у обеих моделей ниже, чем на val: в test попали более «лёгкие» имена. Поэтому сравнивать модели можно только на одном и том же наборе.

В сэмплах появились «олина», «валена», «настина», «елестина», «тама» — новые, но звучащие по-русски имена: модель помнит, что было три буквы назад. Хватает и мусора («ладилььилисн»): 194 имени — очень мало, у Карпатого их 32 тысячи.

Главная слабость: окно фиксированной длины

Заголовок раздела «Главная слабость: окно фиксированной длины»

Модель видит ровно block_size символов. Про «марисилёфов» она не знает, что имя уже длиной в одиннадцать букв и пора заканчивать: в окне только «фов». Увеличить окно до 20 нельзя дёшево. Параметры W1W_1 растут линейно с окном, каждая позиция окна получает свои веса, и знание «после согласной обычно идёт гласная», выученное для позиции 3, надо заново учить для позиции 7. Для слов и длинных текстов это тупик.

Лечат двумя способами, и оба мы разберём:

  • Рекуррентная сеть (следующий урок) читает текст по одному токену и хранит всё прочитанное в векторе состояния фиксированного размера. Окна нет, но всю историю приходится сжимать в один вектор.
  • Внимание (модуль 4.4) не сжимает историю: каждый токен напрямую смотрит на все предыдущие и сам выбирает нужные. Это путь GPT.

Есть и промежуточный вариант — склеивать контекст постепенно, парами, как в WaveNet; ему посвящена пятая лекция makemore. В современных LLM он не прижился, но посмотреть полезно.

Соберите ту же модель на чистом NumPy — без autograd, с ручным backprop. Задание проверит построение датасета, устойчивую cross-entropy, все пять градиентов (численно), scatter-add для таблицы эмбеддингов и то, что после 1500 шагов SGD ваша модель обгоняет биграмму на валидации.

MLP-модель Бенджио на NumPy с ручным backpropnumpy · нужен интернет7 тестов
import math
import random

import numpy as np

# 242 русских имени: полные, уменьшительные и несколько старинных
NAMES = """
александр алексей альберт анатолий андрей антон аркадий арсений артём артур богдан борис вадим
валентин валерий василий вениамин виктор виталий владимир владислав всеволод вячеслав гавриил
геннадий георгий герман глеб григорий давид даниил демид денис дмитрий добрыня евгений егор
емельян ефим захар иван игнат игорь илья иннокентий ипполит кирилл клим константин кузьма
лаврентий лев леонид лука макар максим марк матвей мирон михаил назар никита никифор николай
олег остап павел платон прохор пётр родион роман ростислав руслан савва савелий святослав семён
сергей станислав степан тарас тимофей тимур тихон трофим фёдор филипп эдуард эмиль юрий яков ян
ярослав ада агата александра алёна алина алиса алла анастасия ангелина анжелика анна антонина
арина валентина валерия варвара василиса вера вероника виктория галина дарья диана ева евгения
екатерина елена елизавета есения жанна зинаида злата зоя инна ирина камила карина кира клавдия
кристина ксения лада лариса лидия лилия любовь людмила майя маргарита марина мария милана
мирослава надежда наталья нина нонна оксана олеся ольга полина раиса регина римма роза сабина
светлана серафима снежана софия стефания таисия тамара татьяна ульяна эвелина элина эмилия
юлиана юлия яна ярослава саша маша даша миша коля петя ваня катя лена оля таня наташа женя дима
серёжа лёша андрюша настя юля аня вова витя гриша паша костя ксюша люба надя галя зина тома соня
лиза поля варя федя гоша стёпа тёма слава толя валя рома лёва боря глаша
фаина эльвира ника мила аксинья дарина марфа пелагея ефросинья прасковья евдокия устинья
лукерья фрол фома гордей елисей мстислав любомир велимир
""".split()

# ---------- Готовые функции: словарь, разбиение, инициализация ----------

def build_vocab(names):
    """stoi["."] == 0, буквы по алфавиту получают номера 1, 2, 3, … (как в уроке 4.1.2)."""
    chars = sorted(set("".join(names)))
    stoi = {ch: i + 1 for i, ch in enumerate(chars)}
    stoi["."] = 0
    itos = {i: ch for ch, i in stoi.items()}
    return stoi, itos


def split_names(names):
    """Перемешиваем с seed=0 и делим: 24 имени — val, 24 — test, остальные 194 — train."""
    names = names[:]
    random.Random(0).shuffle(names)
    return names[48:], names[:24], names[24:48]


def init_params(V, block_size=3, n_embd=10, n_hidden=64, seed=0):
    """Случайные начальные параметры MLP-модели. Возвращает словарь numpy-массивов."""
    rng = np.random.default_rng(seed)
    fan_in = block_size * n_embd
    return {
        "C": rng.normal(0, 1, (V, n_embd)),                          # таблица эмбеддингов
        "W1": rng.normal(0, 1, (fan_in, n_hidden)) / math.sqrt(fan_in),
        "b1": np.zeros(n_hidden),
        "W2": rng.normal(0, 1, (n_hidden, V)) * 0.1,                 # маленькие логиты → loss ≈ ln V
        "b2": np.zeros(V),
    }


# ---------- Ваша часть ----------

def build_dataset(names, stoi, block_size=3):
    """Превращает имена в обучающие пары (контекст → следующий символ).

    Контекст — номера block_size предыдущих символов; в начале имени он заполнен нулями («.»).
    Имя «ян» при block_size=3 даёт три пары: [0,0,0]→я, [0,0,я]→н, [0,я,н]→«.».
    Возвращает X — np.array формы (n, block_size) и Y — np.array формы (n,), оба целочисленные.
    """
    # TODO: ваш код
    ...


def forward(params, X):
    """Прямой проход. Возвращает пару (logits, cache).

    emb = C[X] (форма B × block × n_embd) → склейка окна x (B × block·n_embd)
    → h = tanh(x @ W1 + b1) → logits = h @ W2 + b2 (B × V).
    В cache положите всё, что понадобится в backward: например, (X, x, h).
    """
    # TODO: ваш код
    ...


def cross_entropy(logits, Y):
    """Средний NLL правильных символов: mean(logsumexp(logits) − logits[i, Y[i]]).

    Считайте устойчиво: вычтите максимум строки перед exp, иначе при логите 1000 будет overflow.
    Возвращает число float.
    """
    # TODO: ваш код
    ...


def backward(params, cache, logits, Y):
    """Ручной backprop. Возвращает словарь градиентов с теми же ключами, что params.

    dlogits = (softmax(logits) − one_hot(Y)) / B
    dW2 = hᵀ·dlogits, db2 = сумма dlogits по батчу, dh = dlogits·W2ᵀ
    dpre = dh · (1 − h²)  — производная tanh
    dW1 = xᵀ·dpre, db1 = сумма dpre, dx = dpre·W1ᵀ → обратно в форму (B, block, n_embd)
    dC: строка C[i] получает сумму градиентов ВСЕХ позиций, где стоял символ i.
        Обычное dC[X] += demb теряет повторы — нужен np.add.at.
    """
    # TODO: ваш код
    ...


def train(params, X, Y, steps=1500, batch_size=32, lr=0.1, weight_decay=0.003, seed=0):
    """Мини-батчевый SGD. Меняет params на месте и возвращает список loss по шагам.

    На каждом шаге:
      idx = rng.integers(0, len(X), batch_size), где rng = np.random.default_rng(seed) создан один раз;
      прямой проход по X[idx], loss, backward;
      для каждого параметра: p -= lr * (grad + weight_decay * p)   (L2-штраф, как в уроке).
    Меняйте массивы на месте (params[k] -= …), а не создавайте новый словарь.
    """
    # TODO: ваш код
    ...


# ---------- Кнопка «Запустить»: обучаем и сравниваем с биграммой ----------

def bigram_nll(train_names, eval_names, stoi, alpha=0.2):
    """Биграмма из урока 4.1.2 со сглаживанием alpha — ориентир, который надо обогнать."""
    V = len(stoi)
    N = np.zeros((V, V))
    for w in train_names:
        for a, b in zip("." + w, w + "."):
            N[stoi[a], stoi[b]] += 1
    P = (N + alpha) / (N + alpha).sum(axis=1, keepdims=True)
    return -np.mean([math.log(P[stoi[a], stoi[b]]) for w in eval_names for a, b in zip("." + w, w + ".")])


def sample(params, itos, block_size=3, n=8, seed=42):
    rng = np.random.default_rng(seed)
    out = []
    for _ in range(n):
        context, name = [0] * block_size, ""
        while len(name) < 20:
            logits, _ = forward(params, np.array([context]))
            p = np.exp(logits[0] - logits[0].max())
            ix = int(rng.choice(len(p), p=p / p.sum()))
            if ix == 0:
                break
            name += itos[ix]
            context = context[1:] + [ix]
        out.append(name)
    return out


def demo():
    stoi, itos = build_vocab(NAMES)
    train_names, val_names, test_names = split_names(NAMES)
    Xtr, Ytr = build_dataset(train_names, stoi)
    Xva, Yva = build_dataset(val_names, stoi)
    params = init_params(len(stoi))
    print("loss до обучения:", round(cross_entropy(forward(params, Xtr)[0], Ytr), 3), " ln V =", round(math.log(len(stoi)), 3))
    losses = train(params, Xtr, Ytr)
    print("loss последних 100 батчей:", round(float(np.mean(losses[-100:])), 3))
    print("val NLL: MLP", round(cross_entropy(forward(params, Xva)[0], Yva), 3),
          "  биграмма", round(bigram_nll(train_names, val_names, stoi), 3))
    print("Новые имена:", sample(params, itos))


try:
    demo()
except Exception as e:  # пока функции не дописаны
    print("Допишите функции:", type(e).__name__, e)

Когда тесты пройдут:

  1. Замените в своём backward строку np.add.at(dC, X, demb) на dC[X] += demb и запустите тесты. Какой упадёт и почему именно он? Насколько хуже станет val NLL после обучения?
  2. Поставьте lr=1.0 и lr=3.0 в train. Совпадает ли граница, где обучение разваливается, с графиком lr finder?
  3. Обучите модель с block_size=1 и сравните val NLL с биграммой. Объясните результат.

Мы измерили время одного шага обучения этой модели на тестовой машине, в один поток (это оценка: у вас числа будут другими, но соотношение похожим):

БатчNumPy (ваше решение)PyTorch, CPU
320.24 мс0.77 мс
409623 мс7.8 мс

На крошечном батче NumPy быстрее: у PyTorch на каждую операцию уходят накладные расходы — диспетчеризация, запись графа для autograd. На большом батче выигрывает PyTorch: реальные вычисления растут, накладные расходы — нет, а np.add.at в NumPy медленный. Отсюда же torch.set_num_threads(1) в начале скрипта: раздавать работу десяткам потоков на матрицах 32 × 64 дольше, чем посчитать её в одном.

Проверь себя6 вопросов
1 Модель с окном 4, эмбеддингом 8, скрытым слоем 100 и словарём 31. Сколько у неё параметров (с учётом смещений)?
2 Логиты (2,1,0)(2, 1, 0), правильный класс — 0, батч из одного примера. Чему равна компонента градиента loss по первому логиту? Округлите до трёх знаков.
3 Train NLL падает с 1.85 до 1.07, а val NLL растёт с 2.53 до 2.82. Что помогает в такой ситуации?Можно выбрать несколько ответов
4 Почему градиент таблицы эмбеддингов в NumPy собирают через np.add.at(dC, X, dE), а не dC[X] += dE?
5 Модель с окном 1 дала val 2.488, а сглаженная биграмма — 2.514. Почему MLP с окном 1 почти не лучше биграммы?
6 Чем принципиально отличаются способы, которыми RNN и внимание преодолевают фиксированное окно MLP?
  • MLP-модель Бенджио: эмбеддинги символов окна из общей таблицы CC → склейка → tanh⁡\tanh-слой → softmax по словарю. 4309 параметров вместо почти миллиона клеток таблицы 4-грамм, и знание переносится между похожими символами.
  • Данные делим по именам на train / val / test; гиперпараметры — по val, test — один раз в конце. Learning rate находим lr finder: берём значение в несколько раз меньше точки, где loss начинает расти.
  • Кривые train/val показывают переобучение (разрыв растёт) и недообучение (обе кривые высоко). Лечим weight decay и ранней остановкой; разницу меньше шума между seed не принимаем всерьёз.
  • Backprop: dz=(P−onehot)/Bdz = (P - \mathrm{onehot})/B, через tanh — множитель 1−h21 - h^2, градиент таблицы эмбеддингов — scatter-add (np.add.at, index_add_). Проверка — численный градиент.
  • MLP обходит биграмму (test NLL 2.14 против 2.32), но окно фиксированной длины — её потолок.

Дальше: убираем окно — рекуррентная сеть читает имя или текст целиком, а seq2seq переводит одну последовательность в другую и упирается в «бутылочное горлышко» — Рекуррентная языковая модель и seq2seq на практике.