Skip to content

feat(v4): item 7b of the 4.0 cut - SpilloverDiD auto-cluster, uniform cluster=False, cluster metadata (M-080, M-155) - #880

Merged
igerber merged 2 commits into
mainfrom
feat/v4-pr7b-m080-cluster-policy
Oct 29, 2026
Merged

igerber merged 2 commits into
mainfrom
feat/v4-pr7b-m080-cluster-policy

Conversation

@igerber

@igerber igerber commented Oct 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Item 7b of the 4.0 cut (docs/v4-design.md section 9 item 7, second half; [M-080] as re-scoped + the new shared-contract row [M-155]). Follows 7a (feat(v4): item 7a of the 4.0 cut - flip the df_convention default to "cluster" (M-004..M-006, M-128..M-131) #879).
  • SpilloverDiD auto-clusters at unit when cluster= is omitted - the one default standard error that moves at 4.0 - mirroring TwoWayFixedEffects' carve-outs (explicit cluster= wins; the auto-cluster is dropped on the Conley path and never injected as a survey PSU). 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 (new planned row [M-154], item 7c = the next PR).
  • cluster=False is the uniform explicit off-switch: the plain heteroskedasticity-robust sandwich with residual-df t inference on TWFE (static + event-study; one-way CR2 Bell-McCaffrey under hc2_bm), SunAbraham, WooldridgeDiD (the OLS within-transform lane gains the absorbed-FE full-K rescale so its SE equals HC1 on the explicit full-dummy design; logit/poisson run the plain QMLE sandwich) and LPDiD (per-horizon HC1, an 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 and the 2x2 surfaces; still rejected on StackedDiD. Combinations that would ship an unverified variance reject it: inference="wild_bootstrap" (DiD/TWFE), WooldridgeDiD(n_bootstrap > 0), and vcov_type="hc2_bm" on SunAbraham / WooldridgeDiD (cluster-only BM DOF; the one-way port is a new TODO.md row).
  • Shared contract diff_diff.utils.validate_cluster ([M-155]): cluster is str | None | False on every estimator exposing it, validated at __init__, through set_params and again at fit(); DMLDiD's pre-4.0 str-only check generalized.
  • Metadata: DiD/TWFE analytical fits report n_clusters from the regression's effective cluster count (non-survey fits); SunAbrahamResults gains an appended n_clusters and reports cluster_name on clustered analytical fits (its summary() renders the variance-family label on analytical non-survey fits); TWFE's survey-lane auto label is None (the regression received no cluster ids).
  • Ledger: M-080 and M-155 -> done, M-154 -> planned (matrix gate counts 137); spec section 7/9, the variance-conventions roster, REGISTRY notes, the migration guide, llms-full.txt, docs/api/spillover.rst and TODO.md updated. Tutorial 23 section 6 now shows the four-row SE table (CR1 at unit, HC1 via cluster=False, Conley lag 0 / lag 1) and was re-executed.

Methodology references (required if estimator / math changes)

  • Method name(s): auto-cluster policy and the uniform cluster=False off-switch (spec section 7, [M-080]; roster in docs/methodology/variance-conventions.md "Default clustering roster"); CR1 Liang-Zeger / HC1 sandwiches; absorbed-FE full-K rescale (linalg._absorbed_fe_vcov_scale, the fixest vcov="hetero" convention); Gardner (2022) GMM first-stage correction on SpilloverDiD's default CR1 lane.
  • Paper / source link(s): Butts (2021) arXiv:2105.03737; Gardner (2022) arXiv:2207.05943; Sun & Abraham (2021); Wooldridge (2023); Dube, Girardi, Jordà & Taylor (2023) LP-DiD; Bertrand, Duflo & Mullainathan (2004) for the unit-clustering default.
  • Any intentional deviations from the source (and why): SpilloverDiD's new default CR1 lane keeps residual-df t inference at this step (no df_convention knob yet; documented REGISTRY Note + TWFE knob-roster entry; [M-154] closes it in 7c). cluster=False + hc2_bm on SunAbraham / WooldridgeDiD is rejected rather than given an unverified one-way BM DOF (REGISTRY Notes, TODO.md row). Each category's position carries a recognized **Note:** in REGISTRY.md.

Validation

  • Tests added/updated: new tests/test_v4_cluster_policy.py (118 tests: contract roster, Spillover default + carve-outs, every c1 off-switch lane incl. the TWFE and Wooldridge full-dummy HC1 parity pins, the LPDiD singleton-cluster identity, the saturated-Wooldridge NaN contract and the four rejects, the b/c2 no-op, metadata lanes, ledger); tests/test_spillover.py (Wave D goldens pinned with cluster=False; CR1-routing test extended to the default); tests/test_t23_spillover_tva_drift.py (default metadata, CR1 > HC1 > Conley ordering, four-row table source pins); tests/test_v4_inference_policy.py, tests/test_wild_bootstrap.py (new DiD message, analytical n_clusters); tests/test_v4_matrix.py (row counts 137).
  • Backtest / simulation / notebook evidence (if applicable): docs/tutorials/23_spillover_tva.ipynb re-executed (nbmake, Rust backend). Full default suite 15428 passed / 204 skipped; pure-Python backend pass on the new file; CI-pinned mypy clean; py3.9 compile ok.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Changelog

  • changelog.d/ fragment added (or N/A - no user-visible change): Yes (changelog.d/20261029-v4-pr7b-m080-cluster-policy.md)

… cluster=False, cluster metadata (M-080, M-155)

SpilloverDiD auto-clusters at unit when cluster= is omitted - the one
default standard error that moves at 4.0 - with TwoWayFixedEffects'
carve-outs mirrored: an explicit cluster= wins on every lane, the
auto-cluster is dropped on the Conley path and is never injected as a
survey PSU. 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 (new
planned row M-154, item 7c).

cluster=False is the uniform explicit off-switch (M-080): the plain
heteroskedasticity-robust sandwich with residual-df t inference on
TwoWayFixedEffects (static and event-study; one-way CR2 Bell-McCaffrey
under hc2_bm), SunAbraham, WooldridgeDiD (the OLS within-transform lane
gains the absorbed-FE full-K rescale so its SE equals HC1 on the
explicit full-dummy design; logit/poisson run the plain QMLE sandwich)
and LPDiD (per-horizon HC1, an 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. The combinations that would otherwise ship an
unverified variance reject it: inference="wild_bootstrap" (DiD/TWFE),
WooldridgeDiD(n_bootstrap > 0) and vcov_type="hc2_bm" on SunAbraham /
WooldridgeDiD (cluster-only Bell-McCaffrey DOF; the one-way port is a
TODO.md row). SunAbraham's pairs bootstrap accepts it as a no-op.

The shared contract is utils.validate_cluster (new row M-155): cluster
is str | None | False on every estimator exposing it, validated at
__init__, through set_params and again at fit(); DMLDiD's pre-4.0
str-only check is generalized and False is normalized locally so it
never reaches a column lookup.

Metadata: DiD/TWFE analytical fits report n_clusters from the
regression's effective cluster count (non-survey fits); SunAbrahamResults
gains an appended n_clusters field and reports cluster_name on clustered
analytical fits, and its summary() renders the variance-family label on
analytical non-survey fits; TWFE's survey-lane auto label is None (the
regression received no cluster ids).

Tests: new tests/test_v4_cluster_policy.py (the two rows' test_ref)
pins the contract roster, the Spillover default and its carve-outs, every
c1 off-switch lane (incl. the TWFE and Wooldridge full-dummy HC1 parity,
the LPDiD singleton-cluster identity, the saturated-Wooldridge NaN
contract and the four rejects), the b/c2 no-op, the metadata lanes and
the ledger. test_spillover.py pins its Wave D goldens with cluster=False
(the HC1 lane they were captured on) and extends the CR1-routing test to
the default; the inference-policy and wild-bootstrap pins follow the new
DiD message and the analytical n_clusters; the tutorial-23 drift suite
pins the default, the CR1 > HC1 > Conley ordering and the four-row table
source. Tutorial 23 section 6 compares CR1 at unit, HC1 (cluster=False)
and the two Conley fits.

Ledger: M-080 and M-155 -> done, M-154 -> planned (matrix gate counts
137); spec section 7/9, the variance-conventions roster, REGISTRY notes
(DiD, TWFE, SunAbraham, WooldridgeDiD, LPDiD, SpilloverDiD, the TWFE knob
roster, the category-b classes), the migration guide, llms-full.txt,
docs/api/spillover.rst and TODO.md updated.
@github-actions

Copy link
Copy Markdown

Overall assessment: ✅ Looks good

No unmitigated P0 or P1 findings identified in the diff.

Executive summary

  • Spillover’s unit-clustering default agrees with Butts’ recommendation and retains Gardner’s first-stage variance correction.
  • The changed unclustered paths preserve the documented variance conventions and use safe_inference().
  • Residual-df Spillover inference and unsupported Bell–McCaffrey combinations are documented exceptions, not blockers.
  • A changelog fragment is present.
  • Numerical tests could not run: pytest, NumPy, pandas, and SciPy are unavailable in this environment.

Methodology

P3 — Documented inference exceptions.
Locations: docs/methodology/REGISTRY.md:L7077-L7080, docs/methodology/REGISTRY.md:L2001-L2007.

Cross-checks against the Registry and committed paper reviews support the changed CR1/HC1 routing, Wooldridge’s absorbed-FE rescale, and LPDiD’s documented IF variance convention.

Impact: Spillover retains residual-df inference at this release step; SunAbraham/Wooldridge reject unsupported unclustered Bell–McCaffrey inference. Both are explicitly documented.
Concrete fix: None required for approval.

Code Quality

No actionable findings. Shared validation preserves the raw constructor value while normalizing False locally before column lookups.

Performance

No actionable findings identified in the changed paths.

Maintainability

No actionable findings. Parameter introspection through BaseEstimator covers get_params() and transactional set_params().

Tech Debt

P3 — Follow-ups are tracked.
Locations: TODO.md:L24, TODO.md:L89-L90.

Impact: One-way Bell–McCaffrey support, its remaining external parity check, and tutorial refreshes remain incomplete but explicitly tracked.
Concrete fix: None required in this PR; follow the recorded backlog.

Security

No introduced secrets or security vulnerabilities identified.

Documentation/Tests

P3 — Qualify Spillover’s blanket residual-df description.
Locations: diff_diff/spillover.py:L1704-L1705, docs/methodology/REGISTRY.md:L7077-L7080.

Impact: “On every lane” conflicts with the existing survey-df precedence implemented at diff_diff/spillover.py:L3479-L3483. This affects documentation, not computed inference.
Concrete fix: Say “on non-survey lanes; survey design df takes precedence.”

Validation completed: Python 3.9 syntax checks passed for all 18 changed library modules; extracted validator checks and notebook code parsing passed. Estimator tests and numerical parity remain unverified locally.

…y-df precedence

The SpilloverDiD cluster docstring and the REGISTRY 7b Note said inference
uses the residual df "on every lane"; the survey-design df has always taken
precedence under survey_design= (spillover.py df_for_inference). Both now
say "every non-survey lane" and name the precedence. Documentation only.
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: ec9c18e5d42b0425e6304a91d9aa3f5845c8cfaf


Overall assessment: ✅ Looks good

No unmitigated P0 or P1 findings identified. The prior documentation finding is resolved.

Executive summary

  • SpilloverDiD’s unit-clustering default follows Butts’ recommendation and preserves Gardner’s first-stage variance correction.
  • Changed unclustered variance paths agree with the documented Registry contracts.
  • Spillover’s residual-df inference and unsupported Bell–McCaffrey combinations are explicitly documented exceptions.
  • Survey-df precedence is now described correctly in both the docstring and Registry.
  • Static validation passed; numerical tests remain unverified locally because required dependencies are unavailable.

Methodology

Reviewed SpilloverDiD, TWFE, Sun–Abraham, WooldridgeDiD and LPDiD against the Registry, docstrings and available committed paper reviews. No undocumented methodology mismatch identified.

P3 — Documented inference exceptions; informational.
Locations: docs/methodology/REGISTRY.md:L7077-L7082, docs/methodology/REGISTRY.md:L2001-L2007, docs/methodology/REGISTRY.md:L2660-L2673.

Impact: Spillover retains residual-df inference pending M-154; SunAbraham and WooldridgeDiD reject unsupported unclustered Bell–McCaffrey inference. These are documented policies.
Concrete fix: None required for approval.

Code Quality

No actionable findings. Changed paths normalize False before column lookups and retain shared safe_inference() gating.

Performance

No actionable findings in the changed paths.

Maintainability

No actionable findings. BaseEstimator introspection and transactional constructor validation cover parameter round-tripping and set_params().

Tech Debt

P3 — Remaining follow-ups are tracked.
Locations: TODO.md:L24, TODO.md:L89-L90.

Impact: One-way Bell–McCaffrey support, the event-study external parity check and remaining tutorial refreshes remain explicitly tracked.
Concrete fix: None required in this PR; follow the recorded backlog.

Security

No introduced secrets or security vulnerabilities identified.

Documentation/Tests

The prior P3 finding is resolved: diff_diff/spillover.py:L1704-L1706 and docs/methodology/REGISTRY.md:L7077-L7082 now correctly distinguish non-survey residual df from survey-design df precedence, matching diff_diff/spillover.py:L3479-L3484.

The required changelog fragment is present.

Validation passed for Python 3.9 syntax across all 18 changed library modules, extracted shared-validator checks and notebook code parsing. Numerical estimator tests could not run because pytest, NumPy, pandas and SciPy are unavailable.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Oct 29, 2026
@igerber
igerber merged commit e5d7547 into main Oct 29, 2026
43 of 44 checks passed
@igerber
igerber deleted the feat/v4-pr7b-m080-cluster-policy branch October 29, 2026 23:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant