# 03. Jupyter и модули: seed, конфигурация и логирование > Отделяем исследование от повторяемого вычисления и сохраняем контекст каждого запуска. ## Мотивация Ноутбук позволяет быстро соединить код, графики и комментарии. Но его видимый порядок ячеек не равен порядку исполнения: состояние ядра может содержать старые переменные. В результате ноутбук работает у автора и падает после `Restart & Run All`. Повторяемый эксперимент должен иметь явные входы, один путь исполнения и журнал, по которому можно восстановить условия и ход работы. ## Ноутбук и модуль решают разные задачи Ноутбук удобен для: - первичного просмотра данных; - интерактивных графиков и объяснений; - коротких одноразовых гипотез. Модуль `.py` удобен для: - повторно используемых функций и классов; - тестирования и статического анализа; - запуска из командной строки и автоматизации; - ясного порядка исполнения. Практический цикл: гипотеза возникает в ноутбуке, устойчивая логика переносится в `src/`, покрывается тестом, а ноутбук импортирует её. Ноутбук остаётся тонким отчётом, а не единственным местом реализации. ## Скрытое состояние Jupyter Ядро — долгоживущий процесс. Ячейка изменяет его состояние: ```python scale = 10 values = [1, 2, 3] ``` Если позже изменить первую ячейку, но не выполнить её снова, нижняя ячейка увидит старое значение. Номер исполнения `[17]` показывает фактический порядок, но после передачи файла читатель обычно идёт сверху вниз. Критерий исправного ноутбука: 1. перезапустить ядро; 2. выполнить все ячейки сверху вниз; 3. получить все результаты без ручного вмешательства; 4. повторить запуск в чистом окружении. Нельзя полагаться на переменную, созданную удалённой ячейкой, локальный путь или ручное исправление DataFrame в памяти. ## Чистые функции и явные границы **Чистая функция** возвращает результат, определяемый аргументами, и не изменяет внешнее состояние. Работа с файлами, временем, сетью и генератором случайных чисел — побочные эффекты. Их полезно изолировать на краях программы. ```python def standardize(values: list[float], mean: float, std: float) -> list[float]: if std <= 0: raise ValueError("std должен быть положительным") return [(x - mean) / std for x in values] print(standardize([10.0, 12.0], mean=11.0, std=1.0)) ``` Чем меньше неявных зависимостей, тем проще тест и повторный запуск. ## Случайность и seed Псевдослучайный генератор детерминирован: внутреннее состояние и алгоритм задают последовательность. **Seed** инициализирует состояние. Он не «делает числа неслучайными», а позволяет повторить ту же последовательность при тех же условиях. Современный NumPy предпочитает локальный генератор: ```python import numpy as np rng = np.random.default_rng(42) sample = rng.normal(loc=0.0, scale=1.0, size=5) print(sample) ``` Передача генератора делает зависимость явной: ```python import numpy as np def bootstrap_mean(x: np.ndarray, rng: np.random.Generator) -> float: indices = rng.integers(0, len(x), size=len(x)) return float(x[indices].mean()) x = np.array([1.0, 2.0, 5.0, 8.0]) rng = np.random.default_rng(7) print(bootstrap_mean(x, rng)) ``` Глобальный `np.random.seed` влияет на общий генератор процесса. Любой лишний вызов сдвигает последовательность, поэтому удалённая функция может незаметно изменить результат. Независимые локальные генераторы уменьшают связанность. ## Границы детерминизма Одинаковый seed не гарантирует побитово одинаковый результат, если различаются: - версии генератора или библиотеки; - число потоков и порядок параллельных операций; - GPU, драйверы и недетерминированные ядра; - порядок чтения файлов или элементов множества; - платформа и точность вычислений. Сложение чисел с плавающей точкой не ассоциативно: \((a+b)+c\) может отличаться от \(a+(b+c)\) из-за округления. Поэтому для многих ML-экспериментов правильная цель — статистическая воспроизводимость в заданном допуске, а не побитовое равенство. ## Конфигурация как вход Параметры не должны быть разбросаны по ячейкам. Соберём их в неизменяемый объект: ```python from dataclasses import asdict, dataclass import json @dataclass(frozen=True) class Config: seed: int = 42 learning_rate: float = 0.01 epochs: int = 20 config = Config() print(json.dumps(asdict(config), ensure_ascii=False, indent=2)) ``` Конфигурацию валидируют до дорогого запуска. Значения по умолчанию должны быть безопасными, а итоговая разрешённая конфигурация — сохраняться вместе с результатом. Это важно, если аргумент командной строки переопределяет файл. ## Логирование вместо случайных print Лог — структурированная временная последовательность событий. Уровни: - `DEBUG` — детали диагностики; - `INFO` — нормальные этапы процесса; - `WARNING` — необычное, но допустимое состояние; - `ERROR` — операция не выполнена; - `CRITICAL` — продолжение процесса невозможно. ```python import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("train") for epoch, loss in enumerate([1.2, 0.8, 0.55], start=1): logger.info("epoch=%d loss=%.4f", epoch, loss) ``` Шаблон и аргументы передаются раздельно: форматирование не выполняется, если сообщение отфильтровано. В библиотечном коде обычно не настраивают корневой logger; настройка принадлежит точке входа приложения. Полезно писать поля `run_id`, commit, seed, хеш данных, эпоху и метрику. Секреты, персональные данные и полный содержимый датасет в лог не помещают. ## Разобранный воспроизводимый запуск ```python from dataclasses import dataclass import json import logging from pathlib import Path import numpy as np @dataclass(frozen=True) class Config: seed: int = 11 n: int = 1000 def run(config: Config) -> dict[str, float]: rng = np.random.default_rng(config.seed) x = rng.normal(size=config.n) return {"mean": float(x.mean()), "std": float(x.std())} logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") config = Config() metrics = run(config) record = {"config": config.__dict__, "metrics": metrics} Path("artifacts").mkdir(exist_ok=True) Path("artifacts/run.json").write_text( json.dumps(record, indent=2), encoding="utf-8" ) logging.info("result=%s", metrics) ``` Здесь случайность локальна, конфигурация явна, вычислительная функция возвращает данные, а запись артефакта отделена. Для полной воспроизводимости в запись также добавляют версии, Git-коммит и идентификатор данных. ## Типичные ошибки и ограничения - **Ноутбук исполняется только в накопленном состоянии.** Проверяйте чистый запуск. - **Seed ставится внутри каждой функции.** Все вызовы повторяют один и тот же поток. Передавайте один генератор или создавайте независимые потоки намеренно. - **Конфигурация меняется после старта.** Журнал уже не описывает фактический запуск. - **Все сообщения имеют уровень `INFO`.** Диагностика становится шумной и дорогой. - **Исключение проглатывается.** Лог без повторного возбуждения ошибки может создать видимость успешного результата. - **В лог попадают токены.** Логи часто хранятся дольше и доступны шире кода. - **Побитовое равенство объявлено обязательным без причины.** Оно может потребовать более медленных детерминированных алгоритмов. ## Краткое резюме - Ноутбук предназначен для исследования, модуль — для повторного использования. - Исправный ноутбук выполняется сверху вниз после перезапуска ядра. - Локальный генератор NumPy делает поток случайности явной зависимостью. - Итоговую конфигурацию, метаданные среды и метрики нужно сохранять вместе. - Логи объясняют ход запуска, но не должны содержать секреты и лишние данные. ## Связанные материалы - Предыдущая глава: [`02_git_reproducibility.md`](02_git_reproducibility.md). - Практика: [`../assignments/03_reproducibility.md`](../assignments/03_reproducibility.md). - Термины: [`glossary.md`](glossary.md).