Код — forecasting/preprocessing.py.
Предобработка получает DemandData (справочники, ассортимент, дневные продажи, календарь)
и ничего не знает о базе данных: тот же код работает и на PostgreSQL, и на CSV.
Работа идёт в два шага:
build_panel()— недельная панель «неделя × база × позиция» с продажами, календарём и справочными признаками целевой недели;build_training_set(panel, h)— выборка для горизонта h: признаки из истории продаж, известные за h недель до целевой, и целевая переменная.
Ряд «база × позиция» существует
- с максимума из трёх дат: первого движения позиции по складу базы, ввода позиции в ассортимент и открытия базы;
- по день перед выводом позиции из ассортимента или до конца данных.
Почему. Если бы панель была полным декартовым произведением 6 баз × 927 позиций × 260 недель, в ней появились бы нули, которые не означают отсутствия спроса: позицию не продавали, потому что её ещё не было на складе, или потому что пункт выдачи ещё не открылся. Такие «структурные нули» искажают обучение — модель учится, что новая позиция не продаётся, — и метрики: прогноз по несуществующему ряду всегда идеально точен. Начало ряда берётся по первому движению на складе, а не по первой продаже: иначе недели до первой продажи, когда товар уже лежал и не продавался, выпали бы из панели, и модель переоценила бы спрос.
Продажи суммируются по календарным неделям с понедельника. В панель входят только полные недели, целиком лежащие внутри календаря: неполная первая или последняя неделя выглядела бы как провал продаж. Почему именно неделя — см. task.md.
| Значение | Когда | Смысл |
|---|---|---|
| 0 | ряд существует, база работала, продаж позиции не было | спрос был нулевым (или товара не было — см. ниже) |
| NaN | на базе за неделю не было ни одной продажи любой позиции | база была закрыта, спрос неизвестен |
Почему. Сезонный минимаркет у садовых товариществ закрыт с декабря по середину марта. Если записать эти недели как нули, модель научится, что зимой спрос на этой базе падает до нуля, и это верно лишь формально: покупатели не перестали нуждаться в товаре, они просто не могли его купить. Хуже того, нули попали бы в лаги и скользящие средние и занизили бы прогноз на первые недели после открытия. Пропуск честнее: строки с NaN не участвуют ни в обучении, ни в оценке, а скользящие средние их пропускают.
Признак закрытой базы — «ни одной продажи за неделю» — выведен из данных, а не взят из справочника, потому что так же определяются и непредвиденные закрытия (инвентаризация, ремонт), которых в справочнике нет.
Ограничение. Ноль по-прежнему смешивает «спроса не было» и «товар закончился». Различить их
можно по остаткам в stock_movements; это следующий шаг — см. metrics.md.
Построение признаков (feature engineering) — функция _features() в preprocessing.py.
Строка выборки — ряд i и целевая неделя T. Целевая переменная — продажи в неделю T.
Все признаки из истории продаж строятся только по неделям не позже T − h, то есть
по тому, что известно на момент прогноза.
Всего 15 признаков в трёх группах и один служебный внутри модели:
| Группа | Признаки | Сколько | Откуда |
|---|---|---|---|
| история продаж ряда | лаги, лаг 52, скользящие средние, доля недель с продажами | 9 | недельная панель, сдвинутая на h |
| календарь целевой недели | week_sin, week_cos, working_days |
3 | производственный календарь |
| справочники | category_id, branch_format, unit |
3 | products, branches |
| служебный | log_scale |
1 | добавляет DemandModel из qty_mean26 |
| Признак | Формула | Зачем |
|---|---|---|
qty_lag{h} … qty_lag{h+3} |
продажи недель T − h, …, T − h − 3 | последние известные значения: уровень и краткосрочная динамика ряда |
qty_lag52 |
продажи недели T − 52 | годовая сезонность конкретного ряда |
qty_mean4, qty_mean13, qty_mean26 |
средние за 4, 13 и 26 известных недель | устойчивые оценки уровня на разных масштабах: месяц, квартал, полгода |
nonzero_share13 |
доля недель с продажами из 13 известных | степень прерывистости ряда |
week_sin, week_cos |
sin и cos от 2π · неделя / 52,18 | положение недели в году |
working_days |
число рабочих дней в целевой неделе по производственному календарю | праздники: новогодние, майские |
category_id |
категория каталога | общие закономерности товаров одной группы |
branch_format |
гипермаркет, супермаркет, минимаркет, пункт выдачи | общие закономерности точек одного формата |
unit |
единица измерения | разные товары продаются разными порциями |
Внутри модели к ним добавляется log_scale = ln(1 + qty_mean26) — см. model.md.
- Лаги и скользящие средние — стандартная основа глобальных моделей для панелей рядов, в том числе у победителей соревнования M5 по прогнозу розничных продаж [3]. Несколько окон нужны потому, что ряды разные: для частых продаж надёжнее короткое окно, для редких — длинное, и модель сама выбирает, на какое опираться.
- Доля недель с продажами — аналог интервала между спросами из классификации прерывистого спроса Синтетоса — Бойлана [6]. Она позволяет модели отличить ряд «каждую неделю понемногу» от ряда «раз в квартал много» при одинаковом среднем.
- Лаг 52 — сезонность конкретного ряда. Глобальная сезонность сети описывается календарными признаками, а лаг 52 добавляет индивидуальную: дачные товары и кровля сезонны сильнее, чем метизы.
- Синус и косинус недели вместо 52 бинарных колонок (one-hot): соседние недели получают близкие значения, а 52-я неделя оказывается рядом с первой. One-hot разбил бы четыре года истории на 52 независимых группы по четыре наблюдения в каждой.
- Рабочие дни — единственный признак целевой недели, который меняется не плавно: неделя с новогодними праздниками и обычная январская неделя различаются в разы.
- Справочные признаки передаются модели как категориальные, а не числовые: номер категории не упорядочен, и «категория 12 больше категории 7» не имеет смысла.
- Широкая таблица. Панель разворачивается в таблицу «недели × ряды»: 260 строк-недель
и 3 347 колонок-рядов. Сетка недель непрерывная (
asfreq('7D')), поэтому сдвиг на одну строку — это ровно одна неделя. Недели вне ряда, закрытые и будущие — NaN. - Сдвиг на горизонт.
known = qty.shift(h): в строке недели T оказываются продажи недели T − h, последней известной. Все остальные признаки истории считаются отknown. - Лаги:
known.shift(k)для k = 0…3 даютqty_lag{h}…qty_lag{h+3}. Лаг 52 берётся от несдвинутой таблицы (qty.shift(52)): при h ≤ 52 он всё равно в прошлом. - Скользящие средние по
knownза 4, 13 и 26 недель сmin_periods=1: пропуски (закрытые недели) пропускаются, среднее считается по известным неделям окна. - Доля недель с продажами — скользящее среднее индикатора «продажи > 0» за 13 недель; закрытые недели остаются пропусками и в долю не входят.
- Обратно в строки. Таблицы признаков сворачиваются в длинный формат и присоединяются к панели по ключу «неделя, база, позиция».
- Календарь считается по целевой неделе: синус и косинус номера недели ISO, число рабочих дней по производственному календарю, за пределами календаря — понедельник — пятница.
- Справочные признаки получают тип
category.DemandModelзапоминает порядок категорий при обучении и восстанавливает его при прогнозе. - Нормировка в модели.
DemandModelделит все признакиqty_*на масштаб рядаqty_mean26и добавляетlog_scale= ln(1 +qty_mean26) — см. model.md.
Почему через широкую таблицу. Сдвиг и скользящее окно по всем рядам сразу — одна векторная операция pandas, а не цикл по 3 347 рядам. И это гарантирует, что сдвиг не перескочит с одного ряда на другой, как бывает при сдвиге длинной таблицы без группировки.
Пропуски в признаках не заполняются, градиентный бустинг обрабатывает их сам:
| Признак | Когда пропуск |
|---|---|
| лаги | неделя закрыта или ряд в ней ещё не существовал |
qty_lag52 |
ряду меньше года — например, у позиций пункта выдачи в первый год |
| средние, доля недель с продажами | в окне нет ни одной известной недели; строки без qty_mean26 отбрасываются |
Горизонт 1, целевая неделя T = 04.05.2026, вес метра — 0,888 кг. Продажи ряда, м:
| Неделя | 30.03 | 06.04 | 13.04 | 20.04 | 27.04 | 04.05 |
|---|---|---|---|---|---|---|
| продажи | 4 083 | 16 298 | 23 342 | 25 295 | 24 769 | 28 724 — целевая |
| роль | — | qty_lag4 |
qty_lag3 |
qty_lag2 |
qty_lag1 |
y |
Признаки строки:
| Признак | Значение | Комментарий |
|---|---|---|
qty_lag1 … qty_lag4 |
24 769; 25 295; 23 342; 16 298 | четыре последние известные недели |
qty_lag52 |
13 408 | неделя 05.05.2025 — та же неделя год назад |
qty_mean4 |
22 426 | среднее четырёх лагов |
qty_mean13, qty_mean26 |
13 943; 12 544 | уровень за квартал и полгода — ниже весеннего |
nonzero_share13 |
1,0 | продажи были все 13 недель |
week_sin, week_cos |
0,754; −0,657 | 19-я неделя года |
working_days |
5 | (на неделе 27.04 их было 4 — 1 мая) |
category_id, branch_format, unit |
2; hypermarket; m | арматура, гипермаркет, метры |
После нормировки qty_lag1 = 24 769 / 12 544 ≈ 1,97: модель видит «прошлая неделя почти вдвое
выше полугодового уровня», а не абсолютное число метров. log_scale = ln(12 545) ≈ 9,44.
Прогноз — 22 086 м (19,6 т), факт — 28 724 м (25,5 т). Ряд в фазе весеннего роста, и модель отстаёт от него: прогноз близок к среднему за 4 недели.
Перестановочная важность (permutation importance): модель обучена без тестового периода, затем на тесте значения одного признака перемешиваются между строками, и измеряется, насколько вырастает WAPE. Пять перемешиваний на признак, горизонт 1, тест 30.03 — 21.09.2026, исходный WAPE 0,847.
| Признак | Рост WAPE, п. п. |
|---|---|
qty_mean26 |
+406 |
category_id |
+8,3 |
week_sin |
+5,2 |
qty_mean4 |
+2,2 |
qty_lag1 |
+0,3 |
qty_lag52 |
+0,3 |
qty_lag2 |
+0,2 |
unit, qty_lag4, working_days, week_cos |
от −0,1 до +0,1 |
nonzero_share13 |
−0,2 |
qty_lag3 |
−0,5 |
branch_format |
−0,5 |
qty_mean13 |
−2,1 |
Как это читать:
qty_mean26— особый случай. Это масштаб, на который модель делит признаки и умножает прогноз. Перемешав его, мы подставляем ряду масштаб чужого ряда, и прогноз ломается механически. Его важность говорит о роли нормировки, а не об информации в признаке.- Категория товара — самый полезный признак. Сезон и реакция на него у кровли, арматуры и метизов разные, и категория говорит модели, какую закономерность применять. Это довод в пользу сезонного профиля по категориям — см. model.md.
- Синус недели важен, косинус — нет. Вероятное объяснение: синус отличает весну (рост продаж) от осени (спад) при одинаковом уровне, а уровень сезона модель и так видит в лагах.
- Лаги по отдельности весят мало, потому что дублируют друг друга: перемешав один, модель берёт ту же информацию из соседних лагов и средних. Перестановочная важность занижает важность групп скоррелированных признаков.
- Отрицательная важность — не вред. Перемешивание скоррелированного признака создаёт
сочетания значений, которых не бывает в данных, и результат может случайно улучшиться.
Проверка удалением: модель переобучалась без
qty_mean13,qty_lag3,nonzero_share13и без различия форматов баз на трёх тестовых окнах — WAPE менялся не больше чем на 0,2 п. п. Эти признаки избыточны, но не вредны; их удаление упростило бы модель без потери качества, и это стоит проверить отдельным шагом.
- Цена и индекс цен на металл. Цена влияет на спрос, но будущая цена на момент прогноза неизвестна, а текущая скоррелирована с сезоном. Это отдельный эксперимент.
- Остаток на складе. Нужен для учёта дефицита, но сам по себе как признак опасен: остаток зависит от решений о закупке, которые принимаются по прогнозу.
- Продажи других рядов (иерархия: категория на базе, позиция по сети). Кандидат на следующий шаг: они помогают редким рядам.
Утечка (leakage) — попадание в признаки информации, недоступной на момент прогноза [9]. С ней модель показывает на тесте отличные метрики, которые не воспроизводятся в работе.
Как она исключена:
- Для недели T все признаки истории считаются по таблице продаж, сдвинутой на h недель
(
known = qty.shift(h)), и уже от неё берутся лаги и средние. Нет ни одного признака, который читал бы несдвинутую таблицу, кромеqty_lag52, а он при h ≤ 52 смотрит в прошлое не меньше чем на h недель. - Календарь и справочники описывают целевую неделю, но известны заранее: производственный календарь публикуется на год вперёд, ассортимент — справочные данные.
- Разбиение на обучение и тест — по времени, а не случайное (см. model.md).
- Отсутствие утечки проверяется тестом: если изменить продажи недели T, признаки строк с целевой неделей раньше T + h не должны измениться (см. testing.md).
Для каждого горизонта строится своя выборка, в которой первый лаг — qty_lag{h}. Это прямая
стратегия многошагового прогноза: модель на 3 недели вперёд обучается предсказывать
сразу неделю T по данным до T − 3. Альтернатива — рекурсивная стратегия: модель на неделю
вперёд применяется три раза подряд, подставляя свои прогнозы вместо факта. Она проще,
но ошибки накапливаются, а модель на шаге 2 и 3 получает на вход прогнозы, а не факт, — то есть
данные из распределения, которого не видела при обучении [7]. При четырёх горизонтах прямая
стратегия стоит всего четыре обучения.
- В обучение и тест входят строки с известными продажами: база работала, неделя не в будущем.
- Строка, у которой в окне 26 недель до прогноза нет ни одной известной недели (ряд только появился или база всё это время была закрыта), отбрасывается: прогнозировать её не по чему.
- Для прогноза будущего панель продлевается на
future_weeksнедель с неизвестными продажами, иbuild_forecast_set()отдаёт строки недели T = последняя известная + h. Признаки строятся тем же кодом, что и при обучении, поэтому расхождение между обучением и применением исключено.
| Горизонт 1 | Строк |
|---|---|
| вся выборка | 773 397 |
| обучение | 653 264 |
| валидация | 37 786 |
| тест | 82 347 (3 175 рядов) |
| доля нулей: обучение / тест | 59 % / 50 % |