← All projects
Project · Machine learning · Demand forecasting

Five models chased the naive baseline. It won six times out of eight.

A retrospective on forecasting monthly liquor demand for 8 categories across Iowa’s top 25 wholesale accounts — bigquery-public-data.iowa_liquor_sales.sales, no synthetic rows, no fabricated features. This page covers what got built, how every model was scored, the validation techniques that kept the scoring honest, and what actually held up.

6/8categories where naive seasonal beat Prophet, LightGBM, SARIMAX, and their ensemble
2categories where a tuned model earned its complexity — both by double digits
5models built and fairly compared per category: naive, Prophet, LightGBM, SARIMAX, Ridge ensemble
24/24production pipeline tests passing — zero mocked ground truth
01

Two pipelines, one honesty standard.

2 pipelines
1 honesty standard

A production tiered-stack pipeline forecasts weekly demand per store; a second exploration track rebuilds the problem at monthly, category-level granularity to run a much heavier model bake-off. Both are graded the same way: rolling-origin backtests against real holdout months, never a single lucky train/test split.

Production pipeline— weekly, per (store × liquor type), 200 series
naive seasonal-naive Holt-Winters global LightGBM split-conformal intervals
Exploration track— monthly, per liquor category, 5-stage bake-off
1 · EDA 2 · data prep 3 · model + tune 4 · evaluate 5 · champion report

On the production pipeline’s weekly folds, Holt-Winters averaged 0.47 WAPE against naive’s 0.59 — a clear win for the statistical tier. Aggregate to monthly, per-category totals, and that edge disappears. Granularity changed which baseline was worth beating — see Model Comparison below.

02

Naive vs. the category’s actual champion.

8 categories
5 models
WAPE metric

Every model was fit on training data only, forecast forward with genuine recursive rollout (never fed real future values), and scored on a 12-month holdout no model had seen. WAPE — weighted absolute percentage error — is the primary metric: lower is better. Bars below show each category’s naive baseline against its lowest-WAPE model.

Naive baseline Champion (when it beats naive)
brandy naive wins · 0.108 cordial liqueur naive wins · 0.088 gin naive wins · 0.107 other LightGBM 0.141 −18% rum naive wins · 0.088 tequila naive wins · 0.094 vodka LightGBM 0.067 −26% whiskey naive wins · 0.094 0.00 0.05 0.10 0.15 0.20 WAPE
Test-holdout WAPE, naive baseline vs. champion navy = naive baseline · red = champion (lower is better)

Where only one bar shows, naive is the champion — a more complex model was tried and lost. other and vodka are the exceptions: LightGBM’s lag and rolling-window features found real signal a fixed year-ago lookup can’t.

CategoryNaiveProphetLightGBMSARIMAXEnsemble
brandy0.1080.1770.1620.1720.145
cordial liqueur0.0880.1160.0880.1540.122
gin0.1070.1220.1520.1590.173
other0.1720.1790.1410.2380.177
rum0.0880.1640.1580.1490.214
tequila0.0940.1490.1370.2620.132
vodka0.0900.0860.0670.1020.083
whiskey0.0940.1120.1610.1210.134

Table 1 — full 12-month test WAPE, all 5 models. Highlighted cell = lowest WAPE for that row. Cordial liqueur’s tie (naive and LightGBM both at 0.088) is broken in naive’s favor — the simpler model, all else equal.

03

What the test holdout actually looked like.

3 figures
12-month holdout

The WAPE table above is a summary; these are the actual forecasts against actual outcomes on the 12-month holdout no model — including the champion — ever trained on. All three figures are drawn from the same test-holdout run as Table 1, across all 8 categories and all 5 models.

Forecast vs. actual line charts for all 8 liquor categories, showing naive, Prophet, LightGBM, SARIMAX, and ensemble forecasts against actual demand with plus-or-minus 10% and 20% bands
Figure 1 — forecast vs. actual, test holdout. Actual (black) against all 5 models, ±10%/20% bands shaded around actual, one panel per category. Where a model’s line strays outside the outer band, its error that month exceeded 20% of true demand — visible directly, not just as a WAPE average.
Champion model residuals over time for all 8 categories, with major US holiday months marked in red
Figure 2 — champion residuals over time. Forecast minus actual, per category’s own champion, across the test holdout. Red points mark months containing a major US holiday — checking for a residual pattern tied to the calendar, not just a random scatter around zero.
Histograms of champion model residual distributions for all 8 liquor categories
Figure 3 — champion residual distributions. Shapiro-Wilk held at the 5% level for 7 of these 8 — only brandy’s residuals reject normality, consistent with it being the lowest-volume, noisiest category throughout this project.
04

