Перейти к содержимому
Ступень 33.3 PyTorch

Как отлаживать обучение

Ошибки в обучении нейросетей молчат. Учимся их ловить: loss на старте, переобучение одного батча, мониторинг градиентов, галерея типичных багов с реальными цифрами, seed и детерминизм — и китайская «алхимия» («ляньдань»).

УРОКstage-3/3-3-pytorch/03-debugging.mdx2 чУрок 3 из 3
После урока вы сможете
  • Проверять конвейер до долгого запуска — loss на старте около ln⁡C\ln C и переобучение одного батча
  • Следить за loss, нормой градиента и отношением «обновление / вес» и ставить по ним диагноз
  • Узнавать по симптомам забытый zero_grad, двойной softmax, перепутанные оси и слишком большой learning rate
  • Фиксировать seed, понимать пределы детерминизма и не делать выводов из одного запуска

В уроке 0.2.5 вы учились читать traceback: программа падает и сама показывает строку с ошибкой. В обучении нейросетей так бывает только с самыми простыми багами — неправильная форма тензора, не тот dtype. Остальные ошибки молчат. Карпатый сформулировал это так: neural net training fails silently. Код запускается, loss падает, а модель просто учится немного хуже, чем могла бы. Узнать об этом можно, только если специально искать.

В китайском ML-сообществе обучение моделей называют «ляньдань»«алхимия» («ляньдань») — сленг для обучения моделейEN alchemy (Chinese ML slang for training models)Буквально «выплавлять пилюлю бессмертия». Обучение нейросети, особенно подбор гиперпараметров на ощупь. «Ляньдань-ши» («мастер алхимии») — ML-инженер, «ляньдань-лу» («алхимическая печь») — сервер с GPU. — «алхимией», выплавкой пилюли бессмертия. Закладываешь ингредиенты в печь, ждёшь, смотришь, что вышло. В шутке есть правда: многое в обучении действительно подбирается на ощупь. Но большая часть «алхимии» — это непойманные баги. Этот урок — набор инструментов, которые превращают её в инженерию.

Все примеры используют mnist_mlp.py из прошлого урока: положите новые скрипты рядом с ним.

Самое надёжное место для проверки — строка прямо перед logits = model(x). Напечатайте формы, dtype и пару чисел. Нарисуйте несколько картинок вместе с метками. Проверьте, что метки совпадают с картинками, а входы стандартизированы: среднее около 0, разброс около 1. Половина багов с данными видна глазами за минуту.

Свежая модель ничего не знает и даёт почти равномерное распределение по CC классам. Значит, её перекрёстная энтропияперекрёстная энтропияEN cross-entropy должна быть около −ln⁡(1/C)=ln⁡C-\ln(1/C) = \ln C (урок 1.3):

ЗадачаКлассов CCОжидаемый loss на старте
бинарная классификация2ln⁡2≈0.69\ln 2 \approx 0.69
MNIST10ln⁡10≈2.30\ln 10 \approx 2.30
LLM со словарём Qwen151 936ln⁡151 936≈11.93\ln 151\,936 \approx 11.93

В прошлом уроке первая строка лога была loss 2.3326 — всё в порядке. Если бы там стояло 15 или 40, модель на старте была бы уверенно неправа. Обычно это плохая инициализация последнего слоя или неверно собранный loss.

Главный тест конвейера. Возьмите один маленький батч и обучайте на нём сотни шагов. Модель с сотнями тысяч параметров обязана запомнить 32 примера — loss должен упасть почти до нуля:

import math
import torch
import torch.nn.functional as F
from mnist_mlp import MLP, MNIST
torch.manual_seed(0)
ds = MNIST("train")
x, y = ds.x[:32], ds.y[:32] # ОДИН батч из 32 примеров
model = MLP()
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(201):
logits = model(x)
loss = F.cross_entropy(logits, y)
opt.zero_grad()
loss.backward()
opt.step()
if step in (0, 10, 20, 50, 100, 200):
acc = (logits.argmax(dim=1) == y).float().mean().item()
print(f"шаг {step:3d}: loss {loss.item():.4f}, accuracy {acc:.2f}")
print("ожидаемый loss в начале: ln 10 =", round(math.log(10), 4))
шаг 0: loss 2.2821, accuracy 0.25
шаг 10: loss 0.1318, accuracy 1.00
шаг 20: loss 0.0074, accuracy 1.00
шаг 50: loss 0.0006, accuracy 1.00
шаг 100: loss 0.0004, accuracy 1.00
шаг 200: loss 0.0002, accuracy 1.00

