import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.tsa.stattools import adfuller, grangercausalitytests
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from statsmodels.tsa.arima.model import ARIMA
import warnings
warnings.filterwarnings('ignore')
import io
from contextlib import redirect_stdout
from pathlib import Path
def data_path(name):
"""Find the processed data file whether you render from the repo root or from
this file's own folder."""
for base in (Path.cwd(), *Path.cwd().parents):
p = base / "data" / "processed" / name
if p.exists():
return p
raise FileNotFoundError(
f"Could not find data/processed/{name}. "
"Run the matching prep script from the repository root first: "
"`prep_m5.py` for m5_*.csv, `prep_fred.py` for fred_monthly.csv."
)
weekly = pd.read_csv(data_path("m5_weekly.csv"), parse_dates=["week_start"])
def get_series(store, cat):
return (weekly[(weekly.store_id == store) & (weekly.cat_id == cat)]
.set_index("week_start")["units"].sort_index().astype(float))
y = get_series("CA_1", "HOUSEHOLD")
N_TEST = 52
train, test = y.iloc[:-N_TEST], y.iloc[-N_TEST:]
print(f"{len(y)} weeks — train {len(train)}, test {len(test)}")2 Lab 2: Stationarity, ARIMA, and Granger Causality
Run this one live: the executable lab.
2.1 What we are doing
Lecture 2 gave you a rule — test for stationarity before you read an ACF — and a caveat: Granger causality is not structural causality. In the next 35 minutes you will do both, on data where each one actually bites.
The business question: you are forecasting weekly HOUSEHOLD demand at store CA_1, and you want to know whether watching the food aisle helps you predict it. Then, in Step 6, you run the same test on the macro question from the lecture slide — does unemployment predict retail sales? — and find that the answer depends on which years you include.
Suggested time: ~40 minutes · You will need: pandas, numpy, matplotlib, statsmodels, and two prepared files: data/processed/m5_weekly.csv (prep_m5.py) and data/processed/fred_monthly.csv (prep_fred.py).
2.2 Setup
Same pattern as Lab 1 — imports, a path helper, a series accessor, a split.
277 weeks — train 225, test 52
Same store dataset, different store and same category as Lab 1: CA_1 household. Fifty-two weeks held out.
The one new import worth noticing is redirect_stdout from contextlib, and io. That’s not statistics — that’s damage control, and I’ll show you why in Step 5.
2.3 Step 1 — Look, then test
fig, ax = plt.subplots(figsize=(11, 3.2))
ax.plot(y.index, y.values, lw=1.3)
ax.set_title("CA_1 HOUSEHOLD — weekly units"); plt.tight_layout()Notice: the level wanders. Your eye says non-stationary. Now make the test agree.
def report_adf(series, label):
stat, p, lags, nobs, crit, _ = adfuller(series.dropna(), autolag="AIC")
print(f"{label:<28} ADF {stat:>7.2f} p {p:>6.3f} lags {lags:>2} "
f"{'stationary' if p < 0.05 else 'UNIT ROOT'}")
report_adf(y, "levels")Now difference it and re-test.
dy = y.diff().dropna()
report_adf(dy, "first difference")You should see the statistic move decisively. Write down both numbers — you will be asked to justify a choice of \(d\) in the homework, and this is what a clean case looks like.
Look first. Always look first.
The level wanders. It drifts up, it drifts back, there’s no fixed value it returns to. Your eye says non-stationary. Now make the test agree — because your eye is not evidence you can put in a report.
levels ADF -1.33 p 0.614 lags 12 UNIT ROOT
first difference ADF -5.00 p 0.000 lags 11 stationary
Read those two lines carefully, because this is what a clean case looks like and you want it in your memory for comparison later.
In levels: ADF statistic minus one point three three, p-value point six one. Nowhere near significance. We fail to reject the null of a unit root — the series is non-stationary, exactly as your eye said. First difference: minus five point zero zero, p-value effectively zero. Decisive. One difference was enough.
That tells you d equals one, and you now have an evidence trail for that choice rather than a hunch. You’ll be asked to justify a d in the homework. This is what the justification looks like.
2.4 Step 2 — Now you are allowed to read the ACF
Lecture 2’s rule: the ACF of a non-stationary series decays slowly no matter what the underlying process is. Run both and see it.
fig, axes = plt.subplots(2, 2, figsize=(11, 5))
plot_acf(y, lags=30, ax=axes[0,0]); axes[0,0].set_title("ACF — levels (uninformative)")
plot_pacf(y, lags=30, ax=axes[0,1]); axes[0,1].set_title("PACF — levels")
plot_acf(dy, lags=30, ax=axes[1,0]); axes[1,0].set_title("ACF — differenced")
plot_pacf(dy, lags=30, ax=axes[1,1]); axes[1,1].set_title("PACF — differenced")
plt.tight_layout()Compare the top row against the bottom. The top ACF crawls down — that is the signature of an undifferenced series, and it tells you nothing about \(p\) or \(q\). The bottom row is interpretable.
Your turn: using the bottom row and the four reference patterns from Lecture 2, propose a \((p, d, q)\).
In the room: say your proposed order out loud to a classmate and defend it in one sentence.
On your own: type it into a comment before you fit anything, in this form:
# (p, d, q) = (_, _, _) — PACF cuts off after lag ____, ACF cuts off after lag ____
Naming the lag that decided each term is what turns a guess into a reading you can be wrong about — and being wrong about a specific lag is something you can learn from.
Either way the point is committing to an order before you see how it scores.
Four panels. Top row is the raw series, bottom row is differenced.
Look at the top ACF. It crawls down — a long slow decay, bar after bar, staying well above the significance band for twenty-plus lags.
Here’s the trap. That pattern looks like enormously strong autocorrelation, and a reasonable person reads it as “there’s a lot of structure here to model.” It isn’t. That slow decay is the signature of an undifferenced series, and it appears no matter what the underlying process is. A random walk produces it. An AR(1) with a unit root produces it. White noise plus a trend produces it. It tells you nothing about p or q. It tells you only that you skipped a step.
Now the bottom row, after differencing. That is interpretable. The bars drop into the band quickly and you can actually read structure off it.
Pause the video. Using the bottom row and the four reference patterns from Lecture 2, propose a .
Write it down before you continue, and write down why — which panel, which lags. Then post it in the Week 2 thread. I want to see the range of proposals, because there genuinely is one, and that’s part of the lesson.
2.5 Step 3 — Fit it
order = (1, 1, 1) # <- REPLACE with your proposal from Step 2
model = ARIMA(train, order=order).fit()
print(model.summary().tables[0])
fc = model.forecast(N_TEST)
rmse = float(np.sqrt(np.mean((test.values - np.asarray(fc)) ** 2)))
print(f"\n52-week RMSE: {rmse:,.0f}")Line one of this cell is order = (1, 1, 1). Replace it with your proposal. The lab ships with (1,1,1) as a placeholder so the file renders for everyone — it is not the answer.
(Note for the narrator: with the shipped (1,1,1) the 52-week RMSE is 569. Say your own number aloud, then say the following.)
Your number will differ from mine if you chose a different order, and that is fine — that is the exercise. What you should be able to do is say why you chose what you chose.
2.6 Step 4 — Break it: the horizon trap
Now the same model, evaluated a different way.
errs = []
vals = y.values
for i in range(104, len(vals) - 1):
m = ARIMA(vals[:i+1], order=order).fit()
errs.append(vals[i+1] - float(m.forecast(1)[0]))
wf = float(np.sqrt(np.mean(np.array(errs) ** 2)))
print(f"walk-forward 1-step RMSE : {wf:,.0f} ({len(errs)} origins)")
print(f"single-split 52-step RMSE: {rmse:,.0f}")Notice how much smaller the walk-forward number is. It is tempting to quote it — it makes your model look better.
Do not. They answer different questions. One-step-ahead, knowing everything up to last week, is a fundamentally easier problem than forecasting a year out from a fixed origin. If a planner is ordering inventory twelve months ahead, the 52-step number is the honest one.
Same model. Different evaluation. Watch what happens to the number.
walk-forward 1-step RMSE : 352 (172 origins)
single-split 52-step RMSE: 569
Three fifty-two against five sixty-nine. The walk-forward number is nearly forty percent smaller, and it is computed from a hundred and seventy-two separate forecasts instead of one — so it even looks more rigorous. More origins, more evaluation, smaller error. It is enormously tempting to quote it. Do not.
They are not two estimates of the same thing. They answer different questions. One-step-ahead — knowing everything up to last week, forecasting next week — is a fundamentally easier problem than standing at a fixed origin and forecasting fifty-two weeks out. Of course the error is smaller. It would be alarming if it weren’t.
The question is which one matches the decision. If a planner is ordering inventory twelve months ahead, the 52-step number is the honest one, and quoting 352 to that person is not optimistic — it’s wrong.
This is the most common way I see forecast accuracy overstated in practice, and almost nobody does it dishonestly. They compute the number that’s easy to compute, and it happens to be the flattering one.
2.7 Step 5 — Granger causality, and the trap in it
Does the food aisle help predict the household aisle?
grangercausalitytests prints four test variants for every lag up to maxlag — a wall of output you did not ask for. The helper below silences it and pulls out the single F-test.
food = get_series("CA_1", "FOODS")
pair = pd.concat([y.rename("household"), food.rename("food")], axis=1).diff().dropna()
def granger(effect, cause, lag=4):
"""F-test for: do lags of `cause` help predict `effect`?"""
with redirect_stdout(io.StringIO()): # statsmodels prints 4 variants per lag
res = grangercausalitytests(pair[[effect, cause]], maxlag=lag)
F, p = res[lag][0]["ssr_ftest"][0], res[lag][0]["ssr_ftest"][1]
verdict = "significant" if p < 0.05 else "not significant"
print(f"{cause:>10} -> {effect:<10} F = {F:5.2f} p = {p:.4f} {verdict}")
granger("household", "food")Note the argument order: the second argument is the candidate cause. Getting this backwards is the single most common mistake with this function.
Your turn: run the other direction and report both F-statistics.
# ---- your turn: uncomment, then swap the two arguments ----
# granger(___, ___)Does the food aisle help predict the household aisle?
First, the practical note. grangercausalitytests prints four test variants for every lag up to maxlag — for lag 4, that’s a screen and a half of output nobody asked for. The helper swallows it with redirect_stdout and pulls out the single F-test we want. That’s what those imports were for.
food -> household F = 3.83 p = 0.0048 significant
Now the argument order, and please write this down: the second argument is the candidate cause. granger("household", "food") asks whether food helps predict household. Getting this backwards is the single most common mistake with this function, and it is silent — you get a perfectly reasonable-looking result to the wrong question.
Your turn. Run the other direction and report both F-statistics. It’s one line — uncomment and swap the arguments.
household -> food F = 2.80 p = 0.0263 significant
Both directions are significant. Food predicts household, and household predicts food.
Sit with how strange that is. Household goods do not cause groceries. Groceries do not cause household goods. Nobody buys paper towels because they bought bananas. And yet the test says each helps predict the other, at conventional significance, in both directions. We’ll come back to what’s generating that. Hold the question.
2.8 Step 6 — The same test on macro data, and why the sample matters
Step 5 used two aisles of one store. Now the question from the lecture slide: does unemployment Granger-cause retail sales?
Run python scripts/prep_fred.py once before class — it caches three FRED series to data/processed/fred_monthly.csv, so this step needs no network and no API key.
fred = pd.read_csv(data_path("fred_monthly.csv"), parse_dates=["date"]).set_index("date")
# Both series are already stationary transforms:
# retail_growth = monthly % change in retail sales (log-differenced)
# unrate_diff = monthly change in the unemployment rate
macro = fred[["retail_growth", "unrate_diff"]].dropna()
def granger_macro(effect, cause, data, lag=4):
with redirect_stdout(io.StringIO()):
res = grangercausalitytests(data[[effect, cause]], maxlag=lag)
F, p = res[lag][0]["ssr_ftest"][0], res[lag][0]["ssr_ftest"][1]
print(f" {cause:>12} -> {effect:<14} F = {F:6.2f} p = {p:.4f}"
f" {'significant' if p < 0.05 else 'NOT significant'}")
print(f"Full sample: {macro.index.min():%Y-%m} to {macro.index.max():%Y-%m} ({len(macro)} months)")
granger_macro("retail_growth", "unrate_diff", macro)
granger_macro("unrate_diff", "retail_growth", macro)Both directions are significant, and unemployment looks like the stronger driver. That matches the story everyone expects: people lose jobs, people stop spending.
Now drop the COVID window and run it again.
ex_covid = macro[(macro.index < "2020-02-01") | (macro.index > "2021-06-01")]
print(f"Excluding Feb 2020 - Jun 2021 ({len(ex_covid)} months)")
granger_macro("retail_growth", "unrate_diff", ex_covid)
granger_macro("unrate_diff", "retail_growth", ex_covid)Your turn: restrict the sample to everything before February 2020 and run both directions once more.
# ---- your turn: uncomment and fill in the filter ----
# pre_covid = macro[___] # <- rows with index < "2020-02-01"
#
# granger_macro("retail_growth", "unrate_diff", pre_covid)
# granger_macro("unrate_diff", "retail_growth", pre_covid)Now the question from the lecture slide, on real macro data. Does unemployment Granger-cause retail sales?
Both series arrive already stationary — retail growth is log-differenced monthly percent change, unemployment is the monthly change in the rate. So we can test directly.
Full sample: 1992-02 to 2026-06 (412 months)
unrate_diff -> retail_growth F = 18.22 p = 0.0000 significant
retail_growth -> unrate_diff F = 12.80 p = 0.0000 significant
Both directions significant, and unemployment is the stronger of the two — F of eighteen against thirteen. That matches the story everybody expects. People lose jobs, people stop spending. You could write that paragraph without running anything, which is exactly why you should be suspicious of how satisfying it is.
Now drop the COVID window.
Excluding Feb 2020 - Jun 2021 (395 months)
unrate_diff -> retail_growth F = 2.91 p = 0.0214 significant
retail_growth -> unrate_diff F = 6.74 p = 0.0000 significant
Seventeen months removed out of four hundred and twelve — four percent of the sample. The unemployment-to-retail F-statistic falls from 18.22 to 2.91. It’s still significant, barely, but it has lost about six-sevenths of its strength. The reverse direction went from 12.80 to 6.74. Weaker, but still overwhelming.
Your turn. Restrict to everything before February 2020 — no COVID period at all, no recovery either — and run both directions.
Pre-COVID, before Feb 2020 (336 months)
unrate_diff -> retail_growth F = 1.85 p = 0.1196 NOT significant
retail_growth -> unrate_diff F = 9.67 p = 0.0000 significant
Eighteen point two two. Two point nine one. One point eight five.
On twenty-eight years of pre-pandemic data, unemployment does not Granger-cause retail sales. The intuitive direction — the one everybody expects, the one that has a mechanism, the one you’d put in a slide without checking — is gone.
And the direction nobody leads with, retail predicting unemployment, survives every single cut: 12.80, then 6.74, then 9.67. Never above a p-value of point zero zero zero one.
2.9 Before you leave
You should have found, in Step 5, that the relationship runs both ways, at least in part.
Discuss (Step 5): Household goods do not cause groceries, and groceries do not cause household goods. So what is generating a two-way predictive relationship between two aisles of the same store?
And the question that matters commercially: given what you found, is there anything a planner is entitled to do with it? Name one legitimate use and one thing they must not conclude.
In Step 6 the intuitive direction — unemployment driving retail — is strong on the full sample, weak once COVID is removed, and gone entirely before 2020. The reverse direction survives every cut.
Discuss (Step 6): One analyst runs the full sample and reports that unemployment predicts retail sales. Another runs 1992–2019 and reports it does not. Neither has made an error.
What actually happened here — and what does it tell you about a Granger result computed on a sample containing one enormous shared shock?
Retail sales are a coincident-to-leading indicator and unemployment is a lagging one. Does that help explain which direction survived?
Solutions posted to Canvas after class.
Two questions, and I want both in the Week 2 thread before Lecture 3.
First, the aisles. You found a two-way relationship between household goods and groceries. Neither causes the other. So what is generating it?
And the commercial half, which is the one that matters: given what you found, is there anything a planner is entitled to do with it? Name one legitimate use and one thing they must not conclude.
Second, the macro result. One analyst runs the full sample and reports that unemployment predicts retail sales. Another runs 1992 to 2019 and reports that it does not.
Neither of them has made an error. Both ran the correct test, correctly, on a defensible sample. That’s what makes this worth twenty minutes of your attention rather than being a cautionary tale about sloppy work.
What actually happened — and what does it tell you about a Granger result computed on a sample containing one enormous shared shock?
One hint worth using: retail sales are a coincident-to-leading indicator, and unemployment is a lagging one. Firms cut hours and stop hiring after demand falls, not before. Does that help explain which direction survived every cut?
Solutions for the coded parts go up on Canvas after the deadline. Lecture 3 next, where we leave retail data entirely — because it can’t show you what we need to see.
2.10 What your run should produce
Your numbers will differ where a model is stochastic; these are the values this lab produces on the pinned environment.
Reproducible; verified across repeated runs. Step 3 excepted — that number depends on the order the student chooses.
| Quantity | Value |
|---|---|
| Series | CA_1 HOUSEHOLD, 277 weeks (train 225, test 52) |
| ADF levels | −1.33, p = 0.614, 12 lags → unit root |
| ADF first difference | −5.00, p < 0.001, 11 lags → stationary |
| ARIMA(1,1,1) 52-step RMSE | 569 (shipped placeholder order only) |
| Walk-forward 1-step RMSE | 352, over 172 origins |
| food → household | F = 3.83, p = 0.0048 |
| household → food | F = 2.80, p = 0.0263 |
| Macro, full sample (412 mo) | unrate→retail F = 18.22 · retail→unrate F = 12.80 |
| Ex-COVID (395 mo) | unrate→retail F = 2.91 (p = 0.0214) · retail→unrate F = 6.74 |
| Pre-COVID (336 mo) | unrate→retail F = 1.85 (p = 0.1196, not significant) · retail→unrate F = 9.67 |
Note for the instructor. The FRED full sample ends 2026-06, so it moves as prep_fred.py is re-run. The three F-statistics will drift slightly between terms; the pattern — collapse in one direction, survival in the other — is what’s stable, and it’s what the discussion questions rest on. Re-run this appendix before recording if the cache is more than a term old.