Условие
Модель предсказывает выручку магазина на следующую неделю. Аналитик использует обычный 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),
)Что обязательно учесть
- Gap между train и test. Если фичи включают агрегаты за «последнюю неделю», и модель предсказывает на ту же неделю — leakage. Сделайте gap = horizon.
- Сезонность. Test должен покрывать разные сезоны (тестируйте на 12-месячном окне для бизнеса с годовой сезонностью).
- Out-of-sample test holdout. Самый последний период вообще не показывать модели — это эквивалент прод-сценария.
- Не делать 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 — она бесполезна.
Подводные камни
KFold(shuffle=True)на временных данных — leakage из будущего.StandardScaler.fit(X_all)перед CV — leakage статистик.- Feature: «средний доход последнего месяца» при предсказании на сегодня → используется частично будущее, нужен gap.
- Не сравнивать с naive baseline. «R² = 0.7» звучит, но naive «вчера» может давать 0.65.
- Кросс-валидация только по последнему месяцу. Видим только этот сезон — на других может работать иначе.
- Перенос 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.