Перейти к содержимому
ПереводыАндрей Карпатый

Zero to Hero, лекция 3: MLP-модель имён по Бенджио (makemore, часть 2)

Подробный конспект лекции Карпатого: от таблицы биграмм к нейросетевой языковой модели Бенджио 2003 года. Эмбеддинги символов, скрытый слой, устройство тензоров, F.cross_entropy, мини-батчи, поиск learning rate и разбиение на train/dev/test.

  • Биграммная таблица не растёт: с каждым символом контекста строк становится в 27 раз больше. Карпатый переходит к модели из статьи Бенджио и соавторов 2003 года. Каждый символ получает короткий обучаемый вектор — эмбеддингэмбеддинг, векторное представлениеEN embedding. Векторы трёх символов окна склеиваются, идут через скрытый слой с tanh, а на выходе — 27 логитов и softmax.
  • Попутно — внутренности torch.Tensor. Данные всегда лежат в памяти одной лентой, а view лишь меняет способ на неё смотреть. Поэтому склейка векторов через view бесплатна, а через torch.cat — нет.
  • Почему вместо самописного softmax и NLL берут F.cross_entropy: меньше памяти, проще обратный проход и защита от переполнения экспоненты.
  • Основы машинного обучения на живом примере: переобучение одного батча как проверка кода, мини-батчимини-батчEN mini-batchНебольшая случайная порция примеров, по которой считается одна оценка градиента и делается один шаг., поиск learning rate по графику, его затухание, разбиение на train/dev/test, недообучениенедообучениеEN underfitting и что с ним делать.
  • Результат — 2.17 на валидации против 2.45 у биграмм. На двумерных эмбеддингах видно, что сеть сама сгруппировала гласные, а q и служебная точка оказались в стороне.

Курс повторяет лекцию на русских именах в уроке 4.1.4 «Нейросетевая языковая модель: MLP по Бенджио (2003)». Удобнее всего сначала посмотреть лекцию, потом пройти урок: он медленнее объясняет кривые обучения и добавляет раздел про ручной backprop. Перед лекцией полезно освежить softmax и его ловушку с переполнением (1.3.2) и мини-батчевый спуск (1.4.1). Про эмбеддинги слов в духе статьи Бенджио подробно рассказывает урок 4.3.1.

Новичку можно бегло посмотреть главу про устройство тензора (18:38) — главное запомнить, что view не копирует данные. Главы про learning rate (45:41) и про разбиение данных (53:22) пропускать не надо: на них держится вся дальнейшая практика.

Код лекции — ноутбук makemore_part2_mlp.ipynb (MIT), данные names.txt — в репозитории karpathy/makemore. Статья: Bengio et al., A Neural Probabilistic Language Model (JMLR, 2003). Китайская учебная версия с переведёнными пояснениями — 003_makemore_MLP.

Таймкоды — из глав в описании видео.

ВремяГлава в оригиналеО чём
0:00introпочему таблица биграмм тупиковая
1:48Bengio et al. 2003 (MLP language model) paper walkthroughразбор статьи
9:07(re-)building our training datasetокно из трёх символов
12:21implementing the embedding lookup tableтаблица C и индексация
18:38implementing the hidden layer + internals of torch.Tensor: storage, viewsскрытый слой, view, хранилище тензора
29:17implementing the output layerвыходной слой
29:58implementing the negative log likelihood lossNLL вручную
32:22summary of the full networkвся сеть целиком
32:52introducing F.cross_entropy and whyзачем встроенная функция
37:58implementing the training loop, overfitting one batchцикл обучения, переобучение 32 примеров
41:30training on the full dataset, minibatchesвесь набор, мини-батчи
45:41finding a good initial learning rateпоиск learning rate
53:22splitting up the dataset into train/val/test splits and whyразбиение данных
1:00:51experiment: larger hidden layerшире скрытый слой
1:05:28visualizing the character embeddingsкартинка эмбеддингов
1:07:17experiment: larger embedding sizeэмбеддинги длиннее
1:11:50summary of our final code, conclusionитоговый код, 2.17 на валидации
1:13:24sampling from the modelгенерация имён
1:14:59google collab (new!!) notebook advertisementноутбук в Colab

Биграмма видит один символ, и имена выходят плохими. Хочется дать больше контекста, но таблица растёт экспоненциально. При одном символе контекстов 27, при двух — 272=72927^2 = 729, при трёх — почти 20 тысяч. Строк слишком много, а примеров на каждую слишком мало. Нужна модель, у которой число параметров не взрывается с длиной контекста.

Карпатый оговаривается: это не первая работа, где нейросеть предсказывает следующий токен, но очень влиятельная и хорошо написанная. Её часто цитируют как символ самой идеи. В статье модель словесная: словарь около 17 тысяч слов, у каждого слова — вектор признаков длины, например, 30. Сначала векторы случайны, а при обучении backprop двигает их в этом пространстве. Слова с похожим смыслом оказываются рядом.

Зачем это нужно, показывает пример из статьи. Модели нужно продолжить фразу «a dog was running in a …», а такой фразы в данных не было. Зато была «the dog was running in a …». Если сеть выучила, что «a» и «the» взаимозаменяемы, их векторы окажутся рядом, и знание перенесётся. Так же можно перенести знания между «cat» и «dog» или «walking» и «running». Таблица так не умеет: для неё каждый контекст — отдельная строка.

flowchart LR
X["3 номера<br/>символов"] --> C["таблица C<br/>общая для всех позиций"]
C --> E["3 вектора<br/>склеены в один"]
E --> H["скрытый слой<br/>tanh"]
H --> O["выходной слой<br/>27 логитов"]
O --> S["softmax →<br/>P(следующий)"]
Модель Бенджио в символьном варианте лекции. В статье на входе слова, а выходной слой на 17 тысяч нейронов — самая дорогая часть.

Устройство сети по статье:

  1. Три предыдущих слова — номера от 0 до 16 999.
  2. Таблица C размером 17000×3017000 \times 30: номер выбирает строку. Таблица одна для всех трёх позиций.
  3. Три вектора дают входной слой из 90 чисел.
  4. Скрытый слой полносвязный, с tanh. Его размер — гиперпараметргиперпараметрEN hyperparameterНастройка, которую не учат градиентом, а выбирают по валидации — степень полинома, λ, learning rate, число слоёв.: выбор проектировщика, его подбирают экспериментами.
  5. Выходной слой — 17 тысяч нейронов, по логиту на слово. Здесь больше всего вычислений.
  6. Softmax и максимизация лог-правдоподобия правильного слова. Учатся все параметры: оба слоя и таблица C.

Пунктирные стрелки на схеме из статьи — прямые связи со входа на выход. Это вариант модели, который Карпатый не реализует.

block_size — длина контекста: сколько символов смотрим, чтобы предсказать следующий. В начале имени окно заполнено точками, затем сдвигается на символ за шагом. Из emma получаются пять примеров: ... → e, ..e → m, .em → m, emm → a, mma → .. Код из ноутбука лекции (MIT), комментарии наши:

block_size = 3 # длина контекста: сколько символов видим перед следующим
def build_dataset(words):
X, Y = [], []
for w in words:
context = [0] * block_size # в начале имени окно из одних '.'
for ch in w + '.':
ix = stoi[ch]
X.append(context) # вход: три предыдущих символа
Y.append(ix) # ответ: следующий символ
context = context[1:] + [ix] # сдвигаем окно: отрезали старый, добавили новый
return torch.tensor(X), torch.tensor(Y)

Пока код пишется, Карпатый берёт только первые пять имён — это 32 примера. Окно можно сделать любым: при block_size = 10 модель видела бы десять символов.

Для 27 символов не нужно 30 измерений. Карпатый начинает с крошечного пространства: C = torch.randn((27, 2)). Эмбеддинг символа 5 можно получить двумя способами:

  • просто индексацией: C[5];
  • как в прошлой лекции: F.one_hot(torch.tensor(5), num_classes=27).float() @ C.

Результат одинаковый — умножение на one-hot выбирает строку. По дороге Карпатый нарочно ловит две ошибки: one_hot требует тензор, а не число, а целочисленный тензор нельзя умножить на вещественный, нужен .float(). Вывод: таблицу эмбеддингов можно считать первым слоем сети — линейным, без нелинейности, с матрицей весов C и one-hot на входе. Но индексация намного быстрее, поэтому дальше используется она.

Индексация в PyTorch гибкая. Можно брать строки списком (C[[5, 6, 7]]), тензором, с повторами, и даже двумерным тензором номеров: C[X] для X формы 32×332 \times 3 даёт тензор 32×3×232 \times 3 \times 2 — для каждого номера своя строка таблицы. В курсе это слой эмбеддинговслой эмбеддингов (таблица эмбеддингов)EN embedding layerМатрица V × d; по номеру токена возвращает его строку. Эквивалентно one-hot × матрица. В PyTorch — nn.Embedding.; в PyTorch — nn.Embedding.

Скрытому слою нужен вход формы 32×632 \times 6: три двумерных вектора подряд. А у нас 32×3×232 \times 3 \times 2. Карпатый показывает три способа и заодно учит, что в огромной библиотеке PyTorch обычно есть несколько путей, и некоторые лучше:

import torch
emb = torch.randn(32, 3, 2) # 32 примера × 3 символа × 2 числа
# 1) руками склеить три среза — не обобщается на другой block_size
x1 = torch.cat([emb[:, 0, :], emb[:, 1, :], emb[:, 2, :]], 1)
# 2) unbind режет по оси 1 на список срезов любой длины
x2 = torch.cat(torch.unbind(emb, 1), 1)
# 3) view — просто посмотреть на те же числа как на 32×6
x3 = emb.view(32, 6) # или emb.view(-1, 6): PyTorch сам вычислит 32
print((x1 == x3).all().item(), (x2 == x3).all().item()) # True True
print(x3.data_ptr() == emb.data_ptr()) # True: view использует ту же память
print(x1.data_ptr() == emb.data_ptr()) # False: cat выделил новую

Почему view бесплатен. У каждого тензора есть хранилище (storage) — числа одной лентой, как они лежат в памяти. Форма, шаги (strides) и смещение — лишь служебные атрибуты: они говорят, как читать ленту в виде многомерного массива. view меняет только эти атрибуты, ничего не копируя. Так 18 чисел можно увидеть как 2×92 \times 9, 9×29 \times 2 или 3×3×23 \times 3 \times 2. А torch.cat всегда создаёт новый тензор с новой памятью. Подробнее Карпатый советует блог-пост Эдварда Янга «PyTorch internals».

Скрытый слой — h = torch.tanh(emb.view(-1, 6) @ W1 + b1) при W1 формы 6×1006 \times 100. Здесь стоит проверить broadcasting: (32,100)+(100,)(32, 100) + (100,). Вектор смещения дополняется до строки 1×1001 \times 100 и копируется вниз. Каждому примеру прибавляется одно и то же смещение — то, что нужно. Карпатый советует такие места проверять всегда.

Выходной слой: W2 формы 100×27100 \times 27, b2 из 27 чисел, логиты 32×2732 \times 27. Дальше, как в прошлой лекции: экспонента, нормировка, выбор вероятностей правильных символов, минус среднее логарифма. Со случайными весами loss около 17. Всего параметров в этой версии около 3400.

Затем эти строки заменяются одной: loss = F.cross_entropy(logits, Y). Число то же, но Карпатый называет три причины никогда не писать это вручную на практике:

  1. Память и скорость прямого прохода. Ручная версия создаёт промежуточные тензоры (счётчики, вероятности). PyTorch объединяет операции в одно ядро (fused kernel) и лишнего не хранит.
  2. Обратный проход проще. Как с tanh в micrograd: если продифференцировать составное выражение целиком, формула часто сильно упрощается. Для cross-entropy она совсем короткая — её выводят в пятой лекции.
  3. Численная устойчивость. Экспонента большого логита не помещается в float. Но к логитам можно прибавить любую константу — softmax не изменится. Поэтому PyTorch вычитает из логитов максимум: самый большой становится нулём, остальные отрицательными, и переполнения нет.
import torch
import torch.nn.functional as F
logits = torch.tensor([-2.0, 3.0, -3.0, 0.0, 100.0]) # один логит очень большой
counts = logits.exp() # e^100 = inf во float32
print(counts / counts.sum()) # tensor([0., 0., 0., 0., nan]) — сломалось
print(F.softmax(logits, 0)) # почти [0, 0, 0, 0, 1] — встроенная версия вычитает максимум

Курс разбирает эту ловушку в разделе «Ловушка: переполнение» урока 1.3.2 и пишет устойчивый softmax руками.

7. Обучение: переобучить один батч, потом мини-батчи

Заголовок раздела «7. Обучение: переобучить один батч, потом мини-батчи»

Цикл обучения — тот же, что в micrograd: обнулить градиенты (p.grad = None), loss.backward(), шаг p.data += -lr * p.grad. Не забудьте requires_grad = True у всех параметров. Сначала сеть учится на тех же 32 примерах из пяти имён, и loss быстро падает почти до нуля. Около 3400 параметров на 32 примера — сеть просто запоминает данные. Это переобучение одного батча, полезная проверка, что код вообще способен учиться. В курсе она входит в «три проверки перед долгим запуском».

Ровно ноль не получается, и Карпатый объясняет почему. Один и тот же вход ... (начало имени) встречается с ответами e, o, a, s — у пяти имён разные первые буквы. Предсказать их все сразу невозможно.

На всём наборе примеров уже около 228 тысяч, и каждый шаг идёт долго. Решение — мини-батчи: на каждом шаге выбираются 32 случайных примера (torch.randint), и прямой проход, обратный проход и шаг делаются только на них. Градиент по батчу — лишь оценка настоящего, но, по словам Карпатого, лучше сделать много шагов с приблизительным градиентом, чем мало шагов с точным. Обучение становится почти мгновенным. Раз loss одного батча шумный, Карпатый время от времени оценивает loss на всём наборе.

Шаг 0.1 был взят наугад. Карпатый показывает простой способ его найти:

  1. Грубо найти границы. При 0.001 loss почти не падает, при 1 падает, но скачет, при 10 обучение разваливается. Значит, хороший шаг где-то между 0.001 и 1.
  2. Сделать 1000 шагов, на каждом увеличивая learning rate. Растить его нужно экспоненциально, а не линейно: lre = torch.linspace(-3, 0, 1000) — показатели степени, lrs = 10**lre — сами шаги.
  3. Построить график loss от показателя степени. Слева loss почти не меняется, потом наступает «долина», справа начинается неустойчивость. Хороший шаг — в долине. У Карпатого она около 10−110^{-1}, то есть 0.1. Исходная догадка оказалась удачной, но теперь в ней есть уверенность.

Дальше — долгое обучение с 0.1, а когда loss выходит на плато — затухание (decay): шаг уменьшается в 10 раз и делается ещё немного шагов. Так получается около 2.3 — уже лучше биграммы (2.45). Карпатый признаёт, что это кустарно и в продакшене делают иначе, но порядок действий именно такой. В курсе этот приём называется lr finderпоиск learning rate по графику (lr finder)EN learning rate finder (LR range test)Короткий прогон, в котором learning rate растёт экспоненциально от шага к шагу. По графику «loss от lr» берут значение в несколько раз меньше того, где loss начинает расти., и урок 4.1.4 проходит его на своих данных.

Тут Карпатый себя поправляет: «лучше» пока сказать нельзя. Маленькая модель на 3400 параметров не может просто запомнить данные. Но чем больше параметров, тем лучше сеть умеет переобучатьсяпереобучениеEN overfitting: loss на обучающих данных падает почти до нуля, сеть выдаёт имена прямо из набора, а на новых именах loss большой.

Стандартное решение — три части данных:

ЧастьДоляДля чего
trainобучающая выборка (train)EN training set80%подбирать параметры градиентным спуском
dev / validationвалидационная выборка (val, dev)EN validation setНужна для выбора гиперпараметров и ранней остановки. Тест при этом не трогаем.10%подбирать гиперпараметры: размер слоёв, эмбеддингов, силу регуляризации
testтестовая выборка (test)EN test setСмотрим на неё один раз — в самом конце, чтобы честно оценить качество.10%одна финальная оценка в самом конце

Тест можно смотреть очень редко. Каждый раз, когда вы что-то узнаёте по тесту и меняете модель, вы немного учитесь и на нём. Имена перемешиваются с random.seed(42), из 32 033 имён получается 25 626 для обучения, 3203 для валидации и 3204 для теста. В ноутбуке это 182 441, 22 902 и 22 803 примера соответственно.

После обучения на train loss на train и на dev почти одинаковый, около 2.3. Значит, переобучения нет, сеть слишком мала — недообучение. Лечится оно ростом модели.

10. Эксперименты: шире слой, длиннее эмбеддинги

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

Скрытый слой 300 нейронов — около 10 тысяч параметров. Кривая loss «толстая»: батч из 32 примеров даёт много шума. Улучшение есть, но небольшое: после пары уменьшений шага — 2.23 на train и 2.24 на dev. Карпатый подозревает, что узкое место — двумерные эмбеддинги: 27 символов втиснуты в два числа.

Картинка эмбеддингов. Пока векторы двумерные, их можно нарисовать. У сети появилась структура: гласные a, e, i, o, u собрались рядом — сеть считает их похожими и почти взаимозаменяемыми. q стоит особняком, и служебная точка тоже далеко от остальных. Курс рисует ту же картинку для русских имён в уроке 4.1.4.

Эмбеддинги длины 10, скрытый слой 200 — 11 897 параметров. Карпатый строит график не loss, а log10(loss): логарифм сжимает «хоккейную клюшку» в начале обучения, и кривую лучше видно. Loss на train и dev начинают понемногу расходиться — сеть подходит к границе переобучения. Итоговый рецепт: 200 тысяч шагов, первые 100 тысяч с шагом 0.1, остальные с 0.01. Лучший результат Карпатого — 2.17 на валидации. В ноутбуке остались train 2.126 и dev 2.170. Вот этот код целиком (MIT), комментарии наши:

import random
import torch
import torch.nn.functional as F
random.seed(42)
random.shuffle(words)
n1, n2 = int(0.8 * len(words)), int(0.9 * len(words))
Xtr, Ytr = build_dataset(words[:n1]) # 80% — учим параметры
Xdev, Ydev = build_dataset(words[n1:n2]) # 10% — подбираем гиперпараметры
Xte, Yte = build_dataset(words[n2:]) # 10% — финальная оценка
g = torch.Generator().manual_seed(2147483647)
C = torch.randn((27, 10), generator=g) # эмбеддинги: 10 чисел на символ
W1 = torch.randn((30, 200), generator=g) # 3 символа × 10 = 30 входов, 200 нейронов
b1 = torch.randn(200, generator=g)
W2 = torch.randn((200, 27), generator=g) # 27 логитов
b2 = torch.randn(27, generator=g)
parameters = [C, W1, b1, W2, b2] # всего 11 897 чисел
for p in parameters:
p.requires_grad = True
for i in range(200000):
ix = torch.randint(0, Xtr.shape[0], (32,)) # мини-батч из 32 примеров
emb = C[Xtr[ix]] # (32, 3, 10)
h = torch.tanh(emb.view(-1, 30) @ W1 + b1) # (32, 200)
logits = h @ W2 + b2 # (32, 27)
loss = F.cross_entropy(logits, Ytr[ix])
for p in parameters:
p.grad = None
loss.backward()
lr = 0.1 if i < 100000 else 0.01 # ступенчатое затухание шага
for p in parameters:
p.data += -lr * p.grad

На CPU 200 тысяч шагов занимают несколько минут. Для проверки идеи хватит и 20 тысяч: у нас за 20 секунд вышло 2.30 на train и 2.31 на dev.

Генерация повторяет прошлую лекцию, только вход теперь — окно из трёх символов. Начинаем с ..., считаем эмбеддинги и скрытый слой, получаем вероятности через F.softmax (он, как и cross_entropy, защищён от переполнения), выбираем символ, сдвигаем окно — пока не выпадет .:

g = torch.Generator().manual_seed(2147483647 + 10)
for _ in range(20):
out, context = [], [0] * block_size # начинаем с '...'
while True:
emb = C[torch.tensor([context])] # (1, 3, 10): батч из одного примера
h = torch.tanh(emb.view(1, -1) @ W1 + b1)
probs = F.softmax(h @ W2 + b2, dim=1)
ix = torch.multinomial(probs, num_samples=1, generator=g).item()
context = context[1:] + [ix] # сдвигаем окно
out.append(ix)
if ix == 0: # выпал '.', имя закончилось
break
print(''.join(itos[i] for i in out))

В ноутбуке — carmahela, jhovi, kimrin, thil, halanna, jazhien, amerynci, nellara, kaleigh, ham. Заметно ближе к именам, чем у биграммы, но до хороших ещё далеко.

В конце Карпатый предлагает побить его результат и перечисляет «ручки»:

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

И советует прочитать статью Бенджио: 19 страниц, но теперь большая их часть будет понятна, а идей для улучшений там много. Последняя минута видео — реклама Colab: ноутбук можно запустить прямо в браузере, ничего не устанавливая.

Где курс объясняет то же самое и что есть сверх курса

Заголовок раздела «Где курс объясняет то же самое и что есть сверх курса»
Тема лекцииГде в курсеЧто в лекции сверх курса
Модель Бенджио, окно, эмбеддинги, скрытый слой4.1.4 (на русских именах)разбор самой статьи и примера «a dog / the dog»
Эмбеддинги как векторы смысла4.3.1—
view, хранилище, шаги (strides)0.3.1 (то же в NumPy), 4.1.4сравнение cat, unbind и view в PyTorch
F.cross_entropy, устойчивый softmax1.3.2, 3.3.2три причины в одном месте: память, обратный проход, переполнение
Переобучение одного батча3.3.3почему loss не доходит до нуля на неоднозначных входах
Мини-батчи, learning rate, затухание1.4.1, 4.1.4поиск шага руками по экспоненциальной сетке
Train / dev / test, недо- и переобучение4.1.4—
Ручной backprop через эту сеть4.1.4, раздел «Backprop руками»в лекции его нет — он в пятой лекции

Урок 4.1.4 идёт дальше лекции в двух местах: подробнее читает кривые обучения и добавляет weight decay. Обратите внимание на масштаб: в курсе 242 русских имени, у Карпатого — 32 тысячи. Поэтому в курсе переобучение наступает гораздо раньше.

Упражнения из описания видео (копия с пояснениями на китайском — 003_makemore_MLP/MLP_exercises.ipynb в репозитории karpathy-zero-to-hero-zh-transcripts):

  1. Подберите гиперпараметры так, чтобы побить лучший результат Карпатого на валидации.
  2. Инициализация в лекции небрежная. Какой loss был бы, если бы на старте все 27 вероятностей были одинаковыми? (Подсказка: −ln⁡(1/27)-\ln(1/27).) Какой loss получается на самом деле? Подберите инициализацию так, чтобы стартовый loss был близок к идеальному. Это тема следующей лекции.
  3. Прочитайте статью Бенджио, реализуйте любую идею оттуда (например, прямые связи со входа на выход) и проверьте, помогла ли она.

И ещё несколько наших:

  1. Проверьте сами, что C[5] и F.one_hot(torch.tensor(5), 27).float() @ C совпадают.
  2. Найдите learning rate своим графиком для block_size = 5. Сдвинулась ли «долина»?
  3. Обучите модель на русских именах из урока 4.1.2 и сравните loss на валидации с английскими. Учтите, что данных в сто раз меньше.
ENRU
multilayer perceptron (MLP)многослойный перцептрон
context length, block_sizeдлина контекста
embedding, lookup tableэмбеддинг, таблица поиска (таблица эмбеддингов)
feature vectorвектор признаков
hidden layer, hyperparameterскрытый слой, гиперпараметр
storage, view, strideхранилище, представление (view), шаг
concatenateсклеить (конкатенировать)
fused kernelобъединённое ядро
numerical stability, overflowчисленная устойчивость, переполнение
overfitting one batchпереобучение одного батча
minibatchмини-батч
learning rate decayзатухание learning rate
train / dev (validation) / test splitобучающая / валидационная / тестовая выборка
overfitting / underfittingпереобучение / недообучение
log loss plot, “hockey stick”график логарифма loss, «хоккейная клюшка»