Skip to content

Commit e5d7547

Browse files
authored
feat(v4): item 7b of the 4.0 cut - SpilloverDiD auto-cluster, uniform cluster=False, cluster metadata (M-080, M-155) (#880)
1 parent ed8d0b5 commit e5d7547

34 files changed

Lines changed: 2056 additions & 371 deletions

‎TODO.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Related tracking surfaces:
2121

2222
| Issue | Location | Origin | Effort | Priority |
2323
|-------|----------|--------|--------|----------|
24+
| One-way (unclustered) Bell-McCaffrey DOF for `SunAbraham` / `WooldridgeDiD` `vcov_type="hc2_bm"` + `cluster=False`: both reject the combination at 4.0 (item 7b) because their BM contrast helper `_compute_cr2_bm_contrast_dof` is cluster-only; port the one-way lane through `linalg._compute_bm_dof_from_contrasts` (the TWFE event-study core's helper) with a clubSandwich `vcovCR(cluster=1:n, type="CR2")` parity pin, then lift the two rejects and their tests | `diff_diff/sun_abraham.py`, `diff_diff/wooldridge.py` | #v4-7b | Mid | Low |
2425
| `decompose_twfe_weights(method="aipw")`: port upstream `implicit_aipw_weights` (covariate-adjusted only; `twfe_weights.py` rejects the value today and lists `"fwl"`). The R goldens already carry the `decompose.aipw` / `balance.aipw` / `two_period.*` blocks under `meta.reserved_blocks`, so no R re-run is needed | `diff_diff/twfe_weights.py` | #812 | Mid | Medium |
2526
| Consolidate the remaining estimator-entangled DR/logit score variants (`staggered.py::_doubly_robust` + RC twins, `triple_diff.py`, `lwdid.py`, `wooldridge.py`) onto the shared `_dr_scores.py` module, each migration with its own committed oracle capture (the ContinuousDiD lift's two-tier pattern in `tests/test_dr_scores.py`); and add a ridge vcov path to `solve_ridge` if an estimator ever needs analytical ridge inference | `diff_diff/_dr_scores.py` | dml-b0 | Mid | Low |
2627
| Numeric between-period cohorts (e.g. `first_treat=4.5` with integer times) are rejected by LWDiD while CallawaySantAnna estimates them and LWDiD's own datetime/Period cohorts map to the next observed period — close the dtype asymmetry by adopting the next-observed-period mapping for numeric cohorts too (contract documented in REGISTRY cohort-encodings Note + `docs/api/lwdid.rst` Input Contract). Lands only after PR #588 merges | `diff_diff/lwdid.py` | #588 | Quick | Low |
@@ -85,8 +86,8 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
8586

8687
| Issue | Location | Origin | Effort | Priority |
8788
|-------|----------|--------|--------|----------|
88-
| Re-execute the nine tutorials whose committed outputs still show the 3.x residual-df p-values/CIs after the `df_convention` default moved to `"cluster"` (t(G-1)): `01_basic_did`, `02_staggered_did`, `04_parallel_trends`, `05_honest_did`, `07_pretrends_power`, `09_real_world_examples`, `11_imputation_did`, `13_stacked_did`, `16_wooldridge_etwfe` (not `16_survey_did`: the survey df wins). Run under `DIFF_DIFF_BACKEND=rust` with the per-worktree kernel, reconcile any prose that restates a moved number, and `--nbmake` each; scheduled for the 4.0 release PR (`docs/v4-design.md` section 9 item 10) together with any item-7b/8/9 movers (24 was refreshed in #879; 23 belongs to 7b). | `docs/tutorials/*.ipynb` | #879 | Mid | Medium |
89-
| TWFE event-study one-way HC2-BM absorbed-FE R parity against the `mpd_absorbed_fe_did` golden (`clubSandwich::vcovCR(cluster=1:n, type="CR2")`): the within spec with hc2_bm auto-clusters at unit, so the one-way lane needs an explicit singleton-cluster column (`cluster=<row id>` + hc2_bm = CR2 with G=n, PT2018 §3.3) once the G=n CR2 path is verified; the hc2 lane is pinned by `TestTWFEEventStudyAbsorbedFERParity` and the clustered hc2_bm per-period lane by `test_twfe_event_study_cluster_hc2_bm_per_period_df_matches_clubsandwich`; the MultiPeriodDiD lanes died at the 4.0 cut (PR 4c-ii) | `tests/test_estimators_vcov_type.py` | #v4-4c-i | Mid | Medium |
89+
| Re-execute the nine tutorials whose committed outputs still show the 3.x residual-df p-values/CIs after the `df_convention` default moved to `"cluster"` (t(G-1)): `01_basic_did`, `02_staggered_did`, `04_parallel_trends`, `05_honest_did`, `07_pretrends_power`, `09_real_world_examples`, `11_imputation_did`, `13_stacked_did`, `16_wooldridge_etwfe` (not `16_survey_did`: the survey df wins). Run under `DIFF_DIFF_BACKEND=rust` with the per-worktree kernel, reconcile any prose that restates a moved number, and `--nbmake` each; scheduled for the 4.0 release PR (`docs/v4-design.md` section 9 item 10) together with any item-7c/8/9 movers (24 was refreshed in #879; 23 in 7b and again in 7c). 7b's label changes also drift any committed analytical DiD/TWFE summary (", G=" on the variance line) and every SunAbraham summary (the new "Std. errors:" line) - covered by the same re-execution. | `docs/tutorials/*.ipynb` | #879 | Mid | Medium |
90+
| TWFE event-study one-way HC2-BM absorbed-FE R parity against the `mpd_absorbed_fe_did` golden (`clubSandwich::vcovCR(cluster=1:n, type="CR2")`): since 4.0 (item 7b) `cluster=False` + hc2_bm reaches the one-way lane directly (no singleton-cluster column needed; `tests/test_v4_cluster_policy.py` pins finiteness and the static-surface LinearRegression parity only), so the open item is the R pin itself; the hc2 lane is pinned by `TestTWFEEventStudyAbsorbedFERParity` and the clustered hc2_bm per-period lane by `test_twfe_event_study_cluster_hc2_bm_per_period_df_matches_clubsandwich`; the MultiPeriodDiD lanes died at the 4.0 cut (PR 4c-ii) | `tests/test_estimators_vcov_type.py` | #v4-4c-i | Mid | Medium |
9091
| TWFE event-study power profile: `simulate_power`/`simulate_mde`/`simulate_sample_size` have no `_EstimatorProfile` for `TwoWayFixedEffects.fit(event_study=True)` since the MultiPeriodDiD registry entry went with the class (PR 4c-ii); `EventStudyResults` carries no scalar headline, so the profile needs a design (post-period average via the `tests/helpers/event_study_average.py` arithmetic, or a named period) plus its `_SURVEY_FIT_BUILDERS` twin | `diff_diff/power.py` | #v4-4c-ii | Mid | Low |
9192
| Clustered hc2_bm Bell-McCaffrey DOF guard is batch-relative (`linalg._cr2_bm_dof_inner`: a contrast is NaN'd when its scaled max\|B\| falls below 1e-10 of the batch maximum), so the event-study core keeps the otherwise-unused post-period-average contrast column as the anchor to hold the 3.x per-period NaN masks bit-identical (PR 4c-ii, user decision 2026-10-25). Reproduction: `x_nc = treated * 1[period >= 4] + 3e-6 * default_rng(seed).normal(size=n)` as a covariate on the oracle panel flips the mask between `[eye(k), avg]` and `eye(k)` on 20/20 seeds (cond(X) ~ 9e5; 1e-7 never flips, <= 3e-8 rank-drops) - too BLAS-sensitive for a committed oracle, hence the structural pin `test_hc2_bm_contrast_batch_keeps_post_average_anchor`. Decide whether the guard should use a batch-independent anchor (the treatment contrast, or the absolute floor only) and then drop the column | `diff_diff/estimators.py`, `diff_diff/linalg.py` | #v4-4c-ii | Mid | Medium |
9293
| Re-validate the plan-review dual engine on `gpt-6.1-sol` with `tools/plan-review-eval`: re-pin every arm's composite `cli_version` to the live Claude Code + codex CLI pair, add a gpt-6.1-sol arm, run the harness against the gpt-5.6-sol baseline (the 2026-10 swap carried the model over from the code-review campaign 2 verdict without a plan-review campaign). | `tools/plan-review-eval/config/configs.json` | reviewer-eval gpt61sol PR-2 | Heavy | Low |
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
### Breaking Changes
2+
3+
- `SpilloverDiD` auto-clusters at `unit` when `cluster=` is omitted ([M-080], item 7b of the 4.0 cut), mirroring `TwoWayFixedEffects`' carve-outs (explicit `cluster=` wins; the auto-cluster is dropped on the Conley path and never injected as a survey PSU). Its default standard errors, p-values and CIs move; `cluster=False` reproduces the 3.x observation-level HC1 numbers. Results report `cluster_name="unit"` / `n_clusters`. Inference stays on the residual df until the `df_convention` knob lands ([M-154], the next PR).
4+
- `cluster=False` is the uniform explicit off-switch ([M-080]): the plain heteroskedasticity-robust sandwich with residual-df t inference on `TwoWayFixedEffects` (static + event-study; one-way CR2 Bell-McCaffrey under `hc2_bm`), `SunAbraham`, `WooldridgeDiD` (OLS within-transform lane with the absorbed-FE full-K rescale; logit/poisson plain QMLE sandwich) and `LPDiD` (per-horizon HC1; `if_robust` unclustered IF variance on the regression-adjustment path; implicit per-observation PSUs under a survey design); an accepted bit-identical no-op on the unit-level-by-construction estimators (`CallawaySantAnna` incl. its RC auto-PSU path, `EfficientDiD` incl. `hausman_pretest`, `DMLDiD`, `ChaisemartinDHaultfoeuille`, staggered `TripleDifference`, `LWDiD` incl. the sensitivity helpers, `HeterogeneousAdoptionDiD`, `ImputationDiD`, `TwoStageDiD`) and on the 2x2 `DifferenceInDifferences` / 2x2x2 `TripleDifference` surfaces; still rejected on `StackedDiD`. Rejected with `inference="wild_bootstrap"` (DiD/TWFE), `WooldridgeDiD(n_bootstrap > 0)` and `vcov_type="hc2_bm"` on `SunAbraham`/`WooldridgeDiD` (cluster-only Bell-McCaffrey DOF; tracked in TODO.md).
5+
- `cluster` accepts `str | None | False` only ([M-155], `diff_diff.utils.validate_cluster`): `True` and non-`str` column labels raise one shared `ValueError` at construction, through `set_params` and at `fit()` on every estimator exposing the parameter (DMLDiD's existing rule generalized; integer-labelled cluster columns must be renamed).
6+
- Cluster metadata: `DifferenceInDifferences` / `TwoWayFixedEffects` report `n_clusters` from the regression's effective cluster count on non-survey analytical fits (previously `None` unless the wild bootstrap ran); `SunAbrahamResults` gains an appended `n_clusters` field and reports `cluster_name` on clustered analytical fits (previously `None` off the Conley path), and its `summary()` renders the variance-family label on analytical non-survey fits; `TwoWayFixedEffects` under a survey design with no explicit `cluster=` reports `cluster_name=None` (the auto-cluster is never injected as a PSU).
7+
8+
### Internal
9+
10+
- Ledger: M-080 and the new M-155 (shared cluster contract) -> `done`; new M-154 (SpilloverDiD `df_convention`, item 7c) -> `planned`; pinned by `tests/test_v4_cluster_policy.py`. Tutorial 23 section 6 shows the four-row SE table (CR1 at unit, HC1 via `cluster=False`, Conley lag 0 / lag 1) and was re-executed; `tests/test_t23_spillover_tva_drift.py` pins the new default and the table's source.

‎diff_diff/chaisemartin_dhaultfoeuille.py‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
DCDHBootstrapResults,
4646
)
4747
from diff_diff.linalg import solve_ols
48-
from diff_diff.utils import safe_inference, validate_n_bootstrap
48+
from diff_diff.utils import ClusterArg, safe_inference, validate_cluster, validate_n_bootstrap
4949

5050
__all__ = [
5151
"ChaisemartinDHaultfoeuille",
@@ -407,12 +407,14 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE
407407
----------
408408
alpha : float, default=0.05
409409
Significance level for confidence intervals.
410-
cluster : str, optional, default=None
411-
Must be ``None`` (the default). User-specified clustering via
412-
this kwarg is not supported — passing any non-``None`` value
413-
raises ``NotImplementedError`` at construction time (and the
414-
same gate fires from ``set_params``). The effective clustering
415-
depends on how you call ``fit()``:
410+
cluster : str, None or False, optional, default=None
411+
``None`` (the default) and ``False`` (4.0, M-080: an accepted explicit
412+
no-op - the variance is group-level by construction) both select the
413+
default path. User-specified clustering via this kwarg is not
414+
supported — a column name raises ``NotImplementedError`` at
415+
construction time (and the same gate fires from ``set_params``);
416+
other values raise the shared ``ValueError`` (labels must be ``str``).
417+
The effective clustering depends on how you call ``fit()``:
416418
417419
- **Default (no survey_design)**: clustered at the group level
418420
via the cohort-recentered influence-function plug-in
@@ -731,7 +733,7 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE
731733
def __init__(
732734
self,
733735
alpha: float = 0.05,
734-
cluster: Optional[str] = None,
736+
cluster: ClusterArg = None,
735737
n_bootstrap: int = 0,
736738
bootstrap_weights: str = "rademacher",
737739
seed: Optional[int] = None,
@@ -778,7 +780,12 @@ def __init__(
778780
"paths_of_interest=[(...), ...] for explicit user-"
779781
"specified paths. Set one and leave the other as None."
780782
)
781-
if cluster is not None:
783+
# Shared cluster contract first ([M-155]): True / non-str labels raise the
784+
# shared ValueError; False is an accepted explicit no-op ([M-080] category
785+
# b: the variance is unit-level by construction); a column name is
786+
# the unsupported custom clustering below.
787+
cluster = validate_cluster(cluster)
788+
if cluster is not None and cluster is not False:
782789
raise NotImplementedError(
783790
f"cluster={cluster!r}: user-specified clustering is not "
784791
f"supported in ChaisemartinDHaultfoeuille. dCDH clusters at "
@@ -787,8 +794,9 @@ def __init__(
787794
f"multiplier bootstrap. Under survey_design with strictly-"
788795
f"coarser PSUs, bootstrap clustering automatically upgrades "
789796
f"to PSU-level Hall-Mammen wild. To use the default path, "
790-
f"pass cluster=None (the "
791-
f"default). Custom clustering is reserved for a future "
797+
f"pass cluster=None (the default) or cluster=False (an accepted "
798+
f"no-op: the variance is unit-level by construction). Custom "
799+
f"clustering is reserved for a future "
792800
f"phase. See REGISTRY.md ChaisemartinDHaultfoeuille section "
793801
f"for the full contract."
794802
)
@@ -853,14 +861,16 @@ def _validate_invariants(self) -> None:
853861
"paths_of_interest=[(...), ...] for explicit user-"
854862
"specified paths. Set one and leave the other as None."
855863
)
856-
if self.cluster is not None:
864+
validate_cluster(self.cluster)
865+
if self.cluster is not None and self.cluster is not False:
857866
raise NotImplementedError(
858867
f"cluster={self.cluster!r}: user-specified clustering is "
859868
f"not supported in ChaisemartinDHaultfoeuille. dCDH clusters "
860869
f"at the group level by default; under survey_design with "
861870
f"strictly-coarser PSUs the bootstrap automatically upgrades "
862871
f"to PSU-level Hall-Mammen wild clustering. Pass cluster=None "
863-
f"(the default) to use this path. User-specified custom "
872+
f"(the default) or cluster=False (accepted no-op) to use this "
873+
f"path. User-specified custom "
864874
f"clustering is reserved for a future phase. See REGISTRY.md "
865875
f"ChaisemartinDHaultfoeuille section for the full contract."
866876
)
@@ -1041,6 +1051,9 @@ def fit(
10411051
# public parameters are unit/covariates.
10421052
group = unit
10431053
controls = covariates
1054+
# Fit-time re-check of the shared cluster contract ([M-155]; the
1055+
# custom-clustering NotImplementedError lives in __init__).
1056+
validate_cluster(self.cluster)
10441057
# ------------------------------------------------------------------
10451058
# Step 1: Column validation
10461059
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)