Машинное обучение MLOps: от эксперимента до продакшена
0%

MLOps: от эксперимента до продакшена

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

Training/serving skew: расхождение офлайн- и онлайн-путей вычисления признаков

Три источника расхождения — на схеме, но стоит проговорить главный: 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 идёт в две ступени — сначала проверяем техническую корректность, потом эффект.

Разложим по полочкам:

  • Shadow (теневой режим) — кандидат получает копию реального трафика, но его ответ никуда не идёт. Ловит: падения, NaN, несовпадение схемы, нарушение latency, грубый сдвиг распределения скоров. Риск для пользователя нулевой, стоимость — двойные вычисления. Обязательный этап для любой модели, влияющей на деньги.
  • Canary — небольшая доля реального трафика. Ловит уже эффект, но при 5% трафика статистическая мощность мала: ждать значимости по основной метрике придётся долго, поэтому смотрят на guardrail-метрики (ошибки, жалобы, резкие провалы), а не на «стало ли лучше».
  • A/B-тест — единственный способ измерить эффект. Сплит по стабильному хешу от идентификатора пользователя (не по запросу — иначе один человек попадёт в обе группы), заранее заданный размер выборки и срок. Про подводные камни офлайн-метрик versus A/B подробно говорилось в статье про рекомендательные системы.
  • Blue-green — две полные среды, переключение одним изменением роутинга. Даёт откат за секунды ценой двойной инфраструктуры.
  • Interleaving — для ранжирования: результаты двух моделей перемешиваются в одной выдаче. Требует на порядок меньше трафика, чем A/B, но применим только к спискам.

Отдельно про откат. Откат модели — это не «переобучить обратно», а переключение указателя на предыдущий артефакт. Значит, предыдущие версии должны оставаться загружаемыми и совместимыми со схемой признаков. Если признак удалён из feature store — откат сломан. Практика: удалять признаки только после того, как все модели, их использующие, выведены из эксплуатации.

Часть 9. Мониторинг: три уровня и дрейф

Мониторинг ML-системы — это три этажа, и путать их нельзя.

  1. Операционный — RPS, latency p50/p95/p99, ошибки, использование CPU/памяти, доля таймаутов похода за признаками. Тот же Prometheus/Grafana, что и для любого сервиса.
  2. Данные и предсказания — доля пропусков, доля неизвестных категорий, распределение каждого признака, распределение скоров, доля срабатываний фолбэка. Доступен немедленно, меток не требует.
  3. Качество модели — прикладные метрики на размеченных данных. Доступен с лагом обратной связи: в антифроде это дни (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.

Три ловушки мониторинга дрейфа, на которых обжигаются все:

  1. Проблема множественных сравнений. 200 признаков × ежедневная проверка на уровне 5% — это ~10 ложных тревог в день. Через неделю алерты игнорируют. Лечится поправкой на множественность (Бенджамини — Хохберг), укрупнением до «дрейфа скоров и топ-20 признаков по важности» и требованием устойчивости сигнала (k дней подряд).
  2. Размер окна. При миллионе наблюдений KS-тест находит статистически значимый, но практически безразличный сдвиг. Смотрите на величину эффекта (PSI, Вассерштейн), а не на p-value.
  3. Дрейф ≠ деградация. Входы могут уехать, а качество остаться прежним. Дрейф — это повод посмотреть, а автоматический триггер переобучения по дрейфу без проверки качества регулярно приводит к переобучению на шуме.

Когда меток нет вовсе, оценивают качество косвенно: по калибровке на отложенном размеченном срезе, по прокси-метрикам (доля отказов, доля ручных проверок) или методами вроде 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.
  • Скорость итераций против строгости процесса. Пять ступеней согласования делают выкат безопасным и настолько медленным, что модели устаревают в очереди. Здоровый баланс: автоматические ворота (тесты и пороги) строгие, человеческие — минимальные.

Типичные ошибки

  1. Ноутбук как единица поставки. Ячейки, выполненные в произвольном порядке, скрытое состояние ядра, pd.read_csv("/home/ivan/final_data_v3.csv"). Лечится дисциплиной: ноутбук — среда исследования, поставляется модуль с CLI и конфигом.
  2. Отсутствие baseline. Без ответа «сколько даёт правило if orders_30d == 0» непонятно, стоит ли модель эксплуатации. Заведите тривиальный baseline и сравнивайте с ним всегда.
  3. Метрика ML вместо метрики бизнеса. «Подняли AUC на 0.01» — не результат. Результат — «снизили потери от мошенничества на X при том же уровне ложных отказов».
  4. Утечка через препроцессинг. StandardScaler.fit на всей выборке до сплита — классика. Все преобразования обязаны жить внутри Pipeline и обучаться только на train (см. данные и признаки).
  5. Пороги, зашитые в код сервиса. Порог классификации — часть модели и должен переезжать вместе с ней и её калибровкой. Иначе новая модель приезжает со старым порогом и ведёт себя непредсказуемо.
  6. Нет фолбэка. Feature store недоступен — что отдаёт сервис? Пятисотую? Правильный ответ: деградированный, но осмысленный результат (популярное, среднее по сегменту, простая модель на доступных признаках) и метрика доли фолбэков в мониторинге.
  7. Мониторинг без владельца. Дашборд, на который никто не смотрит, эквивалентен отсутствию мониторинга. У каждой модели должен быть owner, on-call и понятный runbook.
  8. Секунды и часовые пояса. Признак «дней с последней покупки», посчитанный в UTC на обучении и в локальном времени в сервисе, даёт систематический сдвиг. Все временные метки — UTC, всегда.
  9. Молчаливое изменение upstream. Аналитики переименовали значение категории с mobile на mobile_app — модель получила незнакомую категорию и стала предсказывать константу. Только контракт на данные с тестами ловит это до прода.
  10. Обучение на данных, которых не будет в проде. Признак есть в DWH, потому что попадает туда ночным ETL, а в момент запроса его нет. Проверка простая: для каждого признака ответьте, откуда сервис возьмёт его за отведённые миллисекунды.

Референсная архитектура

Две стрелки здесь важнее остальных: O4 → T1 (замкнутый контур переобучения) и V3 → D1 (лог предсказаний возвращается в данные). Без них это не платформа, а набор разрозненных сервисов, склеенных человеком.

Мини-итог и чек-лист

Главные мысли:

  • MLOps существует ради трёх свойств: воспроизводимость, наблюдаемость, обратимость. Инструмент, не дающий ни одного из них, не нужен.
  • Модель — не файл, а версионируемый артефакт с происхождением, метриками и состоянием.
  • Training/serving skew и утечки при построении признаков — источник большинства расхождений «офлайн отлично, в проде никак».
  • Мониторинг данных доступен сразу, мониторинг качества — с лагом обратной связи. Стройте оба, но не путайте их сигналы: дрейф — повод посмотреть, деградация — повод действовать.
  • Период переобучения измеряется backtest’ом деградации, а не назначается традицией.
  • Сложность инфраструктуры должна отставать от сложности задачи, а не опережать её.

Чек-лист перед выкатом модели в прод:

  • Обучение воспроизводится одной командой из чистого клона репозитория.
  • Зафиксированы: git SHA, снапшот данных, lock-файл окружения, зерно.
  • Есть baseline, и модель обгоняет его на метрике, связанной с деньгами.
  • Метрики померены по критическим срезам, а не только в среднем.
  • Все преобразования признаков — внутри пайплайна, обученного только на train.
  • Для каждого признака известен источник в онлайне и его задержка.
  • Есть тесты: схема данных, диапазоны, инвариантность, направленные ожидания, latency.
  • Артефакт зарегистрирован в реестре, у модели есть owner и карточка модели.
  • Логирование входного вектора, скора и версии модели включено.
  • Определён фолбэк при недоступности признаков и метрика доли фолбэков.
  • Настроены алерты: latency p99, доля ошибок, PSI по скорам, доля неизвестных категорий.
  • Описана и проверена процедура отката; предыдущая версия загружаема.
  • Прошёл теневой режим, спланированы канарейка и A/B с заранее заданными критериями.

Источники

Что дальше

Это последняя статья трека «Машинное обучение». Если вы прошли его целиком — от постановки задач до вот этой конструкции с реестрами и мониторингом — у вас есть полная картина классического ML: как формулировать задачу, готовить данные, выбирать и обучать модель, честно её оценивать и довозить до пользователя.

Куда двигаться дальше, в зависимости от того, чего не хватает:

  • Глубокое обучение. Всё, что мы обходили стороной, — свёрточные и рекуррентные сети, трансформеры, обучение представлений — живёт в треке нейронные сети. MLOps-практики оттуда переносятся один в один, добавляются только GPU, распределённое обучение и куда более тяжёлые артефакты.
  • Данные. Если больно было на статьях про признаки и снапшоты — это сигнал идти в дата-инжиниринг: хранилища, потоки, оркестрация, качество данных. Половина работы ML-инженера — на самом деле работа с данными.
  • Инженерная база. Продакшн-сервис требует не только модели: см. алгоритмы и структуры данных, паттерны проектирования и архитектурные паттерны. Для горячего пути инференса пригодится Go.
  • Оптимизация и поиск. Подбор гиперпараметров, автоматический дизайн пайплайнов и эволюционные методы — трек SBSE.
  • Продукт и процесс. Модель приносит пользу только внутри продукта: см. продуктовый менеджмент и управление проектами — там про гипотезы, метрики и то, как не построить идеальную модель для несуществующей задачи.

Общая карта всех треков портала и рекомендованные маршруты — на странице роадмапа.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов