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

Zero to Hero, лекция 7: GPT с нуля, в коде, по шагам

Подробный конспект лекции «Let's build GPT»: от биграммной модели на «Крошечном Шекспире» через «математический трюк» с усреднением к self-attention, блокам трансформера и модели на 10 млн параметров — и путь от неё к ChatGPT.

  • Цель лекции — показать, что находится «под капотом» ChatGPT: языковая модель на трансформере из статьи «Attention Is All You Need» (2017). Вместо интернета берётся «Крошечный Шекспир» (около 1 МБ текста), вместо подслов — отдельные символы.
  • Сначала строится базовая линия — биграммная модель (val ≈ 2.5). Затем «математический трюк»: усреднение прошлого можно записать как умножение на нижнетреугольную матрицу. Из этого трюка естественно вырастает self-attentionсамовниманиеEN self-attention.
  • Внимание Карпатый объясняет как механизм общения токенов. Каждый токен выпускает запрос, ключ и значение; веса — скалярные произведения запросов и ключей; маска запрещает смотреть в будущее; деление на d\sqrt{d} не даёт softmax стать слишком «острым».
  • Трансформер собирается по кусочку, и вклад каждого кусочка виден по loss на валидации: одна голова 2.4 → несколько голов 2.28 → FFN 2.24 → остаточные связи 2.08 → LayerNorm 2.06 → увеличенная модель на 10 млн параметров 1.48.
  • Получается decoder-only трансформер, как в GPT. В конце — путь от такой модели к ChatGPT: претрейн на интернете, затем SFT, модель вознаграждения и PPO.

Карпатый предупреждает: лекция опирается на серию makemore — языковое моделирование и основы PyTorch считаются известными. В курсе это модуль 4.1 и раздел 3.3.

Лучший порядок — сначала модуль внимания 4.4 и урок 4.5.1 «Блок трансформера», потом лекция. Она закрепляет пройденное и даёт другую интуицию: внимание как общение узлов в графе. Урок 4.6.2 «GPT на PyTorch» собирает ту же модель в стиле nanoGPT на русском корпусе и на CPU — это идеальная пара к лекции. Можно и наоборот: сначала посмотреть лекцию как интуитивное введение, потом пройти уроки.

Два часа удобно разбить на три захода: данные и биграммная модель (0:00–42:13), трюк и внимание (42:13–1:19:11), сборка трансформера и выводы (1:19:11–конец).

Код лекции — репозиторий karpathy/ng-video-lecture (MIT): bigram.py и gpt.py. Его история коммитов повторяет лекцию шаг за шагом, и в сообщениях коммитов записан loss на валидации. Ноутбук из видео — в Google Colab. По-китайски — ноутбук 007_GPT в учебной версии курса на китайском (karpathy-zero-to-hero-zh-transcripts).

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

ВремяГлава в оригиналеО чём
0:00intro: ChatGPT, Transformers, nanoGPT, Shakespeareчто строим и зачем
7:52reading and exploring the data«Крошечный Шекспир», словарь из 65 символов
9:28tokenization, train/val splitпосимвольный токенизатор, 90/10
14:27data loader: batches of chunks of dataокна длины block_size, батчи
22:11simplest baseline: bigram language model, loss, generationбиграммная модель на nn.Embedding
34:53training the bigram modelAdamW, loss ≈ 2.5
38:00port our code to a scriptbigram.py: GPU, оценка loss
42:13version 1: averaging past context with for loops«мешок слов» циклами
47:11the trick in self-attention: matrix multiply as weighted aggregationтрюк с треугольной матрицей
51:54version 2: using matrix multiplyусреднение умножением
54:42version 3: adding softmaxмаска и softmax
58:26minor code cleanupn_embd, lm_head
1:00:18positional encodingтаблица позиционных эмбеддингов
1:02:00THE CRUX OF THE VIDEO: version 4: self-attentionзапросы, ключи, значения
1:11:38note 1: attention as communicationвнимание как общение в графе
1:12:46note 2: attention has no notion of spaceвнимание работает над множеством
1:13:40note 3: no communication across batch dimensionпримеры батча не общаются
1:14:14note 4: encoder blocks vs. decoder blocksмаска есть или нет
1:15:39note 5: attention vs. self-attention vs. cross-attentionоткуда берутся ключи и значения
1:16:56note 6: “scaled” self-attentionзачем делить на head_size\sqrt{\text{head\_size}}
1:19:11inserting a single self-attention blockодна голова: 2.4
1:21:59multi-headed self-attention4 головы: 2.28
1:24:25feedforward layers of transformer blockFFN: 2.24
1:26:48residual connectionsостаточные связи: 2.08
1:32:51layernormLayerNorm и pre-norm: 2.06
1:37:49scaling up the model! adding dropout10 млн параметров: 1.48
1:42:39encoder vs. decoder vs. both (?) Transformersпочему у нас только декодер
1:46:22super quick walkthrough of nanoGPTголовы как ещё одна ось батча
1:48:53back to ChatGPT, GPT-3, pretraining vs. finetuning, RLHFот GPT к ChatGPT
1:54:32conclusionsитоги

Лекция начинается с ChatGPT. Модель пишет ответ слева направо, а на один и тот же запрос отвечает по-разному: это вероятностная система. По сути это языковая модель: она продолжает последовательность символов, слов или, в общем случае, токенов. Нейросеть внутри — трансформер. Буква T в GPT (Generatively Pretrained Transformer) означает именно его. Статья 2017 года выглядела как обычная работа о машинном переводе, но за пять лет её архитектура с небольшими изменениями разошлась почти по всему ИИ.

Повторить ChatGPT в лекции нельзя, зато можно обучить небольшую языковую модель на трансформере и понять принцип. Готовая версия кода — репозиторий nanoGPT: два файла примерно по 300 строк, модель и обучение. Карпатый показывает, что nanoGPT воспроизводит самую маленькую GPT-2 (124 млн параметров) на датасете OpenWebText. В лекции этот код пишется с пустого файла.

«Крошечный Шекспир» и посимвольный токенизатор

Заголовок раздела ««Крошечный Шекспир» и посимвольный токенизатор»

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

Токенизация здесь простейшая: каждый символ — номер в словаре. Функция encode переводит строку в список чисел, decode — обратно: encode("hii there") даёт [46, 47, 47, 1, 58, 46, 43, 56, 43]. Карпатый сразу показывает альтернативы. SentencePiece от Google режет текст на подслова. Библиотека tiktoken от OpenAI использует BPE: у GPT-2 около 50 тыс. токенов, и та же строка становится тремя числами. Это компромисс: маленький словарь даёт длинные последовательности, большой — короткие. Подробно о нём — в уроке 4.2.1, а самой токенизации посвящена лекция 8.

Весь текст превращается в один длинный тензор чисел. Первые 90% идут на обучение, последние 10% — на валидацию: нужна модель, которая пишет «как Шекспир», а не заучивает его наизусть.

Целиком текст в трансформер не подаётся — это слишком дорого. Обучение идёт на случайных кусках длиной до block_size (в других местах это называют длиной контекста). Важная деталь: в куске из 9 символов спрятано 8 примеров. После первого символа должен идти второй, после первых двух — третий, и так далее.

Учить сразу на всех длинах контекста, от 1 до block_size, полезно не только ради скорости. Так модель привыкает предсказывать и по одному символу, и по полному окну — а генерация как раз начинается с одного символа. Дальше block_size контекст приходится обрезать: больше модель никогда не видит.

Вторая ось — батч. Несколько кусков складываются в один тензор, чтобы загрузить видеокарту, но друг с другом они не взаимодействуют. Батч 4×8 — это 32 независимых примера. Функция из gpt.py (MIT, комментарии переведены):

def get_batch(split):
# небольшой батч входов x и целей y
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - block_size, (batch_size,)) # случайные начала окон
x = torch.stack([data[i:i+block_size] for i in ix]) # (B, T)
y = torch.stack([data[i+1:i+block_size+1] for i in ix]) # то же окно, сдвинутое на 1
x, y = x.to(device), y.to(device)
return x, y

