| Time | Block |
|---|---|
| 0:00 – 0:20 | 69.1 GDP is different: quarterly, late and revised |
| 0:20 – 0:40 | 69.2 A long quarterly history: the ml_gdp_quarterly mart |
| 0:40 – 0:48 | Break |
| 0:48 – 1:20 | 69.3 Nowcasting with a ragged edge |
| 1:20 – 1:43 | 69.4 Evaluating nowcasts: error by month of the quarter |
| 1:43 – 1:50 | Break |
| 1:50 – 2:08 | 69.5 COVID outliers: robust losses and honest reporting |
| 2:08 – 2:30 | 69.6 Mini-project: the GDP leaderboard |
69.1 GDP Is Different: Quarterly, Late and Revised (20 min)
| CPI | Real GDP | |
|---|---|---|
| frequency | monthly | quarterly |
| delay | ~3 weeks after the month | about two months after the quarter ends |
| revisions | essentially none | substantial and repeated |
| volatility | moderate | extreme in 2020 (lockdown collapse and rebound) |
| sample since 1992 | ~400 months | ~130 quarters |
Because GDP arrives late, much of the policy interest is in nowcasting: estimating growth for the current quarter (or the one just ended) before StatCan publishes it, using monthly indicators that arrive earlier. Central banks and private forecasters run nowcasting models continuously for this reason.
We'll build two related targets:
- nowcast (h = 0): annualised real GDP growth of the quarter containing the origin month;
- forecast (h = 1): growth of the next quarter.
69.2 A Long Quarterly History: the ml_gdp_quarterly Mart (20 min)
macro_quarterly starts in 2006, which is too short. Add a modelling mart with the full GDP history in macro_dbt/models/marts/ml/ml_gdp_quarterly.sql:
-- Grain: one row per quarter (first month of quarter). Real GDP growth inputs for Module 7.
-- StatCan 36-10-0104-01, SAAR, chained dollars. Values are the LATEST vintage (revised).
select
quarter,
real_gdp,
real_qoq,
real_qoq_ann,
real_yoy
from {{ ref('int_gdp__growth') }}
where real_gdp is not null
Add tests (unique quarter, quarterly sequential_values) and add it to the forecasting_models exposure (Day 61). Then build it (in .venv, with no pipeline running) and confirm the Parquet export:
source .venv/bin/activate
cd macro_dbt && dbt build --target prod --select ml_gdp_quarterly && cd ..
source .venv-ml/bin/activate
Add a loader to forecasting/data.py:
def load_gdp(start: str = "1992-01-01") -> pd.DataFrame:
g = pd.read_parquet(MARTS / "ml_gdp_quarterly.parquet")
g["quarter"] = pd.to_datetime(g["quarter"])
g = g.set_index("quarter").sort_index().asfreq("QS")
return g.loc[start:]
Which vintage are we forecasting?
The mart holds the latest estimates. A forecast made in 2015 is therefore judged against GDP as revised today, not as first published. Both targets are defensible:
| Target | Pro | Con |
|---|---|---|
| latest vintage | the best estimate of what actually happened | forecasters were trying to predict first releases at the time |
| first release | what markets and media reacted to | needs a vintage history (growing in your platform since Day 27) |
We use the latest vintage and document it. As your gdp_vintages table accumulates, you can compare. A good exercise for the end of the course.
69.3 Nowcasting with a Ragged Edge (33 min)
At any moment, the latest available data ends at different dates for different series: daily market data up to yesterday, the LFS for last month, the OECD CLI two months back, GDP for the quarter before last. Plotted, the end of the dataset is jagged, hence the ragged edge.
Design: monthly origins, quarterly targets
Each month-end is an origin. At origin m:
- the target quarter is the quarter containing m (for the nowcast);
- for each monthly indicator, average the months of the target quarter that are already published (quarter-to-date), using its lag from the spec;
- record how many months of the quarter are known (1, 2 or 3): the nowcast should improve as the quarter fills in;
- add the last published GDP growth: the quarter whose release (about two months after quarter end) has happened by m.
Create forecasting/feature_spec_gdp.yml:
target: real_qoq_ann
gdp_release_lag_months: 2 # quarter q is published ~2 months after it ends
features:
- {name: unemp, column: unemployment_rate, lag: 0, family: labour}
- {name: overnight, column: overnight_rate, lag: 0, family: monetary}
- {name: slope, column: spread_10_2_bp, lag: 0, family: financial}
- {name: wti, column: wti_eom, lag: 0, family: external}
- {name: usd_cad, column: usd_cad_eom, lag: 0, family: external}
- {name: oecd_cli, column: oecd_cli, lag: 2, family: cycle}
- {name: core_yoy, column: core_ex_fe_yoy, lag: 0, family: prices}
(Because the origin is the end of month m, LFS data for m is published early in m+1, so treat it as lag 1 if you want to be conservative. Decide, write the decision in the spec comment, and be consistent.)
Create forecasting/nowcast.py:
"""Ragged-edge nowcasting dataset for quarterly real GDP growth from monthly indicators."""
import numpy as np
import pandas as pd
def last_published_quarter(origin: pd.Timestamp, release_lag: int) -> pd.Period:
"""Most recent quarter whose GDP has been released by the end of `origin`'s month."""
ref = (origin - pd.DateOffset(months=release_lag)).to_period("M")
q = ref.asfreq("Q")
return q if ref.month % 3 == 0 else q - 1 # quarter must have ended by `ref`
def nowcast_dataset(panel: pd.DataFrame, gdp: pd.Series, spec: dict,
horizon_q: int = 0) -> pd.DataFrame:
"""One row per monthly origin: features known at the origin, target = GDP growth of
the quarter containing the origin (+ horizon_q quarters), and the date it becomes known."""
rows = []
lag_gdp = spec["gdp_release_lag_months"]
for m in panel.index:
tq = m.to_period("Q") + horizon_q # target quarter
q_start = (m.to_period("Q")).start_time
row = {"origin": m, "target_quarter": tq.start_time,
"month_in_quarter": (m.month - 1) % 3 + 1,
"target_known": (tq.end_time + pd.DateOffset(months=lag_gdp)).to_period("M")
.start_time}
for f in spec["features"]:
avail_end = m - pd.DateOffset(months=f["lag"])
qtd = panel.loc[q_start:avail_end, f["column"]].dropna()
hist = panel.loc[:avail_end, f["column"]].dropna()
row[f"{f['name']}_qtd"] = qtd.mean() if len(qtd) else np.nan
row[f"{f['name']}_last"] = hist.iloc[-1] if len(hist) else np.nan
row[f"{f['name']}_chg3"] = (hist.iloc[-1] - hist.iloc[-4]) if len(hist) > 3 else np.nan
pq = last_published_quarter(m, lag_gdp)
row["gdp_last_published"] = gdp.get(pq.start_time, np.nan)
row["gdp_prev_published"] = gdp.get((pq - 1).start_time, np.nan)
row["target"] = gdp.get(tq.start_time, np.nan)
rows.append(row)
return pd.DataFrame(rows).set_index("origin")
Key ideas in the code:
target_knownis the month when the target quarter's GDP is published. Training rows are only usable if their target was known by the origin, the same principle as Day 61.4 expressed with explicit dates.- Quarter-to-date averages use only months up to
avail_end, so early in the quarter they're based on one month (or none, givingNaN, which XGBoost handles). gdp_last_publishedis the most recent released quarter. Early in a quarter that's two quarters back, a real feature of nowcasting.
from forecasting.availability import load_monthly_panel, load_spec
from forecasting.data import load_gdp
from forecasting.nowcast import nowcast_dataset
panel = load_monthly_panel()
gdp = load_gdp()["real_qoq_ann"]
gspec = load_spec("forecasting/feature_spec_gdp.yml")
D0 = nowcast_dataset(panel, gdp, gspec, horizon_q=0) # nowcast
D1 = nowcast_dataset(panel, gdp, gspec, horizon_q=1) # next quarter
print(D0.tail(6)[["target_quarter", "month_in_quarter", "gdp_last_published",
"unemp_qtd", "oecd_cli_last", "target"]])
Inspect the last rows: the target is NaN for quarters not yet published, which is exactly what you'll forecast in production on Day 70.
69.4 Evaluating Nowcasts: Error by Month of the Quarter (23 min)
The Day 61 harness assumes targets h months ahead. Here each row carries its own target_known date, so write a general version. Add to forecasting/evaluate.py:
def rolling_origin_general(D: pd.DataFrame, feature_cols: list[str], model_factory,
test_start: str, model_name: str, refit_every: int = 3,
min_train: int = 40) -> pd.DataFrame:
"""Rows carry 'target' and 'target_known'. At origin t, train on rows with target_known ≤ t."""
rows, model = [], None
origins = D.index[D.index >= pd.Timestamp(test_start)]
for i, t in enumerate(origins):
if pd.isna(D.loc[t, "target"]):
continue # outcome not yet published
train = D["target"].notna() & (D["target_known"] <= t)
if train.sum() < min_train:
continue
if model is None or i % refit_every == 0:
model = model_factory().fit(D.loc[train, feature_cols], D.loc[train, "target"])
pred = float(model.predict(D.loc[[t], feature_cols])[0])
rows.append({"model": model_name, "origin": t, "target_quarter": D.loc[t, "target_quarter"],
"month_in_quarter": int(D.loc[t, "month_in_quarter"]),
"forecast": pred, "actual": float(D.loc[t, "target"])})
return pd.DataFrame(rows)
Benchmarks for GDP
| Benchmark | Forecast |
|---|---|
| last published | growth of the latest released quarter (gdp_last_published) |
| historical mean | average growth in the training window |
| AR(1) on published quarters | a simple regression on the last published value (fit with LinearRegression on gdp_last_published) |
from sklearn.linear_model import LinearRegression
from forecasting.models import HistoricalMean, xgb_factory
class LastPublished:
def fit(self, X, y):
return self
def predict(self, X):
return X["gdp_last_published"].to_numpy()
feats = [c for c in D0.columns if c not in ("target", "target_quarter", "target_known")]
TEST = "2012-01-01"
now = pd.concat([
rolling_origin_general(D0, feats, LastPublished, TEST, "last_published"),
rolling_origin_general(D0, feats, HistoricalMean, TEST, "hist_mean"),
rolling_origin_general(D0, ["gdp_last_published"], LinearRegression, TEST, "ar1_published"),
rolling_origin_general(D0, feats, xgb_factory, TEST, "xgb_nowcast"),
], ignore_index=True)
now.to_parquet("data/forecasts/gdp_nowcasts.parquet", index=False)
(LinearRegression can't handle NaN. gdp_last_published is always available after the first few years, but drop rows with missing values for it if needed.)
The signature chart of nowcasting
import matplotlib.pyplot as plt
import numpy as np
err = (now.assign(e=lambda d: d.forecast - d.actual)
.groupby(["model", "month_in_quarter"])["e"]
.apply(lambda e: np.sqrt(np.mean(e ** 2))).unstack("model"))
print(err.round(2))
err.plot(marker="o", figsize=(7, 4), title="Nowcast RMSE by month of the quarter (pp, annualised)")
plt.xticks([1, 2, 3]); plt.ylabel("RMSE"); plt.tight_layout()
plt.savefig("data/forecasts/gdp_nowcast_by_month.png", dpi=120)
A good nowcasting model's error falls as the quarter progresses: by month 3 it has seen most of the quarter's indicators. If your XGBoost line doesn't fall while the benchmarks stay flat, the monthly indicators aren't adding information. That's a valuable, honest finding too.
69.5 COVID Outliers: Robust Losses and Honest Reporting (18 min)
In 2020, Canadian real GDP growth (annualised) swung by tens of percentage points within two quarters, far outside anything in the training history. Three consequences:
- Evaluation: a single 2020 quarter can dominate RMSE (errors are squared). Always report results with and without 2020–2021, and look at MAE too.
- Training: extreme quarters pull squared-error models strongly. Options:
- a robust loss: XGBoost's
objective="reg:pseudohubererror"behaves like squared error for small errors and like absolute error for large ones; - excluding 2020Q2–2021Q1 from training (and saying so);
- adding a pandemic indicator feature (only legitimate if you'd have known it in real time, which you would have).
- a robust loss: XGBoost's
- Extrapolation: trees can't predict beyond the training range (Day 62.5), so no tree model nowcasts a −30% quarter it hasn't seen. Accept that, and don't over-interpret 2020 errors.
from xgboost import XGBRegressor
def xgb_huber():
return XGBRegressor(objective="reg:pseudohubererror", huber_slope=5.0,
n_estimators=300, learning_rate=0.03, max_depth=3,
subsample=0.8, colsample_bytree=0.8, random_state=0)
robust = rolling_origin_general(D0, feats, xgb_huber, TEST, "xgb_huber")
now2 = pd.concat([now, robust])
covid = (now2["target_quarter"] >= "2020-01-01") & (now2["target_quarter"] <= "2021-03-01")
for label, mask in [("all", pd.Series(True, index=now2.index)), ("excl. 2020Q1–2021Q1", ~covid)]:
sub = now2[mask].assign(ae=lambda d: (d.forecast - d.actual).abs(),
se=lambda d: (d.forecast - d.actual) ** 2)
print(label)
print(sub.groupby("model").agg(MAE=("ae", "mean"),
RMSE=("se", lambda s: np.sqrt(s.mean()))).round(2))
(huber_slope sets where the loss switches from squared to linear, in units of the target, here percentage points. Check your XGBoost version supports it; otherwise use the default.)
69.6 Mini-Project: The GDP Leaderboard (23 min)
Build forecasting/gdp_leaderboard.py that:
- Builds
D0(nowcast) andD1(next quarter) from the spec. - Evaluates the benchmarks, XGBoost (squared and Huber loss) and an equal-weight combination (Day 67) for both targets, from 2012.
- Reports MAE and RMSE overall and excluding 2020Q1–2021Q1, plus RMSE by month of the quarter.
- Runs Diebold–Mariano tests of the best model vs
last_published(adapt the function to the general results by usingoriginas the alignment key). - Writes
data/forecasts/gdp_leaderboard.parquetand a Markdown report stating that the target is the latest vintage and that evaluation is pseudo-real-time.
Then update MODEL_DECISIONS.md with the production choice for GDP: which model for the nowcast, which for next quarter, and why.
Day 69 checklist
- I can explain why GDP is harder (frequency, delay, revisions, 2020) and what nowcasting is
- A long-history GDP mart feeds the models, and the vintage choice is documented
- My nowcasting dataset respects each series' lag and GDP's release lag (the ragged edge)
- A general harness trains only on targets published by each origin
- I can show how nowcast error evolves through the quarter, against benchmarks
- I handle COVID with robust losses and report results with and without the outliers
git add forecasting macro_dbt notebooks && git commit -m "Day 69: GDP nowcasting, ragged edge, robust losses, leaderboard" && git push