Часть V · Инженерия · глава 17 из 19
Данные и логирование
Модели закончились — начинается то, на чём они стоят. Эта глава про слой, который обычно не показывают в статьях и на котором ломается больше внедрений, чем на выборе архитектуры: откуда берутся признаки в момент запроса, что именно писать в логи и почему офлайн-метрика может быть отличной, а онлайн — нет.
- Данные различаются свежестью и стоимостью подсчёта — отсюда два контура, а не из любви к архитектуре. Всё в рантайме дорого, всё заранее — неактуально.
- Feature skew — вторая по частоте причина расхождения офлайна и онлайна после неверно выбранной метрики. Расхождение в одну формулу двигает долю на пороге на 4.6 п.п., и офлайн-метрики этого не покажут вообще.
- Покрытие — метрика, о которой забывают. При 50 признаках с покрытием 0.95 полный вектор есть лишь у 7.7% запросов.
- Раздробленная история даёт фейковый холодный старт. Человек с 60 событиями на пяти идентификаторах выглядит новичком по каждому из них.
1. Два таймлайна
Начнём с конкретного примера. Что нужно модели в момент запроса — и что это значит технически.
| Продуктовое требование | Техническая реальность |
|---|---|
| Пользователь любит молочку — показать её выше | «Любит молочку» — агрегат за 90 дней; за 50 мс на запросе его не посчитать |
| Молоко уже в корзине — больше не показывать | Видно только в реальном времени |
| Сейчас вечер пятницы — больше пива и снеков | Контекст запроса, известен мгновенно |
- Разные данные имеют разную свежесть и разную стоимость подсчёта.
- Нельзя всё считать в рантайме — дорого.
- Нельзя всё считать заранее — неактуально или просто невозможно.
Базовое разделение: офлайн — эмбеддинги айтемов и пользовательские статистики, обновляемые раз в сутки или по триггерам; онлайн — последние клики в сессии, геолокация, устройство, время суток.
Важная оговорка: граница чаще инженерная, чем концептуальная. Одну и ту же фичу можно реализовать и там, и там — но она будет стоить разных денег и давать разную свежесть. А если пересчитывать офлайн-батчи часто, граница вообще размывается.
Лямбда-архитектура и её главная боль
- 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\).
| Дней назад | 0 | 7 | 30 | 60 | 90 |
|---|---|---|---|---|---|
| вес события | 1.0000 | 0.8507 | 0.5000 | 0.2500 | 0.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:
| Порог | Доля старше при обучении | В проде | Разница |
|---|---|---|---|
| 30 | 0.894 | 0.914 | +2.0 п.п. |
| 90 | 0.773 | 0.807 | +3.4 п.п. |
| 180 | 0.500 | 0.546 | +4.6 п.п. |
| 365 | 0.062 | 0.077 | +1.6 п.п. |
Числа воспроизводятся скриптом _tools/data_demo.py.
Сдвиг в четырнадцать дней двигает долю на пороге на единицы процентов — и это на одной фиче из сотен. Обратите внимание, где эффект максимален: в середине распределения, где сосредоточена основная масса пользователей.
- Выкатили эксперимент: офлайн-метрики хорошие, модель на валидации отличная.
- В A/B онлайн-метрики хуже — непонятно почему.
- Долго дебажим случайные вещи.
- Мучительно сравниваем значения фич офлайн против онлайна и находим проблему.
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% |
|---|---|---|---|---|---|
| наблюдаемый CTR | 0.0500 | 0.0490 | 0.0475 | 0.0450 | 0.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 событий.
| Идентификаторов | Событий на идентификатор | Порог достигнут | Истории видно |
|---|---|---|---|
| 1 | 60.0 | да | 100% |
| 2 | 30.0 | да | 50% |
| 3 | 20.0 | да | 33% |
| 5 | 12.0 | НЕТ | 20% |
Числа воспроизводятся скриптом _tools/data_demo.py.
При пяти идентификаторах ни один не набирает порога: человек с шестьюдесятью событиями выглядит новичком по каждому своему устройству. Это и есть фейковый холодный старт — история есть, но она раздроблена.
Понятный всем кейс: неделю ходил без залогина, накопил историю, потом зарегистрировался — теперь есть user_id, и историю надо склеить.
А самый тяжёлый случай — платформы, где пользователи осознанно скрывают поведение: ходят в приватном режиме (новый идентификатор каждый раз) и не логинятся. Без матчинга идентификаторов у каждого будет вечно холодный профиль, и качество рекомендаций упрётся в потолок, никак не связанный с моделью.
Вывод, который стоит сформулировать: работать с правильными идентификаторами нужно на уровне общего слоя признаков, а не в каждом пайплайне отдельно. Иначе склейка будет реализована по-разному в трёх местах — и мы вернулись к сюжету про skew.
4. Качество данных и дрейф
Проблемы, которые уже встретились: skew, потери клиентских событий, утечки при реконструкции признаков, потеря истории из-за множественных идентификаторов. Теперь — как за этим следить.
Покрытие — доля запросов, для которых признак доступен. И первое, что стоит посчитать, — как оно ведёт себя при накоплении признаков:
| Признаков | Все на месте при покрытии 0.99 | При 0.95 | При 0.90 |
|---|---|---|---|
| 1 | 0.9900 | 0.9500 | 0.9000 |
| 5 | 0.9510 | 0.7738 | 0.5905 |
| 10 | 0.9044 | 0.5987 | 0.3487 |
| 20 | 0.8179 | 0.3585 | 0.1216 |
| 50 | 0.6050 | 0.0769 | 0.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\) | то, что раньше нравилось, теперь нет; тренд устарел | самое трудное: переобучение помогает, только если контент успевает |
Различать их важно потому, что второй вид легко принять за успех. Метрика выросла — значит модель хороша? Не обязательно: изменилось распределение таргета, а модель ни при чём.
Считаем по бинам, обычно по децилям обучающего распределения. Пороги, принятые в индустрии: до 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\) примеров | там, где данные меняются очень быстро: новости, тикеты |
Бустинг обучается с нуля каждый раз — дообучать маленькими батчами нельзя. Нейросеть можно дообучить поверх старых весов, заморозить нижние слои и трогать только голову, а эмбеддинги новых айтемов обновлять инкрементально.
Это тот самый пункт чек-листа из главы про признаки: «нужно быстро дообучаться под дрейф».
- Забывание. Сеть теряет паттерны, важные месяц назад, — критично для редких сегментов, которые в свежем окне почти не представлены.
- Петля обратной связи усиливается. Модель рекомендует → пользователи смотрят рекомендованное → данные смещены → модель усиливает смещение. Онлайн-дообучение ускоряет этот круг.
- Аномалии. Бот-атака или сбой — и модель быстро впитывает аномалию, причём тем быстрее, чем свежее данные.
Обычно используют комбинацию: регулярное полное переобучение плюс быстрое дообучение сверху. Полный проход работает якорем, дообучение — свежестью.
Вопросы с собеседований
Зачем нужны два контура вычисления признаков?
Потому что разные данные имеют разную свежесть и разную стоимость подсчёта. «Пользователь любит молочку» — агрегат за 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 легко принять за успех.
Первоисточники
- D. Sculley et al. Hidden Technical Debt in Machine Learning Systems, NeurIPS 2015 — каноническая работа про то, что модель это малая часть системы.
- N. Marz, J. Warren. Big Data: Principles and Best Practices of Scalable Realtime Data Systems, Manning 2015 — первоисточник лямбда-архитектуры.
- J. Kreps. Questioning the Lambda Architecture, 2014 — постановка каппа-архитектуры.
- M. Haldar et al. Applying Deep Learning to Airbnb Search, KDD 2019 — раздел про расхождение офлайна и онлайна на практике.
- Z. Liu et al. Monolith: Real Time Recommendation System With Collisionless Embedding Table, 2022 — онлайн-дообучение в проде.
- Числа главы:
_tools/data_demo.pyв этом репозитории.