Skip to content

Latest commit

 

History

History
265 lines (214 loc) · 24.1 KB

preprocessing.md

File metadata and controls

265 lines (214 loc) · 24.1 KB

Предобработка

Код — forecasting/preprocessing.py. Предобработка получает DemandData (справочники, ассортимент, дневные продажи, календарь) и ничего не знает о базе данных: тот же код работает и на PostgreSQL, и на CSV.

Работа идёт в два шага:

  1. build_panel() — недельная панель «неделя × база × позиция» с продажами, календарём и справочными признаками целевой недели;
  2. build_training_set(panel, h) — выборка для горизонта h: признаки из истории продаж, известные за h недель до целевой, и целевая переменная.

1. Недельная панель

Границы ряда

Ряд «база × позиция» существует

  • с максимума из трёх дат: первого движения позиции по складу базы, ввода позиции в ассортимент и открытия базы;
  • по день перед выводом позиции из ассортимента или до конца данных.

Почему. Если бы панель была полным декартовым произведением 6 баз × 927 позиций × 260 недель, в ней появились бы нули, которые не означают отсутствия спроса: позицию не продавали, потому что её ещё не было на складе, или потому что пункт выдачи ещё не открылся. Такие «структурные нули» искажают обучение — модель учится, что новая позиция не продаётся, — и метрики: прогноз по несуществующему ряду всегда идеально точен. Начало ряда берётся по первому движению на складе, а не по первой продаже: иначе недели до первой продажи, когда товар уже лежал и не продавался, выпали бы из панели, и модель переоценила бы спрос.

Неделя

Продажи суммируются по календарным неделям с понедельника. В панель входят только полные недели, целиком лежащие внутри календаря: неполная первая или последняя неделя выглядела бы как провал продаж. Почему именно неделя — см. task.md.

Ноль и пропуск — разные вещи

Значение Когда Смысл
0 ряд существует, база работала, продаж позиции не было спрос был нулевым (или товара не было — см. ниже)
NaN на базе за неделю не было ни одной продажи любой позиции база была закрыта, спрос неизвестен

Почему. Сезонный минимаркет у садовых товариществ закрыт с декабря по середину марта. Если записать эти недели как нули, модель научится, что зимой спрос на этой базе падает до нуля, и это верно лишь формально: покупатели не перестали нуждаться в товаре, они просто не могли его купить. Хуже того, нули попали бы в лаги и скользящие средние и занизили бы прогноз на первые недели после открытия. Пропуск честнее: строки с NaN не участвуют ни в обучении, ни в оценке, а скользящие средние их пропускают.

Признак закрытой базы — «ни одной продажи за неделю» — выведен из данных, а не взят из справочника, потому что так же определяются и непредвиденные закрытия (инвентаризация, ремонт), которых в справочнике нет.

Ограничение. Ноль по-прежнему смешивает «спроса не было» и «товар закончился». Различить их можно по остаткам в stock_movements; это следующий шаг — см. metrics.md.

2. Признаки

Построение признаков (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» не имеет смысла.

Как строятся

  1. Широкая таблица. Панель разворачивается в таблицу «недели × ряды»: 260 строк-недель и 3 347 колонок-рядов. Сетка недель непрерывная (asfreq('7D')), поэтому сдвиг на одну строку — это ровно одна неделя. Недели вне ряда, закрытые и будущие — NaN.
  2. Сдвиг на горизонт. known = qty.shift(h): в строке недели T оказываются продажи недели T − h, последней известной. Все остальные признаки истории считаются от known.
  3. Лаги: known.shift(k) для k = 0…3 дают qty_lag{h} … qty_lag{h+3}. Лаг 52 берётся от несдвинутой таблицы (qty.shift(52)): при h ≤ 52 он всё равно в прошлом.
  4. Скользящие средние по known за 4, 13 и 26 недель с min_periods=1: пропуски (закрытые недели) пропускаются, среднее считается по известным неделям окна.
  5. Доля недель с продажами — скользящее среднее индикатора «продажи > 0» за 13 недель; закрытые недели остаются пропусками и в долю не входят.
  6. Обратно в строки. Таблицы признаков сворачиваются в длинный формат и присоединяются к панели по ключу «неделя, база, позиция».
  7. Календарь считается по целевой неделе: синус и косинус номера недели ISO, число рабочих дней по производственному календарю, за пределами календаря — понедельник — пятница.
  8. Справочные признаки получают тип category. DemandModel запоминает порядок категорий при обучении и восстанавливает его при прогнозе.
  9. Нормировка в модели. DemandModel делит все признаки qty_* на масштаб ряда qty_mean26 и добавляет log_scale = ln(1 + qty_mean26) — см. model.md.

Почему через широкую таблицу. Сдвиг и скользящее окно по всем рядам сразу — одна векторная операция pandas, а не цикл по 3 347 рядам. И это гарантирует, что сдвиг не перескочит с одного ряда на другой, как бывает при сдвиге длинной таблицы без группировки.

Пропуски в признаках не заполняются, градиентный бустинг обрабатывает их сам:

Признак Когда пропуск
лаги неделя закрыта или ряд в ней ещё не существовал
qty_lag52 ряду меньше года — например, у позиций пункта выдачи в первый год
средние, доля недель с продажами в окне нет ни одной известной недели; строки без qty_mean26 отбрасываются

Пример: арматура 12 мм на Юнгородке

Горизонт 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 п. п. Эти признаки избыточны, но не вредны; их удаление упростило бы модель без потери качества, и это стоит проверить отдельным шагом.

Чего среди признаков нет и почему

  • Цена и индекс цен на металл. Цена влияет на спрос, но будущая цена на момент прогноза неизвестна, а текущая скоррелирована с сезоном. Это отдельный эксперимент.
  • Остаток на складе. Нужен для учёта дефицита, но сам по себе как признак опасен: остаток зависит от решений о закупке, которые принимаются по прогнозу.
  • Продажи других рядов (иерархия: категория на базе, позиция по сети). Кандидат на следующий шаг: они помогают редким рядам.

3. Защита от утечки будущего

Утечка (leakage) — попадание в признаки информации, недоступной на момент прогноза [9]. С ней модель показывает на тесте отличные метрики, которые не воспроизводятся в работе.

Как она исключена:

  1. Для недели T все признаки истории считаются по таблице продаж, сдвинутой на h недель (known = qty.shift(h)), и уже от неё берутся лаги и средние. Нет ни одного признака, который читал бы несдвинутую таблицу, кроме qty_lag52, а он при h ≤ 52 смотрит в прошлое не меньше чем на h недель.
  2. Календарь и справочники описывают целевую неделю, но известны заранее: производственный календарь публикуется на год вперёд, ассортимент — справочные данные.
  3. Разбиение на обучение и тест — по времени, а не случайное (см. model.md).
  4. Отсутствие утечки проверяется тестом: если изменить продажи недели T, признаки строк с целевой неделей раньше T + h не должны измениться (см. testing.md).

Отдельная модель на горизонт

Для каждого горизонта строится своя выборка, в которой первый лаг — qty_lag{h}. Это прямая стратегия многошагового прогноза: модель на 3 недели вперёд обучается предсказывать сразу неделю T по данным до T − 3. Альтернатива — рекурсивная стратегия: модель на неделю вперёд применяется три раза подряд, подставляя свои прогнозы вместо факта. Она проще, но ошибки накапливаются, а модель на шаге 2 и 3 получает на вход прогнозы, а не факт, — то есть данные из распределения, которого не видела при обучении [7]. При четырёх горизонтах прямая стратегия стоит всего четыре обучения.

4. Строки выборки

  • В обучение и тест входят строки с известными продажами: база работала, неделя не в будущем.
  • Строка, у которой в окне 26 недель до прогноза нет ни одной известной недели (ряд только появился или база всё это время была закрыта), отбрасывается: прогнозировать её не по чему.
  • Для прогноза будущего панель продлевается на future_weeks недель с неизвестными продажами, и build_forecast_set() отдаёт строки недели T = последняя известная + h. Признаки строятся тем же кодом, что и при обучении, поэтому расхождение между обучением и применением исключено.
Горизонт 1 Строк
вся выборка 773 397
обучение 653 264
валидация 37 786
тест 82 347 (3 175 рядов)
доля нулей: обучение / тест 59 % / 50 %