Skip to content

Commit b06b389

Browse files
feat(timeseries): local_projections (Jordà 2005) with Newey-West HAC
SP-04 Task 1. Adds the modern-macro workhorse impulse response estimator: y_{t+h} - y_{t-1} = α_h + β_h·shock_t + γ_h·controls_{t-1} + ε fitted horizon-by-horizon with Newey-West HAC standard errors (truncation lag default = round(1.5·H)). Returns a result object with horizons, IRF, SE, CI, ``.plot()``, ``.to_frame()``, ``.summary()``. On an AR shock DGP with known IRF {1.0, 1.0, 0.7, 0.4, 0.1, 0, …}: recovers h=0 impact ≈ 1.0, decay after peak to <0.3 by h=8, CI properly brackets the point estimates. Exposed at sp.local_projections. Supports cumulative mode for cumulative-response IRFs. Test suite: 6 tests, all green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b6faff commit b06b389

4 files changed

Lines changed: 273 additions & 1 deletion

File tree

src/statspai/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@
146146
# Nonparametric
147147
from .nonparametric import lpoly, LPolyResult, kdensity, KDensityResult
148148
# Time Series (for causal inference)
149-
from .timeseries import var, VARResult, granger_causality, irf, structural_break, StructuralBreakResult, cusum_test
149+
from .timeseries import (
150+
var, VARResult, granger_causality, irf,
151+
structural_break, StructuralBreakResult, cusum_test,
152+
local_projections, LocalProjectionsResult,
153+
)
150154
# Experimental Design
151155
from .experimental import randomize, RandomizationResult, balance_check, BalanceResult, attrition_test, attrition_bounds, AttritionResult, optimal_design, OptimalDesignResult
152156
# Missing Data / Imputation

src/statspai/timeseries/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from .var import var, VARResult, granger_causality, irf
99
from .structural_break import structural_break, StructuralBreakResult, cusum_test
1010
from .cointegration import engle_granger, johansen, CointegrationResult
11+
from .local_projections import local_projections, LocalProjectionsResult
1112

1213
__all__ = [
1314
"var", "VARResult", "granger_causality", "irf",
1415
"structural_break", "StructuralBreakResult", "cusum_test",
1516
"engle_granger", "johansen", "CointegrationResult",
17+
"local_projections", "LocalProjectionsResult",
1618
]
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Local Projections (Jordà, AER 2005).
2+
3+
Estimates impulse responses by running horizon-by-horizon regressions:
4+
5+
y_{t+h} - y_{t-1} = α_h + β_h · shock_t + γ_h · controls_t + ε_{t,h}
6+
7+
for h = 0, 1, …, H. The sequence ``{β_h}`` traces the impulse response
8+
function. Inference uses Newey-West HAC standard errors (required because
9+
the residuals are serially correlated by construction).
10+
11+
This estimator is much more flexible than a VAR-based IRF — no
12+
finite-lag structure imposed — and is the default in modern empirical
13+
macro (Ramey 2016; Plagborg-Møller & Wolf 2021).
14+
"""
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass, field
18+
from typing import List, Optional
19+
20+
import numpy as np
21+
import pandas as pd
22+
from scipy import stats
23+
24+
25+
# --------------------------------------------------------------------- #
26+
# Newey-West HAC for a single regression
27+
# --------------------------------------------------------------------- #
28+
29+
def _newey_west(X: np.ndarray, e: np.ndarray, lags: int) -> np.ndarray:
30+
"""Newey-West HAC covariance matrix for an OLS regression with
31+
residuals ``e``. Bartlett kernel weights up to ``lags``.
32+
"""
33+
n, k = X.shape
34+
XtX_inv = np.linalg.inv(X.T @ X)
35+
# meat: sum_l w_l (Γ_l + Γ_l') where Γ_l = (1/n) sum_t X_t e_t e_{t-l} X_{t-l}'
36+
omega = np.zeros((k, k))
37+
u = X * e[:, None] # (n, k)
38+
for lag in range(lags + 1):
39+
if lag == 0:
40+
G = u.T @ u
41+
else:
42+
G = u[lag:].T @ u[:-lag]
43+
G = G + G.T
44+
w = 1.0 - lag / (lags + 1.0)
45+
omega += w * G
46+
V = XtX_inv @ omega @ XtX_inv
47+
return V
48+
49+
50+
# --------------------------------------------------------------------- #
51+
# Result
52+
# --------------------------------------------------------------------- #
53+
54+
@dataclass
55+
class LocalProjectionsResult:
56+
horizons: np.ndarray # (H+1,)
57+
irf: np.ndarray # (H+1,)
58+
se: np.ndarray # (H+1,)
59+
ci_lower: np.ndarray # (H+1,)
60+
ci_upper: np.ndarray # (H+1,)
61+
alpha: float
62+
shock_name: str
63+
outcome_name: str
64+
n_obs_per_horizon: np.ndarray # (H+1,) — usable sample per horizon
65+
66+
def to_frame(self) -> pd.DataFrame:
67+
return pd.DataFrame({
68+
"horizon": self.horizons,
69+
"irf": self.irf,
70+
"se": self.se,
71+
"ci_lower": self.ci_lower,
72+
"ci_upper": self.ci_upper,
73+
"n": self.n_obs_per_horizon,
74+
})
75+
76+
def plot(self, ax=None, **kwargs):
77+
import matplotlib.pyplot as plt
78+
if ax is None:
79+
_, ax = plt.subplots(figsize=(7, 4))
80+
ax.plot(self.horizons, self.irf, "-o", color="C0",
81+
label=f"IRF of {self.outcome_name}")
82+
ax.fill_between(self.horizons, self.ci_lower, self.ci_upper,
83+
alpha=0.25, color="C0",
84+
label=f"{int((1 - self.alpha) * 100)}% CI")
85+
ax.axhline(0, color="grey", linewidth=0.6)
86+
ax.set_xlabel("Horizon h")
87+
ax.set_ylabel(f"Response to {self.shock_name}")
88+
ax.legend()
89+
return ax
90+
91+
def summary(self) -> str:
92+
df = self.to_frame()
93+
lines = [
94+
f"Local Projections — {self.outcome_name} on {self.shock_name}",
95+
"-" * 55,
96+
df.to_string(index=False, float_format=lambda x: f"{x: .4f}"),
97+
]
98+
return "\n".join(lines)
99+
100+
def __repr__(self) -> str:
101+
return self.summary()
102+
103+
104+
# --------------------------------------------------------------------- #
105+
# Public entry point
106+
# --------------------------------------------------------------------- #
107+
108+
def local_projections(
109+
data: pd.DataFrame,
110+
outcome: str,
111+
shock: str,
112+
controls: Optional[List[str]] = None,
113+
horizons: int = 20,
114+
nw_lags: Optional[int] = None,
115+
alpha: float = 0.05,
116+
cumulative: bool = False,
117+
) -> LocalProjectionsResult:
118+
"""Estimate impulse responses via Jordà (2005) local projections.
119+
120+
Parameters
121+
----------
122+
data : pd.DataFrame
123+
Time-ordered panel (single series for now — panel extension TBD).
124+
outcome : str
125+
Column name of the outcome variable y.
126+
shock : str
127+
Column name of the shock / treatment variable. Its own lagged
128+
value (shock_{t-1}) is added automatically as a pre-treatment
129+
control.
130+
controls : list of str, optional
131+
Additional pre-treatment controls (measured at time t-1).
132+
horizons : int, default 20
133+
Number of horizons h = 0, 1, …, H to estimate.
134+
nw_lags : int, optional
135+
Newey-West truncation lag. Defaults to ``round(1.5 * horizons)``
136+
per Kilian & Kim (2011) recommendation.
137+
alpha : float, default 0.05
138+
Significance level for the confidence band.
139+
cumulative : bool, default False
140+
If ``True``, return the cumulative response
141+
``y_{t+h} - y_{t-1}``. Default (False) returns ``y_{t+h}``
142+
directly.
143+
"""
144+
if controls is None:
145+
controls = []
146+
df = data.copy().reset_index(drop=True)
147+
n = len(df)
148+
if nw_lags is None:
149+
nw_lags = max(1, int(round(1.5 * horizons)))
150+
151+
y = df[outcome].to_numpy(dtype=float)
152+
s = df[shock].to_numpy(dtype=float)
153+
# Pre-treatment: y_{t-1}, s_{t-1}, controls_{t-1}
154+
lag_y = np.concatenate([[np.nan], y[:-1]])
155+
lag_s = np.concatenate([[np.nan], s[:-1]])
156+
extra_lags = []
157+
extra_names = []
158+
for c in controls:
159+
col = df[c].to_numpy(dtype=float)
160+
extra_lags.append(np.concatenate([[np.nan], col[:-1]]))
161+
extra_names.append(f"lag_{c}")
162+
163+
irf = np.empty(horizons + 1)
164+
se = np.empty(horizons + 1)
165+
n_used = np.empty(horizons + 1, dtype=int)
166+
167+
for h in range(horizons + 1):
168+
# LHS: y_{t+h} (or y_{t+h} - y_{t-1} if cumulative)
169+
if t_end := n - h:
170+
y_lhs = y[h:]
171+
lhs = y_lhs - lag_y[h:] if cumulative else y_lhs
172+
else:
173+
raise RuntimeError("too few observations for horizon")
174+
shock_t = s[:t_end]
175+
lag_y_t = lag_y[:t_end]
176+
lag_s_t = lag_s[:t_end]
177+
extras_t = [arr[:t_end] for arr in extra_lags]
178+
179+
X = np.column_stack([np.ones(t_end), shock_t, lag_y_t, lag_s_t, *extras_t])
180+
valid = ~np.isnan(lhs) & ~np.any(np.isnan(X), axis=1)
181+
X_use = X[valid]
182+
lhs_use = lhs[valid]
183+
if len(X_use) <= X_use.shape[1] + 2:
184+
irf[h] = np.nan; se[h] = np.nan; n_used[h] = int(valid.sum())
185+
continue
186+
beta, *_ = np.linalg.lstsq(X_use, lhs_use, rcond=None)
187+
e = lhs_use - X_use @ beta
188+
V = _newey_west(X_use, e, lags=max(h, nw_lags))
189+
irf[h] = float(beta[1]) # coefficient on shock
190+
se[h] = float(np.sqrt(max(V[1, 1], 0)))
191+
n_used[h] = int(valid.sum())
192+
193+
z_crit = stats.norm.ppf(1 - alpha / 2)
194+
return LocalProjectionsResult(
195+
horizons=np.arange(horizons + 1),
196+
irf=irf,
197+
se=se,
198+
ci_lower=irf - z_crit * se,
199+
ci_upper=irf + z_crit * se,
200+
alpha=alpha,
201+
shock_name=shock,
202+
outcome_name=outcome,
203+
n_obs_per_horizon=n_used,
204+
)

tests/test_local_projections.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Local projections (Jordà 2005) tests."""
2+
from __future__ import annotations
3+
4+
import numpy as np
5+
import pandas as pd
6+
import pytest
7+
8+
from statspai.timeseries.local_projections import local_projections
9+
10+
11+
@pytest.fixture(scope="module")
12+
def ar_dgp():
13+
rng = np.random.default_rng(0)
14+
n = 300
15+
eps = rng.standard_normal(n)
16+
shock = np.zeros(n); shock[0] = rng.standard_normal()
17+
for t in range(1, n):
18+
shock[t] = 0.3 * shock[t-1] + rng.standard_normal()
19+
y = np.zeros(n)
20+
for t in range(4, n):
21+
y[t] = (0.3 * y[t-1] + 1.0 * shock[t] + 0.7 * shock[t-1]
22+
+ 0.4 * shock[t-2] + 0.1 * shock[t-3] + 0.5 * eps[t])
23+
return pd.DataFrame({"y": y, "shock": shock})
24+
25+
26+
def test_irf_recovers_impact(ar_dgp):
27+
res = local_projections(ar_dgp, outcome="y", shock="shock", horizons=8)
28+
# impact response (h=0) should be ≈ 1.0
29+
assert abs(res.irf[0] - 1.0) < 0.2
30+
31+
32+
def test_irf_decays_after_peak(ar_dgp):
33+
res = local_projections(ar_dgp, outcome="y", shock="shock", horizons=12)
34+
# After horizon 4 the response should shrink toward zero
35+
assert abs(res.irf[8]) < 0.3
36+
assert abs(res.irf[11]) < 0.3
37+
38+
39+
def test_confidence_interval_bracketing(ar_dgp):
40+
res = local_projections(ar_dgp, outcome="y", shock="shock", horizons=5, alpha=0.05)
41+
assert (res.ci_lower < res.irf).all()
42+
assert (res.irf < res.ci_upper).all()
43+
44+
45+
def test_cumulative_mode_differs(ar_dgp):
46+
r1 = local_projections(ar_dgp, outcome="y", shock="shock", horizons=5)
47+
r2 = local_projections(ar_dgp, outcome="y", shock="shock", horizons=5,
48+
cumulative=True)
49+
# cumulative and level responses differ by construction
50+
assert np.any(np.abs(r1.irf - r2.irf) > 0.05)
51+
52+
53+
def test_to_frame_shape(ar_dgp):
54+
res = local_projections(ar_dgp, outcome="y", shock="shock", horizons=4)
55+
df = res.to_frame()
56+
assert len(df) == 5
57+
assert set(df.columns) == {"horizon", "irf", "se", "ci_lower", "ci_upper", "n"}
58+
59+
60+
def test_exported_at_sp_dot():
61+
import statspai as sp
62+
assert callable(sp.local_projections)

0 commit comments

Comments
 (0)