Все материалы
Продуктовая аналитикапрактикумсредний

Воспроизводимый анализ в pandas: как превратить ноутбук в pipeline

Как организовать анализ в pandas так, чтобы его можно было повторить: разделить загрузку, очистку, расчёт и проверку результата.

КейсПрактика4 августа 2026 г.обновлено 12 сентября 2026 г.12 мин

Ноутбук может отлично объяснять исследование и при этом плохо подходить для повторного запуска. Через неделю ячейки выполняются в другом порядке, промежуточная переменная уже существует, а исходный CSV заменили новым файлом. Воспроизводимый pipeline превращает такой анализ в последовательность понятных шагов с входом, выходом и проверками.

Коротко

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

  • Перезапуск ноутбука с чистого ядра должен давать тот же результат.
  • Исходные файлы не редактируются на месте.
  • Промежуточные таблицы имеют понятные имена и ожидаемый уровень строк.
  • После каждого важного шага есть хотя бы одна проверка.

Разделите анализ на слои

Не обязательно сразу превращать ноутбук в большой Python-пакет. Достаточно начать с четырёх слоёв. load отвечает за источник, clean — за форму и качество, transform — за бизнес-расчёт, report — за итоговую таблицу или график. Тогда изменение фильтра не смешивается с чтением файла и построением визуализации.

Минимальная структура воспроизводимого анализа
СлойВопросРезультат
loadкакой файл или запрос загружаем?DataFrame исходного зерна
cleanможно ли доверять типам и ключам?проверенная очищенная таблица
transformкак считаем показатель?агрегат на нужном уровне
reportкак показать вывод?таблица, график и короткий текст

Функции лучше скрытых состояний ноутбука

Функция с явным входом и выходом помогает увидеть зависимости. Если очистка использует переменную, созданную пять ячеек назад, её сложно проверить отдельно. Используйте .copy() на границе слоя и возвращайте новый DataFrame, если это не дорогая операция для текущего объёма.

pythonОчистка и контроль таблицы заказов
import pandas as pd

def clean_orders(raw: pd.DataFrame) -> pd.DataFrame:
    orders = raw.copy()
    orders['created_at'] = pd.to_datetime(orders['created_at'], errors='coerce')
    orders['revenue'] = pd.to_numeric(orders['revenue'], errors='coerce')
    orders['channel'] = orders['channel'].fillna('unknown')
    orders = orders.drop_duplicates('order_id')

    if orders['order_id'].isna().any():
        raise ValueError('order_id must not be empty')
    if orders['revenue'].isna().any():
        raise ValueError('revenue contains invalid values')
    return orders

Пять копий расчёта конверсии — это пять определений метрики

Копипаста начинается безобидно. Ячейка считает конверсию за июль, рядом появляется такая же за август, потом разрез по каналам, потом вариант для выгрузки в презентацию. Через месяц в одной копии поправили фильтр тестовых аккаунтов, в остальных четырёх — нет. Формально в ноутбуке один расчёт, фактически пять, и расходятся они на те самые доли процента, которые потом никто не может объяснить.

Функция помогает не тем, что короче, а тем, что у расчёта появляется единственное место. Минимальный контракт — что приходит, на каком зерне и что возвращается. Имена колонок стоит принимать параметрами: conversion_rate(df, numerator='orders', denominator='sessions') переносится на другую таблицу без копии тела, а функция с зашитыми внутрь df['orders'] и df['sessions'] будет скопирована при первом же новом источнике.

Второе требование — чистота. Внутри расчёта нет чтения файла, запроса к базе, print и обращения к переменной, которой нет среди аргументов. Ввод-вывод живёт по краям сценария, расчёт — в середине. Выгода тут не идеологическая: чистую функцию можно позвать дважды с разными аргументами и сравнить ответы, а та, которая сама открывает data/orders.csv, такого сравнения не переживёт. Сюда же относится правка на месте: если расчёт меняет переданный DataFrame, порядок вызовов начинает влиять на результат — ровно то состояние, из которого ноутбук и вынимали.

