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

Monitoring: keeping a forecast honest after it ships

The final builder session. A forecast is never "done" - the world it predicts keeps moving. This session closes the loop: track forecast error as actuals arrive, tell data drift from concept drift, detect when the model has degraded, and trigger a retrain or fall back to the baseline before anyone acts on a broken number. We tie the whole journey back to the analyst who first plotted the series in b1.

🔴 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook ~45 min · capstone
0-3 · Recap 3-18 · Error tracking + drift 18-42 · Build-along: monitor + drift detector 42-45 · Wrap
Part 0

A forecast is never "done"

You shipped the forecast in b9 - refit on a schedule, stored, served through an API. But the moment it goes live, the clock starts: actuals arrive, and the only honest question is whether the forecast is still good. Monitoring is the discipline that answers it. This capstone builds the two checks every production forecast needs - a rolling error tracker and a drift detector - and wires them into a closed loop that decides, automatically, when to retrain or fall back. That loop hands the series right back to the analyst from b1.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today A rolling-error monitor that flags when MAPE crosses a threshold and fires a retrain alert, a simple drift detector that compares recent actuals to the training distribution, a guardrail that falls back to seasonal-naive when the model output is absurd, and the closed-loop mental model that makes forecasting a living system - the full builder journey, wrapped.
Part 1 · covers industry practice

Track forecast error as actuals arrive 7 min live

Backtesting in b8 measured error before you shipped. Monitoring measures it after - on live actuals the model never saw. Every day, yesterday's forecast meets yesterday's actual; log the pair, and a rolling error metric (MAPE or MASE over the last N days) becomes a dashboard that answers one question: is the forecast still as good as the backtest promised? When that rolling number climbs past a threshold, something has changed.

Rolling MAPE over the last N days, with an alert threshold MAPE time → threshold (e.g. 12%) healthy - below threshold error climbing ⚠ DRIFT ALERT PSI 0.31 · retrain fired rolling MAPE 11.4% 7-day window · vs 9.8% base When the rolling line crosses the threshold, alert now - don't wait for the next scheduled refit. Compare live error to the backtested baseline from b8. A rising gap is the signal; the absolute number alone is not.
🔍 Click to zoom - a rolling-MAPE panel with a threshold and a drift-alert tile
LiveData drift vs concept drift3 min▶

When error climbs, ask what changed. Two very different causes, two different fixes:

  • Data drift - the input distribution shifts. The series moves to a new level or variance (a viral spike, a pricing change, a new market). The relationship the model learned may still hold; it just needs to see the new data. A refit often fixes it.
  • Concept drift - the relationship itself changes. A regime change: the weekly pattern inverts, a new channel rewires demand, the pandemic rewrites everything. Refitting on more of the old data will not save you; you may need a new model or new features.
Real world

The two look identical on the error chart - both just make MAPE rise. That is why you monitor the inputs too, not only the error. A drift detector on the actuals tells you the distribution moved (data drift); persistent error even after a refit points to concept drift. The M-competition humility applies here as well: when in doubt, a robust simple model degrades more gracefully than a fragile complex one.

Self-studyGuardrails: sanity bounds and the baseline fallback2 min read▶

Monitoring catches slow degradation. Guardrails catch a model that has gone absurd right now, before the number reaches anyone:

  • Sanity bounds. A forecast of negative sessions, or 10x the historical max, is not a forecast - it is a bug. Clamp or reject it.
  • Baseline fallback. If the model output fails a sanity check or the model errors, fall back to the seasonal-naive forecast from b1. A boring, correct baseline beats a spectacular wrong number every time.
  • Fail loud, serve safe. Log the fallback so a human investigates - but never let the pipeline serve nonsense while it waits for one.

This is the same seasonal-naive line you built in b1, now earning its second job: the safety net under the whole system.

Part 2 · covers industry practice

The closed loop back to b1 5 min live

Everything in this track now becomes a cycle. Forecast, observe the actuals, measure the error, ask whether it drifted, and either retrain or fall back to the baseline - then forecast again. A guardrail gate sits in the middle so no absurd number escapes. This is the loop that turns a one-off model into a living system, and it hands the series straight back to the analyst who first plotted it in b1.

Forecast → observe → measure → drift? → retrain / fall back → forecast Forecast served (b9) Observe actuals the day arrives Measure error rolling MAPE Drift? + guardrail the decision gate Retrain refit on new data Fall back seasonal-naive (b1) Analyst (b1) investigates yes → retrain absurd? → loop closes → forecast The gate: no drift → serve on; drift → retrain; absurd output → fall back to baseline + page a human. The loop never ends. That is the point - a forecast is a living system, not a deliverable you close.
🔍 Click to zoom - the closed loop with a drift + guardrail gate, back to the b1 analyst
LiveTriggering a retrain: schedule + event, together3 min▶

b9 gave you the scheduled clock. Monitoring adds the second, event-driven clock:

  • Scheduled retrain (b9) - refit on cadence, regardless. The steady heartbeat.
  • Event-driven retrain (b10) - fire off-schedule the moment rolling error crosses the threshold or the drift detector alerts. The reflex.
  • Escalation - if a retrain does not restore accuracy, that is a concept-drift signal. Page the analyst - the model, features, or method may need to change, not just the data.
Alert on the trend, not the spike A single bad day is noise; every series has them. Alert on the rolling error crossing a threshold over a window, not one point spiking. Otherwise you cry wolf, the team mutes the alert, and the real degradation slips through.
Demo 1 of 2

A rolling-error monitor that fires a retrain alert ★ 12 min · everyone builds

Build the error tracker: from a forecast-vs-actual log, compute rolling MAPE over the last N days and flag when it crosses a threshold. Crossing fires a "retrain" alert - the event-driven trigger from Part 2.

Simulate a forecast-vs-actual log: each day holds yesterday's forecast and the actual that arrived. Healthy at first, then the actuals drift up (the model starts missing).

Compute per-day absolute percentage error, then a rolling MAPE over the last N days.

Flag every day the rolling MAPE crosses the threshold, and raise a single retrain alert on the first crossing.

★ Run it now (Python) - rolling-error monitor + retrain alertimport pandas as pd, numpy as np # forecast-vs-actual log: healthy for 20 days, then actuals drift upward rng = np.random.default_rng(0) idx = pd.date_range("2026-06-01", periods=40, freq="D") yhat = pd.Series(1000 + 2.5*np.arange(40), index=idx, name="yhat") drift = np.concatenate([np.zeros(20), np.linspace(0, 220, 20)]) # regime shift actual = pd.Series(yhat.values + drift + rng.normal(0, 12, 40), index=idx, name="actual") log = pd.concat([yhat, actual], axis=1) def rolling_monitor(log: pd.DataFrame, window: int = 7, threshold: float = 12.0): ape = (log["actual"] - log["yhat"]).abs() / log["actual"] * 100 # daily APE % roll = ape.rolling(window).mean() # rolling MAPE breach = roll > threshold alert = None if breach.any(): first = breach.idxmax() # first day over threshold alert = {"trigger": "RETRAIN", "date": str(first.date()), "rolling_mape": round(float(roll[first]), 1), "threshold": threshold} return roll, alert roll, alert = rolling_monitor(log) print("latest rolling MAPE:", round(float(roll.iloc[-1]), 1), "%") print("ALERT:", alert if alert else "none - forecast still healthy")
Compare to the backtest, not to zero A "good" MAPE is relative. Anchor the threshold to the backtested MAPE from b8 (say, alert at 1.3x the backtest error), not an absolute number pulled from the air. That way the monitor flags degradation, not just a series that was always noisy.
Demo 2 of 2

A simple drift detector on the actuals ★ 8 min · build your own

Error tells you something broke; drift on the inputs tells you the distribution moved. Build a lightweight detector that compares the recent actuals' distribution to the training distribution using the Population Stability Index (PSI) - a standard, dependency-free drift score - and raises an alert when it crosses a rule-of-thumb bound.

Take a training window (what the model learned on) and a recent window (the latest actuals).

Bin both on the training quantiles, then compute PSI across the bins.

Apply the usual rule of thumb: PSI < 0.1 stable, 0.1-0.25 moderate drift, > 0.25 significant - alert.

★ Run it now (Python) - PSI drift detectorimport numpy as np, pandas as pd def psi(train: np.ndarray, recent: np.ndarray, bins: int = 10) -> float: # bin on TRAIN quantiles so both windows share the same edges edges = np.quantile(train, np.linspace(0, 1, bins + 1)) edges[0], edges[-1] = -np.inf, np.inf tr, _ = np.histogram(train, bins=edges) rc, _ = np.histogram(recent, bins=edges) tr = np.clip(tr / tr.sum(), 1e-6, None) # avoid log(0) rc = np.clip(rc / rc.sum(), 1e-6, None) return float(np.sum((rc - tr) * np.log(rc / tr))) def drift_alert(score: float) -> dict: if score > 0.25: level = "SIGNIFICANT - alert + investigate" elif score > 0.1: level = "MODERATE - watch" else: level = "STABLE" return {"psi": round(score, 3), "verdict": level, "alert": score > 0.25} rng = np.random.default_rng(1) train_actuals = rng.normal(1000, 30, 200) # what the model learned on recent_actuals = rng.normal(1180, 30, 30) # a shifted-up recent window score = psi(train_actuals, recent_actuals) print(drift_alert(score)) # a stable window for contrast: print(drift_alert(psi(train_actuals, rng.normal(1000, 30, 30))))
Real world

PSI is a data-drift alarm, not a diagnosis. A high PSI says the input distribution moved - it does not say whether the learned relationship still holds. Pair it with the error monitor from Demo 1: distribution shifted + error rose after a refit points to concept drift, and that is when you escalate to the analyst rather than just retraining. Keep it simple and honest; a dependency-free PSI you understand beats a black-box drift library you do not.

Homework · capstone

Try it yourself - close your own loop ◐ 40-60 min total

Source material

Sources covered (cited)

Monitoring a live forecast is industry practice built on the evaluation canon this track taught. This session covers:

✓Industry practice - error tracking, data vs concept drift, retrain triggers, guardrails, the closed loopParts 1-2 + both demos · rolling MAPE monitor and PSI drift detector
◐Forecasting: Principles and Practice, 3rd ed (Hyndman & Athanasopoulos, FPP3)error metrics (MAPE/MASE) and the baseline carried into monitoring + fallback
◐M-competitions (Makridakis, M4/M5)robust simple models degrade more gracefully - the humility that survives to production
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the difference between data drift and concept drift?

Data drift often yields to a refit on the new data; concept drift may need a new model or features. Both raise error, so monitor inputs too, not just the error.

2 · Why alert on rolling error rather than a single day's error?

Every series has off days. Alert on the rolling metric crossing a threshold, anchored to the backtest, so you catch real degradation without alert fatigue.

3 · What should a guardrail do when the model output is absurd?

A boring correct baseline beats a spectacular wrong number. Sanity-bound the output, fall back to seasonal-naive on failure, and page a human - never serve nonsense.

Builder track cheat sheet - the whole journey · pin this

b1 · Series + splitdatetime index, resample to a clean grid, split by time, seasonal-naive baseline.
b2 · Decompose + baselinesSTL into trend/season/remainder; naive, drift, seasonal-naive - the lines to beat.
b3 · ETSSES → Holt → Holt-Winters; level, trend, seasonality as smoothed states.
b4 · ARIMA / SARIMAstationarity, differencing, ACF/PACF, auto_arima for the orders.
b5 · Prophetdecomposable trend + seasonality + holidays, changepoints; analyst-friendly.
b6 · ML forecastinglag/rolling/calendar features + gradient boosting; direct vs recursive, no leakage.
b7 · Deep (survey)DeepAR, N-BEATS, TFT - cited, not trained from scratch. Earn the complexity.
b8 · Backtest + metricsrolling-origin CV, MAPE/RMSE/MASE, prediction intervals and their coverage.
b9 · Productionizeretrain-then-serve, batch, store forecast + interval, the API + dashboard contract.
b10 · Monitorrolling-error tracker, data vs concept drift, retrain trigger, baseline guardrail, close the loop.
The rails, alwaysship an interval not a point · beat the baseline or don't ship · split by time, no leakage.
A forecast is never doneit is a living loop back to the b1 analyst. That is the whole point of this track.