Почему ML-модель ломается после запуска: утечка признаков и дрейф
Модель с ROC-AUC 0,94 на валидации даёт 0,68 в проде. Как найти утечку признаков по времени их доступности и как поймать дрейф данных и концепта по PSI, качеству и сегментам.
Содержание статьи
Модель, которая решает, отправлять заказ в сборку или придержать до подтверждения, показала на валидации 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, и это всё, что у вас есть. Тридцать два пункта, которые исчезли, никогда продукту и не принадлежали: они были платой за подглядывание в будущее.
Шесть пунктов — не повод отказываться от модели, а повод заново посчитать её окупаемость. Решение принимается на основе того, что известно в секунду оформления заказа, и если этого мало, выход не в модели, а в данных: добавить сигнал, который появляется раньше. Например, поведение в сессии перед оформлением или результат проверки адреса — их можно получить до решения, а не после.
| Признак | Доля важности | Когда заполняется значение |
|---|---|---|
| support_tickets_count | 0,71 | после обращения клиента, то есть после решения об отмене |
| refund_status | 0,11 | после доставки |
| days_to_status_change | 0,06 | по факту финального статуса |
| payment_method | 0,04 | при оформлении |
| cart_value | 0,04 | при оформлении |
| delivery_region | 0,04 | при оформлении |
Baseline — три поля из формы заказа без модели поверх. «С утечкой» — та же модель вместе с полями, которые заполняются после отмены. «Честная» — минус семь таких полей. До прода доживает только разница 0,68 − 0,62.
Подозрение вызывает не высокая важность сама по себе, а её сочетание с типом поля. Если один признак даёт больше половины качества и при этом описывает исход, а не вход, проверяйте его первым — и проверяйте по дате записи, а не по логике названия.
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, посчитанный по всей выборке, приносит в признак среднее значение цели, в которое вошла и целевая строка: на обучении признак идеален, на новых данных бесполезен. То же делает нормализация, обученная до разбиения, — среднее и дисперсия в ней знают о валидации. Лечится это дисциплиной: любое преобразование, которое смотрит на цель или на статистику всей таблицы, считается внутри фолдов.
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 |
| те же | держится | всё в порядке либо сломан мониторинг | проверить, что метрики считаются на свежих данных |
Порог 0,1 — смотреть, 0,25 — разбираться. Первый месяц в пределах нормы, на втором 0,21, на третьем 0,37: к этому моменту доля нового региона выросла в несколько раз.
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 = Σ (доля_сейчас − доля_baseline) × ln(доля_сейчас / доля_baseline)Бакеты задаются один раз по baseline. Ориентиры: меньше 0,1 — спокойно, 0,1–0,25 — смотреть, больше 0,25 — разбираться.
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; он чинится действием, выбранным по типу расхождения, а не рефлекторным переобучением.
Обе живут не в модели, а в данных вокруг неё: в моменте, на который собрана строка, и в процессе, который этот момент создаёт. Поэтому проверки отсюда ближе к работе аналитика, чем к подбору гиперпараметров.
Материалы по теме
Собеседование Data Scientist и ML-аналитика: метрики, модели и кейсы
Объёмный разбор задач на собеседовании Data Scientist и ML-аналитика: валидация, leakage, imbalance, ROC-AUC, threshold, эксперименты и связь модели с бизнесом.

DuckDB для аналитика: SQL по CSV и Parquet без сервера
Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

SQL или Python: что учить аналитику первым и где граница между ними
SQL и Python в работе аналитика данных: что учить первым, какие задачи решать запросом, а какие в pandas. Одна задача двумя способами на учебной базе и порядок изучения.