Материал подготовлен в рамках курса «Машинное обучение. Продвинутый уровень».

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

Команда приносит модель: в отчёте ROC‑AUC 0.93 на пятифолдовой кросс‑валидации. Через две недели после раскатки боевая метрика — 0.86.

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

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

Ниже — маршрут из восьми шагов, код, который можно забрать к себе, и, что важнее, объяснение, почему Pipeline закрывает только часть проблемы.

Пара слов о том, откуда я на это смотрю.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

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

Рис. 1. Одна и та же модель: как её качество выглядит на стенде и как оно ведёт себя в продакшене
Рис. 1. Одна и та же модель: как её качество выглядит на стенде и как оно ведёт себя в продакшене

Исходные условия

Чтобы разговор был предметным, вот вводные. Стенд обычный, ничего экзотического:

  • Python 3.12, scikit‑learn 1.9.0, LightGBM 4.6.0, pandas 2.3.x. Сервис инференса на JVM, модель отдаётся через Python‑сайдкар.

  • Бинарная классификация, скоринг транзакции на риск, дисбаланс порядка 1 к 60.

  • Около 4 млн строк за 14 месяцев: числовые агрегаты, категориальные поля (мерчант, MCC, страна, устройство) и таблица истории по клиенту, которая джойнится к основной.

  • Метка приходит не сразу: подтверждённый фрод по части кейсов появляется через недели после транзакции.

  • Переобучение раз в неделю, инференс синхронный, бюджет 40 мс.

  • Команда: три человека на стороне DS, двое на стороне платформы.

Задержка метки ломает наивное сравнение «метрика за последние две недели», а как только людей в команде больше двух, «держать логику в голове» перестаёт работать.

Шаг 1. Сначала честно измерить разрыв, а не сразу лечить

Первое, что я прошу сделать, когда слышу «в проде метрика ниже» — не трогать модель. Сначала нужно понять, что именно с чем сравнивается.

Мне как‑то попалась формулировка, которая хорошо сюда ложится: если офлайн‑ и онлайн‑метрика считаются по разным когортам, разным окнам и с разным порогом, их разница вообще ничего не означает. У нас так и было — CV на перемешанных строках за 14 месяцев против потока последних двух недель, где половина меток ещё не созрела. Полный список условий сопоставимости будет в конце; пока достаточно одного — считать только по дням, где метка уже доехала.

Дальше режем данные по времени.

# (Python)
TRAIN_END = "2026-03-01"
DEV_END = "2026-05-01"
LABEL_MATURITY_DAYS = 30   # иллюстративно: задаётся по распределению задержки метки

train_df = df[df["event_ts"] < TRAIN_END]
dev_df = df[(df["event_ts"] >= TRAIN_END) & (df["event_ts"] < DEV_END)]
holdout_df = df[
    (df["event_ts"] >= DEV_END)
    & (df["event_ts"] < NOW - pd.Timedelta(days=LABEL_MATURITY_DAYS))
]

Три окна, строго друг за другом во времени, и у каждого своя роль.

  • На train идёт кросс‑валидация и подбор гиперпараметров.

  • На dev настраивается политика принятия решения, то есть порог. holdout не участвует ни в чём, кроме финальной оценки. Важно, что dev после подбора порога тоже перестаёт быть независимой оценкой качества — это development‑выборка, а не вторая контрольная.

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

Шаг 2. Признаки должны быть корректны на момент события

Самый недооценённый пункт, и именно с него надо начинать разговор про утечки.

К транзакции джойнится история клиента с агрегатами вроде «доля отклонённых операций за 30 дней».

Вопрос, который я теперь задаю первым: на какой момент посчитан этот агрегат?

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

Фактически признак приехал из будущего, и Pipeline тут не поможет — он получает уже испорченный X.

Мой вариант, который я обычно использую: у каждой агрегатной колонки должна быть явная привязка к отсечке — в SQL это условие history.event_ts < base.event_ts, в оконных функциях — граница, исключающая текущие и будущие события относительно момента предсказания.

