Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

forecast-backtest-kit

A rolling-origin backtesting kit for demand forecasting whose feature pipeline is provably leak-free: corrupting every post-origin actual changes zero predictions, a property enforced as a CI test.

CI coverage leakage license

What this solves

  • Most forecast backtests leak: per-target-day lag features quietly feed actuals from inside the forecast window to the model; this repo's first implementation did exactly that (193 of 210 predictions were contaminated) and the fix plus its regression test are the centerpiece.
  • MAPE lies on intermittent demand (division by zero-demand days); the kit reports WAPE as the headline, MAPE only over nonzero days with the exclusion count shown, and signed bias so over-forecasting cannot hide.
  • A single aggregate error hides where the model helps; results split by SKU segment (head, torso, tail, cold-start) against two honest baselines, so "the model wins" becomes "the model wins on head and torso, and here is the tail story".

Why this exists

Demand forecasting drives inventory dollars: over-forecast and capital sits in a warehouse; under-forecast and you stock out on payday weekend. Model improvements are claimed via backtests, so a backtest that flatters the model is worse than no backtest, and the most common flattery is temporal leakage, which produces beautiful offline numbers and a production launch that mysteriously underperforms them.

fbk is a small kit built around one discipline: forecasts for origin O may use nothing after day O-1. Features (lags, rolling means, same-weekday naive) are computed in SQL, anchored at the as-of day with the horizon step h as a model feature (direct multi-horizon forecasting, ADR-001), so the leakage boundary is arithmetic on day indexes in one reviewable file (sql/features.sql). The protocol is verified by a corruption probe that runs in CI: replace every actual at day >= origin with garbage and require that not a single prediction changes.

The included panel generator produces a synthetic but structured demand history (60 SKUs, 540 days: volume tiers, weekly seasonality, promos, cold-start launches, integer noise), labeled clearly as synthetic. On it, LightGBM with as-of features beats seasonal-naive by a wide margin on high-volume SKUs and by much less on the intermittent tail, which is the honest, decision-relevant shape of most real demand forecasting results.

Architecture

flowchart LR
 G[Panel generator<br/>tiers, promos, cold starts] --> DB[(SQLite panel)]
 DB --> FT[features_train_h<br/>as-of anchored, h in 1,4,8,14]
 DB --> FX[features_test_asof<br/>anchored at origin-1]
 FT --> M1[LightGBM<br/>poisson objective]
 FT --> M2[seasonal-naive]
 FT --> M3[moving-average-28]
 FX --> M1 & M2 & M3
 M1 & M2 & M3 --> BT[Rolling-origin backtest<br/>6 origins, horizon 14]
 BT --> R[Per-segment WAPE, MAPE, bias<br/>results JSON + leaderboard]
 LT[Leakage corruption test] -.verifies.-> FX
Loading

Tech stack

Technology Role in this project Why chosen here
SQLite + SQL window functions feature engineering The leakage boundary reads as day-index arithmetic in one .sql file (ADR-002); ports to warehouse SQL
LightGBM (poisson objective) the model under test Count data; the objective matches integer demand with many small values
NumPy matrix assembly Feature dicts to arrays at the model boundary only
Python stdlib generator, metrics, protocol WAPE/MAPE/bias implemented from formulas with exact-value tests
pytest + pytest-cov 17 tests, 96% measured coverage The corruption probe is a test; so is as-of lag correctness against raw SQL
GitHub Actions + ruff CI The backtest itself runs on a small panel every push

Quickstart

Prerequisites: Python 3.10+, git.

git clone https://github.com/tkgo1599-max/forecast-backtest-kit.git
cd forecast-backtest-kit
pip install -e ".[dev]"
pytest # 17 tests, including the leakage probe
make run # full backtest: 60 SKUs, 540 days, leaderboard
./scripts/leak_probe.sh # just the leakage regression, verbose

To backtest your own panel: load rows (sku, day, dow, promo, tier, demand) into PanelStore, keep your feature changes inside sql/features.sql, and re-run the leak probe before believing any new number.

Measured results

Backtest on the reference panel (40 SKUs, 400 days, 6 monthly origins, 14-day horizon), abridged to the "all" and extreme segments; full table from make run:

model segment WAPE MAPE (nonzero) bias
lightgbm all 0.140 37.2% -0.011
moving-average-28 all 0.220 41.8% -0.019
seasonal-naive all 0.235 51.8% -0.029
lightgbm head 0.073 7.9% -0.008
seasonal-naive head 0.150 13.3% -0.039
lightgbm tail 0.359 50.7% -0.007
seasonal-naive tail 0.525 68.6% -0.007

The segment split is the point: the model's advantage concentrates in high-volume SKUs where per-SKU patterns are learnable; on the intermittent tail the gap narrows toward the moving average. A team using this kit would deploy the model for head and torso and keep a cheap baseline for the deep tail, which is a decision an aggregate WAPE could never justify.

Performance under load

Methodology: python benchmark/bench.py runs the complete backtest (SQL features at 4 training horizons per origin, 3 models, 6 origins, horizon 14) at three panel scales. Linux container, shared vCPUs. Raw: benchmark/results/results.json.

skus panel rows wall (s) lgbm WAPE naive WAPE improvement
20 7,208 10.3 0.148 0.238 37.8%
40 14,429 18.4 0.140 0.235 40.6%
80 28,889 42.5 0.143 0.235 39.3%

Wall time scales with panel rows times the training-horizon mix (4x), dominated by the correlated-subquery rolling means; ADR-002 names the window-frame rewrite as the first optimization if panels grow 10x.

Architecture decisions

Intentionally out of scope

Hierarchical reconciliation (SKU-store-region coherence) is deliberately absent: it matters once forecasts feed a hierarchy of decisions, and the trigger is the first consumer that aggregates these forecasts. Also out: probabilistic forecasts (quantile objectives are a LightGBM parameter away, but honest evaluation needs pinball loss and calibration plots, a project of its own), stockout censoring correction, and hyperparameter search (the kit measures protocols, not leaderboard squeezing).

Security and compliance

The kit is offline and needs no credentials. Real demand panels are commercially sensitive: treat any repo containing one as private, and note that the SQLite database is in-memory by default so no panel data lands on disk unless you choose a file path.

Failure modes

Failure Detection Behavior Recovery
Feature change reintroduces leakage Corruption probe in CI Build fails with the count of contaminated predictions Fix the query; the probe names the model
Zero-demand days WAPE well-defined; MAPE excludes and counts them Metrics stay meaningful None needed; by design
All-zero segment Explicit ValueError from metrics Loud failure, not a NaN in a report Inspect the segment; usually a data bug
Cold-start SKU with no history Lags NULL, encoded as -1; history_days feature Model learns the low-history regime Expected; segment reporting shows the cost
Origin misconfiguration Range validation ValueError before any training Fix origins

Hardest problem solved

The first backtest looked great and was wrong. Multi-step forecasts were consuming actual demand from inside their own forecast window: features were computed per target day, so predicting day origin+5 used the real demand at origin+4, information no forecaster has. Offline error was flattered in exactly the way that later disappoints in production.

What makes this story worth telling is the detection method, because eyeballing the feature code had already failed once: corrupt every actual at day >= origin with garbage and re-predict. A leak-free pipeline must produce bit-identical predictions. The probe changed 193 of 210 predictions. The fix (commit "fix(features): anchor all features at the as-of day") rebuilt the features as origin-anchored SQL with h as a model feature, and turned the probe into a permanent regression test that now passes at zero changed predictions and runs in CI. Notably, on this panel the honest numbers came out close to the leaky ones, which is its own lesson: leakage does not announce itself with implausible scores; you have to test for it structurally.

Future work

  • Quantile forecasts (LightGBM pinball objectives) with calibration reporting, for safety-stock decisions.
  • Warehouse SQL port of the feature queries (the reason they are plain SQL).
  • Stockout-aware evaluation: mask censored days from error, report separately.
  • Feature importance drift across origins, to catch regime changes in what the model uses.
  • First metric to watch when adopting: the gap between backtest WAPE and the first month of production WAPE; that gap is the leakage-and-drift detector for the whole system.

About

Rolling-origin demand-forecasting backtest with a provably leak-free feature pipeline: corrupting every post-origin actual changes zero predictions (CI-enforced). LightGBM vs honest baselines, WAPE-first metrics, per-segment results, features in SQL.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /