Материал подготовлен в рамках курса «Машинное обучение. Продвинутый уровень».
Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу, как сделать так, чтобы офлайн‑метрика модели соответствовала тому, что вы увидите в продакшене.
Команда приносит модель: в отчёте ROC‑AUC 0.93 на пятифолдовой кросс‑валидации. Через две недели после раскатки боевая метрика — 0.86.
Не катастрофа, но такой разрыв означает, что офлайн‑оценка плохо отражала условия работы модели в бою — из‑за разных когорт, из‑за утечки или из‑за расхождения в самом пути от признака к решению.
Первая реакция у всех одинаковая — «дрейф», но дрейф мы честно проверили, и его не хватало, чтобы объяснить разницу: врала сама процедура измерения.
Ниже — маршрут из восьми шагов, код, который можно забрать к себе, и, что важнее, объяснение, почему
Pipelineзакрывает только часть проблемы.
Пара слов о том, откуда я на это смотрю.
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
Сразу оговорюсь: я не дата‑сайентист — я тот человек, который принимает модель в прод, подписывает релиз и потом объясняет продукту, почему антифрод стал пропускать транзакции. И вот этот угол зрения, как мне кажется, тут полезнее взгляда из ноутбука: со стороны бэкенда очень хорошо видно место, где обучение и инференс расходятся.

Исходные условия
Чтобы разговор был предметным, вот вводные. Стенд обычный, ничего экзотического:
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 реально контролирует, а какой остаётся полностью на вашей ответственности.

Главная мысль этой схемы: 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())
Две детали, которые легко пропустить.
Первая:
response_method="predict"нужен именно этому скореру, потому что стоимость считается по уже принятому бинарному решению, а не по сырому score.Вторая:
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 собраны все восемь шагов и указано, какой конкретно риск закрывает каждый из них.

Главная мысль этой схемы: ни один шаг не улучшает модель, все восемь улучшают достоверность оценки. Вы делаете работу, после которой цифра в отчёте становится хуже, и это правильный результат.
Но честная кросс‑валидация не обязана совпасть с продом: она может стать 0.88 при боевых 0.76, и это будет означать, что утечку вы убрали, а сдвиг распределения и расхождение фичей — ещё нет. Чтобы офлайн‑оценка была сопоставима с продом, должны совпасть как минимум пять вещей:
Когорта — прод скорит не то же множество, что лежит в выгрузке.
Временная семантика — обучение строго раньше предсказания.
Доступность признаков — то же значение, что реально будет в рантайме.
Определение и зрелость таргета — один и тот же положительный класс и доехавшая метка.
Правило принятия решения — один и тот же порог и одна и та же политика.
Случай, который стоит знать
Лучшая известная мне иллюстрация того, насколько неочевидной бывает утечка, — KDD Cup 2008, соревнование по выявлению рака груди на маммографических данных. Среди полей был идентификатор пациента, который почти все сочли служебным мусором.
Победители — команда с участием Клаудии Перлих и Сахарона Россета — обнаружили, что ID обладает большой предсказательной силой: он оказался связан с источником данных, а источник — с долей злокачественных случаев, потому что часть материала пришла из клинических исследований, куда набирали преимущественно больных.
Позже эта история легла в основу статьи «Leakage in Data Mining: Formulation, Detection, and Avoidance» тех же авторов, где сформулирован принцип learn‑predict separation: при обучении нельзя использовать ничего, что не будет доступно в момент предсказания. Не «ничего про таргет», а именно «ничего, чего не будет в проде».
Формулировка бэкендерская по духу: это тот же вопрос, который мы задаём при проектировании интеграции — а что реально придёт в этот метод в рантайме? Могу себе представить, как аналитик в 2008-м смотрит на колонку
patient_idи думает «ну это же просто счётчик». Я бы на его месте подумал так же.
Что делают команды, которые на этом уже обожглись
Тест на утечку в CI. Pytest перемешивает таргет и прогоняет тот же CV: результат стабильно лучше случайного уровня — сигнал искать утечку или ошибку в схеме валидации. Гоняется на каждом PR.
Общий код признаков для обучения и инференса. Не «переписали на Java по описанию», а один артефакт, вызываемый с обеих сторон. Та же логика, что с DTO: как только контракт дублируется, копии расходятся — вопрос лишь в том, через сколько недель.
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‑алгоритм». Записаться
Полный список бесплатных уроков сентября вы найдете в дайджесте.
Zumbra
Было бы неплохо почитать внимательно и отредактировать выдачу.