MLOps: от эксперимента до продакшена
Четырнадцать статей трека были про то, как получить хорошую модель. Эта — про то, почему хорошая модель сама по себе почти ничего не стоит.
Классическая иллюстрация из статьи Google «Hidden Technical Debt in Machine Learning Systems» (NIPS 2015): нарисована схема реальной ML-системы, и на ней собственно «ML-код» — маленький прямоугольник в центре. Вокруг него — сбор данных, верификация, извлечение признаков, конфигурация, менеджмент ресурсов, инфраструктура сервинга, мониторинг, инструменты анализа. По объёму работы обучение модели — это единицы процентов. Всё остальное и есть MLOps.
Формулировка, которую стоит запомнить:
MLOps — это набор практик, который делает поведение ML-системы воспроизводимым, наблюдаемым и обратимым. Воспроизводимым — я могу получить ровно эту модель ещё раз. Наблюдаемым — я узнаю о деградации раньше, чем бизнес. Обратимым — я могу вернуть предыдущую версию за минуты, не разбудив ML-инженера.
Всё, что не служит этим трём свойствам, — карго-культ и трата бюджета. Держите этот фильтр включённым, читая про инструменты: их десятки, а задач у них ровно три.
Чем ML-система отличается от обычного сервиса
Если бы модель была просто ещё одним микросервисом, хватило бы обычного DevOps. Но есть четыре отличия, и каждое ломает привычные практики.
1. Поведение задаётся данными, а не только кодом. В обычном сервисе баг живёт в коде: есть
коммит, который его внёс, и git bisect его найдёт. В ML-системе поведение — функция от
(код, данные, гиперпараметры, случайное зерно, версии библиотек). Изменение любого из пяти
входов меняет артефакт. Значит, версионировать нужно все пять, а не только первый.
2. Правильность не бинарна. Тест либо зелёный, либо красный. Модель выдаёт 0.87 ROC-AUC — это хорошо или плохо? Ответ зависит от базовой линии, от того, как метрика связана с деньгами (см. оценку моделей), и от того, что было вчера. Отсюда — необходимость непрерывного измерения, а не разовой приёмки.
3. Компоненты не изолируются. В той же статье Google вводят принцип CACE — Changing Anything Changes Everything: у ML-модели нет «независимых» входов. Уберите один признак — веса всех остальных перераспределятся. Улучшите upstream-модель, которая поставляет признак, — downstream модель, откалиброванная под её старые ошибки, ухудшится. Привычная модульность здесь не работает, её приходится заменять контрактами на данные и сквозными тестами.
4. Система деградирует без изменений в коде. Сервис, который не трогали полгода, работает так же. Модель, которую не трогали полгода, почти наверняка стала хуже: мир под ней поменялся. Это единственный класс ПО, который «протухает» в покое.
Есть отличный эмпирический материал на эту тему: «Challenges in Deploying Machine Learning: a Survey of Case Studies» (Paleyes et al., 2020) — разбор десятков реальных проектов по стадиям жизненного цикла, и «Operationalizing Machine Learning: An Interview Study» (Shankar et al., 2022) — интервью с инженерами о том, на что на самом деле уходит время.
Уровни зрелости: куда вы двигаетесь
Google в MLOps: Continuous delivery and automation pipelines in ML описывает три уровня. Это полезная система координат: не для того, чтобы всем стремиться на третий, а чтобы честно понять, где вы и какой следующий шаг реально окупится.
Практическое правило: уровень 0 нормален, пока модель одна и переобучается раз в полгода. Он становится дорогим, когда моделей становится больше трёх или когда цикл переобучения короче месяца — тогда ручные шаги начинают съедать всю команду. Уровень 2 оправдан, когда моделей десятки и над платформой работает отдельная команда; для стартапа с двумя моделями это оверинжиниринг, который замедлит вас сильнее, чем ручной деплой.
Кстати, значительная часть здравого смысла тут пришла из обычной инженерии: см. принципы проектирования и архитектурные паттерны — MLOps не изобретает CI/CD и наблюдаемость заново, он адаптирует их под данные.
Часть 1. Воспроизводимость
Что именно нужно зафиксировать
Воспроизводимость — это способность через полгода из тега в git получить бит-в-бит (или хотя бы метрика-в-метрику) ту же модель. Требуется пять якорей:
| Что | Чем фиксируем | Типичная ошибка |
|---|---|---|
| Код | git commit SHA | «поправил в ноутбуке, не закоммитил» |
| Данные | снапшот/версия датасета, хеш содержимого | «SELECT … WHERE date < today()» |
| Окружение | lock-файл + образ контейнера | pip install -U в Dockerfile |
| Конфигурация | YAML в репозитории, а не аргументы CLI | гиперпараметры в теле ноутбука |
| Случайность | зерно на каждый источник энтропии | train_test_split без random_state |
Самый недооценённый пункт — данные. Запрос WHERE event_date >= '2025-01-01' не воспроизводим:
за это время в таблицу доехали опоздавшие события, кто-то перезалил партицию, а часть строк
удалили по GDPR. Ссылка на «данные» должна быть иммутабельной: либо снапшот с датой среза
(AS OF в лейкхаусе, snapshot id в Iceberg/Delta), либо файл с зафиксированным хешем.
Практика: контроль версий данных
Минимальный рабочий вариант — DVC: данные лежат в S3, а в git — маленькие файлы-указатели с хешами.
# инициализация в существующем git-репозитории
dvc init
dvc remote add -d storage s3://ml-artifacts/dvcstore
# берём датасет под контроль: в git попадёт только data/train.parquet.dvc с md5
dvc add data/train.parquet
git add data/train.parquet.dvc .gitignore && git commit -m "dataset v3: +Q1 2026"
dvc push # содержимое уезжает в S3
# через полгода: воспроизводим состояние на момент релиза
git checkout v1.4.0
dvc checkout # рабочая директория приведена к тем самым данным
Для больших таблиц вместо копирования файлов используют форматы с time travel:
-- Apache Iceberg / Delta Lake: срез таблицы на момент обучения, воспроизводимый навсегда
SELECT * FROM events FOR SYSTEM_VERSION AS OF 4821634975129;
Подробнее про хранение и лейкхаусы — в треке дата-инжиниринга.
Детерминизм: чего реально можно добиться
Полный бит-в-бит детерминизм на GPU недостижим дёшево: атомарные редукции в CUDA складывают числа в недетерминированном порядке, а float-сложение неассоциативно. Разумная цель — детерминизм на CPU и статистическая воспроизводимость на GPU (метрика в пределах шума при фиксированном зерне).
import os, random
import numpy as np
def fix_seed(seed: int = 42) -> None:
"""Фиксируем все источники случайности, которые реально влияют на результат."""
os.environ["PYTHONHASHSEED"] = str(seed) # порядок обхода множеств/словарей в подпроцессах
random.seed(seed)
np.random.seed(seed)
try:
import torch
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.use_deterministic_algorithms(True, warn_only=True) # медленнее, зато повторяемо
torch.backends.cudnn.benchmark = False # автотюнер выбирает разные ядра между запусками
except ImportError:
pass
Отдельная ловушка — параллелизм: n_jobs=-1 в scikit-learn или nthread в LightGBM меняют
порядок редукций, а значит и последние знаки после запятой; при большом числе итераций бустинга
это иногда даёт видимую разницу в метрике. Если нужна точная повторяемость эталонного прогона —
фиксируйте и число потоков.
Часть 2. Трекинг экспериментов
Ноутбук с ячейками, перезапущенными в произвольном порядке, — это не эксперимент, а воспоминание о нём. Трекер решает четыре задачи: (1) хранит связку «параметры → метрики → артефакт», (2) даёт сравнить сотни запусков таблицей, (3) фиксирует происхождение артефакта, (4) позволяет ответить «а с чем именно мы сравниваем новую модель».
Де-факто стандарт с открытым кодом — MLflow. Рабочий пример, который логирует всё нужное для воспроизведения:
import subprocess
import mlflow
import mlflow.sklearn
from sklearn.metrics import roc_auc_score, average_precision_score
from lightgbm import LGBMClassifier
mlflow.set_tracking_uri("http://mlflow.internal:5000")
mlflow.set_experiment("churn/monthly")
params = {"n_estimators": 800, "learning_rate": 0.03, "num_leaves": 63, "random_state": 42}
with mlflow.start_run(run_name="lgbm-v7") as run:
# 1. Происхождение: без этих трёх строк запуск невоспроизводим
mlflow.set_tag("git_sha", subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip())
mlflow.set_tag("dataset_snapshot", "s3://dwh/churn/2026-06-30/") # иммутабельный срез
mlflow.log_artifact("requirements.lock")
# 2. Параметры
mlflow.log_params(params)
model = LGBMClassifier(**params).fit(
X_train, y_train,
eval_set=[(X_valid, y_valid)],
eval_metric="auc",
)
# 3. Метрики — не одна: и ранжирующая, и «денежная», и по срезам
proba = model.predict_proba(X_valid)[:, 1]
mlflow.log_metrics({
"roc_auc": roc_auc_score(y_valid, proba),
"pr_auc": average_precision_score(y_valid, proba),
"recall_at_top5pct": recall_at_k(y_valid, proba, k=0.05),
})
for segment, mask in segments.items(): # проверка на «средняя температура»
mlflow.log_metric(f"roc_auc__{segment}", roc_auc_score(y_valid[mask], proba[mask]))
# 4. Артефакт вместе с сигнатурой входа — защита от несовпадения схем в проде
signature = mlflow.models.infer_signature(X_valid, proba)
mlflow.sklearn.log_model(model, name="model", signature=signature,
input_example=X_valid.head(5))
Три правила, которые отличают полезный трекинг от свалки:
- Логируйте метрики по срезам, а не только агрегат. Модель, которая на 2% лучше в среднем и на 15% хуже на мобильном трафике, — это регрессия, а не улучшение.
- Логируйте сигнатуру модели.
infer_signatureфиксирует имена, порядок и типы колонок; это единственный дешёвый способ поймать «переставили признаки местами» до прода. - Один запуск = один процесс. Если запуск нельзя повторить одной командой из чистого клона — трекер зафиксировал ложь.
Часть 3. Признаки в проде и training/serving skew
Это главный источник тихих потерь качества. Модель обучалась на признаках, посчитанных batch-джобой на историческом озере, а в проде получает признаки, посчитанные другим кодом, из другого хранилища, с другим лагом. Расхождение может быть крошечным — и стоить нескольких процентов конверсии.
Три источника расхождения — на схеме, но стоит проговорить главный: point-in-time correctness.
Для обучающего примера с меткой в момент T разрешено использовать только те значения признаков,
которые были бы доступны сервису в момент T, — с учётом задержки ETL. Наивный JOIN по
user_id подтягивает актуальное значение, то есть будущее относительно T. Это утечка
данных в чистом виде, и она даёт феноменальную офлайн-метрику
и никакого эффекта в A/B.
Корректный join выглядит так:
-- Для каждой метки берём последнее значение признака, доступное СТРОГО до момента события
-- (и ещё с поправкой на лаг доставки данных в 2 часа).
SELECT
l.user_id,
l.event_ts,
l.label,
f.orders_30d,
f.avg_check_30d
FROM labels AS l
ASOF JOIN features AS f -- ClickHouse/DuckDB: ASOF JOIN делает это нативно
ON f.user_id = l.user_id
AND f.computed_ts <= l.event_ts - INTERVAL 2 HOUR;
Feature store: одна декларация на два режима
Идея feature store проста: признак объявляется один раз, а инфраструктура сама умеет отдавать его в двух режимах — исторические значения с point-in-time корректностью для обучения и последние значения с низкой задержкой для инференса.
# feature_repo/definitions.py — единая декларация
from datetime import timedelta
from feast import Entity, FeatureView, Field, FileSource
from feast.types import Float32, Int64
user = Entity(name="user", join_keys=["user_id"])
user_stats = FeatureView(
name="user_stats",
entities=[user],
ttl=timedelta(days=7), # старее — считаем протухшим, а не «нулём»
schema=[
Field(name="orders_30d", dtype=Int64),
Field(name="avg_check_30d", dtype=Float32),
],
source=FileSource(path="s3://dwh/features/user_stats/", timestamp_field="computed_ts"),
online=True,
)
# обучение: point-in-time join делает store, а не вы руками
training_df = store.get_historical_features(
entity_df=labels_df, # колонки: user_id, event_timestamp, label
features=["user_stats:orders_30d", "user_stats:avg_check_30d"],
).to_df()
# инференс: тот же список признаков, но из онлайн-хранилища, за единицы миллисекунд
features = store.get_online_features(
features=["user_stats:orders_30d", "user_stats:avg_check_30d"],
entity_rows=[{"user_id": 42}],
).to_dict()
Когда feature store не нужен. Если признаки приходят прямо в запросе (текст, картинка, параметры формы) или моделей две, а признаков десять — вы получите операционную сложность без выигрыша. Feature store окупается, когда несколько моделей переиспользуют одни признаки и существует реальный онлайн-путь их вычисления.
Дешёвая альтернатива, работающая почти всегда: логировать в проде ровно тот вектор признаков, который получила модель, и обучаться на этих логах. Тогда skew структурно невозможен: обучающие данные порождены самим сервингом. Цена — придётся ждать накопления логов и решать проблему холодного старта для новых признаков.
Часть 4. Реестр моделей и жизненный цикл
Артефакт модели — это не файл в S3 с именем model_final_v2_real.pkl. Это объект с состоянием,
владельцем, метриками приёмки и историей переходов. Реестр (MLflow Model Registry, Vertex AI Model
Registry, SageMaker Model Registry) фиксирует именно это.
Ключевая деталь: переход между состояниями должен быть операцией с аудитом, а не действием человека в UI без следа. Кто перевёл, на основании каких метрик, какой запуск обучения породил артефакт — всё это должно восстанавливаться через год, когда регулятор спросит, почему клиенту отказали в кредите.
Про то, что должно лежать рядом с моделью, есть два хороших стандарта: Model Cards for Model Reporting (Mitchell et al., 2019) — карточка модели с назначением, ограничениями и метриками по группам, и Datasheets for Datasets (Gebru et al.) — то же для данных. В регулируемых отраслях это уже не «хорошая практика», а требование.
Часть 5. Тестирование ML-систем
Обычные юнит-тесты нужны, но недостаточны. Каноническая рубрика — «The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction» (Breck et al., IEEE Big Data 2017): 28 тестов по четырём осям — данные, модель, инфраструктура, мониторинг. Ниже — минимальный практический набор.
Тесты на данные (контракт)
import pandas as pd
import pytest
EXPECTED_SCHEMA = {"user_id": "int64", "orders_30d": "int64", "avg_check_30d": "float32"}
def test_schema(df: pd.DataFrame):
"""Схема — это контракт. Молчаливое появление/исчезновение колонки ломает модель."""
assert {c: str(t) for c, t in df.dtypes.items()} == EXPECTED_SCHEMA
def test_ranges(df: pd.DataFrame):
"""Диапазоны из спецификации домена, а не из наблюдаемых данных."""
assert df["orders_30d"].between(0, 10_000).all()
assert (df["avg_check_30d"] >= 0).all()
def test_nulls(df: pd.DataFrame):
"""Доля пропусков — метрика, у неё есть порог, согласованный с бизнесом."""
assert df["avg_check_30d"].isna().mean() < 0.05
def test_no_duplicate_keys(df: pd.DataFrame):
assert not df.duplicated(subset=["user_id", "computed_ts"]).any()
В проде это оформляют декларативно — Great Expectations, Pandera или TensorFlow Data Validation, который умеет выводить схему из эталонного среза и сравнивать с ней новые партии.
Поведенческие тесты модели
Метрика на holdout — необходимое, но грубое условие. Полезнее проверять свойства, а не число. Классификация из «Beyond Accuracy: Behavioral Testing of NLP Models with CheckList» (Ribeiro et al., ACL 2020) переносится на любые модели:
def test_invariance(model, X):
"""Инвариантность: изменение нерелевантного признака не должно менять решение.
Пример: ID клиента, порядок строк, регистр в свободном тексте."""
base = model.predict_proba(X)[:, 1]
shuffled = X.sample(frac=1.0, random_state=0) # перестановка строк
perturbed = model.predict_proba(shuffled)[:, 1]
assert np.allclose(base, perturbed[shuffled.index.argsort()], atol=1e-6)
def test_directional_expectation(model, X):
"""Направленное ожидание: рост числа заказов не должен увеличивать вероятность оттока."""
X_more = X.copy()
X_more["orders_30d"] += 5
assert (model.predict_proba(X_more)[:, 1] <= model.predict_proba(X)[:, 1] + 1e-9).mean() > 0.95
def test_no_regression_vs_baseline(model, baseline, X_test, y_test):
"""Новая модель не хуже действующей ни в среднем, ни на важных срезах."""
new = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
old = roc_auc_score(y_test, baseline.predict_proba(X_test)[:, 1])
assert new >= old - 0.002
for name, mask in critical_segments.items():
assert roc_auc_score(y_test[mask], model.predict_proba(X_test[mask])[:, 1]) >= \
roc_auc_score(y_test[mask], baseline.predict_proba(X_test[mask])[:, 1]) - 0.01
def test_serialization_roundtrip(model, X):
"""Модель после сохранения/загрузки предсказывает то же самое — иначе прод получит другое."""
path = tmp_path / "m.pkl"
joblib.dump(model, path)
assert np.allclose(model.predict_proba(X), joblib.load(path).predict_proba(X))
def test_latency_budget(model, X):
"""Скорость — функциональное требование, а не «оптимизация потом»."""
t0 = time.perf_counter()
for _ in range(100):
model.predict_proba(X.head(1))
assert (time.perf_counter() - t0) / 100 < 0.010 # p50 < 10 мс на объект
Про то, где брать пороги и почему нельзя смотреть на одну метрику, — в статье про оценку моделей; про то, как не обмануть себя валидацией, — в переобучении и регуляризации.
Часть 6. CI/CD/CT: три конвейера
В ML-системе живут три разных конвейера, и их полезно не путать.
- CI — на изменение кода: линтеры, юнит-тесты, тесты данных на фикстурах, сборка образа.
- CD — на появление нового артефакта модели: выкат в стейджинг, теневой режим, канарейка.
- CT (continuous training) — на расписание, объём новых данных или сигнал дрейфа: обучение, валидация, регистрация кандидата.
Обратите внимание на замыкающую стрелку D → B1: именно она превращает набор скриптов в систему.
Без неё вы получите «автоматизированное обучение», которое всё равно запускает человек, когда
кто-то пожаловался.
Минимальный CI в GitHub Actions, который ловит 80% проблем:
name: ml-ci
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Жёсткая фиксация окружения: без lock-файла воспроизводимости не существует
- run: pip install -r requirements.lock
- name: Статический анализ
run: ruff check . && mypy src/
- name: Юнит-тесты и тесты данных
run: pytest tests/unit tests/data -q
# Обучение на маленьком срезе: цель — проверить, что пайплайн вообще проходит целиком,
# а не получить хорошую модель. Занимает минуты, ловит 90% поломок пайплайна.
- name: Smoke-обучение на 1% данных
run: python -m pipelines.train --config configs/smoke.yaml --max-rows 50000
- name: Поведенческие тесты модели
run: pytest tests/model -q
Отличное каноническое описание всей этой конструкции — статья Continuous Delivery for Machine Learning (Sato, Wider, Windheuser на martinfowler.com). Оркестраторы для CT: Airflow, Prefect, Dagster, Kubeflow Pipelines, Metaflow — выбор здесь менее важен, чем наличие самой DAG.
Часть 7. Сервинг: как модель отвечает
Три режима и как выбрать
| Режим | Задержка | Когда применим | Цена ошибки в выборе |
|---|---|---|---|
| Batch | часы | скоринг всей базы на завтра, рассылки, риск-рейтинги | считаете предсказания для 100 млн пользователей, из которых зайдёт 100 тысяч |
| Онлайн (синхронный) | 10–100 мс | ранжирование выдачи, антифрод в моменте, реклама | держите инфраструктуру ради запроса раз в сутки |
| Потоковый | секунды | реакция на событие, near-real-time признаки | сложность Kafka + состояния там, где хватило бы cron |
Правило выбора одно: режим определяется тем, в какой момент нужен ответ, а не тем, что интереснее делать. Если решение принимается в момент действия пользователя — онлайн. Если результат читают утром из дашборда — batch, и это на порядок дешевле в эксплуатации. Отличный разбор границ — «Real-time machine learning: challenges and solutions» Chip Huyen.
Бюджет задержки
Latency — не «сколько работает predict». Считайте по-инженерному:
p99 бюджета эндпоинта: 150 мс
├─ сеть и балансировщик: 10 мс
├─ поход в feature store: 25 мс ← часто больше, чем сама модель
├─ препроцессинг: 15 мс
├─ inference: 20 мс
├─ постобработка и фильтры: 10 мс
└─ запас на хвосты и GC: 70 мс
Отсюда практические следствия: (1) сеть за признаками почти всегда дороже инференса GBDT —
оптимизируйте сначала её (батчинг ключей, локальный кэш); (2) p99 в разы хуже p50
из-за сборки мусора, холодных кэшей и конкуренции за CPU — планируйте по хвостам;
(3) при батчинге запросов растёт пропускная способность, но растёт и задержка — это явный
компромисс, а не бесплатный обед.
Упаковка и рантайм
# service.py — минимальный, но правильный онлайн-сервис
from contextlib import asynccontextmanager
import numpy as np
import onnxruntime as ort
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
MODEL_VERSION = "churn/7" # версия из реестра, попадает в каждый лог-запись
class PredictRequest(BaseModel):
user_id: int
orders_30d: int = Field(ge=0, le=10_000) # валидация = первый рубеж против мусора
avg_check_30d: float = Field(ge=0.0)
class PredictResponse(BaseModel):
probability: float
model_version: str
state: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Модель грузится один раз при старте, а не на каждый запрос.
# ONNX Runtime: единый рантайм для sklearn/LightGBM/PyTorch, без Python-зависимостей обучения.
state["session"] = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
yield
state.clear()
app = FastAPI(lifespan=lifespan)
@app.get("/health") # liveness: процесс жив
def health() -> dict:
return {"status": "ok", "model_version": MODEL_VERSION}
@app.get("/ready") # readiness: модель загружена и отвечает на пробный вектор
def ready() -> dict:
if "session" not in state:
raise HTTPException(status_code=503, detail="model not loaded")
return {"status": "ready"}
@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest) -> PredictResponse:
# ПОРЯДОК признаков должен совпадать с обучением. Здесь его фиксирует явный список.
x = np.array([[req.orders_30d, req.avg_check_30d]], dtype=np.float32)
proba = float(state["session"].run(None, {"input": x})[1][0][1])
# Логируем ровно то, что видела модель, — это будущие обучающие данные и вход мониторинга
log_prediction(user_id=req.user_id, features=x.tolist(),
score=proba, model_version=MODEL_VERSION)
return PredictResponse(probability=proba, model_version=MODEL_VERSION)
Что здесь важно и часто пропускают:
/healthи/ready— разные вещи. Kubernetes убьёт под по liveness и не пустит трафик по readiness; если модель грузится 40 секунд, а readiness отвечает «ок» сразу, вы получите всплеск 500-х на каждом деплое.- Версия модели — в ответе и в логе. Без неё разбор инцидента «почему клиенту показали это» превращается в археологию.
- ONNX (или аналогичный переносимый формат) разрывает связь с обучающим окружением. Pickle от scikit-learn несовместим между версиями библиотеки и небезопасен для загрузки недоверенного файла — прямое предупреждение в документации.
- Для GPU и высоких нагрузок — специализированные серверы: NVIDIA Triton, TorchServe, BentoML, vLLM для LLM. Они дают динамический батчинг, мультимодельность и метрики из коробки.
Если сервинг упирается в производительность рантайма, полезно посмотреть в сторону компилируемых языков для обвязки — см. трек Go; типовой приём — Python для обучения, Go/C++ для горячего пути инференса через ONNX Runtime.
Часть 8. Как выкатывать: стратегии и их смысл
Ключевое отличие от обычного релиза: модель может быть технически исправна (200 OK, latency в норме) и при этом вредна для бизнеса. Поэтому выкат ML идёт в две ступени — сначала проверяем техническую корректность, потом эффект.
распределение скоров не уехало, p99 в бюджете Note over GW,B: Фаза 2 — канарейка: 5% трафика реально обслуживает v7 U->>GW: POST /predict GW->>B: попал в 5% (стабильный хеш по user_id) B-->>GW: score = 0.29 GW-->>U: 0.29 B->>L: запись с model_version = v7 M->>M: guardrail-метрики по группам: конверсия, жалобы, доля отказов alt Guardrail нарушен M->>GW: мгновенный откат на v6 else Метрики держатся N дней M->>GW: увеличить долю: 5% → 25% → 100% end
Разложим по полочкам:
- Shadow (теневой режим) — кандидат получает копию реального трафика, но его ответ никуда не идёт. Ловит: падения, NaN, несовпадение схемы, нарушение latency, грубый сдвиг распределения скоров. Риск для пользователя нулевой, стоимость — двойные вычисления. Обязательный этап для любой модели, влияющей на деньги.
- Canary — небольшая доля реального трафика. Ловит уже эффект, но при 5% трафика статистическая мощность мала: ждать значимости по основной метрике придётся долго, поэтому смотрят на guardrail-метрики (ошибки, жалобы, резкие провалы), а не на «стало ли лучше».
- A/B-тест — единственный способ измерить эффект. Сплит по стабильному хешу от идентификатора пользователя (не по запросу — иначе один человек попадёт в обе группы), заранее заданный размер выборки и срок. Про подводные камни офлайн-метрик versus A/B подробно говорилось в статье про рекомендательные системы.
- Blue-green — две полные среды, переключение одним изменением роутинга. Даёт откат за секунды ценой двойной инфраструктуры.
- Interleaving — для ранжирования: результаты двух моделей перемешиваются в одной выдаче. Требует на порядок меньше трафика, чем A/B, но применим только к спискам.
Отдельно про откат. Откат модели — это не «переобучить обратно», а переключение указателя на предыдущий артефакт. Значит, предыдущие версии должны оставаться загружаемыми и совместимыми со схемой признаков. Если признак удалён из feature store — откат сломан. Практика: удалять признаки только после того, как все модели, их использующие, выведены из эксплуатации.
Часть 9. Мониторинг: три уровня и дрейф
Мониторинг ML-системы — это три этажа, и путать их нельзя.
- Операционный — RPS, latency p50/p95/p99, ошибки, использование CPU/памяти, доля таймаутов похода за признаками. Тот же Prometheus/Grafana, что и для любого сервиса.
- Данные и предсказания — доля пропусков, доля неизвестных категорий, распределение каждого признака, распределение скоров, доля срабатываний фолбэка. Доступен немедленно, меток не требует.
- Качество модели — прикладные метрики на размеченных данных. Доступен с лагом обратной связи: в антифроде это дни (chargeback), в кредитном скоринге — месяцы, в рекомендациях — минуты.
Формально о дрейфе
Обучение исходит из того, что train и prod берутся из одного распределения $P(x, y)$. В проде оно меняется. Разложим $P(x, y) = P(y \mid x),P(x)$ и получим типологию:
- Covariate shift: меняется $P(x)$, зависимость $P(y \mid x)$ прежняя. Модель по-прежнему «права», но работает в области, где у неё мало обучающих данных. Лечится добавлением свежих данных, иногда — взвешиванием по importance ratio $w(x) = P_{\text{prod}}(x)/P_{\text{train}}(x)$.
- Prior/label shift: меняется $P(y)$ — например, доля мошенничества выросла втрое. Ломает калибровку, но часто не ломает ранжирование. Лечится перекалибровкой и сдвигом порога, а не полным переобучением.
- Concept drift: меняется $P(y \mid x)$ — те же входы теперь означают другое. Единственное лекарство — новые метки. Каноническая работа: «A Survey on Concept Drift Adaptation» (Gama et al., ACM Computing Surveys, 2014).
Дрейф бывает резким (регуляторное изменение, релиз конкурента), постепенным (сезон, старение аудитории) и рекуррентным (пятница/понедельник, декабрь). Реакция на них разная: на резкий — алерт и внеплановое переобучение, на сезонный — календарные признаки, а не паника.
Детекторы: PSI и KS
Population Stability Index — рабочая лошадка продакшена. Дискретизируем признак на $B$ корзин по квантилям обучающей выборки и сравниваем доли:
$$ \mathrm{PSI} = \sum_{i=1}^{B} (p_i - q_i) \ln \frac{p_i}{q_i} $$
где $p_i$ — доля эталона в корзине $i$, $q_i$ — доля текущего окна. Это симметризованная
KL-дивергенция (дивергенция Дженсена по сути того же семейства). Отраслевые пороги, идущие из
кредитного скоринга: < 0.1 — стабильно, 0.1–0.25 — стоит посмотреть, > 0.25 — существенный
сдвиг.
import numpy as np
def psi(reference: np.ndarray, current: np.ndarray, bins: int = 10, eps: float = 1e-6) -> float:
"""Population Stability Index.
Сложность: O(n log n) на построение квантилей эталона (делается один раз при
сохранении модели) и O(m log B) на каждое окно за счёт np.searchsorted внутри
histogram. Память: O(B).
ВАЖНО: границы корзин берутся из эталона и замораживаются вместе с моделью.
Пересчитывать их по текущим данным — значит гарантированно получить PSI ≈ 0
и не увидеть ни одного дрейфа.
"""
edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf # хвосты не должны терять массу
edges = np.unique(edges) # защита от вырожденных признаков
p = np.histogram(reference, bins=edges)[0] / len(reference)
q = np.histogram(current, bins=edges)[0] / len(current)
p = np.clip(p, eps, None) # без сглаживания получим inf на пустой корзине
q = np.clip(q, eps, None)
return float(np.sum((p - q) * np.log(p / q)))
Альтернативы: тест Колмогорова — Смирнова (scipy.stats.ks_2samp) для непрерывных признаков,
хи-квадрат для категориальных, расстояние Вассерштейна, когда важна величина сдвига, а не только
факт. Готовые реализации отчётов — Evidently,
NannyML, WhyLogs.
Три ловушки мониторинга дрейфа, на которых обжигаются все:
- Проблема множественных сравнений. 200 признаков × ежедневная проверка на уровне 5% — это ~10 ложных тревог в день. Через неделю алерты игнорируют. Лечится поправкой на множественность (Бенджамини — Хохберг), укрупнением до «дрейфа скоров и топ-20 признаков по важности» и требованием устойчивости сигнала (k дней подряд).
- Размер окна. При миллионе наблюдений KS-тест находит статистически значимый, но практически безразличный сдвиг. Смотрите на величину эффекта (PSI, Вассерштейн), а не на p-value.
- Дрейф ≠ деградация. Входы могут уехать, а качество остаться прежним. Дрейф — это повод посмотреть, а автоматический триггер переобучения по дрейфу без проверки качества регулярно приводит к переобучению на шуме.
Когда меток нет вовсе, оценивают качество косвенно: по калибровке на отложенном размеченном срезе, по прокси-метрикам (доля отказов, доля ручных проверок) или методами вроде confidence-based performance estimation.
Обратная связь и петли
Особенно коварная штука в проде — петли обратной связи. Модель влияет на данные, на которых будет учиться следующая модель. Рекомендатель показывает товар → пользователь его покупает → модель видит, что товар популярен → показывает ещё чаще. Через месяц каталог схлопнулся до сотни позиций.
Противоядия: обязательная доля случайной или исследовательской выдачи (epsilon-greedy — см. обучение с подкреплением), логирование propensity показа для последующей коррекции смещения, метрики разнообразия и покрытия в наборе guardrail-метрик.
Часть 10. Переобучение: когда и как
Три стратегии триггера:
- По расписанию — просто, предсказуемо, проверяемо. Стартовая точка для 90% команд.
- По объёму новых данных — «накопилось 100k новых меток → переобучаем». Хорошо для быстро растущих доменов.
- По сигналу — падение качества на размеченном срезе или устойчивый дрейф. Умнее, но требует зрелого мониторинга; без него легко получить переобучение по шуму.
Как выбрать период — измеряется, а не угадывается. Метод: backtest деградации. Обучите модель на данных до момента $T$ и померьте её качество на неделях $T{+}1, T{+}2, \ldots$. Скорость падения кривой и даст период. Техника окон описана в статье про временные ряды.
def staleness_curve(train_until, horizon_weeks: int = 12):
"""Как быстро протухает модель: качество как функция возраста.
Стоимость: O(H) обучений при скользящем окне — считается один раз,
зато период переобучения перестаёт быть предметом веры.
"""
model = fit(data[data.ts < train_until])
return [
(w, roc_auc_score(y_week(w), model.predict_proba(X_week(w))[:, 1]))
for w in range(1, horizon_weeks + 1)
]
Дальше — экономика: строим кривую «качество в зависимости от возраста модели», умножаем потерю качества на цену пункта метрики в деньгах и сравниваем со стоимостью цикла переобучения (вычисления + время инженера + риск выката). Точка, где предельная выгода равна предельной стоимости, и есть период.
Полное переобучение или дообучение? Дообучение (warm start) дешевле, но накапливает дрейф и делает результат зависимым от истории — воспроизводимость страдает. Практическое правило: полное переобучение с нуля, если это укладывается в бюджет; инкрементальное — только когда данных столько, что полный проход невозможен, и с регулярным «сбросом» на полное переобучение.
Часть 11. Trade-offs: что сколько стоит
Порядок внедрения, который окупается почти всегда: логирование предсказаний → версии данных и кода → трекинг → тесты на схему → реестр → теневой режим → мониторинг дрейфа → CT → feature store. Логирование предсказаний стоит первым не случайно: это единственная практика, которую нельзя внедрить задним числом. Данные, которые вы не залогировали в январе, вы не получите в марте.
Ещё три компромисса, которые придётся выбирать осознанно:
- Готовая платформа против сборки из компонентов. Vertex AI / SageMaker / Databricks дают скорость старта и vendor lock-in; связка MLflow + Airflow + Feast + Kubernetes даёт контроль и требует людей на её поддержку. Для команды до пяти человек управляемая платформа почти всегда дешевле в сумме.
- Простая модель против сложной. Логистическая регрессия на 30 признаках требует одного дежурного и переносится куда угодно; ансамбль из пяти моделей с онлайн-признаками требует постоянной команды. Разница в качестве часто — единицы процентов. Считайте полную стоимость владения, а не только AUC.
- Скорость итераций против строгости процесса. Пять ступеней согласования делают выкат безопасным и настолько медленным, что модели устаревают в очереди. Здоровый баланс: автоматические ворота (тесты и пороги) строгие, человеческие — минимальные.
Типичные ошибки
- Ноутбук как единица поставки. Ячейки, выполненные в произвольном порядке, скрытое состояние
ядра,
pd.read_csv("/home/ivan/final_data_v3.csv"). Лечится дисциплиной: ноутбук — среда исследования, поставляется модуль с CLI и конфигом. - Отсутствие baseline. Без ответа «сколько даёт правило
if orders_30d == 0» непонятно, стоит ли модель эксплуатации. Заведите тривиальный baseline и сравнивайте с ним всегда. - Метрика ML вместо метрики бизнеса. «Подняли AUC на 0.01» — не результат. Результат — «снизили потери от мошенничества на X при том же уровне ложных отказов».
- Утечка через препроцессинг.
StandardScaler.fitна всей выборке до сплита — классика. Все преобразования обязаны жить внутриPipelineи обучаться только на train (см. данные и признаки). - Пороги, зашитые в код сервиса. Порог классификации — часть модели и должен переезжать вместе с ней и её калибровкой. Иначе новая модель приезжает со старым порогом и ведёт себя непредсказуемо.
- Нет фолбэка. Feature store недоступен — что отдаёт сервис? Пятисотую? Правильный ответ: деградированный, но осмысленный результат (популярное, среднее по сегменту, простая модель на доступных признаках) и метрика доли фолбэков в мониторинге.
- Мониторинг без владельца. Дашборд, на который никто не смотрит, эквивалентен отсутствию мониторинга. У каждой модели должен быть owner, on-call и понятный runbook.
- Секунды и часовые пояса. Признак «дней с последней покупки», посчитанный в UTC на обучении и в локальном времени в сервисе, даёт систематический сдвиг. Все временные метки — UTC, всегда.
- Молчаливое изменение upstream. Аналитики переименовали значение категории с
mobileнаmobile_app— модель получила незнакомую категорию и стала предсказывать константу. Только контракт на данные с тестами ловит это до прода. - Обучение на данных, которых не будет в проде. Признак есть в DWH, потому что попадает туда ночным ETL, а в момент запроса его нет. Проверка простая: для каждого признака ответьте, откуда сервис возьмёт его за отведённые миллисекунды.
Референсная архитектура
иммутабельные снапшоты)] D2[Валидация схемы
и качества] D3[Feature store
offline + online] end subgraph TRAIN["Обучение"] T1[Оркестратор DAG] T2[Трекер экспериментов] T3[Реестр моделей] end subgraph SERVE["Сервинг"] V1[Batch-скоринг] V2[Онлайн-сервис
+ роутер версий] V3[(Лог предсказаний)] end subgraph OBS["Наблюдаемость"] O1[Метрики сервиса] O2[Дрейф данных и скоров] O3[Качество на метках] O4[Алерты и on-call] end S1 & S2 & S3 --> D1 --> D2 --> D3 D3 -->|исторические срезы| T1 T1 --> T2 --> T3 T3 -->|артефакт| V1 & V2 D3 -->|онлайн-значения| V2 V1 & V2 --> V3 V3 --> O2 & O3 V2 --> O1 O1 & O2 & O3 --> O4 O4 -->|триггер переобучения| T1 V3 -->|обучение на реальных векторах| D1
Две стрелки здесь важнее остальных: O4 → T1 (замкнутый контур переобучения) и
V3 → D1 (лог предсказаний возвращается в данные). Без них это не платформа, а набор
разрозненных сервисов, склеенных человеком.
Мини-итог и чек-лист
Главные мысли:
- MLOps существует ради трёх свойств: воспроизводимость, наблюдаемость, обратимость. Инструмент, не дающий ни одного из них, не нужен.
- Модель — не файл, а версионируемый артефакт с происхождением, метриками и состоянием.
- Training/serving skew и утечки при построении признаков — источник большинства расхождений «офлайн отлично, в проде никак».
- Мониторинг данных доступен сразу, мониторинг качества — с лагом обратной связи. Стройте оба, но не путайте их сигналы: дрейф — повод посмотреть, деградация — повод действовать.
- Период переобучения измеряется backtest’ом деградации, а не назначается традицией.
- Сложность инфраструктуры должна отставать от сложности задачи, а не опережать её.
Чек-лист перед выкатом модели в прод:
- Обучение воспроизводится одной командой из чистого клона репозитория.
- Зафиксированы: git SHA, снапшот данных, lock-файл окружения, зерно.
- Есть baseline, и модель обгоняет его на метрике, связанной с деньгами.
- Метрики померены по критическим срезам, а не только в среднем.
- Все преобразования признаков — внутри пайплайна, обученного только на train.
- Для каждого признака известен источник в онлайне и его задержка.
- Есть тесты: схема данных, диапазоны, инвариантность, направленные ожидания, latency.
- Артефакт зарегистрирован в реестре, у модели есть owner и карточка модели.
- Логирование входного вектора, скора и версии модели включено.
- Определён фолбэк при недоступности признаков и метрика доли фолбэков.
- Настроены алерты: latency p99, доля ошибок, PSI по скорам, доля неизвестных категорий.
- Описана и проверена процедура отката; предыдущая версия загружаема.
- Прошёл теневой режим, спланированы канарейка и A/B с заранее заданными критериями.
Источники
- Sculley D. et al. Hidden Technical Debt in Machine Learning Systems, NIPS 2015 — обязательное чтение; CACE, glue code, pipeline jungles.
- Breck E. et al. The ML Test Score: A Rubric for ML Production Readiness, IEEE Big Data 2017 — 28 конкретных тестов, отличный чек-лист для аудита.
- Amershi S. et al. Software Engineering for Machine Learning: A Case Study, ICSE-SEIP 2019 — как ML меняет процессы разработки, на материале Microsoft.
- Paleyes A., Urma R.-G., Lawrence N. Challenges in Deploying Machine Learning: a Survey of Case Studies, ACM CSUR 2022.
- Shankar S. et al. Operationalizing Machine Learning: An Interview Study, 2022.
- Zinkevich M. Rules of Machine Learning: Best Practices for ML Engineering — 43 правила от инженера Google, лучший короткий текст по теме.
- Google Cloud. MLOps: Continuous delivery and automation pipelines in machine learning.
- Sato D., Wider A., Windheuser C. Continuous Delivery for Machine Learning, martinfowler.com.
- Huyen C. Designing Machine Learning Systems, O’Reilly 2022; блог: Real-time ML: challenges and solutions.
- Gama J. et al. A Survey on Concept Drift Adaptation, ACM Computing Surveys 46(4), 2014.
- Polyzotis N. et al. Data Management Challenges in Production Machine Learning, SIGMOD 2017.
- Mitchell M. et al. Model Cards for Model Reporting, FAT* 2019.
- Gebru T. et al. Datasheets for Datasets, CACM 2021.
- Ribeiro M. et al. Beyond Accuracy: Behavioral Testing of NLP Models with CheckList, ACL 2020.
- Документация: MLflow, DVC, Feast, Evidently, TFX, ONNX Runtime, Kubeflow Pipelines.
- Beyer B. et al. Site Reliability Engineering — бесплатно онлайн: SLO, error budget и on-call целиком переносятся на ML-сервисы.
Что дальше
Это последняя статья трека «Машинное обучение». Если вы прошли его целиком — от постановки задач до вот этой конструкции с реестрами и мониторингом — у вас есть полная картина классического ML: как формулировать задачу, готовить данные, выбирать и обучать модель, честно её оценивать и довозить до пользователя.
Куда двигаться дальше, в зависимости от того, чего не хватает:
- Глубокое обучение. Всё, что мы обходили стороной, — свёрточные и рекуррентные сети, трансформеры, обучение представлений — живёт в треке нейронные сети. MLOps-практики оттуда переносятся один в один, добавляются только GPU, распределённое обучение и куда более тяжёлые артефакты.
- Данные. Если больно было на статьях про признаки и снапшоты — это сигнал идти в дата-инжиниринг: хранилища, потоки, оркестрация, качество данных. Половина работы ML-инженера — на самом деле работа с данными.
- Инженерная база. Продакшн-сервис требует не только модели: см. алгоритмы и структуры данных, паттерны проектирования и архитектурные паттерны. Для горячего пути инференса пригодится Go.
- Оптимизация и поиск. Подбор гиперпараметров, автоматический дизайн пайплайнов и эволюционные методы — трек SBSE.
- Продукт и процесс. Модель приносит пользу только внутри продукта: см. продуктовый менеджмент и управление проектами — там про гипотезы, метрики и то, как не построить идеальную модель для несуществующей задачи.
Общая карта всех треков портала и рекомендованные маршруты — на странице роадмапа.