Что доказывает этот тест: прямой проход, loss, backward() и шаг оптимизатора связаны правильно, и градиент действительно уменьшает loss. Что он не доказывает: что модель обобщает. Это проверит валидация. Если же даже один батч не запоминается, дальше идти бессмысленно. Ищите баг в модели, функции потерь или оптимизаторе — не в данных и не в гиперпараметрах.

Одного числа loss мало. Опытные «алхимики» следят минимум за четырьмя величинами:

  • train loss — на каждом шаге (он шумный, поэтому смотрите на скользящее среднее — среднее по последним, скажем, 50 шагам; родственник EMA из урока 1.4.2);
  • val loss и accuracy — раз в эпоху или раз в N шагов;
  • норма градиентанорма градиентаEN gradient normДлина вектора всех градиентов модели (обычно L2). Главный «пульс» обучения: резкий рост предвещает всплеск loss. — длина вектора, составленного из всех градиентов модели (норма из урока 1.1.1: корень из суммы квадратов). Одно число, которое говорит, насколько сильно «тянет» градиент;
  • отношение «обновление / вес» — насколько сильно шаг меняет веса каждого слоя. Если веса слоя в среднем порядка 0.05, а шаг сдвинул их на 0.00005, отношение равно 10−310^{-3}: за шаг веса изменились на одну тысячную.

Норму градиента удобно получать функцией clip_grad_norm_. С бесконечным порогом она ничего не обрезает, но возвращает общую норму:

import torch
import torch.nn.functional as F
from mnist_mlp import MLP, MNIST
ds = MNIST("train")
X, Y = ds.x[:10_000], ds.y[:10_000]
torch.manual_seed(0)
model = MLP()
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(301):
idx = torch.randint(0, 10_000, (64,)) # случайный батч
loss = F.cross_entropy(model(X[idx]), Y[idx])
opt.zero_grad()
loss.backward()
total = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=float("inf")) # общая норма градиента
# копия весов до шага: словарь «имя → тензор»; detach() отрезает копию от графа, clone() копирует числа
before = {n: p.detach().clone() for n, p in model.named_parameters()}
opt.step()
if step in (0, 100, 300):
print(f"шаг {step}: loss {loss.item():.3f}, |grad| всей модели {total.item():.3f}")
for n, p in model.named_parameters():
if p.ndim == 2: # только матрицы весов (ndim — число осей)
# насколько сдвинулись веса за шаг, относительно их типичного размера
upd = ((p.detach() - before[n]).std() / before[n].std()).item()
print(f" {n:12} |grad| {p.grad.norm().item():.3f} обновление/вес {upd:.1e}")
шаг 0: loss 2.310, |grad| всей модели 2.202
net.1.weight |grad| 1.935 обновление/вес 4.8e-02
net.3.weight |grad| 1.041 обновление/вес 2.7e-02
шаг 100: loss 0.293, |grad| всей модели 1.342
net.1.weight |grad| 0.745 обновление/вес 7.8e-03
net.3.weight |grad| 1.115 обновление/вес 4.1e-03
шаг 300: loss 0.087, |grad| всей модели 0.921
net.1.weight |grad| 0.429 обновление/вес 5.4e-03
net.3.weight |grad| 0.814 обновление/вес 3.0e-03

Как читать. Норма градиента плавно снижается — здоровая картина. Резкий рост нормы обычно предвещает всплеск lossвсплеск lossEN loss spikeРезкий скачок функции потерь во время обучения. В больших моделях может испортить многодневный прогон, поэтому техотчёты отдельно сообщают об их отсутствии.. Отношение «обновление / вес» показывает, на какую долю меняются веса за один шаг. В видео Карпатого о makemore (часть 3) ориентир — около 10−310^{-3}. Около 10−110^{-1} означает, что веса переписываются почти целиком на каждом шаге и lr слишком велик. 10−510^{-5} и меньше — слой почти не учится. У нас на старте 5⋅10−25 \cdot 10^{-2}: первый шаг Adam всегда крупный. Дальше отношение оседает около 33–8⋅10−38 \cdot 10^{-3}.

