Чужой код: читать, понимать, чинить
Как за час разобраться в чужом ML-репозитории, расследовать поломку через git log, blame и bisect, пройти код-ревью с обеих сторон, страховать рефакторинг тестами и ловить тихие баги, которые не падают, а портят метрики.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В курсе вы почти всё писали с нуля: микроград, 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, скрипты *.sh | python 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, forward | GPT.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"]
Инструменты для графа: «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
Заголовок раздела «Логирование вместо print»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.20007:52:07 INFO train: шаг 1 | loss 2.10007:52:07 INFO train: шаг 2 | loss 1.40007: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 как инструмент расследования
Заголовок раздела «Git как инструмент расследования»Git из урока 0.1.1 хранит не только код, но и ответ на вопрос
«кто, когда и зачем это поменял». Пример ниже — маленький репозиторий с функцией очистки корпуса clean.py, восемь
коммитов. Симптом: из корпуса пропали все буквы с диакритикой («café» стало «caf», «Zürich» — «z rich»), и токенизатор
их больше не видит.
log, blame и поиск по истории
Заголовок раздела «log, blame и поиск по истории»git log --oneline -- clean.py # история одного файлаgit log -S "a-zа-яё" --oneline # коммиты, где эта строка появилась или исчезлаgit blame -L 6,6 clean.py # кто и когда последним менял строку 64d4c3f3 clean: явный список символов вместо \w4d4c3f32 (Ученик 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, если плохо. Положите её вне истории — неотслеживаемым файлом, чтобы она не менялась при переключении коммитов:
from clean import clean
out = clean("Привет, <b>café</b> мир!")print(repr(out))assert "café" in out, "буквы с диакритикой пропали" # AssertionError → код выхода 1 → «bad»Дальше Git работает сам:
git bisect startgit bisect bad # текущий коммит сломанgit bisect good v1 # а здесь всё работалоgit bisect run python check_letters.pygit 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: явный список символов вместо \wrunning '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 коммитов понадобилось бы около проверок.
Хэши у вас будут другими: они зависят от автора и времени коммита. Проверка не обязана быть тестом: подойдёт
«обучить 200 шагов и сравнить val loss с порогом». Если коммит нельзя проверить (не собирается), скрипт возвращает
код 125, и bisect его пропустит. Ручной режим тот же, только вместо run вы сами пишете git bisect good или
git bisect bad после каждой проверки.
Ветки и pull request
Заголовок раздела «Ветки и pull request»Чинить чужой код прямо в 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 requestpull request (запрос на слияние)EN pull request, merge requestПредложение влить ветку с изменениями в основную; место, где идёт код-ревью и прогоняются автотесты. В GitLab называется merge request. (в GitLab — merge request) — предложение влить ветку в основную. На этой странице идёт ревью и запускаются автотесты (CI). Порядок «сначала тест, потом правка» важен: тест, который падал до правки и проходит после, доказывает, что вы починили именно то, что сломано. Он же становится регрессионным тестомрегрессионный тестEN regression testТест, написанный на уже исправленный баг, чтобы он не вернулся незаметно. и не даст багу вернуться. Подробнее о pull request в чужие открытые проекты — в уроке 11.4.2.
Код-ревью с двух сторон
Заголовок раздела «Код-ревью с двух сторон»В руководстве Google по код-ревью главный принцип сформулирован так: одобряйте изменение, когда оно точно улучшает общее состояние кода, даже если оно не идеально. Ревью — не экзамен на идеальность, а фильтр, который не пускает ухудшения.
Когда ревьюят вас
Заголовок раздела «Когда ревьюят вас»- Маленький PR. В том же руководстве: около 100 строк — обычно нормальный размер, 1000 — обычно слишком много. Десять багов — лучше десять коммитов, а рефакторинг — отдельным PR от исправлений. Большой PR ревьюер пролистает и одобрит не читая, и вы потеряете вторую пару глаз.
- Описание отвечает на три вопроса: что сломано (симптом с числами), почему (причина), как проверено
(какой тест, какие метрики до и после). Например: «val loss в логах вдвое выше train. Причина:
log_softmaxпо оси времени. Проверено:per_token_nllсовпадает сF.cross_entropy, val 1.39 → 0.64». - Сначала ревью сами. Перечитайте свой diff перед отправкой: забытый
breakpoint(), отладочныйprint, закомментированный код находятся за минуту. - На замечания отвечайте по существу. Согласились — исправили. Не согласились — объясните почему, с аргументом или числом.
Когда ревьюите вы
Заголовок раздела «Когда ревьюите вы»Порядок — от крупного к мелкому: сначала замысел (нужна ли правка вообще и туда ли она внесена), потом
корректность, тесты, и только в конце имена и стиль. Мелочи помечайте префиксом «Nit:» (от nitpick — придирка):
так автор понимает, что это необязательно. Пишите вопросами и с причиной («Здесь dim=1 — это ось времени?
Мне кажется, нормировать нужно по словарю»), а не приказами. Отвечать на просьбу о ревью в руководстве Google
советуют не дольше одного рабочего дня: чужой PR, который висит неделю, тормозит команду сильнее, чем ваша
задача.
Что смотрит сеньор в ML-коде
Заголовок раздела «Что смотрит сеньор в ML-коде»| Аспект | Вопросы ревьюера | Пример находки |
|---|---|---|
| Корректность | Совпадает ли с формулой? Те ли оси? Сдвинуты ли метки? Нет ли утечки между train и test? | softmax(dim=1) у тензора (B, T, V) нормирует по времени |
| Численная устойчивость | Что будет при , пустом батче, огромных логитах? Где 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 — скаляр; - будущее не влияет на прошлое: поменяли токен в позиции — логиты в позициях до те же;
- loss на старте для словаря из символов (урок 3.3.3);
- переобучение одного батча: за 100–200 шагов loss на одном батче падает почти до нуля;
- сверка с эталоном: своя функция против
F.cross_entropyилиF.scaled_dot_product_attentionна случайных тензорах; - детерминизм: два запуска с одним seed дают одинаковые числа.
Такие тесты работают секунды, поэтому их гоняют на каждый коммит. Ни один из них не требует знать, где баг.
Каталог тихих багов ML-кода
Заголовок раздела «Каталог тихих багов ML-кода»Галерея из урока 3.3.3 показала, как выглядят забытый zero_grad, двойной softmax и перепутанные оси на MNIST.
Ниже — расширенный каталог для LLM-кода: симптом, по которому баг узнают, и способ его поймать.
| Баг | Симптом | Как поймать |
|---|---|---|
Не тот режим train/eval: при оценке нет model.eval() или после неё нет model.train() | val loss «шумит» между оценками одного чекпоинта; или после первой валидации train loss внезапно становится ниже val | assert model.training после оценки; оценить один чекпоинт дважды — числа равны |
| Статистики нормализации посчитаны по train+test | метрика на тесте чуть лучше, чем потом на проде; часто почти незаметно | статистики считать только в fit на train (Pipeline, урок 2.4.4); тест: изменение test не меняет обработанный train |
| Маска внимания пропускает одну позицию | train loss подозрительно низкий, ниже энтропии текста; генерация — мусор | изменить токены правее — логиты до не должны измениться |
| Метки не сдвинуты на один токен | 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 torchprint(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]])Модель с такой маской на обучении «списывает» ответ: позиция видит токен , который ей и нужно предсказать. В домашней работе модель с одним этим багом за 300 шагов дошла до val loss 0.06 нат/символ, а исправная — до 0.64. Это не успех: корпус составлен из случайно выбранных слов, его энтропия около 0.4 нат/символ (её можно посчитать по правилам генерации корпуса), и честная модель физически не может опуститься ниже. При генерации будущего нет, и модель выдаёт бессмыслицу. Правило: loss ниже энтропии данных — признак утечки ответа, а не гениальности модели.
Broadcasting. Модель вернула вероятности столбцом (n, 1), метки — вектор (n,). Вычитание не падает, а
молча строит матрицу всех пар (урок 0.3.2):
import numpy as npy = 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 меньше . Перенесите этот код на словарь 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. Тесты проверяют каждую функцию отдельно и весь пайплайн целиком.
"""Пайплайн логистической регрессии: «будет ли завтра инцидент в сервисе» по метрикам сегодняшнего дня.
Код достался от коллеги. Он запускается, ничего не падает, 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]])
Проверь себя
Заголовок раздела «Проверь себя»- Чужой репозиторий читают от карты к деталям: точка входа → конфиг → путь данных → модель и loss → что пишется на
диск. Граф вызовов, отладчик с условием и
loggingзаменяют догадки фактами. - Git — инструмент следователя:
log -Sнаходит, откуда взялась строка,blame— кто и когда её менял,bisect runсо скриптом-проверкой находит сломавший коммит за шагов. - Исправление — в ветке и через pull request: сначала тест, который воспроизводит баг, потом минимальная правка. PR маленький, описание отвечает на вопросы «что, почему, как проверено». Ревьюер идёт от замысла к стилю и помечает мелочи «Nit:».
- Перед рефакторингом — тест-фиксатор поведения и тесты-инварианты: формы, «будущее не влияет на прошлое», loss на старте около , сверка с эталонной функцией, детерминизм.
- Тихие баги ML-кода нарушают проверяемые законы. Loss ниже энтропии данных — утечка ответа, разная оценка одного чекпоинта — dropout или случайная валидация, разные кривые при одном seed — не зафиксирован генератор.
Тесты-фиксаторы и маленькие шаги — основа и для большего: постепенно вырастить из унаследованного кода чистую модель предметной области. Как это делают в DDD («пузырь», предохранительный слой, «душитель») — в уроке DDD.4.3; как называть вещи языком бизнеса — в уроке DDD.1.2.
Дальше: урок 11.1.3 — эксперименты как у профи. Seed везде, конфиги вместо правки кода, одна папка на прогон, несколько seed и доверительный интервал разницы. Это нужно, чтобы отличить настоящее улучшение от шума, которого вы сегодня научились бояться.