Перейти к содержимому
Ступень 1111.1 Как на работе

Эксперименты как у профи: воспроизводимость и учёт

Почему «у меня работало» — не результат. Seed во всех генераторах и детерминизм с его ценой, версии окружения, конфиги вместо правки кода, одна папка на прогон с конфигом, метриками и git-хэшем, TensorBoard и MLflow без облака, честное сравнение по нескольким seed с интервалом Стьюдента, абляции и лабораторный журнал.

УРОКstage-11/11-1-real-work/03-experiments.mdx2 ч 30 минУрок 3 из 5
После урока вы сможете
  • Сделать прогон воспроизводимым — seed во всех генераторах, детерминированные ядра там, где они нужны, версии окружения, хэш данных и git-хэш в папке прогона
  • Вынести гиперпараметры в конфиг и запускать эксперименты переопределениями из командной строки, а не правкой кода
  • Вести учёт прогонов в TensorBoard или локальном MLflow и записывать вывод по эксперименту в лабораторный журнал
  • Сравнивать конфигурации по нескольким seed — среднее, разброс, 95%-интервал разности по Стьюденту — и отличать настоящее улучшение от шума

Коллега пишет в чат: «Поднял learning rate с 0.02 до 0.03 — val loss упал на 9%, у меня работало». Вы запускаете у себя и получаете −4%. Какой был коммит? Какие ещё параметры он поменял «по ходу»? На каких данных? Сколько раз запускал? Ответов нет, а значит нет и результата: есть одно случайное число.

В выпускном проекте (10.1.1) вы уже вели LOG.md и писали metrics.json на каждом этапе. На работе прогонов становится сотни, а людей, которые их запускают, — десятки. Через полгода спросят, почему в продакшене lr 0.02, — ответ должен найтись за минуту. В Google Tuning Playbook это сказано жёстко: неучтённые эксперименты всё равно что не существуют.

В уроке — воспроизводимостьвоспроизводимостьEN reproducibilityВозможность получить тот же результат, повторно запустив тот же код на тех же данных. (seed, детерминизм, версии, конфиги, папка прогона), учёт экспериментовучёт экспериментов (трекинг)EN experiment trackingСистема, которая для каждого прогона хранит конфиг, коммит, метрики по шагам и артефакты и позволяет искать и сравнивать прогоны: TensorBoard, MLflow, Weights & Biases, SwanLab или просто папки с JSON. Неучтённый эксперимент всё равно что не существует. и честное сравнение по нескольким seed — продолжение урока 1.3.4 для случая, когда повторов не 500, а пять.

Слово «воспроизводимость» означает разные вещи, и полезно их различать: требования к ним разные.

УровеньВопросЧем обеспечиваетсяЧего ждать
Повторяемостьтот же код, данные, машина — те же числа?seed во всех генераторах, детерминированные ядрана CPU — побитовое совпадение; на GPU — только с флагами детерминизма
Воспроизводимостьдругой человек на другой машине получит то же?git-хэш, конфиг, версии библиотек, хэш данных, команда запускасовпадение в пределах разброса между seed, не до бита
Устойчивость вывода«B лучше A» держится на других seed и данных?несколько seed, интервалы, проверка на свежих данныхтот же знак и порядок эффекта

Побитово повторяемый прогон с одним seed — всё ещё одно случайное число. Выводы делают на третьем уровне, а первые два нужны для отладки и для проверки коллегой.

Урок 3.3.3 дал функцию set_seed для трёх генераторов. В рабочем коде случайность живёт ещё в двух местах: в рабочих процессах DataLoader и в порядке батчей. Рецепт из документации:

import random
import numpy as np
import torch
from torch.utils.data import DataLoader
def set_seed(seed: int) -> torch.Generator:
random.seed(seed) # модуль random: перемешивания и аугментации на Python
np.random.seed(seed) # глобальный генератор NumPy
torch.manual_seed(seed) # PyTorch: CPU и все GPU сразу (manual_seed_all не нужен)
g = torch.Generator()
g.manual_seed(seed) # отдельный генератор для порядка батчей
return g
def seed_worker(worker_id):
"""Каждый рабочий процесс DataLoader получает свой, но повторяемый seed."""
worker_seed = torch.initial_seed() % 2**32
np.random.seed(worker_seed)
random.seed(worker_seed)
g = set_seed(cfg["seed"])
loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4,
worker_init_fn=seed_worker, generator=g)

Без generator порядок батчей завязан на глобальный генератор, который успел «прокрутиться» при инициализации модели: измените архитектуру — изменится и порядок данных, и вы сравните не то, что хотели. Генераторы random и NumPy в рабочих процессах свежие версии PyTorch засевают сами, производно от seed загрузчика; seed_worker из документации нужен для старых версий и для библиотек со своим генератором.

