Все материалы
MLгайдсредний

Почему ML-модель ломается после запуска: утечка признаков и дрейф

Модель с ROC-AUC 0,94 на валидации даёт 0,68 в проде. Как найти утечку признаков по времени их доступности и как поймать дрейф данных и концепта по PSI, качеству и сегментам.

КейсПрактика5 октября 2026 г.16 мин

Модель, которая решает, отправлять заказ в сборку или придержать до подтверждения, показала на валидации ROC-AUC 0,94. Через три недели после запуска на тех же метриках 0,68 — на шесть пунктов выше простого правила без модели. Ещё через четыре месяца качество сползло до 0,61, хотя код не менялся ни разу. Это два разных диагноза с похожим симптомом: сначала признак, которого в момент решения ещё не существует, потом расхождение данных с теми, на которых модель училась. Первая причина видна в первый же день, вторая — через месяцы, и лечатся они по-разному.

Коротко

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

  • Подозрительно высокий offline score — повод искать утечку, а не радоваться.
  • Вопрос к каждому признаку один: существовало ли это значение в секунду, когда модель принимала решение.
  • Случайное разбиение прячет утечку, временное показывает её сразу.
  • Дрейф данных видно по входам, дрейф концепта — только по качеству при неизменных входах.
  • PSI — сигнал проверить, а не команда переобучать.
  • Размеченная цель приходит позже, чем нужен сигнал, поэтому мониторят входы и предсказания.
  • У каждого порога в мониторинге должны быть владелец и заранее описанная реакция.

Падение в первый день и сползание за квартал — разные поломки

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

Отличаются они и лечением. Утечка не исправляется переобучением: новая модель возьмёт тот же признак и снова покажет 0,94. Дрейф не исправляется проверкой признаков: они корректны, просто мир под ними изменился.

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

Как отличить три причины по симптому
Что видноДиагнозПервая проверка
прод хуже валидации с первого дня, дальше ровноутечка признаковвремя появления значения у топ-признаков
первые недели совпадают, потом сползаниедрейф данных или концептаPSI по входам и качество на зрелых исходах
прод хуже на доли процента, но стабильнорасхождение обучения и инференсаодин и тот же батч через обе реализации
качество скачет день ото дняпайплайн признаков, а не модельдоля пропусков и задержка источников

ROC-AUC 0,94 на валидации и 0,68 в проде: признак знал, чем кончился заказ

Начните с таблицы важности признаков. У нашей модели на первом месте стоял support_tickets_count — число обращений в поддержку по этому заказу, — и на него приходилось 0,71 от суммы важностей. Ещё 0,11 давал refund_status, 0,06 — days_to_status_change. То есть 0,88 качества держалось на трёх полях, и все три заполняются после того, как заказ уже прожил свою жизнь. Клиент обращается в поддержку, когда хочет отменить заказ; статус возврата появляется после доставки. В момент оформления, когда модель должна решать, все три поля пусты.

Откуда они тогда взялись в обучающей выборке? Выборку собрали запросом к таблице orders, а эта таблица мутабельная: поля в ней перезаписываются по ходу жизни заказа. Запрос вытащил не состояние на момент оформления, а состояние на момент выгрузки, то есть финал. Это самая частая механика утечки в аналитическом складе, и SQL в ней абсолютно корректен — неверен момент, на который собрана строка.

Дальше арифметика простая. Убираем из набора семь полей, появляющихся после решения, переобучаем на том же алгоритме — и получаем 0,68 вместо 0,94. Baseline, логистическая регрессия на трёх полях из формы заказа, даёт 0,62. Значит честная модель приносит шесть пунктов над baseline, и это всё, что у вас есть. Тридцать два пункта, которые исчезли, никогда продукту и не принадлежали: они были платой за подглядывание в будущее.

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

Что именно давало 0,94
ПризнакДоля важностиКогда заполняется значение
support_tickets_count0,71после обращения клиента, то есть после решения об отмене
refund_status0,11после доставки
days_to_status_change0,06по факту финального статуса
payment_method0,04при оформлении
cart_value0,04при оформлении
delivery_region0,04при оформлении
ROC-AUC трёх версий на одной и той же выборке