И тут же второе условие, о котором забывают ещё чаще. Мало, чтобы исходное событие произошло раньше — нужно, чтобы сам признак был готов раньше. Агрегат посчитан за события до 10:00, но батч дописал его в витрину в 10:37; для решения в 10:05 он недоступен, хотя исторически корректен.

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

Перед схемой ниже — короткое пояснение. На Рис. 2 показаны слои, через которые проходят данные, и отмечено, какой из них Pipeline реально контролирует, а какой остаётся полностью на вашей ответственности.

Рис. 2. Схема принципиальная: три слоя утечки и то, какой из них закрывает Pipeline
Рис. 2. Схема принципиальная: три слоя утечки и то, какой из них закрывает Pipeline

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

Поэтому спор «нужен ли Pipeline» бессмысленный — нужен, но сам по себе он не делает измерение честным.

Шаг 3. Всё, что учится на данных, держим внутри Pipeline

Теперь про слой, который Pipeline закрывает. В исходном ноутбуке импутация медианой и отбор по дисперсии делались один раз, на всём датафрейме, и только потом шёл cross_val_score. Медиана по всем строкам содержит информацию о валидационном фолде, отбор признаков — тем более.

Многие считают «безнадзорную» предобработку безопасной. Это не так: Amit Moscovich и Saharon Rosset (Journal of the Royal Statistical Society, Series B, 2022) показывают на трёх бытовых процедурах — отборе по дисперсии, схлопывании редких категорий и масштабировании — что даже такая предобработка до разбиения смещает оценку CV и может привести к выбору другой модели.

Правило: любое преобразование, которое обучается на данных и участвует в формировании входа модели, должно быть fitted только на обучающей части текущего фолда. В scikit‑learn самый дешёвый способ это гарантировать — держать его внутри Pipeline.

# (Python)
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import TargetEncoder
from lightgbm import LGBMClassifier

numeric = SimpleImputer(strategy="median")
categorical = TargetEncoder(target_type="binary", smooth="auto", cv=te_cv)  # te_cv определим на шаге 5

prep = ColumnTransformer([
    ("num", numeric, NUM_COLS),
    ("cat", categorical, CAT_COLS),
], remainder="drop", verbose_feature_names_out=True)

model = Pipeline([
    ("prep", prep),
    ("clf", LGBMClassifier(n_estimators=600, learning_rate=0.05, random_state=42)),
])

Масштабирования тут намеренно нет: для градиентного бустинга оно бесполезно, а в примерах его тащат по инерции. Импутация осталась, потому что медиана — это как раз обучаемая статистика.

