Policy-intensity panels: HDFE-IV end to end¶
A recurring design in environmental and energy economics: a policy-intensity index measured for every county in every month, a high-dimensional two-way fixed-effect panel, an interaction instrument built from a time-invariant exposure and a time-varying national shock, and inference that has to survive clustering, spatial correlation, and seasonality.
The worked example below follows the shape of Zhang et al. (2026, Science 393:831-836, doi:10.1126/science.aee0747), who study how the intensity of China's solar-expansion policy affects county-level bird diversity. Nothing here is specific to solar panels or birds — the same skeleton fits any "policy index × county-month panel × shift-share-style instrument" question.
Every number StatsPAI produces on this path is pinned against Stata
ivreghdfe / ivreg2 / ranktest / acreg in
tests/reference_parity/test_iv_hdfe_stata_parity.py.
0. The design in one table¶
| Ingredient | This design | StatsPAI |
|---|---|---|
| Outcome | Shannon diversity of the bird community, county × month | sp.diversity_index |
| Treatment | County policy-intensity index | — (constructed upstream) |
| Fixed effects | County + year-month | sp.iv(absorb=[...]) |
| Instrument | Historical solar resource × inverse policy uncertainty | sp.iv("y ~ (d ~ z) + x") |
| Inference | Cluster by county | cluster="county" |
| Robustness | Spatial + serial correlation | sp.conley(...) |
| Weak-ID | Effective F, KP rk, AR set | sp.iv_diag(...) |
| Mechanisms | Same RHS, several LHS | loop + sp.romano_wolf |
1. Build the outcome¶
Citizen-science records arrive as one row per sighting. Turning them into a panel outcome is a modelling decision, not data cleaning: Shannon entropy, species richness and Pielou evenness answer different questions, and a policy can leave Shannon flat while pushing richness down and evenness up.
import statspai as sp
diversity = sp.diversity_index(
records, # one row per sighting
species="species",
by=["county", "ym"],
index=["shannon", "richness", "pielou"],
min_records=5, # thin county-months return NaN, explicitly
)
panel = panel.merge(diversity, on=["county", "ym"], how="left")
min_records matters. Diversity indices are biased downward in small samples,
so a county-month with three sightings is not a low-diversity county-month —
it is an unmeasured one. Setting the floor explicitly keeps that decision in
the code rather than in a footnote.
The same call also returns n_records, which belongs on the right-hand side:
with citizen-science data, observation effort enters the outcome mechanically.
2. The baseline: two-way FE, clustered¶
res = sp.iv(
"shannon ~ (policy ~ z) + temp + wind + log_pop + log_birding_hours",
data=panel,
absorb=["county", "ym"], # HDFE, partialled out (Rust backend)
cluster="county",
)
print(res.summary())
absorb= residualises the outcome, the controls, the endogenous regressor
and the instrument before estimating, so this is ivreghdfe, not a
regression with thousands of dummies. It works for every estimator —
method="liml", "fuller", "gmm", "jive" — accepts multiway
clustering, cluster=["county", "ym"], and takes interacted fixed effects
in the fixest spelling, absorb=["county", "prov^year"] (province-by-year
FE is the standard way to soak up regional policy waves).
The degrees-of-freedom rule you are relying on¶
Clustering on the same dimension you absorb (county FE, county clusters) is
the single most common panel specification, and it has a subtlety: a fixed
effect nested within a clustering dimension costs no residual degrees of
freedom, because the cluster sums already annihilate it. reghdfe
(dofadjustments(clusters)) and fixest (fixef.K="nested") both drop it.
StatsPAI does too, and reports what it did:
res.model_info["fe_nested_in_cluster"] # ['county']
res.model_info["fe_dof_charged"] # 23 == ivreg2's e(sdofminus)
Charging those degrees of freedom anyway inflates standard errors by roughly
sqrt((N-k)/(N-k-G_county)) — several percent in a typical county panel, in
the direction that makes results look weaker than the reference
implementation says they are.
3. Is the instrument strong? Read three numbers, not one¶
diag = sp.iv_diag(
panel,
y="shannon", endog="policy", instruments=["z"],
exog=["temp", "wind", "log_pop", "log_birding_hours"],
absorb=["county", "ym"],
cluster="county",
)
print(diag.summary())
The bundle reports, in the vcov you actually estimated:
- Olea-Pflueger effective F — the pre-test for the concentration parameter. With one instrument it coincides with the KP rk Wald F.
- Kleibergen-Paap rk LM — the underidentification test.
- Kleibergen-Paap rk Wald F — the weakidentification test.
- Anderson-Rubin set — size-correct whatever the first stage looks like.
All four follow the estimator's own variance: cluster the fit and the rank tests cluster too. This matters more than it sounds. A heteroskedasticity-only first-stage F sitting next to cluster-robust coefficients is measuring instrument strength under an assumption the rest of the table has already abandoned, and it is biased toward looking strong.
The AR confidence set is the one to quote when the effective F is anywhere near the Stock-Yogo region:
ar = sp.anderson_rubin_test(
data=panel, y="shannon", endog="policy", instruments=["z"],
exog=["temp", "wind"], absorb=["county", "ym"], cluster="county",
)
ar["ar_ci"], ar["ar_ci_disjoint"]
ar_ci_disjoint flags the case where the AR set is a union of intervals
rather than one — a real possibility under weak identification, and something
a convex hull would quietly hide.
Over-identified specifications¶
Add a second instrument and the over-identification test appears
automatically — Sargan under i.i.d. errors, Hansen J as soon as the
vcov is robust or clustered, exactly as ivreg2 switches. Reading a Sargan
statistic off a clustered regression is reading a test whose null distribution
assumed away the clustering.
4. Spatial and serial correlation¶
Counties are not independent draws, and neither are consecutive months.
sp.conley takes the fitted IV result directly:
spatial = sp.conley(res, panel, lat="lat", lon="lon", dist_cutoff=200)
spacetime = sp.conley(
res, panel, lat="lat", lon="lon", dist_cutoff=200,
time="ym", lag_cutoff=12, unit="county", # 12-month serial window
)
Pass distance="planar" to reproduce Stata acreg's convention exactly;
the default "haversine" is great-circle and symmetric.
A practical note on ordering: run Conley on the absorbed fit. The kernel
is applied to the 2SLS scores of the residualised design, which is what the
acreg ... pfe1() pfe2() route computes.
5. Heterogeneity and mechanisms¶
Subsample splits are just re-fits, and the honest way to compare them is side by side with their intervals:
rows = []
for name, mask in {
"non-poor": panel.poor == 0,
"poor": panel.poor == 1,
}.items():
r = sp.iv(FORMULA, data=panel[mask], absorb=["county", "ym"], cluster="county")
rows.append({"group": name, "beta": r.params["policy"], "se": r.std_errors["policy"]})
Mechanism outcomes share the right-hand side, so they are a family of hypotheses, not one: adjust for it.
sp.romano_wolf(
data=panel, y=["ndvi", "lai", "nightlight"], x="policy",
controls=["temp", "wind"], cluster="county",
)
Two mechanism outcomes moving in opposite directions — vegetation index down, leaf-area index up — is a substantive finding, not a contradiction, and it survives multiplicity adjustment or it does not. Report which.
6. Testing the exclusion restriction where you can¶
An interaction instrument inherits the exclusion restriction of both legs, and exclusion is not testable in the estimation sample. It is testable in a subsample where the instrument has no first stage — a desert county for a solar-resource instrument, an industry with no national shock for a Bartik one. If the instrument still moves the outcome there, it is moving it through something other than the treatment.
zfs = sp.zero_first_stage(
panel, y="shannon", endog="policy", instrument="z",
zfs="is_desert", # the inert subsample
exog=["temp", "log_effort"], absorb=["county", "ym"], cluster="county",
)
print(zfs.summary())
The result reports three things, in this order:
- the premise — the first stage really is ~0 in that subsample, with an interval, because "insignificant" is not "zero";
- the test — the reduced form there, which is the direct effect estimate under the premise;
- the consequence — the implied bias
gamma / piin the main-sample IV estimate, and van Kippersluis–Rietveld's corrected estimate that nets it out (assuming the direct effect is common to both subsamples).
A failure to reject is not a clean bill of health. Quote the confidence interval on the direct effect: it is the set of violations the data cannot rule out.
7. What still needs care¶
- The index is not the policy. A policy-intensity index built from document counts weighted by administrative rank is a measurement model with its own assumptions. Validate it against physical deployment (installed capacity, land footprint) and report the lag structure of that correlation.
- Effort confounds citizen-science outcomes. Control observation effort
and show the result survives dropping thin cells (
min_records). - Exclusion, continued. Where no zero-first-stage subsample exists,
sp.iv(method="plausibly_exog_ltz", ...)puts a prior on the direct effect and reports how large it would have to be to overturn the result — cheaper than arguing about it in prose. - Interpretation. With heterogeneous effects, 2SLS with covariates is a
weighted average that need not be the ATE;
sp.iv_diagprints the TSLS-vs-LATE caveat when the endogenous regressor is binary.
Reference-implementation parity¶
| StatsPAI | Stata | Agreement |
|---|---|---|
sp.iv(absorb=, cluster=) |
ivreghdfe ..., absorb() cluster() |
machine precision |
sp.iv(absorb=, cluster=[a, b]) |
ivreghdfe ..., cluster(a b) |
machine precision |
sp.iv(absorb=, method="liml"/"fuller") |
ivreghdfe ..., liml/fuller(1) |
coefficients exact; SEs differ by O(kappa-1) (documented convention gap) |
sp.iv(absorb=, method="gmm", gmm_vcov="efficient") |
ivreghdfe ..., gmm2s |
machine precision |
KP rk LM / KP rk Wald F |
ranktest / e(idstat), e(widstat) |
machine precision |
Sargan / Hansen J |
e(sargan) / e(j) |
machine precision |
sp.effective_f_test(vcov="classic") |
e(cdf) (Cragg-Donald) |
machine precision |
sp.conley(..., distance="planar") |
acreg ..., spatial pfe1() pfe2() |
machine precision |
References¶
- Anderson, T. W. and Rubin, H. (1949). "Estimation of the Parameters of a Single Equation in a Complete System of Stochastic Equations." Annals of Mathematical Statistics. [@anderson1949estimation]
- Baum, C. F., Schaffer, M. E. and Stillman, S. (2007). "Enhanced routines for instrumental variables/generalized method of moments estimation and testing." The Stata Journal, 7(4), 465-506. [@baum2007enhanced]
- Cameron, A. C., Gelbach, J. B. and Miller, D. L. (2011). "Robust Inference With Multiway Clustering." JBES, 29(2), 238-249. [@cameron2011robust]
- Colella, F., Lalive, R., Sakalli, S. O. and Thoenig, M. (2023). "acreg: Arbitrary correlation regression." The Stata Journal. [@colella2023acreg]
- Conley, T. G. (1999). "GMM estimation with cross sectional dependence." Journal of Econometrics. [@conley1999estimation]
- Kleibergen, F. and Paap, R. (2006). "Generalized reduced rank tests using the singular value decomposition." Journal of Econometrics. [@kleibergen2006generalized]
- van Kippersluis, H. and Rietveld, C. A. (2018). "Pleiotropy-robust Mendelian randomization." International Journal of Epidemiology, 47(4), 1279-1288. [@vankippersluis2018pleiotropy]
- Shannon, C. E. (1948). Bell System Technical Journal, 27(3), 379-423. [@shannon1948mathematical]
- Pielou, E. C. (1966). Journal of Theoretical Biology, 13, 131-144. [@pielou1966measurement]
- Hill, M. O. (1973). Ecology, 54(2), 427-432. [@hill1973diversity]