Часть 2. Биграммная модель — базовая линия

Заголовок раздела «Часть 2. Биграммная модель — базовая линия»

Простейшая нейросетевая языковая модель — биграммнаябиграммаEN bigramПара соседних токенов. Биграммная модель оценивает P(x_t | x_{t−1}) — таблица V × V.: следующий символ угадывается только по текущему. В PyTorch это одна таблица nn.Embedding(vocab_size, vocab_size). Номер символа выбирает строку, и эта строка из 65 чисел сразу служит логитами следующего символа. На выходе — тензор формы (B, T, C) с C = 65.

Loss. F.cross_entropy ждёт логиты формы (N, C), поэтому их приходится вытянуть в (B·T, C), а цели — в (B·T). До обучения loss оказался 4.87. Ожидание для случайной модели — −ln⁡(1/65)≈4.17-\ln(1/65) \approx 4.17: все 65 символов равновероятны. Значит, начальные предсказания не совсем равномерные. Эта проверка есть и в курсе: урок 3.3.3, «loss на старте ≈ ln C».

Генерация. Функция generate берёт логиты только последней позиции, превращает их в вероятности через softmax, выбирает символ torch.multinomial и дописывает его к последовательности. Старт — символ с номером 0, перевод строки. Для биграммы подавать в модель весь контекст бессмысленно: нужен только последний символ. Но Карпатый оставляет функцию общей, потому что позже модель начнёт смотреть в историю.

Обучение. Оптимизатор AdamW. Для больших сетей типичный learning rate — около 3⋅10−43 \cdot 10^{-4}, но маленькие терпят и больше, здесь взят 10−310^{-3}. При батче 32 после 10 тыс. шагов loss около 2.5. Текст уже похож на «что-то», но не на Шекспира: символы ещё не «разговаривают» друг с другом.

Скрипт bigram.py. Код переезжает из ноутбука в файл, и появляются три привычки, которые останутся до конца серии:

  • автоопределение устройства: device = 'cuda' if torch.cuda.is_available() else 'cpu', а модель и данные переносятся на него;
  • функция estimate_loss: loss усредняется по 200 батчам для train и val, иначе оценка слишком шумная;
  • оценка идёт под @torch.no_grad() и в режиме model.eval(). Для биграммы режимы не различаются, но так полезно делать всегда — позже появятся dropout и другие слои с разным поведением.

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

Наивно среднее считается двумя циклами. Тот же результат даёт одно матричное умножение. Возьмём нижнетреугольную матрицу из единиц и нормируем строки, чтобы сумма в каждой была 1:

A=(1001/21/201/31/31/3),B=(276465),AB=(2745.54.675.33).A = \begin{pmatrix} 1 & 0 & 0 \\ 1/2 & 1/2 & 0 \\ 1/3 & 1/3 & 1/3 \end{pmatrix}, \quad B = \begin{pmatrix} 2 & 7 \\ 6 & 4 \\ 6 & 5 \end{pmatrix}, \quad AB = \begin{pmatrix} 2 & 7 \\ 4 & 5.5 \\ 4.67 & 5.33 \end{pmatrix}.

Строка tt произведения — среднее первых tt строк BB: (2+6)/2=4(2+6)/2 = 4, (7+4)/2=5.5(7+4)/2 = 5.5, (2+6+6)/3≈4.67(2+6+6)/3 \approx 4.67. Меняя числа в AA, можно получить любую взвешенную сумму прошлого. Третья версия делает то же через softmax, и именно она станет вниманием:

import torch
import torch.nn.functional as F
torch.manual_seed(1337)
B, T, C = 4, 8, 2
x = torch.randn(B, T, C)
# версия 1: циклы — среднее векторов на позициях 0..t
xbow = torch.zeros(B, T, C)
for b in range(B):
for t in range(T):
xbow[b, t] = x[b, :t + 1].mean(dim=0)
# версия 2: умножение на нормированную нижнетреугольную матрицу
wei = torch.tril(torch.ones(T, T))
wei = wei / wei.sum(dim=1, keepdim=True)
xbow2 = wei @ x # (T, T) @ (B, T, C) -> (B, T, C): умножение по батчу
# версия 3: через softmax — так будет устроено внимание
tril = torch.tril(torch.ones(T, T))
wei = torch.zeros(T, T) # «сродство» токенов: пока все нули
wei = wei.masked_fill(tril == 0, float('-inf')) # будущее недоступно
wei = F.softmax(wei, dim=-1) # каждая строка — равномерные веса по прошлому
xbow3 = wei @ x
print(torch.allclose(xbow, xbow2, atol=1e-6), torch.allclose(xbow, xbow3, atol=1e-6)) # True True

Почему третья версия интереснее. Нули в wei — это «сродство» токенов: насколько каждый прошлый токен интересен текущему. Пока оно у всех одинаковое, но скоро станет зависеть от данных. Строка с -inf говорит: из будущего ничего не берём. Softmax превращает сродство в веса, сумма которых равна 1. Итог главы: взвешенное усреднение прошлого — это умножение на нижнетреугольную матрицу весов.

Перед главным шагом две правки. Таблица эмбеддингов теперь выдаёт не логиты, а векторы размера n_embd = 32, а логиты получает отдельный линейный слой lm_head. Кроме того, добавляется вторая таблица — позиционные эмбеддинги размера block_size × n_embd. Вход блока — сумма tok_emb + pos_emb: вектор знает и какой это символ, и где он стоит. Для биграммы позиция пока бесполезна, но вниманию она понадобится.

Среднее одинаково для всех токенов, а нужно, чтобы веса зависели от содержимого. Например, гласная ищет согласные в своём прошлом и хочет узнать о них побольше. Self-attention решает это так: каждый токен выпускает два вектора.

  • Запрос (query) — «что я ищу».
  • Ключ (key) — «что у меня есть».

Сродство двух токенов — скалярное произведение запроса одного и ключа другого. Если они «совпали», вес большой, и токен получит много информации от этого соседа. Третий вектор — значение (value): «что я сообщу, если я тебе интересен». Карпатый предлагает думать об исходном x как о «личной» информации токена, а о v — как о том, чем он делится в этой голове.

Одна голова внимания размером head_size = 16:

key = nn.Linear(C, head_size, bias=False)
query = nn.Linear(C, head_size, bias=False)
value = nn.Linear(C, head_size, bias=False)
k = key(x) # (B, T, 16) — все токены параллельно и независимо
q = query(x) # (B, T, 16)
wei = q @ k.transpose(-2, -1) * head_size**-0.5 # (B, T, 16) @ (B, 16, T) -> (B, T, T)
wei = wei.masked_fill(tril == 0, float('-inf')) # в будущее не смотрим
wei = F.softmax(wei, dim=-1) # строки — распределения по прошлому
out = wei @ value(x) # (B, T, T) @ (B, T, 16) -> (B, T, 16)

Теперь у каждого примера батча своя матрица весов: она вычисляется из данных. Это и есть одна голова причинногопричинная (каузальная) маскаEN causal maskЗапрещает токену смотреть на будущие позиции — оценки выше диагонали заменяются на −∞ до softmax. Нужна, чтобы GPT-подобная модель училась предсказывать следующий токен на всех позициях сразу. self-attention. В курсе ту же формулу по шагам и с числами разбирает урок 4.4.2.

1. Внимание — механизм общения. Представьте ориентированный граф: у каждого узла есть вектор, и узел собирает взвешенную сумму векторов тех узлов, которые на него указывают. Веса зависят от данных. В языковой модели граф особый: на первый узел указывает только он сам, на второй — первый и он сам, на восьмой — все предыдущие. Но в принципе внимание работает на любом графе.

2. У внимания нет понятия пространства. Оно действует на множество векторов и не знает, где какой стоит. Поэтому позиции приходится добавлять явно. У свёртки всё наоборот: она по построению «живёт» в пространстве.

3. Примеры батча не общаются. При батче 4 и длине 8 это четыре отдельные группы по восемь узлов, хотя обрабатываются все 32 узла разом.

4. Блоки энкодера и декодера. Треугольная маска нужна, только когда предсказываем будущее. Если, например, определяется тональность предложения, всем токенам можно общаться со всеми: строку с маской просто удаляют. Это блок энкодера. С маской — блок декодера: он «декодирует» язык авторегрессионно.

5. Внимание, self-attention и cross-attention. «Self» — потому что запросы, ключи и значения берутся из одного источника x. В cross-attentionперекрёстное внимание (cross-attention)EN cross-attentionЗапросы из одной последовательности, ключи и значения — из другой (декодер смотрит на выход энкодера). Ср. self-attention, где все три из одной последовательности. запросы идут из x, а ключи и значения — из другого источника, например из выхода энкодера.

6. Зачем делить на head_size\sqrt{\text{head\_size}}. Если qq и kk — случайные векторы с нулевым средним и единичной дисперсией, у их скалярного произведения дисперсия порядка head_size. Множитель 1/head_size1/\sqrt{\text{head\_size}} возвращает её к единице:

q = torch.randn(10_000, 16)
k = torch.randn(10_000, 16)
s = (q * k).sum(dim=-1) # 10 тыс. скалярных произведений
print(s.var(), (s * 16**-0.5).var()) # примерно 16 и примерно 1

Это важно из-за softmax. При больших по модулю входах он стремится к one-hot: токен собирает информацию почти от одного соседа. Пример: softmax от (0.1,−0.2,0.3,−0.2,0.5)(0.1, -0.2, 0.3, -0.2, 0.5) — это примерно (0.19,0.14,0.24,0.14,0.29)(0.19, 0.14, 0.24, 0.14, 0.29), а от тех же чисел, умноженных на 8, — (0.03,0.00,0.16,0.00,0.80)(0.03, 0.00, 0.16, 0.00, 0.80). На старте обучения веса должны быть «размытыми», поэтому масштаб и нужен. Подробнее с числами — в уроке 4.4.2.

Дальше Карпатый добавляет к модели по одной детали и каждый раз смотрит на loss на валидации. Сводка (в истории коммитов репозитория для одной головы записано 2.5):

ШагГлаваval loss
биграммная модель38:00≈ 2.5
одна голова внимания, head_size = n_embd = 321:19:11≈ 2.4
четыре головы по 81:21:592.28
+ полносвязный слой (Linear + ReLU)1:24:252.24
блоки + остаточные связи + проекции + FFN ×41:26:482.08
+ LayerNorm (pre-norm) и финальный LayerNorm1:32:512.06
6 слоёв, 6 голов, n_embd = 384, контекст 256, dropout 0.21:37:491.48

С одной головой появляется деталь в генерации: контекст нужно обрезать до последних block_size токенов, иначе таблице позиций не хватит строк. Learning rate приходится снизить — внимание не терпит слишком больших шагов.

Несколько головголова вниманияEN attention headОдна независимая копия внимания со своими проекциями размера d_h = d_model / H. Головы работают параллельно и смотрят на разные отношения между токенами. — несколько независимых каналов общения параллельно. Вместо одной головы размером 32 — четыре по 8, выходы склеиваются обратно в 32. Карпатый сравнивает это с групповой свёрткой. Токенам «есть о чём поговорить»: искать согласные, гласные, определённые позиции — отдельным каналам проще. В курсе — урок 4.4.3.

После внимания токены «посмотрели друг на друга», но им некогда «подумать» о найденном перед выдачей логитов. Поэтому добавляется маленький перцептрон, который применяется к каждому токену отдельно. Внимание — это общение, FFNполносвязный блок (FFN)EN feed-forward network (FFN) — вычисление. Блок трансформера чередует одно с другим, и блоки повторяются.

Две идеи, без которых глубокая сеть не учится

Заголовок раздела «Две идеи, без которых глубокая сеть не учится»

Когда блоков становится несколько, сеть начинает плохо оптимизироваться. Из статьи берутся два средства.

Остаточные связи (Residual Learning, 2015). Карпатый рисует их так: сверху вниз идёт «остаточный путь», от него ответвляется вычисление, и результат прибавляется обратно. Сложение при обратном проходе раздаёт градиент обеим ветвям поровну — это было видно ещё в micrograd. Поэтому от loss ко входу идёт «магистраль» градиента без препятствий. Блоки на старте почти ничего не добавляют и «включаются» постепенно. Нужна ещё проекция — линейный слой, возвращающий результат в остаточный путь. А внутренний слой FFN, как в статье (512 → 2048), в 4 раза шире.

Нормализация слоя. LayerNormнормализация слояEN layer normalization — родственник BatchNorm из третьей лекции makemore, только нормирует не столбцы (по батчу), а строки — каждый вектор токена. Сеть получается проще: не нужны скользящие буферы и нет разницы между обучением и инференсом; обучаемые масштаб и сдвиг остаются. Есть и отступление от статьи: теперь LayerNorm принято ставить перед вниманием и FFN (pre-norm), а не после. Плюс ещё один LayerNorm в конце, перед lm_head.

flowchart TB
X["x: (B, T, n_embd)"] --> LN1["LayerNorm"] --> SA["Многоголовое внимание<br/>(общение)"]
SA --> P1(("+"))
X --> P1
P1 --> LN2["LayerNorm"] --> FF["FFN: Linear → ReLU → Linear<br/>(вычисление)"]
FF --> P2(("+"))
P1 --> P2
P2 --> Y["x на выходе блока"]
Блок из лекции: pre-norm, две остаточные связи. Блоки повторяются n_layer раз.

Последний шаг — сделать модель больше. Появляются переменные n_layer и n_head и dropoutдропаут (dropout)EN dropoutНа обучении каждая активация обнуляется с вероятностью p, остальные умножаются на 1/(1 − p); на инференсе слой ничего не делает. В претрейне современных LLM обычно p = 0. (2014): на каждом шаге случайная часть нейронов обнуляется, и сеть как бы учит ансамбль подсетей, который при тесте работает целиком. Dropout ставится перед возвратом в остаточный путь и на веса внимания после softmax. Он нужен, потому что большая модель на миллионе символов начнёт переобучаться.

Гиперпараметры итоговой модели: батч 64, контекст 256 символов, learning rate 3⋅10−43 \cdot 10^{-4}, n_embd = 384, 6 голов по 64, 6 слоёв, dropout 0.2. Обучение заняло около 15 минут на A100, val loss 1.48. Текст по форме похож на пьесу — реплики, имена персонажей, — но по смыслу бессвязен. Пример длиной 10 тыс. символов лежит в репозитории в файле more.txt. На CPU такую модель Карпатый обучать не советует: нужно уменьшать число слоёв и размерность.

Ядро gpt.py (MIT, комментарии переведены; имя FeedFoward — как в оригинале):

class Head(nn.Module):
""" одна голова self-attention """
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
# маска — не параметр, а буфер модуля
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
self.dropout = nn.Dropout(dropout)
def forward(self, x):
B, T, C = x.shape
k = self.key(x) # (B, T, hs)
q = self.query(x) # (B, T, hs)
# оценки внимания («сродство»)
wei = q @ k.transpose(-2, -1) * k.shape[-1]**-0.5 # (B, T, T)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
wei = F.softmax(wei, dim=-1)
wei = self.dropout(wei)
# взвешенная сумма значений
v = self.value(x) # (B, T, hs)
return wei @ v # (B, T, hs)
class MultiHeadAttention(nn.Module):
""" несколько голов параллельно """
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(head_size * num_heads, n_embd) # проекция в остаточный путь
self.dropout = nn.Dropout(dropout)
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1) # склеиваем выходы голов
return self.dropout(self.proj(out))
class FeedFoward(nn.Module):
""" линейный слой и нелинейность: вычисление для каждого токена отдельно """
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd),
nn.ReLU(),
nn.Linear(4 * n_embd, n_embd), # проекция обратно в остаточный путь
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Block(nn.Module):
""" блок трансформера: общение, затем вычисление """
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size)
self.ffwd = FeedFoward(n_embd)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x):
x = x + self.sa(self.ln1(x)) # pre-norm + остаточная связь
x = x + self.ffwd(self.ln2(x))
return x

Схема из статьи сложнее нашей модели: в ней есть энкодер и блок cross-attention. Мы построили decoder-onlydecoder-only (только декодер)EN decoder-only architectureОдна стопка блоков с причинной маской, цель — следующий токен. Архитектура GPT, Llama, Qwen, DeepSeek, Kimi, GLM-4.5. трансформер. Декодером его делает треугольная маска: модель авторегрессионная, из неё можно сэмплировать. Энкодер не нужен, потому что генерация ни от чего не зависит — мы просто подражаем тексту.

В статье решалась задача машинного перевода, поэтому архитектура была энкодер–декодерэнкодер–декодерEN encoder-decoder architectureДвунаправленный энкодер читает вход, причинный декодер генерирует выход и смотрит на энкодер через cross-attention (исходный Transformer, T5, Whisper).. Энкодер читает французское предложение без маски: все его токены общаются со всеми. Декодер генерирует английский перевод, начиная со специального токена начала и заканчивая токеном конца. В каждом блоке декодера есть cross-attention: запросы идут из декодера, а ключи и значения — из выхода энкодера. Так перевод опирается и на уже сгенерированное начало, и на всё исходное предложение. В курсе три семейства моделей из одного блока сравниваются в уроке 4.5.1.

В nanoGPT два главных файла. train.py — обвязка обучения: чекпоинты, загрузка готовых весов, затухание learning rate, компиляция, обучение на нескольких GPU. model.py почти совпадает с лекцией, с двумя отличиями:

  • головы внимания не отдельные модули, а ещё одна ось батча: тензор становится четырёхмерным (B, головы, T, размер головы). Математически это то же самое, но быстрее;
  • в MLP вместо ReLU стоит GELU — как у OpenAI, чтобы можно было загрузить их веса.

Есть и разделение параметров на те, к которым применяется weight decay, и те, к которым нет. Всё это подробно разбирается в лекции 9 и уроках 4.6.2–4.6.3.

Претрейн. Наш «маленький Шекспир» — около 10 млн параметров и около 1 млн символов. В словаре OpenAI это было бы примерно 300 тыс. токенов. Самая большая модель GPT-3 — 175 млрд параметров, обучена на 300 млрд токенов: данных примерно в миллион раз больше. Гиперпараметры в таблице статьи GPT-3 выглядят знакомо, а архитектура почти та же. Главная трудность — инфраструктура: тысячи видеокарт, которые должны работать согласованно.

Что получается после претрейна. Не ассистент, а «продолжатель документов». Модель болтает интернетом вместо Шекспира. На вопрос она может ответить новыми вопросами или продолжить случайную новость: поведение не определено.

Дообучение. По описанию в блоге OpenAI о ChatGPT, этапов три:

  1. SFT — дообучение на примерах вида «вопрос сверху, ответ ассистента снизу». Таких примеров нужно намного меньше, чем данных претрейна: Карпатый предполагает, что порядка тысяч. Большие модели очень эффективно учатся на малом числе примеров.
  2. Модель вознаграждения. Модель даёт несколько ответов, люди ранжируют их по качеству, и на этих оценках обучается отдельная сеть, предсказывающая желательность ответа.
  3. PPO — алгоритм обучения с подкреплением: модель дообучается так, чтобы её ответы получали высокую награду.

Данные этих этапов почти не публикуются, поэтому их гораздо труднее повторить. nanoGPT покрывает только претрейн. В курсе эти этапы — ступень 6: базовая модель против чат-модели, SFT, RLHF и PPO.

