|
| 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 | + ) |
0 commit comments