new-lvl.pro · Статьи · Forecasting
Статья // 18 мин чтения // code: python

Прогноз продаж в Python:
ARIMA vs Prophet vs LightGBM
на одних данных

Не туториал по одной модели и не сравнение на airline-passengers. Берём 2 года реальных продаж e-com маркетплейса, гоняем три модели по очереди и смотрим, какая выиграла — и почему я бы поставил в прод не победителя.

Спрогнозировать дневные продажи на 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 паттерна, которые увидит любая модель

Апр 23 Окт 23 Апр 24 Окт 24 BF BF
Дневные продажи маркетплейса, янв 2023 — дек 2024. Видны Black Friday-пики (BF) и общий восходящий тренд.

Прежде чем обучать что-либо, смотрим на ряд глазами. Здесь — четыре наблюдения, которые повлияют на выбор модели:

  1. Восходящий тренд ~15% YoY — растущий маркетплейс, классика. Модель без тренда (типа SARIMA без интеграции) будет систематически недооценивать.
  2. Недельная сезонность: чёткие пики в субботу, провалы в среду. Любой бейзлайн без периода 7 окажется хуже, чем «повторим прошлую неделю».
  3. Годовая сезонность: ноябрь–декабрь × 1.5 относительно среднего, январь × 0.7. Это уже потребует модели, которая учитывает год — Holt-Winters с одной сезонностью её не схватит.
  4. Black Friday: 3 дня в конце ноября — отдельная аномалия с амплитудой выше «обычного» декабрьского пика. Это holidays, а не сезонность — нужно подавать явно.

Если хочется глубже про stationarity / ACF / STL-декомпозицию — это уже разобрано в нашем гайде по прогнозированию. Здесь идём сразу к моделям.

То, что должно бить любую ML-модель

Когда я делаю прогноз продаж, первым делом ставлю три бейзлайна. Не для красоты — для контракта: «если ML не бьёт лучший бейзлайн на 15%+, его незачем поддерживать в проде». Сложная модель должна окупить не только свою точность, но и стоимость обслуживания.

Naive: повторяем последнее значение

Модель из одной формулы. Прогноз на любой будущий день — последнее значение в трейне.

yt = yt-1
Не учитывает ничего: ни сезонность, ни тренд. Стенка для самой ленивой модели.

Seasonal naive: повторяем прошлую неделю

Учитывает недельный паттерн — пик в субботу, провал в среду.

yt = yt-7
Период 7 — потому что у нас дневные продажи и недельная сезонность.

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:

naive
MAE = 350 221
sMAPE 43.0%. Самый ленивый бейзлайн — даже до недельной сезонности не доходит.
seasonal naive
MAE = 291 080
sMAPE 35.6%. Учитывает недельный паттерн — сразу бьёт naive на 17%.
Holt-Winters
MAE = 280 021
sMAPE 35.1%. Тренд + сезонность. Лучший из бейзлайнов: −4% к seasonal naive.

Это уже планка. Любая «настоящая» модель должна бить 280 021 ощутимо — иначе её просто не стоит тащить в прод.

Инсайт #1 / Бейзлайн

Бейзлайн бьёт половину 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,
)

На нашем тестовом периоде:

arima · MAE
222 779
−20% к Holt-Winters
arima · sMAPE
25.0%
Симметричная процентная ошибка
arima · WAPE
24.0%
Взвешенная — для сравнения месяц к месяцу

Что хорошо

Что плохо

Prophet: на чём он реально хорош

Prophet от Facebook (теперь Meta) разбирает ряд на три понятных компонента:

y(t) = trend(t) + seasonality(t) + holidays(t) + ε
Главное отличие от ARIMA: не требует стационарности и явно моделирует праздники.

Важный момент. 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)
prophet · MAE
204 543
−8% к ARIMA
prophet · sMAPE
24.4%
Лучший на всех метриках
prophet · WAPE
22.0%
Без чувствительности к нулям

Что хорошо

