Ноутбук с обученной моделью прекрасно выглядит на демонстрации: ячейки бегут одна за другой, метрика качества растёт, заказчик кивает и подписывает план на квартал. Через пару месяцев вскрывается неприятная картина: ноутбук упал на ночном прогоне, модель две недели не переобучалась, скоринги считаются по устаревшим весам, и никто об этом не узнал. Между рабочим экспериментом и надёжным сервисом лежит инженерный контур: версионирование данных, конвейеры с расписанием, сборка артефактов, вывод под нагрузкой, наблюдение за дрейфом. Стоит разобрать, из чего складывается реальная инфраструктура MLOps и почему ноутбук не выдерживает роль боевого сервера, хотя остаётся отличным инструментом для исследовательской работы.
Почему Jupyter-ноутбук ломается при первом продакшен-запуске
Классическая история, знакомая половине команд. Аналитик обучил модель оттока, показал метрику 0.81 ROC AUC, получил одобрение и положил ноутбук на виртуальную машину с запуском по cron. Два месяца всё спокойно. Потом в источнике меняется схема таблицы, ячейка с загрузкой данных падает, планировщик честно пропускает запуски, качество незаметно съезжает к 0.74, а маркетинг ещё месяц работает по испорченным спискам. Разбор инцидента занимает неделю, восстановление весов ещё одну.
У ноутбука как боевой единицы четыре врождённых дефекта. Первый: скрытое состояние. Переменные из прошлых запусков, забытая ячейка в середине, порядок исполнения, который помнит только автор. Ритуал "перезапусти все ячейки сверху вниз" чинит машину, но не гарантирует воспроизведения у коллеги и плохо поддаётся ревью: дифф ноутбука читается с трудом. Второй дефект: окружение. Файл зависимостей отсутствует или не совпадает с содержимым машины, а обновление библиотеки бустинга тихо меняет разбиения. Третий: нет повторов и алертов. Упавший запуск молчит, деградация невидима до момента, когда страдает бизнес-цифра. Четвёртый: производительность. Блокирующая обработка держит таблицы в памяти целиком и не переживает пиковую нагрузку.
Ноутбук остаётся инструментом поиска: интерактивность, визуализация, быстрые пробы гипотез. Продакшен требует другого набора свойств: воспроизводимости, повторяемости, наблюдаемости, откатываемости. Ни одно из этих свойств не появляется само, каждое собирается инженерией.
Версионирование данных и кода экспериментов через DVC и MLflow
Воспроизводимость начинается с ответа на вопрос: какой код, на каких данных и с какими параметрами дал конкретную метрику. Код живёт в git, тут всё привычно. Данные целиком в репозиторий не положить, датасет на десятки гигабайт хранилище не выдержит. DVC решает задачу иначе: в репозитории хранится контрольная сумма файла, сам файл уезжает в удалённое хранилище, а команда dvc checkout восстанавливает состояние данных под конкретный коммит. Файл подменили, изменился один хеш, и разница видна в ревью.
Третий компонент, параметры и метрики запусков, забирает MLflow, актуальная версия библиотеки 3.17.1. Каждый прогон обучения фиксирует гиперпараметры, значение метрики, хеш коммита и тег версии данных. Спор "у меня на ноутбуке работало" превращается в короткий вопрос к журналу экспериментов.
Сборка связки выглядит так:
git tag exp-042
dvc add data/customers.parquet
dvc push
mlflow run . -e train --experiment-name churn-scoring
Скрипт обучения меняется минимально, автологирование снимает рутину:
import mlflow
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingClassifier
mlflow.sklearn.autolog()
df = pd.read_parquet("data/customers.parquet")
X_train, X_val, y_train, y_val = train_test_split(
df.drop(columns=["churned"]),
df["churned"],
test_size=0.2,
)
model = GradientBoostingClassifier(n_estimators=400, learning_rate=0.05)
model.fit(X_train, y_train)
Отдельная беда команд, выросших из ноутбуков, расхождение признаков: для обучения признак считается одним скриптом, для вывода его пишут заново, и в бою модель получает чуть другие числа. Явление называется training-serving skew, лечится единым кодом трансформации и, при росте проекта, хранилищем признаков вроде Feast. Правило воспроизводимости звучит просто: коммит кода плюс тег данных плюс зафиксированное окружение дают одну и ту же метрику. Если равенство нарушено, в конвейере скрытая зависимость, и её ищут до выхода в бой.
Обучение по расписанию в конвейере Airflow с ретраями и алертами
cron умеет запустить команду в три часа ночи и на этом заканчивается. Конвейер делает остальное: повторы при временной ошибке источника, алерт дежурному, блокировку параллельных запусков, проверку свежести среза, историю прогонов и зависимости шагов. Apache Airflow актуальной версии 3.3.2 остаётся рабочей лошадкой: задачи описываются кодом на Python, расписание задаётся cron-выражением, провал виден в интерфейсе и приходит оповещением.
Ежедневный конвейер переобучения занимает полстраницы:
from airflow.sdk import dag, task
@dag(schedule="0 3 * * *", catchup=False)
def churn_daily():
@task(retries=3)
def extract():
return "data/features.parquet"
@task
def train(path: str):
return 0.79
@task(trigger_rule="all_done")
def report(roc_auc: float):
...
report(train(extract()))
churn_daily()
Мелочь с большим эффектом: шаг extract повторяется трижды, временная недоступность источника не рушит ночь, шаг report отрабатывает при любом исходе и отправляет дежурному короткую сводку. Альтернативы выбирают по вкусу команды: Prefect с лёгким синтаксисом, Dagster с упором на данные, Kubeflow Pipelines для шагов в контейнерах. Инструмент вторичен, первичен результат: запуск виден, повторяем, оповещает. Команда, у которой конвейер честно краснеет при провале, узнаёт о проблеме утром, а не через месяц по графику выручки.
Вывод модели через сервис с API вместо функции predict в ноутбуке
Второй соблазн после cron-ноутбука: поднять веб-фреймворк прямо в ноутбуке и звать predict оттуда. Схема держит десяток запросов в минуту и рассыпается на первой нагрузке. Рабочий вариант оборачивает модель в отдельный сервис с контрактом, версией и проверкой здоровья процесса.
Минимальный сервис на FastAPI умещается в двадцать строк:
from fastapi import FastAPI
from joblib import load
from pydantic import BaseModel
app = FastAPI()
model = load("artifacts/model.joblib")
class Features(BaseModel):
tenure_months: int
monthly_fee: float
support_tickets: int
@app.post("/v1/predict")
def predict(item: Features):
probability = model.predict_proba([[
item.tenure_months,
item.monthly_fee,
item.support_tickets,
]])[0][1]
return {"churn_probability": round(float(probability), 4)}
Запуск и проверка ручным запросом:
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
curl -X POST localhost:8000/v1/predict -H "Content-Type: application/json" -d "{\"tenure_months\": 6, \"monthly_fee\": 890.0, \"support_tickets\": 2}"
Ориентиры по скорости: модель градиентного бустинга на 300 деревьев отвечает за 5-10 миллисекунд, обвязка на FastAPI добавляет 1-2 миллисекунды на разбор и сериализацию, четыре рабочих процесса держат сотни запросов в секунду на одном узле с восемью ядрами.
Дальше начинается зрелость. Модель ложится в реестр MLflow, образ Docker собирается с конкретной версией артефакта, выкатка идёт сначала на 5 процентов трафика, метрики новой версии сравниваются с прежней, откат занимает минуту. Для нейросетей добавляется ONNX Runtime: конвертация из фреймворка обучения ускоряет вывод без переписывания модели. Когда моделей становится много, выручает KServe версии 0.21.0 поверх Kubernetes: система сама масштабирует развёрнутые модели по метрике нагрузки. Отдельный вопрос, где считать: ночной батч-скоринг миллиона клиентов и мгновенный ответ для чата поддержки это два разных сервиса, и смешивать их в одном процессе не стоит.
Мониторинг дрейфа признаков и метрик качества в реальном времени
Модель стареет тихо. Данные уезжают, распределения меняются, а сервис продолжает отвечать за миллисекунды и выглядеть здоровым. Лечит наблюдение за входами и предсказаниями, а не только за состоянием процесса.
Стандартный индикатор дрейфа, индекс стабильности популяции PSI, считается несколькими строками:
import numpy as np
def psi(expected, actual, bins=10):
edges = np.quantile(expected, np.linspace(0, 1, bins + 1))
e_share = np.histogram(expected, edges)[0] / len(expected)
a_share = np.histogram(actual, edges)[0] / len(actual)
e_share = np.clip(e_share, 1e-6, None)
a_share = np.clip(a_share, 1e-6, None)
return float(np.sum((a_share - e_share) * np.log(a_share / e_share)))
Интерпретация отраслевая: значение ниже 0.1 говорит о стабильности, диапазон от 0.1 до 0.25 требует внимания, всё выше 0.25 означает выраженный сдвиг и повод открыть инцидент. Проверку гоняют по каждому признаку ежедневно, сравнивая свежий срез с эталонным окном периода обучения. Библиотека Evidently собирает готовые отчёты, порог алерта ставят по живой истории признака, а не по круглой цифре.
Вторая беда, отложенные метки: факт оттока виден через 60 дней после предсказания, честную метрику качества ждать нельзя. Поэтому немедленно мониторят косвенные показатели: распределение вероятностей, долю ответов у порога решения, поведение ключевых сегментов. Третий приём, теневой режим: новая модель получает копии живого трафика, ответы сравниваются с прежней версией без влияния на пользователей. Четвёртый, канареечный выпуск: 5 процентов трафика, сутки наблюдения, полный выкат или откат. Замыкает контур обратная связь: фактические исходы подтягиваются к предсказаниям соединением по идентификаторам, и следующее переобучение учится на свежих событиях.
Минимальный боевой контур MLOps для команды из трёх человек
Команде без бюджетов на платформенную разработку не нужны компоненты из презентаций корпораций, ей нужен короткий контур без присмотра. Сборка из шести шагов закрывает большинство задач:
- Код экспериментов живёт в git, данные версионированы через DVC, окружение зафиксировано файлом зависимостей с точными версиями.;
- Каждый прогон обучения пишется в MLflow: параметры, метрика, хеш коммита, тег версии данных.;
- Расписание и повторы отданы Airflow, провал запуска приходит дежурному алертом.;
- Модель отдаётся сервисом с API в контейнере Docker, тег образа совпадает с версией модели в реестре.;
- Дрейф признаков считается ежедневно, порог PSI 0.25 открывает инцидент и запускает проверку данных.;
- Откат на предыдущую версию модели отработан заранее и укладывается в одну команду.
Такой контур не блещет масштабами, зато каждый элемент проверен и расширяется по мере надобности. Появится второй бизнес-заказчик, добавится второй конвейер. Появится нейросеть, добавится конвертация в ONNX. Если час GPU обходится в пару долларов, два переобучения в неделю стоят дешевле ежедневного обеда. Инфраструктура растёт медленно, зато не рассыпается.
Реальная цена деградации модели для бизнеса в цифрах
Защищать бюджет инфраструктуры проще на языке потерь. Пример расчёта на модели скоринга оттока. Отдел маркетинга обрабатывает 100 000 клиентов в месяц, средний чек удержанного клиента 4000 рублей, конверсия предложения при рабочей модели 3.0 процента. Уставшая модель даёт 2.4 процента. Разница в 0.6 процентного пункта на ста тысячах контактов стоит 600 удержанных клиентов, или 2.4 миллиона рублей в месяц. Против этой цифры стоят скромные расходы: узел под Airflow, узел под сервис вывода и час инженера в неделю на сопровождение. Арифметика убеждает быстрее любого слайда.
Деградация редко выглядит аварией, она выглядит как "плохой месяц". Мониторинг превращает тихую просадку в конкретный график с порогом, а инфраструктура даёт лекарство: переобучить, откатить, вернуть метрику. Без контура лекарств нет, есть только надежда, что модель "пройдёт сама", и надежда в инженерии ходит плохо.
Ноутбук остаётся местом, где рождаются модели и гипотезы, это его честная работа. Продакшен начинается там, где начинается инженерия: версии, расписания, наблюдение, откат. Команды, строящие контур рано, выкатывают модели по требованию бизнеса. Команды, откладывающие, платят по счетам тихой деградации, и счета эти всегда дороже инфраструктуры.