Case study · Time-series forecasting

Forecasting daily demand, and proving it beats the baseline.

A reproducible study on a public retail dataset: build a seasonal-naive baseline, three models of increasing sophistication, and a leakage-free rolling-origin backtest that measures the lift. Then a short causal detour that recovers a known promo effect.

Headline result

Mean across a 6-fold rolling-origin backtest, 28-day horizon.

0.74
SARIMA MASE (1.00 = naive)
−18.5%
error vs seasonal-naive (MASE)
−21%
error vs seasonal-naive (sMAPE)

The target is total daily units sold by a UK online retailer (UCI Online Retail II, 739 days). The benchmark is a seasonal-naive forecast — last week's value, repeated. A SARIMA model beats it on every metric, not just the one that flatters it:

modelsMAPERMSEMASEimprovement vs naive
SARIMA29.676,9820.741+18.5%
ETS30.637,1740.773+15.0%
seasonal-naive37.738,1050.9100.0%
LightGBM36.778,5020.966−6.1%
Backtested MASE by model, with the MASE = 1 naive line.
Backtested MASE by model. Lower is better; the dashed line is the naive benchmark.

The data, and why it's hard

Aggregated to a daily series, the data has three features that decide the modelling:

EDA panels: daily series, demand by day of week, demand by month, and the distribution of daily units.
Exploratory view: the series and its rolling mean, weekly and monthly seasonality, and the right-skewed distribution of daily volume.

How it's evaluated

Time-based, no leakage, lift measured against an explicit baseline.

Every model is scored with rolling-origin cross-validation: six consecutive 28-day windows, each model trained only on the data before its window. No shuffling, no peeking. The headline metric is MASE — mean absolute error scaled by the in-sample seasonal-naive error — because it is comparable across series and states the improvement over the benchmark directly (MASE < 1 means you beat it).

The ML model (LightGBM on lag, rolling, and calendar/holiday features, forecast recursively) is the interesting honest result: it does not beat the classical seasonal models on this single rigid-weekly series. That's expected — gradient boosting earns its keep across many series with a global model, not on one smooth aggregate where a well-specified SARIMA is hard to beat. Reporting that is the point.

Forecast and error analysis

SARIMA forecast versus actual on the held-out test window with an 80% prediction interval.
SARIMA forecast vs actual on the most recent test window, with an 80% prediction interval from the state-space model.
Residuals over the test window and mean residual by day of week.
Residuals are roughly centered through the window; the largest misses are the unpredictable wholesale spikes, not the weekly structure.

Causal bonus: recovering a promo effect

Difference-in-differences, checked against a known ground truth.

There's no labelled promotion in the data, so I simulate one and check the estimator. A synthetic 24-store × 180-day panel is built from the real demand shape — shared seasonality, store-level scaling, noise — and a known +15% uplift is applied to treated stores in the post period. A two-way fixed-effects difference-in-differences on log demand (store fixed effects absorb level, date fixed effects absorb the common seasonal movement) recovers the truth:

+15%
injected uplift (ground truth)
+14.9%
DiD estimate
±0.7pp
95% CI [14.2%, 15.6%]

The validation states the method, recovers a known answer, and reports the interval.

Reproduce it

Public data (no Kaggle auth), deterministic, one command. The download script pulls Online Retail II straight from UCI; raw data is never committed.