Granger 2003 - Spurious Regression Monte Carlo in Python

Summary

A single reproducible NumPy/SciPy script (seeded) that demonstrates every quantitative claim around Granger’s p. 363 account of spurious regression: (1) independent random walks regressed in levels reject a true null 67-90% of the time at nominal 5%, with growing like , that never shrinks and DW , while differencing restores 4-6% size; (2) a Granger-Newbold design with 1-5 random-walk regressors at pushes the joint -test rejection rate from 67% to 98%; (3) Yule’s three shapes of the correlation distribution for , , series; (4) the Engle-Granger residual test separates a spurious pair (4% rejection) from a cointegrated pair (100%); (5) the two-step ECM recovers adjustment speed and short-run effect . A companion plotting script draws a sample spurious pair, Yule’s three histograms and the growth of the spurious .

Overview

The lecture describes the Granger-Newbold result in words only (“a small simulation published in 1974”, p. 363). This note turns it — and the surrounding theory in Granger 2003 - Spurious Regression from Yule to Granger-Newbold and Granger 2003 - Cointegration and the Error-Correction Representation — into a simulation study, written in the aims / data-generating mechanisms / estimands / methods / performance-measures structure of Morris White Crowther 2019 - Simulation Studies (ADEMP) - Overview.

ADEMP elementThis study
AimsShow the size distortion of OLS inference with independent series; show that differencing and cointegration-based methods fix it
Data-generating mechanismsGaussian random walks ; ; independent pairs, independent regressors, once/twice-cumulated noise, and a cointegrated pair ,
Estimandsslope (true 0 for spurious pairs, 2 for the cointegrated pair); ECM adjustment and impact
MethodsOLS in levels; OLS in first differences; joint -test; sample correlation; Engle-Granger residual ADF test; two-step ECM
Performance measuresrejection rate (empirical size/power), median , mean , median DW, , mean and SD of estimates

Replications: 5,000 (Exp. 1-2), 20,000 (Exp. 3), 2,000 (Exp. 4-5). Monte Carlo SE of a rejection rate is , e.g. ; all differences discussed below are many MC SEs apart.

Main Content

The script

Save as spurious_sim.py and run python spurious_sim.py (requires numpy, scipy). Output is printed as markdown table rows.

import numpy as np
import scipy.stats as st
 
def ols(y, X):
    """OLS with intercept already in X. Returns beta, t-stats, R2, DW."""
    beta, *_ = np.linalg.lstsq(X, y, rcond=None)
    resid = y - X @ beta
    n, k = X.shape
    s2 = resid @ resid / (n - k)
    se = np.sqrt(s2 * np.diag(np.linalg.inv(X.T @ X)))
    r2 = 1 - resid @ resid / np.sum((y - y.mean()) ** 2)
    dw = np.sum(np.diff(resid) ** 2) / (resid @ resid)
    return beta, beta / se, r2, dw, resid
 
def add_const(*cols):
    return np.column_stack([np.ones(len(cols[0]))] + list(cols))
 
# ---- Experiment 1: two independent random walks, levels vs differences ----
def exp1(n, reps=5000, seed=0):
    rng = np.random.default_rng(seed)
    rows = {"levels": [], "differences": []}
    for _ in range(reps):
        x = np.cumsum(rng.standard_normal(n))
        y = np.cumsum(rng.standard_normal(n))
        _, t, r2, dw, _ = ols(y, add_const(x))
        rows["levels"].append((t[1], r2, dw))
        _, t, r2, dw, _ = ols(np.diff(y), add_const(np.diff(x)))
        rows["differences"].append((t[1], r2, dw))
    for k, v in rows.items():
        t, r2, dw = map(np.array, zip(*v))
        print(f"| {n} | {k} | {np.mean(np.abs(t) > 1.96):.2f} | {np.median(np.abs(t)):.2f} | "
              f"{r2.mean():.2f} | {np.median(dw):.2f} | {np.mean(r2 > dw):.2f} |")
 