Что плохо

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] в будущем нет. Модель использует свой собственный прошлый прогноз вместо реального значения — и ошибка накапливается. К концу горизонта она заметно хуже, чем в начале. Это видно на графике ниже.

lightgbm · MAE
234 174
Хуже Prophet на 14%
lightgbm · sMAPE
28.7%
Симметричный — recursive ошибка видна
lightgbm · WAPE
25.2%
Сравнимо с ARIMA

Что хорошо

Что плохо

Все три модели на одной картинке

Окт Ноя Дек Кон BF факт ARIMA Prophet LightGBM
Прогнозы трёх моделей на тесте Oct–Dec 2024. ARIMA размазывает Black Friday-пик, Prophet ловит сезонность точнее, LightGBM держится вблизи факта в начале и накапливает ошибку к концу горизонта.

Видно три вещи: (1) ARIMA не угадывает Black Friday — там оранжевая полоса, и видно, что teal-линия её игнорирует. (2) Prophet (purple) ближе всех к актуалу. (3) LightGBM (amber) держится в начале теста, но к концу декабря ошибается заметно сильнее — recursive prediction в действии.

Почему нельзя смотреть только на MAPE

У нас три кандидата на «метрику качества». Все три — про разницу между прогнозом и фактом, но ведут себя по-разному в плохую погоду.

MAE — средняя ошибка в деньгах

MAE = (1/n) · Σ |yi ŷi|
Та же единица измерения, что и сама метрика. На наших данных — условные единицы выручки.

MAPE — средняя процентная ошибка

MAPE = (100/n) · Σ |(yi ŷi) / yi|
Знаменатель — y_i. Если y_i = 0, метрика взрывается.

sMAPE — симметричная процентная ошибка

sMAPE = (100/n) · Σ |yi ŷi| / ((|yi| + |ŷi|)/2)
Знаменатель симметричен по обоим аргументам. Ограничен в [0%, 200%] — взрывов не бывает.

Что произошло на наших данных. В тестовом периоде Oct–Dec 2024 нашлось 2 дня с нулевыми продажами (12 ноября и 24 декабря — праздничные сбои в датасете). На этих днях:

# 12 ноября 2024: actual = 0, prophet прогнозирует 755 794
# MAPE для одного этого дня:
|0 − 755794| / 0 =# Среднее по всему тесту → MAPE = inf для всех моделей

У нас MAPE = ∞ для всех моделей — даже для лучшей. Это не «модели плохие», это метрика сломалась. Надо смотреть на sMAPE и WAPE — они дают сравнимые цифры:

MAEMAPEsMAPEWAPE
seasonal naive291 08035.6%31.3%
Holt-Winters280 02135.1%30.1%
ARIMA222 77925.0%24.0%
Prophet204 54324.4%22.0%
LightGBM234 17428.7%25.2%

WAPE (weighted absolute percentage error, Σ|y−ŷ| / Σ|y|) — мой любимец на маркетплейсе. Он не делит каждый день отдельно, а нормирует общую ошибку на общий объём. На нулевых днях не ломается, и его легко объяснить продакту.

Инсайт #2 / MAPE

MAPE врёт на маркетплейсе

MAPE ломается там, где есть малые или нулевые значения — а в маркетплейсе они есть всегда (праздники, тех-сбои, открытие новой категории). Один день с продажами 50 рублей и прогнозом 1000 — и метрика говорит «1900% ошибка», хотя в деньгах это 950 рублей. На отчёте продакту получается катастрофа из ничего. Я давно перешёл на MAE (для бизнеса — «ошибка в деньгах») + WAPE (для слежения за качеством модели от месяца к месяцу — стабильнее MAPE). MAPE оставляю только когда заранее уверен, что нулей не будет.

Прогноз — это коридор, а не точка

Точечный прогноз почти всегда неверен. Нет в этом ничего плохого — просто детерминистичный прогноз отвечает на неправильный вопрос. Бизнесу нужнее prediction interval: «с вероятностью 80% реальные продажи будут в [X; Y]». Это позволяет управлять риском, а не «ошибкой».

