Перейти к содержимому
Ступень 44.6 GPT с нуля

Цикл обучения LLM

Как устроен настоящий цикл обучения языковой модели — батчи из непрерывного текста, AdamW с разогревом и косинусом, клиппинг и накопление градиентов, кривые train/val — и что на самом деле значит loss 1.5.

УРОКstage-4/4-6-gpt-from-scratch/03-training-loop.mdx2 чУрок 3 из 5
После урока вы сможете
  • Нарезать непрерывный текст на батчи и честно отделить валидационную часть
  • Настроить AdamW с группами weight decay, разогревом и косинусным расписанием и объяснить, зачем нужна каждая деталь
  • Применить клиппинг и накопление градиентов и проверить численно, что накопление эквивалентно большому батчу
  • Перевести loss в perplexity и биты на символ и сравнить модель с n-граммами и архиватором

В прошлом уроке цикл обучения был минимальным: случайный батч, AdamW с постоянным learning rate, шаг. Для маленькой модели этого хватает. Но если открыть код обучения настоящей LLM — nanoGPT, MiniMind или техотчёт DeepSeek-V3 — вы увидите одни и те же дополнительные детали:

  • разогрев и косинус — learning rate сначала растёт, потом плавно падает;
  • weight decay по группам — одни параметры «стягиваются» к нулю, другие нет;
  • клиппинг градиентов — ограничение длины шага;
  • накопление градиентов — большой батч из нескольких маленьких;
  • train и val loss — две кривые, по которым видно, учится модель или зубрит.

Каждая деталь решает конкретную проблему, и без неё большое обучение ломается: расходится, застревает или тихо переобучается. Ниже разберём их по очереди, соберём полный train.py и сравним с простым циклом из прошлого урока на том же корпусе. А в конце выясним, хорош ли наш loss: что значит «1.5» и с чем его сравнивать.

Корпус для предобучения — один длинный поток токенов. Из него случайно вырезаются окна:

flowchart LR
A["Корпус: 801 650 символов подряд"] --> B["B случайных начал i₁…i_B"]
B --> C["x = текст[i : i+T]"]
B --> D["y = текст[i+1 : i+T+1]"]
C --> E["батч (B, T): B·T предсказаний"]
D --> E
Батч из непрерывного текста: окна случайные, цели — те же окна со сдвигом на один токен

Несколько следствий, о которых полезно помнить.

Обучающих примеров больше, чем окон. Окно длины TT даёт TT предсказаний: по первому символу угадать второй, по двум первым — третий и так далее. Батч 32×12832 \times 128 — это 4096 обучающих примеров за один проход.

Эпоха — условное понятие. Окна выбираются случайно и перекрываются, поэтому «прошли весь корпус ровно один раз» не бывает. Удобнее мерить обучение токенами: за 2000 шагов по 4096 токенов модель увидит 8.2 млн символов. Наш обучающий корпус — 801 650 символов, значит каждый символ встретится в среднем примерно 10 раз. Это много: на таком повторении маленькая модель уже может начать запоминать текст. Большие LLM учатся иначе: DeepSeek-V3 увидела 14.8 трлн токенов, и в таких предобучениях почти все данные проходят через модель примерно один раз (лишь самые качественные источники повторяют несколько раз).

Валидация — непрерывный кусок, а не случайные окна. Соседние окна совпадают почти целиком: окно, начатое на символе 1000, и окно, начатое на 1001, делят 127 символов из 128. Если отбирать в валидацию случайные окна, почти каждое из них будет пересекаться с обучающими, и val loss окажется обманчиво хорошим. Это классическая утечка данныхутечка данныхEN data leakage. Поэтому мы отрезаем последние 10% текста целиком.

Документы склеиваются. В настоящем корпусе миллионы отдельных документов. Их записывают подряд через специальный токен конца текста (у GPT-2 это <|endoftext|>), и окно может захватить конец одного документа и начало другого. Это упаковка последовательностейупаковка последовательностейEN sequence packingДокументы склеивают в один поток токенов через разделитель и режут на окна фиксированной длины — без паддинга.: ни один токен окна не тратится на пустое заполнение. Модель сама учится, что после токена-разделителя начинается новая тема.

Оптимизатор — AdamWAdamWEN AdamW (Adam with decoupled weight decay)Adam, в котором затухание весов применяется отдельно от адаптивного шага. Оптимизатор по умолчанию для обучения LLM. из урока 1.4.2: Adam с адаптивными шагами плюс затухание весовзатухание весов (weight decay)EN weight decayНа каждом шаге веса умножаются на (1 − ηλ) — «притягиваются» к нулю. Для обычного SGD равносильно L2-регуляризации, для Adam — нет., который на каждом шаге немного уменьшает веса: w←w−ηλww \leftarrow w - \eta \lambda w. Вопрос, какие веса уменьшать.

# p.dim() — число осей тензора: у матрицы 2, у вектора смещений 1
decay = [p for p in model.parameters() if p.dim() >= 2] # матрицы и эмбеддинги
no_decay = [p for p in model.parameters() if p.dim() < 2] # смещения и параметры LayerNorm
optimizer = torch.optim.AdamW(
# две «группы параметров», у каждой свой weight_decay
[{"params": decay, "weight_decay": weight_decay}, {"params": no_decay, "weight_decay": 0.0}],
lr=max_lr, betas=(0.9, 0.99),
)

Правило из nanoGPT: стягиваем к нулю только двумерные тензоры — матрицы линейных слоёв и эмбеддинги. Это и есть «ёмкость» модели, которую регуляризация должна ограничивать. Одномерные — смещения и множители LayerNorm — не трогаем. Множитель нормализации по смыслу должен быть около 1, и тянуть его к нулю значит постепенно глушить сигнал. А смещений так мало, что переобучиться на них модель не может.

Стандартные гиперпараметры LLM удивительно похожи у всех (β1\beta_1 и β2\beta_2 — коэффициенты скользящих средних градиента и его квадрата в Adam, урок 1.4.2):

Модельβ1\beta_1, β2\beta_2weight decayКлиппингБатч, токенов
GPT-3 175B (2020)0.9, 0.950.11.03.2 млн
Llama 2 (2023)0.9, 0.950.11.04 млн
DeepSeek-V3 (2024)0.9, 0.950.11.0до 63 млн (15 360 × 4096)
MiniMind (pretrain)0.9, 0.999 (по умолчанию в PyTorch)0.01 (по умолчанию)1.0≈ 87 тыс. (32 × 8 × 340)
Наш train.py0.9, 0.990.11.04096 (32 × 128)

Почему у нас β2=0.99\beta_2 = 0.99, а не 0.95? Так советует nanoGPT для маленьких батчей: при 4096 токенах на шаг градиент шумный, и более длинная память второго момента его сглаживает.

скорость обученияскорость обученияEN learning rate — главный гиперпараметр. Современный стандарт — менять его по ходу обучения:

η(s)={ηmax⁡⋅s+1w,s<w(разогрев)ηmin⁡+12(1+cos⁡(πp))(ηmax⁡−ηmin⁡),p=s−wS−w(косинус)\eta(s) = \begin{cases} \eta_{\max} \cdot \dfrac{s + 1}{w}, & s < w \quad \text{(разогрев)} \\[2mm] \eta_{\min} + \dfrac{1}{2}\bigl(1 + \cos(\pi p)\bigr)(\eta_{\max} - \eta_{\min}), & p = \dfrac{s - w}{S - w} \quad \text{(косинус)} \end{cases}

Что здесь написано: первые ww шагов lr линейно растёт от почти нуля до ηmax⁡\eta_{\max}. Потом по половинке косинуса плавно спускается до ηmin⁡\eta_{\min} к последнему шагу SS. Множитель 12(1+cos⁡πp)\frac{1}{2}(1 + \cos \pi p) равен 1 при p=0p = 0, 0.5 на середине и 0 в конце.

def get_lr(step):
"""Линейный разогрев, затем косинус от max_lr до min_lr."""
if step < warmup_steps:
return max_lr * (step + 1) / warmup_steps
progress = (step - warmup_steps) / max(1, max_steps - warmup_steps) # 0 → 1
return min_lr + 0.5 * (1 + math.cos(math.pi * progress)) * (max_lr - min_lr)

С нашими настройками (w=100w = 100, S=2000S = 2000, ηmax⁡=10−3\eta_{\max} = 10^{-3}, ηmin⁡=10−4\eta_{\min} = 10^{-4}):

Шаг04999575105015252000
lr1.0e-55.0e-41.0e-38.7e-45.5e-42.3e-41.0e-4

Зачем разогрев (разогрев learning rateразогрев learning rate (warmup)EN learning rate warmupПервые сотни–тысячи шагов learning rate линейно растёт от нуля до пика, пока статистики Adam не накопились.). В начале обучения всё плохо откалибровано: веса случайные, градиенты крупные и непредсказуемые, а оценки моментов в Adam построены по нескольким шагам и сильно шумят. Полный lr в этот момент — большой шаг в случайном направлении. Модель может «вылететь» в область, где loss растёт или становится NaN (Not a Number, «не число» — так float обозначает результат вроде 0/00/0 или ∞−∞\infty - \infty), и уже не вернуться. Разогрев даёт оптимизатору освоиться. В Llama 2 и DeepSeek-V3 он длится 2000 шагов, у нас — 100.

Зачем спуск (косинусное затухание learning rateкосинусное затухание learning rateEN cosine decay (cosine annealing)После разогрева learning rate плавно спадает от пика до минимума по половине косинусоиды.). С большим lr модель быстро идёт к хорошей области, но не может в ней «осесть»: шум мини-батчей качает её вокруг минимума. Уменьшение lr гасит эти колебания, и loss в конце обучения заметно проседает. Часто именно последние 10–20% шагов дают ощутимую часть итогового выигрыша.

Почему до ηmin⁡=0.1 ηmax⁡\eta_{\min} = 0.1\,\eta_{\max}, а не до нуля. Так делали GPT-3, Llama 2 и nanoGPT: при lr, близком к нулю, последние сотни шагов почти ничего не меняют. Это эмпирическое правило, а не закон. Микро-GPT из урока 4.6.1 спускала lr линейно до нуля, и некоторые исследователи считают такой вариант даже лучшим. Проверьте сами: поставьте min_lr = 0 и сравните финальный val loss.

У косинуса есть неудобство: длину обучения SS надо знать заранее. Захотели учить дольше — расписание уже «спустилось». Поэтому появились альтернативы, и две из них предложили китайские команды (см. китайский угол): ступенчатое расписание DeepSeek и WSDрасписание WSD (warmup — stable — decay)EN warmup-stable-decay (WSD) scheduleРазогрев, длинное плато с постоянным learning rate и короткий спад в конце. Популяризовано MiniCPM (OpenBMB и Университет Цинхуа, 2024). из MiniCPM — долгое плато и короткий спуск в конце.

Иногда градиент оказывается огромным: попался необычный батч или модель подошла к краю устойчивости. Шаг пропорционален градиенту, и один такой шаг может разрушить то, что модель выучила за тысячи предыдущих. Клиппингобрезка градиента (gradient clipping)EN gradient clippingЕсли норма градиента больше порога, градиент масштабируют до порога: clip_grad_norm_(params, 1.0). Стандарт в обучении LLM. ограничивает длину шага:

∥g∥=∑все параметрыgi2,g←g⋅min⁡(1,c∥g∥)\|g\| = \sqrt{\sum_{\text{все параметры}} g_i^2}, \qquad g \leftarrow g \cdot \min\Bigl(1, \frac{c}{\|g\|}\Bigr)

Что здесь написано: все градиенты модели рассматриваются как один длинный вектор. Если его длина больше порога cc (почти всегда c=1.0c = 1.0), весь вектор уменьшается до длины cc. Направление не меняется, меняется только длина. Числовой пример:

import torch
w1 = torch.nn.Parameter(torch.zeros(2))
w2 = torch.nn.Parameter(torch.zeros(1))
w1.grad = torch.tensor([3.0, 4.0]) # вклад в норму: 3² + 4² = 25
w2.grad = torch.tensor([12.0]) # вклад: 12² = 144
norm = torch.nn.utils.clip_grad_norm_([w1, w2], max_norm=1.0)
print(f"норма до обрезки: {norm:.1f}") # sqrt(25 + 144) = 13
print("после:", [round(g, 4) for g in w1.grad.tolist()], [round(g, 4) for g in w2.grad.tolist()])
total = torch.sqrt(w1.grad.pow(2).sum() + w2.grad.pow(2).sum())
print(f"норма после: {total:.3f}")
норма до обрезки: 13.0
после: [0.2308, 0.3077] [0.9231]
норма после: 1.000

Все градиенты поделились на 13, пропорции 3:4:123 : 4 : 12 сохранились. Функция clip_grad_norm_ возвращает норму до обрезки — записывайте её в лог. Это одна из главных «приборных» величин (урок 3.3.3): здоровая норма плавно снижается, а резкие скачки нормы градиентанорма градиентаEN gradient normДлина вектора всех градиентов модели (обычно L2). Главный «пульс» обучения: резкий рост предвещает всплеск loss. часто предшествуют всплеску lossвсплеск lossEN loss spikeРезкий скачок функции потерь во время обучения. В больших моделях может испортить многодневный прогон, поэтому техотчёты отдельно сообщают об их отсутствии.. Клиппинг — страховка, а не лечение. Как с всплесками борются в больших моделях (Kimi K2 обучили на 15.5 трлн токенов без единого всплеска благодаря MuonClip) — в уроке 5.4.4.

Хотим батч из 32 окон, а в память помещается только 16? Или на GPU влезает 8 последовательностей по 4096 токенов, а рецепт требует миллионы токенов на шаг? Выход — накопление градиентовнакопление градиентовEN gradient accumulationГрадиенты складываются (+=), а не перезаписываются. Внутри графа так учитываются все пути к узлу; на уровне обучения — шаг оптимизатора делают раз в несколько микробатчей.: прогоняем несколько микробатчей, складываем их градиенты и только потом делаем шаг оптимизатора.

for micro in range(grad_accum): # накопление градиентов
xb, yb = get_batch("train")
_, loss = model(xb, yb)
(loss / grad_accum).backward() # делим, чтобы получить среднее, а не сумму
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip).item() # норма ДО обрезки
optimizer.step()
optimizer.zero_grad(set_to_none=True)

Ключевая строчка — деление на grad_accum. backward() не заменяет .grad, а прибавляет к нему (урок 3.2.2). Loss каждого микробатча — среднее по его примерам. Сумма KK средних в KK раз больше среднего по большому батчу. Без деления градиент окажется в KK раз крупнее, что равносильно lr в KK раз выше. Проверим численно, что с делением результат совпадает с настоящим большим батчем:

import torch
from model import GPT, GPTConfig
torch.manual_seed(0)
cfg = GPTConfig(vocab_size=50, block_size=32, n_layer=2, n_head=2, n_embd=32)
x = torch.randint(50, (32, 32))
y = torch.randint(50, (32, 32))
# 1) один большой батч из 32 окон
model = GPT(cfg)
_, loss = model(x, y)
loss.backward()
g_big = torch.cat([p.grad.flatten() for p in model.parameters()])
# 2) тот же батч по частям: 4 микробатча по 8 окон, loss делим на 4
model.zero_grad(set_to_none=True)
for i in range(4):
_, loss = model(x[8 * i : 8 * (i + 1)], y[8 * i : 8 * (i + 1)])
(loss / 4).backward() # градиенты складываются в .grad
g_acc = torch.cat([p.grad.flatten() for p in model.parameters()])
# 3) типичная ошибка: забыли поделить на 4
model.zero_grad(set_to_none=True)
for i in range(4):
_, loss = model(x[8 * i : 8 * (i + 1)], y[8 * i : 8 * (i + 1)])
loss.backward()
g_bug = torch.cat([p.grad.flatten() for p in model.parameters()])
print(f"макс. разница большой батч vs накопление: {(g_big - g_acc).abs().max():.1e}")
print(f"без деления на 4 градиент больше в {g_bug.norm() / g_big.norm():.2f} раза")
макс. разница большой батч vs накопление: 7.5e-09
без деления на 4 градиент больше в 4.00 раза

Разница 10−810^{-8} — это ошибки округления float32. Накопление даёт тот же градиент, что большой батч, только медленнее: микробатчи идут по очереди. Итоговый размер — эффективный размер батчаэффективный размер батчаEN effective batch sizeСколько примеров (или токенов) приходится на один шаг оптимизатора: микробатч × шаги накопления градиентов × число GPU.:

Beff=микробатч×шагов накопления×число GPUB_{\text{eff}} = \text{микробатч} \times \text{шагов накопления} \times \text{число GPU}

У нас 16×2×1=3216 \times 2 \times 1 = 32 окна. У MiniMind 32×8=25632 \times 8 = 256 последовательностей на одной карте. При обучении на нескольких GPU каждая карта считает свои микробатчи, а градиенты усредняются между картами — это data parallel (урок 5.4.3).

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

@torch.no_grad()
def estimate_loss():
model.eval()
out = {}
for split in ("train", "val"):
out[split] = torch.stack([model(*get_batch(split))[1] for _ in range(eval_iters)]).mean().item()
model.train()
return out

model.eval() отключает dropout, torch.no_grad() не строит граф (быстрее и без лишней памяти). Не забудьте вернуть model.train().

Как читать две кривые:

Что видимЧто это значитЧто делать
Обе падают, близко друг к другумодель учится и обобщаетучить дальше
Train падает, val стоит или растётпереобучениепереобучениеEN overfitting: модель зубрит текстбольше данных, dropout, меньше модель, остановиться раньше
Обе стоят высоконедообучение или слишком маленький lrбольше модель, больше шагов, проверить lr
Скачок вверх, потом медленный спусквсплеск lossклиппинг, меньше lr, длиннее разогрев

Лучшую по val модель сохраняем — это ранняя остановкаранняя остановкаEN early stopping в мягкой форме: учим до конца, но в итоге берём чекпоинт с минимальным val loss.

Соберём всё вместе. Модель и данные те же, что в прошлом уроке (model.py, input.txt), эффективный батч тот же (32 окна по 128 символов), шагов тоже 2000 — чтобы сравнение было честным.

"""train.py — полноценный цикл обучения: warmup + cosine, weight decay, клиппинг, накопление градиентов.
python train.py (рядом model.py и input.txt)
"""
import math
import os
import time
import torch
from model import GPT, GPTConfig
torch.manual_seed(1337)
torch.set_num_threads(int(os.environ.get("THREADS", min(8, os.cpu_count())))) # потоков CPU: подберите сами
# ── Гиперпараметры ──────────────────────────────────────────────────────────
micro_batch = 16 # столько окон влезает в память за один проход
grad_accum = 2 # копим градиенты 2 микробатчей → эффективный батч 32
block_size = 128
max_steps = 2000
warmup_steps = 100
max_lr, min_lr = 1e-3, 1e-4
weight_decay = 0.1
grad_clip = 1.0
eval_every, eval_iters = 250, 20
# ── Данные (как в уроке 4.6.2) ──────────────────────────────────────────────
RU = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
EN = "abcdefghijklmnopqrstuvwxyz"
ALLOWED = set(RU + RU.upper() + EN + EN.upper() + "0123456789" + " \n.,:;!?-—«»()")
text = "".join(ch for ch in open("input.txt", encoding="utf-8").read() if ch in ALLOWED)
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
data = torch.tensor([stoi[c] for c in text], dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]
def get_batch(split):
d = train_data if split == "train" else val_data
ix = torch.randint(len(d) - block_size - 1, (micro_batch,))
x = torch.stack([d[i : i + block_size] for i in ix])
y = torch.stack([d[i + 1 : i + 1 + block_size] for i in ix])
return x, y
# ── Модель и оптимизатор ────────────────────────────────────────────────────
cfg = GPTConfig(vocab_size=len(chars), block_size=block_size, n_layer=4, n_head=4, n_embd=128)
model = GPT(cfg)
decay = [p for p in model.parameters() if p.dim() >= 2] # матрицы и эмбеддинги
no_decay = [p for p in model.parameters() if p.dim() < 2] # смещения и параметры LayerNorm
optimizer = torch.optim.AdamW(
[{"params": decay, "weight_decay": weight_decay}, {"params": no_decay, "weight_decay": 0.0}],
lr=max_lr, betas=(0.9, 0.99),
)
def get_lr(step):
"""Линейный разогрев, затем косинус от max_lr до min_lr."""
if step < warmup_steps:
return max_lr * (step + 1) / warmup_steps
progress = (step - warmup_steps) / max(1, max_steps - warmup_steps) # 0 → 1
return min_lr + 0.5 * (1 + math.cos(math.pi * progress)) * (max_lr - min_lr)
@torch.no_grad()
def estimate_loss():
model.eval()
out = {}
for split in ("train", "val"):
out[split] = torch.stack([model(*get_batch(split))[1] for _ in range(eval_iters)]).mean().item()
model.train()
return out
# ── Цикл обучения ───────────────────────────────────────────────────────────
best_val, norm, t0 = float("inf"), 0.0, time.time()
log = open("log.csv", "w")
log.write("step,lr,train,val,grad_norm\n")
for step in range(max_steps + 1):
lr = get_lr(step)
for group in optimizer.param_groups: # вручную выставляем lr обеим группам параметров
group["lr"] = lr
if step % eval_every == 0 or step == max_steps:
ls = estimate_loss()
print(f"шаг {step:5d} lr {lr:.1e} train {ls['train']:.3f} val {ls['val']:.3f} "
f"|g| {norm:.2f} {(time.time() - t0) / 60:.1f} мин")
if ls["val"] < best_val: # сохраняем лучшую по val модель
best_val = ls["val"]
torch.save({"model": model.state_dict(), "cfg": cfg, "chars": chars, "step": step}, "best.pt")
log.write(f"{step},{lr:.6g},{ls['train']:.4f},{ls['val']:.4f},{norm:.4f}\n")
log.flush() # сразу записать на диск, а не копить в буфере
if step == max_steps:
break
for micro in range(grad_accum): # накопление градиентов
xb, yb = get_batch("train")
_, loss = model(xb, yb)
(loss / grad_accum).backward() # делим, чтобы получить среднее, а не сумму
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip).item() # норма ДО обрезки
optimizer.step()
optimizer.zero_grad(set_to_none=True)
print(f"лучший val loss: {best_val:.3f}")

Обратите внимание на порядок в одном шаге: lr → (оценка) → микробатчи и backward → клиппинг → step → zero_grad. Клиппинг стоит между backward и step: обрезать имеет смысл уже накопленный градиент, а не градиенты отдельных микробатчей. Колонка |g| в логе — норма градиента на последнем перед оценкой шаге.

Наш запуск (THREADS=1 python train.py, тот же сервер):

шаг 0 lr 1.0e-05 train 4.922 val 4.927 |g| 0.00 0.1 мин
шаг 250 lr 9.9e-04 train 2.676 val 2.710 |g| 0.50 3.0 мин
шаг 500 lr 9.1e-04 train 2.281 val 2.363 |g| 1.35 5.3 мин
шаг 750 lr 7.6e-04 train 1.985 val 2.093 |g| 1.92 7.2 мин
шаг 1000 lr 5.9e-04 train 1.847 val 1.958 |g| 3.48 10.0 мин
шаг 1250 lr 4.0e-04 train 1.717 val 1.823 |g| 2.91 13.3 мин
шаг 1500 lr 2.5e-04 train 1.611 val 1.740 |g| 2.16 16.4 мин
шаг 1750 lr 1.4e-04 train 1.573 val 1.728 |g| 2.19 19.9 мин
шаг 2000 lr 1.0e-04 train 1.528 val 1.683 |g| 2.22 22.4 мин
лучший val loss: 1.683

Сравним с простым циклом из прошлого урока — та же модель, тот же корпус, тот же эффективный батч, 2000 шагов, один поток процессора:

Шагval, простой циклval, train.py
5002.5822.363
10001.9891.958
15001.7791.740
20001.7131.683

По логу полный цикл впереди на всех отметках, а в финале выигрывает 0.03. Но осторожно: лог меряет loss по 20 случайным батчам, и у такой оценки есть свой разброс. Мы 15 раз переоценили одну и ту же обученную модель на разных случайных окнах: значения гуляют со стандартным отклонением около 0.017 при батчах по 16 окон (как в train.py) и около 0.010 при батчах по 32 (как в простом цикле). Разница в 0.03 сравнима с этим шумом. Поэтому финальные модели честнее сравнить по всем данным сразу, без случайности. Добавьте в конец train.py и train_basic.py (проход по всему train на одном потоке займёт минуту-другую):

@torch.no_grad()
def full_loss(model, d, T=128, bs=64):
"""Средний loss по всему тексту d: окна по T символов подряд, без перекрытий и без случайности."""
model.eval()
n = (len(d) - 1) // T # сколько целых окон помещается
x = d[: n * T].view(n, T)
y = d[1 : n * T + 1].view(n, T) # цели — тот же текст со сдвигом на 1
total = sum(model(x[i : i + bs], y[i : i + bs])[1].item() * len(x[i : i + bs]) for i in range(0, n, bs))
model.train()
return total / n
print(f"train {full_loss(model, train_data):.3f} val {full_loss(model, val_data):.3f}")
Финальная модельtrain, весь текст (6262 окна)val, весь текст (695 окон)Разрыв
простой цикл (урок 4.6.2)1.5301.7080.178
train.py1.5321.6950.163

Что видно:

  • Train одинаковый, val лучше на 0.013. Это в два с лишним раза меньше, чем казалось по логу, но уже не шум оценки: модель не выучила обучающий текст лучше, она лучше обобщает. Мы поменяли сразу несколько вещей, так что по отдельности вклад не измерен. Скорее всего, на финал работают weight decay и спуск lr в конце, а что ускорило начало (на шаге 500 разница 0.2), — разогрев, клиппинг или их сочетание — без отдельных запусков не скажешь. Разброс между запусками с разными seed мы тоже не мерили: каждую конфигурацию запускали один раз. Проверьте на своей машине с двумя-тремя seed и с выключением деталей по одной — это и есть честная абляцияабляционный эксперимент, абляцияEN ablation studyУбрать или заменить один компонент и сравнить с полной моделью, чтобы измерить вклад этого компонента.. И мерьте финал через full_loss, а не по последней строке лога.
  • Норма градиента до обрезки — 1.3–3.5 во всех оценках начиная с шага 500. Лог записывает норму раз в 250 шагов, но, судя по этим точкам, после первых сотен шагов клиппинг с порогом 1.0 срабатывал на большинстве шагов. Это не катастрофа: Adam делит шаг на корень из скользящего среднего квадратов градиента, поэтому равномерное уменьшение градиента почти не меняет величину обновления. Клиппинг здесь — страховка от редких выбросов, а не тормоз. Насторожиться стоило бы, если бы норма выросла в десятки раз за несколько шагов.
  • Разрыв train/val ≈ 0.16 — отчасти переобучение (каждый символ модель видела около 10 раз), отчасти разница тем: валидация — последние 10% корпуса, то есть другие уроки курса с другими словами. На Пушкине разрыв будет своим. Val loss при этом всё ещё падал на последней оценке, так что лучший чекпоинт — финальный.

Loss — средний −ln⁡p-\ln p правильного символа, в натах на символ. Сам по себе он ничего не говорит, пока не переведён во что-то понятное и не сравнён с простыми базовыми моделями.

Три перевода одного числа. Возьмём loss 1.5:

  • перплексияперплексияEN perplexity: e1.5≈4.5e^{1.5} \approx 4.5 — модель в среднем колеблется между четырьмя-пятью равновероятными символами. Свежая модель выбирала из всех 137 (e4.92=137e^{4.92} = 137).
  • Биты на символ: 1.5/ln⁡2≈2.161.5 / \ln 2 \approx 2.16. С такой моделью и арифметическим кодированием (способом сжатия, в котором вероятный символ записывается коротким кодом, а маловероятный — длинным) текст можно сжать до 2.16 бита на символ. Предсказывать и сжимать — одна и та же задача.
  • Биты на байт (бит на байтбит на байт (BPB)EN bits per byte (BPB)Loss, пересчитанный на байт исходного текста; в отличие от perplexity, его можно сравнивать между моделями с разными токенизаторами.): русская буква в UTF-8 занимает 2 байта, в среднем по нашему корпусу 1.75 байта на символ, значит 2.16/1.75≈1.242.16 / 1.75 \approx 1.24 бита на байт. Эта метрика не зависит от токенизатора, поэтому ей сравнивают модели с разными словарями (урок 4.2.3).

С чем сравнивать. Посчитаем на том же корпусе и той же валидации простые модели: n-граммы со сглаживанием (предсказание по k−1k - 1 предыдущим символам, как биграммы из урока 4.1.2) и архиватор xz, который сначала «прочитал» обучающую часть.

"""baselines.py — с чем сравнивать loss: n-граммы и архиватор на том же корпусе."""
import collections
import lzma
import math
RU = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
EN = "abcdefghijklmnopqrstuvwxyz"
ALLOWED = set(RU + RU.upper() + EN + EN.upper() + "0123456789" + " \n.,:;!?-—«»()")
text = "".join(ch for ch in open("input.txt", encoding="utf-8").read() if ch in ALLOWED)
n = int(0.9 * len(text))
train, val = text[:n], text[n:]
V = len(set(text))
def ngram_val_nll(k, alpha=0.01):
"""NLL на val для модели «следующий символ по k-1 предыдущим» со сглаживанием alpha."""
grams = collections.Counter(train[i : i + k] for i in range(len(train) - k + 1))
ctx = collections.Counter(train[i : i + k - 1] for i in range(len(train) - k + 2))
total = 0.0
for i in range(len(val) - k + 1):
g = val[i : i + k]
total -= math.log((grams[g] + alpha) / (ctx[g[:-1]] + alpha * V))
return total / (len(val) - k + 1)
print(f"равномерная модель: {math.log(V):.2f}")
for k in range(1, 6):
print(f"{k}-грамма: {ngram_val_nll(k):.2f}")
# xz «читает» train, затем val: считаем, сколько бит добавил именно val
# lzma — встроенный в Python архиватор (тот же алгоритм, что у xz); preset=9 — максимальное сжатие
xz = lambda s: len(lzma.compress(s.encode("utf-8"), preset=9)) * 8 # размер архива в битах
bits = xz(train + val) - xz(train)
print(f"xz (LZMA): {bits / len(val) * math.log(2):.2f} нат/символ ({bits / len(val):.2f} бит)")
равномерная модель: 4.92
1-грамма: 3.56
2-грамма: 2.72
3-грамма: 2.25
4-грамма: 1.95
5-грамма: 2.04
xz (LZMA): 1.93 нат/символ (2.78 бит)

