Skip to content

Commit f55bc06

Browse files
authored
Add WooldridgeDiD opt-out for comparison-support filtering (#820)
1 parent e54a9c0 commit f55bc06

16 files changed

Lines changed: 477 additions & 33 deletions

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ Related tracking surfaces:
5656
| `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 |
5757
| `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 |
5858
| `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 |
59-
| `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 |
6059
| Bad-control imputation estimator (Caetano et al. 2026 Section 6.1, Eqs. 5-7, S8 influence function) on a CallawaySantAnna `estimation_method="reg"` host: two untreated-sample OLS fits per cell plus the generated-regressor IF line. | `staggered.py` | bad-controls PR-B | Heavy | Low |
6160
| Bad-control SC "parallel trends for X" variant (Caetano et al. 2026 Section 7 / S17): a linearity-based alternative to covariate unconfoundedness with its own estimand. | `dml_did.py` | bad-controls PR-B | Heavy | Low |
6261
| `ATT_X(g,t)` event-study aggregation + bootstrap replay for the bad-control pre-test (today analytical per-cell only; never aggregated). | `dml_did_results.py` | bad-controls PR-B | Mid | Low |
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Added
2+
- **WooldridgeDiD comparison-support policy** ([M-147]): set
3+
`unsupported_period_action="error"` to refuse periods lacking eligible comparison
4+
support before removing them. The default `"drop"` preserves filtering and warnings.
5+
The option works across OLS, logit and Poisson independently of
6+
`rank_deficient_action`; results record the fit-time policy in `summary()` and
7+
`to_dict()`. Existing survey and identification checks remain active.

diff_diff/guides/llms-autonomous.txt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -573,13 +573,14 @@ When `has_never_treated == False`:
573573
- `ChaisemartinDHaultfoeuille` - constructs switchers vs. non-switchers
574574
directly; no never-treated requirement.
575575
- TWFE / `MultiPeriodDiD` / `ImputationDiD` / `TwoStageDiD` /
576-
`StackedDiD` / `WooldridgeDiD` - use the last-treated or untreated-
576+
`StackedDiD` - use the last-treated or untreated-
577577
until-late units as implicit controls; estimators do not error, but
578578
consider whether the implicit control structure is what you want.
579579
- `WooldridgeDiD` specifically: use `control_group="not_yet_treated"`
580580
(the default). `control_group="never_treated"` raises when no
581-
cohort-0 units exist. On an all-eventually-treated panel the last
582-
cohort becomes the reference per W2025 Section 5.4, so periods at
581+
cohort-0 units exist. With the default `unsupported_period_action="drop"`,
582+
the last cohort becomes the reference on an all-eventually-treated panel
583+
per W2025 Section 5.4, so periods at
583584
which every unit is treated carry no identified ATT(g, t) and are
584585
REMOVED from the estimation sample before the solve. The fit emits a
585586
`UserWarning` naming the dropped periods, the observation count and
@@ -588,6 +589,19 @@ When `has_never_treated == False`:
588589
cohorts. If your agent surfaces warnings to a user, surface these:
589590
the estimate is computed on fewer rows than were supplied. Stata
590591
`jwdid` performs the same reduction but reports only a smaller `N`.
592+
Use `WooldridgeDiD(unsupported_period_action="error")` when the user prefers
593+
refusal to automatic period filtering: it raises `ValueError` naming the
594+
unsupported periods and affected observation count before removal. A period
595+
lacks support when no positive-weight eligible comparison is observed:
596+
never-treated rows only on OLS + `never_treated`, and also rows before
597+
`g - anticipation` on other paths. The policy applies to all methods,
598+
independently of `rank_deficient_action`; other identification checks and
599+
unidentified-cohort exclusion remain active. Only existing pre-filter checks
600+
retain precedence; later covariate, nonlinear-outcome, or explicit-cluster
601+
validation can be preempted by unsupported-period refusal. With `survey_design`,
602+
its pre-filter design validation still runs first: `"error"` then raises `ValueError`, while
603+
`"drop"` retains the survey-domain `NotImplementedError` when periods would
604+
be removed. Fully supported survey fits are unaffected.
591605
SOME covariate specifications on such a panel are still rank-deficient
592606
(`exovar`, `xgvar`, and `xtvar` with `demean_covariates=False`), because
593607
`D_{G_max} x X` is not normalized; `rank_deficient_action="error"`

diff_diff/guides/llms-full.txt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,13 +1630,29 @@ WooldridgeDiD(
16301630
# carries G-1 entries. Rejected with survey_design= and
16311631
# with control_group="never_treated".
16321632
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
1633+
unsupported_period_action: str = "drop", # "drop" warns and filters; "error" refuses unsupported periods
16331634
)
16341635
```
16351636

16361637
`fit()` additionally accepts `survey_design=` (a `SurveyDesign`) on all three
16371638
methods; see the Survey Support section.
16381639

1639-
**All-eventually-treated panels (no never-treated group).** Use
1640+
**Comparison-support policy.** `unsupported_period_action="drop"` preserves
1641+
period filtering and its warnings. Set `unsupported_period_action="error"`
1642+
to raise `ValueError` naming the unsupported periods and affected observation
1643+
count before removal. Support requires positive-weight never-treated rows on
1644+
OLS + `control_group="never_treated"`; elsewhere not-yet-treated rows before
1645+
`g - anticipation` also qualify. This is independent of `rank_deficient_action`
1646+
and does not control unidentified-cohort exclusion or bypass identification checks.
1647+
Only existing pre-filter configuration, cohort, and survey-design checks retain
1648+
precedence. Later checks (covariate columns, nonlinear outcomes, and non-Conley
1649+
explicit cluster columns) are not preflighted; unsupported-period refusal can
1650+
precede those input errors.
1651+
The results record the fit-time policy in `unsupported_period_action`, `to_dict()`
1652+
and `summary()`, including after aggregation or estimator reconfiguration.
1653+
1654+
**All-eventually-treated panels (no never-treated group).** With the default
1655+
`unsupported_period_action="drop"`, use
16401656
`control_group="not_yet_treated"` — `"never_treated"` raises when no cohort-0
16411657
units exist. Periods at which every unit is treated carry no identified
16421658
ATT(g, t), so they are REMOVED from the estimation sample before the solve and
@@ -1652,11 +1668,14 @@ unaffected either way. Default `xtvar` (`demean_covariates=True`) is FULL
16521668
RANK and fits cleanly.
16531669

16541670
**Survey designs refuse row-dropping paths.** `survey_design=` combined with
1655-
either comparison-support period filtering or unidentified-cohort exclusion
1656-
raises `NotImplementedError` rather than deleting rows, because deletion would
1671+
either comparison-support period filtering (`unsupported_period_action="drop"`)
1672+
or unidentified-cohort exclusion raises `NotImplementedError` rather than deleting rows, because deletion would
16571673
remove their PSUs and strata from the TSL variance. Restrict the frame yourself
1658-
and re-fit. The refusals are conditional: survey fits that drop nothing are
1659-
unaffected.
1674+
and re-fit only after confirming every PSU and stratum survives that restriction.
1675+
With `unsupported_period_action="error"`, unsupported periods instead raise
1676+
`ValueError`, after the existing pre-filter checks, including survey-design
1677+
validation. The refusals are conditional:
1678+
survey fits that drop nothing are unaffected.
16601679

16611680
**Alias:** `ETWFE`
16621681

diff_diff/guides/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ The site is organized into 5 sections, each with a landing page:
7676
- [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html): Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs
7777
- [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html): Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment (absorbing by default; `non_absorbing=True` for on/off treatment, method='local')
7878
- [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference): Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT. DEPRECATED in 3.9, removed in 4.0 - use `TripleDifference` with `first_treat=` (supplying the unit id, the calendar period column and `partition=`) - the same engine (`eligibility=` is `partition=` there; `control_group` takes the underscored values). Alias `SDDD` deprecated with it
79-
- [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html): Wooldridge (2023, 2025) ETWFE — saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias: ETWFE
79+
- [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html): Wooldridge (2023, 2025) ETWFE — saturated OLS, logit/Poisson QMLE (ASF-based ATT); `unsupported_period_action="error"` refuses comparison-support period filtering (default `"drop"`). Alias: ETWFE
8080
- [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html): Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting); variance- or equally-weighted ATT, premean differencing, pooled pre/post, fast. Absorbing by default; non-absorbing (reversible) treatment via `non_absorbing="first_entry"` (Eq. 12) or `"effect_stabilization"` (Eq. 13, window `L`). Complex-survey designs (pweight + stratified-PSU TSL SEs) on the default path via `fit(survey_design=...)`.
8181
- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: recovers the treated group's full counterfactual outcome distribution and quantile treatment effects (ATT + QTE grid) via the CDF transformation `F_10(F_00^{-1}(F_01(y)))`; invariant to monotone outcome transformations (unconditional fits; the covariate QR branch is not); bootstrap inference (panel or repeated cross-section resampling); point parity with R `qte::CiC()`, including its covariate branch (`covariates=` -> per-cell linear quantile regression, Melly-Santangelo-style conditional CiC). Continuous outcomes, numeric covariates. Alias `CiC`.
8282
- [QDiD](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): **Deprecated 3.9, removed 4.0 - use `ChangesInChanges(method="qdid")`.** Athey & Imbens (2006) quantile DiD comparison estimator (additive quantile-by-quantile DiD, matching R `qte::QDiD()` including its covariate branch via `covariates=`); same bootstrap machinery as ChangesInChanges. The paper recommends CiC over QDiD (scale-dependent model with testable restrictions; a non-monotonicity warning fires when violated - unconditional fits only, the covariate-path counterfactual quantile curve is monotone by construction).

diff_diff/wooldridge.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,21 @@ class WooldridgeDiD(BaseEstimator):
971971
always take precedence; the logit/poisson arms are knob-independent
972972
(survey df or normal theory — an explicitly non-default value warns
973973
at fit time). The default flips to ``"cluster"`` at v4.
974+
unsupported_period_action : {"drop", "error"}, default "drop"
975+
How to handle periods lacking the required comparison support.
976+
``"drop"`` removes those periods before estimation and warns;
977+
``"error"`` raises ``ValueError`` before removing them. Support
978+
requires a positive-weight never-treated observation on OLS with
979+
``control_group="never_treated"``; other paths also admit observations
980+
before ``g - anticipation``. This policy is independent of
981+
``rank_deficient_action`` and does not control unidentified-cohort
982+
exclusion. With ``survey_design``, ``"drop"`` still raises
983+
``NotImplementedError`` if periods would be removed, because survey
984+
domain estimation is not supported; ``"error"`` raises ``ValueError``
985+
after the existing pre-filter configuration, cohort, and survey-design
986+
checks. Later validation (including covariate columns, nonlinear
987+
outcomes, and some explicit cluster columns) is not preflighted: an
988+
unsupported-period refusal can precede those input errors.
974989
"""
975990

976991
def __init__(
@@ -993,6 +1008,7 @@ def __init__(
9931008
conley_kernel: str = "bartlett",
9941009
conley_lag_cutoff: Optional[int] = None,
9951010
df_convention: str = "residual",
1011+
unsupported_period_action: str = "drop",
9961012
) -> None:
9971013
self._validate_constructor_args(
9981014
method=method,
@@ -1022,6 +1038,8 @@ def __init__(
10221038
self.conley_kernel = conley_kernel
10231039
self.conley_lag_cutoff = conley_lag_cutoff
10241040
self.df_convention = df_convention
1041+
self._validate_unsupported_period_action(unsupported_period_action)
1042+
self.unsupported_period_action = unsupported_period_action
10251043
# Track whether the user explicitly opted out of the "hc1" default.
10261044
# The auto-cluster-at-unit default in `_fit_ols` is suppressed only
10271045
# when the user explicitly opts into a one-way family (``hc2``,
@@ -1033,6 +1051,12 @@ def __init__(
10331051
self.is_fitted_: bool = False
10341052
self._results: Optional[WooldridgeDiDResults] = None
10351053

1054+
@staticmethod
1055+
def _validate_unsupported_period_action(value: str) -> None:
1056+
"""Validate the period policy without coercing non-string values."""
1057+
if not isinstance(value, str) or value not in ("drop", "error"):
1058+
raise ValueError(f"unsupported_period_action must be 'drop' or 'error', got {value!r}")
1059+
10361060
@staticmethod
10371061
def _validate_constructor_args(
10381062
*,
@@ -1152,6 +1176,7 @@ def fit(
11521176
# mutated and passed the deprecated kwarg still sees the
11531177
# FutureWarning before the raise.
11541178
self.anticipation = validate_anticipation(self.anticipation)
1179+
self._validate_unsupported_period_action(self.unsupported_period_action)
11551180

11561181
df = data.copy()
11571182
df = _warn_and_fill_nan_cohort(df, cohort, stacklevel=2)
@@ -1501,6 +1526,18 @@ def fit(
15011526
}
15021527

15031528
if _unsupported_periods:
1529+
if self.unsupported_period_action == "error":
1530+
_n_unsupported = int(sample[time].isin(_unsupported_periods).sum())
1531+
_plabels = ", ".join(str(t) for t in _unsupported_periods)
1532+
raise ValueError(
1533+
f"Period(s) {_plabels} have no eligible comparison group "
1534+
f"and contain {_n_unsupported} of {len(sample)} observations. "
1535+
"unsupported_period_action='error' refuses the fit before "
1536+
"removing these unsupported periods. Use "
1537+
"unsupported_period_action='drop' to permit automatic "
1538+
"filtering (unavailable with survey_design), or supply data "
1539+
"with the required comparison support."
1540+
)
15041541
if survey_design is not None:
15051542
# Same naive-subsetting problem the unidentified-cohort path
15061543
# refuses below: deleting rows removes their PSUs and strata
@@ -2015,6 +2052,7 @@ def _build(frame: pd.DataFrame, w: Optional[np.ndarray]):
20152052
and _pre_filter_unit_counts[g] > results._n_g_per_cohort[g]
20162053
}
20172054

2055+
results.unsupported_period_action = self.unsupported_period_action
20182056
self._results = results
20192057
self.is_fitted_ = True
20202058
return results

0 commit comments

Comments
 (0)