Правильная валидация без утечки из будущего

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

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

Зачем особая валидация

Случайное train/test-разбиение, привычное в ML, для рядов смертельно: оно кладёт будущие точки в обучение. Модель «подсматривает» ответы и показывает блестящие метрики, которые на проде рассыпаются. Правильная валидация уважает стрелу времени: тест строго после обучения. Это единственный способ оценить, как модель поведёт себя на настоящем будущем.

Цена ошибки тут не теоретическая. Команда выкатывает модель прогноза продаж, на валидации MAPE 4%, все довольны, закупки планируют с опорой на прогноз. Через месяц на проде ошибка оказывается 18%, склады то пустые, то затоварены, и доверие к аналитике подорвано на полгода вперёд. Почти всегда причина — утечка из будущего на этапе валидации: метрика на бумаге была честной арифметически, но нечестной по времени. Backtest существует именно для того, чтобы провал случился на ваших данных в спокойной обстановке, а не на реальных деньгах под отчёт руководству.

Полезно держать в голове простой образ: валидация должна воспроизводить ровно ту ситуацию, в которой модель окажется в проде. В проде она стоит в моменте «сегодня», знает всю прошлую историю и ничего не знает про завтра. Любая процедура проверки, которая даёт модели даже крупицу информации из будущего, завышает оценку и обманывает вас. Поэтому валидация рядов — это не вариант «как удобнее разбить данные», а имитация реальной эксплуатации.

Expanding window backtest

Идея: обучаемся на всех точках до момента i, прогнозируем i, фиксируем ошибку, сдвигаем i вперёд. Обучающее окно расширяется. Реализуем с наивным прогнозом «как вчера».

data = [10, 12, 11, 13, 15, 14, 16, 18, 17, 19]

def naive(hist):
    return hist[-1]            # прогноз = последнее известное

errors = []
for i in range(3, len(data)):
    hist = data[:i]           # только прошлое!
    pred = naive(hist)
    actual = data[i]
    errors.append(abs(actual - pred))
    print(f"train[:{i}] pred={pred} actual={actual} err={abs(actual-pred)}")

print("MAE по бэктесту:", round(sum(errors)/len(errors), 3))

Вывод:

train[:3] pred=11 actual=13 err=2
train[:4] pred=13 actual=15 err=2
train[:5] pred=15 actual=14 err=1
train[:6] pred=14 actual=16 err=2
train[:7] pred=16 actual=18 err=2
train[:8] pred=18 actual=17 err=1
train[:9] pred=17 actual=19 err=2
MAE по бэктесту: 1.714

Ключевая строка — hist = data[:i]: модель видит только прошлое. Каждая ошибка получена честно, как если бы мы стояли в моменте i и не знали будущего. Усреднение даёт реалистичную оценку MAE.

Обратите внимание на устройство цикла: на каждой итерации мы как будто заново «переживаем» один день истории. Срез data[:i] — это снимок всего, что было известно к моменту i, и ни одной точки из будущего в нём нет физически, потому что срез заканчивается на индексе i-1. Прогноз делается, ошибка abs(actual - pred) сразу записывается, и только потом мы позволяем себе «узнать» фактическое data[i]. Семь ошибок, собранных так, — это семь независимых честных проверок на разных участках ряда, и их среднее куда надёжнее, чем одна оценка на единственном тестовом куске. Чем больше таких шагов, тем устойчивее цифра и тем меньше шансов, что вам просто повезло или не повезло с конкретным хвостом данных.

Rolling против expanding

СхемаОбучающее окноКогда
expandingрастёт от началастабильный процесс, данных мало
rollingфиксированной длины, скользитпроцесс меняется, важна свежесть

Expanding копит всю историю — хорош, когда закономерности устойчивы. Rolling держит окно фиксированной длины — лучше, когда процесс эволюционирует и старые данные вредят.

Выбор между схемами — это, по сути, ставка на то, насколько прошлое похоже на будущее. Если вы прогнозируете спрос на стабильный продукт, поведение которого годами не менялось, expanding-окно даёт модели максимум данных и хорошо. Но если в позапрошлом году компания работала в другом ценовом сегменте, поменяла ассортимент или пережила структурный сдвиг рынка, то старые наблюдения уже не описывают сегодняшний процесс — они тянут модель в прошлое. Тогда rolling-окно, которое «забывает» древнюю историю и держит только последние N точек, отражает свежую реальность точнее. На практике полезно прогнать backtest обеими схемами: если rolling заметно лучше, это сигнал, что процесс нестационарен и старые данные действительно мешают.

Как работает под капотом

Любая фича, посчитанная с заглядыванием в будущее, — это утечка. Классические ловушки: глобальная нормализация (среднее по всему ряду, включая тест), заполнение пропусков будущими значениями, целевое кодирование на всей выборке. Правило одно: на момент прогноза t используйте только то, что было известно до t. Backtest именно это и проверяет, прогоняя модель «вслепую» по истории.

Коварство утечки в том, что она почти всегда выглядит безобидной операцией предобработки. Вы делите весь ряд на его среднее и стандартное отклонение, чтобы привести к одному масштабу, — и тем самым «впечатываете» в каждую обучающую точку информацию о среднем будущих значений. Вы заполняете пропуск интерполяцией между соседями — и значение слева от дырки теперь зависит от значения справа, то есть из будущего. Вы кодируете категорию средним таргетом по всей выборке — и каждое наблюдение тайком узнаёт средний исход, включая тестовый период. Каждый из этих шагов по отдельности кажется рутиной, но все они одинаково ломают честность оценки.

Защита проста по формулировке и требует дисциплины на практике: любую статистику, нормализатор или кодировщик нужно «обучать» только на обучающей части окна и потом применять к тесту, а внутри backtest пересчитывать заново на каждом шаге. Удобный мысленный тест — спросить себя: «Если бы я стоял в этой точке вживую, был бы у меня доступ к этому числу?» Если ответ «нет», значит, это утечка, как бы аккуратно ни выглядел код.

Частые ошибки

  • Случайно перемешать ряд и сделать обычное train/test-разбиение — прямая утечка.
  • Нормировать по статистикам всего ряда (включая тест) — утечка через масштаб.
  • Подбирать гиперпараметры на тех же точках, на которых отчитываются о метрике.
  • Заполнять пропуски интерполяцией между соседями — значение слева начинает зависеть от будущего справа.
  • Оценивать модель на единственном тестовом куске вместо множества шагов backtest — оценка случайна и неустойчива.

Итоги

  • Валидация рядов уважает время: тест строго после обучения, без перемешивания.
  • Expanding-окно копит историю, rolling-окно скользит фиксированной длиной.
  • Любой признак считайте только из прошлого относительно момента прогноза.
  • Backtest воспроизводит реальную эксплуатацию: модель в моменте t знает только то, что было до t.
  • Множество шагов backtest даёт устойчивую оценку, одна тестовая выборка — лотерею.
Проверьте себя
1. Почему нельзя использовать случайное train/test-разбиение для временного ряда?
AОно медленное
BОно кладёт будущие точки в обучение — утечка из будущего
CОно меняет метрику
DТак требует sklearn
2. Чем rolling-окно отличается от expanding-окна в бэктесте?
AНичем
BRolling — фиксированной длины и скользит, expanding — растёт от начала
CRolling заглядывает в будущее
DExpanding перемешивает данные