Ship a demand forecast and capacity-alert artifact with rolling backtests, alert review, and retraining policy.
The ranking capstone influenced which products users could purchase. Warehouse teams now need an operational input: forecast daily parcel volume by fulfillment center so staffing and packing capacity can be planned before demand arrives.
This capstone ships a forecast and alert artifact. It doesn't automatically hire labor, move inventory, or page an operator on every miss. It creates a versioned expectation, detects unusually large forecast errors, and records evidence for a planner's human-in-the-loop decision.
Earlier, you built a same-weekday seasonal baseline and separated forecast errors from anomaly review. This capstone packages those mechanics into a release candidate with shadow evidence and a rollback pointer.
Predict daily shipped parcels for each warehouse seven days ahead. Use an explicit planning contract:
| Field | Contract |
|---|---|
| entity | fulfillment center and shipping service tier |
| target | parcels shipped per calendar day |
| horizon | next seven days |
| decision | planner reviews capacity when forecast or alert requires it |
| baseline | same weekday from prior week |
| evaluation | MAE plus underforecast cost by high-volume slice |
Demand can change around promotions, holidays, seller campaigns, inventory shortages, and data outages. Those known drivers should appear as features only if they are scheduled and available before the forecast cutoff.
Hyndman and Athanasopoulos explain why forecast evaluation must use later observations and rolling forecasting origins rather than random splits.[1] For this project, each backtest run records its training cutoff, horizon, model version, and the actual values that arrived afterward.
Your repository surface should look like:
1demand-forecast/
2 data/
3 warehouse_daily_counts.parquet
4 planned_events.json
5 split_manifest.json
6 forecasting/
7 seasonal_baseline.py
8 train_candidate.py
9 rolling_backtest.py
10 alerts/
11 forecast_error_policy.json
12 evaluate_alerts.py
13 reports/
14 backtest_metrics.json
15 alert_review.csv
16 tests/
17 test_future_rows_excluded.py
18 test_observation_join.py
19 test_alert_contract.pyThe candidate can be a tree model over lag features, rolling means, service tier, weekday, and known promotions. It must beat the seasonal baseline on later windows, especially where underforecasting is expensive. A candidate that marginally improves MAE but misses peak-volume days should remain blocked.
The runnable receipt below starts after training. It freezes two seven-day forecast windows issued one week apart. Each issued row stores the cutoff, target date, horizon, baseline, candidate, expected range, and known event context before the target day arrives.
Actual parcel counts belong in a separate append-only observation stream. The backtest joins each later observation to the immutable forecast it evaluates. A real pipeline should materialize the same boundary after every rolling-origin fold.
| Stream | Stored before replay | Arrival time |
|---|---|---|
| issued forecast | center, tier, issue date, target date, horizon, model outputs, range, known-event context | before target day |
| observation | forecast ID, observed date, actual parcel count | on or after target day |
| alert | joined forecast ID, range breach, policies, owner, resolution | after observation join |
1from dataclasses import dataclass
2from datetime import date
3import json
4
5@dataclass(frozen=True)
6class IssuedForecast:
7 forecast_id: str
8 fold: str
9 issued_at: date
10 target_date: date
11 horizon_day: int
12 center: str
13 service_tier: str
14 baseline: int
15 candidate: int
16 lower: int
17 upper: int
18 scheduled_event: str | None = None
19 event_known_at: date | None = None
20
21@dataclass(frozen=True)
22class Observation:
23 forecast_id: str
24 observed_at: date
25 actual: int
26
27@dataclass(frozen=True)
28class BacktestRow:
29 issued: IssuedForecast
30 observation: Observation
31
32INTERVAL_HALF_WIDTH = 6
33INTERVAL_POLICY = "candidate-plus-minus-6-v1"
34
35def issue_forecast(
36 fold: str,
37 issued_at: date,
38 target_date: date,
39 center: str,
40 service_tier: str,
41 baseline: int,
42 candidate: int,
43 scheduled_event: str | None = None,
44 event_known_at: date | None = None,
45) -> IssuedForecast:
46 horizon_day = (target_date - issued_at).days
47 return IssuedForecast(
48 forecast_id=f"{center}:{service_tier}:{issued_at}:{target_date}",
49 fold=fold,
50 issued_at=issued_at,
51 target_date=target_date,
52 horizon_day=horizon_day,
53 center=center,
54 service_tier=service_tier,
55 baseline=baseline,
56 candidate=candidate,
57 lower=candidate - INTERVAL_HALF_WIDTH,
58 upper=candidate + INTERVAL_HALF_WIDTH,
59 scheduled_event=scheduled_event,
60 event_known_at=event_known_at,
61 )
62
63print("interval_policy:", INTERVAL_POLICY, "half_width:", INTERVAL_HALF_WIDTH)1interval_policy: candidate-plus-minus-6-v1 half_width: 61ISSUED_FORECASTS = [
2 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 2), "FC-A", "standard", 100, 103),
3 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 3), "FC-A", "standard", 112, 111),
4 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 4), "FC-A", "standard", 115, 117),
5 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 5), "FC-A", "standard", 118, 117),
6 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 6), "FC-A", "standard", 132, 140, "seller-campaign", date(2026, 1, 29)),
7 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 7), "FC-A", "standard", 82, 83),
8 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 8), "FC-A", "standard", 76, 77),
9 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 9), "FC-A", "standard", 104, 105),
10 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 10), "FC-A", "standard", 110, 112),
11 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 11), "FC-A", "standard", 119, 120),
12 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 12), "FC-A", "standard", 116, 119),
13 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 13), "FC-A", "standard", 160, 164, "seller-campaign", date(2026, 2, 5)),
14 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 14), "FC-A", "standard", 84, 85),
15 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 15), "FC-A", "standard", 78, 79),
16]
17
18ACTUALS = [104, 110, 119, 116, 160, 84, 78, 106, 113, 121, 118, 168, 86, 80]
19OBSERVATIONS = [
20 Observation(forecast.forecast_id, forecast.target_date, actual)
21 for forecast, actual in zip(ISSUED_FORECASTS, ACTUALS, strict=True)
22]
23issued_by_id = {row.forecast_id: row for row in ISSUED_FORECASTS}
24observations_by_forecast_id = {row.forecast_id: row for row in OBSERVATIONS}
25
26print("issued forecasts:", len(ISSUED_FORECASTS))
27print("later observations:", len(OBSERVATIONS))1ROWS = [
2 BacktestRow(forecast, observations_by_forecast_id[forecast.forecast_id])
3 for forecast in ISSUED_FORECASTS
4 if forecast.forecast_id in observations_by_forecast_id
5]
6
7folds = sorted({row.fold for row in ISSUED_FORECASTS})
8for fold in folds:
9 rows = [row for row in ISSUED_FORECASTS if row.fold == fold]
10 print(
11 f"{fold}: cutoff={rows[0].issued_at}",
12 f"window={rows[0].target_date}..{rows[-1].target_date}",
13 f"rows={len(rows)}",
14 )
15print("first forecast id:", ISSUED_FORECASTS[0].forecast_id)
16print("joined backtest rows:", len(ROWS))1fold-1: cutoff=2026-02-01 window=2026-02-02..2026-02-08 rows=7
2fold-2: cutoff=2026-02-08 window=2026-02-09..2026-02-15 rows=7
3first forecast id: FC-A:standard:2026-02-01:2026-02-02
4joined backtest rows: 14The fixture is intentionally compact: one fulfillment center and one service tier make every row inspectable. Its ID includes center, tier, issue date, and target date so additional series can't collide during replay. A production report should repeat the same contract by center, tier, horizon, and event slice.
Mean absolute error (MAE) answers how far point forecasts miss on average. Capacity planning needs a second view because a large underforecast on a peak day can cost more than a small overforecast on a routine day. The next cell assigns a local cost of 3 units to each underforecast parcel on a day with at least 150 observed parcels.
An expected range answers a different question. It should contain a stated share of later observations when measured across enough held-out windows. Hyndman and Athanasopoulos describe prediction intervals as forecast ranges with a specified coverage probability and explain why distributional forecasts need their own accuracy measures.[1] The local candidate ± 6 policy only demonstrates release plumbing; it isn't a calibrated production interval.
1def mae(field: str) -> float:
2 return sum(
3 abs(row.observation.actual - getattr(row.issued, field))
4 for row in ROWS
5 ) / len(ROWS)
6
7def peak_underforecast_cost(field: str) -> int:
8 return sum(
9 3 * max(row.observation.actual - getattr(row.issued, field), 0)
10 for row in ROWS
11 if row.observation.actual >= 150
12 )
13
14def coverage() -> float:
15 return sum(
16 row.issued.lower <= row.observation.actual <= row.issued.upper
17 for row in ROWS
18 ) / len(ROWS)
19
20ALERT_POLICY = "outside-range-review-v1"
21ALERT_OWNER = "capacity-ops"
22CANDIDATE_FORECAST = "warehouse-demand-v1"
23PREVIOUS_FORECAST = "warehouse-demand-v0"
24
25forecast_metrics = {
26 "baseline_mae": round(mae("baseline"), 3),
27 "candidate_mae": round(mae("candidate"), 3),
28 "baseline_peak_underforecast_cost": peak_underforecast_cost("baseline"),
29 "candidate_peak_underforecast_cost": peak_underforecast_cost("candidate"),
30 "range_coverage": round(coverage(), 3),
31 "range_rows": len(ROWS),
32}
33
34print(json.dumps(forecast_metrics, indent=2))1new_alerts = [
2 {
3 "forecast_id": row.issued.forecast_id,
4 "forecast_version": CANDIDATE_FORECAST,
5 "interval_policy": INTERVAL_POLICY,
6 "policy_version": ALERT_POLICY,
7 "owner": ALERT_OWNER,
8 "issued_at": str(row.issued.issued_at),
9 "target_date": str(row.issued.target_date),
10 "horizon_day": row.issued.horizon_day,
11 "center": row.issued.center,
12 "service_tier": row.issued.service_tier,
13 "expected_range": [row.issued.lower, row.issued.upper],
14 "observed_at": str(row.observation.observed_at),
15 "observed": row.observation.actual,
16 "error": row.observation.actual - row.issued.candidate,
17 "scheduled_event": row.issued.scheduled_event,
18 "event_known_at": str(row.issued.event_known_at) if row.issued.event_known_at else None,
19 "resolution": None,
20 }
21 for row in ROWS
22 if not row.issued.lower <= row.observation.actual <= row.issued.upper
23]
24
25print("new alerts:", json.dumps(new_alerts, indent=2))1new alerts: [
2 {
3 "forecast_id": "FC-A:standard:2026-02-01:2026-02-06",
4 "forecast_version": "warehouse-demand-v1",
5 "interval_policy": "candidate-plus-minus-6-v1",
6 "policy_version": "outside-range-review-v1",
7 "owner": "capacity-ops",
8 "issued_at": "2026-02-01",
9 "target_date": "2026-02-06",
10 "horizon_day": 5,
11 "center": "FC-A",
12 "service_tier": "standard",
13 "expected_range": [
14 134,
15 146
16 ],
17 "observed_at": "2026-02-06",
18 "observed": 160,
19 "error": 20,
20 "scheduled_event": "seller-campaign",
21 "event_known_at": "2026-01-29",
22 "resolution": null
23 }
24]The candidate improves average error and peak-day cost, but one seller-campaign day still escapes its expected range. That row belongs in a planner queue. The queue preserves which immutable forecast was issued, what happened later, who owns review, and which interval and alert policies created the alert.
Forecast quality and alert usefulness require separate evidence. A narrow range can create an exhausting queue. A broad range can hide events a planner needed to see. Measure alert precision and recall on historical alerts after reviewers label whether each event required action. Keep the forecast and alert-policy versions on those review rows; otherwise a candidate could pass using evidence produced by a different model or threshold.
The final cell publishes one candidate receipt. It advances to planner shadow review, not production replacement. The previous forecast alias remains explicit so a later promotion process can roll back cleanly.
1REVIEWED_ALERTS = [
2 {
3 "alert_id": "A-101",
4 "forecast_version": CANDIDATE_FORECAST,
5 "policy_version": ALERT_POLICY,
6 "triggered": True,
7 "actionable": True,
8 },
9 {
10 "alert_id": "A-102",
11 "forecast_version": CANDIDATE_FORECAST,
12 "policy_version": ALERT_POLICY,
13 "triggered": True,
14 "actionable": True,
15 },
16 {
17 "alert_id": "A-103",
18 "forecast_version": CANDIDATE_FORECAST,
19 "policy_version": ALERT_POLICY,
20 "triggered": True,
21 "actionable": False,
22 },
23 {
24 "alert_id": "A-104",
25 "forecast_version": CANDIDATE_FORECAST,
26 "policy_version": ALERT_POLICY,
27 "triggered": False,
28 "actionable": True,
29 },
30 {
31 "alert_id": "A-105",
32 "forecast_version": CANDIDATE_FORECAST,
33 "policy_version": ALERT_POLICY,
34 "triggered": False,
35 "actionable": False,
36 },
37]
38
39true_positives = sum(row["triggered"] and row["actionable"] for row in REVIEWED_ALERTS)
40false_positives = sum(row["triggered"] and not row["actionable"] for row in REVIEWED_ALERTS)
41false_negatives = sum(not row["triggered"] and row["actionable"] for row in REVIEWED_ALERTS)
42
43def rate_or_none(numerator: int, denominator: int) -> float | None:
44 return round(numerator / denominator, 3) if denominator else None
45
46alert_review = {
47 "forecast_version": CANDIDATE_FORECAST,
48 "policy_version": ALERT_POLICY,
49 "precision": rate_or_none(true_positives, true_positives + false_positives),
50 "recall": rate_or_none(true_positives, true_positives + false_negatives),
51 "reviewed_rows": len(REVIEWED_ALERTS),
52}
53
54print("alert_review:", alert_review)1required_alert_fields = {
2 "forecast_id", "forecast_version", "interval_policy", "policy_version", "owner",
3 "issued_at", "target_date", "horizon_day", "center", "service_tier",
4 "expected_range", "observed_at", "observed", "error", "scheduled_event",
5 "event_known_at", "resolution",
6}
7release_gates = {
8 "issued_forecast_ids_unique": len(issued_by_id) == len(ISSUED_FORECASTS),
9 "observation_forecast_ids_unique": len(observations_by_forecast_id) == len(OBSERVATIONS),
10 "observations_join_issued_forecasts": all(
11 row.forecast_id in issued_by_id
12 for row in OBSERVATIONS
13 ),
14 "issued_forecasts_have_observations": len(ROWS) == len(ISSUED_FORECASTS),
15 "targets_after_cutoff": all(row.issued.issued_at < row.issued.target_date for row in ROWS),
16 "horizons_match_dates": all(
17 row.issued.horizon_day == (row.issued.target_date - row.issued.issued_at).days
18 for row in ROWS
19 ),
20 "horizons_within_seven_day_contract": all(
21 1 <= row.issued.horizon_day <= 7
22 for row in ROWS
23 ),
24 "observations_arrive_on_or_after_target": all(
25 row.observation.observed_at >= row.issued.target_date
26 for row in ROWS
27 ),
28 "scheduled_events_known_by_cutoff": all(
29 row.event_known_at is None or row.event_known_at <= row.issued_at
30 for row in ISSUED_FORECASTS
31 ),
32 "multiple_rolling_origins": len({row.issued_at for row in ISSUED_FORECASTS}) >= 2,
33 "candidate_beats_baseline_mae": forecast_metrics["candidate_mae"] < forecast_metrics["baseline_mae"],
34 "candidate_reduces_peak_underforecast_cost": (
35 forecast_metrics["candidate_peak_underforecast_cost"]
36 < forecast_metrics["baseline_peak_underforecast_cost"]
37 ),
38 "local_range_coverage_at_least_0_85": forecast_metrics["range_coverage"] >= 0.85,
39 "shadow_alert_count_at_most_3": len(new_alerts) <= 3,
40 "alert_review_matches_candidate_policy": all(
41 row["forecast_version"] == CANDIDATE_FORECAST
42 and row["policy_version"] == ALERT_POLICY
43 for row in REVIEWED_ALERTS
44 ),
45 "alert_precision_evidence_at_least_0_60": (
46 alert_review["precision"] is not None
47 and alert_review["precision"] >= 0.60
48 ),
49 "alert_recall_evidence_at_least_0_60": (
50 alert_review["recall"] is not None
51 and alert_review["recall"] >= 0.60
52 ),
53 "alert_rows_replayable": all(required_alert_fields <= row.keys() for row in new_alerts),
54 "rollback_pointer_recorded": bool(PREVIOUS_FORECAST),
55}
56
57print("release_gates_pass:", all(release_gates.values()))1receipt = {
2 "candidate_forecast": CANDIDATE_FORECAST,
3 "previous_forecast": PREVIOUS_FORECAST,
4 "latest_rolling_origin": str(max(row.issued_at for row in ISSUED_FORECASTS)),
5 "interval_policy": INTERVAL_POLICY,
6 "alert_policy": ALERT_POLICY,
7 "owner": ALERT_OWNER,
8 "replay": {
9 "issued_forecast_rows": len(ISSUED_FORECASTS),
10 "joined_observation_rows": len(ROWS),
11 },
12 "backtest": forecast_metrics,
13 "alert_review": alert_review,
14 "release_gates": release_gates,
15 "candidate_decision": "candidate_for_planner_shadow_review" if all(release_gates.values()) else "hold",
16}
17
18print("candidate_decision:", receipt["candidate_decision"])1assert receipt["candidate_forecast"] == CANDIDATE_FORECAST
2assert receipt["previous_forecast"] == PREVIOUS_FORECAST
3assert receipt["backtest"]["candidate_mae"] < receipt["backtest"]["baseline_mae"]
4print("receipt keys:", sorted(receipt))1print(json.dumps(receipt, indent=2))1{
2 "candidate_forecast": "warehouse-demand-v1",
3 "previous_forecast": "warehouse-demand-v0",
4 "latest_rolling_origin": "2026-02-08",
5 "interval_policy": "candidate-plus-minus-6-v1",
6 "alert_policy": "outside-range-review-v1",
7 "owner": "capacity-ops",
8 "replay": {
9 "issued_forecast_rows": 14,
10 "joined_observation_rows": 14
11 },
12 "backtest": {
13 "baseline_mae": 4.643,
14 "candidate_mae": 2.643,
15 "baseline_peak_underforecast_cost": 108,
16 "candidate_peak_underforecast_cost": 72,
17 "range_coverage": 0.929,
18 "range_rows": 14
19 },
20 "alert_review": {
21 "forecast_version": "warehouse-demand-v1",
22 "policy_version": "outside-range-review-v1",
23 "precision": 0.667,
24 "recall": 0.667,
25 "reviewed_rows": 5
26 },
27 "release_gates": {
28 "issued_forecast_ids_unique": true,
29 "observation_forecast_ids_unique": true,
30 "observations_join_issued_forecasts": true,
31 "issued_forecasts_have_observations": true,
32 "targets_after_cutoff": true,
33 "horizons_match_dates": true,
34 "horizons_within_seven_day_contract": true,
35 "observations_arrive_on_or_after_target": true,
36 "scheduled_events_known_by_cutoff": true,
37 "multiple_rolling_origins": true,
38 "candidate_beats_baseline_mae": true,
39 "candidate_reduces_peak_underforecast_cost": true,
40 "local_range_coverage_at_least_0_85": true,
41 "shadow_alert_count_at_most_3": true,
42 "alert_review_matches_candidate_policy": true,
43 "alert_precision_evidence_at_least_0_60": true,
44 "alert_recall_evidence_at_least_0_60": true,
45 "alert_rows_replayable": true,
46 "rollback_pointer_recorded": true
47 },
48 "candidate_decision": "candidate_for_planner_shadow_review"
49}candidate_for_planner_shadow_review is intentionally narrower than launch approval. Frozen forecasts, later observation joins, and reviewed alerts say this bundle deserves planner observation beside current production forecasts. They don't prove that every center, tier, event slice, or future week will behave well. A reviewed-alert window with no triggered or actionable rows reports None, not an invented precision or recall score.
New outcomes arrive daily, but model replacement should happen on a scheduled or triggered review cycle. Store:
| Operational item | Required decision |
|---|---|
| daily observation join | append actual count, then join it to immutable issued forecast ID |
| weekly accuracy report | compare baseline, production, and shadow candidate by slice |
| range coverage report | measure later-window coverage by center, tier, and horizon |
| alert resolution review | classify actionable, expected, or data issue |
| retraining trigger | investigate sustained cost regression before fitting replacement |
| promotion gate | rerun rolling backtest, protected slices, shadow review, and rollback check |
Use the runnable examples as a release harness. Change one condition at a time, predict the failure, then rerun the examples.
fold-2 issue date from 2026-02-08 to 2026-02-15. Which temporal gates fail?fold-1 Friday candidate from 140 to 132. Which cost gets worse even though only one row changed?INTERVAL_HALF_WIDTH = 0. Why can MAE stay unchanged while range and queue gates fail?A-104 as not actionable. Which alert metric improves, and which stays unchanged?PREVIOUS_FORECAST = "". Which executable gate fails?missing:standard:2026-02-01:2026-02-02. Which replay gate fails?triggered and actionable values to False. Why do alert-evidence gates fail instead of crashing or passing?policy_version to outside-range-review-v0. Which provenance gate fails?| Artifact | Strong submission demonstrates |
|---|---|
| forecast package | immutable issued rows, later observation joins, time-aware folds, seasonal baseline, expected ranges, and previous alias |
| forecast evaluation | MAE, peak underforecast cost, later-window range coverage, and slice review |
| alert workflow | replayable alert rows, reviewed precision and recall, ownership, and resolution logging |
| operations | shadow comparison, retraining investigation, promotion gates, and rollback plan |
| Symptom | Cause | Fix |
|---|---|---|
| Backtest looks precise but live peaks miss | future or event leakage | freeze cutoff and known-in-advance fields |
| Backtest evidence changes after actuals arrive | issued forecast row was updated in place | append observations separately and join by stable forecast ID |
| One center's observation joins another center's forecast | forecast ID omits series identity | include center, tier, issue date, and target date in forecast ID |
| MAE improves while staffing misses stay expensive | peak slice absent from release gate | price high-volume underforecast cost separately |
| Planner receives noisy alerts | range policy has no reviewed outcomes | measure alert precision, recall, and queue volume |
| Alert metrics pass using an older threshold | review rows omit candidate and policy versions | bind every reviewed row to the forecast and alert policy under release |
| Forecast range looks reliable but later coverage fails | interval evidence is too small or in sample | measure held-out coverage by horizon and slice |
| Candidate advances without rollback target | receipt omits previous alias | publish immutable candidate and rollback pointer together |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Questions and insights from fellow learners.