Итог лекции. Весь код обучения — около 200 строк. GPT-3 архитектурно почти такая же, только в 10 тыс. — 1 млн раз больше, смотря как считать.

  • 57:00 — правильно «токены из будущего не общаются с прошлым», а не наоборот (поправка в описании видео).
  • 1:20:05 — нормировать нужно на размер головы, а не на C. Исправлено в репозитории.
  • Инициализация. В README репозитория Карпатый пишет, что в видео почти не говорилось об инициализации весов, хотя для качества она важна: код работает, но сходится медленнее. Позже в gpt.py добавили _init_weights (нормальное распределение с std 0.02 для линейных слоёв и эмбеддингов, нулевые смещения), как в nanoGPT. Подробно об этом — в лекции 9 и уроке 3.4.1.

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

Заголовок раздела «Где курс объясняет то же самое и что есть сверх курса»
Тема лекцииГде в курсеЧто в лекции сверх курса (или наоборот)
Посимвольный токенизатор, размер словаря против длины4.2.1, 4.6.2—
Окна со сдвигом на один, батчи4.6.2, 4.6.3зачем учить на всех длинах контекста сразу
Биграммная модель4.1.2биграмма как nn.Embedding в PyTorch
От среднего к вниманию4.4.2 (маска)вывод в три шага: циклы → матрица → softmax
Q, K, V, масштаб, маска4.4.1, 4.4.2внимание как общение в графе, шесть замечаний
Многоголовое внимание4.4.3головы как отдельные модули; в курсе сразу батчевая версия
FFN, остаточные связи, LayerNorm, pre-norm4.5.1, 3.4.1, 3.4.2абляция: вклад каждой детали в loss
Позиционные эмбеддинги4.5.2курс идёт дальше: синусоиды и RoPE
Энкодер, декодер, cross-attention4.5.1, 4.5.4пример с переводом и токенами начала и конца
Полная GPT и генерация4.6.2курс идёт дальше: связанные веса, проверка числа параметров; KV-кэш в 4.6.4
От претрейна к ChatGPT6.1.1, 6.3.2—
  1. Склонируйте ng-video-lecture и пройдите историю: git log --reverse --oneline, затем git checkout <коммит> и python gpt.py на ключевых шагах. Сверьте loss с таблицей выше.
  2. Упражнение 1 из описания видео — «мастерство n-мерных тензоров». Объедините Head и MultiHeadAttention в один класс, который считает все головы сразу как ещё одну ось батча. Ответ есть в nanoGPT и в уроке 4.4.3.
  3. Упражнение 2. Обучите GPT на своих данных — например, на русском корпусе из урока 4.6.2. Продвинутый вариант Карпатого — научить модель складывать числа a+b=c. Его подсказки: выдавать цифры c в обратном порядке (сложение идёт справа налево), генерировать примеры на лету и не считать loss на позициях условия — цель -1 и ignore_index в cross-entropy.
  4. Упражнение 3. Найдите датасет настолько большой, что разрыва между train и val нет. Предобучите на нём модель, затем дообучите её на Шекспире с меньшим learning rate и числом шагов. Станет ли val loss ниже?
  5. Упражнение 4. Прочитайте пару статей о трансформерах и добавьте одно популярное улучшение — например, RMSNorm или RoPE из уроков 4.5.2–4.5.3. Улучшился ли loss?
ENRU
language modelязыковая модель
character-level tokenizerпосимвольный токенизатор
block size, context lengthдлина контекста (block_size)
bigram modelбиграммная модель
logitsлогиты
affinities«сродство» токенов, оценки внимания
query / key / valueзапрос / ключ / значение
head sizeразмер головы
causal maskпричинная маска
self-attention / cross-attentionсамовнимание / перекрёстное внимание
multi-head attentionмногоголовое внимание
feed-forward networkполносвязный блок (FFN)
residual connection, residual pathwayостаточная связь, остаточный путь
layer normalization, pre-normнормализация слоя, pre-norm
dropoutдропаут
decoder-only transformerdecoder-only трансформер (только декодер)
pretraining / fine-tuningпредобучение (претрейн) / дообучение
reward model, PPOмодель вознаграждения, PPO