Эксперименты как у профи: воспроизводимость и учёт
Почему «у меня работало» — не результат. Seed во всех генераторах и детерминизм с его ценой, версии окружения, конфиги вместо правки кода, одна папка на прогон с конфигом, метриками и git-хэшем, TensorBoard и MLflow без облака, честное сравнение по нескольким seed с интервалом Стьюдента, абляции и лабораторный журнал.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Коллега пишет в чат: «Поднял 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 — всё ещё одно случайное число. Выводы делают на третьем уровне, а первые два нужны для отладки и для проверки коллегой.
Seed везде
Заголовок раздела «Seed везде»Урок 3.3.3 дал функцию set_seed для трёх генераторов. В рабочем
коде случайность живёт ещё в двух местах: в рабочих процессах DataLoader и в порядке батчей. Рецепт из документации:
import randomimport numpy as npimport torchfrom 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 из
документации нужен для старых версий и для библиотек со своим генератором.
Ещё три правила:
- Seed данных отдельно от seed обучения. Разбиение на train и val, генерация синтетики, выборка eval-набора — со
своим фиксированным seed (в выпускном проекте —
seed=2026для eval). Seed обучения меняют от прогона к прогону, а данные должны оставаться теми же. - На нескольких картах веса инициализируют одним seed на всех рангах, а данные у каждого ранга свои — это разобрано в 5.7.4.
- Состояние генераторов — в чекпоинт (урок 3.3.3):
random.getstate(),np.random.get_state(),torch.get_rng_state(),torch.cuda.get_rng_state_all(). Ловушка: с PyTorch 2.6torch.loadпо умолчанию (weights_only=True) откажется читать состояние NumPy. Свои чекпоинты грузите сweights_only=False— и только свои: этот режим исполняет код из файла.
Детерминизм и его цена
Заголовок раздела «Детерминизм и его цена»Даже со всеми seed два прогона на GPU расходятся в последних знаках. Причина та же, что в уроке 3.3.3: сложение
чисел с плавающей точкой неассоциативно, а часть ядер GPU складывает результаты потоков в порядке, который
меняется от запуска к запуску (атомарные сложения в index_add, scatter_add, в backward у embedding). За тысячи
шагов разница в последнем бите вырастает до заметной. Что можно включить:
| Настройка | Что делает | Цена |
|---|---|---|
torch.backends.cudnn.benchmark = False | cuDNN не перебирает алгоритмы свёртки по замеру, а выбирает детерминированно | свёртки могут стать медленнее; трансформеров почти не касается |
torch.backends.cudnn.deterministic = True | только детерминированные алгоритмы свёрток | медленнее свёртки |
torch.use_deterministic_algorithms(True) | все операции PyTorch, у которых есть детерминированная версия, переходят на неё; у остальных — RuntimeError | медленнее операции с атомарными сложениями; часть операций падает |
CUBLAS_WORKSPACE_CONFIG=:4096:8 | фиксирует буферы cuBLAS; в PyTorch до 2.9 включительно без неё torch.mm при включённом детерминизме падает с RuntimeError | 8 буферов по 4 МиБ памяти |
NCCL_ALGO, NCCL_PROTO | фиксируют алгоритм (Ring, Tree…) и протокол (LL, LL128, Simple) коллективных операций | обмен между картами может замедлиться |
Как это выглядит в коде:
def make_deterministic(): torch.backends.cudnn.benchmark = False # не выбирать алгоритм свёртки по замеру скорости torch.use_deterministic_algorithms(True) # детерминированные ядра; где их нет — RuntimeErrorCUBLAS_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 platformimport numpy as npimport 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) со всеми гиперпараметрами и путями прогона. Эксперименты меняют конфиг или переопределяют его из командной строки, а не правят код; конфиг сохраняется в папку прогона., код их только читает,
а эксперимент — это переопределение из командной строки.
name: circlesseed: 0data: {seed: 123, n: 2000}model: {hidden: 16}train: {lr: 0.02, steps: 500, batch: 32}deterministic: falseimport sysimport 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.0003yaml.safe_load("no") → Falseyaml.safe_load("010") → 8lr: 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, timeimport 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 пишется именно из них.
Учёт экспериментов: TensorBoard, MLflow, W&B
Заголовок раздела «Учёт экспериментов: TensorBoard, MLflow, W&B»Сравнивать двадцать кривых в папках с 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-хэшем.
Как сравнивать прогоны честно
Заголовок раздела «Как сравнивать прогоны честно»Интервал разности при пяти seed
Заголовок раздела «Интервал разности при пяти seed»В уроке 1.3.4 шум был от выборки вопросов, и примеров были сотни. Здесь шум от seed: инициализация, порядок
батчей, dropout. Каждый прогон дорог, поэтому повторов мало — обычно 3–5. Посчитаем по каждой конфигурации
среднее , и стандартное отклонение по прогонам , (с ddof=1). Разность и её стандартная
ошибка:
Формула та же, что в 1.3.4: дисперсии независимых величин складываются. Отличие — в множителе: по пяти прогонам само оценено плохо и может случайно оказаться вдвое меньше настоящего разброса. Это учитывает распределение Стьюдентараспределение Стьюдента (t-распределение)EN Student's t-distributionРаспределение среднего, нормированного на оценённое по выборке стандартное отклонение. При малом числе наблюдений у него тяжелее хвосты, чем у нормального: для 95%-интервала по 5 прогонам множитель 2.78 вместо 1.96.: интервал с множителем, который зависит от числа степеней свободы :
| 2 | 4 | 8 | 20 | ||
|---|---|---|---|---|---|
| 4.30 | 2.78 | 2.31 | 2.09 | 1.96 |
Последний столбец — знакомое 1.96: при большой выборке Стьюдент превращается в нормальное распределение. Число степеней свободы для двух групп с разным разбросом даёт формула Уэлча — Саттертуэйта (интервал Уэлчаt-тест Уэлча (интервал Уэлча)EN Welch's t-testСравнение средних двух групп без предположения, что их дисперсии равны: SE = sqrt(s₁²/n₁ + s₂²/n₂), число степеней свободы — по формуле Уэлча — Саттертуэйта. Вариант по умолчанию для сравнения конфигураций по нескольким seed.):
Читаем: при одинаковом разбросе и пяти прогонах в каждой группе ; чем сильнее различаются разбросы, тем ближе к более шумной группы. Если формула пугает, возьмите : интервал выйдет чуть шире, вывод — осторожнее.
Пример: два learning rate, пять seed
Заголовок раздела «Пример: два learning rate, пять seed»Маленькая 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.0129lr=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%. Выглядит как победа.
Взгляд с интервалом. . , , , полуширина . Интервал накрывает ноль: значимой разницы не обнаружено. Между seed одной конфигурации разброс 0.148–0.179 — в четыре с лишним раза больше «эффекта». С множителем 1.96 вместо 2.31 интервал вышел бы на 15% уже — меньше прогонов, больше самоуверенности.
Для сравнения, lr 0.01 против 0.02 даёт с интервалом : это настоящее различие, и его хорошо видно уже на пяти seed.
Парное сравнение по seed. В этом скрипте seed задаёт одни и те же начальные веса и батчи для обоих lr, поэтому прогоны можно сравнить попарно, как вопросы в 1.3.4. Разности по seed: −0.0141, −0.0143, −0.0029, −0.0094, +0.0064; среднее −0.0069, интервал по Стьюденту с : . Уже, но ноль по-прежнему внутри. Парность честна, только если 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 и «карточка прогона».
Данные — 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))
Проверь себя
Заголовок раздела «Проверь себя»- «У меня работало» становится результатом на трёх уровнях: повторяемость (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 — модель в продакшене: что мониторить после релиза, как находить дрейф данных, действовать при инциденте и писать постмортем без поиска виноватых.