fix(v4): ETWFE reference-period normalization (#724) + Stata jwdid/csdid parity#729
Conversation
…did parity Fixes issue #724 and anchors both staggered estimators to their canonical Stata implementations. Eleven local review rounds turned up further identification defects in the same code, including a P0; those are fixed here too. ## The #724 fix With `control_group="never_treated"`, the saturated ETWFE design emitted every cohort x time indicator INCLUDING the reference period -- collinear with the absorbed unit fixed effects -- and left generic QR rank detection to remove whichever column it picked. On the real `mpdta` panel that silently removed ATT(2004, 2004) and ATT(2006, 2007), returning 5 of 7 post-treatment cells. The reference is not a free choice: W2025 Eq. 6.1 excludes `dg_i·f(g-1)_t` "so that s = g-1 is the reference period", and Stata `jwdid ... never` omits the same cell. The design now omits `ref(g)` explicitly -- per-cohort and weight-aware, since a panel-wide rule omits an all-zero column when a cohort is unobserved there and leaves the collinearity intact. The surviving pre-fix estimates were not merely incomplete, they were measured against the wrong baseline. ATT(2007,2007) by hand against references 2006/2005/2004/2003 gives -0.0261 / -0.0571 / -0.0599 / -0.0294; the library returned -0.0571, i.e. a correct 2x2 DiD against a silently chosen 2005. Not confined to `never_treated`: any cohort whose cell block spans its own cohort dummy hits it, which `anticipation > 0` makes reachable on the default `not_yet_treated` path. ## Stata parity arm `benchmarks/stata/generate_etwfe_cs_golden.do` anchors ETWFE and CallawaySantAnna against `jwdid` / `csdid` on the committed `mpdta` panel (pinned SHA-256, so CI needs neither Stata nor network). This REPLACES a test that validated nothing: `test_wooldridge.py` asserted ETWFE == CS within 5e-3, which is false on real data (0.0171 at (2007,2007)) and passed only because `load_mpdta()` was silently serving a synthetic effect-homogeneous DGP when its source 404'd (issue #722). Stata's own two implementations reproduce the same disagreement, establishing it as an estimator property rather than a bug. The arm also surfaced a real deviation: ETWFE `hc1` SEs are uniformly BELOW `jwdid`'s (anti-conservative), ~0.1% at G=500 and ~2.8% at G=20. The mechanism is NOT identified -- it tracks sqrt(G/(G-1)) but sits above it, and `solve_ols` already applies the full CR1 -- so it is recorded as measured, not diagnosed, and pinned rather than "corrected" with an unverified factor. The never-treated arm shows the SAME factor over a different cell count, which rules out explanations keyed to the comparison set. ## Identification defects found while reviewing the above - P0: partial rank loss returned MISLABELED contrasts as ATTs. When some treatment columns were dropped, the survivors were re-identified as whatever contrast the reduced design supports and kept their ATT(g,t) labels. Measured with true effects 1.0 and 3.0: ATT(3,5) came back as -1.9393 -- wrong sign -- inside overall_att = +0.7703 against a true ~1.8, with NO warning under `rank_deficient_action="silent"`. Pre-existing and worse at base. A fit now fails closed unless every requested cell survives. - Within-cohort support CONNECTIVITY: per-period support is necessary but not sufficient; identification runs through the unit FE. A cohort whose units split into disjoint observation windows had a genuine post cell dropped (`g4_t5`) with the overall ATT computed from the survivor. OLS-only -- the nonlinear paths use explicit dummies and are unaffected. - A fit no longer returns a NaN overall ATT as success (point estimate only; NaN SE stays legitimate where only the variance is unidentifiable). - Invalid survey weights (NaN / negative / Inf, fractional fweight) and invalid strata/PSU/FPC could hide in rows that cohort exclusion deleted, yielding a confident finite estimate: 0.021416714008590323, with a warning blaming a missing pre-treatment period. The FULL design is now validated pre-exclusion. - `survey_design=` + unidentified-cohort exclusion is REFUSED: deleting rows drops their PSUs/strata from the TSL variance and from `df_survey = n_PSU - n_strata` (measured 22 -> 14, finite SE, no diagnostic). Domain estimation via `SurveyDesign.subpopulation()` is the correct answer and is tracked. - `rank_deficient_action="error"` was silently downgraded to a warning for skipped cells; all three modes now dispatch. ## Deliberate capability regression All-eventually-treated panels (no never-treated group) now RAISE. They previously returned finite, silently wrong ATTs -- on a true-1.5 DGP: ATT(3,8) = -0.0096, ATT(5,9) = -0.2393, overall_att = 1.0023. W2025 Section 5.4 defines the fix (last cohort as control, its columns dropped deliberately rather than by QR); the library already does this for the cohort-TREND column but not for the cells. Tracked at High priority; landing it restores these fits. Four existing tests asserted only that such values were FINITE, which is what let a P0 live in a tested path; they now assert the refusal with the measured numbers in their docstrings. ## Tracking Every refusal added here is classified terminal vs stopgap in TODO.md, with the stopgaps carrying what "done" means: survey domain estimation, anticipation-only estimand semantics, same-period comparison support, component-aware estimation, W2025 5.4 cell normalization, the SE-derivation subsample ladder, and the duplicate survey resolution. M-123/M-124 and the CHANGELOG record the user-visible behavior changes. Docs: REGISTRY normalization + connectivity + invariant notes, tutorial 16 (lead-and-lag vs lag-only, demonstrated rather than asserted), API page, constructor docstring, and the local paper review all corrected to match runtime behavior. Verification: 6992 passed across every WooldridgeDiD/SurveyDesign-touching suite; notebook re-validated with --nbmake; mypy 2.3.0 clean; black/ruff clean. Stata golden regenerated with local Stata (StataSE 19), verified zero numeric or key-set change -- only `meta.ssc_versions` moved.
Overall assessment✅ Looks good — no unmitigated P0 or P1 findings. Executive summary
MethodologyDocumented Stata HC1 SE deviation
Documented all-eventually-treated limitation
The primary normalization itself is methodologically sound: Code QualityNo findings. All changed inference construction uses PerformanceDuplicate survey resolution
MaintainabilityNo findings. The changed interaction-matrix return contract is propagated to its callers, and diagnostics are centralized. Tech DebtDeferred estimator capabilities are explicitly tracked
SecurityNo findings. No credential, private-key, or secret patterns were detected in the changed sources or fixtures. Documentation/TestsFew-cluster Stata parity is not regression-pinned
The committed |
Summary
Fixes issue #724 and anchors both staggered estimators to their canonical Stata implementations. Eleven local review rounds surfaced further identification defects in the same code, including a P0; those are fixed here too.
The #724 fix. With
control_group="never_treated"the saturated ETWFE design emitted every cohort×time indicator including the reference period — collinear with the absorbed unit FE — and left generic QR rank detection to remove whichever column it picked. On the realmpdtapanel that silently removedATT(2004, 2004)andATT(2006, 2007), returning 5 of 7 post-treatment cells. The design now omitsref(g)explicitly, per-cohort and weight-aware.The pre-fix survivors were not merely incomplete — they were measured against the wrong baseline.
ATT(2007,2007)computed by hand against references 2006/2005/2004/2003 gives−0.0261 / −0.0571 / −0.0599 / −0.0294; the library returned−0.0571, a correct 2×2 DiD against a silently chosen 2005.Not confined to
never_treated: any cohort whose cell block spans its own cohort dummy hits it, whichanticipation > 0makes reachable on the default path.Stata parity arm. Anchors ETWFE and CallawaySantAnna against
jwdid/csdidon the committedmpdtapanel (pinned SHA-256, so CI needs neither Stata nor network). This replaces a test that validated nothing:test_wooldridge.pyasserted ETWFE == CS within5e-3, false on real data (0.0171 at(2007,2007)), passing only becauseload_mpdta()was silently serving a synthetic effect-homogeneous DGP when its source 404'd (issue #722). Stata's own two implementations reproduce the same disagreement.Identification defects found while reviewing the above:
ATT(g,t)labels. Measured with true effects 1.0 and 3.0:ATT(3,5) = −1.9393(wrong sign) insideoverall_att = +0.7703against a true ~1.8, with no warning underrank_deficient_action="silent". Pre-existing and worse at base.fweight, missing PSU/strata confined to a cohort that exclusion removed produced a confident finite estimate with a warning blaming a missing pre-treatment period. The full design is now validated pre-exclusion.survey_design=+ unidentified-cohort exclusion is refused — deleting rows drops their PSUs/strata from the TSL variance and fromdf_survey = n_PSU − n_strata(measured 22 → 14, finite SE, no diagnostic).rank_deficient_action="error"was silently downgraded to a warning for skipped cells; all three modes now dispatch.Behavior changes (please read before merging)
Deliberate capability regression: all-eventually-treated panels (no never-treated group) now raise. They previously returned finite, silently wrong ATTs — on a true-1.5 DGP:
ATT(3,8) = −0.0096,ATT(5,9) = −0.2393,overall_att = 1.0023. W2025 §5.4 defines the fix (last cohort as control, columns dropped deliberately rather than by QR); the library already applies this to the cohort-trend column but not to the cells. Tracked at High priority — landing it restores these fits.Four existing tests asserted only that such values were finite, which is what let a P0 live in a tested path; they now assert the refusal with the measured numbers in their docstrings.
Ledger rows
M-123/M-124and the CHANGELOG record all user-visible changes.Methodology references (required if estimator / math changes)
jwdid(Rios-Avila) andcsdid— SSC.jwdid—hc1SEs are uniformly belowjwdid's (anti-conservative): ~0.1% at G=500, ~2.8% at G=20. The mechanism is not identified — it trackssqrt(G/(G-1))but sits above it, andsolve_olsalready applies full CR1 — so it is recorded as measured, not diagnosed, and pinned rather than "corrected" with an unverified factor. The never-treated arm shows the same factor over a different cell count, ruling out explanations keyed to the comparison set. CallawaySantAnna shows no such gap.g−1(legitimate per §6.1; documented).TODO.md, with the stopgaps carrying what "done" means.Validation
tests/test_wooldridge.py,tests/test_etwfe_cs_stata_parity.py,tests/test_methodology_wooldridge.py,tests/test_v4_matrix.pycsdid/jwdid/jwdid_never; onlymeta.ssc_versionsmoved (drdidgained a checksum identifier where it previously recorded"unknown").docs/tutorials/16_wooldridge_etwfe.ipynbre-validated withpytest --nbmake. 6992 tests pass across everyWooldridgeDiD/SurveyDesign-touching suite; mypy 2.3.0 clean; black/ruff clean.G=500SE ratio only; the few-cluster ratios quoted in REGISTRY were measured ad hoc and are explicitly labeled as trend-shape rather than gated. The subsample ladder that would pin them is a named required artifact on the SE-derivation TODO row — it is the same instrument that derivation needs.Security / privacy