← к модулям

Темпоральная валидация

Как ты разбиваешь данные на train/test, решает не меньше, чем сама модель. Ниже — вживую: random vs leave-last-out vs global-temporal split на одном датасете. Урок: хорошая offline-метрика без правильного split — самообман.

⚠ Таблица сравнения посчитана офлайн (сравнить протоколы — значит переобучить модели под каждый split); страница её только показывает. Ползунок отсечки считается вживую, но дёшево (popularity — это счётчики).

1 · Глобальная отсечка по времени — вживую

Двигай отсечку: всё до неё — train, всё после — test. Смотри, как меняются размеры выборок, доля cold пользователей (кого не было в train) и метрика popularity. Popularity — это просто счётчики, поэтому считается мгновенно.

2 · Один датасет, три протокола сплита

Те же модели, посчитанные под тремя способами разбиения (обучены офлайн под каждый протокол — сравнить их — значит переобучить). Смотри, как random завышает числа и как под честным temporal ранжирование моделей переворачивается.

метрика:

Теория простым языком

Как ты разбиваешь данные на train/test, влияет на метрику не меньше, чем сама модель. Неправильный split даёт красивые, но лживые числа: модель «подглядывает будущее» и на бумаге выглядит лучше, чем будет в проде.

Три протокола

Random splitдержим случайные X% взаимодействий (или по одному случайному на юзера). Игнорирует время — в train могут попасть события, случившиеся позже тестовых. Модель эффективно видит будущее пользователя → метрика завышена.
Leave-last-outпрячем последнее по времени взаимодействие каждого юзера. Это хороший warm-start протокол: проверяем, умеет ли модель предсказать следующий айтем для уже известных пользователей. Не «хуже» random — он про другую задачу. Но это не имитация единого момента времени: в train могут быть события других юзеров, которые случились позже тестового события текущего (cross-user temporal leakage).
Global temporal splitодна глобальная отсечка по времени: всё до неё — train, всё после — test. Убирает прямую утечку будущих interactions — учимся на прошлом, проверяем на будущем, как в проде. Но это работает только если все фичи, popularity/item-статистики, энкодеры и negative-sampling тоже считаются строго по train-окну (иначе утечёт через них). Плюс появляется реальный cold-start: юзеры/айтемы, которых не было в train.

Что показывает эксперимент

В таблице выше random даёт метрику примерно вдвое-втрое выше leave-last-out для большинства моделей — но не для всех: у content-based всё наоборот (0.0029 против 0.0083), так что «random всегда завышает» — упрощение. И это совокупный эффект, а не чистая цена утечки: вместе с ней меняется таргет (случайное событие вместо последнего), а с ним горизонт и сложность предсказания; вдобавок порог релевантности отбирает у двух протоколов разные подмножества юзеров (342 против 363, общих 230). Чтобы вычленить именно утечку, нужны одинаковые запросы и две версии фич — PIT против leaky; так это сделано в D2b. Под global temporal ранжирование переворачивается: popularity/NeuMF выходят вперёд, а MF/BPR падают почти в ноль. Разбивка warm / cold ниже показывает почему.

Пример. Почему popularity вдруг «побеждает». Срез маленький и на две трети cold: из 60 юзеров в знаменателе 40 не было в train. У новых юзеров нет истории, а их первый выбор часто оказывается популярным айтемом (популярность здесь — обычные train-счётчики, без окна и затухания, так что «недавно-популярный» было бы неточно). NeuMF попадает туда же не потому, что «умеет cold-start»: он выдаёт один и тот же список всем холодным юзерам, на 70% совпадающий с топом популярности — это айтемные смещения, впитавшие популярность. MF, BPR и item-CF отдают ноль по той же причине, с другим знаком: их пустая строка эмбеддинга даёт либо нулевые скоры (произвольный порядок), либо случайную инициализацию. Вывод: колонка cold сравнивает не архитектуры, а случайно сложившиеся политики для неизвестного юзера. Честно сравнивать модели можно на warm; для cold нужен единый явный fallback (popularity, content, session, onboarding) и замер системы целиком. И ещё: на маленьком срезе числа шумные — читай направление, не абсолют.

Виды утечки (leakage)

Важно: один и тот же user в train и test — это не утечка (warm-start это нормальный сценарий: есть история, предсказываем следующий айтем). Плохо другое:

Duplicate leakageодно и то же interaction-событие попало и в train, и в test.
Future user-history leakageдля тестового события юзера в train оказались его более поздние действия.
Cross-user temporal leakageпри per-user split train содержит будущие события других юзеров относительно тестового момента.
Item / statistics leakageitem/user/popularity-фичи или энкодеры посчитаны с учётом test-периода.
Target leakageв признаки просочился сам label или его производная.

В нашем эксперименте всё считается строго по train-окну: каждая модель обучается только на train-матрице протокола, popularity и novelty (self-information) — тоже по train, а TF-IDF content-модели берёт статичные признаки айтемов (жанры/теги), не тестовые interactions. Так утечки через фичи не возникает.

val ≠ test

Нужны три части, а не две: train (учим), validation (крутим гиперпараметры, раннюю остановку, отбор модели) и test (трогаем один раз в самом конце). Если подбирать гиперпараметры по тесту — ты в него утёк, и финальная оценка снова оптимистична.

Hyperparameter tuning без утечкивесь отбор (grid search, early stopping, выбор модели) — только на validation. Test остаётся «запечатанным» до финального замера. В темпоральном варианте val — это отдельное временное окно между train и test.
Темпоральные окна
|— train —————|— val —|— test —|   → по времени
обучение         тюнинг   финальный
                          замер (1 раз)
Rolling-window / time-based CV (для полноты)

В проде часто делают несколько последовательных срезов по времени (rolling / expanding window): обучились на [0..t], проверили на [t..t+Δ], сдвинули окно, повторили. Это даёт устойчивую оценку во времени и ловит сезонность/дрейф. Здесь не реализуем — идея та же: никогда не тестировать на прошлом относительно train.

Сильные стороны

  • Global temporal честнее всего отражает прод: учимся на прошлом, предсказываем будущее.
  • Правильный split ловит cold-start и дрейф — то, что random прячет.
  • Отдельный validation бережёт test от утечки при тюнинге.

Слабые стороны

  • Темпоральный split строже и «неудобнее»: числа ниже, cold-start больно бьёт.
  • Меньше тестовых данных в позднем окне (маленькая когорта → шумная метрика).
  • Global temporal-когорта не сравнима напрямую с 1-на-юзера протоколами (разный состав).

⚠️ Что может пойти не так

  • Random split на временных данных — почти всегда оптимистичный самообман (модель видит будущее).
  • Тюнинг гиперпараметров по тесту = утечка; заведи отдельный validation.
  • Сравнивать метрики между протоколами «в лоб» нельзя, если у них разный состав/размер теста.
  • Leave-last-out честнее random, но cross-user leakage всё ещё есть.
  • Cold-start под global temporal — это фича, а не баг: так и выглядит реальный прод.

🧠 Проверь себя: Почему random split на данных со временем даёт оптимистичную метрику?

Где это в жизни

В любой продовой системе бэктест делают по времени (обучились на прошлом, замерили на следующем окне), а перед раскаткой — A/B (Фаза 14). Правильная валидация — это мост между «красивым offline» и «работает в проде»; следом идут feature store (те же фичи на обучении и в serving, без утечки) и мониторинг.