Как отлаживать обучение
Ошибки в обучении нейросетей молчат. Учимся их ловить: loss на старте, переобучение одного батча, мониторинг градиентов, галерея типичных багов с реальными цифрами, 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 из прошлого урока: положите новые скрипты рядом с ним.
Перед долгим запуском: три проверки
Заголовок раздела «Перед долгим запуском: три проверки»1. Посмотрите на то, что входит в модель
Заголовок раздела «1. Посмотрите на то, что входит в модель»Самое надёжное место для проверки — строка прямо перед logits = model(x). Напечатайте формы, dtype и пару чисел.
Нарисуйте несколько картинок вместе с метками. Проверьте, что метки совпадают с картинками, а входы
стандартизированы: среднее около 0, разброс около 1. Половина багов с данными видна глазами за минуту.
2. Loss на старте ≈ ln C
Заголовок раздела «2. Loss на старте ≈ ln C»Свежая модель ничего не знает и даёт почти равномерное распределение по классам. Значит, её перекрёстная энтропияперекрёстная энтропияEN cross-entropy должна быть около (урок 1.3):
| Задача | Классов | Ожидаемый loss на старте |
|---|---|---|
| бинарная классификация | 2 | |
| MNIST | 10 | |
| LLM со словарём Qwen | 151 936 |
В прошлом уроке первая строка лога была loss 2.3326 — всё в порядке. Если бы там стояло 15 или 40, модель на старте
была бы уверенно неправа. Обычно это плохая инициализация последнего слоя или неверно собранный loss.
3. Переобучите один батч
Заголовок раздела «3. Переобучите один батч»Главный тест конвейера. Возьмите один маленький батч и обучайте на нём сотни шагов. Модель с сотнями тысяч параметров обязана запомнить 32 примера — loss должен упасть почти до нуля:
import mathimport torchimport torch.nn.functional as Ffrom 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, отношение равно : за шаг веса изменились на одну тысячную.
Норму градиента удобно получать функцией clip_grad_norm_. С бесконечным порогом она ничего не обрезает, но
возвращает общую норму:
import torchimport torch.nn.functional as Ffrom 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) ориентир — около . Около означает, что веса переписываются почти целиком на каждом шаге и lr слишком велик. и меньше — слой почти не учится. У нас на старте : первый шаг Adam всегда крупный. Дальше отношение оседает около –.
В больших проектах эти графики пишут в 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 torchimport torch.nn.functional as Ffrom torch.utils.data import DataLoader, Subsetfrom 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, шаг 100 | loss, шаг 299 | test accuracy |
|---|---|---|---|
| SGD, lr = 0.1 — норма | 0.218 | 0.330 | 92.3% |
SGD — забыли zero_grad | 1.076 | 5.986 | 22.2% |
| AdamW, lr = 1e-3 — норма | 0.205 | 0.337 | 92.1% |
AdamW — забыли zero_grad | 1.388 | 1.696 | 57.7% |
| AdamW — softmax дважды | 1.554 | 1.584 | 91.5% |
Строку «SGD — забыли zero_grad» вы, скорее всего, не повторите в точности. Это обучение неустойчиво, и крошечные
различия в округлении за сотни шагов вырастают в совсем другой результат. У нас один и тот же скрипт при 1, 2 и 4
потоках процессора дал loss на шаге 299 = 2.6, 16.7 и 6.0 и accuracy 17–22%. Откуда берутся такие различия, вы
узнаете в разделе о детерминизме. Остальные строки устойчивы: у вас они совпадут с нашими или разойдутся лишь
в последних знаках.
Баг 1: забыли zero_grad
Заголовок раздела «Баг 1: забыли zero_grad»Симптом: loss сначала падает, потом начинает расти или скакать. Почему: backward() прибавляет новый
градиент к старым (урок 3.3.1). На шаге в .grad лежит сумма всех градиентов.
SGD делает шаг пропорционально этой сумме, и шаг растёт с каждой итерацией. К концу loss поднимается выше, чем у
необученной модели (), а accuracy падает до 20%. Adam нормирует размер шага, поэтому не
взрывается, но направление определяет давняя история, а не текущий батч. Это как momentum, который ничего не
забывает. Результат — 57.7% вместо 92%.
Лечение: opt.zero_grad() в каждой итерации. Исключение — сознательное накопление
градиентовнакопление градиентовEN gradient accumulationГрадиенты складываются (+=), а не перезаписываются. Внутри графа так учитываются все пути к узлу; на уровне обучения — шаг оптимизатора делают раз в несколько микробатчей.: несколько backward() подряд, затем один step() и zero_grad(). Так делает MiniMind, чтобы
получить большой батч на маленькой видеокарте.
Баг 2: softmax дважды
Заголовок раздела «Баг 2: softmax дважды»Симптом: loss застревает на подозрительно «круглом» уровне и не опускается, хотя accuracy выглядит прилично.
Почему: F.cross_entropy сама применяет softmax. Если модель уже вернула вероятности, «логиты» лежат в
. Лучшее, что может сделать модель, — выдать , и тогда loss равен
Ниже 1.46 loss не опустится никогда. У нас он стоит на 1.55–1.58, а градиенты при этом слабые: softmax в насыщении почти не пропускает сигнал. Коварство в том, что accuracy — 91.5%: argmax от softmax тот же, что и от логитов. Если смотреть только на accuracy, баг можно не заметить месяцами.
Лечение: модель возвращает логиты; softmax — только при генерации или выводе вероятностей. Если в модели всё-таки
стоит LogSoftmax, то к ней нужна F.nll_loss, а не F.cross_entropy.
Баг 3: перепутали оси
Заголовок раздела «Баг 3: перепутали оси»Самая коварная семья багов: формы тензоров совпадают «достаточно», чтобы не было ошибки. Напомним: «ось» — это номер измерения массива (урок 0.3.1). У батча картинок формы (B, 28, 28) ось 0 — номер примера, оси 1 и 2 — строки и столбцы пикселей. Три примера.
Броадкастинг в регрессии. Модель nn.Linear(3, 1) возвращает форму (B, 1), а метки имеют форму (B,):
import torchimport 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, 2x = 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: выход на позиции не должен зависеть от позиций после (урок 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.
Баг 4: слишком большой learning rate
Заголовок раздела «Баг 4: слишком большой learning rate»Обучим тот же MLP обычным SGD по 100 шагов с разными lr. Кроме loss и accuracy посчитаем «мёртвые» нейроны.
Мёртвый нейрон — тот, чей ReLU выдаёт 0 на всех примерах. Градиент через него не идёт, и он больше не учится.
model.net[:3] — первые три слоя Sequential (Flatten, Linear, ReLU), то есть выход скрытого слоя.
"""lr_lab.py — что делает с MLP слишком большой learning rate."""import torchimport torch.nn.functional as Ffrom torch.utils.data import DataLoaderfrom bug_lab import small, test_loaderfrom 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 — почти к , уровню
случайного угадывания. Это подпись «умершей» сети. Огромный шаг загнал 86% нейронов в отрицательную зону ReLU, и
сеть почти перестала различать входы: accuracy 20% при 10% у случайного ответа. При lr = 1000 веса за 15 шагов
разогнались так, что loss дошёл до , а следующий шаг дал nan. Отсюда два правила: если loss посреди
обучения вдруг встал около , ищите взорвавшийся шаг; если 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 укажет на виновника. Почему градиенты
вообще взрываются и как помогает инициализация — в уроке
«Инициализация и нормализация».
Ещё пять частых ошибок
Заголовок раздела «Ещё пять частых ошибок»- Забыли
model.eval()при оценке. Dropout продолжает выключать нейроны, и метрики «скачут» от запуска к запуску. total_loss += lossвместоloss.item(). Тензор тянет за собой весь граф вычислений, и память растёт с каждым шагом. Для логов всегда берите.item().- Метки не того типа.
F.cross_entropyс метками-float падает:expected target dtype to be Long or Byte, but got Float. Чините черезy.long(). - Картинки и метки перемешаны по отдельности. Модель учится на шуме, и val accuracy застревает около .
Перемешивайте индексы, а не массивы, — это и делает
DataLoader. - Валидация на обучающих данных или утечка из теста (урок 2.1). Метрики прекрасные, в реальности — провал.
Расширенный каталог тихих багов для рабочего LLM-кода — маска внимания на одну позицию, несдвинутые метки, broadcasting в функции потерь — с симптомами и способами их поймать собран в уроке 11.1.2.
Seed и детерминизм
Заголовок раздела «Seed и детерминизм»Чтобы сравнивать эксперименты, случайность нужно контролировать. Случайны инициализация весов, порядок батчей, dropout. Стандартная функция фиксирует все генераторы сразу:
import randomimport numpy as npimport 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)). (повторяемость результата до
последнего бита) сложнее, потому что сложение чисел с плавающей точкой неассоциативно: результат зависит от того,
как расставить скобки, то есть в каком порядке складывать. В школьной математике всегда, а
в компьютере после каждого сложения число округляется до ближайшего представимого (урок 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.0158seed 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, размера батча, ширины сети и т. п. «Тяоцань-ся» — «рыцарь подкрутки параметров». («подкрутка параметров») — подбор гиперпараметров, а «тяоцань-ся» — «рыцарь подкрутки», который только этим и занимается. Приём, который работает, но никто не знает почему, называют «сюаньсюэ» — «мистика».
Алхимия начинается там, где эксперименты нельзя повторить и сравнить. Её противоядие — дисциплина:
- Сначала маленький и надёжный бейзлайнбазовое решение, бейзлайнEN baselineПростейшая модель (среднее, самый частый класс), с которой сравнивают всё остальное. (простая отправная модель, с которой сравнивают все улучшения), потом усложнения. Три проверки из начала урока — до любого долгого запуска.
- Одно изменение за раз. Поменяли lr и ширину сразу — не узнаете, что сработало. Это и есть абляцияабляционный эксперимент, абляцияEN ablation studyУбрать или заменить один компонент и сравнить с полной моделью, чтобы измерить вклад этого компонента.: убираем или меняем одну деталь и смотрим, как изменился результат.
- Журнал экспериментов. Для каждого запуска: гипотеза, что изменили, seed, результат, вывод. Это тот же тетрадь ошибок, только для моделей: записанный баг второй раз не повторится.
- Несколько seed на любой важный вывод.
- Логируйте всё: конфиг, коммит кода, кривые loss и норм градиента.
Как эта дисциплина устроена в команде, где прогонов сотни, — конфиги, папка на каждый прогон, трекеры и лабораторный журнал — в уроке 11.1.3.
Практика
Заголовок раздела «Практика»Задание 1. Повторите галерею. Запустите bug_lab.py и получите таблицу результатов. Затем добавьте флаг
wrong_axis=True, который считает accuracy как (logits.argmax(dim=0) == y). Объясните, почему код падает с ошибкой
формы, и придумайте вариант той же ошибки, который не упадёт.
Задание 2. Найдите три бага. Скрипт ниже запускается без ошибок, но учится плохо:
import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import DataLoader, Subsetfrom 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 = 0for 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 на старте, один батч), и только потом читайте код.
Ответ
nn.Softmax(dim=1)в конце модели — softmax дважды. Уберите эту строку.- Нет
opt.zero_grad()передloss.backward()— градиенты копятся. 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?
Проверь себя
Заголовок раздела «Проверь себя»- Ошибки обучения молчат. Перед долгим запуском: посмотрите на входы, проверьте loss на старте (), переобучите один батч.
- Логируйте train/val loss, норму градиента и отношение «обновление / вес». Резкий рост нормы предвещает всплеск loss.
- Типичные баги и их подписи: забытый
zero_grad— loss растёт; softmax дважды — loss упирается в пол ; броадкастинг (B, 1) против (B,) — модель предсказывает среднее; слишком большой lr — мёртвые ReLU и NaN. - Seed фиксирует случайность, но float неассоциативен. Разброс между seed — мера шума: меньшие различия ничего не значат.
- «Ляньдань» превращается в инженерию через бейзлайн, одно изменение за раз, журнал экспериментов и несколько seed.
Дальше: модуль 3.4 — почему глубокие сети трудно обучать и как с этим справляются инициализация, нормализация и остаточные связи.