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

Чужой код: читать, понимать, чинить

Как за час разобраться в чужом ML-репозитории, расследовать поломку через git log, blame и bisect, пройти код-ревью с обеих сторон, страховать рефакторинг тестами и ловить тихие баги, которые не падают, а портят метрики.

УРОКstage-11/11-1-real-work/02-legacy-code.mdx2 ч 30 минУрок 2 из 5
После урока вы сможете
  • Составить карту незнакомого репозитория — точки входа, конфиги, граф вызовов — и проверять гипотезы отладчиком и логами, а не догадками
  • Найти коммит, который всё сломал, через git log -S, blame и bisect, в том числе автоматически скриптом
  • Оформить pull request, который легко ревьюить, и ревьюить чужой ML-код по чек-листу сеньора
  • Узнавать по симптому десять тихих багов ML-кода и писать тест, который ловит каждый

В курсе вы почти всё писали с нуля: микроград, GPT, токенизатор, RAG. На работе всё иначе. Вы приходите в команду, где уже есть репозиторий на 30 тысяч строк, и первая задача звучит как «после прошлого релиза метрика просела на два пункта, разберись». Автор кода в отпуске или уволился, тесты покрывают треть, README устарел. Это и есть унаследованный кодунаследованный код (legacy)EN legacy codeКод, который достался от других людей или от прошлых версий проекта; авторов часто нет рядом, тестов мало. В китайском сленге — «цзучуань дайма» («код от предков»)., и работа с ним занимает большую часть времени инженера.

Опаснее всего в ML-коде тихие багитихий багEN silent bugОшибка, при которой программа не падает и не пишет предупреждений, но результат неверный — в ML это испорченные обучение или метрика.. Обычная программа с ошибкой падает с traceback (урок 0.2.5). Обучение с ошибкой почти всегда доходит до конца: loss падает, чекпоинт сохраняется, графики красивые. Карпатый в «A Recipe for Training Neural Networks» формулирует это так: нейросети ломаются молча. Можно неделю «улучшать» модель, у которой метки сдвинуты на один символ.

Урок собирает рабочие инструменты: как читать большой репозиторий, как расследовать поломку через Git, как проходить код-ревьюкод-ревьюEN code reviewПроверка изменений кода другим инженером до слияния в основную ветку — на корректность, читаемость, тесты и производительность., как страховать правки тестами и какие тихие баги искать в первую очередь. В конце — задание с пятью спрятанными багами и домашняя работа, где их десять.

Не читайте код файл за файлом сверху вниз: так вы утонете в деталях раньше, чем поймёте, что важно. Сначала нарисуйте карту. Её можно составить за час, отвечая на вопросы по порядку:

ВопросГде искатьПример ответа для nanoGPT
Что запускают?README, if __name__ == "__main__", pyproject.toml, скрипты *.shpython train.py config/train_shakespeare_char.py
Откуда берутся параметры?argparse, YAML/JSON-конфиги, переменные окруженияconfigurator.py переопределяет глобальные переменные train.py
Как идут данные?функции загрузки, Dataset, get_batch.bin-файлы токенов → get_batch → X, Y
Где модель и loss?model.py, forwardGPT.forward(idx, targets) возвращает логиты и loss
Что пишется на диск?save, torch.save, логиckpt.pt в out_dir

Примеры в правом столбце проверены по репозиторию nanoGPT (урок 4.6.2). В его configurator.py автор сам предупреждает в докстринге: «Poor Man’s Configurator. Probably a terrible idea». Такие честные комментарии в чужом коде — подарок: они говорят, где ждать сюрпризов.

Точка входа — функция, с которой начинается выполнение: main(), тело if __name__ == "__main__":, обработчик HTTP-запроса. От неё вы идёте по вызовам вглубь и рисуете граф: кто кого вызывает и что передаёт. Вот такой граф для проекта charlm из домашней работы этого урока:

flowchart LR
M["train.main<br/>(CLI, конфиг)"] --> T["train.train"]
T --> S["data.split_train_val"] --> W["data.make_windows"]
T --> B["data.iterate_batches"]
T --> L["train.lr_at"]
T --> ST["train.train_step"] --> G["model.CharGPT"] --> A["model.attention<br/>+ causal_mask"]
T --> E["evaluate.estimate_loss"] --> N["evaluate.per_token_nll"]
T --> C["utils.save_checkpoint"]
Граф вызовов charlm: от точки входа к листьям. Баги чаще всего живут в листьях, а проявляются в корне