На ревью я ищу глазами .fit_transform( вне пайплайна. Это не приговор — бывают архитектуры, где такой вызов корректен, — но первый повод спросить, на каком наборе выполняется fit.

Как проверить: прогоните cross_validate на объекте model целиком. Если число просело относительно старого — вы не сломали модель, вы починили измерение.

Шаг 4. Сплит должен отражать способ эксплуатации модели

Здесь я сам довольно долго ошибался. У одного клиента в датасете десятки транзакций; при обычном StratifiedKFold часть попадает в обучение, часть — в валидацию, и модель отчасти запоминает клиента. Отсюда напрашивается «группировать по client_id». Но это верно не всегда — всё зависит от того, на каком потоке модель работает в бою.

Сценарий эксплуатации

Что должен воспроизводить сплит

Инструмент

Скорим новые транзакции уже известных клиентов

только сдвиг во времени

разбиение по времени, группировка не нужна

Скорим клиентов, которых модель не видела

независимость по сущности

StratifiedGroupKFold по client_id

И то, и другое (типично для антифрода)

обе оси сразу

своё разбиение по времени с вычисткой пересекающихся групп

Важная деталь, о которую спотыкаются постоянно: StratifiedGroupKFold не соблюдает временной порядок, а shuffle=True делает разбиение групп случайным. Он решает задачу независимости по сущности и только её. TimeSeriesSplit, наоборот, соблюдает время, но ничего не знает о группах. Готового splitter'а «время плюс группа» в scikit‑learn нет — его нужно написать, благо интерфейс маленький.

# (Python)
import numpy as np

class TimeGroupWalkForward:
    """Расширяющееся окно по времени. Между обучением и валидацией —
    разрыв под созревание метки. Опционально из train убираются
    сущности, попавшие в валидационную часть этого же фолда."""

    def __init__(self, n_splits=5, gap_days=30, drop_seen_groups=True):
        self.n_splits = n_splits
        self.gap_days = gap_days
        self.drop_seen_groups = drop_seen_groups

    def split(self, X, y=None, groups=None):
        ts = X["event_ts"].to_numpy()
        chunks = np.array_split(np.argsort(ts), self.n_splits + 1)
        gap = np.timedelta64(self.gap_days, "D")

        for i in range(1, self.n_splits + 1):
            valid_idx = chunks[i]
            train_idx = np.concatenate(chunks[:i])
            train_idx = train_idx[ts[train_idx] < ts[valid_idx].min() - gap]

            if self.drop_seen_groups and groups is not None:
                g = np.asarray(groups)
                train_idx = train_idx[~np.isin(g[train_idx], g[valid_idx])]

            yield train_idx, valid_idx

    def get_n_splits(self, X=None, y=None, groups=None):
        return self.n_splits

Это минимальная иллюстрация интерфейса splitter'а, а не библиотечная реализация.

Три оговорки.

  • Куски равны по числу строк, а не по длительности: при неравномерном потоке границы фолдов разъедутся по времени, поэтому в проде окна задают явными датами, а заодно следят, чтобы граница не проходила внутри одинаковых timestamp.

  • gap_days моделирует только задержку метки — задержка готовности признаков обеспечивается отдельно, на шаге 2.

  • И drop_seen_groups=True — это не «защита от утечки вообще», а конкретная постановка «валидационный клиент модели полностью незнаком»; для скоринга новых транзакций известных клиентов опцию выключают.

Помню, как однажды переход с обычного KFold на такую схему уронил ROC‑AUC почти на пять пунктов — и это было лучшее, что случилось с тем проектом за квартал.

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

Шаг 5. Target encoding: cross‑fitting нужен, но он не про время

Кодировать категорию средним значением таргета соблазнительно и часто работает. Но если считать это среднее по всему обучающему набору, вы переносите таргет в признак напрямую.

У TargetEncoder есть встроенный cross‑fitting: при fit_transform он кодирует каждый фолд по статистике остальных. Отсюда следствие — fit_transform и fit().transform() дают разный результат, и это намеренно. Выносить энкодер из пайплайна «для скорости» нельзя.

Дальше место, которое обычно не обсуждают: каким splitter'ом идёт этот внутренний cross‑fitting? По умолчанию для бинарного таргета — обычным StratifiedKFold, без групп и без времени. Снаружи вы разделили клиентов, а внутри энкодера тот же клиент снова оказался по обе стороны.

Начиная с версии 1.9 в cv можно передать собственный splitter, а groups маршрутизируется во внутренний CV через metadata routing:

# (Python)
import sklearn
from sklearn.model_selection import StratifiedGroupKFold

sklearn.set_config(enable_metadata_routing=True)

te_cv = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42)

# groups доезжает до внутреннего splitter'а:
# prep.fit_transform(X_train, y_train, groups=train_df["client_id"])

В реальной схеме prep лежит внутри Pipeline, а тот — внутри RandomizedSearchCV. Чтобы groups, переданный в search.fit(), дошёл не только до внешнего splitter'а, но и до энкодера, routing должен быть включён на всей цепочке мета‑эстиматоров.

И тут же — честное ограничение, на которое я наткнулся, когда попробовал передать туда свой walk‑forward. TargetEncoder требует CV‑схему, где каждая строка ровно один раз оказывается в валидационном фолде, иначе поднимает ValueError. Расширяющееся окно этому условию не удовлетворяет по построению: первый кусок данных в валидацию не попадает никогда.

То есть StratifiedGroupKFold здесь убирает утечку через сущность, но не делает target encoding корректным по времени — снаружи и внутри остаётся разная временная семантика. Если для вас это критично, кодировку нужно считать самому как point‑in‑time агрегат на шаге 2, а не доверять её энкодеру.

