Как выявить расхождение признаков между обучением и продакшеном

Модель оценивает риск отмены заказа продавцом. На тестовом наборе результат выглядит убедительно, но после запуска часть заказов получает неожиданно низкий риск — при этом версия модели и порог не менялись. Причина может находиться не в алгоритме, а в том, как для него собираются признаки.
Представим заказ, для которого в обучающей строке указаны две отмены продавца за последние семь дней. Во время оформления сервис не видел ни одной. На первый взгляд команда может начать проверять версию модели, однако расхождение возникло раньше: обучающая таблица была выгружена спустя неделю и содержала окончательный счётчик, а продакшен принимал решение в конкретный момент времени.
Ниже — моделируемый сценарий, а не описание инцидента на действующем маркетплейсе. На четырёх заказах разберём, как воспроизвести проблему, отличить будущие события от задержанных и превратить расхождение признаков в проверяемое условие релиза. Числа в примере нужны для демонстрации и не позволяют делать выводы о качестве реальной модели.
Почему обучающие и рабочие признаки расходятся
Training-serving skew — это несовпадение условий, в которых модель обучается и применяется. Оно возникает из-за разных преобразований данных, изменения самих данных между этапами, различий в схеме входа или несовпадения рассчитанных признаков. Отдельный класс проблем связан с тем, что обучение использует информацию, которая стала известна только после момента принятия решения.
Одних распределений признаков недостаточно для расследования. Одинаковые гистограммы не гарантируют, что значения совпадают у тех же объектов: если переставить значения между заказами, распределение останется прежним, а предсказания могут измениться. Поэтому агрегатный мониторинг нужно дополнять попарным сравнением одинаковых запросов в один и тот же момент времени.
Пример с отменами продавцов
Пусть модель использует признак seller_cancellations_7d — число отмен продавца за семь суток до оформления заказа. У заказа есть время принятия решения decision_at, а у каждой отмены — два времени: occurred_at, когда событие произошло, и available_at, когда запись стала доступна сервису признаков.
Событие можно учитывать только в том случае, если оно уже произошло и было доступно системе к моменту прогноза. В тестовом наборе у продавца S1 отмены произошли в 11:00 и 11:30. Первая стала доступна в 11:02, вторая — только в 13:00. У продавца S2 отмена произошла в 09:00, но поступила в систему в 13:00. Заказы оценивались в 10:00, 12:00 и 14:00.
- O1, S1, 10:00: поздняя выгрузка показывает 2 отмены, но на момент решения доступно 0;
- O2, S1, 12:00: поздняя выгрузка показывает 2, фактически доступна 1;
- O3, S1, 14:00: оба значения равны 2;
- O4, S2, 12:00: поздняя выгрузка показывает 1, но сервис ещё не получил это событие, поэтому корректное значение равно 0.
Поздняя выгрузка отвечает на вопрос «сколько отмен известно сейчас», а обучающая строка должна отвечать на другой вопрос: «сколько отмен было доступно в момент, когда модель оценивала заказ». Если применить условный порог счётчик >= 1, бинарное решение изменится для O1 и O4. Для O2 значение также отличается, хотя при таком пороге итог остаётся тем же.
Как воспроизвести ошибку
Проблему можно воспроизвести без ML-библиотек на стандартной библиотеке Python. В примере задаются четыре заказа, события отмен, их время возникновения и время доступности. Одна функция считает события из поздней выгрузки, другая — события, которые действительно были видны сервису на момент решения.
Ожидаемые значения для поздней выгрузки: O1 — 2, O2 — 2, O3 — 2, O4 — 1. Значения, доступные во время прогноза: 0, 1, 2 и 0 соответственно. Такой тест сразу показывает две разные причины ошибки. Для O1 поздняя таблица включает будущие относительно прогноза отмены, а для O4 событие уже произошло, но поступило в систему после принятия решения.
Фильтра по occurred_at недостаточно: нужно проверять и available_at. Окно в семь суток задаётся как интервал от decision_at − 7 суток включительно до decision_at исключительно. В рабочей системе также понадобятся единая временная зона, точность временных меток, стабильные идентификаторы событий и правило обработки повторной доставки.
Что проверять в действующей системе
Для расследования нужно сопоставлять не абстрактные строки, а один и тот же запрос. Минимальная диагностическая запись включает request_id, идентификатор сущности, decision_at, версию модели, версию вычисления признаков, фактическое значение на входе сервиса и источник, из которого можно восстановить историческое значение.
Проверка проходит в два этапа:
- Контракт признака. Сверяются наличие поля, тип, единицы измерения, значение по умолчанию и правила обработки пропусков. Число отмен и доля отмен — разные признаки, даже если их названия похожи.
- Парный пересчёт. Для тех же запросов признак восстанавливается относительно времени решения и сравнивается с сохранённым входом модели. В отчёте фиксируются число и доля несовпадений, величина разницы и распределение ошибок по источникам.
Сравнение offline и online на сегодняшней базе не подходит для вчерашнего заказа: оба значения уже могут учитывать исправления и поздние события. Нужен журнал входных признаков либо исторический источник, способный восстановить состояние на момент решения. Такой журнал не обязан содержать все персональные данные — для диагностики часто достаточно идентификатора запроса, выбранных признаков и версий конвейера с учётом политики хранения и доступа.
Исторические данные и point-in-time join
При подготовке обучающего набора значение признака нужно выбирать относительно времени каждой строки. Point-in-time join ищет подходящую запись назад от временной метки и ограничивает период действия признака. Это предотвращает ситуацию, когда к старому решению присоединяется сегодняшнее состояние.
Однако соединения только по времени события может быть недостаточно. Если партнёр передаёт данные с задержкой или исправляет прошлые записи, потребуется отдельное время доступности либо сохранённая версия состояния, которую видел сервис. В разных системах можно различать момент создания события у партнёра, поступления в шину, записи в хранилище и материализации в online-хранилище. Для проверки нужно выбрать границу, после которой модель действительно могла использовать значение.
Общая функция преобразования для обучения и обслуживания снижает риск различий в округлении, заполнении пропусков и других операциях. Но она не восстановит прошлое состояние источника, если история уже перезаписана. Поэтому для каждого признака стоит зафиксировать определение, окно, точку отсчёта, источник, временную зону, допустимую задержку, правило обработки поздних событий и владельца.
Какие гейты поставить перед релизом
Релизные проверки полезно разделить по типам возможных сбоев:
- Схема: обязательные поля присутствуют, типы совместимы, допустимые значения не изменились.
- Построчное равенство: на контрольных запросах нет необъяснимых расхождений критичных признаков.
- Время доступности: исторический пересчёт не использует записи, появившиеся после decision_at.
- Покрытие: доля пропусков и устаревших значений не превышает согласованный порог.
- Продуктовый контроль: после появления целевых меток качество и нагрузка на процесс остаются в заданных границах.
Универсального порога для доли несовпадений нет. Для критичного признака, влияющего на автоматическое решение, может потребоваться полное совпадение на эталонном наборе. Для приблизительных счётчиков допустимую погрешность и задержку следует согласовать отдельно. Сам факт расхождения не доказывает ухудшение модели, но показывает, что офлайн-оценка описывает другой вход.
Инструменты проверки схем, статистических аномалий и различий распределений помогают заметить проблему на уровне набора данных. Построчный тест решает более узкую задачу — проверяет конкретное значение, на основании которого было принято конкретное решение. После исправления признака обучение и проверку нужно пересобрать с корректным историческим состоянием, заново выбрать порог и сравнить новую версию с текущей на тех же объектах.
Итоги
В смоделированном примере поздняя выгрузка расходится со значением, доступным во время прогноза, в трёх из четырёх строк. Условное бинарное решение меняется в двух случаях. Простая фильтрация по времени возникновения не устраняет проблему: для заказа O4 событие произошло заранее, но сервис узнал о нём позже.
Тот же подход применим к сумме платежей, числу обращений в поддержку, тарифу клиента и другим признакам. Нужно восстановить состояние данных на момент решения, сохранить фактический вход модели и сравнить его с исторической обучающей строкой. Тогда training-serving skew превращается из расплывчатой жалобы на «дрифт» в воспроизводимую ошибку с идентификатором запроса, временем и конкретным значением признака.