Как получить интервал у каждой модели

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%-коридор
ARIMA Prophet LightGBM Окт Ноя Дек Кон BF
80%-интервалы трёх моделей на тесте. Сплошная белая линия — факт, пунктир — точечный прогноз модели, затенённая область — коридор q10..q90.

Калибровка интервала — отдельная история. 80%-коридор должен реально содержать факт около 80% дней. Если содержит только 60% — модель не калибрована, и нужно либо увеличивать interval_width, либо переобучать. Самый честный способ проверить — посчитать pinball loss на тестовом периоде.

На графике видно: у ARIMA коридор уже всех остальных — модель проще и менее уверена в самой себе на структурных пиках. У LightGBM коридор шире, но точечный прогноз ближе к актуалу в середине теста.

Инсайт #3 / Коридор

Не бывает одного прогноза — прогноз это коридор

Бизнесу часто нужнее «верхняя граница для закупки» (чтобы было что продать) и «нижняя для планирования кэша» (чтобы не разориться), чем средняя точка. Сразу отдавай продакту три цифры: q10 / q50 / q90 — это сильно меняет разговор. Точечный прогноз провоцирует обсуждение «модель ошиблась». Коридор — обсуждение «риск-менеджмент». Один и тот же прогноз, но в первом случае ты «виноват», во втором — у тебя инструмент управления.

Когда что брать

Сводим всё в одну таблицу. Числовые ячейки — реальные результаты на нашем тесте Oct–Dec 2024.

ARIMAProphetLightGBM
MAE на тесте222 779204 543234 174
sMAPE25.0%24.4%28.7%
WAPE24.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 месяца?
ARIMA и LightGBM скорее всего упадут — данных мало для оценки сезонности и обучения деревьев. Holt-Winters и Prophet — единственные жизнеспособные варианты. И сразу скажи продакту, что прогноз будет плохим: на 3 месяцах годовая сезонность не видна, модель будет угадывать тренд впустую.
Как часто переобучать модель в проде?
Зависит от стабильности паттерна. Для ритейла нормальная стартовая точка — раз в неделю по расписанию. Если случился drift (пандемия, смена ассортимента, новый канал) — переобучай по триггеру алерта, не жди расписания. На задачах с шумной метрикой имеет смысл retrain раз в день — модель забывает «случайные» сдвиги быстрее.
Где взять данные на свой проект?
Самый быстрый способ — наш SQL-тренажёр sql.new-lvl.pro: там есть таблицы orders / order_items за 2 года с реалистичными паттернами (сезонность, Black Friday, retention decay). Можно сразу прогнать SQL из этой статьи и применить любую модель из примеров.
А что про NeuralProphet, N-BEATS, TFT и LSTM?
Для горизонта 90 дней на дневных продажах — overkill. Сложнее в поддержке, медленнее обучаются, на маленьких рядах (< 5 лет дневных данных) выигрыш обычно около нуля или отрицательный. Если хочешь шире про DL-методы для рядов — см. наш гайд по прогнозированию, там есть отдельная глава про LSTM и Transformer.
Сначала вытащи свой ряд из БД через SQL
SQL-блок из этой статьи разбирается в нашем тренажёре первым в premium-секции. Бесплатных задач — 20, premium — 80, реальный маркетплейс-датасет.
sql.new-lvl.pro
АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Прогноз продаж — одна из задач, которую я делал и в офлайн-ритейле (15 лет), и теперь в маркетплейсе. Главное, что понял: правильный выбор метрики и баланс «точность vs стоимость поддержки» всегда важнее, чем выбор модели. Все три инсайта в этой статье — оттуда.

Написать в Telegram
// ЗАКРЕПИ НА ПРАКТИКЕ

От теории к задачам — один клик

В SQL-тренажёре есть задачи на метрики, когорты, воронки и retention. 20 бесплатных задач на реальной базе маркетплейса.

▶ Открыть SQL-тренажёр ★ Premium · 80 задач
Все материалы: База знаний · Telegram