В больших проектах эти графики пишут в TensorBoard, Weights & Biases или китайский SwanLab — о нём ниже.

flowchart TD
A["Loss ведёт себя странно"] --> B{"Loss на старте ≈ ln C?"}
B -->|"нет"| B1["Ошибка в последнем слое<br/>или в функции потерь"]
B -->|"да"| C{"Один батч переобучается?"}
C -->|"нет"| C1["Баг в модели, loss или оптимизаторе:<br/>zero_grad, softmax дважды, оси"]
C -->|"да"| D{"Train loss падает на всех данных?"}
D -->|"нет, растёт или NaN"| D1["Слишком большой lr:<br/>уменьшить в 3–10 раз, клиппинг"]
D -->|"нет, стоит"| D2["Слишком маленький lr<br/>или модель слишком мала"]
D -->|"да"| E{"Val loss тоже падает?"}
E -->|"нет"| E1["Переобучение или утечка /<br/>разные распределения train и val"]
E -->|"да"| F["Всё работает — можно подбирать гиперпараметры"]
Дерево диагностики: идём сверху вниз и не перескакиваем шаги

Соберём «лабораторию»: одна функция обучения, каждый баг включается флагом. 10 000 картинок, 300 шагов, батч 64, одинаковый seed — чтобы сравнение было честным. Сохраните код в bug_lab.py: он понадобится и дальше в уроке.

"""bug_lab.py — одна функция обучения, баги включаются флагами."""
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader, Subset
from mnist_mlp import MLP, MNIST, evaluate
small = Subset(MNIST("train"), range(10_000)) # 10 000 картинок для быстрых опытов
test_loader = DataLoader(MNIST("test"), batch_size=1000)
def train(steps=300, lr=1e-3, opt_name="adamw", seed=0,
forget_zero_grad=False, softmax_twice=False, log_every=100, verbose=True):
torch.manual_seed(seed)
model = MLP()
opt_cls = torch.optim.AdamW if opt_name == "adamw" else torch.optim.SGD
opt = opt_cls(model.parameters(), lr=lr)
loader = DataLoader(small, batch_size=64, shuffle=True)
step = 0
while step < steps:
for x, y in loader:
logits = model(x)
if softmax_twice:
logits = logits.softmax(dim=1) # БАГ: cross_entropy сама применит softmax
loss = F.cross_entropy(logits, y)
if not forget_zero_grad: # БАГ при forget_zero_grad=True
opt.zero_grad()
loss.backward()
opt.step()
if verbose and (step % log_every == 0 or step == steps - 1):
print(f" шаг {step:3d}: loss {loss.item():.4f}")
step += 1
if step == steps:
break
acc = evaluate(model, test_loader, "cpu")[1]
if verbose:
print(f" → test accuracy {acc:.4f}")
return acc
if __name__ == "__main__": # при import из других скриптов опыты не запускаются
train(opt_name="sgd", lr=0.1) # норма
train(opt_name="sgd", lr=0.1, forget_zero_grad=True) # баг 1
train() # норма, AdamW
train(forget_zero_grad=True) # баг 1 с AdamW
train(softmax_twice=True) # баг 2

Результаты нашего запуска (loss — на одном батче, поэтому шумный):

Запускloss, шаг 100loss, шаг 299test accuracy
SGD, lr = 0.1 — норма0.2180.33092.3%
SGD — забыли zero_grad1.0765.98622.2%
AdamW, lr = 1e-3 — норма0.2050.33792.1%
AdamW — забыли zero_grad1.3881.69657.7%
AdamW — softmax дважды1.5541.58491.5%

Строку «SGD — забыли zero_grad» вы, скорее всего, не повторите в точности. Это обучение неустойчиво, и крошечные различия в округлении за сотни шагов вырастают в совсем другой результат. У нас один и тот же скрипт при 1, 2 и 4 потоках процессора дал loss на шаге 299 = 2.6, 16.7 и 6.0 и accuracy 17–22%. Откуда берутся такие различия, вы узнаете в разделе о детерминизме. Остальные строки устойчивы: у вас они совпадут с нашими или разойдутся лишь в последних знаках.