Отдельный вопрос — что функция отдаёт на пустом входе. Соблазнительно написать if df.empty: return 0.0, и это худший из вариантов: ноль в отчёте читается как «конверсии не было», хотя данных не было вовсе. Выбор между исключением и nan — выбор между двумя заметными вариантами, и оба лучше тихого нуля. Тот же принцип про форму результата: функция, которая для одного сегмента возвращает число, а для нескольких DataFrame, превращает вызывающий код в набор условий.

Побочный эффект явного контракта — расчёт становится проверяемым. На вход подаётся таблица из четырёх строк с дублем, пропуском и записью на границе периода, на выходе сверяется заранее известное число. До выделения функции проверять было нечего: ячейка зависела от состояния ядра. Как собирать такой контрольный набор и что сверять кроме одного числа, разобрано в материале про тестирование расчётов.

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

Границы аналитической функции
ЭлементГде живётПочему так
чтение файла или запросв сценарии, снаружииначе функцию не позвать на контрольной таблице
имена колонокв параметрахиначе вторая таблица даёт вторую копию кода
период и фильтрыв аргументахвидно, чем отличаются два запуска
print и запись результатаснаруживывод не зависит от того, где считали
пустой входявная ветка: nan или ошибканоль читается как измеренное значение
pythonРасчёт с явным контрактом и честным пустым входом
def conversion_rate(df: pd.DataFrame, numerator: str, denominator: str) -> float:
    """Доля numerator в denominator на том зерне, которое пришло в df."""
    base = df[denominator].sum()
    if base == 0:
        return float('nan')  # не 0.0: «нет конверсий» и «нет базы» — разные ответы
    return df[numerator].sum() / base


def conversion_by(df: pd.DataFrame, dimension: str, numerator: str, denominator: str) -> pd.DataFrame:
    grouped = df.groupby(dimension, as_index=False).agg(
        numerator=(numerator, 'sum'),
        denominator=(denominator, 'sum'),
    )
    base = grouped['denominator'].mask(grouped['denominator'] == 0)
    grouped['conversion'] = grouped['numerator'] / base
    return grouped

Проверка результата — часть расчёта

Проверки должны ловить не только ошибки типов, но и смысловые поломки. Если после фильтра все заказы исчезли, код может выполниться без исключения. Поэтому полезны минимальный размер выборки, диапазон дат, уникальность ключа и контрольная сумма выручки.

pythonПроверки перед публикацией отчёта
def assert_report_ready(orders: pd.DataFrame, report: pd.DataFrame) -> None:
    assert len(orders) > 0, 'empty orders table'
    assert orders['order_id'].is_unique, 'duplicate order_id'
    assert orders['created_at'].notna().all(), 'invalid dates'
    assert report['revenue'].ge(0).all(), 'negative revenue'
    assert report['channel'].notna().all(), 'missing channel'
Assertion не заменяет мониторинг

Проверки остановят конкретный запуск и помогут заметить проблему. Для регулярного процесса дополнительно сохраняйте статус, размер входа, время обновления и причину ошибки.

Как выглядит главный сценарий

В финальном ноутбуке или скрипте порядок действий читается сверху вниз. Сначала получаем raw-данные, затем вызываем очистку, строим отчёт и отдельно проверяем его. Такой код проще перенести из ноутбука в задачу по расписанию, когда расчёт станет регулярным.

pythonОдин прозрачный путь от файла к отчёту
raw_orders = pd.read_csv('data/orders.csv')
orders = clean_orders(raw_orders)

report = (
    orders.groupby('channel', as_index=False)
    .agg(orders=('order_id', 'nunique'), revenue=('revenue', 'sum'))
)

assert_report_ready(orders, report)
report.to_csv('output/channel_report.csv', index=False)

Что ломает воспроизводимость

Самые неприятные проблемы часто не выглядят как ошибки Python. Ноутбук зависит от текущей рабочей директории, использует файл с тем же именем, но другим периодом, или считает показатель после ручного удаления строк. Это нужно вынести из памяти автора в код и описание запуска.

  • Не полагайтесь на порядок выполнения ячеек — перезапускайте ядро перед публикацией.
  • Не смешивайте ручное редактирование CSV с автоматической очисткой.
  • Храните параметры периода и фильтры в одном месте.
  • Добавляйте в результат дату обновления и размер исходной выборки.
  • Если расчёт стал регулярным, вынесите его из ноутбука в скрипт или задачу.

Параметры должны быть видны в одном месте

Ноутбук становится трудно повторить, когда период, список каналов и порог фильтра спрятаны в разных ячейках. Соберите параметры в начале сценария и передавайте их в функции. Тогда коллега может запустить анализ за другой месяц, не меняя формулу внутри расчёта. Это также помогает отличить изменение входного периода от изменения логики.

Параметры результата стоит сохранять вместе с отчётом: date_from, date_to, timezone, версия источника и время запуска. Если через неделю цифра отличается, сначала сравните эти поля. Без них даже правильный результат трудно воспроизвести, потому что неизвестно, на каком срезе он был получен.

Не превращайте конфигурацию в десятки переменных. Оставьте только то, что действительно меняется между запусками, а константы бизнес-правила вынесите в функцию или документ определения метрики. Хорошая конфигурация отвечает на вопрос «что запускаем», а не повторяет весь код в виде словаря.

pythonЯвный запуск анализа с параметрами
CONFIG = {
    'date_from': '2026-07-01',
    'date_to': '2026-08-01',
    'timezone': 'UTC',
    'source_version': 'orders_v3',
}

orders = load_orders(CONFIG['date_from'], CONFIG['date_to'])
clean = clean_orders(orders)
report = build_report(clean)
report['source_version'] = CONFIG['source_version']

Слои помогают понять, где изменилась цифра

Минимальная структура анализа состоит из raw, clean, metrics и output. Raw повторяет вход и не редактируется. Clean приводит типы, удаляет или маркирует плохие строки. Metrics считает показатели на определённом зерне. Output готовит таблицу для графика или передачи команде. Не обязательно создавать отдельный файл для каждого слоя, но границы должны быть видны в коде.

Контрольные числа переходят между слоями. После чтения зафиксируйте строки и период, после очистки — строки в quarantine и сумму выручки, после агрегации — число уникальных заказов и покупателей. Если итог изменился, разница должна объясняться переходом между двумя соседними слоями, а не обнаруживаться в последней ячейке.

Такой подход особенно полезен при споре о метрике. Можно показать, что источник прислал 100 000 строк, 2 300 были исключены из-за неверной даты, а итоговая выручка отличается только на сумму этих строк. Обсуждение становится предметным: команда видит не только число, но и путь к нему.

Слои небольшого аналитического pipeline
СлойЧто здесь хранитсяПроверка
rawкопия входного источникапериод, размер, хэш или версия
cleanтипизированные и очищенные данныедубли, пропуски, quarantine
metricsодна строка на день, канал или когортузерно и знаменатель
outputтаблица для графика или выгрузкисхема и дата обновления

Ноутбук и скрипт решают разные задачи

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

Граница проходит не по длине файла, а по повторяемости. Если один и тот же отчёт собирают каждую неделю, ручная последовательность ячеек уже является скрытым процессом. Переведите её в функцию, добавьте тест на контрольном файле и оставьте ноутбук как объяснение результата, а не как единственное место, где существует логика.

Для запуска по расписанию добавьте явные ошибки и логирование. Сообщение «KeyError» мало помогает владельцу отчёта. Сообщение «в источнике orders_v3 отсутствует колонка revenue» позволяет быстро исправить проблему или передать её владельцу данных.

Воспроизводимость — это договор команды

Документируйте вход, параметры, ожидаемый выход и владельца источника. Один аккуратный README рядом с pipeline полезнее, чем сложная инфраструктура без понятного запуска.

Ноутбук можно отдавать, когда номера выполнения ячеек идут по порядку

Самая частая причина, по которой чужой ноутбук не повторяется, видна до запуска. Посмотрите на номера в квадратных скобках у ячеек: 3, 4, 17, 5, 28, 12. Это порядок, в котором ячейки выполнялись на самом деле, и он не совпадает с порядком, в котором их будет читать и запускать следующий человек. Переменная, созданная в ячейке с номером 17 и переопределённая в ячейке с номером 5, у него получит другое значение — или не получит никакого, и расчёт упадёт на середине.

Смотреть глазами не обязательно. Ноутбук — это JSON, и счётчик запуска лежит в поле execution_count каждой ячейки с кодом. Счётчик null означает, что ячейка не выполнялась в этой сессии вообще; невозрастающая последовательность означает, что порядок чтения не равен порядку запуска. Десяток строк на разбор файла отвечают на вопрос «это вообще прогоняли сверху вниз?» за секунду, а не за полчаса чужого расчёта.

Для передачи важно и то, где лежат параметры. Выше речь шла о том, чтобы собрать их в одном месте; у ноутбука, который отдают, место конкретное — первая ячейка с кодом, и ничего кроме параметров в ней нет. Тогда запуск за другой месяц не требует чтения всего файла, а инструменты вроде papermill умеют подменять такую ячейку снаружи: papermill report.ipynb out/2026-08.ipynb -p date_from 2026-08-01. Выполненный экземпляр остаётся рядом с результатом и сам отвечает, на каких параметрах получена цифра.

Сдача — это Restart Kernel и прогон всех ячеек на чистом ядре, а не «у меня посчитано». Полезным этот прогон делают две вещи. Сразу после загрузки ячейка печатает число строк, границы периода и версию источника — получатель видит, что у него те же данные, а не похожие. И расчёт не правит исходный файл и не дописывает результат в папку, из которой читает: иначе второй прогон даст третий ответ, и виноватым окажется код, а не данные.

Чего ноутбук не умеет, тем его и не нагружают. Сохранённые выводы ячеек делают историю изменений нечитаемой: поправил одну строку кода — в коммит уехала картинка в base64. Для файла, который живёт в репозитории, выводы чистят перед коммитом. Версии библиотек ноутбук тоже не фиксирует, поэтому рядом должен лежать файл зависимостей: через полгода расчёт иначе упадёт или, хуже, тихо изменит ответ на новом поведении библиотеки по умолчанию.

И про «отдать в прод» буквально. Ноутбук по расписанию работает, пока внутри него один путь и понятное сообщение об ошибке. Как только появляются ветки по сегментам, повторные попытки и обработка сбоев источника, в расписание уходит модуль с функциями, а ноутбук остаётся рядом как разбор результата — с графиком, промежуточной таблицей и текстом, которых в модуле не будет.

pythonПорядок запуска ячеек читается прямо из файла
import json
from pathlib import Path

cells = json.loads(Path('report.ipynb').read_text())['cells']
counts = [cell['execution_count'] for cell in cells if cell['cell_type'] == 'code']

never_run = sum(1 for count in counts if count is None)
out_of_order = sum(
    1 for a, b in zip(counts, counts[1:]) if a is not None and b is not None and b < a
)
print({'code_cells': len(counts), 'never_run': never_run, 'out_of_order': out_of_order})
  • Номера выполнения возрастают, пустых среди них нет.
  • Параметры — в первой ячейке с кодом и больше нигде.
  • Прогон на чистом ядре печатает размер входа, период и версию источника.
  • Исходные файлы после прогона не изменились ни на байт.
  • Рядом лежат версии библиотек и выполненный экземпляр с результатом.

Практика: сделайте отчёт повторяемым за один вечер

Возьмите любой свежий ноутбук с продуктовым отчётом и запустите его на чистом ядре. Отметьте все переменные, которые появились до текущей ячейки, и все места, где DataFrame меняется на месте. Затем вынесите параметры периода, загрузку и очистку в функции. После каждого слоя добавьте два-три контрольных числа. Уже этот проход обычно показывает, какие шаги держались на памяти автора.

Положите рядом маленький CSV с известным ответом. Он должен содержать дубль, пропуск и строку на границе периода. Если pipeline даёт ожидаемый результат на таком файле и на реальном срезе, можно переходить к автоматизации. Если нет — сначала исправьте определение и тест, не добавляя новый оркестратор.

Финальный результат сохраните вместе с метаданными и ссылкой на входной файл. Читатель будущего отчёта должен понять, какую цифру он видит, за какой период и какой набор правил был применён. Это и есть практическая версия воспроизводимости.

  • Перезапустить ноутбук с чистого ядра.
  • Собрать параметры и путь к источнику в одном месте.
  • Разделить raw, clean, metrics и output.
  • Добавить контрольные числа и маленькую фикстуру.
  • Сохранить метаданные запуска рядом с результатом.
Продолжить чтение
Вся библиотека