# ---- Experiment 2: Granger-Newbold style, k independent random-walk regressors, T=50 ----
def exp2(n=50, reps=5000, seed=1):
    rng = np.random.default_rng(seed)
    for k in range(1, 6):
        rej, r2s, dws = [], [], []
        for _ in range(reps):
            y = np.cumsum(rng.standard_normal(n))
            Xs = np.cumsum(rng.standard_normal((n, k)), axis=0)
            X = add_const(*Xs.T)
            beta, t, r2, dw, resid = ols(y, X)
            # joint F test of all slopes = 0
            rss1 = resid @ resid
            rss0 = np.sum((y - y.mean()) ** 2)
            F = ((rss0 - rss1) / k) / (rss1 / (n - k - 1))
            rej.append(F)
            r2s.append(r2); dws.append(dw)
        crit = st.f.ppf(0.95, k, n - k - 1)
        print(f"| {k} | {np.mean(np.array(rej) > crit):.2f} | {np.mean(r2s):.2f} | {np.median(dws):.2f} |")
 
# ---- Experiment 3: Yule-style sampling distribution of correlation ----
def exp3(n=10, reps=20000, seed=2):
    rng = np.random.default_rng(seed)
    for order, name in [(0, "white noise I(0)"), (1, "random walk I(1)"), (2, "twice-cumulated I(2)")]:
        rs = []
        for _ in range(reps):
            x = rng.standard_normal(n); y = rng.standard_normal(n)
            for _ in range(order):
                x = np.cumsum(x); y = np.cumsum(y)
            rs.append(np.corrcoef(x, y)[0, 1])
        rs = np.array(rs)
        print(f"| {name} | {n} | {np.mean(np.abs(rs) > 0.5):.2f} | {np.mean(np.abs(rs) > 0.9):.2f} | {np.std(rs):.2f} |")
 
# ---- Experiment 4: a genuinely cointegrated pair vs spurious pair (Engle-Granger step 2) ----
def adf_t(e, lags=1):
    """Dickey-Fuller t on residuals: de_t = rho e_{t-1} + sum gamma de_{t-j}; no constant."""
    de = np.diff(e)
    Y = de[lags:]
    cols = [e[lags:-1]] + [de[lags - j:-j] for j in range(1, lags + 1)]
    X = np.column_stack(cols)
    beta, *_ = np.linalg.lstsq(X, Y, rcond=None)
    r = Y - X @ beta
    s2 = r @ r / (len(Y) - X.shape[1])
    se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[0, 0])
    return beta[0] / se
 
def exp4(n=200, reps=2000, seed=3, crit=-3.37):
    # crit: approx. 5% Engle-Granger critical value, 2 variables with constant (MacKinnon), T~200
    rng = np.random.default_rng(seed)
    res = {"independent RWs": [], "cointegrated (y = 2x + AR(1) error)": []}
    for _ in range(reps):
        x = np.cumsum(rng.standard_normal(n))
        y_ind = np.cumsum(rng.standard_normal(n))
        u = np.zeros(n)
        eps = rng.standard_normal(n)
        for t in range(1, n):
            u[t] = 0.5 * u[t - 1] + eps[t]
        y_coint = 1.0 + 2.0 * x + u
        for k, y in [("independent RWs", y_ind), ("cointegrated (y = 2x + AR(1) error)", y_coint)]:
            beta, t, r2, dw, e = ols(y, add_const(x))
            res[k].append((beta[1], r2, dw, adf_t(e) < crit))
    for k, v in res.items():
        b, r2, dw, rej = map(np.array, zip(*v))
        print(f"| {k} | {np.median(b):.2f} | {np.std(b):.3f} | {r2.mean():.2f} | {np.median(dw):.2f} | {rej.mean():.2f} |")
 
 
# ---- Experiment 5: two-step Engle-Granger ECM on the cointegrated pair ----
def exp5(n=200, reps=2000, seed=4):
    rng = np.random.default_rng(seed)
    out = []
    for _ in range(reps):
        x = np.cumsum(rng.standard_normal(n))
        u = np.zeros(n); eps = rng.standard_normal(n)
        for t in range(1, n):
            u[t] = 0.5 * u[t - 1] + eps[t]
        y = 1.0 + 2.0 * x + u
        b = ols(y, add_const(x))[0]
        z = y - b[0] - b[1] * x                       # step 1: equilibrium error
        dy, dx = np.diff(y), np.diff(x)
        beta, t, r2, dw, _ = ols(dy, add_const(z[:-1], dx))   # step 2: ECM
        out.append((beta[1], beta[2], t[1]))
    g, d, tg = map(np.array, zip(*out))
    print(f"| gamma (adjustment, true -0.5) | {g.mean():.3f} | {g.std():.3f} |")
    print(f"| delta (short-run, true 2.0) | {d.mean():.3f} | {d.std():.3f} |")
    print(f"| median t(gamma) | {np.median(tg):.2f} | |")
 
