Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Related tracking surfaces:
| Re-run the R-dependent benchmark refresh so `docs/benchmarks.rst`'s TWFE "SE Rel Diff 0.1%" cell reflects the 3.9 K_reference convergence (expected 0.1% -> 0.0%; the table is generated, never hand-edited — the movement is noted in the CHANGELOG entry). | `docs/benchmarks.rst`, `benchmarks/R/` | #variance-inventory | Quick | Low |
| `SunAbraham`: a cohort not observed at its own reference relative period (`e = -1 - anticipation`) makes that cohort's block collinear, so QR drops an unnamed column (`dropping 1 of 12 columns (column 9)`) and `overall_att` comes back **NaN**. Found by auditing the sibling estimator while fixing the ETWFE analogue (#724); PRE-EXISTING, not introduced there. Lower severity than #724 — that returned a silently WRONG finite number, this returns NaN with a rank warning — but the event-study surface still looks complete, so a user may not notice the loss. SA already omits its reference explicitly and tracks `_reference_observed`, so the fix is per-cohort support for that flag rather than the ETWFE-style redesign. | `diff_diff/sun_abraham.py` | #724-audit | Mid | Low |
| Define `N_g` (W2025 Eqs. 7.4/7.6) for UNBALANCED panels where comparison-support filtering removes every observation of some units in an estimated cohort, then replace the fail-closed guard with the defined behavior. `_n_g_per_cohort` is read off the final sample, so those units vanish from the cohort-share weights; measured on a cohort supplied with 100 units of which 90 appear only at a dropped period, `aggregate(weights="cohort_share")` moves 1.8078 -> 3.8157. The paper assumes a balanced panel and does not say whether `N_g` counts the supplied cohort or the surviving units, and the two disagree materially, so `aggregate` currently raises naming the cohorts and counts ([M-125]); `weights="cell"` is unaffected and balanced panels never trip it. Settle the estimand (likely: count the supplied cohort, since ATT(g,t) is a cohort-level quantity, but that weights units with no retained observation) and gate with a test computing Eq. 7.4 by hand on unequal cohort sizes. | `diff_diff/wooldridge_results.py`, `diff_diff/wooldridge.py` | #729-followup | Mid | Medium |
| `WooldridgeDiD` + `survey_design=` does not support DOMAIN ESTIMATION, so BOTH row-deleting paths are currently REFUSED (`NotImplementedError`, all three methods) rather than performed: unidentified-cohort exclusion ([M-123]) and comparison-support period filtering ([M-125]). One fix unblocks both. Implementing it properly means zero-padding the excluded rows' weights while retaining strata/PSU/FPC, per REGISTRY *Subpopulation Analysis (Phase 6)* / Lumley (2004) 3.4, so TSL variance and `df_survey = n_PSU - n_strata` use the full design (naive deletion measured 22 -> 14 on a two-stratum panel). `SurveyDesign.subpopulation()` already implements the contract and SpilloverDiD Wave E.3 is the in-repo precedent; the blocker is that the weighted within-transform rejects zero-weight units, shared machinery behind 7 estimators. Landing it would turn both refusals back into supported fits. Gate with a `SurveyDesign.subpopulation()` parity test on ATT, TSL SE and survey df where the excluded cohort exhausts a PSU. | `diff_diff/wooldridge.py`, `diff_diff/utils.py` | #724-codex-R4/R5 | Heavy | Medium |
| `WooldridgeDiD` + `survey_design=` does not support DOMAIN ESTIMATION, so BOTH row-deleting paths are currently REFUSED (`NotImplementedError` on all three methods; the comparison-support path instead raises `ValueError` under `unsupported_period_action="error"`, which pre-empts it -- the unidentified-cohort refusal is unaffected by that setting) rather than performed: unidentified-cohort exclusion ([M-123]) and comparison-support period filtering ([M-125]). One fix unblocks both. Implementing it properly means zero-padding the excluded rows' weights while retaining strata/PSU/FPC, per REGISTRY *Subpopulation Analysis (Phase 6)* / Lumley (2004) 3.4, so TSL variance and `df_survey = n_PSU - n_strata` use the full design (naive deletion measured 22 -> 14 on a two-stratum panel). `SurveyDesign.subpopulation()` already implements the contract and SpilloverDiD Wave E.3 is the in-repo precedent; the blocker is that the weighted within-transform rejects zero-weight units, shared machinery behind 7 estimators. Landing it would turn both refusals back into supported fits. Gate with a `SurveyDesign.subpopulation()` parity test on ATT, TSL SE and survey df where the excluded cohort exhausts a PSU. | `diff_diff/wooldridge.py`, `diff_diff/utils.py` | #724-codex-R4/R5 | Heavy | Medium |
| `WooldridgeDiD` REFUSES a fit whose only treatment cells fall inside the anticipation window, discarding estimates it successfully computed. `_require_estimable_overall_att` ([M-124]) raises when no cell has `t >= g`, because the overall ATT averages only `t >= g` (W2025 excludes anticipation leads) while cells from `t >= g - anticipation` are ESTIMATED. For a cohort never observed at or after its own treatment date, those anticipation-window ATT(g, t) are real, identified estimates and are thrown away with the fit. **The refusal is a stopgap for the missing estimand semantics, not the intended end state.** Real fix: decide what such a fit should return — most likely the per-cell ATT(g, t) plus an overall that is explicitly undefined with a stated reason (not a bare NaN, per the no-silent-NaN convention) — then relax the guard to that. Needs a REGISTRY note defining the estimand and an `aggregate()` story for the anticipation-only case. | `diff_diff/wooldridge.py` | #724-codex-R2 | Mid | Medium |
| `WooldridgeDiD` comparison-support accounting is PARTIAL. Per-period support now runs before the solve and removes periods with no eligible comparison, reporting them (REGISTRY *per-period comparison support*). What remains is the per-`(g, t)` half: the completeness gate still refuses when a cell is lost to a cause the period filter cannot see -- treated cohorts sharing no comparison period with each other, and covariate collinearity -- so those users get a refusal naming the cell rather than an upfront diagnostic naming the cause. Real fix: compute per-cell eligible-control support and report exactly which cells are unidentified and why BEFORE solving. Note the cohort-count proxy remains invalid and is still pinned (`TestOverallAttFailsClosed::test_two_cohorts_without_same_period_controls_fail_closed`, verified unaffected by the period filter). | `diff_diff/wooldridge.py` | #724-codex-R2 | Mid | Medium |
| `WooldridgeDiD` DROPS the observations of a cohort with no supported pre-period before `g - anticipation` ([M-123]) rather than identifying it. Excluding the rows is correct given `g-1` normalization -- leaving them in silently loads the cohort's effect onto the time FE -- but dropping a cohort a user supplied is a lossy last resort. **Route (b) is now SETTLED NEGATIVELY and is not the answer:** the paper's no-never-treated last-cohort normalization shipped (W2025 Sec 5.4, per-period comparison support), and it does NOT identify these cohorts -- `wooldridge-2025-review.md:477` is explicit that in the final period the last cohort's ATT is unidentified, and the implementation still excludes any cohort whose reference is `None`. **Route (a) remains open:** an explicit user-supplied reference period per cohort -- W2025 Section 6.1 says any pre-treatment period may serve and the pre-trend `t`-test is invariant to the choice, so a cohort with ANY supported pre-period is a candidate even when `g-1` is missing. If route (a) also fails to identify the cohort, convert this row into a REGISTRY Note recording exclusion as the deliberate final answer. | `diff_diff/wooldridge.py`, `docs/methodology/REGISTRY.md` | #724 | Heavy | Medium |
Expand All @@ -56,7 +56,6 @@ Related tracking surfaces:
| `ContinuousDiD` CGBS-2024 remaining extensions (earlier phases — `covariates=` reg/dr, `treatment_type="discrete"`, single-cohort `control_group="lowest_dose"` with estimand `ATT(d)−ATT(d_L)` — are already supported; see REGISTRY Note #7). Remaining (all deferred `NotImplementedError`, documented): `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate); `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF); multi-cohort **heterogeneous-support** discrete aggregation (support-aware: average each dose only over the cohorts that observe it); **multi-cohort `lowest_dose`** (within-cohort `d_L` reference + support-aware cross-cohort aggregation); and **`covariates=` × `lowest_dose`** (conditional-PT-relative-to-`d_L` estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. | `continuous_did.py` | CGBS-2024 | Heavy | Low |
| `WooldridgeDiD` does not apply the W2025 Sec 5.4 `D_{G_max} x X` covariate normalization, and three sibling covariate rank deficiencies are pre-existing. Measured with the period range pinned and only the never-treated units toggled: (1) time-invariant `exovar` is absorbed by the unit FE, 4 of 26 columns, IDENTICALLY with and without never-treated units; (2) `xgvar`'s cell x covariate block, 19 of 41, identical on both panels; (3) `xtvar` under `demean_covariates=False` does exhibit the `sum_g D_g x = x` dependency that the default demeaning removes; (4) the newly-reachable case -- time-VARYING data passed through `exovar`, which its own docstring reserves for time-invariant covariates -- where the paper's `dT_i` rule would give a deterministic `D_{G_max} x X` drop instead of QR's arbitrary pick (coefficients unaffected, `1.35e-14`; `rank_deficient_action="error"` raises). REGISTRY's narrowed Sec 5.4 note cross-references this row. **Trap for whoever takes it:** `xtvar` under the DEFAULT `demean_covariates=True` is FULL RANK -- the raw block carries demeaned values while `D_g x X` carries raw ones -- and forcing the drop there moves `overall_att` 1.11903 -> 1.46269. Pinned as-is by `TestComparisonSupportFiltering::test_cells_derived_groups_did_not_leak_into_the_design`. | `diff_diff/wooldridge.py` | #729-followup | Heavy | Medium |
| `WooldridgeDiD.n_control_units` counts never-treated UNITS on `control_group="never_treated"` regardless of method, but on the nonlinear paths (`logit`/`poisson`) treated units' pre-treatment rows ARE the identifying comparison -- only the OLS path absorbs them into their own cells. So the reported count under-states the comparison pool exactly where the REGISTRY control-pool asymmetry note applies. Widen to `not_yet_treated or (never_treated and method != "ols")`, or document the count as never-treated-units-by-definition. Behavior is PRE-EXISTING; documented for now in the REGISTRY control-pool Note rather than changed, because widening moves a public results field and wants its own ledger row and test matrix. | `diff_diff/wooldridge.py` | #729-followup | Mid | Low |
| `WooldridgeDiD` has no opt-out for comparison-support period filtering: a user who would rather see the refusal than a reduced sample cannot ask for it. Adding one means a constructor parameter (`get_params`/`set_params` propagation, transactional validation), a ledger row, and a test matrix across both predicate branches and all three `rank_deficient_action` modes -- deliberately out of scope for the change that introduced the filter. The always-on warning is the interim answer. | `diff_diff/wooldridge.py` | #729-followup | Mid | Low |

### Performance

Expand Down
20 changes: 20 additions & 0 deletions changelog.d/20260905-etwfe-unsupported-period-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
### Added
- **`WooldridgeDiD(unsupported_period_action=...)`** ([M-147]): an opt-out for
per-period comparison-support filtering. An *unsupported period* is one
lacking the required comparison support: no positive-weight eligible
comparison observation is observed there, so no `ATT(g, t)` at that period
is identified. `"drop"` (the default) is unchanged: such periods are removed
before the solve and the reduction is warned, exactly as before. `"error"`
refuses with `ValueError` before removing any period, naming the periods,
the would-be-dropped observation count and the cause (structural, or
zero survey weight), for users who would rather see the refusal than
estimate on a reduced sample. The refusal precedes the `survey_design=`
refusal and is not gated on `rank_deficient_action`. No estimate changes
under either value.

### Internal
- **Doc-snippet tests run inside `tmp_path`**: `tests/test_doc_snippets.py`
previously executed snippets with the repository root as the working
directory, so `savefig('<name>.png')` calls in the API docs wrote PNGs into
the checkout (four such files were caught in review). Snippet side effects
now land in the per-test temporary directory.
21 changes: 13 additions & 8 deletions diff_diff/guides/llms-autonomous.txt
Original file line number Diff line number Diff line change
Expand Up @@ -564,14 +564,19 @@ When `has_never_treated == False`:
(the default). `control_group="never_treated"` raises when no
cohort-0 units exist. On an all-eventually-treated panel the last
cohort becomes the reference per W2025 Section 5.4, so periods at
which every unit is treated carry no identified ATT(g, t) and are
REMOVED from the estimation sample before the solve. The fit emits a
`UserWarning` naming the dropped periods, the observation count and
the cause, plus a second naming any cohort left with no estimated
cells (the last cohort, normally). `results.groups` excludes those
cohorts. If your agent surfaces warnings to a user, surface these:
the estimate is computed on fewer rows than were supplied. Stata
`jwdid` performs the same reduction but reports only a smaller `N`.
which every unit is treated carry no identified ATT(g, t) -- they lack
the required comparison support -- and, under the default
`unsupported_period_action="drop"`, are REMOVED from the estimation
sample before the solve. The fit then emits a `UserWarning` naming the
dropped periods, the observation count and the cause, plus a second
naming any cohort left with no estimated cells (the last cohort,
normally). `results.groups` excludes those cohorts. If your agent
surfaces warnings to a user, surface these: the estimate is computed
on fewer rows than were supplied. If a refusal is preferable to a
smaller sample, set `unsupported_period_action="error"`: the fit
raises `ValueError` before removing anything and no reduction warning
is emitted. Stata `jwdid` performs the same reduction but reports only
a smaller `N`.
SOME covariate specifications on such a panel are still rank-deficient
(`exovar`, `xgvar`, and `xtvar` with `demean_covariates=False`), because
`D_{G_max} x X` is not normalized; `rank_deficient_action="error"`
Expand Down
24 changes: 18 additions & 6 deletions diff_diff/guides/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,12 @@ WooldridgeDiD(
# carries G-1 entries. Rejected with survey_design= and
# with control_group="never_treated".
df_convention: str = "residual", # OLS analytical t/p/CI df (3.9: default-hc1 arms moved z -> t(residual)): "residual" (default), "cluster" (G-1, hc1-clustered only), "normal" (z); survey/BM DOF keep precedence; GLM arms knob-independent (explicit non-default warns); flips at v4
unsupported_period_action: str = "drop", # {"drop","error"}. An UNSUPPORTED period lacks the required
# comparison support: no positive-weight ELIGIBLE comparison
# observation is observed there, so no ATT(g, t) at that period is
# identified. "drop" (default) removes such periods before the solve
# and warns; "error" refuses (ValueError) BEFORE removing any period,
# naming periods + cause. Not gated on rank_deficient_action.
)
```

Expand All @@ -1613,19 +1619,25 @@ the last cohort becomes the reference (W2025 Section 5.4). The fit warns naming
the dropped periods, the observation count and the cause, and separately names
any cohort left with no estimated cells; `results.groups` excludes those
cohorts. Stata `jwdid` performs the same reduction silently, reporting only a
smaller `N`. SOME covariate specifications on such a panel are still
smaller `N`. To be REFUSED instead of estimating on the reduced sample, set
`unsupported_period_action="error"`: the fit then raises `ValueError` before
removing any period, naming the periods and the cause. SOME covariate specifications on such a panel are still
rank-deficient -- `exovar`, `xgvar`, and `xtvar` with
`demean_covariates=False` -- because `D_{G_max} x X` is not normalized;
`rank_deficient_action="error"` raises on those and coefficients are
unaffected either way. Default `xtvar` (`demean_covariates=True`) is FULL
RANK and fits cleanly.

**Survey designs refuse row-dropping paths.** `survey_design=` combined with
either comparison-support period filtering or unidentified-cohort exclusion
raises `NotImplementedError` rather than deleting rows, because deletion would
remove their PSUs and strata from the TSL variance. Restrict the frame yourself
and re-fit. The refusals are conditional: survey fits that drop nothing are
unaffected.
either comparison-support period filtering (under the default
`unsupported_period_action="drop"`) or unidentified-cohort exclusion raises
`NotImplementedError` rather than deleting rows, because deletion would remove
their PSUs and strata from the TSL variance. Under
`unsupported_period_action="error"` the comparison-support path raises its own
`ValueError` first (naming the periods and cause, with the same PSU/stratum
caveat), so the survey `NotImplementedError` is reached only on the drop path.
Restrict the frame yourself and re-fit. The refusals are conditional: survey
fits that drop nothing are unaffected.

**Alias:** `ETWFE`

Expand Down
Loading
Loading