Ещё три правила:

  1. Seed данных отдельно от seed обучения. Разбиение на train и val, генерация синтетики, выборка eval-набора — со своим фиксированным seed (в выпускном проекте — seed=2026 для eval). Seed обучения меняют от прогона к прогону, а данные должны оставаться теми же.
  2. На нескольких картах веса инициализируют одним seed на всех рангах, а данные у каждого ранга свои — это разобрано в 5.7.4.
  3. Состояние генераторов — в чекпоинт (урок 3.3.3): random.getstate(), np.random.get_state(), torch.get_rng_state(), torch.cuda.get_rng_state_all(). Ловушка: с PyTorch 2.6 torch.load по умолчанию (weights_only=True) откажется читать состояние NumPy. Свои чекпоинты грузите с weights_only=False — и только свои: этот режим исполняет код из файла.

Даже со всеми seed два прогона на GPU расходятся в последних знаках. Причина та же, что в уроке 3.3.3: сложение чисел с плавающей точкой неассоциативно, а часть ядер GPU складывает результаты потоков в порядке, который меняется от запуска к запуску (атомарные сложения в index_add, scatter_add, в backward у embedding). За тысячи шагов разница в последнем бите вырастает до заметной. Что можно включить:

НастройкаЧто делаетЦена
torch.backends.cudnn.benchmark = FalsecuDNN не перебирает алгоритмы свёртки по замеру, а выбирает детерминированносвёртки могут стать медленнее; трансформеров почти не касается
torch.backends.cudnn.deterministic = Trueтолько детерминированные алгоритмы свёртокмедленнее свёртки
torch.use_deterministic_algorithms(True)все операции PyTorch, у которых есть детерминированная версия, переходят на неё; у остальных — RuntimeErrorмедленнее операции с атомарными сложениями; часть операций падает
CUBLAS_WORKSPACE_CONFIG=:4096:8фиксирует буферы cuBLAS; в PyTorch до 2.9 включительно без неё torch.mm при включённом детерминизме падает с RuntimeError8 буферов по 4 МиБ памяти
NCCL_ALGO, NCCL_PROTOфиксируют алгоритм (Ring, Tree…) и протокол (LL, LL128, Simple) коллективных операцийобмен между картами может замедлиться

Как это выглядит в коде:

def make_deterministic():
torch.backends.cudnn.benchmark = False # не выбирать алгоритм свёртки по замеру скорости
torch.use_deterministic_algorithms(True) # детерминированные ядра; где их нет — RuntimeError
Окно терминала
CUBLAS_WORKSPACE_CONFIG=:4096:8 python train.py configs/base.yaml deterministic=true

Переменную окружения задают в команде запуска, до старта Python. В документации PyTorch 2.10 и новее этого требования уже нет, но со старыми версиями без неё детерминизм не включится, а задать её безвредно. По текущей документации флаг касается и внимания: backward у scaled_dot_product_attention с бэкендами Flash и Efficient становится детерминированным, а бэкенд cuDNN отключается. С warn_only=True будет лишь предупреждение.

Про распределённое обучение помните главное: результат all-reduce зависит от порядка суммирования, а он зависит от числа карт и выбранного алгоритма NCCL. Прогоны на 1 и на 4 картах на GPU побитово обычно не совпадают — проверяйте их совпадение в пределах шума, как в 5.7.4. А побитовое совпадение двух прогонов на одних и тех же четырёх картах с одними и теми же версиями библиотек — разумная цель отладки: включите детерминизм, а если не хватит, зафиксируйте алгоритм NCCL.

Когда включать. Детерминизм — инструмент отладки и регрессионных тестов: «после рефакторинга loss на 200 шагах совпадает побитово» (тест-фиксатор из 11.1.2). В больших прогонах его обычно выключают ради скорости, а выводы делают по нескольким seed. Его цену у себя замерьте (блок «На вашей машине» ниже).

Один и тот же код на разных версиях torch может дать разные числа: меняются ядра и значения по умолчанию. Поэтому вместе с прогоном сохраняют снимок окружения: точные версии библиотекзакрепление версий зависимостейEN dependency pinningФиксация точных версий библиотек (numpy==2.4.6, а не numpy>=2), чтобы окружение можно было воспроизвести. Снимок текущего окружения — pip freeze > requirements.lock.txt. (pip freeze), версии CUDA и cuDNN, модель GPU и драйвер.

import platform
import numpy as np
import torch
def env_info():
return {
"python": platform.python_version(), "torch": torch.__version__,
"cuda": torch.version.cuda, "cudnn": torch.backends.cudnn.version(),
"numpy": np.__version__, "host": platform.node(),
"gpus": [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())],
}

Версию драйвера даст nvidia-smi --query-gpu=name,driver_version --format=csv,noheader. Данные фиксируют хэшем: sha256 сырого и очищенного файла, как в карточке датасета из урока 11.1.1, или хэшем из .dvc-файла, если команда пользуется DVC.

Пока эксперименты ставят правкой lr = 0.02 в train.py, история прогонов — это история правок, которые никто не закоммитил. Правило: все гиперпараметры и пути живут в конфигеконфиг (файл конфигурации)EN configuration fileФайл (обычно YAML) со всеми гиперпараметрами и путями прогона. Эксперименты меняют конфиг или переопределяют его из командной строки, а не правят код; конфиг сохраняется в папку прогона., код их только читает, а эксперимент — это переопределение из командной строки.

configs/base.yaml
name: circles
seed: 0
data: {seed: 123, n: 2000}
model: {hidden: 16}
train: {lr: 0.02, steps: 500, batch: 32}
deterministic: false
import sys
import yaml
def load_config(path, overrides):
with open(path, encoding="utf-8") as f:
cfg = yaml.safe_load(f)
for item in overrides: # "train.lr=0.03" → cfg["train"]["lr"] = 0.03
key, value = item.split("=", 1)
*parents, last = key.split(".")
node = cfg
for k in parents:
node = node[k] # опечатка в имени раздела → KeyError, и это хорошо
if last not in node:
raise KeyError(f"нет такого параметра: {key}")
node[last] = yaml.safe_load(value) # "0.03" → 0.03, "true" → True
return cfg
cfg = load_config(sys.argv[1], sys.argv[2:]) # python train.py configs/base.yaml train.lr=0.03 seed=2

Проверка «нет такого параметра» важна: без неё train.lrr=0.03 молча добавит новый ключ, и вы запустите старый lr, думая, что новый. Вторая ловушка — сам YAML. Библиотека PyYAML следует старой спецификации YAML 1.1:

yaml.safe_load("3e-4") → '3e-4' # строка!
yaml.safe_load("3.0e-4") → 0.0003
yaml.safe_load("no") → False
yaml.safe_load("010") → 8

lr: 3e-4 — строка, no — ложь, 010 — восьмеричное число 8. Пишите 3.0e-4 или 0.0003 и проверяйте типы сразу после загрузки: assert isinstance(cfg["train"]["lr"], float).

Идея Hydra. То же самое, только готовое, делает Hydra от Meta: конфиг собирается из частей (например, model/small.yaml и optim/adamw.yaml), переопределяется из командной строки, а схему с типами можно задать dataclass-ом. Функцию-точку входа помечают декоратором @hydra.main(...), и она получает готовый cfg. Запуск:

Окно терминала
python train.py train.lr=0.03 seed=2 # один прогон → outputs/<дата>/<время>/
python train.py -m train.lr=0.02,0.03 seed=0,1,2,3,4 # 10 прогонов → multirun/<дата>/<время>/0…9/

В каждой папке Hydra сохраняет .hydra/config.yaml и .hydra/overrides.yaml: что запускали и чем отличался прогон — та же идея, что в следующем разделе.

Всё, что нужно, чтобы понять и повторить прогон, лежит в одной папке, а папка никогда не перезаписывается:

runs/
└── 2026-10-06_143210_circles_lr0.03_s2/
├── config.yaml # полный конфиг после переопределений
├── meta.json # git-хэш, были ли правки, команда, sha256 данных, версии, GPU
├── uncommitted.patch # только если код не был закоммичен
├── requirements.lock.txt # pip freeze
├── metrics.jsonl # по строке JSON на каждую оценку
├── train.log # logging из урока 11.1.2
├── tb/ # события TensorBoard
└── ckpt/ # веса, оптимизатор, состояние генераторов

Код, который создаёт такую папку (функция env_info — из прошлого раздела):

import hashlib, json, pathlib, subprocess, sys, time
import yaml
def git(*args):
return subprocess.run(["git", *args], capture_output=True, text=True).stdout
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def start_run(cfg, data_path, root="runs"):
stamp = time.strftime("%Y-%m-%d_%H%M%S")
run = pathlib.Path(root) / f"{stamp}_{cfg['name']}_lr{cfg['train']['lr']}_s{cfg['seed']}"
run.mkdir(parents=True) # папка уже есть → ошибка: чужой прогон не затираем
(run / "config.yaml").write_text(yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), encoding="utf-8")
dirty = git("status", "--porcelain", "--untracked-files=no").strip() != ""
if dirty: # код не закоммичен — сохраняем сами правки
(run / "uncommitted.patch").write_text(git("diff", "HEAD"), encoding="utf-8")
meta = {
"git_commit": git("rev-parse", "HEAD").strip() or None,
"git_dirty": dirty,
"command": " ".join([sys.executable, *sys.argv]),
"data_sha256": sha256(data_path),
"env": env_info(),
"started": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
(run / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
freeze = subprocess.run([sys.executable, "-m", "pip", "freeze"], capture_output=True, text=True).stdout
(run / "requirements.lock.txt").write_text(freeze, encoding="utf-8")
return run
def log_metrics(run, step, **metrics):
with open(run / "metrics.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps({"step": step, **metrics}) + "\n")

Почему uncommitted.patch, а не запрет запускать с правками: на этапе поиска код правят каждые пять минут, и запрет никто не соблюдёт, а патч поверх коммита восстанавливает код точно. Но в отчёт и в продакшен идут только прогоны с git_dirty: false. Упавшие и неудачные прогоны тоже не удаляйте: допишите в meta.json статус. Раздел «Неудачные попытки» отчёта из 10.1.2 пишется именно из них.

Сравнивать двадцать кривых в папках с JSON неудобно. Для этого есть трекеры:

ИнструментЧто даётГде хранитКогда брать
Папки + JSONLминимум без зависимостейв папке прогонавсегда, как основа
TensorBoardкривые, гистограммы, наложение прогонов, таблица гиперпараметровфайлы событий в папке прогонаодин человек, одна машина
MLflowпараметры, метрики, артефакты, поиск и сортировка прогонов, реестр моделейлокальная база SQLite или свой серверсотни прогонов, команда, свои серверы
Weights & Biasesто же плюс отчёты, совместная работа, перебор гиперпараметровоблако; есть офлайн-режим и self-hosted версиякоманде можно отдавать метрики во внешнее облако

TensorBoard (пакет tensorboard) пишет прямо в папку прогона:

from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter(log_dir=str(run / "tb"))
writer.add_scalar("loss/train", loss, step) # в цикле обучения
writer.add_scalar("loss/val", val, step) # при каждой оценке, рядом с log_metrics(run, step, val_loss=val)
writer.add_hparams({"lr": cfg["train"]["lr"], "seed": cfg["seed"]}, {"hparam/val_loss": val}) # в конце
writer.close()

tensorboard --logdir runs покажет все прогоны рядом. MLflow удобнее, когда прогоны нужно искать и сортировать по параметрам, и работает без облака:

import mlflow
mlflow.set_tracking_uri("sqlite:///mlflow.db") # локальный файл базы: без облака и без сервера
mlflow.set_experiment("circles-lr")
with mlflow.start_run(run_name=run.name):
mlflow.log_params({"lr": cfg["train"]["lr"], "seed": cfg["seed"], "git_commit": meta["git_commit"]})
for step, val in history:
mlflow.log_metric("val_loss", val, step=step)
mlflow.log_artifacts(str(run)) # вся папка прогона рядом с метриками

Интерфейс: mlflow ui --backend-store-uri sqlite:///mlflow.db, затем http://127.0.0.1:5000. Адрес хранилища задавайте явно: в свежих версиях MLflow по умолчанию это mlflow.db, в старых — папка mlruns/, и прогоны, записанные разными версиями, иначе окажутся в разных местах. У W&B офлайн-режим включается wandb.init(..., mode="offline"), а позже прогоны выгружаются командой wandb sync.

Правило одно: трекер — витрина, а источник правды — папка прогона с конфигом и git-хэшем.

В уроке 1.3.4 шум был от выборки вопросов, и примеров были сотни. Здесь шум от seed: инициализация, порядок батчей, dropout. Каждый прогон дорог, поэтому повторов мало — обычно 3–5. Посчитаем по каждой конфигурации среднее aˉ\bar a, bˉ\bar b и стандартное отклонение по прогонам sas_a, sbs_b (с ddof=1). Разность и её стандартная ошибка:

Δ=bˉ−aˉ,SEΔ=sa2na+sb2nb.\Delta = \bar b - \bar a, \qquad \mathrm{SE}_\Delta = \sqrt{\frac{s_a^2}{n_a} + \frac{s_b^2}{n_b}}.

Формула та же, что в 1.3.4: дисперсии независимых величин складываются. Отличие — в множителе: по пяти прогонам само ss оценено плохо и может случайно оказаться вдвое меньше настоящего разброса. Это учитывает распределение Стьюдентараспределение Стьюдента (t-распределение)EN Student's t-distributionРаспределение среднего, нормированного на оценённое по выборке стандартное отклонение. При малом числе наблюдений у него тяжелее хвосты, чем у нормального: для 95%-интервала по 5 прогонам множитель 2.78 вместо 1.96.: интервал Δ±t0.975,ν⋅SEΔ\Delta \pm t_{0.975,\nu}\cdot\mathrm{SE}_\Delta с множителем, который зависит от числа степеней свободы ν\nu:

ν\nu24820∞\infty
t0.975,νt_{0.975,\nu}4.302.782.312.091.96

Последний столбец — знакомое 1.96: при большой выборке Стьюдент превращается в нормальное распределение. Число степеней свободы для двух групп с разным разбросом даёт формула Уэлча — Саттертуэйта (интервал Уэлчаt-тест Уэлча (интервал Уэлча)EN Welch's t-testСравнение средних двух групп без предположения, что их дисперсии равны: SE = sqrt(s₁²/n₁ + s₂²/n₂), число степеней свободы — по формуле Уэлча — Саттертуэйта. Вариант по умолчанию для сравнения конфигураций по нескольким seed.):

ν=(sa2/na+sb2/nb)2(sa2/na)2na−1+(sb2/nb)2nb−1.\nu = \frac{\left(s_a^2/n_a + s_b^2/n_b\right)^2}{\dfrac{(s_a^2/n_a)^2}{n_a - 1} + \dfrac{(s_b^2/n_b)^2}{n_b - 1}}.

Читаем: при одинаковом разбросе и пяти прогонах в каждой группе ν=8\nu = 8; чем сильнее различаются разбросы, тем ближе ν\nu к n−1n - 1 более шумной группы. Если формула пугает, возьмите ν=min⁡(na,nb)−1\nu = \min(n_a, n_b) - 1: интервал выйдет чуть шире, вывод — осторожнее.

Маленькая MLP учится отличать две окружности. Данные фиксированы своим seed, seed обучения задаёт веса и порядок батчей. Сравниваем lr 0.02 и 0.03 на 500 шагах Adam — весь эксперимент на NumPy, меньше секунды:

import numpy as np
def make_data(n=2000, seed=123): # у данных СВОЙ seed: они одинаковы во всех прогонах
rng = np.random.default_rng(seed)
y = rng.integers(0, 2, n) # класс 1 — внутреннее кольцо
r = np.where(y == 1, 0.5, 1.0) + rng.normal(0, 0.15, n)
a = rng.uniform(0, 2 * np.pi, n)
x = np.stack([r * np.cos(a), r * np.sin(a)], axis=1)
return x[:1500], y[:1500], x[1500:], y[1500:]
X_tr, y_tr, X_va, y_va = make_data()
def train(lr, seed, steps=500, hidden=16, batch=32):
# MLP 2 → 16 → 1, Adam; seed задаёт начальные веса и порядок батчей; возвращает val loss
rng = np.random.default_rng(seed)
params = [rng.normal(0, 1.0, (2, hidden)), np.zeros(hidden),
rng.normal(0, hidden ** -0.5, (hidden, 1)), np.zeros(1)]
m = [np.zeros_like(p) for p in params]
v = [np.zeros_like(p) for p in params]
for t in range(1, steps + 1):
W1, b1, W2, b2 = params
idx = rng.integers(0, len(X_tr), batch) # случайный батч
x, y = X_tr[idx], y_tr[idx][:, None]
h = np.tanh(x @ W1 + b1)
p = 1 / (1 + np.exp(-(h @ W2 + b2)))
dz = (p - y) / batch # градиент log loss по логиту
dh = dz @ W2.T * (1 - h ** 2)
grads = [x.T @ dh, dh.sum(0), h.T @ dz, dz.sum(0)]
for i, g in enumerate(grads): # шаг Adam
m[i] = 0.9 * m[i] + 0.1 * g
v[i] = 0.999 * v[i] + 0.001 * g * g
params[i] -= lr * (m[i] / (1 - 0.9 ** t)) / (np.sqrt(v[i] / (1 - 0.999 ** t)) + 1e-8)
W1, b1, W2, b2 = params
p = 1 / (1 + np.exp(-(np.tanh(X_va @ W1 + b1) @ W2 + b2)))[:, 0]
p = np.clip(p, 1e-7, 1 - 1e-7)
return float(-np.mean(y_va * np.log(p) + (1 - y_va) * np.log(1 - p)))
for lr in [0.02, 0.03]:
losses = np.array([train(lr, seed) for seed in range(5)])
print(f"lr={lr}: {np.round(losses, 4)} среднее {losses.mean():.4f} std {losses.std(ddof=1):.4f}")
lr=0.02: [0.151 0.179 0.148 0.1679 0.1561] среднее 0.1604 std 0.0129
lr=0.03: [0.1369 0.1647 0.1451 0.1585 0.1625] среднее 0.1535 std 0.0120

Последние знаки могут отличаться: они зависят от библиотеки линейной алгебры под NumPy.

Взгляд «как у коллеги». Seed 0: 0.1510 → 0.1369, минус 9%. Выглядит как победа.

Взгляд с интервалом. Δ=0.1535−0.1604=−0.0069\Delta = 0.1535 - 0.1604 = -0.0069. SEΔ=0.01292/5+0.01202/5=0.0079\mathrm{SE}_\Delta = \sqrt{0.0129^2/5 + 0.0120^2/5} = 0.0079, ν≈8\nu \approx 8, t≈2.31t \approx 2.31, полуширина 2.31⋅0.0079=0.0182.31 \cdot 0.0079 = 0.018. Интервал [−0.025; +0.011][-0.025;\ +0.011] накрывает ноль: значимой разницы не обнаружено. Между seed одной конфигурации разброс 0.148–0.179 — в четыре с лишним раза больше «эффекта». С множителем 1.96 вместо 2.31 интервал вышел бы на 15% уже — меньше прогонов, больше самоуверенности.

Для сравнения, lr 0.01 против 0.02 даёт +0.040+0.040 с интервалом [+0.024; +0.056][+0.024;\ +0.056]: это настоящее различие, и его хорошо видно уже на пяти seed.

Парное сравнение по seed. В этом скрипте seed задаёт одни и те же начальные веса и батчи для обоих lr, поэтому прогоны можно сравнить попарно, как вопросы в 1.3.4. Разности по seed: −0.0141, −0.0143, −0.0029, −0.0094, +0.0064; среднее −0.0069, интервал по Стьюденту с ν=4\nu = 4: [−0.018; +0.004][-0.018;\ +0.004]. Уже, но ноль по-прежнему внутри. Парность честна, только если seed управляет одной и той же случайностью в обеих конфигурациях: другая архитектура расходует генератор иначе.

Сколько seed нужно. Чтобы разница 0.007 при таком разбросе вышла за полуширину интервала, нужно около 27 seed на конфигурацию; чтобы находить её в 80% экспериментов (мощностьмощность экспериментаEN statistical powerВероятность, что эксперимент обнаружит эффект, если он действительно есть. Обычно планируют 80%; для этого считают нужный размер выборки до эксперимента. 80%) — около 50. Отсюда практичный вопрос до запуска: какой эффект нам вообще важен? Если нам нужны только улучшения больше 0.03, пяти seed достаточно: интервал уже исключил эффект больше 0.025.

Абляцияабляционный эксперимент, абляцияEN ablation studyУбрать или заменить один компонент и сравнить с полной моделью, чтобы измерить вклад этого компонента. меняет ровно одну вещь (правила — в 10.1.2). Tuning Playbook добавляет важное уточнение. Для каждого эксперимента гиперпараметры делятся на три группы:

  • научные — то, что сравниваем (число слоёв);
  • мешающие (nuisance) — то, что нужно подобрать заново для каждого варианта, иначе сравнение нечестное (learning rate: у глубокой сети свой лучший lr);
  • фиксированные — то, что не трогаем, и вывод верен только при этих значениях (функция активации).

Типичная ошибка: «4 слоя хуже 2 слоёв», когда lr подбирали для 2 слоёв, — сравнили не архитектуры, а то, как они переносят чужой lr. Seed — тоже мешающий параметр, но его не подбирают, а усредняют: «лучший seed для каждого варианта» — это проклятие победителя из 1.3.4. И помните о множественных сравнениях: из 20 абляций без всякого эффекта одна в среднем выйдет «значимой».

Лабораторный журнал и вывод по эксперименту

Заголовок раздела «Лабораторный журнал и вывод по эксперименту»

Журнал экспериментовжурнал экспериментовEN experiment logЗапись каждого запуска — дата, коммит, конфиг, гипотеза, результат, вывод. Из него потом пишется отчёт. — это то, что связывает папки прогонов в рассуждение. Цикл одного эксперимента:

flowchart LR
H["Гипотеза<br/>и ожидаемый эффект"] --> C["Конфиг:<br/>одно изменение"]
C --> R["Прогоны:<br/>N seed"] --> S["Сводка:<br/>среднее, std, ДИ"]
S --> V["Вывод<br/>и решение"] --> J["Запись<br/>в журнал"]
J -->|"следующая гипотеза"| H
Цикл эксперимента: ожидание записывается до запуска, вывод — с интервалом

Шаблон записи, заполненный по нашему примеру:

## 2026-10-06 · EXP-014 · lr 0.03 вместо 0.02
Гипотеза: за 500 шагов модель с lr 0.02 недоучивается; lr 0.03 снизит val loss.
Изменение: train.lr 0.02 → 0.03, остальное — configs/base.yaml @ a1b2c3d.
Важный эффект: −0.03 и больше (меньший нам не нужен).
Протокол: 5 seed (0–4), val loss на шаге 500, интервал Уэлча 95%.
Прогоны: runs/2026-10-06_*_circles_lr0.03_s{0..4}
Результат: 0.1604 ± 0.0129 → 0.1535 ± 0.0120; Δ = −0.0069 [−0.025; +0.011].
Вывод: значимой разницы не обнаружено; эффекта больше 0.025 нет.
Решение: оставляем lr 0.02. Если вопрос станет важным — 30 seed или 1000 шагов.

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

Как формулировать вывод:

Что показал интервалКак писатьКак не писать
целиком по нужную сторону от нуля«улучшение 0.040 [0.024; 0.056], 5 seed»«стало лучше» без чисел
накрывает ноль, узкий«значимой разницы не обнаружено; эффекта больше X нет»«чуть лучше», «тенденция»
накрывает ноль, широкий«данных мало, вывода нет; нужно N seed»«не работает»
целиком по ненужную сторону«ухудшение 0.040 [0.024; 0.056]»молча не включить в отчёт

Таблица из примера (пять seed × три learning rate): посчитайте сводку по seed и интервал Уэлча, выведите «лучше / хуже / в пределах шума» для всех конфигураций против базовой и соберите «карточку прогона» с группой по хэшу конфига без seed. Множитель Стьюдента даёт готовая функция t975.

Пять seed, три конфигурации: что значимо и что записать в карточкуnumpy · нужен интернет8 тестов
"""Честное сравнение прогонов по нескольким seed и «карточка прогона».

Данные — val loss маленькой MLP (две окружности, Adam, 500 шагов) для трёх learning rate,
по 5 seed на каждый: ровно тот эксперимент, который разобран в уроке. Чем МЕНЬШЕ loss, тем лучше.
"""
import hashlib
import json
import math

import numpy as np

RUNS = {
    "lr=0.01": [0.2036, 0.2072, 0.1978, 0.2033, 0.1915],
    "lr=0.02": [0.1510, 0.1790, 0.1480, 0.1679, 0.1561],
    "lr=0.03": [0.1369, 0.1647, 0.1451, 0.1585, 0.1625],
}

# Квантили t_{0.975} распределения Стьюдента: множитель для 95%-интервала при df степенях свободы.
_T975 = {1: 12.7062, 2: 4.3027, 3: 3.1824, 4: 2.7764, 5: 2.5706, 6: 2.4469, 7: 2.3646, 8: 2.3060,
         9: 2.2622, 10: 2.2281, 12: 2.1788, 15: 2.1314, 20: 2.0860, 30: 2.0423, 60: 2.0003, 120: 1.9799}


def t975(df):
    """ГОТОВАЯ функция. Множитель 95%-интервала для df степеней свободы (линейная интерполяция по таблице).

    t975(4) ≈ 2.776, t975(8) ≈ 2.306; при df → ∞ стремится к 1.96 — тому самому числу из урока 1.3.4.
    """
    if df < 1:
        raise ValueError("нужна хотя бы одна степень свободы: по 2 прогона в каждой группе")
    keys = sorted(_T975)
    if df >= keys[-1]:
        return 1.96 + (_T975[keys[-1]] - 1.96) * keys[-1] / df
    for lo, hi in zip(keys, keys[1:]):
        if lo <= df <= hi:
            w = (df - lo) / (hi - lo)
            return _T975[lo] + w * (_T975[hi] - _T975[lo])


def seed_summary(values):
    """Сводка по seed: {"n": число прогонов, "mean": среднее, "std": стандартное отклонение с ddof=1}.

    Меньше двух прогонов — ValueError: по одному прогону разброс не оценить.
    """
    # TODO: ваш код
    ...


def diff_ci(a, b):
    """95%-интервал разности средних b − a по Уэлчу (дисперсии групп могут различаться).

    SE = sqrt(s_a²/n_a + s_b²/n_b); df — формула Уэлча — Саттертуэйта; интервал: diff ± t975(df) · SE.
    Возвращает {"diff", "se", "df", "low", "high"}.
    """
    # TODO: ваш код
    ...


def verdict(a, b, lower_is_better=True):
    """Вывод о варианте b относительно базы a: "лучше", "хуже" или "в пределах шума".

    «Лучше» или «хуже» — только если 95%-интервал разности целиком по одну сторону от нуля.
    Для loss лучше меньше (lower_is_better=True), для accuracy — больше (False).
    """
    # TODO: ваш код
    ...


def compare_to_baseline(runs, baseline, lower_is_better=True):
    """Сравнить каждую конфигурацию с базовой.

    runs — {имя: [метрика по seed]}. Возвращает {имя: {"diff", "low", "high", "verdict"}}
    для всех конфигураций, КРОМЕ базовой. Неизвестная база — KeyError.
    """
    # TODO: ваш код
    ...


def config_group(config):
    """Идентификатор группы прогонов: первые 8 символов sha256 от конфига БЕЗ ключа "seed".

    Конфиг сериализуется как json.dumps(..., sort_keys=True, ensure_ascii=False), поэтому порядок
    ключей не важен. Прогоны одной конфигурации с разными seed попадают в одну группу.
    """
    # TODO: ваш код
    ...


def run_card(config, history, git_commit, git_dirty):
    """Карточка прогона — словарь, который кладут в папку прогона рядом с весами.

    history — список пар (шаг, val_loss) в порядке обучения. Возвращает:
      run_id        "<группа>-s<seed>", например "3f9a0c1e-s2"
      group         config_group(config)
      seed          config["seed"]
      config        КОПИЯ конфига (исходный словарь не меняем)
      final         val loss на последнем шаге
      best, best_step  лучший (наименьший) val loss и шаг, где он достигнут (при равенстве — более ранний)
      git           {"commit": git_commit, "dirty": git_dirty}
      reproducible  True, только если коммит известен и в рабочей копии нет незакоммиченных правок
    """
    # TODO: ваш код
    ...


# Эксперимент из урока: три learning rate, по 5 seed; база — lr 0.02.
for name, values in RUNS.items():
    print(name, "→", seed_summary(values))
results = compare_to_baseline(RUNS, "lr=0.02")
for name, r in (results or {}).items():
    print(f"{name} против lr=0.02: {r['diff']:+.4f}, 95%-ДИ [{r['low']:+.4f}; {r['high']:+.4f}] → {r['verdict']}")
cfg = {"name": "circles", "seed": 2, "train": {"lr": 0.03, "steps": 500}}
print("карточка:", run_card(cfg, [(100, 0.41), (300, 0.19), (500, 0.1451)], "a1b2c3d", False))
Проверь себя6 вопросов
1 Два прогона с одинаковым seed на одной GPU дали val loss 2.3141 и 2.3157. Какие причины возможны?Можно выбрать несколько ответов
2 Конфигурация A: среднее 0.420 по 5 seed, B: 0.405. Стандартная ошибка разности 0.006, степеней свободы 8 (t0.975=2.31t_{0.975} = 2.31). Чему равна полуширина 95%-интервала разности? Округлите до трёх знаков.
3 Для loss 95%-интервал разности «новое − старое» равен [−0.031; +0.006][-0.031;\ +0.006] по 5 seed. Какой вывод правильный?
4 В base.yaml написано lr: 3e-4, и конфиг читается через yaml.safe_load (PyYAML). Что окажется в cfg["lr"]?
5 Вы сравниваете модель с 2 и с 4 слоями. Learning rate подобран для 2 слоёв и для 4 слоёв не менялся. Что не так?
6 Что обязательно должно попасть в папку прогона, чтобы коллега мог его повторить? Выберите все.Можно выбрать несколько ответов
  • «У меня работало» становится результатом на трёх уровнях: повторяемость (seed везде, детерминированные ядра), воспроизводимость (git-хэш, конфиг, версии, хэш данных) и устойчивость вывода (несколько seed и интервал).
  • Детерминизм на GPU включают флагами cudnn.benchmark = False, use_deterministic_algorithms(True) и CUBLAS_WORKSPACE_CONFIG; он медленнее, поэтому нужен для отладки и регрессионных тестов, а не для выводов.
  • Гиперпараметры живут в конфиге, эксперимент — переопределение из командной строки; опечатки и YAML-ловушки ловит проверка ключей и типов. Один прогон — одна неперезаписываемая папка; трекер — витрина, папка — источник правды.
  • При 3–5 seed интервал разности строят по Стьюденту и Уэлчу: множитель 2.3–2.8 вместо 1.96. Одно изменение за раз, мешающие гиперпараметры подбирают для каждого варианта, вывод пишут с интервалом и ожидаемым эффектом, записанным до запуска.

Дальше: урок 11.1.4 — модель в продакшене: что мониторить после релиза, как находить дрейф данных, действовать при инциденте и писать постмортем без поиска виноватых.