learn-timeseries-forecasting-with-phoebe / Builder session 8 of 10
Learn Timeseries Forecasting with Phoebe · Builder track · Session 8 of 10

Backtesting & metrics: the honest scoreboard

A single holdout number is noisy - it can flatter a bad model or punish a good one by luck. This session builds the honest scoreboard: rolling-origin cross-validation that marches the forecast origin forward and averages the error over many folds, the three metrics that matter (MAPE, RMSE, and the naive-scaled MASE), and prediction-interval coverage - checking that your 80% band actually holds ~80% of the actuals. We wire all of it back to the b3 live forecaster's MAPE ladder.

🟡 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook Evaluation done right
0-3 · Why one split lies 3-18 · Rolling-origin + the metrics 18-42 · Build-along: metrics + backtest 42-45 · Q&A
Part 0

Why we do not trust one number

Every model so far - baselines, ETS, ARIMA, Prophet, gradient boosting - reported a single holdout MAPE. That number is a start, but it is one draw from a noisy process: land on a quiet fortnight and your model looks brilliant; land on a promo spike and it looks broken. Tonight we replace the single guess with a distribution of scores across many folds, add metrics that survive zeros and let you compare across series, and finally check that our prediction intervals are honest. This is the session that decides which model actually ships.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today A from-scratch implementation of MAPE, RMSE, and MASE; a rolling-origin backtest loop that reports mean error plus its spread across folds; an understanding of prediction-interval coverage (does the 80% band hold ~80% of actuals); and a rule you can defend in a review: ship the model that wins on a backtested, naive-scaled score - not on one lucky holdout.
Part 1 · covers FPP3 time-series cross-validation

Rolling-origin cross-validation 6 min live

The fix for a noisy holdout is to use many of them, honestly. Rolling-origin cross-validation (also called time-series CV or walk-forward) picks an origin, trains on everything before it, forecasts the next H steps, scores that fold, then rolls the origin forward and repeats. Average the fold errors and you get a stable score with a spread that tells you how much to trust it. The train window can expand (keep all history) or slide (fixed length) - but it never, ever includes the future.

Rolling-origin: train grows, the H-step test fold marches forward Fold 1 train test H Fold 2 train test H Fold 3 train test H Fold 4 train test H origin marches forward → Score each green fold, then average across folds. The mean is the headline; the spread is the trust.
🔍 Click to zoom - each fold trains on the past, tests on the next H, origin rolls forward
LiveExpanding vs sliding window, and how many folds3 min▶

Two dials, one rule:

  • Expanding window keeps all history before the origin - best when the series is stable and more data helps.
  • Sliding window keeps a fixed-length recent history - best when old behaviour is no longer relevant (a regime change).
  • Forecast at your real horizon. Each test fold is exactly H steps, because that is the decision you actually make.
  • Enough folds to see spread. A handful of folds already turns one number into a mean plus a range - which is the whole point.
The one unbreakable rule The train window never contains a timestamp at or after the origin. Fit everything - scaling, feature stats, model selection - inside the train window only. Peek past the origin and you have leaked, and the backtest becomes fiction.
Part 2 · covers FPP3 evaluation

Three metrics, and honest intervals 5 min live