Инструменты для графа: «Go to Definition» (F12) и «Go to References» (Shift+F12) в VS Code, поиск по репозиторию через grep -rn или rg (ripgrep), а для динамики — вызов traceback.print_stack() внутри функции: он печатает, кто её вызвал. Например, rg -n "estimate_loss\(" за секунду покажет все места вызова оценки. Если их три, а model.train() восстанавливается только в одном, вы уже нашли подозреваемого.

ОтладчикотладчикEN debuggerПрограмма, которая останавливает ваш код в нужном месте и показывает значения всех переменных. В Python — pdb, в VS Code — встроенный. pdb вы знаете из урока 0.2.5. В обучающем коде его ставят с условием, чтобы не останавливаться на каждом из десяти тысяч шагов:

for step, (xb, yb) in enumerate(loader):
if step == 0:
breakpoint() # (Pdb) p xb.shape, yb.shape
# (Pdb) p tok.decode(xb[0].tolist())
# (Pdb) p tok.decode(yb[0].tolist())
loss = train_step(model, optimizer, xb, yb)
if not math.isfinite(loss): # train_step вернул float; нужен import math
breakpoint() # остановиться ровно там, где появился nan

Два p tok.decode(...) на первом шаге — самая полезная проверка в ML-отладке. Вход и цель должны быть кусками одного текста, сдвинутыми на символ. Если это два разных текста или один и тот же — баг в данных, и дальше можно не смотреть. Для упавшего скрипта есть «посмертный» режим: python -m pdb -c continue train.py запускает программу и открывает отладчик в месте исключения, со всеми переменными.

print хорош для одноразового эксперимента. В коде, который живёт месяцами, используют модуль logging:

import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S")
log = logging.getLogger("train")
for step in range(3):
loss = 4.2 / (step + 1)
log.debug("шаг %d: форма батча %s", step, (32, 64)) # при уровне INFO не печатается
log.info("шаг %d | loss %.3f", step, loss)
log.warning("val loss ниже train loss — проверьте dropout и утечки")
07:52:07 INFO train: шаг 0 | loss 4.200
07:52:07 INFO train: шаг 1 | loss 2.100
07:52:07 INFO train: шаг 2 | loss 1.400
07:52:07 WARNING train: val loss ниже train loss — проверьте dropout и утечки

Что это даёт. У каждой строки есть время, поэтому видно, где программа «думала» 40 минут. Уровень (DEBUG, INFO, WARNING, ERROR) позволяет включить подробности одной строкой logging.getLogger("train").setLevel(logging.DEBUG) в точке входа, не правя функции, где стоят сообщения. Имя логгера говорит, из какого модуля сообщение. Вывод можно направить в файл рядом с чекпоинтом. И главное: отладочные сообщения не надо удалять перед коммитом, как print, — они молчат, пока не понадобятся. Форма log.info("loss %.3f", loss) с аргументами вместо f-строки собирает текст, только если сообщение действительно будет напечатано.

Git из урока 0.1.1 хранит не только код, но и ответ на вопрос «кто, когда и зачем это поменял». Пример ниже — маленький репозиторий с функцией очистки корпуса clean.py, восемь коммитов. Симптом: из корпуса пропали все буквы с диакритикой («café» стало «caf», «Zürich» — «z rich»), и токенизатор их больше не видит.

Окно терминала
git log --oneline -- clean.py # история одного файла
git log -S "a-zа-яё" --oneline # коммиты, где эта строка появилась или исчезла
git blame -L 6,6 clean.py # кто и когда последним менял строку 6
4d4c3f3 clean: явный список символов вместо \w
4d4c3f32 (Ученик 2026-10-06 07:50:02 +0000 6) text = re.sub(r"[^a-zа-яё0-9\s.,!?-]", " ", text)

git log -S (его называют «кирка», pickaxe) находит коммиты, где число вхождений строки изменилось. Это лучший способ узнать, откуда взялась подозрительная строка. построчная история файлапострочная история файла (git blame)EN git blameПоказывает для каждой строки файла, в каком коммите, кем и когда она изменена последний раз. показывает последнюю правку каждой строки, а git show 4d4c3f3 — весь коммит с сообщением и diff. Здесь причина видна сразу: класс \w в Python включает буквы любых алфавитов, в том числе «é» и «ü», а явный список a-zа-яё — нет. Хорошее сообщение коммита объяснило бы, зачем это сделали; плохое («fix») не объяснит ничего. Поэтому сообщения пишут для будущего следователя.

