Driscoll-Kraay and clustered standard errors for a panel local projection¶
Clustering by entity is the reflex. On a panel hit by a common shock it is
the wrong reflex, and it does not fail loudly — it fails by making your standard
errors smaller. tsecon.panel_lp exposes the choice as se_type, and this
recipe shows what each one is actually correcting.
The recipe¶
import numpy as np, tsecon
rng = np.random.default_rng(5)
N, T = 30, 120 # 30 countries, 120 quarters
shock = rng.standard_normal(T) # ONE common shock, same for everyone
common = rng.standard_normal(T) # an unobserved common factor
alpha = rng.normal(0, 1, N) # entity fixed effects
y = np.zeros((N, T)) # outcome is N x T
for i in range(N):
for t in range(1, T):
y[i, t] = (alpha[i] + 0.5 * y[i, t - 1] + 0.8 * shock[t]
+ 0.9 * common[t] + rng.standard_normal())
for se in ("nonrobust", "cluster", "driscoll_kraay"):
r = tsecon.panel_lp(y, shock, horizon=6, n_lag_controls=2, se_type=se)
band = " ".join(f"{s:.3f}" for s in r["se"][:4])
tstat = r["irf"][2] / r["se"][2]
print(f"{se:>15} se(h=0..3) {band} t at h=2 {tstat:6.2f}")
nonrobust se(h=0..3) 0.026 0.032 0.033 0.034 t at h=2 9.31
cluster se(h=0..3) 0.021 0.022 0.022 0.023 t at h=2 14.18
driscoll_kraay se(h=0..3) 0.086 0.142 0.142 0.128 t at h=2 2.18
Reading it¶
The point estimates are identical across all three rows — only the uncertainty changes. What changes is dramatic:
clustermade things worse. Clustering by entity allows arbitrary correlation within a country over time, and assumes independence across countries. This panel violates exactly that assumption: a common factor hits every country in the same quarter. The t-statistic goes up, from 9.3 to 14.2. Nothing warns you.driscoll_kraayis four to six times wider and drops the t-statistic to 2.2. It clusters by time and applies a HAC kernel across periods, so it absorbs both the cross-sectional dependence and the serial correlation the local projection guarantees.
When the regressor is a single common shock — one number per period, shared by
every unit — the effective sample size is closer to T than to N × T.
Driscoll-Kraay is the estimator that knows this. Reach for cluster when your
shock varies across entities and cross-sectional dependence is genuinely
implausible.
Building the band¶
r = tsecon.panel_lp(y, shock, horizon=6, n_lag_controls=2, se_type="driscoll_kraay")
for h in range(7):
lo = r["irf"][h] - 1.96 * r["se"][h]
hi = r["irf"][h] + 1.96 * r["se"][h]
print(f"h={h} {r['irf'][h]:+.3f} [{lo:+.3f}, {hi:+.3f}]")
h=0 +0.731 [+0.563, +0.899]
h=1 +0.515 [+0.237, +0.794]
h=2 +0.310 [+0.032, +0.588]
h=3 +0.239 [-0.012, +0.490]
h=4 +0.140 [-0.141, +0.421]
h=5 +0.226 [-0.084, +0.535]
h=6 +0.125 [-0.129, +0.380]
Under Driscoll-Kraay the response is significant through h=2 and
indistinguishable from zero afterwards. Under clustered standard errors every
horizon would have looked significant.
Short panels: correcting Nickell bias without lying about the uncertainty¶
With T = 120 above, dynamic-panel (Nickell) bias is negligible. Shorten the
panel and it is not: fixed effects + dynamics + short T biases the IRF by
O(h/T) — growing with the horizon — and no se_type fixes a biased point
estimate. panel_lp has two half-panel corrections, and they treat the
standard errors very differently:
rng = np.random.default_rng(3)
N, T = 30, 40 # a SHORT panel this time
shock = rng.standard_normal(T)
alpha = rng.normal(0, 1, N)
y = np.zeros((N, T))
for i in range(N):
for t in range(1, T):
y[i, t] = (alpha[i] + 0.8 * y[i, t - 1] + 0.8 * shock[t]
+ rng.standard_normal())
common = dict(horizon=4, n_lag_controls=1, se_type="driscoll_kraay", bandwidth=2.0)
fe = tsecon.panel_lp(y, shock, **common)
dj = tsecon.panel_lp(y, shock, jackknife=True, **common)
spj = tsecon.panel_lp(y, shock, bias_correction="spj", **common)
print("true " + " ".join(f"{0.8*0.8**h:+.3f}" for h in range(5)))
for r, label in ((fe, "fe"), (dj, "dj"), (spj, "spj")):
irf = " ".join(f"{v:+.3f}" for v in r["irf"])
se = " ".join(f"{v:.3f}" for v in r["se"])
print(f"{label:>4} ({r['bias_correction']:>15}) irf {irf} se {se}")
true +0.800 +0.640 +0.512 +0.410 +0.328
fe ( none) irf +0.751 +0.512 +0.376 +0.300 +0.064 se 0.030 0.079 0.083 0.154 0.153
dj (dhaene_jochmans) irf +0.752 +0.539 +0.436 +0.399 +0.346 se 0.030 0.079 0.083 0.154 0.153
spj ( spj) irf +0.752 +0.546 +0.441 +0.369 +0.266 se 0.033 0.098 0.117 0.188 0.238
Reading it (one illustrative draw — the rates live in the panel model card's Monte Carlo table):
- The uncorrected FE row sinks below the truth as
hgrows — ath=4it reports 0.064 against a true 0.328. That is the horizon-amplified Nickell bias, and it shrinks withT, not withN. jackknife=True(Dhaene-Jochmans) moves the points and nothing else. Itsserow is bit-identical to FE's by construction: the plug-in SE is kept on an asymptotic-equivalence argument that has not arrived atT=40. The round-2 audit measured the cost: atN=20, T=60, h=8the correction removes the bias but inflates the estimator's dispersion ~36% whilesedoesn't move, costing 8pp of coverage (0.880 → 0.804, recovering byT≈240).bias_correction="spj"(Mei-Sheng-Shi 2026) moves the points and recomputes the SEs for the corrected estimator (residuals at the corrected coefficients, jackknife-adjusted scores, per their reference implementation) — here 0.153 → 0.238 ath=4, wider exactly where the correction is doing the most work. In the card's seeded Monte Carlo the SPJ route cuts theh=2bias by ~4-15x atT∈{20,40}and covers no worse than FE — but atT=20neither reaches the nominal 95% with Driscoll-Kraay standard errors (0.74 FE vs 0.82 SPJ ath=2): DK itself is a short-Tapproximation, the same caveat this page'sbandwidthgotcha carries. Do not read "bias-corrected" as "exact bands atT=20".
The two corrections are the same 2·full − ½(half₁+half₂) combination with
different bookkeeping (the DJ halves are self-contained windows; the MSS/SPJ
halves keep full-panel leads and lags and split the usable rows at their
median), so they coincide in the points only in the degenerate
no-lags-at-h=0-even-T case and differ everywhere else. Ask for one or the
other; asking for both raises.
Gotchas¶
- Shapes.
outcomeisN × T(entities by time);shockis lengthT.tsecon.panel_fetakes the sameoutcomeplusregressorsask × N × Tand offers the samese_typemenu. bandwidthsets the Driscoll-Kraay kernel width. Like any HAC bandwidth it moves the answer; report it. It acts only underse_type="driscoll_kraay"— since 0.6.0 passing it explicitly withse_type="cluster"or"nonrobust"raises instead of being silently absorbed (cluster/nonrobust SEs use no kernel, so there is nothing for a bandwidth to do).jackknife=Trueapplies the Dhaene-Jochmans half-panel correction for Nickell bias. It does what it says to the point estimate — the bias is essentially eliminated — but it also inflates the estimator's finite-sample variance while the reportedseis kept from the uncorrected full-sample fit (the two are asymptotically equivalent, DJ Theorem 3.1; in a short panel that equivalence has not arrived). Measured at N=20, T=60, h=8: bias −0.070 → +0.005, but the true sampling sd grows 36% (0.235 → 0.319) against a bit-identical reported se of 0.212, so nominal-95% coverage falls 0.880 → 0.804. By T ≈ 240 the gap closes (0.944 → 0.927). So: at moderate-to-longTthe DJ jackknife is fine; whenTis short — exactly where a correction matters most — preferbias_correction="spj", which recomputes the SEs for the corrected estimator (adjusted-score sandwich); see the section above and the panel model card.cumulative=Trueswitches to the Ramey-Zubairy cumulated-outcome convention.- Slopes genuinely heterogeneous across units? A pooled estimator is the wrong
model, not the wrong standard error — see
tsecon.panel_mean_groupandtsecon.panel_pmg.