ai_learning/modules/00_environment/notes/03_jupyter_seeds_logging.md

12 KiB
Raw Permalink Blame History

03. Jupyter и модули: seed, конфигурация и логирование

Отделяем исследование от повторяемого вычисления и сохраняем контекст каждого запуска.

Мотивация

Ноутбук позволяет быстро соединить код, графики и комментарии. Но его видимый порядок ячеек не равен порядку исполнения: состояние ядра может содержать старые переменные. В результате ноутбук работает у автора и падает после Restart & Run All.

Повторяемый эксперимент должен иметь явные входы, один путь исполнения и журнал, по которому можно восстановить условия и ход работы.

Ноутбук и модуль решают разные задачи

Ноутбук удобен для:

  • первичного просмотра данных;
  • интерактивных графиков и объяснений;
  • коротких одноразовых гипотез.

Модуль .py удобен для:

  • повторно используемых функций и классов;
  • тестирования и статического анализа;
  • запуска из командной строки и автоматизации;
  • ясного порядка исполнения.

Практический цикл: гипотеза возникает в ноутбуке, устойчивая логика переносится в src/, покрывается тестом, а ноутбук импортирует её. Ноутбук остаётся тонким отчётом, а не единственным местом реализации.

Скрытое состояние Jupyter

Ядро — долгоживущий процесс. Ячейка изменяет его состояние:

scale = 10
values = [1, 2, 3]

Если позже изменить первую ячейку, но не выполнить её снова, нижняя ячейка увидит старое значение. Номер исполнения [17] показывает фактический порядок, но после передачи файла читатель обычно идёт сверху вниз.

Критерий исправного ноутбука:

  1. перезапустить ядро;
  2. выполнить все ячейки сверху вниз;
  3. получить все результаты без ручного вмешательства;
  4. повторить запуск в чистом окружении.

Нельзя полагаться на переменную, созданную удалённой ячейкой, локальный путь или ручное исправление DataFrame в памяти.

Чистые функции и явные границы

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

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 предпочитает локальный генератор:

import numpy as np

rng = np.random.default_rng(42)
sample = rng.normal(loc=0.0, scale=1.0, size=5)
print(sample)

Передача генератора делает зависимость явной:

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-экспериментов правильная цель — статистическая воспроизводимость в заданном допуске, а не побитовое равенство.

Конфигурация как вход

Параметры не должны быть разбросаны по ячейкам. Соберём их в неизменяемый объект:

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 — продолжение процесса невозможно.
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, хеш данных, эпоху и метрику. Секреты, персональные данные и полный содержимый датасет в лог не помещают.

Разобранный воспроизводимый запуск

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 делает поток случайности явной зависимостью.
  • Итоговую конфигурацию, метаданные среды и метрики нужно сохранять вместе.
  • Логи объясняют ход запуска, но не должны содержать секреты и лишние данные.

Связанные материалы