Цикл обучения LLM
Как устроен настоящий цикл обучения языковой модели — батчи из непрерывного текста, AdamW с разогревом и косинусом, клиппинг и накопление градиентов, кривые train/val — и что на самом деле значит loss 1.5.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке цикл обучения был минимальным: случайный батч, 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
Несколько следствий, о которых полезно помнить.
Обучающих примеров больше, чем окон. Окно длины даёт предсказаний: по первому символу угадать второй, по двум первым — третий и так далее. Батч — это 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Документы склеивают в один поток токенов через разделитель и режут на окна фиксированной длины — без паддинга.: ни один токен окна не тратится на пустое заполнение.
Модель сама учится, что после токена-разделителя начинается новая тема.
AdamW и weight decay по группам
Заголовок раздела «AdamW и weight decay по группам»Оптимизатор — AdamWAdamWEN AdamW (Adam with decoupled weight decay)Adam, в котором затухание весов применяется отдельно от адаптивного шага. Оптимизатор по умолчанию для обучения LLM. из урока 1.4.2: Adam с адаптивными шагами плюс затухание весовзатухание весов (weight decay)EN weight decayНа каждом шаге веса умножаются на (1 − ηλ) — «притягиваются» к нулю. Для обычного SGD равносильно L2-регуляризации, для Adam — нет., который на каждом шаге немного уменьшает веса: . Вопрос, какие веса уменьшать.
# p.dim() — число осей тензора: у матрицы 2, у вектора смещений 1decay = [p for p in model.parameters() if p.dim() >= 2] # матрицы и эмбеддингиno_decay = [p for p in model.parameters() if p.dim() < 2] # смещения и параметры LayerNormoptimizer = 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 удивительно похожи у всех ( и — коэффициенты скользящих средних градиента и его квадрата в Adam, урок 1.4.2):
| Модель | , | weight decay | Клиппинг | Батч, токенов |
|---|---|---|---|---|
| GPT-3 175B (2020) | 0.9, 0.95 | 0.1 | 1.0 | 3.2 млн |
| Llama 2 (2023) | 0.9, 0.95 | 0.1 | 1.0 | 4 млн |
| DeepSeek-V3 (2024) | 0.9, 0.95 | 0.1 | 1.0 | до 63 млн (15 360 × 4096) |
| MiniMind (pretrain) | 0.9, 0.999 (по умолчанию в PyTorch) | 0.01 (по умолчанию) | 1.0 | ≈ 87 тыс. (32 × 8 × 340) |
Наш train.py | 0.9, 0.99 | 0.1 | 1.0 | 4096 (32 × 128) |
Почему у нас , а не 0.95? Так советует nanoGPT для маленьких батчей: при 4096 токенах на шаг градиент шумный, и более длинная память второго момента его сглаживает.
Расписание learning rate: разогрев и косинус
Заголовок раздела «Расписание learning rate: разогрев и косинус»скорость обученияскорость обученияEN learning rate — главный гиперпараметр. Современный стандарт — менять его по ходу обучения:
Что здесь написано: первые шагов lr линейно растёт от почти нуля до . Потом по половинке косинуса плавно спускается до к последнему шагу . Множитель равен 1 при , 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)С нашими настройками (, , , ):
| Шаг | 0 | 49 | 99 | 575 | 1050 | 1525 | 2000 |
|---|---|---|---|---|---|---|---|
| lr | 1.0e-5 | 5.0e-4 | 1.0e-3 | 8.7e-4 | 5.5e-4 | 2.3e-4 | 1.0e-4 |
Зачем разогрев (разогрев learning rateразогрев learning rate (warmup)EN learning rate warmupПервые сотни–тысячи шагов learning rate линейно растёт от нуля до пика, пока статистики Adam не накопились.). В начале обучения всё плохо откалибровано: веса случайные, градиенты крупные и непредсказуемые, а оценки моментов в Adam построены по нескольким шагам и сильно шумят. Полный lr в этот момент — большой шаг в случайном направлении. Модель может «вылететь» в область, где loss растёт или становится NaN (Not a Number, «не число» — так float обозначает результат вроде или ), и уже не вернуться. Разогрев даёт оптимизатору освоиться. В Llama 2 и DeepSeek-V3 он длится 2000 шагов, у нас — 100.
Зачем спуск (косинусное затухание learning rateкосинусное затухание learning rateEN cosine decay (cosine annealing)После разогрева learning rate плавно спадает от пика до минимума по половине косинусоиды.). С большим lr модель быстро идёт к хорошей области, но не может в ней «осесть»: шум мини-батчей качает её вокруг минимума. Уменьшение lr гасит эти колебания, и loss в конце обучения заметно проседает. Часто именно последние 10–20% шагов дают ощутимую часть итогового выигрыша.
Почему до , а не до нуля. Так делали GPT-3, Llama 2 и nanoGPT: при lr, близком
к нулю, последние сотни шагов почти ничего не меняют. Это эмпирическое правило, а не закон. Микро-GPT из
урока 4.6.1 спускала lr линейно до нуля, и некоторые исследователи считают такой вариант
даже лучшим. Проверьте сами: поставьте min_lr = 0 и сравните финальный val loss.
У косинуса есть неудобство: длину обучения надо знать заранее. Захотели учить дольше — расписание уже «спустилось». Поэтому появились альтернативы, и две из них предложили китайские команды (см. китайский угол): ступенчатое расписание 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. ограничивает длину шага:
Что здесь написано: все градиенты модели рассматриваются как один длинный вектор. Если его длина больше порога (почти всегда ), весь вектор уменьшается до длины . Направление не меняется, меняется только длина. Числовой пример:
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² = 25w2.grad = torch.tensor([12.0]) # вклад: 12² = 144norm = torch.nn.utils.clip_grad_norm_([w1, w2], max_norm=1.0)print(f"норма до обрезки: {norm:.1f}") # sqrt(25 + 144) = 13print("после:", [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, пропорции сохранились. Функция 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 каждого микробатча — среднее по его примерам.
Сумма средних в раз больше среднего по большому батчу. Без деления градиент окажется в раз крупнее,
что равносильно lr в раз выше. Проверим численно, что с делением результат совпадает с настоящим большим батчем:
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 делим на 4model.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() # градиенты складываются в .gradg_acc = torch.cat([p.grad.flatten() for p in model.parameters()])
# 3) типичная ошибка: забыли поделить на 4model.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 разаРазница — это ошибки округления float32. Накопление даёт тот же градиент, что большой батч, только медленнее: микробатчи идут по очереди. Итоговый размер — эффективный размер батчаэффективный размер батчаEN effective batch sizeСколько примеров (или токенов) приходится на один шаг оптимизатора: микробатч × шаги накопления градиентов × число GPU.:
У нас окна. У MiniMind последовательностей на одной карте. При обучении на нескольких GPU каждая карта считает свои микробатчи, а градиенты усредняются между картами — это data parallel (урок 5.4.3).
Train и val loss
Заголовок раздела «Train и val loss»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 outmodel.eval() отключает dropout, torch.no_grad() не строит граф (быстрее и без лишней памяти). Не забудьте
вернуть model.train().
Как читать две кривые:
| Что видим | Что это значит | Что делать |
|---|---|---|
| Обе падают, близко друг к другу | модель учится и обобщает | учить дальше |
| Train падает, val стоит или растёт | переобучениепереобучениеEN overfitting: модель зубрит текст | больше данных, dropout, меньше модель, остановиться раньше |
| Обе стоят высоко | недообучение или слишком маленький lr | больше модель, больше шагов, проверить lr |
| Скачок вверх, потом медленный спуск | всплеск loss | клиппинг, меньше lr, длиннее разогрев |
Лучшую по val модель сохраняем — это ранняя остановкаранняя остановкаEN early stopping в мягкой форме: учим до конца, но в итоге берём чекпоинт с минимальным val loss.
Полный train.py
Заголовок раздела «Полный train.py»Соберём всё вместе. Модель и данные те же, что в прошлом уроке (model.py, input.txt), эффективный батч тот же
(32 окна по 128 символов), шагов тоже 2000 — чтобы сравнение было честным.
"""train.py — полноценный цикл обучения: warmup + cosine, weight decay, клиппинг, накопление градиентов.
python train.py (рядом model.py и input.txt)"""import mathimport osimport 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 микробатчей → эффективный батч 32block_size = 128max_steps = 2000warmup_steps = 100max_lr, min_lr = 1e-3, 1e-4weight_decay = 0.1grad_clip = 1.0eval_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] # смещения и параметры LayerNormoptimizer = 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 |
|---|---|---|
| 500 | 2.582 | 2.363 |
| 1000 | 1.989 | 1.958 |
| 1500 | 1.779 | 1.740 |
| 2000 | 1.713 | 1.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.530 | 1.708 | 0.178 |
train.py | 1.532 | 1.695 | 0.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 1.5
Заголовок раздела «Что значит loss 1.5»Loss — средний правильного символа, в натах на символ. Сам по себе он ничего не говорит, пока не переведён во что-то понятное и не сравнён с простыми базовыми моделями.
Три перевода одного числа. Возьмём loss 1.5:
- перплексияперплексияEN perplexity: — модель в среднем колеблется между четырьмя-пятью равновероятными символами. Свежая модель выбирала из всех 137 ().
- Биты на символ: . С такой моделью и арифметическим кодированием (способом сжатия, в котором вероятный символ записывается коротким кодом, а маловероятный — длинным) текст можно сжать до 2.16 бита на символ. Предсказывать и сжимать — одна и та же задача.
- Биты на байт (бит на байтбит на байт (BPB)EN bits per byte (BPB)Loss, пересчитанный на байт исходного текста; в отличие от perplexity, его можно сравнивать между моделями с разными токенизаторами.): русская буква в UTF-8 занимает 2 байта, в среднем по нашему корпусу 1.75 байта на символ, значит бита на байт. Эта метрика не зависит от токенизатора, поэтому ей сравнивают модели с разными словарями (урок 4.2.3).
С чем сравнивать. Посчитаем на том же корпусе и той же валидации простые модели: n-граммы со сглаживанием (предсказание по предыдущим символам, как биграммы из урока 4.1.2) и архиватор xz, который сначала «прочитал» обучающую часть.
"""baselines.py — с чем сравнивать loss: n-граммы и архиватор на том же корпусе."""import collectionsimport lzmaimport 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.921-грамма: 3.562-грамма: 2.723-грамма: 2.254-грамма: 1.955-грамма: 2.04xz (LZMA): 1.93 нат/символ (2.78 бит)Это числа для нашего корпуса в 890 тысяч символов. На более свежей версии корпуса (1.4 млн символов) вышло 1.88
у 4-грамм и 1.83 у xz: чем больше текста, тем сильнее и простые модели. Поэтому сравнивайте свою GPT с базовыми
линиями, посчитанными на вашем input.txt.
Сведём в одну шкалу (val loss, наты на символ):
| Модель | val loss | Perplexity |
|---|---|---|
| Равномерная, | 4.92 | 137 |
| Частоты букв (1-грамма) | 3.56 | 35 |
| Биграммы | 2.72 | 15 |
| 4-граммы (лучшие из n-грамм) | 1.95 | 7.0 |
| Архиватор xz | 1.93 | 6.9 |
Наша GPT, 0.83M параметров, 2000 шагов на CPU (full_loss) | 1.70 | 5.4 |
| nanoGPT на «Шекспире», CPU-конфигурация нашего размера: 4 слоя, , контекст 64, 2000 шагов (для ориентира) | 1.88 | 6.6 |
| nanoGPT на «Шекспире», 10.7M параметров, GPU (для ориентира) | 1.47 | 4.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).
Проверь себя
Заголовок раздела «Проверь себя»- Батч — случайных окон из непрерывного текста и те же окна со сдвигом; валидация — отдельный непрерывный кусок. Обучение удобнее мерить токенами, а не эпохами.
- AdamW: weight decay только для матриц и эмбеддингов. Стандарт LLM: , weight decay 0.1, клиппинг 1.0.
- Расписание: разогрев защищает начало, спуск даёт финальный выигрыш. Альтернативы косинусу — ступени DeepSeek и WSD из MiniCPM.
- Клиппинг ограничивает длину шага общей нормой градиента (норму до обрезки полезно логировать). Накопление градиентов с делением loss на даёт ровно тот же градиент, что большой батч.
- Финальные модели сравнивают по всей валидации: шум оценки по случайным батчам сравним с разницей между вариантами. Полный цикл дал 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-кэш.