Правильная валидация без утечки из будущего
Учимся честно проверять модель: тест всегда после обучения, никакого перемешивания и заглядывания вперёд.
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 даёт устойчивую оценку, одна тестовая выборка — лотерею.