Как проверить: посмотрите на важность закодированных категорий в двух прогонах — через fit_transform внутри пайплайна и через вынесенный наружу fit().transform(). Если во втором случае признак резко вырывается в топ, вы смотрите не на сигнал, а на утечку.

Шаг 6. Отбор признаков и подбор гиперпараметров — тоже внутри

Классика: сначала отбирают топ-50 признаков на всех данных, потом делают кросс‑валидацию на этих пятидесяти. Метрика прекрасная, смысла в ней нет — отбор уже подсмотрел ответы. Искать надо по всему пайплайну сразу и с одной и той же схемой cv:

# (Python)
from sklearn.model_selection import RandomizedSearchCV

cv = TimeGroupWalkForward(n_splits=5, gap_days=30, drop_seen_groups=True)

param_dist = {
    "prep__num__strategy": ["median", "mean"],
    "prep__cat__smooth": [1.0, 10.0, "auto"],
    "clf__num_leaves": [31, 63, 127],
    "clf__learning_rate": [0.03, 0.05, 0.1],
}

search = RandomizedSearchCV(
    model, param_dist, n_iter=40, cv=cv,   # 40 x 5 = 200 обучений: бюджет считайте заранее
    scoring="average_precision", n_jobs=-1, random_state=42,
)
search.fit(X_train, y_train, groups=train_df["client_id"])

Здесь стоит объяснить смену метрики. История началась с ROC‑AUC, а оптимизируем мы average precision: при дисбалансе 1 к 60 она честнее отражает качество ранжирования положительного класса, потому что не «размазывается» огромным числом отрицательных примеров.

Двойное подчёркивание — это адресация внутрь вложенных шагов. Мне это напоминает путь к бину в Spring‑конфиге: вы тюните не «модель», а конструкцию целиком.

Отдельный нюанс: если у бустинга включён early stopping, валидационная выборка обычно уезжает мимо пайплайна непредобработанной. С версии 1.6 для этого есть Pipeline(transform_input=...) через metadata routing — для поддерживаемых объектов это снимает часть самописной маршрутизации, хотя сам routing остаётся экспериментальным API.

Как проверить: сравните search.best_score_ с оценкой на holdout. Заметное расхождение — повод разобрать пайплайн, а не готовый диагноз: причиной может быть и утечка, и сдвиг распределения, и просто маленькая выборка положительного класса.

Шаг 7. Порог решения — часть decision policy, а не константа в конфиге

Сначала важное разграничение, иначе вся статья читается неправильно. ROC‑AUC не зависит от порога вообще: это характеристика ранжирования. Порог отвечает за перевод score в бизнес‑решение. Падение AUC порогом не лечится — это разные уровни системы, и дальше речь про второй.

Мы долго держали порог 0.5 в YAML‑файле сервиса. Дисбаланс сам по себе не доказывает, что 0.5 плох: при откалиброванных вероятностях и симметричной цене ошибки он может быть разумным. Проблема в том, что это не универсальная константа, и подбирать порог на тех же данных, на которых обучались, нельзя.

Ещё важнее, по какой метрике подбирать. F1 — самый частый выбор в туториалах и для антифрода слишком грубый objective: он ничего не знает про сумму транзакции, стоимость ручной проверки и цену пропущенного фрода. TunedThresholdClassifierCV принимает скорер, собранный через make_scorer, так что цену ошибки можно выразить прямо:

# (Python)
from sklearn.metrics import make_scorer
from sklearn.model_selection import TunedThresholdClassifierCV

REVIEW_COST = 250  # условная стоимость одной ручной проверки

def net_benefit(y_true, y_pred, amount):
    caught = (y_true == 1) & (y_pred == 1)
    missed = (y_true == 1) & (y_pred == 0)
    false_alarm = (y_true == 0) & (y_pred == 1)
    return (
        amount[caught].sum()
        - amount[missed].sum()
        - REVIEW_COST * false_alarm.sum()
    )