Baseline — три поля из формы заказа без модели поверх. «С утечкой» — та же модель вместе с полями, которые заполняются после отмены. «Честная» — минус семь таких полей. До прода доживает только разница 0,68 − 0,62.

ROC-AUC
Признак красный не потому, что он сильный

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

Prediction time — это секунда, а у признака есть дата записи

Утечка перестаёт быть загадкой, если завести для модели одну таблицу: признак, момент появления значения, доступен ли он на prediction time. Prediction time задаётся событием, а не абстракцией: «секунда, в которую заказ создан», «момент отправки заявки», «полночь перед рассылкой». Пока этот момент не назван, спорить о признаках бессмысленно — непонятно, относительно чего считать будущее.

Разметка ловит и вторую форму утечки, более коварную, чем поле из финала. Агрегаты вроде «число заказов пользователя» обычно считают по всей истории таблицы, включая заказы, которые произошли позже оцениваемого. Значение есть, колонка выглядит невинно, а внутри — информация о будущем этого же пользователя. Лечится сдвигом окна: агрегат считается по строкам, созданным строго до prediction time.

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

Разметка десяти признаков по времени доступности
ПризнакКогда появляетсяДоступен в секунду оформленияЧто делать
cart_valueпри оформлениидабрать как есть
user_orders_beforeнакапливаетсяда, если окно закрыто датой заказапереписать с границей окна
user_orders_totalнакапливаетсянет: включает будущие заказызаменить на версию с окном
address_check_resultчерез 2 секунды после оформленияпочти: нужен таймаутсчитать пропуск допустимым значением
manager_reviewedпосле ручной проверкинетубрать
support_tickets_countпосле обращениянетубрать
refund_statusпосле доставкинетубрать, это часть цели
Один и тот же агрегат с закрытым и с открытым окном
-- утечка: считает все заказы пользователя, включая более поздние
select o.order_id,
  (select count(*) from orders prev where prev.user_id = o.user_id) as user_orders_total
from orders o;

-- честно: окно закрыто моментом решения
select o.order_id,
  (select count(*)
   from orders prev
   where prev.user_id = o.user_id
     and prev.created_at < o.created_at) as user_orders_before
from orders o;

Случайное разбиение прячет то, что временное показывает сразу

Если строки перемешать и отрезать 20% на валидацию, в валидации окажутся заказы, которые произошли раньше части обучающих. Модель обучается на сентябре и проверяется на августе, а заодно получает возможность запомнить конкретных пользователей: один и тот же человек попадает в обе части. На нашем наборе случайное разбиение давало 0,94, временное — 0,71 при том же коде и признаках. Эти 23 пункта разницы и есть измерение утечки, а не шум.

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

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

pythonВременное разбиение с зазором и out-of-fold кодирование
TRAIN_END = pd.Timestamp("2026-06-01")
LABEL_DELAY = pd.Timedelta(days=30)  # столько ждём исход

train = df[df.created_at < TRAIN_END]
valid = df[(df.created_at >= TRAIN_END + LABEL_DELAY) & (df.created_at < TRAIN_END + LABEL_DELAY + pd.Timedelta(days=30))]

# кодирование категории средним по цели — только внутри фолдов
folds = GroupKFold(n_splits=5)
train["region_te"] = np.nan
for fit_idx, apply_idx in folds.split(train, groups=train.user_id):
    means = train.iloc[fit_idx].groupby("delivery_region").target.mean()
    train.iloc[apply_idx, train.columns.get_loc("region_te")] = (
        train.iloc[apply_idx].delivery_region.map(means)
    )

Признаки те же, а precision падает — модель устарела вместе с процессом

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

Data drift — это изменение распределения входов. Запустили рекламу в новом регионе, и доля заказов оттуда выросла с 4% до 19%; появилась категория, которой в обучении почти не было. Модель продолжает отвечать, но для новой части потока она экстраполирует. Concept drift — изменение самой связи: входы выглядят как раньше, а их смысл другой. Поменяли политику бесплатного возврата, и «большая корзина» перестала означать повышенный риск отмены.

Есть и третий вид, который в учебниках обычно не называют, а на практике встречается чаще прочих: дрейф процесса. Кто-то поменял порог отсечения, добавил ручную проверку до модели или переделал форму заказа — и на вход приходит другой срез заявок. Видно это по предсказаниям: распределение score уезжает при неизменных признаках. Поэтому порядок проверки один — входы и пайплайн, предсказания, качество на зрелых исходах, сегменты.

Две оси дрейфа и что они означают
ВходыКачествоЧто этоДействие
поехалиупалоdata drift: новый сегмент или каналдообучение, вес сегмента, отдельный порог
поехалидержитсясостав изменился без вреданичего, кроме записи в журнал
те жеупалоconcept drift или смена процессаискать изменение правил, затем retrain
те жедержитсявсё в порядке либо сломан мониторингпроверить, что метрики считаются на свежих данных
PSI по ключевому признаку после запуска

Порог 0,1 — смотреть, 0,25 — разбираться. Первый месяц в пределах нормы, на втором 0,21, на третьем 0,37: к этому моменту доля нового региона выросла в несколько раз.

PSI

PSI считается по бакетам baseline, и ломается именно на них

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

Посмотрите на пример, который можно пересчитать руками. Baseline разбит на пять равных бакетов, по 20% в каждом. Сейчас доли такие: 12%, 16%, 20%, 24%, 28% — поток плавно сдвинулся в сторону больших значений. Слагаемые: 0,0409, 0,0089, 0, 0,0073, 0,0269. Сумма 0,084, то есть ниже порога 0,1. Сдвиг, который на гистограмме выглядит очевидным, PSI ещё не считает событием — и это нормально, метрика создана ловить смену формы, а не медленный наклон.

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

Последнее про выбор признаков. PSI по двум сотням колонок каждый день даст десяток срабатываний без смысла: часть признаков естественно сезонна, часть слабо влияет на ответ. Считайте его по восьми-десяти, которые дают основную важность, и обязательно по выходу модели: prediction drift доступен без разметки и часто сигналит раньше входов.

PSI
PSI = Σ (доля_сейчас − доля_baseline) × ln(доля_сейчас / доля_baseline)

Бакеты задаются один раз по baseline. Ориентиры: меньше 0,1 — спокойно, 0,1–0,25 — смотреть, больше 0,25 — разбираться.

pythonPSI по фиксированным границам baseline
def psi(baseline, current, bins=5, eps=1e-6):
    edges = np.quantile(baseline, np.linspace(0, 1, bins + 1))
    edges[0], edges[-1] = -np.inf, np.inf  # хвосты остаются внутри крайних бакетов
    expected = np.histogram(baseline, bins=edges)[0] / len(baseline)
    actual = np.histogram(current, bins=edges)[0] / len(current)
    expected, actual = expected + eps, actual + eps
    return float(np.sum((actual - expected) * np.log(actual / expected)))

# границы считаются по baseline один раз и сохраняются вместе с моделью
psi(train.cart_value, october.cart_value)

Исход известен через 60 дней, а решение нужно сегодня

Главное ограничение мониторинга качества — задержка цели. Если отмена фиксируется в среднем через 11 дней, а спорные случаи закрываются через два месяца, то precision, посчитанный по зрелым заказам, описывает модель двухмесячной давности. Ждать его, чтобы заметить поломку, — значит узнавать о ней последним.

Поэтому мониторинг строят в два слоя. Быстрый слой не требует разметки: распределение score, доля заказов выше порога, доля пропусков в признаках, доля значений вне словаря, задержка пайплайна. Если при тех же входах доля придержанных заказов выросла с 27% до 19%, что-то случилось с калибровкой или с порогом — и это видно сегодня, без единого известного исхода.

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

Что можно посмотреть сегодня, а что придётся ждать
СигналЗадержкаО чём говорит
распределение score и доля выше порогаминутыкалибровка, порог, состав входящего потока
PSI по ключевым признакамсуткисдвиг входов, новые сегменты
доля пропусков и значений вне словарясуткиполомка источника или новая категория
ранний прокси цели7 днейнаправление изменения качества
precision и recall на зрелых исходах60 днейфактическое качество прошлого среза

Переобучение — одна из четырёх реакций, и обычно не первая

Retrain выглядит универсальным ответом, но он отвечает только на одну из причин. Если порядок объектов модель сохраняет, а сами вероятности уехали — ROC-AUC держится, а доля отсечённых заказов выросла, — нужна перекалибровка, и она стоит одного прогона на свежей выборке. Если поехал один сегмент, работает отдельный порог для него, а не новая модель для всех.

У переобучения есть и своя ловушка. Модель влияет на данные, которые потом станут обучающими: заказы, которые она придержала, не доехали до клиента, и их исход неизвестен. Учиться на результате собственных решений — значит усиливать прежнее смещение. Выход — держать небольшую долю потока вне модели или восстанавливать отказанные случаи отдельной процедурой, но решать это надо до retrain, а не после.

Любая реакция начинается с проверки входа: доля пропусков, скакнувшая с 0% до 12%, — инцидент пайплайна, а не модели. Переобучение на таких данных зафиксирует поломку в весах.

Наблюдение и реакция на него
Что видноРеакцияПочему не retrain
score уехал, ранжирование целоперекалибровкадискриминация в порядке, смещена шкала
упал один сегментотдельный порог или вес сегментаостальной поток менять не за что
связь признаков с целью измениласьretrain на свежем окнеэто и есть его случай
доля пропусков выросла вдвоепочинить источникмодель выучит поломку
убыток растёт, причина непонятнаfallback на правилосначала остановить ущерб

Мониторинг без владельца и реакции — это просто график

Таблица мониторинга занимает одну страницу и отвечает на четыре вопроса по каждой строке: что смотрим, как часто, при каком значении вмешиваемся и кто вмешивается. Без последних двух столбцов дашборд превращается в витрину: метрика краснеет, её видят, обсуждают и ничего не делают.

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

Таблица мониторинга одной модели
Что смотримЧастотаПорогВладелецРеакция
PSI по 8 ключевым признакамежедневнобольше 0,25 по любомудата-инженерпроверить источник, затем состав потока
доля значений вне словаряежедневнобольше 2%дата-инженеробновить словарь, найти новый канал
средний score и доля выше порогаежедневносдвиг больше 5 п.п.ML-инженерпроверить калибровку и порог
доля пропусков в признакахежедневнорост вдвоедата-инженерприостановить автоматические решения
precision и recall на зрелых исходахежемесячноминус 3 п.п.ML-инженерразбор по сегментам
качество по 5 крупным сегментамежемесячнолюбой сегмент минус 5 п.п.аналитикотдельный порог для сегмента

Проверки перед запуском и в первую неделю

Перед запуском проверяется время, после запуска — совпадение. Обе проверки дешёвые и обе ловят именно те поломки, из-за которых модель теряет качество без изменений в коде.

  • Prediction time назван событием, а не словами «в момент решения».
  • У каждого признака записан момент появления значения; поля после исхода исключены.
  • Обучающая выборка собрана из снимков, а не из мутабельной таблицы с перезаписью.
  • Разбиение временное, с зазором, равным задержке цели, и с группировкой по пользователю.
  • Рядом с моделью измерен honest baseline — иначе непонятно, что она добавляет.
  • Первая неделя: прогон обучающей и продовой реализаций на одних и тех же 10 000 строк, расхождение предсказаний меньше процента.
  • Baseline для PSI зафиксирован и сохранён вместе с моделью, включая границы бакетов.
  • У каждого порога в мониторинге есть владелец и одна строка с описанием реакции.

Итог

Две причины, с которых стоит начинать разбор упавшей модели, различаются по времени. Утечка обнаруживается в первый день и измеряется разницей между случайным и временным разбиением; она чинится не алгоритмом, а дисциплиной сборки признаков. Дрейф приходит месяцами и измеряется сравнением с зафиксированным baseline; он чинится действием, выбранным по типу расхождения, а не рефлекторным переобучением.

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

Продолжить чтение
Вся библиотека