bisect: поиск сломавшего коммита делением пополам

Заголовок раздела «bisect: поиск сломавшего коммита делением пополам»

Когда подозрительной строки не видно, работает поиск сломавшего коммита делением пополампоиск сломавшего коммита делением пополам (git bisect)EN git bisectGit сам выбирает коммит посередине между «хорошим» и «плохим», вы отвечаете good или bad; среди N коммитов виновник находится за ~log₂N проверок. Можно автоматизировать скриптом (git bisect run).. Вы знаете хороший коммит (тег v1, где всё работало) и плохой (HEAD). Git выбирает коммит посередине, вы проверяете и отвечаете good или bad, и диапазон сокращается вдвое. Это двоичный поиск из урока 0.2.5, только по истории.

Сначала напишите проверку, которая возвращает код выхода 0, если всё хорошо, и не 0, если плохо. Положите её вне истории — неотслеживаемым файлом, чтобы она не менялась при переключении коммитов:

check_letters.py
from clean import clean
out = clean("Привет, <b>café</b> мир!")
print(repr(out))
assert "café" in out, "буквы с диакритикой пропали" # AssertionError → код выхода 1 → «bad»

Дальше Git работает сам:

Окно терминала
git bisect start
git bisect bad # текущий коммит сломан
git bisect good v1 # а здесь всё работало
git bisect run python check_letters.py
git bisect reset # вернуться туда, где были
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[f2d2a8f…] clean: убрать ссылки
running 'python' 'check_letters.py'
'привет, café мир!'
Bisecting: 1 revision left to test after this (roughly 1 step)
[4d4c3f3…] clean: явный список символов вместо \w
running 'python' 'check_letters.py'
'привет, caf мир!'
AssertionError: буквы с диакритикой пропали
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[5571191…] clean: убрать эмодзи и мусор
running 'python' 'check_letters.py'
'привет, café мир!'
4d4c3f3… is the first bad commit

Вывод сокращён: traceback и полные хэши убраны. Три запуска проверки на семь коммитов. Для 1000 коммитов понадобилось бы около log⁡21000≈10\log_2 1000 \approx 10 проверок. Хэши у вас будут другими: они зависят от автора и времени коммита. Проверка не обязана быть тестом: подойдёт «обучить 200 шагов и сравнить val loss с порогом». Если коммит нельзя проверить (не собирается), скрипт возвращает код 125, и bisect его пропустит. Ручной режим тот же, только вместо run вы сами пишете git bisect good или git bisect bad после каждой проверки.

Чинить чужой код прямо в main нельзя. Рабочий цикл такой:

flowchart LR
A["git switch -c fix/zh-chars"] --> B["тест, который падает"]
B --> C["минимальная правка"]
C --> D["тест зелёный, остальные тоже"]
D --> E["git push, pull request"]
E --> F["код-ревью и CI"]
F -->|замечания| C
F -->|LGTM| G["слияние в main"]
Исправление бага через ветку и pull request: сначала тест, который воспроизводит баг, потом правка

Pull requestpull request (запрос на слияние)EN pull request, merge requestПредложение влить ветку с изменениями в основную; место, где идёт код-ревью и прогоняются автотесты. В GitLab называется merge request. (в GitLab — merge request) — предложение влить ветку в основную. На этой странице идёт ревью и запускаются автотесты (CI). Порядок «сначала тест, потом правка» важен: тест, который падал до правки и проходит после, доказывает, что вы починили именно то, что сломано. Он же становится регрессионным тестомрегрессионный тестEN regression testТест, написанный на уже исправленный баг, чтобы он не вернулся незаметно. и не даст багу вернуться. Подробнее о pull request в чужие открытые проекты — в уроке 11.4.2.

В руководстве Google по код-ревью главный принцип сформулирован так: одобряйте изменение, когда оно точно улучшает общее состояние кода, даже если оно не идеально. Ревью — не экзамен на идеальность, а фильтр, который не пускает ухудшения.

  1. Маленький PR. В том же руководстве: около 100 строк — обычно нормальный размер, 1000 — обычно слишком много. Десять багов — лучше десять коммитов, а рефакторинг — отдельным PR от исправлений. Большой PR ревьюер пролистает и одобрит не читая, и вы потеряете вторую пару глаз.
  2. Описание отвечает на три вопроса: что сломано (симптом с числами), почему (причина), как проверено (какой тест, какие метрики до и после). Например: «val loss в логах вдвое выше train. Причина: log_softmax по оси времени. Проверено: per_token_nll совпадает с F.cross_entropy, val 1.39 → 0.64».
  3. Сначала ревью сами. Перечитайте свой diff перед отправкой: забытый breakpoint(), отладочный print, закомментированный код находятся за минуту.
  4. На замечания отвечайте по существу. Согласились — исправили. Не согласились — объясните почему, с аргументом или числом.

Порядок — от крупного к мелкому: сначала замысел (нужна ли правка вообще и туда ли она внесена), потом корректность, тесты, и только в конце имена и стиль. Мелочи помечайте префиксом «Nit:» (от nitpick — придирка): так автор понимает, что это необязательно. Пишите вопросами и с причиной («Здесь dim=1 — это ось времени? Мне кажется, нормировать нужно по словарю»), а не приказами. Отвечать на просьбу о ревью в руководстве Google советуют не дольше одного рабочего дня: чужой PR, который висит неделю, тормозит команду сильнее, чем ваша задача.

АспектВопросы ревьюераПример находки
КорректностьСовпадает ли с формулой? Те ли оси? Сдвинуты ли метки? Нет ли утечки между train и test?softmax(dim=1) у тензора (B, T, V) нормирует по времени
Численная устойчивостьЧто будет при x=0x = 0, пустом батче, огромных логитах? Где log от нуля? Какой dtype?torch.log(p) вместо log_softmax; exp без вычитания максимума; id токенов в uint16
ЧитаемостьПонятно ли без автора? Есть ли «магические числа»? Нужен ли комментарий «почему»?x[:, :-1] без комментария, что это сдвиг для целей
ТестыЕсть ли тест, который падал до правки? Проверяет ли он поведение, а не реализацию?правка маски без теста «будущее не влияет на прошлое»
ПроизводительностьНет ли .item() или .cpu() в горячем цикле? Копий тензоров? Python-цикла вместо векторной операции?loss.item() на каждом микробатче синхронизирует GPU

РефакторингрефакторингEN refactoringИзменение устройства кода без изменения его поведения. Безопасен только под защитой тестов. — изменение устройства кода без изменения поведения. «Без изменения поведения» нельзя проверить на глаз, поэтому перед рефакторингом чужого кода сначала пишут тест-фиксатор (characterization test): он записывает, что код делает сейчас, даже если вы не уверены, что это правильно.

import torch
# 1) До рефакторинга: сохранить веса, вход и выход как «снимок» поведения
torch.manual_seed(0)
model = build_model().eval()
x = torch.randint(0, 100, (2, 16))
torch.save({"state": model.state_dict(), "x": x, "logits": model(x).detach()}, "tests/golden.pt")
# 2) Тест рядом с кодом: после каждой правки поведение то же самое
def test_refactor_keeps_behavior():
g = torch.load("tests/golden.pt")
model = build_model().eval()
model.load_state_dict(g["state"]) # веса из снимка: порядок инициализации мог поменяться
torch.testing.assert_close(model(g["x"]), g["logits"], atol=1e-5, rtol=0)

Для ML-кода к фиксаторам добавляют тесты-инварианты — свойства, верные при любых весах. Они ловят баги, которые снимок «запомнил бы» как норму:

  • формы: выход модели (B, T, V), loss — скаляр;
  • будущее не влияет на прошлое: поменяли токен в позиции t+1t+1 — логиты в позициях до tt те же;
  • loss на старте ≈ln⁡V\approx \ln V для словаря из VV символов (урок 3.3.3);
  • переобучение одного батча: за 100–200 шагов loss на одном батче падает почти до нуля;
  • сверка с эталоном: своя функция против F.cross_entropy или F.scaled_dot_product_attention на случайных тензорах;
  • детерминизм: два запуска с одним seed дают одинаковые числа.

Такие тесты работают секунды, поэтому их гоняют на каждый коммит. Ни один из них не требует знать, где баг.

Галерея из урока 3.3.3 показала, как выглядят забытый zero_grad, двойной softmax и перепутанные оси на MNIST. Ниже — расширенный каталог для LLM-кода: симптом, по которому баг узнают, и способ его поймать.

БагСимптомКак поймать
Не тот режим train/eval: при оценке нет model.eval() или после неё нет model.train()val loss «шумит» между оценками одного чекпоинта; или после первой валидации train loss внезапно становится ниже valassert model.training после оценки; оценить один чекпоинт дважды — числа равны
Статистики нормализации посчитаны по train+testметрика на тесте чуть лучше, чем потом на проде; часто почти незаметностатистики считать только в fit на train (Pipeline, урок 2.4.4); тест: изменение test не меняет обработанный train
Маска внимания пропускает одну позициюtrain loss подозрительно низкий, ниже энтропии текста; генерация — мусоризменить токены правее tt — логиты до tt не должны измениться
Метки не сдвинуты на один токенloss почти 0 за сотню шагов; модель «копирует» входtok.decode(x[0]) и tok.decode(y[0]) на первом батче
Broadcasting: (n, 1) против (n,)метрика правдоподобная, но не та; loss «растёт», хотя accuracy нормальнаяassert a.shape == b.shape перед поэлементными операциями
Забытый или не на месте zero_gradс SGD loss растёт и скачет; с AdamW обучение просто хужедва шага с lr = 0: в .grad — градиент только последнего батча
Перепутанные оси (softmax, mean, view вместо transpose)потеря не совпадает с эталонной; странные значения метрикисверка с F.cross_entropy; сумма вероятностей по нужной оси = 1
dtype и переполнениеinf и nan в fp16; «чужие» токены после сохранения idпроверять диапазон перед приведением типа; torch.isfinite
seed и недетерминизмдва одинаковых запуска дают разные кривые; «улучшение» не воспроизводитсязапустить дважды и сравнить побитово (урок 3.3.3)
Перемешанная валидация с оценкой на части батчейкривая val скачет, «лучший» чекпоинт выбран шумомоценивать фиксированный val целиком, shuffle=False

Разберём четыре случая подробнее: на них чаще всего спотыкаются, когда переходят от учебного кода к рабочему.

Маска на одну позицию. torch.tril строит нижний треугольник. Параметр diagonal=1 добавляет к нему ещё одну диагональ — ту, где столбец на единицу правее строки, то есть «следующий токен»:

import torch
print(torch.tril(torch.ones(4, 4, dtype=torch.bool)).long()) # правильно
print(torch.tril(torch.ones(4, 4, dtype=torch.bool), diagonal=1).long()) # видит позицию i + 1
# два вывода поставлены рядом
tensor([[1, 0, 0, 0], tensor([[1, 1, 0, 0],
[1, 1, 0, 0], [1, 1, 1, 0],
[1, 1, 1, 0], [1, 1, 1, 1],
[1, 1, 1, 1]]) [1, 1, 1, 1]])

Модель с такой маской на обучении «списывает» ответ: позиция ii видит токен i+1i+1, который ей и нужно предсказать. В домашней работе модель с одним этим багом за 300 шагов дошла до val loss 0.06 нат/символ, а исправная — до 0.64. Это не успех: корпус составлен из случайно выбранных слов, его энтропия около 0.4 нат/символ (её можно посчитать по правилам генерации корпуса), и честная модель физически не может опуститься ниже. При генерации будущего нет, и модель выдаёт бессмыслицу. Правило: loss ниже энтропии данных — признак утечки ответа, а не гениальности модели.

Broadcasting. Модель вернула вероятности столбцом (n, 1), метки — вектор (n,). Вычитание не падает, а молча строит матрицу n×nn \times n всех пар (урок 0.3.2):

import numpy as np
y = np.array([1, 0, 1])
p = np.array([[0.9], [0.2], [0.6]]) # (3, 1)
print((y - p).shape) # (3, 3)
print(np.mean((y - p) ** 2), np.mean((y - p.ravel()) ** 2)) # 0.3144 вместо 0.07

Ошибка в 4.5 раза, и число выглядит вполне правдоподобно. Лечение: явный assert p.shape == y.shape.