Симптом: loss сначала падает, потом начинает расти или скакать. Почему: backward() прибавляет новый градиент к старым (урок 3.3.1). На шаге kk в .grad лежит сумма всех kk градиентов. SGD делает шаг пропорционально этой сумме, и шаг растёт с каждой итерацией. К концу loss поднимается выше, чем у необученной модели (ln⁡10≈2.3\ln 10 \approx 2.3), а accuracy падает до 20%. Adam нормирует размер шага, поэтому не взрывается, но направление определяет давняя история, а не текущий батч. Это как momentum, который ничего не забывает. Результат — 57.7% вместо 92%.

Лечение: opt.zero_grad() в каждой итерации. Исключение — сознательное накопление градиентовнакопление градиентовEN gradient accumulationГрадиенты складываются (+=), а не перезаписываются. Внутри графа так учитываются все пути к узлу; на уровне обучения — шаг оптимизатора делают раз в несколько микробатчей.: несколько backward() подряд, затем один step() и zero_grad(). Так делает MiniMind, чтобы получить большой батч на маленькой видеокарте.

Симптом: loss застревает на подозрительно «круглом» уровне и не опускается, хотя accuracy выглядит прилично. Почему: F.cross_entropy сама применяет softmax. Если модель уже вернула вероятности, «логиты» лежат в [0,1][0, 1]. Лучшее, что может сделать модель, — выдать [1,0,…,0][1, 0, \dots, 0], и тогда loss равен

−ln⁡e1e1+(C−1)e0=ln⁡(e+C−1)−1≈1.461при C=10-\ln \frac{e^{1}}{e^{1} + (C - 1) e^{0}} = \ln(e + C - 1) - 1 \approx 1.461 \quad \text{при } C = 10

Ниже 1.46 loss не опустится никогда. У нас он стоит на 1.55–1.58, а градиенты при этом слабые: softmax в насыщении почти не пропускает сигнал. Коварство в том, что accuracy — 91.5%: argmax от softmax тот же, что и от логитов. Если смотреть только на accuracy, баг можно не заметить месяцами.

Лечение: модель возвращает логиты; softmax — только при генерации или выводе вероятностей. Если в модели всё-таки стоит LogSoftmax, то к ней нужна F.nll_loss, а не F.cross_entropy.

Самая коварная семья багов: формы тензоров совпадают «достаточно», чтобы не было ошибки. Напомним: «ось» — это номер измерения массива (урок 0.3.1). У батча картинок формы (B, 28, 28) ось 0 — номер примера, оси 1 и 2 — строки и столбцы пикселей. Три примера.

Броадкастинг в регрессии. Модель nn.Linear(3, 1) возвращает форму (B, 1), а метки имеют форму (B,):

import torch
import torch.nn as nn
torch.manual_seed(0)
X = torch.randn(512, 3)
y = X @ torch.tensor([2.0, -1.0, 0.5]) + 0.1 * torch.randn(512) # форма (512,)
def fit(buggy):
torch.manual_seed(0)
model = nn.Linear(3, 1)
opt = torch.optim.SGD(model.parameters(), lr=0.1)
for _ in range(200):
pred = model(X) # форма (512, 1)!
target = y if buggy else y.unsqueeze(1) # (512,) или (512, 1)
loss = ((pred - target) ** 2).mean() # (512,1) - (512,) → (512, 512) молча
opt.zero_grad(); loss.backward(); opt.step()
w = [round(v, 3) for v in model.weight.data.squeeze().tolist()]
print(f"{'с багом ' if buggy else 'без бага'}: MSE {loss.item():.3f}, веса {w}")
fit(buggy=False)
fit(buggy=True)
без бага: MSE 0.010, веса [2.0, -1.0, 0.503]
с багом : MSE 5.415, веса [0.0, 0.0, 0.0]

Разность (512, 1) и (512,) по правилам броадкастинга (урок 0.3) превращается в матрицу 512 × 512: каждый прогноз сравнивается с каждой меткой. Лучшее, что можно сделать в такой постановке, — предсказывать среднее, и модель честно обнулила веса. F.mse_loss в этом случае хотя бы выдаёт предупреждение Using a target size ... that is different to the input size. Самописная формула молчит.

view вместо transpose. view и reshape меняют форму, сохраняя порядок чисел в памяти. transpose и permute переставляют оси. На матрице 2 × 3 с числами 0…5 a.view(3, 2) даёт [[0, 1], [2, 3], [4, 5]], а a.T — [[0, 3], [1, 4], [2, 5]]. Формы одинаковые, содержимое разное. Хуже всего, когда такая ошибка перемешивает примеры батча: сеть продолжит учиться, просто хуже. Карпатый предлагает ловить это градиентом: пусть loss зависит только от примера 0. Тогда градиент должен прийти только в пример 0:

import torch
B, T, C = 4, 3, 2
x = torch.randn(B, T, C, requires_grad=True) # батч из 4 последовательностей
def check(f, name):
x.grad = None
out = f(x)
out[0].sum().backward() # loss зависит только от примера 0...
touched = (x.grad.abs().sum(dim=(1, 2)) > 0).tolist()
print(f"{name:28} градиент дошёл до примеров: {touched}")
check(lambda t: t.transpose(1, 2), "transpose(1, 2) → (B, C, T)")
check(lambda t: t.transpose(0, 1).reshape(B, T * C), "transpose(0,1) + reshape")
transpose(1, 2) → (B, C, T) градиент дошёл до примеров: [True, False, False, False]
transpose(0,1) + reshape градиент дошёл до примеров: [True, True, True, False]

Во втором случае «пример 0» на выходе собран из кусков примеров 0, 1 и 2 — батч перемешан. Тот же приём проверяет причинную маску в GPT: выход на позиции tt не должен зависеть от позиций после tt (урок 4.4).

Логиты языковой модели. У LLM логиты имеют форму (B, T, V), а F.cross_entropy ждёт (N, C). Если передать их как есть, PyTorch сочтёт классами ось 1 и упадёт. Например, при B = 2, T = 5, V = 7 ошибка будет такой: Expected target size [2, 7], got [2, 5]. Правильно — развернуть батч и позиции в одну ось: F.cross_entropy(logits.view(-1, V), targets.view(-1)). Ровно так считает loss MiniMind.

Обучим тот же MLP обычным SGD по 100 шагов с разными lr. Кроме loss и accuracy посчитаем «мёртвые» нейроны. Мёртвый нейрон — тот, чей ReLU выдаёт 0 на всех примерах. Градиент через него не идёт, и он больше не учится. model.net[:3] — первые три слоя Sequential (Flatten, Linear, ReLU), то есть выход скрытого слоя.

"""lr_lab.py — что делает с MLP слишком большой learning rate."""
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from bug_lab import small, test_loader
from mnist_mlp import MLP, evaluate
X = small.dataset.x[:10_000] # small.dataset — весь MNIST; берём те же 10 000 картинок
for lr in [0.1, 1, 3, 1000]:
torch.manual_seed(0)
model = MLP()
opt = torch.optim.SGD(model.parameters(), lr=lr)
losses = []
for step, (x, y) in enumerate(DataLoader(small, batch_size=64, shuffle=True)):
loss = F.cross_entropy(model(x), y)
if not torch.isfinite(loss): # inf или NaN: дальше учить бессмысленно
break
opt.zero_grad()
loss.backward()
opt.step()
losses.append(loss.item())
if step == 99: # 100 шагов
break
if not torch.isfinite(loss):
print(f"lr={lr:<4} макс. loss {max(losses):9.3g} на шаге {step} loss = {loss.item()}")
continue
with torch.no_grad():
h = model.net[:3](X) # выход ReLU: (10000, 256)
dead = (h.max(dim=0).values == 0).float().mean().item() # молчит на ВСЕХ примерах
acc = evaluate(model, test_loader, "cpu")[1]
print(f"lr={lr:<4} макс. loss {max(losses):9.3g} loss в конце {losses[-1]:.3f} "
f"мёртвых ReLU {dead:4.0%} accuracy {acc:.1%}")
lr=0.1 макс. loss 2.31 loss в конце 0.456 мёртвых ReLU 0% accuracy 88.0%
lr=1 макс. loss 20.7 loss в конце 2.440 мёртвых ReLU 75% accuracy 22.9%
lr=3 макс. loss 2.89e+03 loss в конце 2.203 мёртвых ReLU 86% accuracy 19.7%
lr=1000 макс. loss 1.33e+33 на шаге 15 loss = nan

Посмотрите на строку lr = 3. Loss взлетел до тысяч, а потом опустился к 2.2 — почти к ln⁡10≈2.30\ln 10 \approx 2.30, уровню случайного угадывания. Это подпись «умершей» сети. Огромный шаг загнал 86% нейронов в отрицательную зону ReLU, и сеть почти перестала различать входы: accuracy 20% при 10% у случайного ответа. При lr = 1000 веса за 15 шагов разогнались так, что loss дошёл до 103310^{33}, а следующий шаг дал nan. Отсюда два правила: если loss посреди обучения вдруг встал около ln⁡C\ln C, ищите взорвавшийся шаг; если loss стал nan, учить дальше бессмысленно — откатывайтесь к последнему чекпоинту и уменьшайте lr.

При lr = 1 и 3 обучение хаотично, поэтому ваши проценты могут отличаться на несколько, а то и на десяток пунктов. У нас при двух потоках процессора вместо одного вышло 71% и 85% мёртвых нейронов и accuracy 27% и 11%. Картина при этом та же.

Лечение: уменьшить lr в 3–10 раз; добавить разогрев (warmup) из урока 1.4; включить обрезку градиентаобрезка градиента (gradient clipping)EN gradient clippingЕсли норма градиента больше порога, градиент масштабируют до порога: clip_grad_norm_(params, 1.0). Стандарт в обучении LLM. torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) между backward() и step(). Чтобы поймать NaN в момент появления, проверяйте torch.isfinite(loss) на каждом шаге. Для поиска операции, где он родился, включите torch.autograd.set_detect_anomaly(True): обучение замедлится, но traceback укажет на виновника. Почему градиенты вообще взрываются и как помогает инициализация — в уроке «Инициализация и нормализация».

  1. Забыли model.eval() при оценке. Dropout продолжает выключать нейроны, и метрики «скачут» от запуска к запуску.
  2. total_loss += loss вместо loss.item(). Тензор тянет за собой весь граф вычислений, и память растёт с каждым шагом. Для логов всегда берите .item().
  3. Метки не того типа. F.cross_entropy с метками-float падает: expected target dtype to be Long or Byte, but got Float. Чините через y.long().
  4. Картинки и метки перемешаны по отдельности. Модель учится на шуме, и val accuracy застревает около 1/C1/C. Перемешивайте индексы, а не массивы, — это и делает DataLoader.
  5. Валидация на обучающих данных или утечка из теста (урок 2.1). Метрики прекрасные, в реальности — провал.

Расширенный каталог тихих багов для рабочего LLM-кода — маска внимания на одну позицию, несдвинутые метки, broadcasting в функции потерь — с симптомами и способами их поймать собран в уроке 11.1.2.

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

import random
import numpy as np
import torch
def set_seed(seed: int):
random.seed(seed) # модуль random Python
np.random.seed(seed) # NumPy
torch.manual_seed(seed) # PyTorch: CPU и все GPU

Одинаковый seed на CPU даёт побитово одинаковые числа: torch.manual_seed(0); torch.randn(3) всегда выдаст [1.5410, -0.2934, -2.1788]. Но полный детерминизмдетерминизм (детерминированность)EN determinismОдинаковый вход и код всегда дают побитово одинаковый результат. Seed фиксирует случайность, но на GPU ещё нужны детерминированные алгоритмы (torch.use_deterministic_algorithms(True)). (повторяемость результата до последнего бита) сложнее, потому что сложение чисел с плавающей точкой неассоциативно: результат зависит от того, как расставить скобки, то есть в каком порядке складывать. В школьной математике (a+b)+c=a+(b+c)(a + b) + c = a + (b + c) всегда, а в компьютере после каждого сложения число округляется до ближайшего представимого (урок 0.0.2), и ошибки округления зависят от порядка:

import torch
print((0.1 + 0.2) + 0.3, 0.1 + (0.2 + 0.3)) # 0.6000000000000001 0.6
torch.manual_seed(0)
v = torch.randn(1_000_000)
s1 = v.sum()
s2 = v[torch.randperm(len(v))].sum() # те же числа в другом порядке
print(f"{s1.item():.6f} {s2.item():.6f}")
0.6000000000000001 0.6
-1561.452759 -1561.453003

Миллион одних и тех же чисел в другом порядке дают другую сумму в float32. Порядок меняет даже число потоков: запустите этот код с OMP_NUM_THREADS=1 python …, и у нас s1 стала равна −1561.453125. GPU складывает параллельно, и некоторые его ядра (kernels — маленькие программы, которые GPU выполняет над тензорами) делают это в непредсказуемом порядке. Отсюда расхождения в последних знаках, которые за тысячи шагов вырастают в заметные. В неустойчивом обучении, как в галерее багов выше, — в совсем другой результат. Для строгой воспроизводимости на GPU добавляют:

torch.backends.cudnn.benchmark = False # не выбирать алгоритм свёртки по замеру
torch.use_deterministic_algorithms(True) # только детерминированные ядра (иначе — ошибка)
# и в окружении до запуска: CUBLAS_WORKSPACE_CONFIG=:4096:8

Это медленнее, поэтому так делают при отладке, а не в больших прогонах. Та же неассоциативность объясняет, почему LLM с «температурой 0» иногда отвечает по-разному (урок 4.1).

Шум от seed. Обучим одну и ту же модель из bug_lab.py (300 шагов AdamW на 10 000 примерах) с пятью разными seed. Seed задаёт и начальные веса, и порядок батчей:

from bug_lab import train
accs = [train(seed=s, verbose=False) for s in range(5)]
print("accuracy по seed 0..4:", [f"{a:.4f}" for a in accs])
print(f"среднее {sum(accs) / 5:.4f}, разброс (max − min) {max(accs) - min(accs):.4f}")
print("seed 0 ещё раз:", f"{train(seed=0, verbose=False):.4f}")
accuracy по seed 0..4: ['0.9209', '0.9273', '0.9312', '0.9254', '0.9367']
среднее 0.9283, разброс (max − min) 0.0158
seed 0 ещё раз: 0.9209

Разница между запусками — 1.6 процентного пункта без единого изменения в коде. При этом повторный запуск с seed 0 даёт ровно те же 0.9209: на одной машине с теми же настройками результат на CPU воспроизводится побитово. Последние знаки у вас могут отличаться, а разброс будет того же порядка. Вывод: если новая идея улучшила accuracy на 0.5 п.п. в одном запуске, вы ничего не доказали. Запускайте 3–5 seed и сравнивайте средние. Если разница меньше разброса — это шум.

Seed и чекпоинт. В прошлом уроке вы продолжали обучение из чекпоинта. Чтобы продолжение совпало с непрерывным обучением до последнего знака, одного seed мало: генератор случайных чисел к этому моменту уже «прокручен» перемешиваниями прошлых эпох. Поэтому в чекпоинт кладут и его состояние — "rng": torch.get_rng_state(), а после загрузки вызывают torch.set_rng_state(ckpt["rng"]) (на GPU есть ещё torch.cuda.get_rng_state_all()). Мы проверили на MLP из mnist_mlp.py: без этой строки loss после продолжения расходится с непрерывным запуском, с ней совпадает побитово.

Слово «ляньдань» прижилось в китайском ML-сленге не случайно. «Ляньдань-ши» («мастер алхимии») — так в шутку называют ML-инженера, «ляньдань-лу» («алхимическая печь») — сервер с GPU, «тяоцань»подбор гиперпараметровEN hyperparameter tuningПоиск learning rate, размера батча, ширины сети и т. п. «Тяоцань-ся» — «рыцарь подкрутки параметров». («подкрутка параметров») — подбор гиперпараметров, а «тяоцань-ся» — «рыцарь подкрутки», который только этим и занимается. Приём, который работает, но никто не знает почему, называют «сюаньсюэ» — «мистика».

Алхимия начинается там, где эксперименты нельзя повторить и сравнить. Её противоядие — дисциплина:

  1. Сначала маленький и надёжный бейзлайнбазовое решение, бейзлайнEN baselineПростейшая модель (среднее, самый частый класс), с которой сравнивают всё остальное. (простая отправная модель, с которой сравнивают все улучшения), потом усложнения. Три проверки из начала урока — до любого долгого запуска.
  2. Одно изменение за раз. Поменяли lr и ширину сразу — не узнаете, что сработало. Это и есть абляцияабляционный эксперимент, абляцияEN ablation studyУбрать или заменить один компонент и сравнить с полной моделью, чтобы измерить вклад этого компонента.: убираем или меняем одну деталь и смотрим, как изменился результат.
  3. Журнал экспериментов. Для каждого запуска: гипотеза, что изменили, seed, результат, вывод. Это тот же тетрадь ошибок, только для моделей: записанный баг второй раз не повторится.
  4. Несколько seed на любой важный вывод.
  5. Логируйте всё: конфиг, коммит кода, кривые loss и норм градиента.

Как эта дисциплина устроена в команде, где прогонов сотни, — конфиги, папка на каждый прогон, трекеры и лабораторный журнал — в уроке 11.1.3.

Задание 1. Повторите галерею. Запустите bug_lab.py и получите таблицу результатов. Затем добавьте флаг wrong_axis=True, который считает accuracy как (logits.argmax(dim=0) == y). Объясните, почему код падает с ошибкой формы, и придумайте вариант той же ошибки, который не упадёт.

Задание 2. Найдите три бага. Скрипт ниже запускается без ошибок, но учится плохо:

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Subset
from mnist_mlp import MNIST
torch.manual_seed(0)
loader = DataLoader(Subset(MNIST("train"), range(10_000)), batch_size=64, shuffle=True)
model = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10),
nn.Softmax(dim=1),
)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
running = 0
for step, (x, y) in enumerate(loader):
logits = model(x)
loss = F.cross_entropy(logits, y)
loss.backward()
opt.step()
running += loss
if step % 50 == 49:
print(f"шаг {step + 1}: средний loss {running / 50:.3f}")
running = 0
шаг 50: средний loss 2.039
шаг 100: средний loss 2.049
шаг 150: средний loss 2.070

Сначала примените инструменты урока (loss на старте, один батч), и только потом читайте код.

Ответ
  1. nn.Softmax(dim=1) в конце модели — softmax дважды. Уберите эту строку.
  2. Нет opt.zero_grad() перед loss.backward() — градиенты копятся.
  3. running += loss хранит графы всех 50 шагов в памяти. Нужно running += loss.item().

После исправлений средний loss: 0.780 → 0.364 → 0.285 на шагах 50, 100, 150.

Задание 3. Охота на мёртвые нейроны. Измените lr_lab.py: оставьте только lr = 3 и считайте долю мёртвых ReLU после каждого шага. На каком шаге большинство нейронов «умирает»? Помогает ли clip_grad_norm_(…, 1.0) между backward() и step() при том же lr?

Проверь себя5 вопросов
1 Классификатор на 100 классов только что инициализирован. Какой loss (натуральный логарифм) вы ожидаете на первом шаге? Округлите до двух знаков.
2 Модель заканчивается nn.Softmax(dim=1), а loss — F.cross_entropy. Ниже какого значения loss не опустится никогда при C=2C = 2 классах? Округлите до двух знаков.
3 Вы переобучаете один батч из 16 примеров, но loss за 500 шагов опустился только с 2.30 до 1.9. Что это значит?
4 Какие утверждения верны?Можно выбрать несколько ответов
5 Отношение «обновление / вес» у первого слоя на протяжении обучения около 0.20.2. Что вероятнее всего?
  • Ошибки обучения молчат. Перед долгим запуском: посмотрите на входы, проверьте loss на старте (≈ln⁡C\approx \ln C), переобучите один батч.
  • Логируйте train/val loss, норму градиента и отношение «обновление / вес». Резкий рост нормы предвещает всплеск loss.
  • Типичные баги и их подписи: забытый zero_grad — loss растёт; softmax дважды — loss упирается в пол ln⁡(e+C−1)−1\ln(e + C - 1) - 1; броадкастинг (B, 1) против (B,) — модель предсказывает среднее; слишком большой lr — мёртвые ReLU и NaN.
  • Seed фиксирует случайность, но float неассоциативен. Разброс между seed — мера шума: меньшие различия ничего не значат.
  • «Ляньдань» превращается в инженерию через бейзлайн, одно изменение за раз, журнал экспериментов и несколько seed.

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