ECON 8310: Business Forecasting — University of Nebraska at Omaha
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).
Setup
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltfrom statsmodels.tsa.stattools import adfuller, grangercausalitytestsfrom statsmodels.graphics.tsaplots import plot_acf, plot_pacffrom statsmodels.tsa.arima.model import ARIMAimport warningswarnings.filterwarnings('ignore')import iofrom contextlib import redirect_stdoutfrom pathlib import Pathdef 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"/ nameif p.exists():return praiseFileNotFoundError(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 =52train, test = y.iloc[:-N_TEST], y.iloc[-N_TEST:]print(f"{len(y)} weeks — train {len(train)}, test {len(test)}")
dy = y.diff().dropna()report_adf(dy, "first difference")
first difference ADF -5.00 p 0.000 lags 11 stationary
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.
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.
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.
Step 3 — Fit it
order = (1, 1, 1) # <- REPLACE with your proposal from Step 2model = 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}")
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.
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.05else"not significant"print(f"{cause:>10} -> {effect:<10} F = {F:5.2f} p = {p:.4f}{verdict}")granger("household", "food")
food -> household F = 3.83 p = 0.0048 significant
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(___, ___)
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 ratemacro = 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.05else'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)
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 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.
Dropping the rows first and then lagging would splice January 2020 straight onto July 2021, as if they were neighboring months. So build every lag on the unbroken series, then drop each row whose target or any of its lags falls inside the window.
import statsmodels.api as smfrom scipy import statsdef granger_excluding(effect, cause, data, start, end, lag=4): d = pd.DataFrame({"y": data[effect]})for k inrange(1, lag +1): # lags on the unbroken series d[f"y{k}"], d[f"x{k}"] = data[effect].shift(k), data[cause].shift(k) d = d.dropna() d = d[(d.index < start) | (d.index > pd.Timestamp(end) + pd.DateOffset(months=lag))] own = sm.OLS(d["y"], sm.add_constant(d[[f"y{k}"for k inrange(1, lag +1)]])).fit() both = sm.OLS(d["y"], sm.add_constant(d.drop(columns="y"))).fit() F = ((own.ssr - both.ssr) / lag) / (both.ssr / both.df_resid) p = stats.f.sf(F, lag, both.df_resid)print(f" {cause:>12} -> {effect:<14} F = {F:6.2f} p = {p:.4f}"f" {'significant'if p <0.05else'NOT significant'}")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_excluding("retail_growth", "unrate_diff", macro, "2020-02-01", "2021-06-01")granger_excluding("unrate_diff", "retail_growth", macro, "2020-02-01", "2021-06-01")
Excluding Feb 2020 - Jun 2021 (395 months)
unrate_diff -> retail_growth F = 2.42 p = 0.0480 significant
retail_growth -> unrate_diff F = 9.96 p = 0.0000 significant
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)
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?