dtype и переполнение. nanoGPT хранит токены GPT-2 в np.uint16: в его prepare.py комментарий поясняет, что это можно, потому что максимальный id 50 256 меньше 2162^{16}. Перенесите этот код на словарь Qwen3 (151 669 токенов, урок 4.2.3), и большие id тихо заворачиваются: np.array([151000]).astype(np.uint16) даёт 19928. Модель учится на перемешанных токенах, и никакой ошибки нет. Тот же класс багов — FP16 с максимумом 65 504 (урок 5.4.1): torch.tensor([300.0], dtype=torch.float16) ** 2 даёт inf.

Перемешанная валидация. Сама по себе shuffle=True у валидационного загрузчика безвредна, если вы проходите весь набор. Беда, когда оценка берёт первые eval_iters батчей: каждый раз это другое подмножество, кривая val скачет на шуме, а «лучший чекпоинт» выбран случайно. Хуже того, на временных рядах перемешивание ломает порядок, от которого зависит модель. Проверка простая: оцените один и тот же чекпоинт дважды. Числа должны совпасть до последнего знака.

Небольшой «чужой» пайплайн на NumPy: логистическая регрессия предсказывает по метрикам сервиса за сегодня, будет ли инцидент завтра. Код запускается и печатает accuracy 0.460, log loss 0.700 и норму весов 0.17. В нём спрятаны пять тихих багов: утечка статистик нормализации, сдвиг меток, broadcasting в функции потерь, неверный знак регуляризации в градиенте и перемешивание X без y. Найдите и исправьте их, не меняя имён и аргументов функций. После исправления accuracy поднимется до 0.793, log loss опустится до 0.482, а норма весов станет 1.18. Тесты проверяют каждую функцию отдельно и весь пайплайн целиком.

Пять тихих багов в пайплайне логистической регрессииnumpy · нужен интернет6 тестов
"""Пайплайн логистической регрессии: «будет ли завтра инцидент в сервисе» по метрикам сегодняшнего дня.

Код достался от коллеги. Он запускается, ничего не падает, accuracy что-то показывает. Но в нём спрятаны
ПЯТЬ тихих багов: каждый портит обучение или метрику, не роняя программу. Найдите и исправьте их точечно,
не меняя имена и аргументы функций. Докстринги описывают, как функция ДОЛЖНА работать, — им можно верить.
make_data и sigmoid исправны.
"""
import numpy as np


def make_data(n_days=600, seed=0):
    """Дневные метрики сервиса (3 признака) и флаг «в этот день был инцидент».

    Инцидент в день t+1 зависит от метрик дня t. Метрики медленно дрейфуют со временем.
    """
    rng = np.random.default_rng(seed)
    feats = rng.normal(size=(n_days, 3)) + np.linspace(0, 1.5, n_days)[:, None] * np.array([1.0, 0.5, 0.0])
    w_true, b_true = np.array([2.0, -1.5, 1.0]), -1.0
    events = np.zeros(n_days, dtype=int)
    p_next = 1 / (1 + np.exp(-(feats[:-1] @ w_true + b_true)))
    events[1:] = (rng.random(n_days - 1) < p_next).astype(int)
    return feats, events


def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -30, 30)))


def make_xy(feats, events):
    """Признаки дня t → метка «был ли инцидент на СЛЕДУЮЩИЙ день». Возвращает X (n−1, d) и y (n−1,)."""
    X = feats[:-1]
    y = events[:-1]
    return X, y


def standardize(X_train, X_test):
    """Вычитает среднее и делит на std; статистики — ТОЛЬКО по обучающей выборке."""
    X_all = np.vstack([X_train, X_test])
    mu = X_all.mean(axis=0)
    sd = X_all.std(axis=0) + 1e-8
    return (X_train - mu) / sd, (X_test - mu) / sd


def shuffle(X, y, rng):
    """Одна и та же случайная перестановка строк X и элементов y."""
    idx = rng.permutation(len(X))
    return X[idx], y


def log_loss(p, y):
    """Средняя логистическая потеря. p и y — векторы длины n."""
    p = np.clip(p, 1e-12, 1 - 1e-12).reshape(-1, 1)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))


def gradient(X, y, w, b, lam):
    """Градиент J(w, b) = log_loss + lam/2 · ||w||² по w и b."""
    p = sigmoid(X @ w + b)
    grad_w = X.T @ (p - y) / len(y) - lam * w
    grad_b = float(np.mean(p - y))
    return grad_w, grad_b


def fit(X, y, lam=0.1, lr=0.1, epochs=30, batch_size=32, seed=0):
    """Мини-батчевый градиентный спуск. Возвращает w, b и историю log_loss на обучении по эпохам."""
    rng = np.random.default_rng(seed)
    w, b = np.zeros(X.shape[1]), 0.0
    history = []
    for _ in range(epochs):
        Xs, ys = shuffle(X, y, rng)
        for i in range(0, len(Xs), batch_size):
            gw, gb = gradient(Xs[i : i + batch_size], ys[i : i + batch_size], w, b, lam)
            w, b = w - lr * gw, b - lr * gb
        history.append(log_loss(sigmoid(X @ w + b), y))
    return w, b, history


def run_pipeline(feats, events, test_frac=0.25):
    """Весь путь: метки → деление по времени → стандартизация → обучение → метрики на тесте."""
    X, y = make_xy(feats, events)
    n_train = int(len(X) * (1 - test_frac))           # деление по времени: тест — последние дни
    X_tr, X_te = standardize(X[:n_train], X[n_train:])
    y_tr, y_te = y[:n_train], y[n_train:]
    w, b, history = fit(X_tr, y_tr)
    p = sigmoid(X_te @ w + b)
    return {
        "accuracy": float(np.mean((p > 0.5) == y_te)),
        "log_loss": log_loss(p, y_te),
        "w_norm": float(np.linalg.norm(w)),
        "train_loss": history,
    }


feats, events = make_data()
report = run_pipeline(feats, events)
print(f"accuracy на тесте: {report['accuracy']:.3f}")
print(f"log loss на тесте: {report['log_loss']:.3f}")
print(f"норма весов:       {report['w_norm']:.2f}")
print("train loss по эпохам:", [round(x, 3) for x in report["train_loss"][::6]])
Проверь себя6 вопросов
1 После правки коллеги train и val loss символьной модели упали до 0.03 нат/символ, хотя энтропия текста около 0.5. Генерация — бессмыслица. Что вероятнее всего?
2 Между «хорошим» и «плохим» коммитом 200 коммитов. Сколько проверок понадобится git bisect в худшем случае? (целое число)
3 Вероятности модели имеют форму (n, 1), метки — (n,). Какой формы будет p - y в NumPy?
4 Вы оценили один и тот же чекпоинт на валидации дважды и получили 1.412 и 1.437. Какие причины возможны?Можно выбрать несколько ответов
5 Какие советы по код-ревью соответствуют руководству Google?Можно выбрать несколько ответов
6 Почему забытый zero_grad с SGD обычно «взрывает» обучение, а с AdamW только ухудшает его?
  • Чужой репозиторий читают от карты к деталям: точка входа → конфиг → путь данных → модель и loss → что пишется на диск. Граф вызовов, отладчик с условием и logging заменяют догадки фактами.
  • Git — инструмент следователя: log -S находит, откуда взялась строка, blame — кто и когда её менял, bisect run со скриптом-проверкой находит сломавший коммит за log⁡2N\log_2 N шагов.
  • Исправление — в ветке и через pull request: сначала тест, который воспроизводит баг, потом минимальная правка. PR маленький, описание отвечает на вопросы «что, почему, как проверено». Ревьюер идёт от замысла к стилю и помечает мелочи «Nit:».
  • Перед рефакторингом — тест-фиксатор поведения и тесты-инварианты: формы, «будущее не влияет на прошлое», loss на старте около ln⁡V\ln V, сверка с эталонной функцией, детерминизм.
  • Тихие баги ML-кода нарушают проверяемые законы. Loss ниже энтропии данных — утечка ответа, разная оценка одного чекпоинта — dropout или случайная валидация, разные кривые при одном seed — не зафиксирован генератор.

Тесты-фиксаторы и маленькие шаги — основа и для большего: постепенно вырастить из унаследованного кода чистую модель предметной области. Как это делают в DDD («пузырь», предохранительный слой, «душитель») — в уроке DDD.4.3; как называть вещи языком бизнеса — в уроке DDD.1.2.

Дальше: урок 11.1.3 — эксперименты как у профи. Seed везде, конфиги вместо правки кода, одна папка на прогон, несколько seed и доверительный интервал разницы. Это нужно, чтобы отличить настоящее улучшение от шума, которого вы сегодня научились бояться.