Это числа для нашего корпуса в 890 тысяч символов. На более свежей версии корпуса (1.4 млн символов) вышло 1.88 у 4-грамм и 1.83 у xz: чем больше текста, тем сильнее и простые модели. Поэтому сравнивайте свою GPT с базовыми линиями, посчитанными на вашем input.txt.

Сведём в одну шкалу (val loss, наты на символ):

Модельval lossPerplexity
Равномерная, ln⁡137\ln 1374.92137
Частоты букв (1-грамма)3.5635
Биграммы2.7215
4-граммы (лучшие из n-грамм)1.957.0
Архиватор xz1.936.9
Наша GPT, 0.83M параметров, 2000 шагов на CPU (full_loss)1.705.4
nanoGPT на «Шекспире», CPU-конфигурация нашего размера: 4 слоя, d=128d = 128, контекст 64, 2000 шагов (для ориентира)1.886.6
nanoGPT на «Шекспире», 10.7M параметров, GPU (для ориентира)1.474.3

Обратите внимание на 5-граммы: они хуже 4-грамм. Длинный контекст в n-грамме почти всегда новый, его не было в обучающем тексте, и модель откатывается к сглаживанию. Таблица счётчиков не умеет обобщать: «кошка спит» и «кот спит» для неё не имеют ничего общего. Трансформер обобщает через эмбеддинги и внимание, поэтому с ростом контекста становится только лучше.

Наш результат — 1.70 (по всей валидации, как и у n-грамм). Это 2.45 бита на символ и 1.40 бита на байт. Модель на 0.8M параметров, обученная двадцать минут на одном ядре процессора, предсказывает текст заметно лучше лучшей n-граммы (perplexity 5.4 против 7.0) и лучше архиватора xz.

А что значит 1.5 в нашей задаче? Это следующая ступень: perplexity 4.5 вместо 5.4, примерно уровень baby GPT из nanoGPT на «Шекспире». Train loss мы уже довели до 1.53, а val пока 1.70 и снижается всё медленнее: маленькой модели не хватает ёмкости, а корпусу — объёма и разнообразия. Путь к 1.5 и ниже — модель побольше, больше текста и больше шагов, то есть видеокарта (раздел ниже). Чем ниже loss, тем дороже каждая следующая сотая: эта зависимость и есть законы масштабированиязаконы масштабированияEN scaling laws (урок 5.1.1).

Проверь себя6 вопросов
1 Микробатч 16 окон, 4 шага накопления, 4 GPU. Каков эффективный размер батча в окнах?
2 Норма всех градиентов модели — 13, порог клиппинга — 1.0. На какое число умножатся все градиенты? Округлите до трёх знаков.
3 Что случится, если при накоплении градиентов по 4 микробатчам забыть делить loss на 4?
4 Какие утверждения про расписание learning rate верны?Можно выбрать несколько ответов
5 Символьная модель дала val loss 1.5 (наты). Чему равна perplexity? Округлите до одного знака.
6 Почему валидацию отрезают непрерывным куском в конце текста, а не набирают из случайных окон?
  • Батч — BB случайных окон из непрерывного текста и те же окна со сдвигом; валидация — отдельный непрерывный кусок. Обучение удобнее мерить токенами, а не эпохами.
  • AdamW: weight decay только для матриц и эмбеддингов. Стандарт LLM: β=(0.9,0.95)\beta = (0.9, 0.95), weight decay 0.1, клиппинг 1.0.
  • Расписание: разогрев защищает начало, спуск даёт финальный выигрыш. Альтернативы косинусу — ступени DeepSeek и WSD из MiniCPM.
  • Клиппинг ограничивает длину шага общей нормой градиента (норму до обрезки полезно логировать). Накопление градиентов с делением loss на KK даёт ровно тот же градиент, что большой батч.
  • Финальные модели сравнивают по всей валидации: шум оценки по случайным батчам сравним с разницей между вариантами. Полный цикл дал val 1.695 против 1.708 у простого при одинаковом train loss.
  • Наша GPT (val 1.70, perplexity 5.4, 2.45 бита на символ) обошла лучшие n-граммы (1.95) и архиватор xz (1.93). Loss сравнивают только на одном тексте с одним токенизатором, а между токенизаторами — в битах на байт.

Дальше: модель обучена — научим её быстро говорить. Разберём, почему генерация без кэша тратит работу впустую, и добавим KV-кэш — Генерация и KV-кэш.