How the scoring itself was kept honest.

6 techniques
kept honest

A model that beats naive on one lucky split isn’t demonstrating skill — it’s demonstrating variance. Every technique below exists to make that distinction, and each one changed a real number when it was checked properly.

Backtesting

Rolling-origin, not one split

The production pipeline walks the origin forward across 6 folds, scoring point accuracy, interval coverage, and — separately — folds whose horizon overlaps the 2020 COVID window.

Avg. interval coverage 90.5% against a 90% target, across 6 real folds.
Split design

Walk-forward train / val / test

Exploration track: fit on train only, tune on a 6-month validation window, then refit on train+val and score once on a genuinely unseen 12-month holdout — never touched during tuning.

Champion selection uses test WAPE only, never validation WAPE.
Uncertainty

Split-conformal intervals

No distributional assumptions — just calibrated residual quantiles. But the guarantee has a real sample-size floor: 1/(1−confidence) calibration points, minimum.

95% CIs need ≥20 points; only 6 existed. Dropped, not faked.
Regularization

Leave-one-out alpha search

The Ridge ensemble’s weights were numerically meaningless at first — alpha=3,000 behaved identically to alpha=0.1, because the penalty was invisible against a 10⁸–10₁⁰-scale loss. Standardizing first made regularization actually bite.

LOO-selected alpha ranged 0.001 → 100 across categories.
Statistical tests

ADF, KPSS, Shapiro-Wilk

Stationarity was mixed, not uniform — vodka and rum tested stationary outright; brandy, cordial liqueur, and tequila didn’t. Champion-residual normality held for 7 of 8 categories.

No single ARIMA order would have fit all 8 series equally well.
Clustering

Data-driven, not eyeballed

A plain significance test found every category pair “significantly” correlated — useless. Switching to STL-residual co-movement plus an elbow cut in the linkage distances (not a hand-picked threshold) found the real structure.

One real cluster: cordial liqueur, rum, tequila, vodka, whiskey.
05

What actually held up.

4 findings
6 bugs caught
01

Beating naive is genuinely hard at this granularity

At monthly, category-level aggregation with ~7 years of history, four more sophisticated models and an ensemble of all three lost to same-month-last-year on 6 of 8 categories. That’s the headline finding, and it’s reported as-is rather than buried under whichever model looks most sophisticated.

02

Granularity decides which baseline wins

The same naive family loses more often in the production pipeline’s weekly, per-store backtests, where Holt-Winters’ extra structure has more series and more history per series to actually pay off. Aggregation level isn’t a neutral choice — it changes the honest answer.

03

Complexity earned its place exactly twice

other and vodka both improved on naive by a real, double-digit margin under LightGBM — not a rounding-error win. That’s the honest bar for “worth deploying something more complex than a lookup table.”

04

No feature was fabricated to fill a gap

Price, promotions, temperature, payday timing, unemployment, consumer confidence, and CPI all appear in the original spec and none exist in this dataset. Every one is marked not available in the EDA summary rather than approximated or invented. sale_dollars is flagged as a real, unused signal for future work.

Bug log — caught by checking the numbers, not by the code running without errors
Data integrityBigQuery’s DATE type crashed a second process
FixedExtraction normalized the column to plain datetime64 at write time instead of requiring every downstream reader to import the exact library that could decode it.
Data integrityReal returns data rejected as “invalid”
FixedA handful of weeks have negative bottle counts — returns exceeding sales. The schema’s ≥0 constraint was silently discarding real observations.
Statistical validityRidge regularization had zero effect
FixedAlpha values from 0.1 to 3,000 produced identical weights — the penalty was numerically inert against the loss scale until inputs were standardized first.
Statistical validityPooled correlation masked every real signal
FixedCorrelating sales against calendar features across all 8 categories at once washed out relationships that were strong within any single category.
Statistical validityEvery category pair looked “significant”
FixedA plain significance test on cross-category correlation returned “yes” for all 28 pairs — n was large enough that it stopped discriminating anything.
Config robustnessHorizon buckets broke the moment they were configurable
FixedHardcoded 12-month bins assumed a 12-month test window. Caught only by actually running a 6-month config, not by reading the code.
No price or promotion dataA real markdown in the forecast window is invisible to every model here.
No macro indicatorsUnemployment, consumer confidence, and CPI aren’t in this dataset at any granularity.
Small calibration sets6–18 residuals per category means 95% intervals stay out of reach; even 80% runs wide.
~7 years of monthly historyLong enough for year-over-year signal, short for anything needing multiple full cycles.

The point of validating a forecast isn’t to make the fanciest model win — it’s to find out whether it does, and say so either way.