One metric never tells the whole story. Use three: MAPE (percentage error, intuitive but breaks near zeros), RMSE (in the series' own units, punishes big misses hardest), and MASE - the mean absolute error scaled by the in-sample naive error. MASE is the honest one: MASE < 1 means you beat naive, MASE > 1 means you did not, and it compares cleanly across series of different scales. Then, separately, check the coverage of your interval: an 80% band should contain about 80% of the actuals - no more, no less.

MetricWhat it saysWatch out
MAPEAverage error as a % of the actual - easy to explain to a business.Explodes when actuals are near zero; punishes over- and under-forecast unevenly.
RMSETypical error in the series' own units; large errors dominate.Not comparable across series of different scale; sensitive to outliers.
MASEError relative to the naive baseline. < 1 beats naive; scale-free.Needs the in-sample naive error as denominator - use the seasonal version for seasonal data.
Interval coverage: does the 80% band actually hold ~80% of actuals? 80% band forecast median out out H steps ahead → Here 8 of 10 land inside = 80% coverage. Too few inside = interval too narrow; too many = too wide.
🔍 Click to zoom - ~80% of actuals should land inside the 80% band
Self-studyWhy MASE is the metric to lead with2 min read▶

MAPE is what you show a business; MASE is what you defend in a model review. Its denominator is the in-sample error of the naive forecast, so it bakes the baseline straight into the score:

  • MASE = 1 - you tied the naive baseline. No value added.
  • MASE < 1 - you beat naive. The smaller, the better.
  • MASE > 1 - a complex model losing to "same weekday last week". Do not ship it.
Real world

This closes the loop on b1's promise. "Beat the baseline or do not ship" stops being a slogan the moment MASE is on the dashboard - a single number that says, unarguably, whether the complexity earned its keep.

Demo 1 of 2

Implement MAPE, RMSE, and MASE from scratch ★ 12 min · everyone builds

Three short functions, no black boxes. We score the seasonal-naive baseline and one model (a 7-day moving average) on the Lumen holdout so you can read all three numbers side by side - and watch MASE tell you plainly whether the model beat naive.

Rebuild the Lumen daily series and the time split from b1 (train / test, horizon H).

Write MAPE, RMSE, and MASE as pure functions - MASE scaled by the in-sample seasonal-naive error (m=7).

Score the seasonal-naive baseline and one model; print all three metrics for each and compare.

★ Run it now (Python) - MAPE, RMSE, MASE from scratchimport pandas as pd, numpy as np # rebuild the Lumen daily series + split from b1 idx = pd.date_range("2026-01-01", periods=112, freq="D") season = np.array([40,55,60,50,30,-120,-140]) i = np.arange(112) y = 1000 + 2.5*i + season[i % 7] + 14*np.sin(i*1.7) + 9*np.cos(i*0.6) s = pd.Series(np.round(y), index=idx, name="sessions") m, H = 7, 14 train, test = s.iloc[:-H], s.iloc[-H:] def mape(a, f): return np.mean(np.abs((a - f) / a)) * 100 def rmse(a, f): return np.sqrt(np.mean((a - f) ** 2)) def mase(a, f, tr, m=7): naive = np.mean(np.abs(tr[m:].values - tr[:-m].values)) # in-sample seasonal-naive MAE return np.mean(np.abs(a - f)) / naive # two forecasts on the holdout snaive = pd.Series([train.iloc[-m + (k % m)] for k in range(H)], index=test.index) movavg = pd.Series(train.iloc[-m:].mean(), index=test.index) # flat 7-day mean for name, fc in [("seasonal-naive", snaive), ("7d moving avg", movavg)]: print(f"{name:15s} MAPE {mape(test, fc):5.1f}% " f"RMSE {rmse(test, fc):6.1f} MASE {mase(test, fc, train):.2f}")
Read MASE first Whichever line shows MASE < 1 beat the naive baseline; MASE > 1 did not, no matter how good its MAPE looks. On this weekly-seasonal series the flat moving average usually loses to seasonal-naive - exactly the humility lesson from b1.
Demo 2 of 2

A rolling-origin backtest loop ★ 8 min · build your own

Now turn one number into a distribution. We slide the origin forward, refit a simple model on the expanding history, forecast H steps, score the fold, and report the mean MAPE plus its spread across folds. This is the b3 live forecaster's single MAPE, made honest - the same score, averaged over many origins instead of one.

Choose fold origins that each leave H future days to score against.

For each origin: train on everything before it, forecast seasonal-naive H steps, record the fold MAPE.

Report mean MAPE and standard deviation across folds - the headline and the trust range.

★ Run it now (Python) - rolling-origin backtest# hand loop (mirrors sklearn.model_selection.TimeSeriesSplit, but H-step folds) n_folds = 6 fold_mapes = [] for k in range(n_folds): # origin marches back from the end; each fold scores the next H days end = len(s) - (n_folds - 1 - k) * H if end - H < 2 * m: # need enough history to train continue tr, te = s.iloc[:end - H], s.iloc[end - H:end] fc = pd.Series([tr.iloc[-m + (j % m)] for j in range(len(te))], index=te.index) fold_mapes.append(mape(te, fc)) fold_mapes = np.array(fold_mapes) print("per-fold MAPE:", np.round(fold_mapes, 1)) print(f"rolling-origin MAPE: {fold_mapes.mean():.1f}% " f"+/- {fold_mapes.std():.1f} (over {len(fold_mapes)} folds)")
Real world

The spread is the point. A model with mean MAPE 9% +/- 2% is a very different bet from 9% +/- 8%. Swap the seasonal-naive step for ETS, ARIMA, Prophet, or your gradient-boosting model and rank them by mean backtested MASE, not by whoever won a single lucky holdout. That ranking is the one you defend in a review - and the honest version of the ladder the b3 live forecaster hints at.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Sources covered (cited)

Taught from the forecasting canon on evaluation. This session covers:

✓Forecasting: Principles and Practice, 3rd ed (Hyndman & Athanasopoulos, FPP3) - otexts.com/fpp3Ch. 5 · evaluation, MAPE/RMSE/MASE, time-series cross-validation (rolling origin)
◐M-competitions (Makridakis, M4/M5)Framing · MASE as the scale-free, beat-the-naive scoreboard
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why prefer rolling-origin CV over a single holdout?

A single split can flatter or punish a model by luck. Rolling-origin marches the origin forward, so you get a mean error and a spread that says how much to trust it.

2 · What does MASE < 1 mean?

MASE scales your error by the in-sample naive error. Below 1 means you beat naive; above 1 means a complex model lost to the baseline and should not ship.

3 · An 80% prediction interval should contain roughly what share of actuals?

Coverage checks interval honesty. If only 50% of actuals land inside an 80% band, the interval is overconfident; a point forecast without a well-calibrated interval is false confidence.

Builder session 8 cheat sheet · pin this

One holdout is noisya single split can flatter or punish by luck. Never decide a model on one number.
Rolling-origin CVtrain before the origin, forecast next H, score, roll forward, average. Expanding or sliding window.
Never leak past the originfit scaling, features, and model selection inside the train window only.
MAPEerror as a % - intuitive, but breaks near zeros.
RMSEerror in the series' units, punishes big misses; not comparable across scales.
MASEerror / in-sample naive error. < 1 beats naive, scale-free - the metric to lead with.
Interval coveragean 80% band should hold ~80% of actuals. Too few = too narrow; too many = too wide.
Next sessionb9 - productionizing: retrain cadence, batch serving, the forecast API + dashboard.