RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← Exploration Рантайм →

Часть V · Инженерия · глава 17 из 19

Данные и логирование

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

Что унести из главы
  • Данные различаются свежестью и стоимостью подсчёта — отсюда два контура, а не из любви к архитектуре. Всё в рантайме дорого, всё заранее — неактуально.
  • Feature skew — вторая по частоте причина расхождения офлайна и онлайна после неверно выбранной метрики. Расхождение в одну формулу двигает долю на пороге на 4.6 п.п., и офлайн-метрики этого не покажут вообще.
  • Покрытие — метрика, о которой забывают. При 50 признаках с покрытием 0.95 полный вектор есть лишь у 7.7% запросов.
  • Раздробленная история даёт фейковый холодный старт. Человек с 60 событиями на пяти идентификаторах выглядит новичком по каждому из них.

1. Два таймлайна

Начнём с конкретного примера. Что нужно модели в момент запроса — и что это значит технически.

Продуктовое требованиеТехническая реальность
Пользователь любит молочку — показать её выше«Любит молочку» — агрегат за 90 дней; за 50 мс на запросе его не посчитать
Молоко уже в корзине — больше не показыватьВидно только в реальном времени
Сейчас вечер пятницы — больше пива и снековКонтекст запроса, известен мгновенно
Три вывода, из которых растёт вся архитектура
  1. Разные данные имеют разную свежесть и разную стоимость подсчёта.
  2. Нельзя всё считать в рантайме — дорого.
  3. Нельзя всё считать заранее — неактуально или просто невозможно.

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

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

Лямбда-архитектура и её главная боль

полная история события за месяцы поток событий из рантайма Batch Layer раз в сутки, пересчёт агрегатов с нуля Speed Layer инкремент по событию, задержка секунды Serving Layer KV-хранилище, две зеркальные части UPS мерж, фоллбеки, профиль одна логика в двух местах баг чинить дважды · деплой дважды · разные языки и часто разные команды → данные легко разъезжаются
Пунктир в середине — не связь, а проблема: агрегация реализована дважды, и две реализации обязаны совпадать.
Пример целиком: «количество покупок молочки за 30 дней»
  • Batch, раз в сутки: читаем все заказы за 30 дней, считаем количество, пишем в KV.
  • Speed, на каждое событие: новый заказ приходит в очередь, стрим инкрементально обновляет счётчик, пишет дельту.
  • UPS: забирает профиль из batch-части, смотрит на время её обновления, добирает нужные дельты из speed-части.

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

Во что это обходится и чем лечится

108 пользователей, 3 события в день, окно 30 дней:

честное окно: кольцевой буфер событий9.0e+09 событий × 16 байт = 144.0 ГБ
экспоненциальное затухание: одно число на пользователя108 × 8 байт = 800.0 МБ
разницав 180 раз

Если хочется stateless-обновления, счётчик заменяют экспоненциальным угасанием по времени. Постоянная подбирается через период полураспада: 30 дней дают \(\lambda = 0.02310\).

Дней назад07306090
вес события1.00000.85070.50000.25000.1250

Числа воспроизводятся скриптом _tools/data_demo.py в этом репозитории.

Цена: у затухания нет резкой границы окна, и «за 30 дней» превращается в «примерно за месяц». Для продуктового правила («вернуть деньги, если покупка была в течение месяца») это неприемлемо; для признака модели — почти всегда нормально.

Каппа-архитектура — решение?

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

Почему так делают редко:

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

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

User Profile Service

За данными обычно не ходят в KV-хранилища напрямую: логику мержа выносят в отдельный сервис. Он получает идентификатор, читает из зеркал всё, что известно о пользователе, делает аккуратный мерж, чтобы не задублировать данные — batch уже мог учесть часть событий, которые есть и в дельтах, — держит фоллбеки на недоступность и возвращает сериализованный профиль.

Смысл выделения в сервис ровно один: логика мержа сложная и должна быть в одном месте. Разложенная по клиентам, она разъедется — по той же причине, по которой разъезжается batch и speed.

2. Feature skew

Канонический пример

Офлайн-джоба считает возраст клиента как (now − first_order_date).days. Онлайн-код считает то же самое как (now − registration_date).days. Числа получились разные — не со зла: офлайн-код писала одна команда, онлайн — другая, договорились сделать одинаково, но со временем разъехались.

Модель обучается на одном, инферит на другом.

Насколько это сдвигает распределение

Пусть между регистрацией и первым заказом в среднем 14 дней, а возраст клиента распределён со средним 180 и разбросом 120:

ПорогДоля старше при обученииВ продеРазница
300.8940.914+2.0 п.п.
900.7730.807+3.4 п.п.
1800.5000.546+4.6 п.п.
3650.0620.077+1.6 п.п.

Числа воспроизводятся скриптом _tools/data_demo.py.

Сдвиг в четырнадцать дней двигает долю на пороге на единицы процентов — и это на одной фиче из сотен. Обратите внимание, где эффект максимален: в середине распределения, где сосредоточена основная масса пользователей.

Как это выглядит, если забить
  1. Выкатили эксперимент: офлайн-метрики хорошие, модель на валидации отличная.
  2. В A/B онлайн-метрики хуже — непонятно почему.
  3. Долго дебажим случайные вещи.
  4. Мучительно сравниваем значения фич офлайн против онлайна и находим проблему.

Feature skew — вторая после неправильно выбранной метрики причина расхождения офлайн- и онлайн-результатов. Это фраза, которую стоит уметь произнести на собеседовании дословно.

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

3. Что писать в логи

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

Логируем признакиРеконструируем
Какв момент инференса пишем значения, которые использовались для предсказаниялогируем только клиентскую часть, значения восстанавливаем офлайн
Заточно то, что видела модель; нет риска другого распределения; предикт воспроизводиммгновенно добавляем новые признаки; не тратимся на место
Противдорого по месту; новый признак нужно ждать, пока залогируетсявосстановить точно почти невозможно; не все признаки восстановимы, и это ограничивает дизайн; легко получить утечку
Сколько стоит логировать признаки

108 запросов в сутки, 100 кандидатов на запрос, 500 признаков по 4 байта:

Что логируемВ суткиВ год
признаки всех кандидатов20.0 ТБ7.3 ПБ
только топ-10 показанных2.0 ТБ730.0 ТБ

Числа воспроизводятся скриптом _tools/data_demo.py.

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

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

Минимальный необходимый набор

Бэкендные логи: идентификаторы запроса, пользователя, айтема; серверное время; позиция; скор модели.

Фронтендные логи: очень важно — идентификатор запроса, с которого пришёл айтем; идентификаторы айтема и пользователя; время — серверное или по попаданию лога в хранилище (клиентским часам верить нельзя).

Оба потока мержим по идентификатору запроса — это единственный честный способ получить настоящую картину произошедшего.

Почему при прочих равных предпочитают бэкендные логи

Клиентские события теряются: сеть, закрытая вкладка, блокировщики. При истинном CTR 0.050:

Потеря фронтенд-логов0%2%5%10%20%
наблюдаемый CTR0.05000.04900.04750.04500.0400
смещение−2.0%−5.0%−10.0%−20.0%

Числа воспроизводятся скриптом _tools/data_demo.py.

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

Единая схема события — не бюрократия

Одно фронтовое событие потом используется в четырёх местах: построение датасета для модели, подсчёт признаков, A/B для метрик эксперимента, мониторинги и дашборды.

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

ID Graph

При сборке пула выясняется, что один реальный человек имеет несколько идентификаторов: залогиненный user_id, device_id телефона, device_id ноутбука, cookie_id. История размазана между ними.

Фейковый холодный старт

У пользователя 60 событий за период. Порог, после которого модель считает историю содержательной, — 20 событий.

ИдентификаторовСобытий на идентификаторПорог достигнутИстории видно
160.0да100%
230.0да50%
320.0да33%
512.0НЕТ20%

Числа воспроизводятся скриптом _tools/data_demo.py.

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

Где особенно критично

Понятный всем кейс: неделю ходил без залогина, накопил историю, потом зарегистрировался — теперь есть user_id, и историю надо склеить.

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

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

4. Качество данных и дрейф

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

Покрытие — метрика, о которой забывают

Покрытие — доля запросов, для которых признак доступен. И первое, что стоит посчитать, — как оно ведёт себя при накоплении признаков:

ПризнаковВсе на месте при покрытии 0.99При 0.95При 0.90
10.99000.95000.9000
50.95100.77380.5905
100.90440.59870.3487
200.81790.35850.1216
500.60500.07690.0052

Числа воспроизводятся скриптом _tools/data_demo.py.

При 50 признаках с покрытием 0.95 полный вектор есть лишь у 7.7% запросов. То есть работа с пропусками — не краевой случай, а основной режим, и проектировать её надо соответственно.

Отдельная опасность — расхождение обучения и прода. Модель обучалась при покрытии 0.87, а в проде признак доступен для 0.60 запросов: доля запросов в невиданном режиме выросла с 13% до 40%, в 3.1 раза. Поведение на таких примерах непредсказуемо.

Резкое падение покрытия почти всегда означает поломку выше по потоку — сломанный пайплайн, изменившийся формат, отвалившийся источник. Это самый дешёвый сигнал тревоги из существующих.

Три вида дрейфа

ВидЧто изменилосьПримерЛечение
Covariateраспределение \(X\)новая когорта пользователей, рекламная кампания, сезонпереобучение на свежих данных плюс починка источника
Labelраспределение \(Y\)кликрейт вырос, потому что пришла лояльная аудитория, а не потому что модель лучшеразобраться в причине: сезонность, продукт или баг
Conceptсама связь \(X \to Y\)то, что раньше нравилось, теперь нет; тренд устарелсамое трудное: переобучение помогает, только если контент успевает

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

PSI: чем детектируют covariate drift
$$ \mathrm{PSI} = \sum_{b} \bigl(p^{\text{new}}_b - p^{\text{old}}_b\bigr)\,\ln \frac{p^{\text{new}}_b}{p^{\text{old}}_b} $$

Считаем по бинам, обычно по децилям обучающего распределения. Пороги, принятые в индустрии: до 0.10 — стабильно, 0.10–0.25 — насторожиться, больше 0.25 — распределение изменилось.

Сдвиг распределенияPSI на 10 бинахВердикт
0.00 σ0.0000стабильно
0.10 σ0.0096стабильно
0.25 σ0.0598стабильно
0.50 σ0.2377насторожиться
1.00 σ0.9261изменилось

Числа воспроизводятся скриптом _tools/data_demo.py.

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

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

Предсказуемый дрейф — это возможность, а не проблема

Перед праздниками паттерны меняются кардинально и предсказуемо. Значит, к этому можно подготовиться: обучить праздничную модель или увеличить вес соответствующего периода прошлого года.

Это редкий случай, когда дрейф известен заранее — грех не воспользоваться.

Конвейеры переобучения

ВариантКогда подходит
Ручное — обучаем когда хотим, деплоим рукамихорошо для старта, плохо в сложном многочастном проде
Раз в период — конвейер по расписаниюдостаточно для большинства задач
По триггеру — метрика упала, задетектирован дрейф, накопилось \(N\) примеровтам, где данные меняются очень быстро: новости, тикеты
Почему нейросетям тут удобнее

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

Это тот самый пункт чек-листа из главы про признаки: «нужно быстро дообучаться под дрейф».

Три риска онлайн-дообучения
  1. Забывание. Сеть теряет паттерны, важные месяц назад, — критично для редких сегментов, которые в свежем окне почти не представлены.
  2. Петля обратной связи усиливается. Модель рекомендует → пользователи смотрят рекомендованное → данные смещены → модель усиливает смещение. Онлайн-дообучение ускоряет этот круг.
  3. Аномалии. Бот-атака или сбой — и модель быстро впитывает аномалию, причём тем быстрее, чем свежее данные.

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

Вопросы с собеседований

Зачем нужны два контура вычисления признаков?

Потому что разные данные имеют разную свежесть и разную стоимость подсчёта. «Пользователь любит молочку» — агрегат за 90 дней, за 50 мс на запросе его не посчитать. «Молоко уже в корзине» видно только в реальном времени. Всё считать в рантайме дорого, всё заранее — неактуально или невозможно.

Отсюда лямбда: batch пересчитывает агрегаты с нуля раз в сутки, speed инкрементально обновляет по событиям, serving хранит две зеркальные части, а отдельный сервис профиля их мержит с учётом того, что batch мог уже учесть часть событий из дельт.

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

Что такое feature skew и почему это важно?

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

Масштаб: при разрыве в 14 дней доля клиентов старше порога сдвигается на 4.6 п.п. в середине распределения, где сосредоточена основная масса. И это одна фича из сотен.

Feature skew — вторая по частоте причина расхождения офлайн- и онлайн-результатов после неправильно выбранной метрики. Лечится общей библиотекой вычисления признаков, которую зовут и офлайн, и онлайн.

Логировать признаки или восстанавливать их потом?

Логирование даёт ровно то, что видела модель, гарантирует одинаковое распределение и позволяет воспроизвести предикт. Цена — место и задержка: при 10⁸ запросов, 100 кандидатах и 500 признаках это 20 ТБ в сутки и 7.3 ПБ в год, а новый признак становится доступен для обучения только через окно обучения после начала записи.

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

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

Какой минимальный набор логов нужен?

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

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

Что такое ID Graph и зачем он нужен?

Склейка идентификаторов одного человека: user_id при логине, device_id разных устройств, cookie_id. Без неё история размазана.

Конкретно: у пользователя 60 событий, порог содержательной истории — 20. На пяти идентификаторах получается по 12, и ни один порога не достигает — человек выглядит новичком по каждому своему устройству. Это фейковый холодный старт: история есть, но раздроблена.

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

Что такое покрытие фичи и почему за ним следят?

Доля запросов, для которых признак доступен. Следят по двум причинам.

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

Вторая — расхождение обучения и прода: обучались при покрытии 0.87, в проде 0.60, доля запросов в невиданном режиме выросла с 13% до 40%. Поведение модели там непредсказуемо.

И резкое падение покрытия почти всегда означает поломку выше по потоку — это самый дешёвый сигнал тревоги из существующих.

Какие бывают виды дрейфа и как их детектировать?

Covariate — изменилось распределение X (новая когорта, кампания, сезон); лечится переобучением и починкой источника. Label — изменилось распределение Y (кликрейт вырос, потому что пришла лояльная аудитория); надо разобраться в причине. Concept — изменилась сама связь X → Y; самое трудное, переобучение помогает, только если контент успевает.

Различать важно, потому что label drift легко принять за успех модели.

Детекция: для признаков — PSI по децилям обучающего распределения, пороги 0.10 и 0.25. Порядок величин полезно помнить: сдвиг в полсигмы даёт PSI 0.24, а в четверть сигмы — только 0.06. Для предсказаний — распределение скоров на инференсе. Для таргетов — посуточный CTR по сегментам.

Как устроено переобучение и чем рискует онлайн-дообучение?

Три варианта конвейера: руками (для старта), по расписанию (достаточно для большинства задач), по триггеру — падение метрики, детекция дрейфа, накопление N примеров (для новостей и подобного).

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

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

Шпаргалка одним экраном

Два контура

Разная свежесть и разная стоимость. Всё в рантайме дорого, всё заранее — неактуально.

Боль лямбды

Одна логика в двух местах. Каппа убирает её ценой того, что новая фича всегда трудна.

Окно

Честное окно — 144 ГБ буфера; затухание — 800 МБ. В 180 раз, но граница размывается.

Skew

Вторая причина расхождения офлайна и онлайна. 14 дней разницы → 4.6 п.п. на пороге.

Логи

20 ТБ в сутки за все признаки. Мерж по request_id. Клиентским часам не верить.

Потери фронта

Потерянный клик становится нулём, а не пропуском: смещение ровно на долю потерь.

Покрытие

50 фич по 0.95 → полный вектор у 7.7%. Падение покрытия = поломка выше по потоку.

Дрейф

Covariate / label / concept. PSI: 0.24 при полусигме. Label drift легко принять за успех.

Первоисточники