ML-подход: лаги, окна и календарные признаки
Превращаем временной ряд в таблицу признаков и решаем прогноз как обычную задачу регрессии для бустинга.
Лаговый признак — значение ряда k шагов назад (x_{t-k}); набор лагов и окон превращает ряд в таблицу, понятную обычным ML-моделям.
Зачем ML для рядов
Классические модели (ARIMA) описывают один ряд, но плохо учитывают внешние факторы и нелинейности. ML-подход переформулирует задачу: «по признакам момента t предскажи x_t». Признаки строят из прошлого ряда (лаги, скользящие статистики) и из календаря (день недели, месяц, праздник). Дальше работает любой регрессор — особенно хорош градиентный бустинг (XGBoost, LightGBM, CatBoost), сильный на табличных данных.
Где ML обыгрывает классику, видно на реальных задачах ритейла. Спрос на товар зависит не только от собственной истории продаж, но и от цены, промо-акции конкурента, погоды, дня выплаты зарплат, школьных каникул. ARIMA умеет работать в основном с самим рядом, а вот бустинг легко принимает все эти факторы как дополнительные колонки таблицы и сам находит, какие из них важны. Более того, ML-постановка естественно масштабируется на тысячи рядов сразу: вместо того чтобы подбирать отдельную ARIMA под каждый из 5000 SKU, вы строите одну общую модель на объединённой таблице признаков, и она учится на похожих товарах вместе. Для крупного каталога это разница между «невозможно сопровождать» и «один обучаемый пайплайн».
Есть и обратная сторона, о которой честно стоит сказать заранее: ML-подход перекладывает работу с подбора модели на инженерию признаков. ARIMA из коробки умеет тренд и сезонность, а дереву их надо подать «на блюдечке» в виде лагов, скользящих средних и календарных колонок. Качество ML-прогноза почти целиком определяется тем, насколько вдумчиво собраны признаки, — и именно этому посвящён остаток урока.
Строим лаговые и оконные признаки
series = [5, 7, 6, 8, 10, 9]
rows = []
for i in range(2, len(series)):
rows.append({
"t": i,
"lag1": series[i-1],
"lag2": series[i-2],
"roll2": round((series[i-1] + series[i-2]) / 2, 1), # среднее 2 прошлых
"target": series[i],
})
for r in rows:
print(r)
Вывод:
{'t': 2, 'lag1': 7, 'lag2': 5, 'roll2': 6.0, 'target': 6}
{'t': 3, 'lag1': 6, 'lag2': 7, 'roll2': 6.5, 'target': 8}
{'t': 4, 'lag1': 8, 'lag2': 6, 'roll2': 7.0, 'target': 10}
{'t': 5, 'lag1': 10, 'lag2': 8, 'roll2': 9.0, 'target': 9}
Ряд стал обычной таблицей: признаки в колонках, целевое значение — в target. Заметьте: все признаки строки t используют только точки до t. Это и есть защита от утечки на уровне инженерии признаков.
Разберём, что несёт каждый тип признака. Лаги (lag1, lag2) — это «память» ряда: они дают модели вчерашнее и позавчерашнее значение, чтобы она опиралась на недавнюю динамику. Скользящее окно (roll2) сглаживает шум: вместо одной случайной вчерашней точки модель видит усреднённый уровень последних дней, и это устойчивее к разовым выбросам. На практике лагов берут больше — например, lag1 для вчера, lag7 для того же дня неделю назад (недельная сезонность), lag365 для прошлогоднего сезона, — и несколько окон разной ширины, чтобы поймать и быстрые, и медленные движения. Каждый такой признак — это явная подсказка модели о структуре времени, которую дерево само из голого индекса извлечь не может.
Критично, какой именно сдвиг считается допустимым. Признак строки t имеет право использовать только series[i-1] и дальше в прошлое; стоит случайно написать series[i] (текущее значение) или центрированное окно, захватывающее series[i+1], — и модель получит ответ заранее. На обучении это даёт фантастические метрики, а в проде, где будущего нет, признак просто нечем заполнить. Поэтому правило из предыдущего урока — «только прошлое относительно t» — здесь становится конкретным требованием к индексам в коде.
Календарные признаки
Спрос зависит от календаря: будни/выходные, месяц, праздники, день зарплаты. Эти признаки дают модели «контекст времени», который из самого ряда не извлечь.
import datetime
dates = [datetime.date(2024, 12, d) for d in (28, 29, 30, 31)]
for dt in dates:
print(dt.isoformat(),
"weekday=", dt.weekday(), # 0=Пн .. 6=Вс
"is_weekend=", dt.weekday() >= 5,
"month=", dt.month)
Вывод:
2024-12-28 weekday= 5 is_weekend= True month= 12 2024-12-29 weekday= 6 is_weekend= True month= 12 2024-12-30 weekday= 0 is_weekend= False month= 12 2024-12-31 weekday= 1 is_weekend= False month= 12
28 и 29 декабря — выходные (weekday 5 и 6). Модель, получив is_weekend, сможет учесть субботний всплеск продаж, не выводя его из самого ряда. Календарные фичи — самый дешёвый способ поднять качество прогноза спроса.
Почему календарь так важен, легко понять на спросе. Продажи в супермаркете в субботу систематически выше, чем во вторник; декабрь с предновогодним ажиотажем не похож ни на один другой месяц; в день зарплаты и сразу после него люди тратят больше. Всё это — устойчивые повторяющиеся паттерны, но из одной только истории продаж модель вытаскивает их с трудом: ей пришлось бы «догадаться» о недельном цикле через лаги. Дать готовый флаг is_weekend или номер месяца куда дешевле и надёжнее. Отдельная тема — праздники: они не привязаны к фиксированному дню недели и сдвигают спрос на дни вперёд (закупаются заранее), поэтому к календарю обычно добавляют бинарные признаки «праздник», «канун праздника» и «дней до ближайшего праздника». Циклические величины вроде дня недели и месяца, кстати, часто кодируют синусом и косинусом, чтобы декабрь и январь оказались «соседями», а не далёкими числами 12 и 1.
Как работает под капотом
Градиентный бустинг строит ансамбль деревьев, каждое следующее исправляет ошибки предыдущих. Деревья ловят нелинейности и взаимодействия признаков (например, «выходной И конец месяца»), что ARIMA не умеет. Но у подхода есть слабость: дерево не экстраполирует за пределы виденных значений. Поэтому при сильном тренде ряд сначала дифференцируют или прогнозируют приращения, а не уровень.
Слабость с экстраполяцией стоит прочувствовать наглядно. Дерево решений делит пространство признаков на прямоугольные области и в каждой выдаёт константу — среднее по обучающим точкам этой области. Если в обучении продажи никогда не превышали 1000 единиц, то и предсказать больше 1000 дерево физически не может: для значений за пределами виденного диапазона оно просто отдаёт ближайшую известную константу. Для растущего бизнеса это катастрофа: модель будет вечно «упираться в потолок» вчерашних максимумов и занижать прогноз ровно тогда, когда компания растёт. Лекарство — убрать тренд из таргета: прогнозировать не сам уровень, а его приращение (разность с прошлым шагом) или относительный темп роста. Приращения колеблются вокруг нуля и остаются в знакомом диапазоне, дерево с ними справляется, а уровень потом восстанавливают кумулятивным сложением.
Полезно понимать и силу метода — взаимодействия. ARIMA складывает вклады факторов линейно, а дерево естественно выражает условия вида «если выходной И конец месяца И была промо-акция, то всплеск особенно сильный». Такие совместные эффекты в рознице встречаются постоянно, и именно на них бустинг обыгрывает классические модели. Цена этой гибкости — потребность в большом и честно собранном наборе признаков и аккуратная защита от утечки, о которой шла речь выше.
Частые ошибки
- Включить в признаки точку из будущего (например, lag0 или скользящее с центрированием) — утечка.
- Ждать от деревьев экстраполяции тренда — они не выходят за диапазон обучения.
- Забыть календарные и событийные признаки — модель не увидит праздничных всплесков.
- Прогнозировать уровень растущего ряда напрямую — модель упрётся в потолок прошлых максимумов; прогнозируйте приращения.
- Кодировать день недели и месяц как обычные числа, теряя их цикличность вместо синус-косинус кодирования.
Итоги
- ML-подход превращает ряд в таблицу лаговых, оконных и календарных признаков.
- Градиентный бустинг ловит нелинейности и взаимодействия, но не экстраполирует тренд.
- Все признаки строятся только из прошлого относительно момента прогноза.
- При сильном тренде прогнозируйте приращения, а не уровень, иначе дерево упрётся в потолок.
- Одна общая ML-модель на тысячи рядов сопровождается проще, чем тысяча отдельных ARIMA.