business_scorer = make_scorer(
    net_benefit, response_method="predict"
).set_score_request(amount=True)

# X_dev / y_dev — второе окно из шага 1; сплиты материализуем,
# потому что groups до внутреннего cv иначе не доедет
X_dev, y_dev = dev_df[FEATURES], dev_df[TARGET]
threshold_splits = list(cv.split(X_dev, y_dev, groups=dev_df["client_id"]))

tuned = TunedThresholdClassifierCV(
    estimator=search.best_estimator_,
    scoring=business_scorer,
    cv=threshold_splits,
)
tuned.fit(X_dev, y_dev, amount=dev_df["amount"].to_numpy())

Две детали, которые легко пропустить.

  1. Первая: response_method="predict" нужен именно этому скореру, потому что стоимость считается по уже принятому бинарному решению, а не по сырому score.

  2. Вторая: X_dev — то самое второе окно из шага 1, более позднее, чем данные поиска гиперпараметров. Подбор гиперпараметров и подбор порога — два разных этапа, и если делать их на одной выборке, вы дважды выбираете по одним и тем же данным. После этого нетронутым остаётся только holdout.

Функция стоимости здесь намеренно игрушечная: реальная экономика антифрода включает и частичное возмещение, и chargeback fee, и стоимость оттока, и это не формула для продакшена. Но спорить с продуктом о цене ложной блокировки полезнее, чем молча оптимизировать F1.

В scikit‑learn 1.9 к этому добавилась metric_at_thresholds: считает произвольную бинарную метрику сразу по всем порогам, удобно показывать продукту график «полнота против ручной нагрузки».

Как проверить: сделайте поиск по конфигам сервиса на число 0.5. Если оно всё ещё где‑то лежит, значит порог живёт в двух местах сразу — и однажды эти два места разъедутся.

Шаг 8. Один артефакт model‑serving слоя

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

# (Python)
import joblib, sklearn, json

joblib.dump(tuned, "scoring_model.joblib")

with open("model_meta.json", "w") as f:
    json.dump({
        "sklearn": sklearn.__version__,
        "features_in": list(tuned.feature_names_in_),
        "feature_schema_version": FEATURE_SCHEMA_VERSION,
        "threshold": float(tuned.best_threshold_),
        "train_window": [TRAIN_END, DEV_END],
    }, f)

Слово «один» тут требует оговорки, иначе получится противоречие с тем, что было сказано про три слоя. Артефакт закрывает model‑serving слой: всё, что живёт внутри Pipeline, плюс политику решения.

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

Кстати, вот тут и работает бюджет в 40 мс: тяжёлые агрегаты по истории считаются до слоя скоринга, а в рантайме Pipeline получает уже готовые признаки и укладывается в лимит.

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

И отдельно: joblib и pickle небезопасны при загрузке недоверенных файлов, так что грузить их стоит только из своего registry.

Как проверить: прогоните через загруженный артефакт сотню строк из holdout и сравните с тем, что получилось на машине обучения. Предсказанные классы должны совпасть полностью; для вероятностей задайте численный допуск через assert_allclose, потому что BLAS и железо дают расхождения в последних знаках.

Ниже — маршрут целиком. На Рис. 3 собраны все восемь шагов и указано, какой конкретно риск закрывает каждый из них.

Рис. 3. План действий: восемь шагов и риск, который закрывает каждый
Рис. 3. План действий: восемь шагов и риск, который закрывает каждый

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

Но честная кросс‑валидация не обязана совпасть с продом: она может стать 0.88 при боевых 0.76, и это будет означать, что утечку вы убрали, а сдвиг распределения и расхождение фичей — ещё нет. Чтобы офлайн‑оценка была сопоставима с продом, должны совпасть как минимум пять вещей:

  1. Когорта — прод скорит не то же множество, что лежит в выгрузке.

  2. Временная семантика — обучение строго раньше предсказания.

  3. Доступность признаков — то же значение, что реально будет в рантайме.

  4. Определение и зрелость таргета — один и тот же положительный класс и доехавшая метка.

  5. Правило принятия решения — один и тот же порог и одна и та же политика.

Случай, который стоит знать

Лучшая известная мне иллюстрация того, насколько неочевидной бывает утечка, — KDD Cup 2008, соревнование по выявлению рака груди на маммографических данных. Среди полей был идентификатор пациента, который почти все сочли служебным мусором.

Победители — команда с участием Клаудии Перлих и Сахарона Россета — обнаружили, что ID обладает большой предсказательной силой: он оказался связан с источником данных, а источник — с долей злокачественных случаев, потому что часть материала пришла из клинических исследований, куда набирали преимущественно больных.

Позже эта история легла в основу статьи «Leakage in Data Mining: Formulation, Detection, and Avoidance» тех же авторов, где сформулирован принцип learn‑predict separation: при обучении нельзя использовать ничего, что не будет доступно в момент предсказания. Не «ничего про таргет», а именно «ничего, чего не будет в проде».

Формулировка бэкендерская по духу: это тот же вопрос, который мы задаём при проектировании интеграции — а что реально придёт в этот метод в рантайме? Могу себе представить, как аналитик в 2008-м смотрит на колонку patient_id и думает «ну это же просто счётчик». Я бы на его месте подумал так же.

Что делают команды, которые на этом уже обожглись

  1. Тест на утечку в CI. Pytest перемешивает таргет и прогоняет тот же CV: результат стабильно лучше случайного уровня — сигнал искать утечку или ошибку в схеме валидации. Гоняется на каждом PR.

  2. Общий код признаков для обучения и инференса. Не «переписали на Java по описанию», а один артефакт, вызываемый с обеих сторон. Та же логика, что с DTO: как только контракт дублируется, копии расходятся — вопрос лишь в том, через сколько недель.

  3. Shadow‑режим. Модель считает предсказания в бою, но ни на что не влияет. Сравнение офлайна и прода на созревшей когорте — один из самых надёжных способов убедиться, что оба пути от признака к решению совпадают.

Уверенная работа с ML начинается с понимания не только алгоритмов, но и всего процесса вокруг модели. Пройдите бесплатный тест по машинному обучению, чтобы оценить свой текущий уровень и увидеть, какие темы требуют внимания.

Где этот подход не сработает

Если данные не помещаются в память и обработка живёт в Spark, Pipeline останется только для финального этапа.

Если утечка сидит в источнике, как с patient_id, порядок шагов не поможет: нужен анализ происхождения полей.

Для произвольных преобразований нескольких таблиц до единого X стандартный Pipeline тоже не предназначен — там стоит посмотреть на DataOps в skrub.

И при большом поиске по сетке всё это дорого: есть экспериментальный HalvingRandomSearchCV (импортируется после enable_halving_search_cv) и кэш трансформеров через memory.

Чек‑лист перед релизом

Симптом

Что проверить

CV сильно выше временного holdout

предобработка вне пайплайна, признак из будущего, стратегия сплита

CV резко падает после группировки

запоминание сущности; заодно уточнить, тот ли это сценарий эксплуатации

Офлайновая CV заметно выше оценки на проде

сдвиг распределения, задержка метки, разная популяция, расхождение фичей, утечка

Категориальный признак «слишком» важен

target encoding без cross‑fitting или с чужим splitter'ом

Отобранные признаки нестабильны между фолдами

отбор до кросс‑валидации, сэмплирование, дрейф

Одинаковый вход даёт разные предсказания офлайн и в проде

версия библиотеки, порядок колонок, порог, разные версии артефакта

Боевая метрика ухудшается со временем

дрейф, изменение популяции, устаревание модели

Всё это проверяет, в сущности, один навык: умение отличать качество модели от качества измерения.

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

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

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

Разобраться с этим на практике можно через реальные сценарии разработки ML‑систем:

  • 9 сентября в 18:00. «Оптимизируем построение модели через Pipeline». Записаться

  • 23 сентября в 18:00. «Дерево решений — простой и интерпретируемый ML‑алгоритм». Записаться

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

Комментарии (1)


  1. Zumbra
    07.09.2026 12:31

    Было бы неплохо почитать внимательно и отредактировать выдачу.