Спрогнозировать дневные продажи на 90 дней вперёд
Берём датасет нашего SQL-тренажёра sql.new-lvl.pro — синтетический e-com маркетплейс за 2023–2024. Суммы в условных единицах (датасет генерируемый), но паттерны реалистичные: тренд, недельная и годовая сезонность, Black Friday-всплеск, дни-нули в январе.
Цель — прогноз дневной выручки SUM(amount) по оплаченным заказам на последние 92 дня 2024 года. Train: январь 2023 — сентябрь 2024 (635 дней). Test: октябрь — декабрь 2024 (92 дня).
Что измеряем: MAE (в условных единицах), MAPE, sMAPE, WAPE в процентах. Стек: Python 3, pmdarima, prophet, lightgbm, statsmodels.
SQL для дневных продаж — копируй в SQL-тренажёр
SELECT DATE(order_date) AS day, SUM(amount) AS sales FROM sandbox.orders WHERE status = 'completed' GROUP BY 1 ORDER BY 1;
4 паттерна, которые увидит любая модель
Прежде чем обучать что-либо, смотрим на ряд глазами. Здесь — четыре наблюдения, которые повлияют на выбор модели:
- Восходящий тренд ~15% YoY — растущий маркетплейс, классика. Модель без тренда (типа SARIMA без интеграции) будет систематически недооценивать.
- Недельная сезонность: чёткие пики в субботу, провалы в среду. Любой бейзлайн без периода 7 окажется хуже, чем «повторим прошлую неделю».
- Годовая сезонность: ноябрь–декабрь × 1.5 относительно среднего, январь × 0.7. Это уже потребует модели, которая учитывает год — Holt-Winters с одной сезонностью её не схватит.
- Black Friday: 3 дня в конце ноября — отдельная аномалия с амплитудой выше «обычного» декабрьского пика. Это holidays, а не сезонность — нужно подавать явно.
Если хочется глубже про stationarity / ACF / STL-декомпозицию — это уже разобрано в нашем гайде по прогнозированию. Здесь идём сразу к моделям.
То, что должно бить любую ML-модель
Когда я делаю прогноз продаж, первым делом ставлю три бейзлайна. Не для красоты — для контракта: «если ML не бьёт лучший бейзлайн на 15%+, его незачем поддерживать в проде». Сложная модель должна окупить не только свою точность, но и стоимость обслуживания.
Naive: повторяем последнее значение
Модель из одной формулы. Прогноз на любой будущий день — последнее значение в трейне.
Seasonal naive: повторяем прошлую неделю
Учитывает недельный паттерн — пик в субботу, провал в среду.
Holt-Winters: уровень + тренд + сезонность
Линейная модель с экспоненциальным сглаживанием. Помещается в 5 строк через statsmodels:
from statsmodels.tsa.holtwinters import ExponentialSmoothing m = ExponentialSmoothing( train, trend="add", seasonal="add", seasonal_periods=7, ).fit() forecast = m.forecast(92)
Прогоняем на тестовом квартале (Oct–Dec 2024), считаем MAE:
Это уже планка. Любая «настоящая» модель должна бить 280 021 ощутимо — иначе её просто не стоит тащить в прод.
Бейзлайн бьёт половину ML-решений
Когда я первый раз делал прогноз продаж в новой системе, я три дня тюнил Prophet — а seasonal naive получился всего на 5% хуже. Бизнесу было всё равно: интересна не точность, а модель, которая работает в проде год и не падает. Запомни цифру MAE Holt-Winters перед тем, как смотреть на сложные модели. Если ML не бьёт её на 15%+ — это не модель, это академическое упражнение. Поддерживать y_t = y_{t-7} можно SQL-запросом из 5 строк, без отдельного пайплайна.
ARIMA: классика, но не всегда тупик
Три буквы — это три механики. AR (autoregressive): сегодняшнее значение зависит от вчерашнего. I (integrated): один раз дифференцируем ряд, чтобы убрать тренд. MA (moving average): зависим от ошибок прошлых прогнозов. Подробно про ACF/PACF и подбор параметров — в гайде.
Подводный камень. ARIMA без сезонности на дневных продажах — это билет в один конец. Нужна SARIMA с периодом m=7. Без неё модель «не видит» субботу и среду как разные дни и расстраивает всех, кто на неё надеется.
Все параметры подбираются автоматически
import pmdarima as pm arima = pm.auto_arima( train, seasonal=True, m=7, suppress_warnings=True, stepwise=True, ) point, conf = arima.predict( n_periods=92, return_conf_int=True, alpha=0.20, )
На нашем тестовом периоде:
Что хорошо
- Интервалы из коробки — через
return_conf_int=True. Не нужно тренировать второй модели. - Интерпретируемые коэффициенты — можно объяснить продакту, что именно «думает» модель.
- Без ML-зоопарка — один пакет, один вызов, никаких feature pipelines.
Что плохо
- Не любит структурные сдвиги. Black Friday для ARIMA — статистический выброс, который она «размазывает», а не моделирует.
- На длинных рядах работает медленно. На наших 635 точках
auto_arimaдумает 30–60 секунд. - Нельзя добавить внешние фичи без расширения SARIMAX. Если у тебя есть данные о маркетинговых расходах — придётся менять модель.
Prophet: на чём он реально хорош
Prophet от Facebook (теперь Meta) разбирает ряд на три понятных компонента:
Важный момент. Holidays в Prophet — это твой DataFrame, а не «магия из коробки». Для маркетплейса критично явно подать Black Friday и Новый год — иначе они улетят в шум.
Custom holidays + 80% interval
from prophet import Prophet holidays = pd.DataFrame([ {"holiday": "black_friday", "ds": "2023-11-24", "lower_window": -1, "upper_window": 2}, {"holiday": "new_year", "ds": "2024-01-01", "lower_window": -2, "upper_window": 5}, # ... повторить для всех годов в обучении ]) m = Prophet( weekly_seasonality=True, yearly_seasonality=True, holidays=holidays, interval_width=0.80, ) m.fit(train.rename(columns={"day":"ds", "sales":"y"})) forecast = m.predict(future)
Что хорошо
- Holidays — родной формат, после ручного DataFrame ты получаешь честное моделирование праздников, а не статистический выброс.
- Интервалы из коробки через
interval_width, причём калибровка обычно адекватная. - Лёгкая поддержка. 3 строки тюнинга вместо 30 — это окупается на горизонте полугода больше, чем выигрыш в точности.
Что плохо
- Внешние регрессоры — натянуто. Можно через
add_regressor, но удовольствия меньше, чем в LightGBM. - Тренд по умолчанию слишком уверенно «улетает» на длинном горизонте. Лечится через
changepoint_prior_scale. - Установка иногда ломается — нужен
cmdstanpyилиpystan. На M1/M2 чаще встречается, чем хотелось бы.
LightGBM на лагах: когда деревья думают про время
ARIMA и Prophet видят время как ось — у них есть встроенная связь «вчера → сегодня». LightGBM ничего про время не знает: для него каждая строка независима. Чтобы он начал понимать ряд — мы конструируем фичи, которые превращают временной ряд в обычную таблицу.
Главная инвестиция: feature pipeline
def make_features(df): # лаги: вчера, неделю назад, две недели, месяц for lag in [1, 7, 14, 28]: df[f"lag_{lag}"] = df["sales"].shift(lag) # скользящие средние и std for w in [7, 28]: df[f"roll_mean_{w}"] = df["sales"].shift(1).rolling(w).mean() df[f"roll_std_{w}"] = df["sales"].shift(1).rolling(w).std() # календарные df["dayofweek"] = df["day"].dt.dayofweek df["month"] = df["day"].dt.month df["is_weekend"] = (df["dayofweek"] >= 5).astype(int) df["is_holiday"] = df["day"].isin(HOLIDAYS).astype(int) return df
Time-aware валидация — обязательно. Обычный cross_val_score запрещён: он перемешает дни, и в тренировку утечёт «будущее». Используем TimeSeriesSplit с 5 фолдами:
from sklearn.model_selection import TimeSeriesSplit import lightgbm as lgb cv = TimeSeriesSplit(n_splits=5) model = lgb.LGBMRegressor( objective="regression", n_estimators=300, learning_rate=0.05, num_leaves=31, ) # ... fit на train + предикт recursive по 92 дням теста
Главный подводный камень — recursive prediction. Когда мы прогнозируем на 90 дней вперёд, лагов y[t-1], y[t-7] в будущем нет. Модель использует свой собственный прошлый прогноз вместо реального значения — и ошибка накапливается. К концу горизонта она заметно хуже, чем в начале. Это видно на графике ниже.
Что хорошо
- Любая внешняя фича — без боли. Цена, остатки, маркетинг-расходы, погода — просто столбец в таблице.
- Ловит нелинейные взаимодействия. Например, «выходной + конец месяца» — деревья решат это лучше любого линейного метода.
- Быстро учится. 5 секунд на наших данных — на порядок быстрее
auto_arima.
Что плохо
- Интервалы — отдельный квест. Учим две дополнительные модели с
objective="quantile", alpha=0.10 и 0.90. Подробнее — в разделе про коридор. - Recursive forecast накапливает ошибку. Чем длиннее горизонт, тем заметнее. На 90 днях это уже минус несколько процентов точности.
- Тяжело поддерживать. Десятки фичей × смены paipeline'ов в проде = инвестиции в инфраструктуру.
Все три модели на одной картинке
Видно три вещи: (1) ARIMA не угадывает Black Friday — там оранжевая полоса, и видно, что teal-линия её игнорирует. (2) Prophet (purple) ближе всех к актуалу. (3) LightGBM (amber) держится в начале теста, но к концу декабря ошибается заметно сильнее — recursive prediction в действии.
Почему нельзя смотреть только на MAPE
У нас три кандидата на «метрику качества». Все три — про разницу между прогнозом и фактом, но ведут себя по-разному в плохую погоду.
MAE — средняя ошибка в деньгах
MAPE — средняя процентная ошибка
sMAPE — симметричная процентная ошибка
Что произошло на наших данных. В тестовом периоде Oct–Dec 2024 нашлось 2 дня с нулевыми продажами (12 ноября и 24 декабря — праздничные сбои в датасете). На этих днях:
# 12 ноября 2024: actual = 0, prophet прогнозирует 755 794 # MAPE для одного этого дня: |0 − 755794| / 0 = ∞ # Среднее по всему тесту → MAPE = inf для всех моделей
У нас MAPE = ∞ для всех моделей — даже для лучшей. Это не «модели плохие», это метрика сломалась. Надо смотреть на sMAPE и WAPE — они дают сравнимые цифры:
| MAE | MAPE | sMAPE | WAPE | |
|---|---|---|---|---|
| seasonal naive | 291 080 | ∞ | 35.6% | 31.3% |
| Holt-Winters | 280 021 | ∞ | 35.1% | 30.1% |
| ARIMA | 222 779 | ∞ | 25.0% | 24.0% |
| Prophet | 204 543 | ∞ | 24.4% | 22.0% |
| LightGBM | 234 174 | ∞ | 28.7% | 25.2% |
WAPE (weighted absolute percentage error, Σ|y−ŷ| / Σ|y|) — мой любимец на маркетплейсе. Он не делит каждый день отдельно, а нормирует общую ошибку на общий объём. На нулевых днях не ломается, и его легко объяснить продакту.
MAPE врёт на маркетплейсе
MAPE ломается там, где есть малые или нулевые значения — а в маркетплейсе они есть всегда (праздники, тех-сбои, открытие новой категории). Один день с продажами 50 рублей и прогнозом 1000 — и метрика говорит «1900% ошибка», хотя в деньгах это 950 рублей. На отчёте продакту получается катастрофа из ничего. Я давно перешёл на MAE (для бизнеса — «ошибка в деньгах») + WAPE (для слежения за качеством модели от месяца к месяцу — стабильнее MAPE). MAPE оставляю только когда заранее уверен, что нулей не будет.
Прогноз — это коридор, а не точка
Точечный прогноз почти всегда неверен. Нет в этом ничего плохого — просто детерминистичный прогноз отвечает на неправильный вопрос. Бизнесу нужнее prediction interval: «с вероятностью 80% реальные продажи будут в [X; Y]». Это позволяет управлять риском, а не «ошибкой».
Как получить интервал у каждой модели
- ARIMA: из коробки —
predict(return_conf_int=True, alpha=0.20)возвращает 80%-коридор за один вызов. - Prophet: из коробки —
Prophet(interval_width=0.80)на этапе создания модели. - LightGBM: отдельный квест — обучаем 2 дополнительные модели с
objective="quantile".
Quantile prediction для LightGBM — 6 строк
import lightgbm as lgb q10 = lgb.LGBMRegressor(objective="quantile", alpha=0.10).fit(X, y) q90 = lgb.LGBMRegressor(objective="quantile", alpha=0.90).fit(X, y) lower = q10.predict(X_test) upper = q90.predict(X_test) # Между ними — 80%-коридор
Калибровка интервала — отдельная история. 80%-коридор должен реально содержать факт около 80% дней. Если содержит только 60% — модель не калибрована, и нужно либо увеличивать interval_width, либо переобучать. Самый честный способ проверить — посчитать pinball loss на тестовом периоде.
На графике видно: у ARIMA коридор уже всех остальных — модель проще и менее уверена в самой себе на структурных пиках. У LightGBM коридор шире, но точечный прогноз ближе к актуалу в середине теста.
Не бывает одного прогноза — прогноз это коридор
Бизнесу часто нужнее «верхняя граница для закупки» (чтобы было что продать) и «нижняя для планирования кэша» (чтобы не разориться), чем средняя точка. Сразу отдавай продакту три цифры: q10 / q50 / q90 — это сильно меняет разговор. Точечный прогноз провоцирует обсуждение «модель ошиблась». Коридор — обсуждение «риск-менеджмент». Один и тот же прогноз, но в первом случае ты «виноват», во втором — у тебя инструмент управления.
Когда что брать
Сводим всё в одну таблицу. Числовые ячейки — реальные результаты на нашем тесте Oct–Dec 2024.
| ARIMA | Prophet | LightGBM | |
|---|---|---|---|
| MAE на тесте | 222 779 | 204 543 | 234 174 |
| sMAPE | 25.0% | 24.4% | 28.7% |
| WAPE | 24.0% | 22.0% | 25.2% |
| Интервалы из коробки | ✓ | ✓ | ⚠ через quantile |
| Внешние фичи | ✗ | ⚠ regressors | ✓ |
| Структурные сдвиги (BF) | ✗ | ⚠ через holidays | ✓ |
| Сложность поддержки | низкая | низкая | высокая |
| Скорость обучения | медленно (~60s) | быстро (~10s) | быстро (~5s) |
| Recursive ошибка | нет | нет | есть |
На наших данных Prophet выиграл по всем метрикам: MAE 204 543, sMAPE 24.4%, WAPE 22.0%. LightGBM проиграл Prophet на 13% — recursive prediction накапливает ошибку, и на 90-дневном горизонте это заметно.
Когда брать ARIMA
Короткий ряд (<1 года), интерпретируемость важнее точности, нет внешних фичей. Это редкий, но реальный кейс — например, новый продукт с историей в полгода и нужно объяснить директору, что именно «думает» модель.
Когда брать Prophet
Есть holidays, история >1 года, нужны интервалы из коробки, нет ML-инженера в команде. По моему опыту — самый частый победитель в проде на ритейл-задачах. Прост, понятен, не требует feature engineering. На наших данных это сработало напрямую.
Когда брать LightGBM
Много внешних фичей (цена, остатки, маркетинг-расходы, погода, конкурентные цены), длинная история, есть кому поддерживать пайплайн. На задачах классификации и регрессии LightGBM почти всегда лучший — но прогноз ряда — это про recursive forecast, и тут он чаще проигрывает, чем выигрывает.
Если бы LightGBM выиграл на 5–10% MAE — я бы всё равно поставил Prophet в прод. Поддержка одной строки Prophet(...).fit(...) стоит дешевле, чем пайплайн на 30 фичей — и эта разница накапливается за полгода больше, чем выигрыш в точности. ML-модель должна окупить не только свою точность, но и стоимость своего поддержания. На наших данных Prophet оказался лучшим даже без этой поправки — отличный сюрприз для прода.
Частые вопросы
Можно ли смешивать модели в ансамбль?
(prophet + lightgbm) / 2 часто даёт +3–5% к лучшей одиночной модели — почти бесплатно. Stacking сложнее (нужна meta-модель), и в проде это чаще overkill: тяжелее поддерживать. Если время есть, начни с простого среднего и сравни с лучшей одиночной моделью.Что делать, если ряд очень короткий — 3 месяца?
Как часто переобучать модель в проде?
Где взять данные на свой проект?
orders / order_items за 2 года с реалистичными паттернами (сезонность, Black Friday, retention decay). Можно сразу прогнать SQL из этой статьи и применить любую модель из примеров.