🇬🇧 English · 🇪🇸 Español
Local Projections (LP)
Local Projections (Jordà 2005) estimate impulse response functions by running a sequence of direct predictive regressions for each horizon \(h = 0, 1, \dots, H\):
where \(x_t\) is a structural shock or policy variable, \(w_t\) contains control variables (which enter contemporaneously and with \(p\) lags, together with \(p\) lags of \(y_t\) and \(x_t\)), and \(\beta_h\) traces out the impulse response function at horizon \(h\).
In contrast to Structural VARs, which iterate forward a one-step-ahead linear model, Local Projections: - Do not compound specification errors across horizons. - Naturally accommodate non-linearities, asymmetric regimes, and state-dependent effects. - Support flexible inference via Newey-West HAC or Driscoll-Kraay standard errors without requiring stationary companion matrices.
In puremacro 2.0, all local projection estimators return a unified LPResult object that behaves as a pandas DataFrame while exposing one-call plotting (.plot()) and publication-grade export methods (.to_latex(), .to_typst(), .to_markdown()).
The code blocks on this page build on each other: run them in order and the whole page executes verbatim.
Quickstart: Single-Series LP-HAC
To estimate impulse responses with Newey-West heteroskedasticity- and autocorrelation-consistent (HAC) standard errors:
import numpy as np
import pandas as pd
from puremacro.lp import lp_hac
# Synthetic dataset: response of output to a policy shock
rng = np.random.default_rng(42)
T = 200
shock = rng.standard_normal(T)
output = np.cumsum(0.6 * shock + 0.4 * rng.standard_normal(T))
df = pd.DataFrame({"gdp": output, "shock": shock})
# Estimate LP up to horizon 12 with 4 lags of controls and 90% CI
res = lp_hac(df, y="gdp", x="shock", horizon=12, lags=4, ci=0.90)
# 1. Print structured summary (with significance flags)
print(res.summary())
# 2. Inspect point estimates and standard errors
print(res[["h", "beta", "se", "lo", "hi"]].head())
# 3. Plot IRF with confidence bands in 1 line
res.plot(title="Response of GDP to Structural Shock")
The LPResult Object
The returned res is an LPResult (subclassing pd.DataFrame). You can treat it as a standard DataFrame or use its specialized econometric attributes:
| Property / Method | Description |
|---|---|
res.point |
Point estimates \(\hat{\beta}_h\) as a NumPy array of shape (H+1,) |
res.se |
HAC standard errors \(\hat{\text{se}}(\hat{\beta}_h)\) of shape (H+1,) (all-NaN for band-only estimators such as lp_quantile) |
res.ci_lower |
Lower confidence bound \(\hat{\beta}_h - z_{\alpha/2} \cdot \hat{\text{se}}\) |
res.ci_upper |
Upper confidence bound \(\hat{\beta}_h + z_{\alpha/2} \cdot \hat{\text{se}}\) |
res.t_stat |
\(\hat{\beta}_h / \hat{\text{se}}\) (NaN where no SE is available) |
res.horizons |
Array of horizons evaluated [0, 1, ..., H] |
res.labels |
[] for single-coefficient results; ['H', 'L'] / ['pos', 'neg'] for regime or sign results |
res.plot() |
Renders an impulse response plot with shaded confidence bands (one line and band per regime for regime results, one line per quantile for lp_quantile) |
res.summary() |
Formatted tabular summary string with significance flags: *** p<0.01, ** p<0.05, * p<0.10 (two-sided normal z-test); for band-only estimators a single * marks bands that exclude zero |
res.to_markdown() |
Renders table formatted for Quarto, GitHub, or Obsidian |
res.to_latex() |
Generates a clean \begin{tabular} for LaTeX / Overleaf |
res.to_typst() |
Generates a #table(...) block for modern Typst documents |
For regime or sign results (lp_state_dep, lp_state_dep_iv, lp_asymmetric, lp_garch_state) the table carries one coefficient per label (beta_H/beta_L, beta_pos/beta_neg) and res.point, res.se, res.ci_lower, res.ci_upper and res.t_stat return a DataFrame indexed by h with one column per label.
Instrumental Variable Local Projections (lp_iv)
When the policy intervention \(x_t\) is endogenous (for example, policy interest rates reacting contemporaneously to economic shocks), standard OLS local projections suffer from omitted-variable bias.
lp_iv implements Two-Stage Least Squares (2SLS) local projections using an external instrument \(z_t\) (such as high-frequency monetary surprises or narrative tax changes):
The block below first extends the synthetic dataset with the illustrative columns used in the rest of this page (an endogenous policy rate driven by a high-frequency surprise, two controls, government spending driven by a military-news instrument, and an unemployment rate in percent):
from puremacro.lp import lp_iv
# Illustrative columns for the remaining examples (synthetic, seeded above)
df["hf_monetary_surprise"] = rng.standard_normal(T)
df["fedfunds"] = 0.7 * df["hf_monetary_surprise"] + 0.5 * rng.standard_normal(T)
df["inflation"] = rng.standard_normal(T)
df["commodity_prices"] = rng.standard_normal(T)
df["military_news"] = rng.standard_normal(T)
df["gov_spending"] = 0.6 * df["military_news"] + 0.5 * rng.standard_normal(T)
df["unemployment_rate"] = 6.0 + 1.5 * rng.standard_normal(T) # percent
res_iv = lp_iv(
df,
y="gdp",
x="fedfunds",
z="hf_monetary_surprise",
controls=["inflation", "commodity_prices"],
horizon=16,
lags=4,
ci=0.90,
)
# First-stage F statistics across horizons
print("First stage F:", res_iv["first_stage_f"].values)
res_iv.plot(title="LP-IV: Monetary Transmission via External Instrument")
Weak-instrument diagnostics (3.4.0)
z accepts a sequence as well as a single name, and every LP-IV row now
carries the Montiel Olea & Pflueger (2013) effective F-statistic
alongside its critical values:
| Column | Meaning |
|---|---|
first_stage_f |
With one instrument, the squared first-stage t-statistic (unchanged from 3.3.0). With two or more, it equals mop_f, because a robust joint Wald F is the only sensible scalar there |
mop_f |
The MOP effective F, \(\hat{\pi}' (Z'Z) \hat{\pi} \; / \; \operatorname{tr}\!\big((Z'Z)^{-1} \hat{\Omega}_{zz}\big)\), with the same Newey-West bandwidth \(L = h + 1\) as the horizon's HAC errors |
mop_cv_10, mop_cv_20 |
Critical values at the 5% level for 10% and 20% worst-case Nagar bias |
The critical values are computed from the MOP closed form rather than read off a table:
the \(1 - \alpha\) quantile of a non-central \(\chi^2\) with \(K\) degrees of freedom and non-centrality \(K / \tau\), divided by \(K\). For a single instrument at \(\alpha = 0.05\) this reproduces the published MOP values 23.11 (\(\tau = 10\%\)) and 15.06 (\(\tau = 20\%\)) — and 37.42 at 5%, 12.04 at 30%:
from puremacro.lp.iv import mop_critical_values # not re-exported at puremacro.lp
mop_critical_values(1) # (23.10851121160664, 15.061552535779715)
mop_critical_values(1, tau=(0.05, 0.30)) # (37.417561545697716, 12.045036998377999)
mop_critical_values(2) # (19.294343449962533, 12.172075007359934)
For \(K \ge 2\) the full MOP critical value is data dependent (it replaces
\(K\) by an effective degrees of freedom estimated from the first-stage HAC
covariance). What mop_cv_10 / mop_cv_20 report is the \(W = I\)
reduction, which is exact for \(K = 1\) and a reference point otherwise.
Anderson-Rubin confidence sets
anderson_rubin=True (alias weak_iv_robust=True) adds ar_lo, ar_hi
and ar_set_type. The set \(\{\beta_0 : AR(\beta_0) \le \chi^2_{K,\,1-\alpha}\}\)
is inverted in closed form — the limit \(AR(\pm\infty)\) decides whether
the tails are rejected, and the endpoints are Brent roots of
\(AR - \text{cv}\) — rather than being read off a fixed grid, so an
interval far from the 2SLS point estimate is not silently truncated:
ar_set_type |
The set is | Read it as |
|---|---|---|
bounded |
\([\,\)ar_lo, ar_hi\(\,]\) |
the usual interval; the instruments identify \(\beta\) |
unbounded_rays |
\((-\infty,\) ar_hi\(\,] \cup [\,\)ar_lo\(, \infty)\), with ar_lo > ar_hi |
weak identification: a bounded interval does not exist |
all_real |
\((-\infty, \infty)\); ar_lo = \(-\infty\), ar_hi = \(+\infty\) |
the instruments say nothing about \(\beta\) |
empty |
\(\varnothing\); both bounds NaN | every candidate is rejected — normally a misspecified or over-identified model |
# Two instruments for one endogenous regressor
df["hf_surprise_2"] = 0.4 * df["hf_monetary_surprise"] + 0.6 * rng.standard_normal(T)
df["fedfunds2"] = (0.5 * df["hf_monetary_surprise"] + 0.5 * df["hf_surprise_2"]
+ 0.5 * rng.standard_normal(T))
res_weak = lp_iv(
df,
y="gdp",
x="fedfunds2",
z=["hf_monetary_surprise", "hf_surprise_2"],
controls=["inflation"],
horizon=4,
lags=4,
ci=0.90,
weak_iv_robust=True,
)
print(res_weak[["h", "beta", "mop_f", "mop_cv_10", "ar_lo", "ar_hi", "ar_set_type"]].round(4))
# h beta mop_f mop_cv_10 ar_lo ar_hi ar_set_type
# h
# 0 0 0.0115 238.0877 19.2943 -0.0967 0.1123 bounded
# 1 1 0.1049 229.4756 19.2943 -0.0479 0.2764 bounded
# 2 2 0.1415 228.8576 19.2943 -0.0408 0.3303 bounded
# 3 3 0.0729 228.2393 19.2943 -0.1292 0.2407 bounded
# 4 4 0.0952 233.6795 19.2943 -0.1213 0.2832 bounded
Lag-augmented LP-IV is the same surface under a different variance
estimator. la_lp gained z=, anderson_rubin= and weak_iv_robust=,
and la_lp_iv is the instrument-first spelling of the same estimator
(res.method == "la_lp_iv"); both are exported from puremacro.lp:
from puremacro.lp import la_lp_iv
res_la = la_lp_iv(
df, y="gdp", x="fedfunds", z="hf_monetary_surprise",
horizon=4, lags=4, ci=0.90, weak_iv_robust=True,
)
Lag augmentation (Montiel Olea & Plagborg-Møller 2021, Econometrica
89(4), 1789–1823) controls for one more lag of every series than the
non-augmented LP needs. The regression scores are then serially
uncorrelated, so the plain Eicker-Huber-White (HC0) standard error is
valid at every horizon, uniformly over the persistence of the data (their
§2.1 and Proposition 1). MOPM prove this for OLS local projections;
la_lp_iv carries it over to LP-IV and uses White — not HAC — everywhere:
in the second stage, in mop_f, and in the AR set (lags = 0 is the
zero-lag Bartlett case). first_stage_f there equals mop_f for any
number of instruments.
Lag count. la_lp and la_lp_iv include p_aug = n_lags + extra_lags
lags of x and y and control_lags lags of each control (default
p_aug), with the same p_aug at every horizon. The default
extra_lags=1 is MOPM's single extra lag: in a VAR(p) their LP controls
for p lags of all the series, one more than the p − 1 lags that the LP
representation needs (§4.1, p. 20), so n_lags = p − 1 reproduces their
specification exactly. A larger n_lags is the conservative choice, and
MOPM note that it has no asymptotic efficiency cost (§6, p. 28). No source
recommends p + h lags. The lag specification is recorded on the result:
from puremacro.lp import la_lp
res_ols = la_lp(df, y="gdp", x="fedfunds", horizon=8, lags=4, ci=0.90)
print(res_ols.attrs["extra_lags"], res_ols.attrs["p_aug"])
# 1 5
Changed after 4.3.0. The default used to be
extra_lags = max(horizons)lags at every horizon. The estimate at a givenhtherefore depended on the largest horizon requested,horizons=[0]got no augmentation, and controls got onlyn_lagslags. To reproduce old results exactly, passextra_lags=max(horizons), control_lags=n_lags.Clustering is not supported. Neither
lp_ivnorla_lp_ivtakes acluster=argument, and no weak-instrument statistic on this page is cluster-robust: the variance is Newey-West HAC inlp_ivand White inla_lp_iv. Cluster-robust panel inference lives inpanel_lp/panel_lp_dk(§ Panel Local Projections), which do not computemop_for AR sets.puremacro.inference.weak_iv.olea_pflueger_faccepts acluster=array, but that is a standalone first-stage statistic, not part of the LP-IV pipeline.
State-Dependent Local Projections (lp_state_dep)
Macroeconomic transmission often differs across economic regimes (e.g. recessions vs. expansions, or high vs. low debt regimes). lp_state_dep partitions responses based on a state variable \(s_t\):
where \(F(s_t) \in [0, 1]\) is the high-regime weight.
Smooth Logistic vs. Sharp Threshold
- Sharp Threshold (
transition="threshold"): \(F(s_t) = \mathbb{I}\{s_t > c\}\). Observations are discretely partitioned into high and low regimes. - Smooth Transition (
transition="logistic", default): \(F(s_t) = \frac{1}{1 + \exp(-\gamma (s_t - c) / \sigma_s)}\), where \(\sigma_s\) is the sample standard deviation of the state, so \(\gamma > 0\) is the speed of transition in standard deviations of \(s_t\) (Auerbach & Gorodnichenko 2012 use \(\gamma = 1.5\) on a standardized state).
The cutoff \(c\) is the threshold argument on the raw scale of the state variable: threshold=6.5 on an unemployment rate in percent means 6.5 %. The default threshold=None splits at the sample mean of the state (the standardized-zero convention of Auerbach-Gorodnichenko). A cutoff that leaves every observation in one regime raises a ValueError naming the state's range instead of a singular regression.
from puremacro.lp import lp_state_dep
# Smooth transition around 6.5 % unemployment (state_var= is an alias of state=)
res_state = lp_state_dep(
df,
y="gdp",
x="gov_spending",
state="unemployment_rate",
transition="logistic",
gamma=3.0,
threshold=6.5,
horizon=12,
lags=2,
ci=0.90,
)
print(res_state[["h", "beta_H", "se_H", "beta_L", "se_L"]].head())
print(res_state.summary()) # one row per horizon and regime, with flags
print(res_state.point.head()) # DataFrame with columns H and L
res_state.plot(title="Spending multiplier: high vs low unemployment")
State-Dependent LP-IV (lp_state_dep_iv)
Following Ramey & Zubairy (2018, JPE), evaluating state-dependent government spending multipliers requires instrumenting the interaction terms to identify state-specific causal effects:
lp_state_dep_iv estimates both first-stage regressions, checks regime-specific instrument relevance via first-stage F statistics, and computes second-stage multipliers with HAC inference. threshold and gamma follow exactly the lp_state_dep conventions above:
from puremacro.lp import lp_state_dep_iv
res_rz = lp_state_dep_iv(
df,
y="gdp",
x="gov_spending",
z="military_news",
state="unemployment_rate",
threshold=6.5, # 6.5 % unemployment, raw scale of the state
transition="threshold", # Or 'logistic'
horizon=16,
lags=4,
ci=0.90,
)
# Inspect high-slack vs low-slack multipliers and first-stage F stats
print(res_rz[["h", "beta_H", "first_stage_f_H", "beta_L", "first_stage_f_L"]])
# Export the multipliers table directly to LaTeX for your manuscript
latex_table = res_rz.to_latex()
Panel Local Projections (panel_lp & panel_lp_dk)
For cross-country or regional panel datasets, puremacro provides two-way fixed-effects panel local projections with cluster-robust (panel_lp) or Driscoll-Kraay (1998) standard errors (panel_lp_dk, or panel_lp(..., cov_type="driscoll-kraay")) robust to cross-sectional spatial correlation and arbitrary serial correlation.
The panel can be a long-form frame whose entity and time identifiers are columns (unit_col= / time_col=), or a frame already indexed by an (entity, time) MultiIndex whose level names are given by entity_level= / time_level=:
from puremacro.lp import panel_lp, panel_lp_dk
# Long-form panel: one row per (country, date)
countries = [f"C{i}" for i in range(8)]
dates = list(pd.period_range("2000Q1", periods=60, freq="Q"))
panel_df = pd.DataFrame({
"country": np.repeat(countries, len(dates)),
"date": dates * len(countries),
})
panel_df["credit_shock"] = rng.standard_normal(len(panel_df))
panel_df["real_gdp"] = 0.5 * panel_df["credit_shock"] + rng.standard_normal(len(panel_df))
res_panel = panel_lp_dk(
panel_df,
y="real_gdp",
x="credit_shock",
unit_col="country",
time_col="date",
horizon=12,
lags=2,
)
res_panel.plot(title="Panel LP: Credit Shock Response (Driscoll-Kraay SEs)")
# Same estimates from a frame already indexed by (country, date)
res_indexed = panel_lp_dk(
panel_df.set_index(["country", "date"]),
y="real_gdp", x="credit_shock",
entity_level="country", time_level="date",
horizon=12, lags=2,
)
assert np.allclose(res_panel.point, res_indexed.point)
# Cluster-robust SEs by entity, or Driscoll-Kraay through cov_type
res_cluster = panel_lp(panel_df, y="real_gdp", x="credit_shock",
unit_col="country", time_col="date", horizon=12, lags=2)
res_dk = panel_lp(panel_df, y="real_gdp", x="credit_shock",
unit_col="country", time_col="date", horizon=12, lags=2,
cov_type="driscoll-kraay")
assert np.allclose(res_dk.se, res_panel.se)
Automatic Row-Order Invariance
In puremacro, all panel LP estimators sort the panel index (unit, time) before applying time shifts. Even if your input DataFrame contains unsorted rows, puremacro ensures bit-identical numerical results and guards against positional lag leakage.
Standardized Signatures & Parameter Aliases
Every estimator exported by puremacro.lp (including lp_state_dep and lp_did) accepts the modern keyword-only arguments below in addition to the legacy names:
| Modern Parameter | Accepted Legacy Alias | Default | Description |
|---|---|---|---|
lags |
n_lags |
2 — except la_lp and smooth_lp (4) and lp_did (0) |
Number of lag controls included in the projection (for lp_did: lagged outcome changes \(\Delta y_{i,t-k}\) added as controls) |
horizon |
horizons |
20, i.e. \(h = 0 \dots 20\) — except lp_iv_lewbel and lp_did (12) |
Max projection horizon (computes \(h = 0 \dots H\)) |
ci |
alpha |
0.90 — except smooth_lp (0.95) |
Confidence interval coverage (e.g. ci=0.95 \(\leftrightarrow\) alpha=0.05) |
ci and alpha must be probabilities in \((0, 1)\): passing a percentage by mistake (ci=90) or alpha=1.5 raises a ValueError instead of silently producing NaN bands. lp_state_dep additionally accepts state_var= as an alias of state=.
Comparison: Local Projections vs. SVAR
| Feature | Local Projections (puremacro.lp) |
Structural VAR (puremacro.var) |
|---|---|---|
| Underlying Philosophy | Direct multi-step regression | Iterated 1-step linear model |
| Persistence Assumption | Robust to non-stationary persistence | Sensitive to unit roots in companion matrix |
| State Dependence | Built-in via interaction terms (\(F(s_t) x_t\)) | Requires non-linear TVP/Threshold VAR |
| Efficiency at Long Horizons | Higher variance at large \(H\) | More efficient if VAR is correctly specified |
| Data Requirements | Long time series needed for high \(H\) | Moderate samples suffice |