Module 7 · Day 70 of 80

Forecasts in Production

About 2.5 hours
Video lesson coming soon
Day 70 · Forecasts in Production
Module 7: Forecasting with XGBoost & Chronos · The recording will appear here as soon as it is published. The written lesson below is available now.

What you'll learn today

  • run_forecasts.py publishes forecasts with run IDs, data and spec fingerprints, versions and benchmark forecasts
  • dbt models keep every forecast, the latest forecasts, and live accuracy against realised outcomes
  • Inputs outside the training range raise warnings; GX validates forecast coherence before publication
  • The forecast subflow runs in the daily refresh and after relevant StatCan releases, with policy rows documented
  • A model card states intended use, data, evaluation, limitations and monitoring
  • The dashboard shows forecasts, intervals, drivers, track record and caveats

Today's plan

Timed blocks for the day. Hover a bar for details.

LessonHands-on projectBreak2:30 total
Written lesson
TimeBlock
0:00 – 0:1570.1 From notebook results to a forecasting service
0:15 – 0:4570.2 run_forecasts.py: reproducible forecasts with metadata
0:45 – 0:53Break
0:53 – 1:1570.3 Forecast marts in dbt: history, latest, and live accuracy
1:15 – 1:3570.4 Quality gates and monitoring for forecasts
1:35 – 1:43Break
1:43 – 2:0570.5 Orchestration: the forecast subflow
2:05 – 2:2070.6 The model card and the forecast dashboard
2:20 – 2:3070.7 Module 7 recap and the road to causal inference

70.1 From Notebook Results to a Forecasting Service (15 min)

Backtests answer "how good would this have been?". Production answers "what do we forecast today, and how is it doing?". The principles you applied to data apply to forecasts:

Data platform principleFor forecasts
raw data is kept, versioned and traceable (Modules 3, 5)every published forecast is kept, with the data, code and model that produced it
marts are tested before use (Module 4)forecasts pass quality checks before reaching dashboards
quality is monitored over time (Day 47)live accuracy is tracked as outcomes arrive: a true out-of-sample test that no backtest can match
documented contracts (Days 40–41)a model card states intended use and limits
orchestration with retries and alerts (Module 6)forecasting is a task in the pipeline, triggered by new data

Retraining policy for this course:

  • Every run: refit the chosen models on all data available (cheap for our sizes) with fixed hyperparameters from MODEL_DECISIONS.md.
  • Quarterly: rerun the leaderboards (Days 63, 67–69) and review the model decisions. Changes are made deliberately, through a reviewed update to MODEL_DECISIONS.md and the specs, never automatically.

70.2 run_forecasts.py: Reproducible Forecasts with Metadata (30 min)

Create forecasting/run_forecasts.py (runs in .venv-ml; exit codes follow the platform convention: 0 ok · 1 failed · 2 warnings):

"""Produce today's CPI and GDP forecasts, intervals and explanations, with full metadata.

Usage:  python -m forecasting.run_forecasts
Writes: data/forecasts/published/run_<ts>.parquet (forecasts)
        data/forecasts/published/expl_<ts>.parquet (SHAP family contributions)
Exit:   0 ok · 1 failure · 2 warnings (e.g. inputs outside the training range)
"""
import hashlib
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path

import numpy as np
import pandas as pd
import xgboost

from forecasting.availability import build_features, load_monthly_panel, load_spec
from forecasting.data import MARTS, load_cpi, load_gdp
from forecasting.explain import xgb_contributions
from forecasting.features import univariate_cpi_features
from forecasting.models import xgb_factory
from forecasting.monitoring import out_of_range_report
from forecasting.nowcast import nowcast_dataset

OUT = Path("data/forecasts/published")
CPI_H = [1, 3, 6, 12]


def fingerprint(paths) -> str:
    """Hash of the input files' contents: identifies exactly which data produced a forecast."""
    h = hashlib.sha256()
    for p in sorted(paths):
        h.update(Path(p).read_bytes())
    return h.hexdigest()[:16]


def interval_from_backtest(errors: pd.Series, point: float) -> tuple[float, float]:
    lo, hi = np.quantile(errors.dropna(), [0.1, 0.9])
    return point + lo, point + hi


def cpi_forecasts(run_meta: dict, backtest: pd.DataFrame) -> tuple[list, list, list]:
    cpi, panel = load_cpi(), load_monthly_panel()
    spec = load_spec("forecasting/feature_spec_cpi.yml")
    X_mac, fam = build_features(panel, spec)
    X = univariate_cpi_features(cpi).join(X_mac, how="left").loc["2006-01":]
    y = cpi["cpi_yoy"]
    origin = y.index.max()
    rows, expl, warnings = [], [], []

    for h in CPI_H:
        target = y.shift(-h)
        known = X.index.shift(h, freq="MS") <= origin
        train = X.notna().any(axis=1) & target.notna() & known
        model = xgb_factory().fit(X[train], target[train])
        x0 = X.loc[[origin]]
        point = float(model.predict(x0)[0])
        bt = backtest.query("model == 'xgb_multivariate' and h == @h")
        lo, hi = interval_from_backtest(bt["forecast"] - bt["actual"], point)
        rows.append({**run_meta, "target": "cpi_yoy", "model": "xgb_multivariate", "horizon": h,
                     "origin": origin, "target_period": origin + pd.DateOffset(months=h),
                     "forecast": point, "q10": lo, "q90": hi})
        rows.append({**run_meta, "target": "cpi_yoy", "model": "naive", "horizon": h,
                     "origin": origin, "target_period": origin + pd.DateOffset(months=h),
                     "forecast": float(y.loc[origin]), "q10": np.nan, "q90": np.nan})
        c = xgb_contributions(model, x0).iloc[0]
        for feat, val in c.items():
            expl.append({**run_meta, "target": "cpi_yoy", "horizon": h, "feature": feat,
                         "family": fam.get(feat, "base_value" if feat == "base_value" else "own_history"),
                         "contribution": float(val)})
        warnings += out_of_range_report(X[train], x0, label=f"cpi h={h}")
    return rows, expl, warnings


def gdp_nowcast(run_meta: dict) -> tuple[list, list]:
    panel, gdp = load_monthly_panel(), load_gdp()["real_qoq_ann"]
    gspec = load_spec("forecasting/feature_spec_gdp.yml")
    rows, warnings = [], []
    for hq, label in [(0, "nowcast"), (1, "next_quarter")]:
        D = nowcast_dataset(panel, gdp, gspec, horizon_q=hq)
        feats = [c for c in D.columns if c not in ("target", "target_quarter", "target_known")]
        origin = D.index.max()
        train = D["target"].notna() & (D["target_known"] <= origin)
        model = xgb_factory().fit(D.loc[train, feats], D.loc[train, "target"])
        point = float(model.predict(D.loc[[origin], feats])[0])
        rows.append({**run_meta, "target": "real_gdp_qoq_ann", "model": f"xgb_{label}",
                     "horizon": hq, "origin": origin,
                     "target_period": D.loc[origin, "target_quarter"],
                     "forecast": point, "q10": np.nan, "q90": np.nan})
        warnings += out_of_range_report(D.loc[train, feats], D.loc[[origin], feats],
                                        label=f"gdp {label}")
    return rows, warnings


def main() -> int:
    OUT.mkdir(parents=True, exist_ok=True)
    now = datetime.now(timezone.utc)
    inputs = [MARTS / f for f in ("macro_monthly.parquet", "ml_cpi_monthly.parquet",
                                  "ml_gdp_quarterly.parquet")]
    run_meta = {
        "run_id": uuid.uuid4().hex[:12],
        "as_of": now,
        "data_fingerprint": fingerprint(inputs),
        "spec_fingerprint": fingerprint([Path("forecasting/feature_spec_cpi.yml"),
                                         Path("forecasting/feature_spec_gdp.yml")]),
        "xgboost_version": xgboost.__version__,
    }
    try:
        backtest = pd.read_parquet("data/forecasts/cpi_multivariate.parquet")
        cpi_rows, expl, w1 = cpi_forecasts(run_meta, backtest)
        gdp_rows, w2 = gdp_nowcast(run_meta)
    except Exception as e:
        print(f"✗ forecasting failed: {type(e).__name__}: {e}")
        return 1

    stamp = now.strftime("%Y%m%dT%H%M%S")
    pd.DataFrame(cpi_rows + gdp_rows).to_parquet(OUT / f"run_{stamp}.parquet", index=False)
    pd.DataFrame(expl).to_parquet(OUT / f"expl_{stamp}.parquet", index=False)
    print(f"✓ published run {run_meta['run_id']} ({len(cpi_rows) + len(gdp_rows)} forecasts)")

    warnings = w1 + w2
    for w in warnings:
        print(f"⚠ {w}")
    return 2 if warnings else 0


if __name__ == "__main__":
    sys.exit(main())

Every published row carries its provenance: run_id, as_of, a fingerprint of the input data, a fingerprint of the feature specs, and the library version. Six months from now you'll be able to answer "which data and which features produced the forecast we briefed in March?". That's the forecasting counterpart of Day 37's snapshots and Day 47's validation history.

The naive forecast is published alongside the model's, so live accuracy (70.3) always has its benchmark.


70.3 Forecast Marts in dbt: History, Latest, and Live Accuracy (23 min)

Published forecasts are data, so model them in dbt (Day 47's external-source pattern).

macro_dbt/models/staging/forecasts/_forecasts__sources.yml

version: 2
sources:
  - name: forecasts
    description: "Forecasts published by forecasting/run_forecasts.py (Module 7)."
    meta:
      external_location: "../data/forecasts/published/run_*.parquet"
    tables:
      - name: runs

stg_forecasts__runs.sql casts types: cast(origin as date), cast(target_period as date), cast(as_of as timestamp).

marts/forecasts/forecast_latest.sql

-- Grain: one row per target × model × horizon, from the most recent run.
with ranked as (
    select *,
           row_number() over (partition by target, model, horizon order by as_of desc) as rn
    from {{ ref('stg_forecasts__runs') }}
)
select * exclude (rn) from ranked where rn = 1

marts/forecasts/forecast_accuracy.sql: the payoff of keeping every forecast:

-- Grain: one row per published forecast whose outcome is now known.
-- This is TRUE out-of-sample evaluation: forecasts were made before outcomes existed.
with f as (
    select * from {{ ref('stg_forecasts__runs') }}
),

cpi_actual as (
    select month as target_period, cpi_yoy as actual
    from {{ ref('ml_cpi_monthly') }}
),

gdp_actual as (
    select quarter as target_period, real_qoq_ann as actual
    from {{ ref('ml_gdp_quarterly') }}
),

joined as (
    select f.*, a.actual
    from f
    join cpi_actual as a using (target_period)
    where f.target = 'cpi_yoy'
    union all by name
    select f.*, a.actual
    from f
    join gdp_actual as a using (target_period)
    where f.target = 'real_gdp_qoq_ann'
)

select
    *,
    forecast - actual                                   as error,
    abs(forecast - actual)                              as abs_error,
    case when q10 is not null
         then actual between q10 and q90 end            as inside_80
from joined
where actual is not null

Add a small summary mart, forecast_track_record: MAE and RMSE per target, model and horizon over the last 24 months of live forecasts, with the model's RMSE relative to the naive forecast published in the same runs.

Why this matters more than any backtest: live accuracy uses forecasts that were genuinely made before the outcomes. No leakage is possible, no tuning on the test period, and no pretraining contamination for Chronos. Over time, forecast_track_record becomes the most credible evidence about your models.


70.4 Quality Gates and Monitoring for Forecasts (20 min)

Inputs outside the training range

Trees can't extrapolate (Day 62.5). When today's inputs lie outside the range seen in training, the forecast deserves a warning. Create forecasting/monitoring.py:

"""Monitoring helpers: flag inputs outside the training distribution."""
import pandas as pd


def out_of_range_report(X_train: pd.DataFrame, x_now: pd.DataFrame, label: str,
                        z_limit: float = 4.0) -> list[str]:
    """Warn for features outside the training min–max, or more than z_limit std from the mean."""
    warnings = []
    for col in x_now.columns:
        v = x_now[col].iloc[0]
        s = X_train[col].dropna()
        if pd.isna(v) or s.empty:
            continue
        if v < s.min() or v > s.max():
            warnings.append(f"{label}: {col}={v:.2f} outside training range "
                            f"[{s.min():.2f}, {s.max():.2f}]")
        elif s.std() > 0 and abs(v - s.mean()) / s.std() > z_limit:
            warnings.append(f"{label}: {col}={v:.2f} is {abs(v - s.mean()) / s.std():.1f} std "
                            f"from its training mean")
    return warnings

run_forecasts.py returns exit code 2 when such warnings occur, so the forecast is published with a flag (Day 53's CompletedWithWarnings) and the dashboard can say so.

GX checks on published forecasts

Register marts.forecast_latest as a GX table asset (Day 43, setup_gx.py) and add a spec, quality/specs/forecast_latest.yml (Day 44 format):

description: "Latest published forecasts: completeness and coherence before dashboards use them."
asset: forecast_latest
batch_definition: full
owner: macro-team

critical:
  - type: ExpectColumnValuesToNotBeNull
    kwargs: {column: forecast}
    meta: {plan_id: F01, dimension: completeness}
  - type: ExpectColumnPairValuesAToBeGreaterThanB
    kwargs: {column_A: q90, column_B: forecast, or_equal: true, ignore_row_if: either_value_is_missing}
    meta: {plan_id: F02, dimension: consistency, rationale: "Upper bound ≥ point forecast."}
  - type: ExpectColumnPairValuesAToBeGreaterThanB
    kwargs: {column_A: forecast, column_B: q10, or_equal: true, ignore_row_if: either_value_is_missing}
    meta: {plan_id: F03, dimension: consistency, rationale: "Point forecast ≥ lower bound."}
  - type: ExpectTableRowCountToBeBetween
    kwargs: {min_value: 10}
    meta: {plan_id: F04, dimension: completeness,
           rationale: "4 CPI horizons × 2 models + 2 GDP targets."}

warning:
  - type: ExpectColumnValuesToBeBetween
    kwargs: {column: forecast, min_value: -5, max_value: 15, mostly: 1.0}
    meta: {plan_id: F05, dimension: plausibility,
           rationale: "CPI forecasts outside −5…15% (or GDP outside it) need review before publication."}

Rebuild suites (python quality/build_suites.py). The forecast checks join the warehouse critical/warning checkpoints, so run_quality.sh now protects forecasts too.


70.5 Orchestration: The Forecast Subflow (23 min)

Add the interpreter for the modelling environment to orchestration/settings.py:

ML_PY = str(REPO / ".venv-ml" / "bin" / "python")

and a step policy (Day 54):

    "forecast_models": dict(retries=1, delays=[300], timeout=1800,
                            transient=(1,), warning=(2,)),

(A Chronos model download or a temporary file-system issue is worth one retry. Exit code 2, inputs out of range, is a warning.)

Create orchestration/flows/forecast.py:

"""Forecast subflow: produce forecasts, model them in dbt, validate, publish."""
from prefect import flow

from orchestration.settings import ML_PY
from orchestration.tasks.commands import step
from orchestration.tasks.dbt import dbt_invoke


@flow(name="forecast")
def forecast_flow(target: str = "prod", drill: dict | None = None) -> dict:
    states = {"forecast_models": step("forecast_models",
                                      [ML_PY, "-m", "forecasting.run_forecasts"], drill=drill)}
    if states["forecast_models"].is_completed():
        states["dbt_forecasts"] = dbt_invoke.with_options(name="dbt:forecasts")(
            ["build", "--target", target, "--select", "source:forecasts+"], return_state=True)
    return states

Where it runs:

  • daily_refresh (Day 60): after quality_flow and before publish_flow, so dashboards include the newest forecasts. If forecast_models fails, the policy publishes dashboards without updated forecasts (yesterday's remain, with their as_of dates visible) and ends the run CompletedWithWarnings.
  • statcan_release (Day 59): after a CPI, LFS or GDP release is published, call forecast_flow(), so new information reaches the forecast within minutes.

Forecasting reads only Parquet marts, so it needs no DuckDB lock. The dbt step that models forecasts is a writer, and dbt_invoke already takes the writer slot (Day 59). Update STATE_POLICY.md with the new rows.


70.6 The Model Card and the Forecast Dashboard (15 min)

forecasting/MODEL_CARD.md

A model card (Mitchell et al., 2019) documents a model the way a data contract documents a dataset. Write one with these sections:

# Model card — Canadian CPI inflation and real GDP growth forecasts

## Intended use
Statistical forecasts of CPI yoy inflation (1–12 months) and real GDP growth (nowcast, next quarter)
for internal briefings and dashboards. NOT for: causal policy evaluation; official projections.

## Models
- CPI: XGBoost (direct, one model per horizon), multivariate features (feature_spec_cpi.yml);
  benchmark: naive (published alongside). Hyperparameters: MODEL_DECISIONS.md.
- GDP: XGBoost nowcast/next-quarter on ragged-edge monthly indicators (feature_spec_gdp.yml).

## Data
Marts: ml_cpi_monthly, macro_monthly, ml_gdp_quarterly (dbt, tested; GX-validated).
Publication lags encoded per feature. GDP target = latest vintage.

## Evaluation
Pseudo-real-time rolling-origin backtests from 2014 (CPI) / 2012 (GDP); Diebold–Mariano vs benchmarks;
interval coverage; results in data/forecasts/*leaderboard*. Live track record: marts.forecast_track_record.

## Limitations and caveats
- Tree models cannot extrapolate beyond the training range (flagged at run time).
- 2020–21 outliers dominate GDP errors; results reported with and without.
- Backtests use revised data for LFS, OECD CLI and GDP (pseudo-real-time).
- SHAP explanations describe the model's associations, not causal effects.
- Chronos (comparator) may have seen public macro series in pretraining.

## Monitoring
Out-of-range input warnings (exit code 2); GX checks F01–F05; live accuracy vs naive; explanation drift.

## Ownership and review
Owner: macro-team. Quarterly re-evaluation; changes via reviewed MODEL_DECISIONS.md updates.

The forecast dashboard

Extend Day 66's forecast_explained page in dashboards/build_dashboards.py to read the dbt-exported marts (forecast_latest, forecast_track_record, and the latest expl_*.parquet):

  1. CPI fan chart: history, model forecast with the 80% band, and the naive forecast for contrast.
  2. GDP nowcast for the current quarter, with the latest published quarter beside it.
  3. Drivers: SHAP family contributions for the 12-month CPI forecast.
  4. Track record: live RMSE vs naive over time, from forecast_track_record.
  5. The header shows the forecast as_of, any out-of-range warning, and the model card's one-line caveat.

Add an exposure forecast_dashboard depending on forecast_latest and forecast_track_record (Day 40), and add the latest 12-month CPI forecast and GDP nowcast as KPIs in macro_kpis (Day 39).


70.7 Module 7 Recap and the Road to Causal Inference (10 min)

What you built in Module 7 (Days 61–70)

DaysCapability
61ŷ vs β̂; a leak-free rolling-origin harness; benchmarks and metrics
62trees, forests, boosting from scratch, XGBoost; no extrapolation
63AR benchmarks; direct vs recursive; Diebold–Mariano; prediction intervals
64leak-free tuning with time-series CV; nested evaluation
65–66SHAP: theory, global and local explanations, decompositions over time, explanation drift
67zero-shot Chronos; probabilistic scoring; combinations; contamination checks
68multivariate CPI forecasts with publication lags; ablation by information family
69GDP nowcasting with a ragged edge; robust losses; honest COVID reporting
70production forecasts with provenance, live accuracy, quality gates, orchestration, a model card

How this completes the capstone's "Model & Forecast" row

Capstone deliverableWhere
next-quarter GDP forecastsDay 69 nowcast and next-quarter models, published daily (Day 70)
CPI forecastsDays 68–70, with explanations and track record
integrated with the Prefect-orchestrated pipelineDay 70's forecast subflow, triggered by schedules and StatCan releases
interactive visualisation layerthe forecast dashboard page and cockpit KPIs

Looking ahead: Module 8 — Causal Inference with DoubleML (Days 71–80)

Forecasting asks what will happen. Policy asks what would happen if…. Throughout this module we warned that SHAP values and feature importance are not causal. Module 8 tackles causality directly:

  • Days 71–73: causal inference theory, confounding, and directed acyclic graphs (DAGs) for macro questions.
  • Days 74–77: nuisance parameters, cross-fitting and the partially linear model: using flexible ML (including the XGBoost skills you've just built) to control for confounders, while estimating a causal parameter with valid standard errors.
  • Days 78–80: estimating the causal impact of Bank of Canada overnight rate hikes on housing starts, controlling for business-cycle indicators. You'll add a housing-starts series through the platform you built (a new dlt source or StatCan table via WAP, a staging model, tests and a mart), the same path every series in this course has followed.

Day 70 checklist

  • run_forecasts.py publishes forecasts with run IDs, data and spec fingerprints, versions and benchmark forecasts
  • dbt models keep every forecast, the latest forecasts, and live accuracy against realised outcomes
  • Inputs outside the training range raise warnings; GX validates forecast coherence before publication
  • The forecast subflow runs in the daily refresh and after relevant StatCan releases, with policy rows documented
  • A model card states intended use, data, evaluation, limitations and monitoring
  • The dashboard shows forecasts, intervals, drivers, track record and caveats
git add .
git commit -m "Day 70: production forecasts, live accuracy, quality gates, model card — Module 7 complete"
git push