if __name__ == "__main__":
    print("Exp1"); [exp1(n) for n in (50, 100, 500)]
    print("Exp2"); exp2()
    print("Exp3"); exp3()
    print("Exp4"); exp4()
    print("Exp5"); exp5()

Experiment 1 — levels vs differences (; 5,000 reps; seed 0)

Independent random walks, OLS of on

Regressionmedian mean median DW
50levels0.673.140.240.300.31
50differences0.060.690.022.000.00
100levels0.764.620.250.150.55
100differences0.050.690.012.000.00
500levels0.9010.010.240.030.80
500differences0.040.670.002.000.00

Interpretation: matches Phillips’ asymptotics (theorem): size distortion grows with ; median scales like (ratio 10.01/4.62 = 2.17 vs ); converges to a non-degenerate distribution (mean ); DW ; the flag (rule of thumb) catches more spurious fits as grows. Differenced regressions have correct size (0.04-0.06), median (the median is 0.674) and DW .

Experiment 2 — Granger-Newbold design with regressors (; 5,000 reps; seed 1)

One random walk on independent random walks; joint -test of all slopes at 5%

regressorsrejection ratemean median DW
10.670.250.30
20.850.400.42
30.920.510.55
40.950.580.68
50.980.650.80

Interpretation: the qualitative Granger-Newbold (1974) finding — rejection of a true null in most replications with one regressor, approaching certainty with five — is reproduced; mean climbs to 0.65 from pure noise. DW rises with only because more regressors absorb more of the wandering; it stays far below 2 and the pattern is typical.

Experiment 3 — Yule’s correlation shapes (; 20,000 reps; seed 2)

Sampling distribution of for independent series of three integration orders

Series typeSD of
white noise 100.140.000.33
random walk 100.430.020.52
twice-cumulated 100.840.500.82

Interpretation: Yule’s (1926) three cases (described in Granger 2003 - Spurious Regression from Yule to Granger-Newbold): concentrated around 0 for random series; spread over most of once cumulated; U-shaped with half the mass beyond when cumulated twice. Yule’s 0.95 church-marriage/mortality correlation is ordinary under the -like row.

Experiment 4 — Engle-Granger test, spurious vs cointegrated (; 2,000 reps; seed 3)

Levels OLS plus ADF(1) test on residuals, 5% critical value

Critical value: is the finite-sample () Engle-Granger/MacKinnon 5% value for two variables with a constant. The asymptotic value, about , is the one quoted in the Engle-Granger algorithm.

Pairmedian SD of mean median DWEG rejects “no cointegration”
independent RWs (true )0.020.6210.240.070.04
cointegrated, , , 2.000.0320.981.041.00

Interpretation: in the spurious case is centred near 0 but with SD 0.62 that does not shrink with (Phillips); in the cointegrated case it is superconsistent (SD 0.03). The residual unit-root test, with Engle-Granger rather than Dickey-Fuller critical values (approximately the MacKinnon value for two variables with a constant), holds its size (0.04) and has full power here. DW alone would not separate them perfectly at small , but the gap (0.07 vs 1.04) is large.

Experiment 5 — two-step ECM on the cointegrated pair (; 2,000 reps; seed 4)

with

ParameterMean estimateSD
(adjustment, true )0.063
(short-run, true )1.9990.074
median —

Interpretation: the Engle-Granger two-step recovers the error-correction dynamics with a small finite-sample bias in (about , from using estimated ). This is Granger’s “Putting the analysis in the form of an error-correction model resolves many of the difficulties found with spurious regression” (p. 363) in numbers.

Figures — what Granger chose not to draw

Granger replaced diagrams with “internal visualization” (p. 360; see Granger 2003 - Econometrics, Integrated Series and the String-of-Pearls Picture). The three figures below make the simulation’s claims visible. A second, self-contained script produces them: save it as spurious_plots.py and run python spurious_plots.py <output_dir> (requires numpy, matplotlib; about 15 s). Figure 2 uses the same seed and generator as Experiment 3, so its numbers match that table exactly.

"""Figures for the spurious-regression note. Requires numpy, matplotlib.
Writes three PNGs into the directory given as argv[1] (default: current dir)."""
import sys, os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
 
OUT = sys.argv[1] if len(sys.argv) > 1 else "."
BLUE, ORANGE = "#2a78d6", "#eb6834"
INK, MUTED, SURFACE, GRID = "#0b0b0b", "#52514e", "#fcfcfb", "#e4e3df"
plt.rcParams.update({
    "figure.facecolor": SURFACE, "axes.facecolor": SURFACE,
    "axes.edgecolor": MUTED, "axes.labelcolor": INK, "text.color": INK,
    "xtick.color": MUTED, "ytick.color": MUTED, "axes.grid": True,
    "grid.color": GRID, "grid.linewidth": 0.8, "axes.spines.top": False,
    "axes.spines.right": False, "font.size": 10,
})
 
def ols_t(y, x):
    X = np.column_stack([np.ones_like(x), x])
    b, *_ = np.linalg.lstsq(X, y, rcond=None)
    e = y - X @ b
    s2 = e @ e / (len(y) - 2)
    se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1])
    r2 = 1 - e @ e / np.sum((y - y.mean()) ** 2)
    return b, b[1] / se, r2
 
# Figure 1: one spurious pair (first draw from seed 0; not selected for drama
# beyond taking the first draw of the generator)
rng = np.random.default_rng(0)
T = 100
x = np.cumsum(rng.standard_normal(T)); y = np.cumsum(rng.standard_normal(T))
b, t, r2 = ols_t(y, x)
fig, ax = plt.subplots(1, 2, figsize=(10, 3.8))
ax[0].plot(x, color=BLUE, lw=2, label="x (random walk)")
ax[0].plot(y, color=ORANGE, lw=2, label="y (independent random walk)")
ax[0].set_xlabel("t"); ax[0].set_ylabel("level"); ax[0].legend(frameon=False)
ax[0].set_title("Two independent random walks, T = 100", loc="left")
ax[1].scatter(x, y, s=16, color=BLUE, edgecolor=SURFACE, linewidth=0.8)
xs = np.linspace(x.min(), x.max(), 2)
ax[1].plot(xs, b[0] + b[1] * xs, color=INK, lw=2)
ax[1].set_xlabel("x"); ax[1].set_ylabel("y")
ax[1].set_title(f"Levels OLS: slope {b[1]:.2f}, t = {t:.1f}, R² = {r2:.2f}", loc="left")
fig.tight_layout(); fig.savefig(os.path.join(OUT, "Granger 2003 - spurious pair.png"), dpi=150)
print(f"Fig1 slope={b[1]:.3f} t={t:.2f} R2={r2:.3f}")
 
# Figure 2: Yule's three shapes (same generator as Experiment 3: seed 2, 20,000 reps, T=10)
rng = np.random.default_rng(2)
n, reps = 10, 20000
fig, ax = plt.subplots(1, 3, figsize=(10, 3.2), sharey=True)
for i, (order, name) in enumerate([(0, "white noise I(0)"), (1, "random walk I(1)"),
                                   (2, "twice-cumulated I(2)")]):
    rs = np.empty(reps)
    for r in range(reps):
        a = rng.standard_normal(n); c = rng.standard_normal(n)
        for _ in range(order):
            a = np.cumsum(a); c = np.cumsum(c)
        rs[r] = np.corrcoef(a, c)[0, 1]
    ax[i].hist(rs, bins=np.linspace(-1, 1, 41), color=BLUE, edgecolor=SURFACE, linewidth=1)
    ax[i].set_title(f"{name}\nSD(r) = {rs.std():.2f}", loc="left")
    ax[i].set_xlabel("sample correlation r (T = 10)")
    print(f"Fig2 {name}: P(|r|>0.9)={np.mean(np.abs(rs)>0.9):.2f} SD={rs.std():.2f}")
ax[0].set_ylabel("count of 20,000 pairs")
fig.tight_layout(); fig.savefig(os.path.join(OUT, "Granger 2003 - Yule correlation shapes.png"), dpi=150)
 
# Figure 3: median |t| in levels vs differences against T, with sqrt(T) reference
rng = np.random.default_rng(5)
Ts = [25, 50, 100, 200, 400, 800]
lev, dif = [], []
for T in Ts:
    tl, td = [], []
    for _ in range(2000):
        x = np.cumsum(rng.standard_normal(T)); y = np.cumsum(rng.standard_normal(T))
        tl.append(abs(ols_t(y, x)[1])); td.append(abs(ols_t(np.diff(y), np.diff(x))[1]))
    lev.append(np.median(tl)); dif.append(np.median(td))
    print(f"Fig3 T={T}: median|t| levels={lev[-1]:.2f} differences={dif[-1]:.2f}")
fig, ax = plt.subplots(figsize=(6.5, 4))
ax.plot(Ts, lev, "o-", color=BLUE, lw=2, ms=8, label="levels (spurious)")
ax.plot(Ts, dif, "o-", color=ORANGE, lw=2, ms=8, label="first differences")
ref = lev[0] * np.sqrt(np.array(Ts) / Ts[0])
ax.plot(Ts, ref, "--", color=MUTED, lw=1.5, label="∝ √T reference")
ax.axhline(1.96, color=MUTED, lw=1, ls=":")
ax.text(Ts[-1], 1.75, "|t| = 1.96", color=MUTED, ha="right")
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xticks(Ts); ax.set_xticklabels([str(t) for t in Ts])
ax.set_xlabel("sample size T (log scale)"); ax.set_ylabel("median |t| (log scale)")
ax.set_title("Spurious t grows like √T; differenced t does not", loc="left")
ax.legend(frameon=False)
fig.tight_layout(); fig.savefig(os.path.join(OUT, "Granger 2003 - t growth with T.png"), dpi=150)

Printed output (NumPy 2.2.6, Matplotlib 3.10.3):

Fig1 slope=-0.270 t=-6.75 R2=0.318
Fig2 white noise I(0): P(|r|>0.9)=0.00 SD=0.33
Fig2 random walk I(1): P(|r|>0.9)=0.02 SD=0.52
Fig2 twice-cumulated I(2): P(|r|>0.9)=0.50 SD=0.82
Fig3 T=25: median|t| levels=2.20 differences=0.70
Fig3 T=50: median|t| levels=3.25 differences=0.67
Fig3 T=100: median|t| levels=4.42 differences=0.68
Fig3 T=200: median|t| levels=6.66 differences=0.70
Fig3 T=400: median|t| levels=9.16 differences=0.67
Fig3 T=800: median|t| levels=12.56 differences=0.69

Figure 1 — one spurious pair

Reading: the first draw from seed 0, not a selected example. Two independent random walks of length 100 wander apart: drifts up and drifts down. The levels regression reports slope , and , a “highly significant” negative relationship between series that share nothing. The scatter shows the cause: consecutive points are joined in time, so the cloud is a few long excursions, not 100 independent observations. That is Granger’s string of pearls (p. 360).

Figure 2 — Yule's three correlation shapes ( , 20,000 pairs each)

Reading: the histograms behind Experiment 3. White noise gives a hump centred on 0 (SD 0.33, close to ). Random walks give an almost flat distribution over (SD 0.52). Twice-cumulated series give a U shape with half the mass beyond (SD 0.82). These are the bell, flat and U shapes Yule (1926) reported for random series, conjunct series with random differences, and conjunct series with conjunct differences; his own SDs were about 0.32 and 0.51-0.55 for the first two (Granger 2003 - Spurious Regression from Yule to Granger-Newbold).

Figure 3 — the spurious grows like (2,000 pairs per , seed 5)

Reading: on log-log axes the median levels follows the dashed reference line from 2.2 at to 12.6 at , as Phillips (1986) predicts. The differenced regression stays flat near 0.67-0.70, the median of . More data makes the spurious finding more convincing, which is why a larger sample is no protection.

Examples

Adapting the script as a persistence-matched null for a real analysis

Setup: an MMM or driver regression of a weekly KPI () on a candidate series returns in levels.

Steps: (1) fit an AR(1) or ARIMA(0,1,0) to the candidate series; (2) generate 2,000 surrogate candidates from that fitted process, independent of the KPI; (3) rerun the same regression (same controls, same transformations) with each surrogate; (4) report where 5.1 falls in the surrogate distribution of . With random-walk-like candidates at , the median surrogate is about 4.6 (Exp. 1), so is unremarkable — evidence comparable to a coin flip, not to .

Why it works: the null distribution inherits the persistence that invalidates the textbook distribution. The same logic (calibrate a procedure on data where the truth is known) underlies Designing Simulated-Data Experiments and Zhang Vaver 2017 - Aggregate Marketing System Simulator - Overview.

Connections

See Also