From 4d06a7b5a76f73cf58ddb956399fcf3fcbbb797e Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 5 Sep 2026 19:37:17 -0400 Subject: [PATCH] feat(etwfe): unsupported_period_action opt-out for comparison-support period filtering (M-147) --- TODO.md | 3 +- ...0260905-etwfe-unsupported-period-action.md | 20 + diff_diff/guides/llms-autonomous.txt | 21 +- diff_diff/guides/llms-full.txt | 24 +- diff_diff/wooldridge.py | 204 +++++++- docs/api/wooldridge_etwfe.rst | 7 +- docs/doc-deps.yaml | 4 + docs/methodology/REGISTRY.md | 5 +- docs/migration-4.0.md | 11 +- docs/tutorials/16_wooldridge_etwfe.ipynb | 10 +- docs/v4-deprecations.yaml | 15 +- docs/v4-design.md | 5 +- tests/test_doc_snippets.py | 10 +- tests/test_v4_matrix.py | 24 +- tests/test_wooldridge.py | 488 ++++++++++++++++-- 15 files changed, 752 insertions(+), 99 deletions(-) create mode 100644 changelog.d/20260905-etwfe-unsupported-period-action.md diff --git a/TODO.md b/TODO.md index 96e8d23b9..212130bb9 100644 --- a/TODO.md +++ b/TODO.md @@ -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 | @@ -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 diff --git a/changelog.d/20260905-etwfe-unsupported-period-action.md b/changelog.d/20260905-etwfe-unsupported-period-action.md new file mode 100644 index 000000000..511ccb150 --- /dev/null +++ b/changelog.d/20260905-etwfe-unsupported-period-action.md @@ -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('.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. diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 07ac6624e..b3265d37c 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -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"` diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 39e3cb9f5..4b63704fc 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -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. ) ``` @@ -1613,7 +1619,9 @@ 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 @@ -1621,11 +1629,15 @@ 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` diff --git a/diff_diff/wooldridge.py b/diff_diff/wooldridge.py index fac91a168..c9c9a0f46 100644 --- a/diff_diff/wooldridge.py +++ b/diff_diff/wooldridge.py @@ -46,6 +46,7 @@ _VALID_METHODS = ("ols", "logit", "poisson") _VALID_CONTROL_GROUPS = ("never_treated", "not_yet_treated") _VALID_BOOTSTRAP_WEIGHTS = ("rademacher", "webb", "mammen") +_VALID_UNSUPPORTED_PERIOD_ACTIONS = ("drop", "error") def _logistic(x: np.ndarray) -> np.ndarray: @@ -164,9 +165,10 @@ def _require_complete_cell_set( The same-period comparison-support filter now catches the most common cause BEFORE the solve, with a better message: periods where no unit is untreated - are removed from the estimation sample and reported. This remains the - correctness backstop for what that filter cannot see -- cohorts sharing no - comparison period, and covariate collinearity. + are removed from the estimation sample and reported (or, under + ``unsupported_period_action="error"``, refused outright before any row is + removed). This remains the correctness backstop for what that filter cannot + see -- cohorts sharing no comparison period, and covariate collinearity. """ missing = [ k @@ -184,8 +186,9 @@ def _require_complete_cell_set( "the survivors identify whatever contrast the reduced design supports " "(e.g. a difference between two treated cohorts), so reporting them " "under their original labels, or averaging them into the overall ATT, " - "would be silently wrong. Periods with no eligible comparison group are " - "already removed before the solve, so this points at one of two other " + "would be silently wrong. Periods with no eligible comparison group never " + "reach the solve (they are dropped, or refused under " + "unsupported_period_action='error'), so this points at one of two other " "causes: treated cohorts that share no comparison period with each " "other, or a covariate collinear with the treatment cells. Check your " "covariates first if any were supplied; otherwise restrict the panel to " @@ -248,7 +251,8 @@ def _require_estimable_overall_att( "No treatment effect is identified: every cohort-time cell was " "removed from the design, so the overall ATT is undefined. This " "means the treatment cells are collinear with the absorbed " - f"fixed effects even after unsupported periods were removed -- with " + f"fixed effects even though no period without a comparison group " + f"reached the solve -- with " f"control_group={control_group!r}, this happens when the surviving " "cohorts share no comparison period, or when a supplied covariate is " f"collinear with the cells. {hint}" @@ -644,6 +648,66 @@ def _compute_references( return references +def _comparison_support_cause( + structural: List[Any], + zero_weight: List[Any], + include_pre: bool, + anticipation: int, +) -> str: + """Cause sentence(s) for periods that lack the required comparison support. + + A period is unsupported when no POSITIVE-WEIGHT eligible comparison + observation exists there. That has two distinct causes, and the message + must name the right one: + + - STRUCTURAL: no eligible comparison row is observed at all -- on the + ``never_treated`` + OLS branch no never-treated unit, elsewhere every + unit already treated (accounting for anticipation); + - ZERO-WEIGHT: eligible comparison rows ARE observed but every one carries + zero survey weight, so none can supply support (support is + weight-aware: a zero-weight row is absent from the sqrt(w)-scaled + regression). + + Shared by the drop warning and the ``unsupported_period_action="error"`` + refusal so their wording never diverges. Contract: with a SINGLE cause the + returned sentence names no periods (the caller's ``Period(s) ...`` prefix + supplies them) and the structural sentence is byte-identical to the + pre-existing warning text. Only when BOTH causes are present are two + sentences returned, each carrying its own explicit period list, joined + with ``"; "``. The zero-weight clause is reachable today only through the + refusal: ``_cell_w`` is populated only from ``survey_design.weights``, and + under a survey the drop path refuses with ``NotImplementedError`` before + it can warn. + """ + both = bool(structural) and bool(zero_weight) + + def _labels(periods: List[Any]) -> str: + return ", ".join(str(t) for t in periods) + + parts: List[str] = [] + if structural: + where = f"at period(s) {_labels(structural)}" if both else "at those periods" + if include_pre: + parts.append( + f"no never-treated units are observed {where}, so ATT(g, t) " + "there is not identified against any untreated outcome" + ) + else: + at = f" {where}" if both else "" + parts.append( + f"every unit is already treated{at} (accounting for " + f"`anticipation={anticipation}`), so ATT(g, t) there is not " + "identified against any untreated outcome" + ) + if zero_weight: + where = f"at period(s) {_labels(zero_weight)}" if both else "at those periods" + parts.append( + f"the only eligible comparison observations {where} carry zero " + "survey weight, so none can supply support" + ) + return "; ".join(parts) + + def _cells_derived_groups(gt_effects: Dict) -> List[Any]: """Cohorts carrying at least one ESTIMATED cell, for results metadata. @@ -971,6 +1035,28 @@ class WooldridgeDiD(BaseEstimator): always take precedence; the logit/poisson arms are knob-independent (survey df or normal theory — an explicitly non-default value warns at fit time). The default flips to ``"cluster"`` at v4. + unsupported_period_action : {"drop", "error"}, default "drop" + What ``fit()`` does with UNSUPPORTED periods. An unsupported period is + one lacking the required comparison support: no positive-weight + ELIGIBLE comparison observation -- never-treated, or not-yet-treated + except on the ``never_treated`` + OLS branch, where treated units' + pre-treatment rows sit in their own indicators -- is observed there, + so no ``ATT(g, t)`` at that period is identified against an untreated + outcome (W2025 Section 5.4; see the Methodology Registry). ``"drop"`` + (the default) removes those periods from the estimation sample before + the solve and warns naming the periods, the observation count and the + cause. ``"error"`` refuses BEFORE removing any period, raising + ``ValueError`` with the same information, for users who would rather + see the refusal than estimate on a reduced sample. The refusal is + decided before the ``survey_design=`` refusal, before any + reference-movement warning, cohort exclusion or row removal, and + before any rank warning, and it is NOT gated + on ``rank_deficient_action`` (that setting governs how rank warnings + surface, not whether the estimation sample may change). There is no + "keep the rows" mode: the cells at an unsupported period are collinear + with the time effects by construction, so retaining them can only + yield a rank-deficient design that the completeness gate refuses with + a less specific message. """ def __init__( @@ -993,6 +1079,7 @@ def __init__( conley_kernel: str = "bartlett", conley_lag_cutoff: Optional[int] = None, df_convention: str = "residual", + unsupported_period_action: str = "drop", ) -> None: self._validate_constructor_args( method=method, @@ -1001,6 +1088,7 @@ def __init__( vcov_type=vcov_type, cohort_trends=cohort_trends, df_convention=df_convention, + unsupported_period_action=unsupported_period_action, ) self.method = method @@ -1022,6 +1110,7 @@ def __init__( self.conley_kernel = conley_kernel self.conley_lag_cutoff = conley_lag_cutoff self.df_convention = df_convention + self.unsupported_period_action = unsupported_period_action # Track whether the user explicitly opted out of the "hc1" default. # The auto-cluster-at-unit default in `_fit_ols` is suppressed only # when the user explicitly opts into a one-way family (``hc2``, @@ -1042,6 +1131,7 @@ def _validate_constructor_args( vcov_type: str, cohort_trends: bool = False, df_convention: str = "residual", + unsupported_period_action: str = "drop", ) -> None: """Shared validation for both ``__init__`` and ``set_params``. @@ -1066,6 +1156,11 @@ def _validate_constructor_args( f"{{'classical','hc1','hc2','hc2_bm','conley'}}; got '{vcov_type}'" ) validate_df_convention(df_convention) + if unsupported_period_action not in _VALID_UNSUPPORTED_PERIOD_ACTIONS: + raise ValueError( + "unsupported_period_action must be one of " + f"{_VALID_UNSUPPORTED_PERIOD_ACTIONS}, got {unsupported_period_action!r}" + ) if method != "ols" and vcov_type != "hc1": raise NotImplementedError( f"WooldridgeDiD(method={method!r}, vcov_type={vcov_type!r}) is " @@ -1449,7 +1544,19 @@ def fit( # emitted cell below t = g - anticipation and so are baseline. # # Weight-aware: a zero-weight row is absent from the sqrt(w)-scaled - # regression, so it cannot supply support. + # regression, so it cannot supply support. The unweighted mask is kept + # alongside so the cause can be named accurately: a period unsupported + # only because its eligible rows carry zero survey weight is a + # different diagnosis from one with no eligible row at all. + # + # `unsupported_period_action="error"` refuses HERE, before the survey + # refusal and before any row is removed: both refuse, but this one + # names the identification cause, and the user has explicitly asked + # for a refusal rather than a reduced sample. There is no "keep the + # rows" mode -- the cells at an unsupported period sum to that period's + # time indicator, so retaining them can only produce a rank-deficient + # design that the completeness gate refuses with a vaguer message + # (measured on every branch and every rank_deficient_action mode). _include_pre = self.control_group == "never_treated" and self.method == "ols" _cohort_arr = sample[cohort].to_numpy() _time_arr = sample[time].to_numpy() @@ -1459,13 +1566,18 @@ def fit( else np.nan_to_num(np.asarray(_cell_w, dtype=float), nan=0.0) ) - _eligible = _cohort_arr == 0 + _eligible_unweighted = _cohort_arr == 0 if not _include_pre: - _eligible = _eligible | ((_cohort_arr - self.anticipation) > _time_arr) - _eligible = _eligible & (_w_arr > 0) + _eligible_unweighted = _eligible_unweighted | ( + (_cohort_arr - self.anticipation) > _time_arr + ) + _eligible = _eligible_unweighted & (_w_arr > 0) _supported_periods = set(np.unique(_time_arr[_eligible]).tolist()) _unsupported_periods = sorted(set(np.unique(_time_arr).tolist()) - _supported_periods) + _unweighted_supported = set(np.unique(_time_arr[_eligible_unweighted]).tolist()) + _zero_weight_periods = [t for t in _unsupported_periods if t in _unweighted_supported] + _structural_periods = [t for t in _unsupported_periods if t not in _unweighted_supported] # References are needed BEFORE the filter -- not by the predicate above, # which reads only cohort/time/anticipation and row weights, but to tell @@ -1501,6 +1613,59 @@ def fit( } if _unsupported_periods: + # Everything the refusal and the warning report is computed up + # front: pure arithmetic over `_time_arr` and `sample`, removing + # no rows. The drop itself happens only on the "drop" path below. + _plabels = ", ".join(str(t) for t in _unsupported_periods) + _keep_rows = ~pd.Series(_time_arr, index=sample.index).isin(_unsupported_periods) + _n_dropped = int((~_keep_rows).sum()) + _n_total = len(sample) + _n_periods_total = len(set(np.unique(_time_arr).tolist())) + _cause = _comparison_support_cause( + _structural_periods, _zero_weight_periods, _include_pre, self.anticipation + ) + + if self.unsupported_period_action == "error": + # The user asked to be refused rather than estimate on a + # reduced sample. Raised before the survey refusal (both + # refuse; this one names the cause) and before any row is + # touched. The remedy is conditional: under `survey_design=` + # the default "drop" ALSO refuses, so advising it there would + # promise a fit that cannot happen, and the PSU/stratum caveat + # the survey refusal carries must travel with the restriction + # advice. + if survey_design is None: + _remedy = ( + "To estimate the remaining cells instead, set " + "unsupported_period_action='drop' (the default); to " + "estimate those periods, add never-treated units or " + "restrict the panel." + ) + else: + _remedy = ( + "Under `survey_design=` the default " + "unsupported_period_action='drop' also refuses, because " + "deleting rows would remove their PSUs and strata from the " + "TSL variance and from `df_survey = n_PSU - n_strata`. " + "Restrict the frame to the supported periods explicitly " + "and re-fit, but first confirm every PSU and stratum " + "survives that restriction; on an unbalanced panel a PSU " + "observed only at these periods disappears with them. " + "Adding never-treated units also restores support." + ) + raise ValueError( + f"Period(s) {_plabels} lack the required comparison support: " + f"no eligible comparison group exists at those periods -- " + f"{_cause}. An unsupported period carries no identified " + "ATT(g, t), because there is no positive-weight ELIGIBLE " + "comparison observation to difference against (on the " + "never_treated + OLS branch not-yet-treated rows are observed " + "but ineligible). unsupported_period_action='error' refuses " + f"rather than removing them; {_n_dropped} of {_n_total} " + f"observations ({len(_unsupported_periods)} of " + f"{_n_periods_total} periods) would be dropped. {_remedy}" + ) + if survey_design is not None: # Same naive-subsetting problem the unidentified-cohort path # refuses below: deleting rows removes their PSUs and strata @@ -1509,7 +1674,6 @@ def fit( # row is removed. `SurveyDesign.subpopulation()` is NOT the # remedy here -- it zero-pads the excluded rows, which # `_reject_zero_weight_groups` then refuses on the OLS path. - _plabels = ", ".join(str(t) for t in _unsupported_periods) raise NotImplementedError( f"Period(s) {_plabels} have no eligible comparison group, so " "they carry no identified ATT(g, t) and would be dropped from " @@ -1525,30 +1689,14 @@ def fit( "the way this refusal exists to prevent." ) - _keep_rows = ~pd.Series(_time_arr, index=sample.index).isin(_unsupported_periods) - _n_dropped = int((~_keep_rows).sum()) - _n_total = len(sample) - _n_periods_total = len(set(np.unique(_time_arr).tolist())) sample = sample.loc[_keep_rows.to_numpy()].copy() if _cell_w is not None: _cell_w = _cell_w[_keep_rows.to_numpy()] - if _include_pre: - _cause = ( - "no never-treated units are observed at those periods, so " - "ATT(g, t) there is not identified against any untreated " - "outcome" - ) - else: - _cause = ( - "every unit is already treated (accounting for " - f"`anticipation={self.anticipation}`), so ATT(g, t) there is " - "not identified against any untreated outcome" - ) warnings.warn( f"Dropped {_n_dropped} of {_n_total} observations " f"({len(_unsupported_periods)} of {_n_periods_total} periods: " - f"{', '.join(str(t) for t in _unsupported_periods)}) from the " + f"{_plabels}) from the " f"estimation sample: no eligible comparison group exists at those " f"periods -- {_cause}. To estimate those periods, add " "never-treated units or restrict the panel.", diff --git a/docs/api/wooldridge_etwfe.rst b/docs/api/wooldridge_etwfe.rst index dbcb911fe..fa9a6b1a8 100644 --- a/docs/api/wooldridge_etwfe.rst +++ b/docs/api/wooldridge_etwfe.rst @@ -81,7 +81,12 @@ dict; its slope is the baseline (zero in deviation form). solve and the last cohort becomes the reference. The reduction is always reported — the number of observations and periods dropped, the reason, and any cohort left without cells. Stata's ``jwdid`` performs - the same reduction silently, reporting only a smaller ``N``. + the same reduction silently, reporting only a smaller ``N``. An + *unsupported period* is one lacking the required comparison support + (no positive-weight eligible comparison observation is observed there); + ``unsupported_period_action="error"`` refuses with ``ValueError`` before + removing any such period, for users who would rather see the refusal + than estimate on a reduced sample. See ``docs/methodology/REGISTRY.md`` → ``## WooldridgeDiD (ETWFE)`` → "Heterogeneous cohort trends" for the full normalization contract. diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 7beb3e6c3..206d7558e 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -786,6 +786,10 @@ sources: - path: diff_diff/guides/llms-full.txt section: "WooldridgeDiD" type: user_guide + - path: diff_diff/guides/llms-autonomous.txt + section: "WooldridgeDiD bullet under the all-eventually-treated guidance" + type: user_guide + note: "States the comparison-support reduction and its unsupported_period_action opt-out; keep in sync with the fit() filter block." - path: diff_diff/guides/llms.txt section: "Estimators" type: user_guide diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index e19c8601c..d820ea32b 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2423,12 +2423,13 @@ The interaction coefficient `δ_{g,t}` identifies `ATT(g, t)` under parallel tre - **Note (reference-period normalization, W2025 Eq. 6.1/6.4 — `never_treated` OLS only):** this applies to the **lead-and-lag** specification, i.e. `control_group="never_treated"` on the OLS path, which is the only configuration that emits pre-treatment cells (`include_pre`). The default `not_yet_treated` (and both nonlinear paths) emit `t >= g − anticipation` cells only — the lag-only specification — so no cell is omitted from those designs and no placebo cells are produced; the reference period is still computed there, but solely to detect cohorts that have none (see the unidentified-cohort Note). For the lead-and-lag specification, for each treated cohort `g` the design OMITS one cell — the reference period — because the cohort's full cell block spans the cohort indicator `1{G_i = g}`, which the unit fixed effects absorb. The paper is explicit: Eq. 6.1 excludes `dg_i · f(g-1)_t` "so that `s = g − 1` is the reference period", and Eq. 6.4 describes the saturated regression as a collection of 2×2 DiDs using `g − 1` as reference. The library omits `ref(g) = max{t : t < g − anticipation AND cohort g is OBSERVED at t}` — `g − 1` on a balanced panel, and the cohort's own latest available pre-period otherwise. The support must be **per-cohort**: a panel-wide rule omits an identically-zero column whenever that cohort is unobserved at the panel's latest pre-period, leaving the collinearity intact. Anchored to Stata `jwdid ... never`, which omits the same cell for every cohort (`tests/test_etwfe_cs_stata_parity.py::TestNeverTreatedVsStataJwdid`). **Before v3.9 the reference was emitted and generic QR rank detection dropped an arbitrary column instead** — on `mpdta` that silently removed two genuine post-treatment effects, `(2004, 2004)` and `(2006, 2007)` (issue #724). - **Note (reference-period sensitivity, W2025 Section 6.1):** the choice of reference is a NORMALIZATION, not an identifying assumption. The paper notes any set of pre-treatment periods may serve, and the pre-trend `t`-test is numerically identical whichever is used. `g − 1` is the paper's canonical choice and the one this library implements. **The point estimates are NOT invariant to it** — only the two-sided pre-trend test is (Section 6.1). Different references give different finite-sample 2×2 contrasts, all consistent for ATT(g, t) under parallel trends: on `mpdta`, ATT(2007, 2007) computed by hand against references 2006/2005/2004/2003 gives −0.0261 / −0.0571 / −0.0599 / −0.0294. This is exactly why issue #724 mattered — QR dropped `g2007_t2005`, silently making 2005 the reference, and the estimator returned −0.0571: a correct 2×2 DiD against the wrong baseline. It also means the unbalanced-panel fallback (the cohort's latest AVAILABLE pre-period) yields a different contrast from `g − 1`, which is legitimate but should be read as such. - **Note (unidentified cohorts — library identification limit):** a cohort with NO observed period before `g − anticipation` has no reference cell, so none of its ATTs are identified. The library warns naming the cohort and **excludes its observations** from the estimation sample. *(Conditional since comparison-support filtering landed: when the filter removes that cohort's rows first, the cohort never reaches this path and is instead named by the zero-cell / fully-dropped warning above. The observations are excluded either way; only which warning names them differs.)* Excluding only its columns would be worse than the original bug: `_filter_sample` retains every treated row, so the cohort would join the omitted baseline beside the controls and load its treatment effect onto the time fixed effects — measured on `mpdta` at `anticipation=1`, that moved `ATT(2006, 2006)` by 0.0077 and `ATT(2006, 2007)` by 0.0055, past the 5e-3 this library treats as material. Exclusion can cascade (the removed rows may have been another cohort's not-yet-treated comparison), so the surviving sample is re-checked and `fit()` raises when no comparison observations remain rather than returning an all-NaN fit. This is a **library limit, not the paper's last-cohort result** — W2025's "the last cohort `T` plays the role of the never-treated group" is conditioned on there being no never-treated group at all, a different case. -- **Note (per-period comparison support, W2025 Section 5.4 — the cell half):** a period at which **no unit is untreated** carries no identified `ATT(g, t)`, because there is no untreated outcome to difference against. Such periods are removed from the estimation sample **before the solve**. The eligible set is keyed on the regression baseline, not on the method: on the lead-and-lag branch (`never_treated` + OLS) only never-treated rows qualify, because every `(g, t)` except each cohort's reference is emitted, so a later cohort's rows sit in their own indicator; elsewhere not-yet-treated rows qualify too. **The omitted reference cells do NOT count as support** even though they sit in the baseline: cohort `h`'s indicator is absorbed by the unit FE (`1{h,t} = D_h − Σ_{t'≠t} h_{t'}`), so the period dummy stays reproducible from the emitted cells and the design remains collinear — measured on a panel with never-treated units through `t=4` and cohorts 3, 6 through `t=6`, retaining `t=5` on the strength of `ref(6)=5` raises on the lost `(6,1)`, while dropping it fits at `overall_att = 1.0171`. On an all-eventually-treated panel this yields exactly Eq. 5.15's cell set: cohort `G_max` is the reference and receives nothing. Anchored to Stata `jwdid` (`tests/test_etwfe_cs_stata_parity.py::TestAllEventuallyTreatedVsStataJwdid`): identical cell set, identical `N` (764 of 955), ATTs agreeing to ~1e-15. +- **Note (per-period comparison support, W2025 Section 5.4 — the cell half):** a period at which **no unit is untreated** carries no identified `ATT(g, t)`, because there is no untreated outcome to difference against. Such periods are removed from the estimation sample **before the solve** *(under the default `unsupported_period_action="drop"`; `"error"` refuses before removing them, see the opt-out Note below)*. The eligible set is keyed on the regression baseline, not on the method: on the lead-and-lag branch (`never_treated` + OLS) only never-treated rows qualify, because every `(g, t)` except each cohort's reference is emitted, so a later cohort's rows sit in their own indicator; elsewhere not-yet-treated rows qualify too. **The omitted reference cells do NOT count as support** even though they sit in the baseline: cohort `h`'s indicator is absorbed by the unit FE (`1{h,t} = D_h − Σ_{t'≠t} h_{t'}`), so the period dummy stays reproducible from the emitted cells and the design remains collinear — measured on a panel with never-treated units through `t=4` and cohorts 3, 6 through `t=6`, retaining `t=5` on the strength of `ref(6)=5` raises on the lost `(6,1)`, while dropping it fits at `overall_att = 1.0171`. On an all-eventually-treated panel this yields exactly Eq. 5.15's cell set: cohort `G_max` is the reference and receives nothing. Anchored to Stata `jwdid` (`tests/test_etwfe_cs_stata_parity.py::TestAllEventuallyTreatedVsStataJwdid`): identical cell set, identical `N` (764 of 955), ATTs agreeing to ~1e-15. - **Note (the reduction is always reported — deviation from `jwdid`):** Stata `jwdid` performs the same reduction **silently**, reporting only a smaller `N`. This library warns on every fit that drops rows, naming the periods, the observation count, and the branch-correct cause, and separately naming any cohort left with no estimated cells or stripped of every row. Neither warning is gated on `rank_deficient_action` — that setting governs how rank warnings surface, not whether the estimation sample changed. Being explicit is a deliberate improvement, not a numerical deviation: the estimates are identical. +- **Note (`unsupported_period_action` — opt-out of the reduction):** an **unsupported period** is one lacking the required comparison support — no positive-weight **eligible** comparison observation is observed there (never-treated on the `never_treated` + OLS branch; never-treated or not-yet-treated elsewhere), so no `ATT(g, t)` at that period is identified against an untreated outcome. `unsupported_period_action="drop"` (the default) is the behavior above, byte-identical: remove those periods before the solve and warn. `unsupported_period_action="error"` **refuses before removing any period**, raising `ValueError` at the filter point with the periods, the would-be-dropped observation count, the cause and the definition, for a user who would rather see the refusal than estimate on a reduced sample. There is deliberately **no "keep the rows" mode**: measured with the filter disabled, every branch and every `rank_deficient_action` mode refuses anyway — the emitted cells at an unsupported period sum to that period's time indicator, so `rank_deficient_action="error"` raises rank-deficient (`g5_t8`, `g5_t9` on the `{3,5,8}` all-treated panel) and `"warn"`/`"silent"` reach the completeness gate on the lost cells (`(5,8),(5,9)`; `(3,5),(3,6)` on the `never_treated` branch; `(8,8),(8,9)` on logit/Poisson) with a message that blames rank reduction — so skipping the filter can only yield a worse-worded refusal, never a fit. **Ordering:** the refusal is decided at the top of the filter block, before the `survey_design=` refusal (both refuse; this one names the identification cause) and before any row is touched; the earlier constructor and fit gates are unchanged. **Not gated on `rank_deficient_action`** (same convention as every other sample-changing decision here): the message is identical across `warn`/`error`/`silent` and no comparison-support, reference-movement, zero-cell or rank warning precedes it (the pre-filter outcome-fit hint, which reads only the outcome column, can legitimately fire first). **Cause accuracy:** support is weight-aware, so a period can be unsupported *structurally* (no eligible row observed) or by *zero weight* (eligible rows observed, every one at zero survey weight). A shared helper names the right cause for both the warning and the refusal — single-cause sentences name no periods and the structural sentence is byte-identical to the pre-existing warning; only when both causes are present does each sentence carry its own period list. The zero-weight cause is reachable today only through the refusal (under a survey the drop path refuses first; without one every weight is 1) — measured on cohorts `{0,3,5,8}` with cohort-0 rows at zero weight for `t ≥ 8` (periods 8, 9 on OLS/logit/Poisson), on the `never_treated` branch with never-treated rows at zero weight for `t ≥ 5` (periods 5, 6), and on mixed frames (zero-weight 8 / structural 9; zero-weight 5 / structural 6). **The remedy is conditional:** without `survey_design=` the message advises `"drop"` to estimate the remaining cells; with it, the message states that `"drop"` also refuses and carries the PSU/stratum caveat of the survey refusal below. No results field records the setting: it changes no estimate (a successful `"error"` fit is identical to the `"drop"` fit on the same frame). Ledger `M-147`. - **Note (reference movement under filtering):** on the `not_yet_treated` branch a reference period can never be filtered — reference eligibility (`t < g − anticipation`) IS the support predicate's own second disjunct evaluated at that cohort's rows — and the estimator asserts this at runtime, raising if it is ever violated (silent renormalization is the issue #724 defect class). On the `never_treated` + OLS branch it CAN move, because eligibility there does not imply never-treated presence: measured, dropping `t=5` moves `ref(6)` from 5 to 4. That is legitimate — the ATTs stay correctly labelled and validly identified, just normalized against a different baseline period — so it **warns** naming the cohort and both periods rather than raising. A reference that becomes `None` is not a move: that cohort is unidentified and the exclusion path already warns by name. - **Note (zero-cell cohorts are warned, not raised):** a cohort retained only as a control — cohort `G_max` under the Section 5.4 normalization — or one that loses every row to filtering produces no `ATT(g, t)`. `fit()` reports it by name and continues. `results.groups` and `_n_g_per_cohort` are derived from the **emitted cell set** so such a cohort is not advertised as estimated (`set(results.groups) == {g for (g, t) in group_time_effects}`). `cohort_trend_coefs` is keyed on PRESENT cohorts instead, because the trend baseline is exactly the zero-cell cohort; the two therefore differ by design, and the invariant on that dict is `set(cohort_trend_coefs) ⊆ present cohorts`. - **Note (`aggregate(weights="cohort_share")` is REFUSED when filtering removed units):** `_n_g_per_cohort` — `N_g` in W2025 Eqs. 7.4/7.6 — is read off the FINAL sample. Comparison-support filtering is the first thing in this estimator that can remove SOME units of a RETAINED cohort: `_filter_sample` keeps every row of every treated unit, and unidentified-cohort exclusion removes whole cohorts, so before this the count was always the full supplied cohort. On an **unbalanced** panel a unit observed only at unsupported periods vanishes with them, and `N_g` then describes a strictly smaller cohort than the user supplied — measured on a cohort supplied with 100 units of which 90 appear only at a dropped period: `aggregate(weights="cohort_share")` moves from 1.8078 to 3.8157. W2025 Section 7 defines `N_g` on a balanced panel and does not say whether it counts the supplied cohort or the surviving units; the two disagree materially, so `aggregate` **fails closed** naming the cohorts and unit counts rather than choosing silently (mirroring the survey + `cohort_share` refusal). `weights="cell"` never reads `N_g` and is unaffected, and the refusal cannot fire on a **balanced** panel — filtering there removes whole periods and no units — so the all-eventually-treated capability is untouched. Defining the unbalanced estimand is tracked in `TODO.md`. -- **Note (comparison-support filtering is REFUSED under `survey_design=`):** a second survey boundary alongside the unidentified-cohort refusal below, and for the same reason — deleting rows removes their PSUs and strata from the Taylor-linearized meat and from `df_survey = n_PSU − n_strata`. Raised **before any row is removed**, and strictly conditional on the filter actually dropping rows (survey fits that lose nothing are unaffected). The message points at **explicit frame restriction**, not `SurveyDesign.subpopulation()`: subpopulation zero-pads the excluded rows, which `_reject_zero_weight_groups` then refuses on the OLS path (measured: `Survey weights sum to zero for time period(s) [8, 9]`). **That workaround is exact only when every PSU and stratum survives the restriction**, which holds on a BALANCED panel — measured there: no PSU and no stratum is removed, `df_survey` is unchanged, and ATTs/SEs agree to ≤5e-15. It does NOT hold in general: on an unbalanced panel a PSU or stratum observed only at unsupported periods disappears with them (measured: restricting a 6-period frame to its 5 supported periods dropped one PSU and one stratum entirely), which changes the Taylor-linearized meat and can change `df_survey`. Before relying on the restriction, confirm the restricted frame retains every PSU and stratum of the design you specified and is the survey universe you intend; if it does not, there is no supported path until domain estimation lands. +- **Note (comparison-support filtering is REFUSED under `survey_design=`):** a second survey boundary alongside the unidentified-cohort refusal below, and for the same reason — deleting rows removes their PSUs and strata from the Taylor-linearized meat and from `df_survey = n_PSU − n_strata`. Raised **before any row is removed** (and, under `unsupported_period_action="error"`, pre-empted by that setting's own `ValueError`, which carries the same PSU/stratum caveat), and strictly conditional on the filter actually dropping rows (survey fits that lose nothing are unaffected). The message points at **explicit frame restriction**, not `SurveyDesign.subpopulation()`: subpopulation zero-pads the excluded rows, which `_reject_zero_weight_groups` then refuses on the OLS path (measured: `Survey weights sum to zero for time period(s) [8, 9]`). **That workaround is exact only when every PSU and stratum survives the restriction**, which holds on a BALANCED panel — measured there: no PSU and no stratum is removed, `df_survey` is unchanged, and ATTs/SEs agree to ≤5e-15. It does NOT hold in general: on an unbalanced panel a PSU or stratum observed only at unsupported periods disappears with them (measured: restricting a 6-period frame to its 5 supported periods dropped one PSU and one stratum entirely), which changes the Taylor-linearized meat and can change `df_survey`. Before relying on the restriction, confirm the restricted frame retains every PSU and stratum of the design you specified and is the survey universe you intend; if it does not, there is no supported path until domain estimation lands. - **Note (control-pool asymmetry on `never_treated`):** `control_group="never_treated"` restricts the comparison pool to never-treated units **on the OLS path only**, where `include_pre` emits every `(g, t)` except each cohort's reference and treated units' pre-treatment rows therefore sit in their own indicators. On `method="logit"` / `"poisson"` only post-treatment cells are emitted — including all cells would make each cohort dummy collinear with the sum of its own indicators — so treated units' pre-treatment rows ARE the identifying comparison there, exactly as under `not_yet_treated`. Measured at HEAD on `never_treated` + Poisson with never-treated units observed only at `t=1..3` and cohorts 4, 7 at `t=1..5`: the fit succeeds and perturbing only cohort-7 rows at `t∈{4,5}` moves `ATT(4,4)` and `ATT(4,5)` materially, while the OLS counterpart on a panel where that fit is defined is invariant to ~1e-15. This asymmetry is **pre-existing and structural**, not introduced by the support filter. Related: `n_control_units` counts never-treated UNITS on this setting regardless of method, so on the nonlinear paths it under-reports the rows actually doing the comparison — widening it is tracked in `TODO.md`. - **Note:** unobserved `(g, t)` pairs are skipped rather than emitted as identically-zero columns, and the skipped pairs are named in a `UserWarning` (gated on `rank_deficient_action`, so `"silent"` remains silent). - **Note (within-cohort support connectivity — OLS/unit-FE path only):** per-period support is **necessary but not sufficient** for identification. Identification runs through the unit fixed effects, so each emitted `(g, t)` cell must be connected to an **unemitted** period in the bipartite graph whose nodes are that cohort's units and periods (edge = a supported observation). Within any connected component in which *every* period is a treatment cell, those columns sum to the component's unit indicators — absorbed by the unit FE — so the block is rank-deficient and QR drops one, potentially a genuine post-treatment effect, leaving the overall ATT an average over an incomplete cell set. This is issue #724's failure mode reached through **unit** support rather than through the reference period, and per-period observation counts cannot detect it. Measured on a panel where cohort 4's units split into `{2, 4}` and `{1, 5}` groups: `g4_t5` was silently dropped and `overall_att` computed from the single surviving post cell. `fit()` now **fails closed** naming the unidentified cells (not gated on `rank_deficient_action` — that setting governs how rank warnings surface, not whether an unidentified estimand may be returned as a number). On `not_yet_treated` and the nonlinear paths pre-treatment periods are not emitted, so a component holding any pre-period is safe — which is why the condition is "contains an unemitted period", not "contains the reference". The condition applies **only to the OLS path**, whose within-transformation absorbs the unit fixed effects: `logit`/`poisson` use explicit cohort + time dummies, nothing absorbs a component's cell block, and such designs are full rank — so the check is gated on `method="ols"` and those paths estimate the same panel normally. Component-aware estimation (rather than refusal) is tracked in `TODO.md`. diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index dc34ddbfa..23a76febe 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -224,7 +224,8 @@ Smaller items that do not fit the families above — two inert `SyntheticDiD` co parameters, the `covariates=` constructor-to-`fit()` move, a retired transition warning, the Bacon roster re-homing, and the family-wide `anticipation` validation below. The appendix lists the ledger-derived removals/flips; [M-144], [M-145], and [M-146] are behavior tightenings with -no removal/deprecation fields and appear here only. +no removal/deprecation fields, and [M-147] is an additive `WooldridgeDiD` knob of the same shape; +all four appear here only. - `anticipation` is validated across the family ([M-144], landing at 4.0): whole-valued floats that previously fit identically to their integer now raise — pass the `int`; bool and @@ -254,6 +255,14 @@ no removal/deprecation fields and appear here only. dead no-op (it prints no alpha-based interval; the displayed confidence set is keyed on its own stored `gamma`), and a non-fit value now raises like the rest of the family. +- `WooldridgeDiD(unsupported_period_action=...)` ([M-147], landing at 4.0): 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. The default `"drop"` is unchanged: such periods + are removed before the solve and the reduction is warned. `"error"` refuses with `ValueError` + before removing any period, naming the periods and the cause, for users who would rather see + the refusal than estimate on a reduced sample. No estimate changes under either value. + One pending decision: the `DIFF_DIFF_SOLVE_OLS_FASTPATH` environment default has a go/no-go due at 4.0 that has not been made. If it lands on, it is a numerics change and will be documented then; "evaluated, kept off" is an equally valid outcome, so it carries no appendix row today. diff --git a/docs/tutorials/16_wooldridge_etwfe.ipynb b/docs/tutorials/16_wooldridge_etwfe.ipynb index b0af71c66..dfb7d8d7d 100644 --- a/docs/tutorials/16_wooldridge_etwfe.ipynb +++ b/docs/tutorials/16_wooldridge_etwfe.ipynb @@ -1199,7 +1199,12 @@ "Wooldridge (2025) Section 5.4 gives the answer: the **last cohort serves as the\n", "reference**, and \"all variables in regression (5.3) involving `dT_i` get\n", "dropped\". `WooldridgeDiD` implements that — it removes the unsupported periods\n", - "before estimating and tells you it did.\n", + "before estimating and tells you it did. An *unsupported period* is one\n", + "lacking the required comparison support: no positive-weight eligible\n", + "comparison observation is observed there, so no `ATT(g, t)` at that period\n", + "is identified. If you would rather be refused than estimate on a reduced\n", + "sample, pass `unsupported_period_action=\"error\"`: the fit then raises\n", + "`ValueError` before removing anything, naming the periods and the cause.\n", "\n", "Use `control_group=\"not_yet_treated\"` (the default); `\"never_treated\"` raises\n", "when there are no never-treated units to use.\n" @@ -1302,7 +1307,7 @@ "3. **Nonlinear paths** (Poisson, Logit) use the ASF formula: E[f(η₁)] − E[f(η₀)] — the only valid ATT definition for nonlinear models\n", "4. **Four aggregations** mirror Stata's `estat` commands: event, group, calendar, simple\n", "5. **Delta-method SEs** for all aggregations, including nonlinear paths\n", - "6. **All-eventually-treated panels** estimate via the Section 5.4 normalization: the last cohort is the reference, unsupported periods are dropped, and the reduction is always reported\n", + "6. **All-eventually-treated panels** estimate via the Section 5.4 normalization: the last cohort is the reference, unsupported periods are dropped (or refused, with `unsupported_period_action=\"error\"`), and the reduction is always reported\n", "7. **When to prefer ETWFE**: nonlinear outcomes, or when a single-regression framework is preferred\n", "8. **When to prefer CS/ImputationDiD**: covariate adjustment via IPW/DR, or multiplier bootstrap inference\n", "\n", @@ -1315,6 +1320,7 @@ "| `anticipation` | `0` | Anticipation periods before treatment |\n", "| `alpha` | `0.05` | Significance level |\n", "| `cluster` | `None` | Column for clustering (default: unit variable) |\n", + "| `unsupported_period_action` | `'drop'` | `'drop'` removes periods lacking comparison support and warns; `'error'` refuses before removing them |\n", "\n", "**References:**\n", "- Wooldridge, J. M. (2025). Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators. *Empirical Economics*, 69(5), 2545–2587. Published version of SSRN 3906345 / NBER Working Paper 29154; cited as Wooldridge (2025) throughout this tutorial, including Eq. 6.1/6.4 (reference period) and Section 5.4 (all-eventually-treated panels).\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 45b7fb7dd..a5c4c6f07 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -1465,7 +1465,7 @@ rows: phase: 2 test_ref: tests/test_wooldridge.py code_refs: [diff_diff/wooldridge.py, docs/methodology/REGISTRY.md, tests/test_etwfe_cs_stata_parity.py] - notes: "Per-period COMPARISON-SUPPORT filtering: WooldridgeDiD.fit() now removes periods at which no unit is untreated from the estimation sample BEFORE the solve, instead of carrying them into a rank-deficient design. This is the cell half of W2025 Section 5.4 - with no never-treated group the last cohort serves as the reference, and 'all variables in regression (5.3) involving dT_i get dropped' - so all-eventually-treated panels ESTIMATE again, resolving the capability regression [M-124] recorded. Measured on a {3,5,8} panel over t=1..9 with a true ATT of 1.5: cells (3,3..7) and (5,5..7), overall_att=1.5502, cohort 8 receiving none. Anchored externally to Stata jwdid on the mpdta panel with never-treated counties dropped (golden block jwdid_alltreated): identical cell set, identical N (764 of 955), ATTs agreeing to ~1e-15. The eligible set is keyed on the regression BASELINE, not the method: on never_treated + OLS only never-treated rows qualify, because include_pre emits every (g,t) except each cohort's reference so a later cohort's rows sit in their own indicator; elsewhere not-yet-treated rows qualify too. Omitted reference cells deliberately do NOT count as support - they sit in the baseline but do not identify the period, since the cohort indicator is absorbed by the unit FE and the period dummy stays reproducible from the emitted cells (measured: retaining a period on the strength of ref(6)=5 raises on the lost (6,1), while dropping it fits at overall_att=1.0171). SAMPLE REDUCTION IS ALWAYS REPORTED, in two parts and neither gated on rank_deficient_action: at filter time the periods, observation count and branch-correct cause (emitted there so it survives a downstream raise), and after the final build any cohort left with no cells or stripped of every row (read from the FINAL cell set so a cohort that unidentified-cohort exclusion also removes is not double-reported). Stata jwdid performs the same reduction silently, reporting only a smaller N; being explicit is a deliberate improvement, not a numerical deviation. REFERENCE MOVEMENT: on the not_yet_treated branch a reference can never be filtered (eligibility t < g - anticipation IS the predicate's own second disjunct) and the estimator asserts this at runtime; on never_treated + OLS it CAN move, which renormalizes that cohort's ATTs against a different baseline period, so that case warns naming the cohort and both periods rather than raising. RESULTS METADATA: results.groups and _n_g_per_cohort are now derived from the emitted cell set so a cohort retained only as a control is not advertised as estimated; cohort_trend_coefs stays keyed on PRESENT cohorts because the trend baseline is exactly the zero-cell cohort. The design-side readers of the cohort list (covariate blocks, trend block) deliberately keep the present-cohort list - routing them through the cells-derived one would drop D_{G_max} x X and silently apply a covariate normalization this row does NOT include (measured: 8/8 ATTs move, max 0.0648). Under survey_design= the filter REFUSES rather than subsetting, conditional on rows actually dropping, for the same reason as [M-123]. COHORT-SHARE BOUNDARY: `_n_g_per_cohort` (N_g, Eqs. 7.4/7.6) is read off the FINAL sample, and this filter is the first thing in the estimator that can remove SOME units of a RETAINED cohort - `_filter_sample` keeps every row of every treated unit and unidentified-cohort exclusion removes whole cohorts, so before this N_g was always the full supplied cohort. On an UNBALANCED panel a unit observed only at unsupported periods vanishes with them and N_g then describes a strictly smaller cohort (measured: 90 of a cohort's 100 units observed only at a dropped period moves aggregate(weights='cohort_share') from 1.8078 to 3.8157). W2025 Section 7 assumes a balanced panel and does not say which reading applies, so aggregate() FAILS CLOSED naming the cohorts and unit counts rather than choosing silently, mirroring the existing survey + cohort_share refusal. weights='cell' never reads N_g and is unaffected; a BALANCED panel drops whole periods and no units, so the all-eventually-treated capability this row delivers cannot trip it. Defining the unbalanced estimand is tracked in TODO.md. SURVEY WORKAROUND IS CONDITIONAL: the refusal message points at explicit frame restriction, which is exact only when every PSU and stratum survives the restriction - true on a balanced panel, NOT in general (measured: restricting a 6-period unbalanced frame to its 5 supported periods removed one PSU and one stratum outright). REGISTRY, the survey roadmap and the error message all state that condition rather than claiming equivalence. NOT INCLUDED, tracked in TODO.md: the D_{G_max} x X covariate normalization, so covariates on such a panel remain rank-deficient (coefficients unaffected, but rank_deficient_action='error' raises); and an opt-out parameter for the filtering. introduced_in gates the 3.9 cut; deprecated_in stays null so the early-flip guard does not fire against the PR that ships it." + notes: "Per-period COMPARISON-SUPPORT filtering: WooldridgeDiD.fit() now removes periods at which no unit is untreated from the estimation sample BEFORE the solve, instead of carrying them into a rank-deficient design. This is the cell half of W2025 Section 5.4 - with no never-treated group the last cohort serves as the reference, and 'all variables in regression (5.3) involving dT_i get dropped' - so all-eventually-treated panels ESTIMATE again, resolving the capability regression [M-124] recorded. Measured on a {3,5,8} panel over t=1..9 with a true ATT of 1.5: cells (3,3..7) and (5,5..7), overall_att=1.5502, cohort 8 receiving none. Anchored externally to Stata jwdid on the mpdta panel with never-treated counties dropped (golden block jwdid_alltreated): identical cell set, identical N (764 of 955), ATTs agreeing to ~1e-15. The eligible set is keyed on the regression BASELINE, not the method: on never_treated + OLS only never-treated rows qualify, because include_pre emits every (g,t) except each cohort's reference so a later cohort's rows sit in their own indicator; elsewhere not-yet-treated rows qualify too. Omitted reference cells deliberately do NOT count as support - they sit in the baseline but do not identify the period, since the cohort indicator is absorbed by the unit FE and the period dummy stays reproducible from the emitted cells (measured: retaining a period on the strength of ref(6)=5 raises on the lost (6,1), while dropping it fits at overall_att=1.0171). SAMPLE REDUCTION IS ALWAYS REPORTED, in two parts and neither gated on rank_deficient_action: at filter time the periods, observation count and branch-correct cause (emitted there so it survives a downstream raise), and after the final build any cohort left with no cells or stripped of every row (read from the FINAL cell set so a cohort that unidentified-cohort exclusion also removes is not double-reported). Stata jwdid performs the same reduction silently, reporting only a smaller N; being explicit is a deliberate improvement, not a numerical deviation. REFERENCE MOVEMENT: on the not_yet_treated branch a reference can never be filtered (eligibility t < g - anticipation IS the predicate's own second disjunct) and the estimator asserts this at runtime; on never_treated + OLS it CAN move, which renormalizes that cohort's ATTs against a different baseline period, so that case warns naming the cohort and both periods rather than raising. RESULTS METADATA: results.groups and _n_g_per_cohort are now derived from the emitted cell set so a cohort retained only as a control is not advertised as estimated; cohort_trend_coefs stays keyed on PRESENT cohorts because the trend baseline is exactly the zero-cell cohort. The design-side readers of the cohort list (covariate blocks, trend block) deliberately keep the present-cohort list - routing them through the cells-derived one would drop D_{G_max} x X and silently apply a covariate normalization this row does NOT include (measured: 8/8 ATTs move, max 0.0648). Under survey_design= the filter REFUSES rather than subsetting, conditional on rows actually dropping, for the same reason as [M-123]. COHORT-SHARE BOUNDARY: `_n_g_per_cohort` (N_g, Eqs. 7.4/7.6) is read off the FINAL sample, and this filter is the first thing in the estimator that can remove SOME units of a RETAINED cohort - `_filter_sample` keeps every row of every treated unit and unidentified-cohort exclusion removes whole cohorts, so before this N_g was always the full supplied cohort. On an UNBALANCED panel a unit observed only at unsupported periods vanishes with them and N_g then describes a strictly smaller cohort (measured: 90 of a cohort's 100 units observed only at a dropped period moves aggregate(weights='cohort_share') from 1.8078 to 3.8157). W2025 Section 7 assumes a balanced panel and does not say which reading applies, so aggregate() FAILS CLOSED naming the cohorts and unit counts rather than choosing silently, mirroring the existing survey + cohort_share refusal. weights='cell' never reads N_g and is unaffected; a BALANCED panel drops whole periods and no units, so the all-eventually-treated capability this row delivers cannot trip it. Defining the unbalanced estimand is tracked in TODO.md. SURVEY WORKAROUND IS CONDITIONAL: the refusal message points at explicit frame restriction, which is exact only when every PSU and stratum survives the restriction - true on a balanced panel, NOT in general (measured: restricting a 6-period unbalanced frame to its 5 supported periods removed one PSU and one stratum outright). REGISTRY, the survey roadmap and the error message all state that condition rather than claiming equivalence. NOT INCLUDED, tracked in TODO.md: the D_{G_max} x X covariate normalization, so covariates on such a panel remain rank-deficient (coefficients unaffected, but rank_deficient_action='error' raises); the opt-out parameter for the filtering has since shipped as [M-147] (unsupported_period_action). introduced_in gates the 3.9 cut; deprecated_in stays null so the early-flip guard does not fire against the PR that ships it." - id: M-124 kind: behavior group: etwfe-reference-period @@ -1772,3 +1772,16 @@ rows: test_ref: tests/test_staggered.py code_refs: [diff_diff/results_base.py, diff_diff/staggered_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/imputation_results.py, diff_diff/efficient_did_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/sun_abraham.py, diff_diff/results.py, diff_diff/triple_diff.py, diff_diff/trop_results.py, diff_diff/continuous_did_results.py, diff_diff/synthetic_control_results.py, docs/methodology/REGISTRY.md] notes: "Staggered-family summary(alpha=)/print_summary(alpha=) tightening via the shared results_base._require_fit_alpha guard (the DMLDiDResults/EventStudyResults precedent): eight results classes (CallawaySantAnnaResults, StaggeredTripleDiffResults, ChaisemartinDHaultfoeuilleResults, ImputationDiDResults, EfficientDiDResults, TwoStageDiDResults, StackedDiDResults, SunAbrahamResults) previously did alpha = alpha or self.alpha and relabeled the confidence-interval header at the REQUESTED alpha while always printing the FIT-TIME stored intervals - silent coverage mislabeling on any non-fit alpha (bootstrap percentile intervals cannot be reconstructed from the SE at all). Behavior delta: accepted-and-mislabeled -> loud ValueError; alpha=0.0, previously swallowed by the falsy `or` idiom, now raises too. StaggeredTripleDiffResults is INCLUDED although its parent estimator is removed at 4.0 ([M-013]/[M-014]) - the mislabel is a live 3.x rendering bug, distinct from the estimator's frozen construction shape. Resolved - the guard is applied family-wide: the non-staggered summaries (DiDResults incl. SpilloverDiDResults by inheritance, MultiPeriodDiDResults, SyntheticDiDResults, TripleDifferenceResults, TROPResults, ContinuousDiDResults, SyntheticControlResults - the last previously a dead no-op alpha, now an honest raise) adopted it via the same helper (an optional message= override carries class-accurate wording where the staggered default's bootstrap-percentile rationale would be false; the staggered default message is byte-unchanged). Sibling behavioral evidence lives in tests/test_estimators.py, tests/test_triple_diff.py, tests/test_trop.py, tests/test_continuous_did.py, tests/test_methodology_synthetic_control.py, and tests/test_spillover.py (test_ref stays the staggered anchor: the schema's test_ref is a single path and this row remains the one logical contract). introduced_in 4.0 per the [M-144] rationale; status done is terminal." + - id: M-147 + kind: behavior + group: etwfe-reference-period + old: "diff_diff:WooldridgeDiD[unsupported_period_action]" + new: null + introduced_in: "4.0" + deprecated_in: null + removed_in: null + status: done + phase: 5 + test_ref: tests/test_wooldridge.py + code_refs: [diff_diff/wooldridge.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-autonomous.txt, docs/api/wooldridge_etwfe.rst, docs/migration-4.0.md, docs/tutorials/16_wooldridge_etwfe.ipynb] + notes: "Opt-out for per-period comparison-support filtering ([M-125]), the item that row listed as NOT INCLUDED. New constructor parameter WooldridgeDiD(unsupported_period_action='drop'|'error'), validated in _validate_constructor_args so set_params stays transactional (BaseEstimator probe re-init). An UNSUPPORTED period is one lacking the required comparison support: no positive-weight ELIGIBLE comparison observation is observed there (never-treated on the never_treated + OLS branch; never-treated or not-yet-treated elsewhere), so no ATT(g, t) at that period is identified against an untreated outcome. 'drop' (default) is BYTE-IDENTICAL to the M-125 behavior - filter before the solve, warn naming periods/observation count/cause, continue (measured on the {3,5,8} all-treated builder: overall_att 1.5416, cells (3,3..7),(5,5..7), 1470 of 1890 rows; the drop warning text is unchanged against the pre-change source). 'error' REFUSES before removing any period, raising ValueError at the filter point with the same information plus the definition, for users who would rather see the refusal than estimate on a reduced sample. WHY REFUSE RATHER THAN SKIP THE FILTER: measured in-memory with the filter disabled, every branch and every rank_deficient_action mode refuses anyway - the emitted cells at an unsupported period sum to that period's time indicator, so the design is collinear by construction; under 'error' rank mode solve_ols raises rank-deficient (g5_t8/g5_t9 on the all-treated panel), under warn/silent the completeness gate raises on the lost cells ((5,8),(5,9); (3,5),(3,6) on the never_treated branch; (8,8),(8,9) on logit/poisson) with a message blaming rank reduction - so a 'keep the rows' mode can only yield a worse-worded refusal, never a fit. ORDERING: the refusal is decided at the top of the filter block, BEFORE the survey_design NotImplementedError and before any row is touched (both refuse; this one names the identification cause); the earlier constructor/fit gates are unchanged. NOT gated on rank_deficient_action (that setting governs how rank warnings surface, not whether the sample may change) - the refusal message is identical across warn/error/silent and no comparison-support, reference-movement, zero-cell or rank warning is emitted before it (the pre-filter outcome-fit hint, which reads only the outcome column, can legitimately precede it - pinned by a binary-outcome test). CAUSE ACCURACY: support is weight-aware (_eligible & w > 0, _cell_w populated only from survey_design.weights), so a period can be unsupported STRUCTURALLY (no eligible row observed) or by ZERO WEIGHT (eligible rows observed but every one at zero survey weight). The shared helper _comparison_support_cause names the right one - single-cause sentences name no periods and the structural sentence is byte-identical to the pre-existing warning text; only when BOTH causes are present does each sentence carry its own period list, joined with '; '. The zero-weight clause is reachable today only through the refusal (under a survey the drop path refuses first; without one every weight is 1); measured on cohorts {0,3,5,8} with cohort-0 rows at zero weight for t>=8 (periods 8, 9 zero-weight caused on ols/logit/poisson) and on the never_treated branch with never-treated rows at zero weight for t>=5 (5, 6), plus mixed frames (zero-weight 8 / structural 9; zero-weight 5 / structural 6). REMEDY IS CONDITIONAL: without survey_design the message advises 'drop' to estimate the remaining cells; with survey_design it states that 'drop' also refuses there and carries the PSU/stratum caveat of the survey refusal (restriction is exact only when every PSU and stratum survives it). NO RESULTS FIELD: the setting changes no estimate (a successful 'error' fit is identical to the 'drop' fit on the same frame) and WooldridgeDiDResults already omits rank_deficient_action and other constructor knobs; recorded here as the deliberate exception to the CONTRIBUTING new-parameter checklist. Two post-solve gate messages that presumed the drop happened ('already removed before the solve') are reworded to stay true under both modes; neither is pinned by any artifact. introduced_in 4.0 per the [M-144] rationale (shipped post-3.9-cut, version 3.11.1, _NEXT_RELEASE -> 4.0); status done is terminal so the row gates nothing further." diff --git a/docs/v4-design.md b/docs/v4-design.md index 6dbe5c787..764fa3e9c 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -1113,8 +1113,9 @@ forever - a removed symbol resurrecting is a test failure. class/function rows and alias rows also assert `__all__` membership consistent with their status (stale `import *` entries fail). The shipped row ids are a - committed snapshot in the enforcement test (128 as of the DML - review-follow-ups pair; previously 126 as of the family-wide + committed snapshot in the enforcement test (129 as of the ETWFE + comparison-support opt-out row M-147; previously 128 as of the DML + review-follow-ups pair and 126 as of the family-wide anticipation validation row: Phase 1 + the diagnostic-family amendment + the M-092/M-093 results-contract rows + the M-094..M-096 amendment rows + diff --git a/tests/test_doc_snippets.py b/tests/test_doc_snippets.py index da2d06cc1..6639219dd 100644 --- a/tests/test_doc_snippets.py +++ b/tests/test_doc_snippets.py @@ -433,7 +433,7 @@ def _restore_datasets_module(): "test_id, code, skip_reason", [pytest.param(tid, c, s, id=tid) for tid, c, s in _CASES], ) -def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str]): +def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str], tmp_path, monkeypatch): """Execute a documentation code snippet and assert no API/runtime errors. ``os.environ`` is snapshot/restored around the exec: snippets may @@ -442,11 +442,19 @@ def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str]): unreverted mutation leaks process state into every later test in the session (it flipped the backend-arm selection of the dCDH pinned bootstrap baseline under full-suite order). + + The snippet runs with ``tmp_path`` as the working directory: several + snippets call ``savefig('.png')`` with a bare filename, and with the + repo root as CWD those files landed there and were swept into a + developer's change set (``event_study.png`` and three siblings, ~208 KB, + caught in review). Isolating the CWD keeps snippet side effects out of + the checkout entirely instead of ignoring them by name. """ if skip_reason: pytest.skip(skip_reason) ns = _build_namespace() + monkeypatch.chdir(tmp_path) env_snapshot = os.environ.copy() try: exec(compile(code, f"<{test_id}>", "exec"), ns) diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index c56f046d7..8922aa651 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -131,11 +131,12 @@ # pscore_trim tightening) = 124; + the CiC results-field rename (M-143) = 125; # + the family-wide anticipation validation row (M-144) = 126; + the DML # review-follow-ups pair (M-145 family-wide pscore_trim validation, M-146 -# family-wide summary-alpha guard) = 128. +# family-wide summary-alpha guard) = 128; + the ETWFE comparison-support +# opt-out row (M-147, unsupported_period_action) = 129. # Ids are never reused and terminal rows are never deleted, so the ledger # only grows - raise the floor when rows are added; a lower parse count # means scanner/format drift or an illegal row deletion. -ROW_COUNT_FLOOR = 128 +ROW_COUNT_FLOOR = 129 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -191,6 +192,9 @@ (143, 143), (144, 144), (145, 146), + # (147,147) = the ETWFE comparison-support opt-out + # (WooldridgeDiD unsupported_period_action, the M-125 NOT-INCLUDED item). + (147, 147), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -589,14 +593,15 @@ def test_initial_ids_never_deleted(): """The shipped id set is immutable: ids are never deleted or reused (spec section 11). ROW_COUNT_FLOOR alone would let a delete-one-add-one edit pass; this snapshot cannot. - Extends as rows ship (128 as of the DML review-follow-ups pair: + Extends as rows ship (129 as of the ETWFE comparison-support opt-out row: Phase 1 + diagnostic-family + M-092/M-093 + M-094..M-096 + the M-097..M-115 public-function completeness sweep + M-117..M-120/M-122 + M-123/M-124 + M-125 + M-126 + M-127..M-131 + M-132..M-135 + - M-136..M-138 + M-139 + M-140..M-142 + M-143 + M-144 + M-145/M-146).""" + M-136..M-138 + M-139 + M-140..M-142 + M-143 + M-144 + M-145/M-146 + + M-147).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 128 + assert len(EXPECTED_INITIAL_IDS) == 129 def test_version_tuple_pads_to_three_components(): @@ -1060,17 +1065,18 @@ def _changes_at_4_0(row): Keyed on the two LIFECYCLE version fields only - a symbol removed at 4.0, or one whose deprecation warning starts firing at 4.0 (the ``field-flip`` family, removed - at 5.0). 108 of the 128 rows qualify. + at 5.0). 108 of the 129 rows qualify. - The 20 that do not, and why (this enumeration is the contract - a reader of the + The 21 that do not, and why (this enumeration is the contract - a reader of the guide must be able to trust that nothing 4.0-relevant was dropped): - 12 ``behavior`` rows with ``introduced_in: 3.9`` and no dep/rem: already shipped in 3.9, so there is no 4.0 action. They get their own guide section, not an appendix row. - - 3 ``behavior`` rows with ``introduced_in: 4.0`` and no deprecation/removal + - 4 ``behavior`` rows with ``introduced_in: 4.0`` and no deprecation/removal fields (``M-144`` anticipation validation, ``M-145`` pscore_trim validation, - ``M-146`` summary-alpha guard - the post-cut validation tightenings): they land + ``M-146`` summary-alpha guard - the post-cut validation tightenings - and + ``M-147``, the ETWFE ``unsupported_period_action`` opt-out): they land AT 4.0 but remove/deprecate nothing, so they appear in the guide's "Remaining 4.0 changes" prose, not the ledger-derived appendix. - ``M-062``, ``M-063``: aliases, introduce-only / all lifecycle fields null. diff --git a/tests/test_wooldridge.py b/tests/test_wooldridge.py index 1a5c4680f..fc2e4aa50 100644 --- a/tests/test_wooldridge.py +++ b/tests/test_wooldridge.py @@ -128,6 +128,7 @@ def test_default_construction(self): assert est.bootstrap_weights == "rademacher" assert est.seed is None assert est.rank_deficient_action == "warn" + assert est.unsupported_period_action == "drop" assert not est.is_fitted_ def test_invalid_method_raises(self): @@ -3856,6 +3857,137 @@ def _all_treated(cohorts=(3, 5, 8), periods=9, n_per=70, seed=7, effect=1.5): uid += 1 return pd.DataFrame(rows) + @staticmethod + def _full_support(seed=21): + """Never-treated + cohorts 3, 5 over t=1..6: every period has a comparison.""" + rng = np.random.default_rng(seed) + rows = [] + for u in range(120): + g = 0 if u < 40 else (3 if u < 80 else 5) + for t in range(1, 7): + rows.append( + { + "unit": u, + "time": t, + "cohort": g, + "y": rng.standard_normal() + 1.0 * int(g > 0 and t >= g), + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _branch_one(nt_periods=4, seed=5): + """`never_treated` + OLS branch-1 panel. + + Never-treated units observed t=1..`nt_periods`; cohorts 3 and 6 observed + t=1..6. With `nt_periods=4`, periods 5 and 6 have no never-treated row + and are unsupported on that branch even though cohort 6 is still + untreated at t=5 (observed but INELIGIBLE there). + """ + rng = np.random.default_rng(seed) + rows = [] + uid = 0 + for _ in range(30): + for t in range(1, nt_periods + 1): + rows.append( + {"unit": uid, "time": t, "cohort": 0, "y": 0.2 * t + rng.normal(0, 0.05)} + ) + uid += 1 + for g in (3, 6): + for _ in range(30): + for t in range(1, 7): + rows.append( + { + "unit": uid, + "time": t, + "cohort": g, + "y": 0.2 * t + (1.0 if t >= g else 0.0) + rng.normal(0, 0.05), + } + ) + uid += 1 + return pd.DataFrame(rows) + + @staticmethod + def _zero_weight_nyt(kind, mixed=False, seed=3): + """`not_yet_treated` survey panel whose unsupported periods are ZERO-WEIGHT caused. + + Cohorts (0, 3, 5, 8), 30 units each, link-appropriate outcome. Pure + (`mixed=False`): every cohort observed t=1..9 and cohort-0 rows carry + `w=0` at t >= 8, so periods 8 and 9 have never-treated rows that cannot + supply support. Mixed (`mixed=True`): cohort 0 observed t=1..8 only + with `w=0` at t=8, so period 8 is zero-weight caused and period 9 is + structural (no eligible row at all). + """ + rng = np.random.default_rng(seed) + rows = [] + uid = 0 + for g in (0, 3, 5, 8): + for _ in range(30): + uid += 1 + fe = 0.3 * rng.standard_normal() + for t in range(1, 10): + if mixed and g == 0 and t == 9: + continue + lin = fe + 0.05 * t + 0.8 * int(g > 0 and t >= g) + if kind == "ols": + y = lin + 0.3 * rng.standard_normal() + elif kind == "logit": + y = rng.binomial(1, 1.0 / (1.0 + np.exp(-lin))) + else: + y = rng.poisson(np.exp(lin)) + zero = (t == 8) if mixed else (t >= 8) + rows.append( + { + "unit": uid, + "time": t, + "cohort": g, + "y": y, + "w": 0.0 if (g == 0 and zero) else 1.0, + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _zero_weight_nt(mixed=False, seed=5): + """`never_treated` + OLS survey panel with zero-weight-caused unsupported periods. + + Never-treated units observed t=1..6 with `w=0` at t >= 5 (pure: periods + 5 and 6 both zero-weight caused), or t=1..5 with `w=0` at t=5 (mixed: + period 5 zero-weight caused, period 6 structural). Cohorts 3 and 6 + observed t=1..6 at `w=1`. + """ + rng = np.random.default_rng(seed) + rows = [] + uid = 0 + nt_last = 5 if mixed else 6 + for _ in range(30): + uid += 1 + for t in range(1, nt_last + 1): + zero = (t == 5) if mixed else (t >= 5) + rows.append( + { + "unit": uid, + "time": t, + "cohort": 0, + "y": 0.2 * t + rng.normal(0, 0.05), + "w": 0.0 if zero else 1.0, + } + ) + for g in (3, 6): + for _ in range(30): + uid += 1 + for t in range(1, 7): + rows.append( + { + "unit": uid, + "time": t, + "cohort": g, + "y": 0.2 * t + (1.0 if t >= g else 0.0) + rng.normal(0, 0.05), + "w": 1.0, + } + ) + return pd.DataFrame(rows) + @pytest.mark.parametrize("anticipation,expected_kept", [(0, 7), (1, 6), (2, 5)]) def test_eq_5_15_cell_set_on_a_balanced_panel(self, anticipation, expected_kept): """The retained cell set is Eq. 5.15's, intersected with observed times. @@ -3897,20 +4029,7 @@ def test_eq_5_15_cell_set_on_a_balanced_panel(self, anticipation, expected_kept) def test_no_op_when_every_period_has_a_comparison(self): """Filtering is a no-op iff every period has an eligible comparison -- not merely 'a never-treated group exists'.""" - rng = np.random.default_rng(21) - rows = [] - for u in range(120): - g = 0 if u < 40 else (3 if u < 80 else 5) - for t in range(1, 7): - rows.append( - { - "unit": u, - "time": t, - "cohort": g, - "y": rng.standard_normal() + 1.0 * int(g > 0 and t >= g), - } - ) - df = pd.DataFrame(rows) + df = self._full_support() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = WooldridgeDiD(control_group="not_yet_treated").fit( @@ -3927,28 +4046,7 @@ def test_branch_one_reference_move_warns_by_name(self): would use. Silent renormalization is the #724 defect class, so this warns rather than raising. """ - rng = np.random.default_rng(5) - rows = [] - uid = 0 - for _ in range(30): # never-treated, observed t=1..4 only - for t in range(1, 5): - rows.append( - {"unit": uid, "time": t, "cohort": 0, "y": 0.2 * t + rng.normal(0, 0.05)} - ) - uid += 1 - for g in (3, 6): - for _ in range(30): - for t in range(1, 7): - rows.append( - { - "unit": uid, - "time": t, - "cohort": g, - "y": 0.2 * t + (1.0 if t >= g else 0.0) + rng.normal(0, 0.05), - } - ) - uid += 1 - df = pd.DataFrame(rows) + df = self._branch_one() # never-treated observed t=1..4 only with pytest.warns(UserWarning, match=r"reference period moved from 5 to 4"): res = WooldridgeDiD(method="ols", control_group="never_treated").fit( df, outcome="y", unit="unit", time="time", first_treat="cohort" @@ -4341,6 +4439,324 @@ def test_bootstrap_runs_on_a_filtered_fit(self): assert np.isfinite(res.overall_se) +class TestUnsupportedPeriodAction: + """`unsupported_period_action`: refuse instead of dropping unsupported periods. + + 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"` (default) removes such + periods before the solve and warns (M-125, byte-identical); `"error"` + refuses BEFORE any row is removed. There is no "keep the rows" mode: with + the filter disabled every branch and every `rank_deficient_action` mode + refuses anyway (collinear cells), only with a vaguer message. + + Panels come from `TestComparisonSupportFiltering`'s builders; every number + asserted here was measured on those exact builders before the tests were + written. + """ + + KW = dict(outcome="y", unit="unit", time="time", first_treat="cohort") + DEFINITION = "lack the required comparison support" + NO_GROUP = "no eligible comparison group" + ZERO_W = "zero survey weight" + STRUCTURAL = { + "not_yet_treated": "every unit is already treated", + "never_treated": "no never-treated units are observed", + } + DROP_ADVICE = "set unsupported_period_action='drop'" + + @staticmethod + def _panels(): + b = TestComparisonSupportFiltering + return { + ("not_yet_treated", "ols"): (b._all_treated(), "8, 9"), + ("never_treated", "ols"): (b._branch_one(), "5, 6"), + ("not_yet_treated", "logit"): (b._all_treated_nonlinear("logit"), "8, 9"), + ("not_yet_treated", "poisson"): (b._all_treated_nonlinear("poisson"), "8, 9"), + } + + # ---- 1. constructor contract ----------------------------------------- + def test_contract_default_get_set_params_and_validation(self): + est = WooldridgeDiD() + assert est.unsupported_period_action == "drop" + assert est.get_params()["unsupported_period_action"] == "drop" + est.set_params(unsupported_period_action="error") + assert est.unsupported_period_action == "error" + assert ( + WooldridgeDiD(unsupported_period_action="error").get_params()[ + "unsupported_period_action" + ] + == "error" + ) + + with pytest.raises(ValueError, match="unsupported_period_action"): + WooldridgeDiD(unsupported_period_action="keep") + with pytest.raises(ValueError, match="unsupported_period_action"): + WooldridgeDiD(unsupported_period_action=False) + + # Transactional: a bad value via set_params leaves EVERYTHING unchanged. + est = WooldridgeDiD() + before = est.get_params() + with pytest.raises(ValueError, match="unsupported_period_action"): + est.set_params(unsupported_period_action="bogus", alpha=0.10) + assert est.get_params() == before + assert est.unsupported_period_action == "drop" and est.alpha == 0.05 + + # ---- 2. structural matrix: both branches x all three rank modes ------- + @pytest.mark.parametrize( + "control_group,method", + [ + ("not_yet_treated", "ols"), + ("never_treated", "ols"), + ("not_yet_treated", "logit"), + ("not_yet_treated", "poisson"), + ], + ) + def test_error_refuses_up_front_on_every_branch_and_rank_mode(self, control_group, method): + """The refusal names the periods and the branch-correct cause, emits no + warning at all, and is identical across the three rank modes -- it is + not gated on `rank_deficient_action`.""" + df, periods = self._panels()[(control_group, method)] + messages = set() + for rda in ("warn", "error", "silent"): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ValueError) as exc: + WooldridgeDiD( + method=method, + control_group=control_group, + rank_deficient_action=rda, + unsupported_period_action="error", + ).fit(df, **self.KW) + assert not w, ( + "refusal must precede the filtering-block warnings (drop, reference " + f"move, zero-cell) and every rank warning; got {[str(m.message) for m in w]}" + ) + messages.add(str(exc.value)) + assert len(messages) == 1, "message must not depend on rank_deficient_action" + msg = messages.pop() + assert f"Period(s) {periods} {self.DEFINITION}" in msg + assert self.NO_GROUP in msg + assert self.STRUCTURAL[control_group] in msg + assert self.DROP_ADVICE in msg + assert self.ZERO_W not in msg and "PSU" not in msg + + def test_outcome_fit_hint_is_the_only_warning_that_may_precede_the_refusal(self): + """The no-warning guarantee is scoped to the filtering block and rank warnings. + + The step-0g outcome-fit hint reads only the outcome COLUMN and runs + before the comparison-support block, so on a binary outcome under the + default OLS it legitimately fires first. Pin that as the documented + exception rather than let a future binary fixture look like a + regression of the matrix test above. + """ + df = TestComparisonSupportFiltering._all_treated_nonlinear("logit") + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ValueError, match=rf"Period\(s\) 8, 9 {self.DEFINITION}"): + WooldridgeDiD(method="ols", unsupported_period_action="error").fit(df, **self.KW) + msgs = [str(m.message) for m in w] + assert len(msgs) == 1 and "looks binary" in msgs[0], msgs + assert not [m for m in msgs if self.NO_GROUP in m or "Rank-deficient" in m] + + # ---- 3. zero-weight cause under a survey design ------------------------ + @pytest.mark.parametrize( + "control_group,method,periods,n_obs", + [ + ("not_yet_treated", "ols", "8, 9", 1080), + ("not_yet_treated", "logit", "8, 9", 1080), + ("not_yet_treated", "poisson", "8, 9", 1080), + ("never_treated", "ols", "5, 6", 540), + ], + ) + def test_zero_weight_cause_is_named_under_survey_design( + self, control_group, method, periods, n_obs + ): + """Eligible rows exist at those periods but carry zero survey weight. + + The structural sentence would be FALSE here (never-treated rows ARE + observed), so the refusal must name the zero-weight cause instead, and + its remedy must not advise `'drop'` -- under a survey design the + default refuses too. + """ + from diff_diff.survey import SurveyDesign + + b = TestComparisonSupportFiltering + df = ( + b._zero_weight_nyt(method) + if control_group == "not_yet_treated" + else b._zero_weight_nt() + ) + design = SurveyDesign(weights="w") + with pytest.raises(ValueError) as exc: + WooldridgeDiD( + method=method, control_group=control_group, unsupported_period_action="error" + ).fit(df, survey_design=design, **self.KW) + msg = str(exc.value) + assert f"Period(s) {periods} {self.DEFINITION}" in msg + assert self.ZERO_W in msg + assert self.STRUCTURAL[control_group] not in msg + assert "PSU" in msg and "stratum" in msg + assert self.DROP_ADVICE not in msg + + # Companions: the default still refuses under the survey design + # (unchanged), and without it the same frame fits -- support is then + # unweighted, so nothing is unsupported. + with pytest.raises(NotImplementedError, match=self.NO_GROUP): + WooldridgeDiD(method=method, control_group=control_group).fit( + df, survey_design=design, **self.KW + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = WooldridgeDiD(method=method, control_group=control_group).fit(df, **self.KW) + assert np.isfinite(res.overall_att) + assert res.n_obs == n_obs + + # ---- 3b. mixed causes: the helper's join branch ------------------------ + @pytest.mark.parametrize( + "control_group,method,zero_period,structural_period", + [ + ("not_yet_treated", "ols", "8", "9"), + ("not_yet_treated", "logit", "8", "9"), + ("not_yet_treated", "poisson", "8", "9"), + ("never_treated", "ols", "5", "6"), + ], + ) + def test_mixed_causes_name_each_period_under_its_own_cause( + self, control_group, method, zero_period, structural_period + ): + from diff_diff.survey import SurveyDesign + + b = TestComparisonSupportFiltering + df = ( + b._zero_weight_nyt(method, mixed=True) + if control_group == "not_yet_treated" + else b._zero_weight_nt(mixed=True) + ) + design = SurveyDesign(weights="w") + with pytest.raises(ValueError) as exc: + WooldridgeDiD( + method=method, control_group=control_group, unsupported_period_action="error" + ).fit(df, survey_design=design, **self.KW) + msg = str(exc.value) + structural = self.STRUCTURAL[control_group] + assert msg.count(structural) == 1 + assert msg.count(self.ZERO_W) == 1 + # Each cause carries its own explicit period list, in its own sentence. + s_idx, z_idx = msg.index(structural), msg.index(self.ZERO_W) + s_sentence = msg[s_idx : msg.index(", so ATT", s_idx)] + z_sentence = msg[msg.rfind("; ", 0, z_idx) + 2 : z_idx] + assert f"at period(s) {structural_period}" in s_sentence + assert f"at period(s) {zero_period}" in z_sentence + assert "; the only eligible comparison observations" in msg + + with pytest.raises(NotImplementedError, match=self.NO_GROUP): + WooldridgeDiD(method=method, control_group=control_group).fit( + df, survey_design=design, **self.KW + ) + + # ---- 4. no-op invariance ----------------------------------------------- + def test_error_is_a_no_op_when_every_period_has_support(self): + df = TestComparisonSupportFiltering._full_support() + results = {} + for action in ("drop", "error"): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + results[action] = WooldridgeDiD(unsupported_period_action=action).fit(df, **self.KW) + assert not [m for m in w if self.NO_GROUP in str(m.message)] + a, b = results["drop"], results["error"] + assert a.overall_att == b.overall_att + assert a.overall_se == b.overall_se + assert a.n_obs == b.n_obs == len(df) + assert sorted(a.group_time_effects) == sorted(b.group_time_effects) + + # ---- 5. ordering before the filter-block survey refusal ----------------- + @pytest.mark.parametrize("method", ["ols", "logit", "poisson"]) + def test_error_precedes_the_survey_refusal(self, method): + from diff_diff.survey import SurveyDesign + + b = TestComparisonSupportFiltering + df = b._all_treated() if method == "ols" else b._all_treated_nonlinear(method) + df = df.copy() + df["w"] = 1.0 + with pytest.raises(ValueError) as exc: + WooldridgeDiD( + method=method, control_group="not_yet_treated", unsupported_period_action="error" + ).fit(df, survey_design=SurveyDesign(weights="w"), **self.KW) + msg = str(exc.value) + assert f"Period(s) 8, 9 {self.DEFINITION}" in msg + assert "PSU" in msg + assert self.DROP_ADVICE not in msg + + # ---- 6. the default is byte-identical to M-125 ------------------------- + def test_drop_default_reproduces_the_filtered_fit(self): + """Measured on `_all_treated()` (NOT the inline 70/70/60 panel that pins + 1.5502 elsewhere): overall_att 1.5416117, se 0.0943478, 1470 of 1890.""" + df = TestComparisonSupportFiltering._all_treated() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + res = WooldridgeDiD().fit(df, **self.KW) + assert res.overall_att == pytest.approx(1.5416, abs=5e-3) + assert res.n_obs == 1470 and len(df) == 1890 + assert sorted((int(g), int(t)) for g, t in res.group_time_effects) == [ + (3, 3), + (3, 4), + (3, 5), + (3, 6), + (3, 7), + (5, 5), + (5, 6), + (5, 7), + ] + drop = [str(m.message) for m in w if self.NO_GROUP in str(m.message)] + assert len(drop) == 1 + assert drop[0].startswith("Dropped 420 of 1890 observations (2 of 9 periods: 8, 9)") + + # ---- 7. the refusal replaces the downstream gate -------------------------- + def test_refusal_replaces_the_completeness_gate_where_the_filter_is_the_cause(self): + """`{3: [1,2,3], 5: [3,4,5]}` loses t=5 to the filter and then trips the + completeness gate under the default; under "error" the up-front refusal + fires instead. The gate's message must no longer claim the periods were + 'already removed' and must name the opt-out.""" + df = TestOverallAttFailsClosed._unbalanced({3: [1, 2, 3], 5: [3, 4, 5]}) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ValueError, match="not identified and were removed") as exc: + WooldridgeDiD(control_group="not_yet_treated").fit(df, **self.KW) + gate = str(exc.value) + assert "already removed" not in gate + assert "unsupported_period_action" in gate + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with pytest.raises(ValueError, match=rf"Period\(s\) 5 {self.DEFINITION}"): + WooldridgeDiD( + control_group="not_yet_treated", unsupported_period_action="error" + ).fit(df, **self.KW) + assert not w + + # ---- 8. warning / refusal wording lockstep -------------------------------- + def test_warning_and_refusal_share_the_cause_sentence(self): + df = TestComparisonSupportFiltering._all_treated() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + WooldridgeDiD().fit(df, **self.KW) + warning = next(str(m.message) for m in w if self.NO_GROUP in str(m.message)) + with pytest.raises(ValueError) as exc: + WooldridgeDiD(unsupported_period_action="error").fit(df, **self.KW) + refusal = str(exc.value) + + def cause(text): + start = text.index(" -- ") + 4 + return text[start : text.index("untreated outcome", start) + len("untreated outcome")] + + assert cause(warning) == cause(refusal) + assert cause(warning) == ( + "every unit is already treated (accounting for `anticipation=0`), so " + "ATT(g, t) there is not identified against any untreated outcome" + ) + + class TestWooldridgeDfConvention: """The three-value df_convention knob on WooldridgeDiD OLS arms (3.9 / M-127)."""