Собесов

Хабр ML — кросс-валидация для временных рядов

ML / Data ScienceВалидацияСредняяMiddle

Условие

Модель предсказывает выручку магазина на следующую неделю. Аналитик использует обычный KFold (shuffle=True), и получает R² = 0.92 на CV. На проде модель показывает R² = 0.4. В чём проблема и как правильно делать CV?

Решение

Проблема — data leakage из будущего

При shuffle=True примеры из будущего попадают в train, прошлого — в test. Модель «видит» паттерны позже целевой точки и предсказывает фактически интерполированные значения. На проде такого не будет — отсюда обвал качества.

Walk-forward / expanding-window CV

from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5, test_size=7)
 
for fold, (tr, te) in enumerate(tscv.split(X)):
    print(f"Fold {fold}: train [{tr[0]}..{tr[-1]}], test [{te[0]}..{te[-1]}]")

Каждый fold: train на всём прошлом до момента T, test на T..T+h. Никогда test раньше train.

Sliding-window альтернатива

Каждый fold: train на последних N точках, test на N+1..N+h. Полезно, если данные нестационарны и старая история мешает.

def sliding_split(X, train_size=180, test_size=7, step=7):
    for start in range(0, len(X) - train_size - test_size + 1, step):
        yield (
            slice(start, start + train_size),
            slice(start + train_size, start + train_size + test_size),
        )

Что обязательно учесть

  1. Gap между train и test. Если фичи включают агрегаты за «последнюю неделю», и модель предсказывает на ту же неделю — leakage. Сделайте gap = horizon.
  2. Сезонность. Test должен покрывать разные сезоны (тестируйте на 12-месячном окне для бизнеса с годовой сезонностью).
  3. Out-of-sample test holdout. Самый последний период вообще не показывать модели — это эквивалент прод-сценария.
  4. Не делать feature engineering до split. StandardScaler.fit на всех данных → leakage статистик из будущего.

Особые ситуации

Panel data (магазины × дни). Можно делать group-aware time split: разделять по времени, но в test-фолде учитывать только те магазины, которые есть в train. GroupTimeSeriesSplit.

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

Прогноз с horizon > 1. Если предсказываете на 1, 7, 30 дней — оценивайте каждый horizon отдельно. Метрика на h=1 редко такая же, как на h=30.

Метрики

Для регрессии временного ряда:

  • MAPE — относительная ошибка, понятна бизнесу, но плохо ведёт себя около нуля.
  • sMAPE — симметричная MAPE.
  • MAE/RMSE — в единицах целевой.
  • Pinball loss — для квантильных прогнозов.
  • Naive baseline — всегда сравнивайте с «вчера» или «среднее по предыдущей неделе». Если модель не бьёт baseline — она бесполезна.

Подводные камни

  1. KFold(shuffle=True) на временных данных — leakage из будущего.
  2. StandardScaler.fit(X_all) перед CV — leakage статистик.
  3. Feature: «средний доход последнего месяца» при предсказании на сегодня → используется частично будущее, нужен gap.
  4. Не сравнивать с naive baseline. «R² = 0.7» звучит, но naive «вчера» может давать 0.65.
  5. Кросс-валидация только по последнему месяцу. Видим только этот сезон — на других может работать иначе.
  6. Перенос validation strategy между prod и offline — должны совпадать. Если в проде используется expanding-window — и валидация expanding-window.

Эталонный ответ

Для временных рядов: TimeSeriesSplit (walk-forward или sliding), gap между train и test = horizon, отдельный out-of-sample holdout (самый поздний период). Никаких shuffle=True. Все статистики (scaler, encoder) обучать ТОЛЬКО на train fold. Сравнивать с naive baseline.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти