Привет, Хабр!
Сегодня рассмотрим то, что чаще всего ломает даже круто выглядящие модели при работе с временными рядами — неправильная кросс-валидация. Разберем, почему 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 и от этого сложнее оценивать разброс метрик.
Когда какой сплиттер брать
Ситуация | Лучший выбор |
---|---|
Стабильный, длинный ряд без резких режим-шабов |
|
Нужно аккумулировать всю историю, метрики по одинаковым горизонтам |
|
Есть подозрение на drifts/structural breaks, важна свежесть данных |
|
В серию вмешиваются лаговые фичи → риск утечки | любой из трёх с |
Итог: выбирая сплиттер, задавай себе два вопроса:
Насколько далеко моему бизнесу важно помнить прошлое?
Как часто поведение ряда меняется радикально?
Агрегаты и лаги: где прячется 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 — пошагово
Собираем фичи в одной функции (чтобы гарантировать последовательность).
Оборачиваем в
FunctionTransformer
— тогда pipeline позаботится о правильном расчёте внутри каждого фолда.Пишем 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, приглашаем вас на два ближайших открытых урока:
«Как правильно готовить данные для ML‑моделей?» — 3 июля в 18:00
«Random Forest — мощный метод ансамблирования в ML» — 16 июля в 18:00
Эти занятия помогут вам избежать классических ошибок при работе с данными и научат строить надежные модели, что особенно важно при анализе временных рядов.
Следите за полным расписанием открытых уроков в нашем календаре — там вы найдете еще много полезных мероприятий по Data Science и смежным направлениям. А чтобы системно прокачать навыки, ознакомьтесь с нашим каталогом курсов по Data Science — выберите программу, которая подойдет именно вам.