Module 7 · Day 69 of 80

Nowcasting and Forecasting Real GDP Growth

About 2.5 hours
Video lesson coming soon
Day 69 · Nowcasting and Forecasting Real GDP Growth
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

  • 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

Today's plan

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

LessonHands-on projectBreak2:30 total
Written lesson
TimeBlock
0:00 – 0:2069.1 GDP is different: quarterly, late and revised
0:20 – 0:4069.2 A long quarterly history: the ml_gdp_quarterly mart
0:40 – 0:48Break
0:48 – 1:2069.3 Nowcasting with a ragged edge
1:20 – 1:4369.4 Evaluating nowcasts: error by month of the quarter
1:43 – 1:50Break
1:50 – 2:0869.5 COVID outliers: robust losses and honest reporting
2:08 – 2:3069.6 Mini-project: the GDP leaderboard

69.1 GDP Is Different: Quarterly, Late and Revised (20 min)

CPIReal GDP
frequencymonthlyquarterly
delay~3 weeks after the monthabout two months after the quarter ends
revisionsessentially nonesubstantial and repeated
volatilitymoderateextreme 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:

TargetProCon
latest vintagethe best estimate of what actually happenedforecasters were trying to predict first releases at the time
first releasewhat markets and media reacted toneeds 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_known is 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, giving NaN, which XGBoost handles).
  • gdp_last_published is 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

BenchmarkForecast
last publishedgrowth of the latest released quarter (gdp_last_published)
historical meanaverage growth in the training window
AR(1) on published quartersa 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:

  1. Evaluation: a single 2020 quarter can dominate RMSE (errors are squared). Always report results with and without 2020–2021, and look at MAE too.
  2. 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).
  3. 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:

  1. Builds D0 (nowcast) and D1 (next quarter) from the spec.
  2. Evaluates the benchmarks, XGBoost (squared and Huber loss) and an equal-weight combination (Day 67) for both targets, from 2012.
  3. Reports MAE and RMSE overall and excluding 2020Q1–2021Q1, plus RMSE by month of the quarter.
  4. Runs Diebold–Mariano tests of the best model vs last_published (adapt the function to the general results by using origin as the alignment key).
  5. Writes data/forecasts/gdp_leaderboard.parquet and 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