Skip to content

Commit f3419fb

Browse files
igerberclaude
andcommitted
feat(had): cluster-robust SE on the continuous paths (Phase 2a)
Thread cluster= into bias_corrected_local_linear on the continuous designs (continuous_at_zero / continuous_near_d_lower). Previously cluster= was ignored on the continuous path with a UserWarning; the Phase-1c wrapper already supports cluster, so the estimator just needed to pass it through. The CCT-2014 robust variance becomes cluster-robust and the beta-scale SE is se_robust / |den|. - Resolve the per-unit cluster array for the continuous designs (was mass-point only) and thread it into _fit_continuous -> bias_corrected_local_linear. - Composes with the weights= shortcut (weighted cluster-robust). - cluster= + survey_design= raises NotImplementedError (rejected BEFORE cluster extraction so the error is predictable even with a malformed cluster column; the Binder-TSL survey variance would override the cluster-robust SE -- route clustering through survey_design=SurveyDesign(psu=<col>)). - Cluster must be unit-constant: a nonexistent column, NaN, or within-unit- varying cluster now raises (mirrors the mass-point path) instead of being silently ignored. - Single-cluster guard at the variance-computation site: _nprobust_port.lprobust NaNs se_rb/se_cl when fewer than two clusters fall in the ACTIVE KERNEL WINDOW (eC = cluster[ind]) -- a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth. This NaN-couples the downstream t-stat / p-value / CI (att stays finite), matching the mass-point CR1 single-cluster contract, and also covers the direct bias_corrected_local_linear API. A window with >=2 clusters is bit-identical, so the nprobust clustered DGP-4 golden parity is preserved. - Result metadata reports vcov_type="cr1" + cluster_name; inference_method stays "analytical_nonparametric". - Event-study (Phase 2b) cluster threading remains a follow-up (the per-horizon cband sup-t bootstrap would mix variance families under clustering); that path still emits the "cluster ignored" warning. Validated: the estimator clustered SE equals the direct bias_corrected_local_linear(cluster=...).se_robust / |den| to machine precision, unweighted and weighted; single/degenerate-cluster fits NaN the inference triple. REGISTRY note + CHANGELOG + TODO (Phase 2b remains) updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d572925 commit f3419fb

7 files changed

Lines changed: 313 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **`HeterogeneousAdoptionDiD` cluster-robust SE on the continuous paths** (Phase 2a). `cluster=`
12+
is now threaded into `bias_corrected_local_linear` on the `continuous_at_zero` /
13+
`continuous_near_d_lower` designs, so the CCT-2014 robust variance becomes cluster-robust and the
14+
β̂-scale SE is `se_robust / |den|` (previously `cluster=` was ignored on the continuous path with a
15+
`UserWarning`). Composes with the `weights=` shortcut (weighted cluster-robust). The `cluster=` +
16+
`survey_design=` composition raises `NotImplementedError` (route clustering through
17+
`survey_design=SurveyDesign(psu=<cluster_col>)`). Cluster IDs must be unit-constant — a nonexistent
18+
column, NaN, or within-unit-varying cluster now raises (mirroring the mass-point path) instead of
19+
being silently ignored. Cluster-robust inference with fewer than two clusters in the active kernel
20+
window (the in-bandwidth subset the CCT variance is computed on) returns `se=nan` (and NaN t-stat /
21+
p-value / CI, `att` finite), matching the mass-point CR1 single-cluster contract; the guard lives in
22+
`_nprobust_port.lprobust` so it also covers the direct `bias_corrected_local_linear` API. Result metadata reports `vcov_type="cr1"` +
23+
`cluster_name`. The mass-point path and the event-study (Phase 2b) path are unchanged (Phase 2b
24+
still defers cluster with a warning).
25+
1026
## [3.6.1] - 2026-07-01
1127

1228
### Added

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
3737
| `TwoWayFixedEffects(vcov_type in {hc2, hc2_bm})` with replicate-weight designs raises `NotImplementedError` (`twfe.py:~233`). The replicate path re-demeans per replicate, which doesn't compose with the full-dummy HC2/HC2-BM build — a correct impl needs per-replicate full-dummy refit. Workaround: `hc1` for replicate-weight CR1. | `twfe.py::fit` | follow-up | Heavy | Low |
3838
| TWFE's HC2/HC2-BM inline full-dummy build (`twfe.py:280-315`) duplicates the dummy-construction logic in `DifferenceInDifferences(fixed_effects=...)` (`estimators.py:478-486`). Extract a shared helper, or delegate TWFE's HC2/HC2-BM path to DiD's `fixed_effects=` branch (with TWFE-specific cluster-default threading), to reduce drift risk on FE naming / survey behavior / result-surface conventions. Substantive refactor — touches both estimators. | `twfe.py::fit`, `estimators.py::DifferenceInDifferences.fit` | follow-up | Heavy | Low |
3939
| Decide whether to formally deprecate `CallawaySantAnna.cluster=X` in favor of `survey_design=SurveyDesign(psu=X)` (the bare-cluster path already synthesizes a minimal SurveyDesign). Two equivalent paths = redundant surface. Mirrors the question for ImputationDiD / EfficientDiD / TwoStageDiD. | `staggered.py` | follow-up | Mid | Low |
40-
| `HeterogeneousAdoptionDiD` continuous paths: thread `cluster=` through `bias_corrected_local_linear` (the Phase-1c wrapper already supports cluster; Phase 2a ignores it with a `UserWarning`). | `had.py`, `local_linear.py` | Phase 2a | Mid | Low |
40+
| `HeterogeneousAdoptionDiD` **event-study (Phase 2b)** continuous cluster= threading: Phase 2a static path now threads `cluster=` into `bias_corrected_local_linear` (cluster-robust CCT SE, unweighted + weighted). The per-horizon event-study path still ignores `cluster=` with a `UserWarning` because the `cband` sup-t bootstrap normalizes HC-scale perturbations by the analytical SE and would mix variance families under clustering (mirrors the mass-point `weights= + cluster= + cband=True` `NotImplementedError`). Needs a per-horizon clustered-bootstrap variance-family reconciliation. | `had.py::_fit_event_study` | Phase 2b | Mid | Low |
4141
| `SpilloverDiDResults` not registered in `DiagnosticReport`'s `_APPLICABILITY` / `_PT_METHOD` tables, so `DiagnosticReport(spillover_result)` doesn't route to event-study diagnostics. Decide which diagnostics apply (PT, pre-trends power, heterogeneity, design-effect) and add an end-to-end test. | `diagnostic_report.py` | Wave C | Mid | Low |
4242

4343
### Performance

diff_diff/_nprobust_port.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,19 @@ def lprobust(
13611361
se_cl = float(np.sqrt((deriv_fact**2) * V_Y_cl[deriv, deriv]))
13621362
se_rb = float(np.sqrt((deriv_fact**2) * V_Y_bc[deriv, deriv]))
13631363

1364+
# Cluster-robust variance is unidentified when fewer than two clusters
1365+
# contribute to the ACTIVE kernel window (``eC = cluster[ind]``): the
1366+
# between-cluster meat is degenerate, so a finite ``se`` here would report
1367+
# unidentified clustered inference as if identified. NaN both SEs so any
1368+
# downstream inference (the ``safe_inference`` gate in
1369+
# ``bias_corrected_local_linear``; HAD's beta-scale rescale) is NaN-coupled.
1370+
# Unclustered fits (``eC is None``) are unaffected, and a clustered window
1371+
# with >= 2 distinct clusters is bit-identical, so the DGP-4 golden parity
1372+
# is preserved.
1373+
if eC is not None and len(np.unique(eC)) < 2:
1374+
se_cl = float("nan")
1375+
se_rb = float("nan")
1376+
13641377
# --- Per-observation influence function for the BIAS-CORRECTED point
13651378
# estimate at ``deriv`` (Phase 4.5 survey composition).
13661379
# Aligned with ``V_Y_bc`` (NOT ``V_Y_cl``) so survey-composed variance

diff_diff/had.py

Lines changed: 89 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -297,18 +297,22 @@ class HeterogeneousAdoptionDiDResults:
297297
``"analytical_nonparametric"`` (continuous designs) or
298298
``"analytical_2sls"`` (mass-point).
299299
vcov_type : str or None
300-
Effective variance-covariance family used. ``None`` on continuous
301-
paths (they use the CCT-2014 robust SE from Phase 1c, not the
302-
library's ``vcov_type`` enum). Mass-point: ``"classical"`` or
303-
``"hc1"`` when ``cluster`` is not supplied, and ``"cr1"``
304-
whenever ``cluster`` is supplied (cluster-robust CR1 is computed
305-
regardless of the requested ``vcov_type`` because
306-
classical/hc1 + cluster collapses to the same CR1 sandwich).
307-
Downstream consumers reading ``result.to_dict()`` can inspect
308-
this field directly to determine the effective SE family.
300+
Effective variance-covariance family used. On continuous paths:
301+
``None`` when unclustered (they use the CCT-2014 robust SE from
302+
Phase 1c, not the library's ``vcov_type`` enum) and ``"cr1"`` when
303+
``cluster`` is supplied (the CCT SE becomes cluster-robust; paired
304+
with ``inference_method="analytical_nonparametric"`` this identifies
305+
the clustered CCT variance, distinct from the mass-point 2SLS CR1
306+
sandwich). Mass-point: ``"classical"`` or ``"hc1"`` when ``cluster``
307+
is not supplied, and ``"cr1"`` whenever ``cluster`` is supplied
308+
(cluster-robust CR1 is computed regardless of the requested
309+
``vcov_type`` because classical/hc1 + cluster collapses to the same
310+
CR1 sandwich). Downstream consumers reading ``result.to_dict()`` can
311+
inspect this field directly to determine the effective SE family.
309312
cluster_name : str or None
310-
Column name of the cluster variable on the mass-point path when
311-
cluster-robust SE is requested. ``None`` otherwise.
313+
Column name of the cluster variable when cluster-robust SE is
314+
requested — on the mass-point path (2SLS CR1) or the continuous
315+
paths (clustered CCT SE, Phase 2a). ``None`` otherwise.
312316
survey_metadata : SurveyMetadata or None
313317
Repo-standard survey metadata dataclass from
314318
:class:`diff_diff.survey.SurveyMetadata`. ``None`` when ``fit()``
@@ -2608,11 +2612,18 @@ class HeterogeneousAdoptionDiD:
26082612
the mass-point path consumes these; continuous paths ignore
26092613
both with a warning.
26102614
cluster : str or None
2611-
Column name for cluster-robust SE on the mass-point path (CR1).
2612-
Ignored with a ``UserWarning`` on the continuous paths in Phase
2613-
2a (nonparametric cluster support exists on Phase 1c but is
2614-
exposed separately via ``bias_corrected_local_linear``; the
2615-
estimator-level knob is queued for a follow-up PR).
2615+
Column name for cluster-robust SE. On the mass-point path this is
2616+
the 2SLS CR1 sandwich; on the continuous (``continuous_at_zero`` /
2617+
``continuous_near_d_lower``) paths (Phase 2a) it threads the cluster
2618+
IDs into ``bias_corrected_local_linear`` so ``se_robust`` is the
2619+
cluster-robust CCT-2014 nonparametric SE (``β̂``-scale
2620+
``se = se_robust / |den|``). Composes with the ``weights=`` shortcut
2621+
(weighted cluster-robust). The cluster + ``survey_design=``
2622+
composition raises ``NotImplementedError`` (the Binder-TSL survey
2623+
variance would override the cluster-robust SE — route clustering
2624+
through ``survey_design=SurveyDesign(psu=<cluster_col>)`` instead).
2625+
Cluster must be constant within unit. Estimator-level cluster
2626+
threading on the event-study path (Phase 2b) remains a follow-up.
26162627
26172628
Notes
26182629
-----
@@ -3172,11 +3183,11 @@ def fit(
31723183
)
31733184

31743185
# ---- Aggregate to unit-level first differences (no cluster yet) ----
3175-
# Defer cluster validation/extraction until after the design is
3176-
# resolved: the continuous paths ignore cluster= with a warning,
3177-
# so a malformed or irrelevant cluster column must not abort a
3178-
# valid continuous fit. Cluster extraction is re-run below only
3179-
# when resolved_design == "mass_point".
3186+
# Cluster validation/extraction is re-run below (after design
3187+
# resolution) whenever cluster= is set, so this first aggregation
3188+
# skips the cluster column. Both the mass-point 2SLS sandwich and the
3189+
# continuous (Phase 2a) bias_corrected_local_linear path consume the
3190+
# extracted cluster IDs.
31803191
d_arr, dy_arr, _, _ = _aggregate_first_difference(
31813192
data,
31823193
outcome_col,
@@ -3340,14 +3351,35 @@ def fit(
33403351
else:
33413352
resolved_design = design_arg
33423353

3343-
# ---- Extract cluster IDs (mass-point path only) ----
3344-
# Continuous paths ignore cluster= with a warning emitted later in
3345-
# the dispatch block; the cluster column is not read for them. On
3346-
# the mass-point path we now re-run the aggregation with
3347-
# cluster_col so validation (missing column / NaN / within-unit
3348-
# variance) fires only when cluster is actually going to be used.
3354+
# ---- Extract cluster IDs (mass-point + continuous paths) ----
3355+
# Reject the continuous cluster + ``survey_design=`` composition BEFORE
3356+
# extracting/validating the cluster column, so the unsupported-
3357+
# composition error is predictable even when the cluster column itself
3358+
# is malformed (a nonexistent column would otherwise raise ``ValueError``
3359+
# first). The Binder-TSL survey path composes variance from the per-unit
3360+
# IF and would silently override the cluster-robust local-linear SE.
3361+
if (
3362+
cluster_arg is not None
3363+
and resolved_survey_unit_full is not None
3364+
and resolved_design in ("continuous_at_zero", "continuous_near_d_lower")
3365+
):
3366+
raise NotImplementedError(
3367+
f"cluster={cluster_arg!r} + survey_design= on the "
3368+
f"'{resolved_design}' path is not yet supported: the survey path "
3369+
f"composes Binder-TSL variance via compute_survey_if_variance and "
3370+
f"would silently override the cluster-robust local-linear SE. Pass "
3371+
f"cluster= alone (unweighted cluster-robust), weights= + cluster= "
3372+
f"(weighted cluster-robust), or "
3373+
f"survey_design=SurveyDesign(psu=<cluster_col>) to cluster through "
3374+
f"the survey (Binder-TSL) path."
3375+
)
3376+
# Re-run the aggregation with cluster_col so validation (missing column /
3377+
# NaN / within-unit variance) fires only when cluster is actually going to
3378+
# be used. The per-unit cluster array is threaded into the 2SLS sandwich on
3379+
# the mass-point path and into ``bias_corrected_local_linear`` on the
3380+
# continuous paths (Phase 2a).
33493381
cluster_arr: Optional[np.ndarray] = None
3350-
if resolved_design == "mass_point" and cluster_arg is not None:
3382+
if cluster_arg is not None:
33513383
_, _, cluster_arr, _ = _aggregate_first_difference(
33523384
data,
33533385
outcome_col,
@@ -3556,21 +3588,13 @@ def fit(
35563588
UserWarning,
35573589
stacklevel=2,
35583590
)
3559-
if cluster_arg is not None:
3560-
warnings.warn(
3561-
f"cluster={cluster_arg!r} is ignored on the "
3562-
f"'{resolved_design}' path in Phase 2a. Cluster-"
3563-
f"robust SE on the nonparametric path is exposed "
3564-
f"via diff_diff.bias_corrected_local_linear directly "
3565-
f"but not yet threaded through the estimator-level "
3566-
f"knob.",
3567-
UserWarning,
3568-
stacklevel=2,
3569-
)
3591+
# (cluster= + survey_design= was rejected up front, before cluster
3592+
# extraction — see the guard in the design-resolution block above.)
35703593
# Fit on FULL (unfiltered) arrays so the IF aligns with the
35713594
# full survey design. bias_corrected_local_linear drops
35723595
# zero-weight rows internally for its validation + selector +
3573-
# fit, then zero-pads the IF back to full length. Survey
3596+
# fit, then zero-pads the IF back to full length, and filters the
3597+
# cluster IDs by the same positive-weight mask. Survey
35743598
# composition below runs on the full design, preserving
35753599
# domain-estimation semantics.
35763600
att, se, bc_fit, bw_diag = self._fit_continuous(
@@ -3580,10 +3604,16 @@ def fit(
35803604
d_lower_val,
35813605
weights_arr=weights_unit_full,
35823606
resolved_survey_unit=resolved_survey_unit_full,
3607+
cluster_arr=cluster_arr,
35833608
)
35843609
inference_method = "analytical_nonparametric"
3585-
vcov_label: Optional[str] = None
3586-
cluster_label: Optional[str] = None
3610+
# Cluster-robust (Phase 2a): the CCT nonparametric SE from
3611+
# bias_corrected_local_linear is cluster-robust when cluster= is
3612+
# threaded. vcov_type="cr1" + inference_method="analytical_
3613+
# nonparametric" together identify the clustered CCT variance
3614+
# (distinct from the mass-point 2SLS CR1 sandwich).
3615+
vcov_label: Optional[str] = "cr1" if cluster_arg is not None else None
3616+
cluster_label: Optional[str] = cluster_arg if cluster_arg is not None else None
35873617
elif resolved_design == "mass_point":
35883618
# Review R4 P1: narrow the cluster+weighted rejection. Only
35893619
# survey= + cluster= is a silent-mismatch case (the
@@ -3843,6 +3873,7 @@ def _fit_continuous(
38433873
weights_arr: Optional[np.ndarray] = None,
38443874
resolved_survey_unit: Any = None, # ResolvedSurveyDesign (G,) or None
38453875
force_return_influence: bool = False,
3876+
cluster_arr: Optional[np.ndarray] = None,
38463877
) -> Tuple[float, float, Optional[BiasCorrectedFit], Optional[BandwidthResult]]:
38473878
"""Fit Phase 1c ``bias_corrected_local_linear`` and form the WAS estimate.
38483879
@@ -3928,8 +3959,16 @@ def _fit_continuous(
39283959
# Unconditional IF computation would add a small O(G) cost
39293960
# to every fit; gate it on the survey path.
39303961
return_influence=(resolved_survey_unit is not None or force_return_influence),
3931-
# No cluster / vce threading in Phase 2a (see UserWarning
3932-
# in fit()).
3962+
# Cluster-robust CCT variance (Phase 2a): when ``cluster_arr``
3963+
# is provided, ``bias_corrected_local_linear`` forwards it to
3964+
# the bandwidth selector and the ``lprobust`` variance so
3965+
# ``se_robust`` is the cluster-robust nonparametric SE. It also
3966+
# filters ``cluster_arr`` by the same positive-weight mask it
3967+
# uses to drop zero-weight rows, so a full-length array aligns.
3968+
# ``cluster=`` composes with ``weights=`` (weighted cluster-
3969+
# robust); the ``survey_design=`` composition is rejected
3970+
# up-front in ``fit()`` (Binder-TSL would override se_robust).
3971+
cluster=cluster_arr,
39333972
)
39343973
except (ZeroDivisionError, FloatingPointError, np.linalg.LinAlgError):
39353974
return float("nan"), float("nan"), None, None
@@ -3969,6 +4008,12 @@ def _fit_continuous(
39694008
else:
39704009
se = float(bc_fit.se_robust) / abs(den)
39714010

4011+
# Note: cluster-robust inference with fewer than two clusters in the
4012+
# active kernel window is NaN'd inside bias_corrected_local_linear /
4013+
# _nprobust_port.lprobust (``se_robust`` becomes NaN), so ``se`` here is
4014+
# already NaN in that degenerate case and safe_inference NaNs the
4015+
# downstream t-stat / p-value / CI (att stays the raw point estimate,
4016+
# mirroring the mass-point CR1 single-cluster contract).
39724017
return att, se, bc_fit, bc_fit.bandwidth_diagnostics
39734018

39744019
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)