Как стать автором
Поиск
Написать публикацию
Обновить
580.12
OTUS
Развиваем технологии, обучая их создателей

Кросс-валидация на временных рядах: как не перемешать время

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров1.9K

Привет, Хабр!

Сегодня рассмотрим то, что чаще всего ломает даже круто выглядящие модели при работе с временными рядами — неправильная кросс-валидация. Разберем, почему KFold тут не работает, как легко словить утечку будущего, какие сплиттеры реально честны по отношению ко времени, как валидировать фичи с лагами и агрегатами.

Почему KFold — плохая идея для time-series

KFold — штука классная… но только если твои данные не зависят от времени. Он был создан для мира, где каждый объект независим. Для задач классификации изображений или анализа табличных данных KFold — почти рефлекс. Но вот идет речь про временные ряды — спрос на здравый смысл резко вырастает.

Время в таких задачах — не просто колонка date, которую можно бросить в dropna() и забыть. Это контекст, причинно-следственная структура. А KFold с его shuffle=True относится ко времени, как к скучной подписи на графике. Он перемешивает данные, обучая модель на будущем и тестируя на прошлом. То есть буквально заставляет её знать будущее, чтобы предсказать прошлое.

Например:

from sklearn.model_selection import KFold, cross_val_score
from sklearn.ensemble import RandomForestRegressor

cv = KFold(n_splits=5, shuffle=True, random_state=42)
score = cross_val_score(RandomForestRegressor(), X, y, cv=cv)

Выглядит аккуратно, работает без ошибок — а внутри скрывается полнейшее нарушение причинности: модель видит куски будущего, когда обучается, и потом радуется "о, как я круто предсказал".

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

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

Поэтому метрики будут лживыми, а фичи с look-ahead эффектом пройдут незамеченными.

Что делать вместо

TimeSeriesSplit

Каждый фолд берёт всё, что накопилось раньше, и пристёгивает очередной тест-отрезок справа:

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(
    n_splits=5,          # сколько раз «катнём» валидацию
    test_size=90,        # длина теста в днях/часах/минутках
    gap=7,               # буфер между train и test, спасает от look-ahead при лагах
)

for tr, ts in tscv.split(data):
    print(data.index[tr][0], '…', data.index[tr][-1], '→', data.index[ts][0], '…', data.index[ts][-1])

gap появился в версии 0.24 и даёт модели «санитарную зону», если вы строите признаки, заглядывающие на один-два шага вперёд.

Плюсы

  • встроено в sklearn, значит — никакого внешнего зоопарка;

  • растущая история правдоподобна для процессов, где данные только прибавляются.

Минусы

  • модель тащит в Train всю древность; при структурных сдвигах (см. эконометрику про structural breaks) это приводит к деградации;

  • нет прямой возможности забыть старые эпохи, если они мешают.

Можно ограничить max_train_size, чтобы не скармливать модели доисторических мамонтов — и ускоришь обучение, и защитишься от сломанных закономерностей.

Expanding window

TimeSeriesSplit ок, но иногда нужна тонкая настройка — например, отдельно задать первые X точек, потом сдвигаться по месяцам, а horizon оставить в N дней. Тут заходит sktime:

from sktime.split import ExpandingWindowSplitter

splitter = ExpandingWindowSplitter(
    initial_window=365*2,   # 2 года — первый train
    step_length=30,         # каждые 30 точек/дней сдвигаемся
    fh=[1, 7, 30],          # прогнозируем завтра, через неделю и через месяц
)

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

Но при нестационарных рядах ошибки старых эпох могут тянуть модель вниз. Здесь спасает либо регуляризация, либо… наш третий сплиттер.

Rolling (Sliding) window

Когда нужно забывать старое — держим train-окно фиксированной длины и катим его вправо.

В sklearn пока нет родного класса, но BaseCrossValidator писать проще, чем SQL с тремя JOIN-ами:

from sklearn.model_selection import BaseCrossValidator
import numpy as np

class RollingWindowCV(BaseCrossValidator):
    def __init__(self, window, horizon, step=1):
        self.window, self.horizon, self.step = window, horizon, step

    def split(self, X, y=None, groups=None):
        n = len(X)
        for start in range(0, n - self.window - self.horizon + 1, self.step):
            train = np.arange(start, start + self.window)
            test  = np.arange(start + self.window, start + self.window + self.horizon)
            yield train, test

    def get_n_splits(self, X=None, y=None, groups=None):
        return (len(X) - self.window - self.horizon) // self.step + 1

Использование один-в-один со всеми cross_val_score, GridSearchCV и друзьями:

cv = RollingWindowCV(window=365, horizon=30, step=30)
scores = cross_val_score(model, X, y, cv=cv, scoring='neg_mean_absolute_error')

Модель всегда смотрит только на «актуальный» год (или сколько задашь) — резкая смена трендов не пробивает. Колчество фолдов обычно меньше, чем у expanding window и от этого сложнее оценивать разброс метрик.

Когда какой сплиттер брать

Ситуация

Лучший выбор

Стабильный, длинный ряд без резких режим-шабов

TimeSeriesSplit

Нужно аккумулировать всю историю, метрики по одинаковым горизонтам

ExpandingWindowSplitter

Есть подозрение на drifts/structural breaks, важна свежесть данных

RollingWindowCV

В серию вмешиваются лаговые фичи → риск утечки

любой из трёх с gap > max(lag)

Итог: выбирая сплиттер, задавай себе два вопроса:

  1. Насколько далеко моему бизнесу важно помнить прошлое?

  2. Как часто поведение ряда меняется радикально?

Агрегаты и лаги: где прячется leakage

Коварные агрегаты

Среднее за будущие 7 дней в тренировке даёт модели инфу, недоступную на момент прогноза. Классика:

df['mean_7d'] = df['sales'].rolling(7).mean()  # нужно .shift(1)!

Осечка: без shift(1) окно заглядывает на текущий день, совпадающий с лейблом — привет, data leakage. Такая ошибка способна добавить +15…+40 pp MAPE разницы между offline и prod.

«Целевые» лаги

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

for lag in range(1, 8):
    df[f'sales_lag_{lag}'] = df['sales'].shift(lag)

Проверка: никаких NaN в train-отрезке? Значит shift ок.

Валидируем фичи с look-ahead — пошагово

  1. Собираем фичи в одной функции (чтобы гарантировать последовательность).

  2. Оборачиваем в FunctionTransformer — тогда pipeline позаботится о правильном расчёте внутри каждого фолда.

  3. Пишем unit-тест: берём последний train-индекс и проверяем, что все даты фичей ≤ последней даты тренировки (ручной assert спасёт от ночных релизов-убийц).

import pandas as pd
from sklearn.preprocessing import FunctionTransformer
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestRegressor

def make_features(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    # безопасный лаг
    out['lag_1'] = out['target'].shift(1)
    # rolling mean без утечки
    out['roll_mean_7'] = out['target'].shift(1).rolling(7).mean()
    return out.drop(columns=['target'])  # признаки отдельно

feature_maker = FunctionTransformer(make_features, validate=False)

pipe = Pipeline([
    ('feat', feature_maker),
    ('model', RandomForestRegressor(n_estimators=300, random_state=0))
])

Шаблон pipeline для time-series CV

Напишем минималистичный, но готовй скелет. Он:

  • шифует даты в индекс,

  • строит фичи без утечек,

  • использует TimeSeriesSplit (можно подменить на любой наш),

  • считает метрики на кросс-валидации и не трогает тест до финала.

import pandas as pd
from sklearn.metrics import mean_absolute_percentage_error as mape
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score, TimeSeriesSplit

NUM_COLS = ['lag_1', 'roll_mean_7', 'promo']   # пример
CAT_COLS = ['store_id', 'item_id']

num_pipe = Pipeline([
    ('scaler', StandardScaler(with_mean=False))  # with_mean=False safe для спарс
])

preprocessor = ColumnTransformer([
    ('num', num_pipe, NUM_COLS),
    ('cat', 'passthrough', CAT_COLS)
])

full_pipe = Pipeline([
    ('features', feature_maker),
    ('prep',      preprocessor),
    ('model',     GradientBoostingRegressor(random_state=0))
])

tscv = TimeSeriesSplit(n_splits=5, test_size=90, gap=7)

cv_scores = cross_val_score(
    full_pipe,
    X=df,                # df с target внутри
    y=df['target'],
    cv=tscv,
    scoring='neg_mean_absolute_percentage_error',
    n_jobs=-1
)

print('CV MAPE:', -cv_scores.mean())

gap=7 создаёт санитарную зону: модель не видит предыдущую неделю теста, где возможны лаги.

ColumnTransformer — дружит с pandas и не даёт случайно притащить категорию в нормалайзер.

StandardScaler внутри CV (!) — статистики считаются только на train-части каждого фолда, боремся с look-ahead bias, о котором любят напоминать олды на CrossValidated.

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


Если вы хотите глубже разобраться в том, как правильно подготавливать данные для машинного обучения и как эффективно применять мощные методы, такие как Random Forest, приглашаем вас на два ближайших открытых урока:

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

Следите за полным расписанием открытых уроков в нашем календаре — там вы найдете еще много полезных мероприятий по Data Science и смежным направлениям. А чтобы системно прокачать навыки, ознакомьтесь с нашим каталогом курсов по Data Science — выберите программу, которая подойдет именно вам.

Теги:
Хабы:
+8
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS