From 5efc3e392020604f78218c953ba2c67d0460eb01 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 1 Aug 2026 22:41:41 -0400 Subject: [PATCH 1/5] feat(v4): mechanical rename wave - canonical parameter/field names with 3.9 FutureWarning shims (2(c)-ii PR-A, 32 ledger rows) First of the two 2(c)-ii rename-sweep PRs (v4-design section 9 item 4; mechanical wave). Flips 32 ledger rows planned -> shimmed - M-032, M-033/M-034, M-035..M-042, M-088/M-089, M-094, M-097..M-113, M-114 - via the shared sentinel machinery in the new leaf module diff_diff/_deprecation.py (_NotSupplied sentinel + resolve_renamed_kwarg + require_arg + deprecated_field_property; single FutureWarning with the uniform migration message, both-spellings ValueError, missing-argument TypeError naming the NEW param). Renames (old spelling stays through 3.9 as a warning alias; removal in 4.0; the NEW name takes the OLD name's signature position so positional calls bind identically, and the deprecated path routes to bit-identical results): - WooldridgeDiD.fit: cohort= -> first_treat= [M-032]. - ChaisemartinDHaultfoeuille.fit: group= -> unit= [M-033], controls= -> covariates= [M-034]; twowayfeweights(): group= -> unit= [M-097]; the removal-bound chaisemartin_dhaultfoeuille() wrapper keeps its own group param (dies with M-077) but forwards to the canonical name. - ChaisemartinDHaultfoeuilleResults.groups -> units [M-114]: dataclass field renamed, read-only FutureWarning alias property under the old name, __setstate__ migration for 3.8-era pickles, repr n_units= and summary "Units (post-filter):". to_dict() never serialized the list (pinned by test); n_groups_dropped_* counters and CS-family cohort "groups" vocabulary are deliberately untouched. - HeterogeneousAdoptionDiD.fit + joint_pretrends_test + joint_homogeneity_test + did_had_pretest_workflow: outcome_col/dose_col/time_col/unit_col/first_treat_col -> bare outcome/dose/time/unit/first_treat [M-035..M-039, M-098..M-112]; followers of renamed required params (pre_periods/base_period/ post_periods; dCDH time/treatment) carry required-sentinels with the restored missing-argument errors pinned. - RegressionDiscontinuity.fit: outcome_col/running_col/treatment_col -> outcome/running/takeup [M-040..M-042] (takeup per section 8 rule 7's non-binary corollary); RegressionDiscontinuityResults.treatment_col -> takeup [M-094] with alias property, __setstate__, and a dual-key to_dict window ("takeup" + deprecated "treatment_col" through 3.9). RDPlot.fit: outcome_col/running_col -> outcome/running [M-088/M-089]. - trim_weights(): weight_col= -> weights= [M-113]. Sentinel-carrying params are transitionally annotated Any (mypy zero- error gate; the 4.0 removal restores narrow annotations). User-facing validation/guidance strings across had.py, chaisemartin_dhaultfoeuille.py and the results modules now name the canonical spellings. Enforcement kept green in the same diff: docs/v4-deprecations.yaml rows flipped to shimmed/phase 5 with test_ref tests/test_v4_rename_mechanical.py (41 behavioral pins: per-surface old-name warning + canonical-silence + routing parity + both-supplied rejection + field-trio/pickle/dual-key contracts); tests/test_naming_guard.py gains the four new rule-1 time-surface allowlist entries (HAD.fit and the three pretest functions). Bulk migration to canonical names: ~900 call sites across 30 test files (AST-guided kwarg rewrite; fit-kwargs dict builders and results-attribute reads swept by hand; TripleDifference's rule-3 group= and the unmerged third-party LWDiD surface deliberately untouched), tutorials 16/19/20/ 21/22 (re-executed via nbmake), guides llms*.txt (llms-full keeps the deprecated tail listed inside the CI-pinned HAD signature block), docs rst (incl. both renamed-field autosummary pages), README's one catalog line, REGISTRY.md kwarg references, and the two paper reviews with kwarg mentions. CHANGELOG names the full row inventory. --- CHANGELOG.md | 34 + README.md | 2 +- diff_diff/_deprecation.py | 164 ++++ diff_diff/agent_workflow.py | 5 +- diff_diff/chaisemartin_dhaultfoeuille.py | 99 ++- .../chaisemartin_dhaultfoeuille_results.py | 34 +- diff_diff/guides/llms-autonomous.txt | 10 +- diff_diff/guides/llms-full.txt | 75 +- diff_diff/guides/llms-practitioner.txt | 2 +- diff_diff/guides/llms.txt | 2 +- diff_diff/had.py | 98 ++- diff_diff/had_pretests.py | 148 +++- diff_diff/practitioner.py | 34 +- diff_diff/prep.py | 22 +- diff_diff/rdd.py | 102 ++- diff_diff/rdplot.py | 37 +- diff_diff/wooldridge.py | 20 +- ...diff.ChaisemartinDHaultfoeuilleResults.rst | 2 +- ...ff_diff.RegressionDiscontinuityResults.rst | 2 +- docs/api/chaisemartin_dhaultfoeuille.rst | 6 +- docs/api/prep.rst | 2 +- docs/api/regression_discontinuity.rst | 2 +- docs/api/wooldridge_etwfe.rst | 6 +- docs/choosing_estimator.rst | 12 +- docs/methodology/REGISTRY.md | 24 +- ...o-cattaneo-farrell-titiunik-2019-review.md | 2 +- .../papers/dechaisemartin-2026-review.md | 4 +- docs/practitioner_decision_tree.rst | 10 +- docs/r_comparison.rst | 4 +- docs/troubleshooting.rst | 26 +- docs/tutorials/16_wooldridge_etwfe.ipynb | 16 +- docs/tutorials/19_dcdh_marketing_pulse.ipynb | 4 +- docs/tutorials/20_had_brand_campaign.ipynb | 18 +- docs/tutorials/21_had_pretest_workflow.ipynb | 20 +- docs/tutorials/22_had_survey_design.ipynb | 64 +- docs/v4-deprecations.yaml | 162 ++-- tests/test_chaisemartin_dhaultfoeuille.py | 604 +++++++------- ...test_chaisemartin_dhaultfoeuille_parity.py | 30 +- tests/test_conley_vcov.py | 26 +- ...est_dcdh_bootstrap_cell_period_coverage.py | 2 +- tests/test_dcdh_cell_period_coverage.py | 2 +- ...dcdh_heterogeneity_cell_period_coverage.py | 2 +- tests/test_did_had_parity.py | 16 +- tests/test_etwfe_cs_stata_parity.py | 10 +- tests/test_event_study_surface.py | 4 +- tests/test_had.py | 36 +- tests/test_had_pretests.py | 112 +-- ...methodology_chaisemartin_dhaultfoeuille.py | 24 +- tests/test_methodology_had.py | 100 +-- tests/test_methodology_wooldridge.py | 160 ++-- tests/test_naming_guard.py | 4 + tests/test_rdd.py | 20 +- tests/test_rdd_methodology.py | 38 +- tests/test_rdd_parity.py | 2 +- tests/test_results_serialization.py | 4 +- tests/test_survey_dcdh.py | 148 ++-- tests/test_survey_dcdh_replicate_psu.py | 74 +- tests/test_t19_marketing_pulse_drift.py | 10 +- tests/test_t20_had_brand_campaign_drift.py | 18 +- tests/test_t21_had_pretest_workflow_drift.py | 28 +- tests/test_t22_had_survey_design_drift.py | 44 +- tests/test_target_parameter.py | 18 +- tests/test_v4_rename_mechanical.py | 743 ++++++++++++++++++ tests/test_variance_conventions.py | 2 +- tests/test_wooldridge.py | 346 ++++---- 65 files changed, 2613 insertions(+), 1288 deletions(-) create mode 100644 diff_diff/_deprecation.py create mode 100644 tests/test_v4_rename_mechanical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f4a5573..3ec596baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Deprecated +- **Mechanical rename wave** (v4 program 2(c)-ii, ledger rows [M-032] + [M-033] [M-034] [M-035]..[M-042] [M-088] [M-089] [M-094] [M-097]..[M-113] + [M-114]): the following parameters/fields gain their canonical names now, + with the old spellings kept as deprecated aliases that emit + `FutureWarning` and will be REMOVED in 4.0. The new name takes the old + name's signature position, so positional calls are unaffected, and the + deprecated path routes to bit-identical results. Passing both spellings + raises `ValueError`. + - `WooldridgeDiD.fit`: `cohort=` -> `first_treat=` [M-032]. + - `ChaisemartinDHaultfoeuille.fit`: `group=` -> `unit=` [M-033], + `controls=` -> `covariates=` [M-034]; `twowayfeweights()`: + `group=` -> `unit=` [M-097]. + - `ChaisemartinDHaultfoeuilleResults.groups` -> `.units` [M-114] + (deprecated read-only alias property; `repr` reports `n_units=`, + `summary()` prints "Units (post-filter):"; old pickles migrate via + `__setstate__`; the `n_groups_dropped_*` counters are unchanged, and + CS-family `groups` cohort vocabulary is NOT affected). + - `HeterogeneousAdoptionDiD.fit` and the `joint_pretrends_test` / + `joint_homogeneity_test` / `did_had_pretest_workflow` functions: + `outcome_col`/`dose_col`/`time_col`/`unit_col`/`first_treat_col` -> + bare `outcome`/`dose`/`time`/`unit`/`first_treat` + [M-035]..[M-039], [M-098]..[M-112]. + - `RegressionDiscontinuity.fit`: `outcome_col`/`running_col`/ + `treatment_col` -> `outcome`/`running`/`takeup` [M-040]..[M-042]; + `RegressionDiscontinuityResults.treatment_col` -> `.takeup` [M-094] + (alias property + `__setstate__`; `to_dict()` emits BOTH `"takeup"` + and the deprecated `"treatment_col"` key through 3.9, the old key is + dropped in 4.0). `RDPlot.fit`: `outcome_col`/`running_col` -> + `outcome`/`running` [M-088] [M-089]. + - `trim_weights()`: `weight_col=` -> `weights=` [M-113]. + - Note: sentinel-defaulted shim parameters are transitionally annotated + `Any`; the 4.0 removal restores the narrow annotations. + ### Changed - **Shared `BaseEstimator` mixin** (`diff_diff/_base.py`, v4 program 2(c)-i): the 25 hand-rolled `get_params`/`set_params` pairs (6 divergent diff --git a/README.md b/README.md index cb69a528e..8c203bf37 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility - [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves - [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`. -- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp, fuzzy, AND covariate-adjusted regression discontinuity with robust bias-corrected inference and rdrobust-parity bandwidth selection (all 10 selectors, mass-point handling; fuzzy via `treatment_col=` with a first-stage block and weak-identification warning; covariates via `covariates=` - CCFT 2019, same estimand, covariate-aware bandwidths). Canonical `att` is the bias-corrected estimate with a coherent robust CI (rdrobust's printed headline is `att_conventional`). Alias `RDD`. +- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Calonico, Cattaneo & Titiunik (2014) sharp, fuzzy, AND covariate-adjusted regression discontinuity with robust bias-corrected inference and rdrobust-parity bandwidth selection (all 10 selectors, mass-point handling; fuzzy via `takeup=` with a first-stage block and weak-identification warning; covariates via `covariates=` - CCFT 2019, same estimand, covariate-aware bandwidths). Canonical `att` is the bias-corrected estimate with a coherent robust CI (rdrobust's printed headline is `att_conventional`). Alias `RDD`. - [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html) - Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (Ustyuzhanin 2026) - [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html) - Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py new file mode 100644 index 000000000..08dd0bff1 --- /dev/null +++ b/diff_diff/_deprecation.py @@ -0,0 +1,164 @@ +"""Shared deprecation-shim machinery for the 3.9 rename sweep (4.0 program, Phase 2(c)-ii). + +This module is a deliberate LEAF: it imports only the standard library, never +other ``diff_diff`` modules, so every estimator module can import it without +cycles (the ``_base.py`` precedent). It is private and unexported; the 4.0 +removal PR deletes the shims that use it. + +The helpers implement the ledger's shim contract (``docs/v4-deprecations.yaml``, +groups ``renames-*``; rules in ``docs/v4-design.md`` section 8): + +- Renamed parameter: the NEW name occupies the old name's signature position, + the OLD name moves to the signature tail with the ``NOT_SUPPLIED`` sentinel + default. ``resolve_renamed_kwarg`` maps old to new with a single + ``FutureWarning``, rejects both-supplied calls loudly, and returns the + caller-declared default when neither is passed. +- Dropped parameter (no successor name, e.g. ``robust``): the parameter keeps + its position with a ``None`` sentinel default; the constructor calls + ``warn_deprecated_kwarg`` when a value was actually supplied. +- Renamed results field: the dataclass field takes the new name and + ``deprecated_field_property`` builds the read-only warning alias under the + old name (a plain class attribute, so it is a descriptor but NOT a + ``__dataclass_fields__`` entry - the shape ``tests/test_v4_matrix.py`` + asserts for ``shimmed`` field rows). + +Stacklevel contract: ``resolve_renamed_kwarg`` / ``warn_deprecated_kwarg`` +must be called DIRECTLY from the public method that owns the parameter +(user frame -> public method -> helper -> ``warnings.warn`` = stacklevel 3). +``deprecated_field_property``'s getter warns at stacklevel 2 (user frame -> +property getter). + +All warnings are ``FutureWarning`` (visible to end users by default) per the +section 2 category rule: new shims warn with ``FutureWarning``; only the +pre-existing M-001..M-003 sites keep ``DeprecationWarning``. +""" + +import warnings +from typing import Any + +__all__: "list[str]" = [] + + +class _NotSupplied: + """Sentinel for renamed/deprecated parameters (M-020 precedent). + + A plain ``None`` default cannot distinguish "not passed" from "passed + None", so a bare ``None`` default would fire the FutureWarning on EVERY + call (and break the warnings-as-errors ``cls(**est.get_params())`` + round-trip in ``tests/test_base_estimator.py``). The warning must fire + only when the caller actually supplies the argument. + """ + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return "" + + +NOT_SUPPLIED = _NotSupplied() + + +def deprecated_kwarg_message(qualname: str, name: str, instruction: str) -> str: + """The single message template every shim warning and test pins.""" + return f"{qualname}({name}=) is deprecated and will be removed in 4.0; {instruction}." + + +def warn_deprecated_kwarg( + qualname: str, name: str, instruction: str, *, stacklevel: int = 3 +) -> None: + """Emit the deprecation warning for a supplied deprecated parameter. + + The caller performs the was-it-supplied check (sentinel or ``None`` + depending on the parameter's legal value space) so that default and + round-trip constructions stay silent. + """ + warnings.warn( + deprecated_kwarg_message(qualname, name, instruction), + FutureWarning, + stacklevel=stacklevel, + ) + + +def resolve_renamed_kwarg( + qualname: str, + old_name: str, + old_value: Any, + new_name: str, + new_value: Any, + *, + default: Any = None, + extra: str = "", +) -> Any: + """Resolve a renamed keyword pair during the 3.9 shim window. + + Parameters + ---------- + qualname : str + The public surface the parameters belong to (``"Class.method"`` or + a module-level function name) - used verbatim in messages. + old_name, old_value : str, Any + The deprecated parameter and what the caller passed (``NOT_SUPPLIED`` + when absent). + new_name, new_value : str, Any + The canonical parameter and what the caller passed (``NOT_SUPPLIED`` + when absent). + default : Any + Returned when neither name was supplied. Required parameters pass + ``NOT_SUPPLIED`` here and re-validate with :func:`require_arg`. + extra : str + Appended to the warning message (e.g. M-031's note that ``time=`` + survives as the calendar column). + + Returns + ------- + Any + The effective value for the canonical parameter. + """ + old_passed = not isinstance(old_value, _NotSupplied) + new_passed = not isinstance(new_value, _NotSupplied) + if old_passed and new_passed: + raise ValueError( + f"{qualname}() got both {old_name}= (deprecated) and {new_name}=; " + f"pass only {new_name}=." + ) + if old_passed: + message = deprecated_kwarg_message(qualname, old_name, f"use {new_name}= instead") + if extra: + message = f"{message} {extra}" + warnings.warn(message, FutureWarning, stacklevel=3) + return old_value + if new_passed: + return new_value + return default + + +def require_arg(qualname: str, name: str, value: Any) -> None: + """Re-validate required-ness after the rename mapping. + + Sentinel-defaulted required parameters lose Python's built-in + missing-argument ``TypeError``; this restores it, naming the NEW + parameter. + """ + if isinstance(value, _NotSupplied): + raise TypeError(f"{qualname}() missing required argument: '{name}'") + + +def deprecated_field_property(cls_name: str, old: str, new: str) -> property: + """Build the read-only warning alias for a renamed results field. + + Assigned WITHOUT an annotation in the dataclass body, so it stays a + plain descriptor (never a ``__dataclass_fields__`` entry). Wording + follows the ``SyntheticDiDResults`` renamed-field alias precedent + (M-003, ``diff_diff/results.py``), with the FutureWarning category the + ledger declares for the M-094/M-095/M-114 rows. + """ + + def _get(self: Any) -> Any: + warnings.warn( + f"{cls_name}.{old} is deprecated; use {new} instead. " "Will be removed in 4.0.", + FutureWarning, + stacklevel=2, + ) + return getattr(self, new) + + _get.__name__ = old + _get.__doc__ = f"Deprecated alias for :attr:`{new}` (removed in 4.0)." + return property(_get) diff --git a/diff_diff/agent_workflow.py b/diff_diff/agent_workflow.py index 98f5886e9..7fcb2e9bd 100644 --- a/diff_diff/agent_workflow.py +++ b/diff_diff/agent_workflow.py @@ -163,7 +163,7 @@ def agent_workflow( # `first_treat` does not by itself identify which estimator to use: # CallawaySantAnna (binary staggered), ContinuousDiD (continuous- # dose with first_treat), and HeterogeneousAdoptionDiD event-study - # (heterogeneous intensity with first_treat_col) all accept it. + # (heterogeneous intensity with first_treat) all accept it. # Show CallawaySantAnna as the binary-staggered canonical example # and list the alternatives for continuous / heterogeneous designs # so an agent isn't steered to the wrong estimator. @@ -185,7 +185,8 @@ def agent_workflow( " - Continuous dose : ContinuousDiD (also takes first_treat=)", " - Heterogeneous adoption intensity:", " HeterogeneousAdoptionDiD (event study,", - " takes first_treat_col=, NOT first_treat=)", + " takes first_treat=; first_treat_col= is a", + " deprecated alias slated for 4.0 removal)", ] else: fit_example_kwargs = _join_kwargs( diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index 056d5e85c..2e611f235 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -37,6 +37,7 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.chaisemartin_dhaultfoeuille_bootstrap import ( ChaisemartinDHaultfoeuilleBootstrapMixin, ) @@ -385,7 +386,7 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE - Normalized estimator ``DID^n_l``, cost-benefit aggregate ``delta``, and sup-t simultaneous confidence bands - Residualization-style covariate adjustment (``DID^X``) via - ``controls=``, group-specific linear trends (``DID^{fd}``) via + ``covariates=``, group-specific linear trends (``DID^{fd}``) via ``trends_linear=True``, state-set-specific trends via ``trends_nonparam=``, heterogeneity testing, non-binary treatment, HonestDiD sensitivity integration on placebos via ``honest_did=True`` @@ -871,13 +872,13 @@ def fit( self, data: pd.DataFrame, outcome: str, - group: str, - time: str, - treatment: str, + unit: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + treatment: Any = NOT_SUPPLIED, # ---------- forward-compat parameters ---------- aggregate: Optional[str] = None, L_max: Optional[int] = None, - controls: Optional[List[str]] = None, + covariates: Any = NOT_SUPPLIED, trends_linear: Optional[bool] = None, trends_nonparam: Optional[Any] = None, honest_did: bool = False, @@ -886,6 +887,9 @@ def fit( design2: bool = False, # ---------- deferred (separate effort) ---------- survey_design: Any = None, + # ---------- deprecated aliases (removed in 4.0) ---------- + group: Any = NOT_SUPPLIED, + controls: Any = NOT_SUPPLIED, ) -> ChaisemartinDHaultfoeuilleResults: """ Fit the dCDH estimator on individual-level panel data. @@ -894,21 +898,21 @@ def fit( ---------- data : pd.DataFrame Individual-level panel. Must contain columns for ``outcome``, - ``group``, ``time``, and ``treatment``. The estimator - internally aggregates to ``(group, time)`` cells. + ``unit``, ``time``, and ``treatment``. The estimator + internally aggregates to ``(unit, time)`` cells. outcome : str Outcome variable column name. - group : str - Group identifier column name. Treatment must be constant - within each ``(group, time)`` cell after aggregation; - ``ValueError`` is raised if any cell has fractional - treatment after grouping (within-cell-varying treatment - indicates a fuzzy design not supported in Phase 1). + unit : str + Unit identifier column name (the R ``DIDmultiplegt`` "group"). + Treatment must be constant within each ``(unit, time)`` cell + after aggregation; ``ValueError`` is raised if any cell has + fractional treatment after grouping (within-cell-varying + treatment indicates a fuzzy design not supported in Phase 1). time : str Time period column name. Must be sortable. treatment : str Per-observation treatment column. Must be numeric and constant - within each ``(group, time)`` cell. Both binary ``{0, 1}`` and + within each ``(unit, time)`` cell. Both binary ``{0, 1}`` and non-binary (ordinal or continuous) treatment are supported. Non-binary treatment requires ``L_max >= 1``. aggregate : str, optional @@ -922,7 +926,7 @@ def fit( switch estimator ``DID_M`` is computed (Phase 1 behavior). Must be a positive integer not exceeding the number of post-baseline periods in the panel. - controls : list of str, optional + covariates : list of str, optional Column names for covariate adjustment via residualization-style ``DID^X`` (Web Appendix Section 1.2). Requires ``L_max >= 1``. One ``theta_hat`` per baseline treatment value, estimated by @@ -955,7 +959,7 @@ def fit( against ``did_multiplegt_dyn(by_path, predict_het, placebo)``). Joint Wald F-test across rows is NOT computed (per-horizon inference only). Cannot be combined with - ``controls``, ``trends_linear``, or ``trends_nonparam``. + ``covariates``, ``trends_linear``, or ``trends_nonparam``. Requires ``L_max >= 1``. Under ``by_path`` / ``paths_of_interest``, per-path heterogeneity coefficients also surface on ``results.path_heterogeneity_effects`` and @@ -1025,6 +1029,12 @@ def fit( closed-form; bootstrap would double-count variance). See REGISTRY.md ``ChaisemartinDHaultfoeuille`` Notes for the full contract. + group : str, optional + Deprecated alias for ``unit`` (row M-033); warns with + ``FutureWarning`` and will be removed in 4.0. + controls : list of str, optional + Deprecated alias for ``covariates`` (row M-034); warns with + ``FutureWarning`` and will be removed in 4.0. Returns ------- @@ -1039,6 +1049,29 @@ def fit( If any forward-compat parameter is set to a non-default value, with a clear pointer to the relevant ROADMAP phase. """ + unit = resolve_renamed_kwarg( + "ChaisemartinDHaultfoeuille.fit", + "group", + group, + "unit", + unit, + default=NOT_SUPPLIED, + ) + require_arg("ChaisemartinDHaultfoeuille.fit", "unit", unit) + require_arg("ChaisemartinDHaultfoeuille.fit", "time", time) + require_arg("ChaisemartinDHaultfoeuille.fit", "treatment", treatment) + covariates = resolve_renamed_kwarg( + "ChaisemartinDHaultfoeuille.fit", + "controls", + controls, + "covariates", + covariates, + default=None, + ) + # Body-local names; the public parameters are unit/covariates + # (M-033 / M-034). + group = unit + controls = covariates # ------------------------------------------------------------------ # Step 1: Column validation # ------------------------------------------------------------------ @@ -1314,8 +1347,8 @@ def fit( if controls is not None: if not controls: raise ValueError( - "controls must be a non-empty list of column names, " - "got an empty list. Pass controls=None to disable " + "covariates must be a non-empty list of column names, " + "got an empty list. Pass covariates=None to disable " "covariate adjustment." ) if L_max is None: @@ -1557,7 +1590,7 @@ def fit( if self.twfe_diagnostic and (n_groups_dropped_interior_gap + n_groups_dropped_crossers) > 0: warnings.warn( f"TWFE diagnostic sample-contract notice: the dCDH point " - f"estimate, results.groups, and inference fields use a " + f"estimate, results.units, and inference fields use a " f"POST-FILTER sample after Step 5b dropped " f"{n_groups_dropped_interior_gap} interior-gap group(s) " f"and Step 6 dropped {n_groups_dropped_crossers} multi-" @@ -3815,7 +3848,7 @@ def fit( # follows this restriction to avoid inconsistent behavior. if controls is not None: raise ValueError( - "heterogeneity cannot be combined with controls. " + "heterogeneity cannot be combined with covariates. " "R's did_multiplegt_dyn disallows predict_het with " "controls; remove one of the two options." ) @@ -4212,7 +4245,7 @@ def fit( placebo_conf_int=placebo_ci, placebo_available=placebo_available, per_period_effects=per_period_effects, - groups=all_groups, + units=all_groups, time_periods=all_periods, n_obs=n_obs_post, n_treated_obs=effective_n_treated, @@ -8710,7 +8743,7 @@ def chaisemartin_dhaultfoeuille( return est.fit( data, outcome=outcome, - group=group, + unit=group, time=time, treatment=treatment, **fit_kwargs, @@ -8720,11 +8753,12 @@ def chaisemartin_dhaultfoeuille( def twowayfeweights( data: pd.DataFrame, outcome: str, - group: str, - time: str, - treatment: str, + unit: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + treatment: Any = NOT_SUPPLIED, rank_deficient_action: str = "warn", survey_design: Any = None, + group: Any = NOT_SUPPLIED, ) -> TWFEWeightsResult: """ Standalone TWFE decomposition diagnostic. @@ -8739,7 +8773,8 @@ def twowayfeweights( data : pd.DataFrame Individual-level panel. outcome : str - group : str + unit : str + Unit identifier column (the R/Stata ``twowayfeweights`` "group"). time : str treatment : str rank_deficient_action : str, default="warn" @@ -8755,12 +8790,24 @@ def twowayfeweights( (aggregated numbers are identical to ``fit(..., survey_design=sd).twfe_*`` under the same input). + group : str, optional + Deprecated alias for ``unit`` (row M-097); warns with + ``FutureWarning`` and will be removed in 4.0. + Returns ------- TWFEWeightsResult Object with attributes ``weights`` (DataFrame), ``fraction_negative`` (float), ``sigma_fe`` (float), and ``beta_fe`` (float). """ + unit = resolve_renamed_kwarg( + "twowayfeweights", "group", group, "unit", unit, default=NOT_SUPPLIED + ) + require_arg("twowayfeweights", "unit", unit) + require_arg("twowayfeweights", "time", time) + require_arg("twowayfeweights", "treatment", treatment) + # Body-local name; the public parameter is unit (M-097). + group = unit # Survey resolution (optional): mirrors the fit() path so that the # standalone helper produces identical numbers to fit(..., survey_design=sd). survey_weights = None diff --git a/diff_diff/chaisemartin_dhaultfoeuille_results.py b/diff_diff/chaisemartin_dhaultfoeuille_results.py index 7aa94be53..443131723 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille_results.py +++ b/diff_diff/chaisemartin_dhaultfoeuille_results.py @@ -31,6 +31,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property from diff_diff.results import _get_significance_stars from diff_diff.results_base import BaseResults @@ -298,7 +299,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults): sample** passed by the user (the same input the standalone :func:`twowayfeweights` function uses) — NOT the post-filter estimation sample described by ``overall_att`` and - ``groups``. When ``fit()`` drops groups via the ragged-panel + ``units``. When ``fit()`` drops groups via the ragged-panel or ``drop_larger_lower`` filters, ``results.twfe_*`` and ``results.overall_att`` describe different samples and a ``UserWarning`` is emitted; see REGISTRY.md @@ -322,8 +323,10 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults): the two are computed on different samples when ``fit()`` filters drop groups — see the ``twfe_weights`` docstring above for the sample contract. - groups : list - Group identifiers in the post-filter sample. + units : list + Unit identifiers in the post-filter sample. (The deprecated + read-only alias ``groups`` warns and returns this list; removed + in 4.0 — row M-114.) time_periods : list Time periods in the panel. n_obs : int @@ -398,7 +401,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults): ``did_multiplegt_dyn`` provides no joint / sup-t bands. covariate_residuals : pd.DataFrame, optional ``DID^X`` first-stage diagnostics: per-baseline ``theta_hat``, - ``n_obs``, and ``r_squared``. Populated when ``controls`` is set. + ``n_obs``, and ``r_squared``. Populated when ``covariates`` is set. linear_trends_effects : dict, optional Cumulated ``DID^{fd}`` level effects ``delta^{fd}_l``. Keyed by horizon. Populated when ``trends_linear=True``. @@ -557,7 +560,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults): per_period_effects: Dict[Any, Dict[str, Any]] # --- Metadata --- - groups: List[Any] + units: List[Any] time_periods: List[Any] n_obs: int n_treated_obs: int @@ -680,6 +683,23 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults): # in the generated __init__ (the constructor signature is public API). event_study_df: Optional[float] = field(default=None, repr=False) + # Deprecated read-only alias for ``units`` (row M-114; removed in 4.0). + # No annotation, so it stays a descriptor and never becomes a + # __dataclass_fields__ entry. + groups = deprecated_field_property("ChaisemartinDHaultfoeuilleResults", "groups", "units") + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate pickles created before the ``groups`` -> ``units`` rename. + + Results pickled before row M-114 stored the list under ``groups``; + rewriting the key on load keeps both ``units`` and the deprecated + ``groups`` alias working on old pickles. + """ + if "groups" in state and "units" not in state: + state = dict(state) + state["units"] = state.pop("groups") + self.__dict__.update(state) + # ------------------------------------------------------------------ # Repr / properties # ------------------------------------------------------------------ @@ -784,7 +804,7 @@ def __repr__(self) -> str: f"ChaisemartinDHaultfoeuilleResults(" f"{label}={self.overall_att:.4f}{sig}, " f"SE={self.overall_se:.4f}, " - f"n_groups={len(self.groups)}, " + f"n_units={len(self.units)}, " f"n_switcher_cells={self.n_switcher_cells})" ) @@ -850,7 +870,7 @@ def summary(self, alpha: Optional[float] = None) -> str: if self.L_max is not None and self.L_max >= 1 else f"{'Switcher cells (N_S):':<35} {self.n_switcher_cells:>10}" ), - f"{'Groups (post-filter):':<35} {len(self.groups):>10}", + f"{'Units (post-filter):':<35} {len(self.units):>10}", f"{'Cohorts:':<35} {self.n_cohorts:>10}", f"{'Time periods:':<35} {len(self.time_periods):>10}", "", diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index b2384e8d2..9100a132c 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -355,7 +355,7 @@ supported / out of scope; `warn` supported but with documented caveats; | `StaggeredTripleDifference` | ✓ | ✓ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | | `ContinuousDiD` | ✗ | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ | | `HeterogeneousAdoptionDiD` | ✗ | partial | partial | ✗ | ✗ | ✗ | ✗ | ✓ | warn | -| `RegressionDiscontinuity` | ✗ (cross-sectional; sharp: treatment = running >= cutoff; fuzzy: observed take-up via `treatment_col=`) | ✗ | ✗ | ✗ | ✗ | ✓ (precision only - estimand unchanged, unlike the DiD conditional-PT role; `covariates=`) | ✗ | ✗ | ✗ (follow-up) | +| `RegressionDiscontinuity` | ✗ (cross-sectional; sharp: treatment = running >= cutoff; fuzzy: observed take-up via `takeup=`) | ✗ | ✗ | ✗ | ✗ | ✓ (precision only - estimand unchanged, unlike the DiD conditional-PT role; `covariates=`) | ✗ | ✗ | ✗ (follow-up) | **Footnotes.** - `TwoWayFixedEffects` + staggered: fits but mixes positive and negative @@ -400,10 +400,10 @@ supported / out of scope; `warn` supported but with documented caveats; - `HeterogeneousAdoptionDiD` staggered support is `partial`, not general. Paper Appendix B.2 restricts staggered use to the **last treatment cohort plus never-treated units**. With - `aggregate="event_study"` and a `first_treat_col` kwarg, + `aggregate="event_study"` and a `first_treat` kwarg, `fit()` auto-filters to `F_last = max(cohorts)` and emits a `UserWarning` naming kept/dropped counts; earlier-cohort units - are dropped. Without `first_treat_col`, a multi-cohort panel + are dropped. Without `first_treat`, a multi-cohort panel raises `ValueError`. For full staggered support that retains every cohort, use `ChaisemartinDHaultfoeuille` instead. @@ -689,7 +689,7 @@ intensity of exposure differs): auto-filters to the last treatment cohort (`F_last = max(cohorts)`) plus never-treated units and emits a `UserWarning` naming kept/dropped counts; earlier treated - cohorts are dropped. The `first_treat_col` kwarg is + cohorts are dropped. The `first_treat` kwarg is **required** for the auto-filter to activate; without it a multi-cohort panel raises `ValueError` pointing the caller at `ChaisemartinDHaultfoeuille` for full staggered support. The @@ -1320,7 +1320,7 @@ This guide does **not**: libraries. Regression discontinuity IS in scope - sharp, fuzzy, AND covariate-adjusted: route running-variable/threshold designs to `RegressionDiscontinuity` (alias `RDD`); imperfect compliance at the - threshold is the fuzzy design (`fit(..., treatment_col=...)`, local + threshold is the fuzzy design (`fit(..., takeup=...)`, local Wald ratio with a first-stage block); precision covariates go in via `fit(..., covariates=[...])` (same estimand - check covariate balance first by fitting each covariate as the outcome). Cluster-robust RD diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 7b4926be9..0339c3745 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -283,14 +283,14 @@ ChaisemartinDHaultfoeuille( est.fit( data: pd.DataFrame, outcome: str, - group: str, # Group identifier + unit: str, # Unit (group) identifier time: str, treatment: str, # Per-observation binary treatment or non-binary intensity # ---- multi-horizon ---- L_max: int | None = None, # Max horizon; None = l=1 only # ---- covariates and extensions ---- aggregate: str | None = None, # Reserved; raises NotImplementedError - controls: list[str] | None = None, # DID^X residualization-style covariates + covariates: list[str] | None = None, # DID^X residualization-style covariates trends_linear: bool | None = None, # DID^{fd} group-specific linear trends trends_nonparam: Any | None = None, # DID^s state-set-specific trends honest_did: bool = False, # HonestDiD sensitivity on placebos @@ -299,7 +299,7 @@ est.fit( ) -> ChaisemartinDHaultfoeuilleResults ``` -`L_max` controls multi-horizon computation. `controls`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported; only `aggregate` still raises `NotImplementedError`. +`L_max` controls multi-horizon computation. `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported; only `aggregate` still raises `NotImplementedError`. The 3.8 names `group=` and `controls=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). **Usage:** @@ -313,7 +313,7 @@ data = generate_reversible_did_data( est = ChaisemartinDHaultfoeuille() results = est.fit( - data, outcome="outcome", group="group", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) results.print_summary() @@ -325,7 +325,7 @@ print(f"DID_- (leavers): {results.leavers_att:.3f}") print(f"Placebo (DID^pl): {results.placebo_effect:.3f}") # Multi-horizon event study -results = est.fit(data, outcome="outcome", group="group", +results = est.fit(data, outcome="outcome", unit="group", time="period", treatment="treatment", L_max=3) for h in sorted(results.event_study_effects): e = results.event_study_effects[h] @@ -340,7 +340,7 @@ df = results.to_dataframe("event_study") # includes placebos as negative horizo from diff_diff import twowayfeweights diagnostic = twowayfeweights( - data, outcome="outcome", group="group", time="period", treatment="treatment", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) print(f"Plain TWFE coefficient: {diagnostic.beta_fe:.3f}") print(f"Fraction of negative weights: {diagnostic.fraction_negative:.3f}") @@ -801,17 +801,18 @@ HeterogeneousAdoptionDiD( ```python had.fit( data: pd.DataFrame, - outcome_col: str, - dose_col: str, - time_col: str, - unit_col: str, - first_treat_col: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) + outcome: str, + dose: str, + time: str, + unit: str, + first_treat: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) aggregate: str = "overall", # "overall" (single scalar WAS) or "event_study" (per-horizon WAS) *, cband: bool = True, # Simultaneous (sup-t) confidence bands on event-study fits that are survey-weighted OR clustered (keyword-only) survey_design: SurveyDesign | None = None, # Survey-design kwarg (weights, strata, PSU, FPC) — the sole weighting entry trends_lin: bool = False, # Eq 17 linear-trend detrending. Requires aggregate="event_study"; needs F>=3 (pre-period depth) for the regression; rejects survey_design= (raises NotImplementedError under trends_lin). covariates: Any | None = None, # NOT IMPLEMENTED — non-None raises NotImplementedError (deferred Appendix B.1 / Theorem 6 covariate-adjusted extension; pre-residualize the outcome on covariates as a workaround) + outcome_col: str = ..., dose_col: str = ..., time_col: str = ..., unit_col: str = ..., first_treat_col: str = ..., # deprecated aliases for the five column params (FutureWarning; removed in 4.0) ) -> HeterogeneousAdoptionDiDResults | HeterogeneousAdoptionDiDEventStudyResults ``` @@ -827,21 +828,21 @@ from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow # Vet the testable identifying assumptions on the two-period panel first: report = did_had_pretest_workflow( - data_2p, outcome_col='y', unit_col='unit', time_col='t', - dose_col='d', first_treat_col='first_treat') + data_2p, outcome='y', unit='unit', time='t', + dose='d', first_treat='first_treat') print(report.summary()) # Single-period scalar WAS (aggregate="overall" default) on the two-period panel: est = HeterogeneousAdoptionDiD() -results = est.fit(data_2p, outcome_col='y', unit_col='unit', - time_col='t', dose_col='d', - first_treat_col='first_treat') +results = est.fit(data_2p, outcome='y', unit='unit', + time='t', dose='d', + first_treat='first_treat') print(results.summary()) # Multi-period per-horizon WAS on the multi-period panel: -es = est.fit(data_mp, outcome_col='y', unit_col='unit', - time_col='t', dose_col='d', - first_treat_col='first_treat', +es = est.fit(data_mp, outcome='y', unit='unit', + time='t', dose='d', + first_treat='first_treat', aggregate='event_study') ``` @@ -851,7 +852,7 @@ es = est.fit(data_mp, outcome_col='y', unit_col='unit', ### RegressionDiscontinuity -Regression discontinuity estimator - sharp and fuzzy, with optional covariate adjustment (Calonico, Cattaneo & Titiunik 2014; covariates per Calonico, Cattaneo, Farrell & Titiunik 2019), parity-targeting R rdrobust 4.0.0. SHARP (default): treatment is assigned by a known threshold of an observed running variable (`running >= cutoff`; units exactly at the cutoff are treated); no treatment column. FUZZY: pass the OBSERVED take-up column via `fit(..., treatment_col=...)` (R's `fuzzy=`) - the estimand becomes the local Wald ratio (complier LATE at the cutoff for BINARY take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which) with a linearized bias correction, and the results gain a full `first_stage*` three-row block. Point estimation via kernel-weighted local polynomials on each side; data-driven MSE/CER-optimal bandwidths (all 10 rdrobust selectors; fuzzy selects on the ratio objective by default, with a sharp-on-Y switch under one-sided perfect compliance or `sharpbw=True`); robust bias-corrected inference. COVARIATE ADJUSTMENT: pass `fit(..., covariates=[...])` (R's `covs=`) for the CCFT 2019 additive common-coefficient adjustment - the estimand is UNCHANGED (precision only, unlike the DiD estimators' conditional-parallel-trends role); requires covariate BALANCE at the cutoff (testable: fit each covariate as the outcome and inspect its RD p-value); bandwidths are covariate-aware. Cross-sectional - no panel/time dimension. +Regression discontinuity estimator - sharp and fuzzy, with optional covariate adjustment (Calonico, Cattaneo & Titiunik 2014; covariates per Calonico, Cattaneo, Farrell & Titiunik 2019), parity-targeting R rdrobust 4.0.0. SHARP (default): treatment is assigned by a known threshold of an observed running variable (`running >= cutoff`; units exactly at the cutoff are treated); no treatment column. FUZZY: pass the OBSERVED take-up column via `fit(..., takeup=...)` (R's `fuzzy=`) - the estimand becomes the local Wald ratio (complier LATE at the cutoff for BINARY take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which) with a linearized bias correction, and the results gain a full `first_stage*` three-row block. Point estimation via kernel-weighted local polynomials on each side; data-driven MSE/CER-optimal bandwidths (all 10 rdrobust selectors; fuzzy selects on the ratio objective by default, with a sharp-on-Y switch under one-sided perfect compliance or `sharpbw=True`); robust bias-corrected inference. COVARIATE ADJUSTMENT: pass `fit(..., covariates=[...])` (R's `covs=`) for the CCFT 2019 additive common-coefficient adjustment - the estimand is UNCHANGED (precision only, unlike the DiD estimators' conditional-parallel-trends role); requires covariate BALANCE at the cutoff (testable: fit each covariate as the outcome and inspect its RD p-value); bandwidths are covariate-aware. Cross-sectional - no panel/time dimension. ```python RegressionDiscontinuity( @@ -882,25 +883,27 @@ RegressionDiscontinuity( ```python rd.fit( data: pd.DataFrame, - outcome_col: str, - running_col: str, - treatment_col: str | None = None, # None = sharp; a column name = fuzzy (observed take-up; any numeric, typically binary) + outcome: str, + running: str, + takeup: str | None = None, # None = sharp; a column name = fuzzy (observed take-up; any numeric, typically binary) covariates: list[str] | None = None, # Pre-determined covariate columns (R's covs=); additive common-coefficient adjustment, SAME estimand ) -> RegressionDiscontinuityResults ``` +The 3.8 names `outcome_col=`, `running_col=`, and `treatment_col=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). + ```python from diff_diff import RegressionDiscontinuity rd = RegressionDiscontinuity(cutoff=0.0) -results = rd.fit(df, outcome_col="y", running_col="score") +results = rd.fit(df, outcome="y", running="score") results.att # ROBUST bias-corrected estimate (canonical binding: t_stat == att/se, conf_int centered on att) results.att_conventional # rdrobust's printed headline coefficient (conventional local-polynomial estimate) results.conf_int # robust bias-corrected CI results.h_left, results.b_left # selected bandwidths print(results.summary()) # three-row Conventional / Bias-Corrected / Robust table, as in rdrobust -fuzzy = rd.fit(df, "y", "score", treatment_col="takeup") # fuzzy RD +fuzzy = rd.fit(df, "y", "score", takeup="takeup") # fuzzy RD fuzzy.att # linearized bias-corrected local Wald ratio, robust row (complier LATE for binary take-up) fuzzy.first_stage # take-up jump (bias-corrected; full three-row first_stage* mirror available) fuzzy.estimand # "fuzzy (LATE for compliers at the cutoff)" (binary take-up) / "fuzzy (local Wald ratio at the cutoff; non-binary take-up)" / "sharp (ATE at the cutoff)" @@ -960,17 +963,19 @@ RDPlot( ```python rp.fit( data: pd.DataFrame, - outcome_col: str, - running_col: str, + outcome: str, + running: str, covariates: list[str] | None = None, # Covariate-adjusted plot (R's covs=, covs_eval="mean"); gammas exposed as covariate_coefficients ) -> RDPlotResult ``` +The 3.8 names `outcome_col=` and `running_col=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). + ```python from diff_diff import RDPlot rp = RDPlot(cutoff=0.0) -res = rp.fit(df, outcome_col="y", running_col="score") +res = rp.fit(df, outcome="y", running="score") res.J, res.J_IMSE, res.J_MV # selected / IMSE-optimal / mimicking-variance bins per side res.rscale # implied scale; WIMSE weights via res.wimse_variance_weight / res.wimse_bias_weight res.to_dataframe() # vars_bins: R-named per-bin columns (rdplot_mean_bin/mean_x/mean_y/min_bin/max_bin/se_y/N/ci_l/ci_r) @@ -1408,13 +1413,15 @@ etwfe.fit( outcome: str, unit: str, time: str, - cohort: str, # First treatment period (0 or NaN = never treated) + first_treat: str, # First treatment period (0 or NaN = never treated) exovar: list[str] | None = None, # Time-invariant covariates (no interaction/demeaning) xtvar: list[str] | None = None, # Time-varying covariates (demeaned within cohort*period) xgvar: list[str] | None = None, # Covariates interacted with each cohort indicator ) -> WooldridgeDiDResults ``` +The 3.8 name `cohort=` remains accepted as a deprecated alias for `first_treat=` (FutureWarning; removed in 4.0). + **Aggregation types:** ```python @@ -1434,16 +1441,16 @@ from diff_diff import WooldridgeDiD # level) parallel trends, and less biased/more precise in Wooldridge (2023) sims; # a different identifying assumption, so a recommended comparison not a free switch. etwfe = WooldridgeDiD(method='ols') -results = etwfe.fit(data, outcome='y', unit='id', time='t', cohort='first_treat') +results = etwfe.fit(data, outcome='y', unit='id', time='t', first_treat='first_treat') print(results.aggregate("simple")) # Logit (binary outcomes) etwfe_logit = WooldridgeDiD(method='logit') -results = etwfe_logit.fit(data, outcome='y_bin', unit='id', time='t', cohort='first_treat') +results = etwfe_logit.fit(data, outcome='y_bin', unit='id', time='t', first_treat='first_treat') # Poisson (count outcomes) etwfe_pois = WooldridgeDiD(method='poisson') -results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', cohort='first_treat') +results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', first_treat='first_treat') ``` ### Convenience Functions @@ -2005,8 +2012,8 @@ from diff_diff import ( # aggregate="event_study" -> steps 1 + 2 + 3 (QUG + joint Stute pre-trends + # joint homogeneity-linearity Stute) on multi-period panels. report = did_had_pretest_workflow( - data, outcome_col='y', unit_col='unit', time_col='t', - dose_col='d', first_treat_col='first_treat', + data, outcome='y', unit='unit', time='t', + dose='d', first_treat='first_treat', aggregate='overall', survey_design=None) # SurveyDesign for survey-aware pretests (Phase 4.5 C) print(report.summary()) diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 7b0b39f96..afe6c5515 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -325,7 +325,7 @@ from diff_diff import ChaisemartinDHaultfoeuille, SurveyDesign sd = SurveyDesign(weights='pw', strata='stratum', psu='cluster', nest=True) results = ChaisemartinDHaultfoeuille().fit( - data, outcome='y', group='unit_id', time='period', + data, outcome='y', unit='unit_id', time='period', treatment='treated', L_max=3, # multi-horizon event study survey_design=sd, # survey-aware analytical SE (TSL) diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index b2cc2d934..42731e296 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -71,7 +71,7 @@ The site is organized into 5 sections, each with a landing page: - [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html): Triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility - [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html): Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves - [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html): de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲=0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲>0`, continuous-near-d̲ or mass-point), with multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release (repeated cross-sections rejected by the validator). Alias `HAD`. -- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Calonico, Cattaneo & Titiunik (2014) sharp AND fuzzy regression discontinuity with robust bias-corrected inference, parity-targeting R rdrobust 4.0.0 (all 10 data-driven bandwidth selectors, mass-point handling, three-row conventional/bias-corrected/robust output; canonical `att` = the bias-corrected estimate with a coherent robust CI - rdrobust's printed headline is `att_conventional`). Fuzzy via `fit(..., treatment_col=...)`: local Wald ratio (complier LATE for binary take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which), first-stage `first_stage*` block, weak-first-stage warning. Covariate adjustment via `fit(..., covariates=[...])` (CCFT 2019 additive common-coefficient, R's `covs=`): SAME estimand, precision only; requires covariate balance at the cutoff (testable: fit each covariate as the outcome); covariate-aware bandwidths; collinear columns dropped with a warning (`covs_drop`). Cluster-robust variance is a documented follow-up. Alias `RDD`. +- [RegressionDiscontinuity](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Calonico, Cattaneo & Titiunik (2014) sharp AND fuzzy regression discontinuity with robust bias-corrected inference, parity-targeting R rdrobust 4.0.0 (all 10 data-driven bandwidth selectors, mass-point handling, three-row conventional/bias-corrected/robust output; canonical `att` = the bias-corrected estimate with a coherent robust CI - rdrobust's printed headline is `att_conventional`). Fuzzy via `fit(..., takeup=...)`: local Wald ratio (complier LATE for binary take-up under monotonicity; ratio-of-jumps otherwise - the `estimand` field says which), first-stage `first_stage*` block, weak-first-stage warning. Covariate adjustment via `fit(..., covariates=[...])` (CCFT 2019 additive common-coefficient, R's `covs=`): SAME estimand, precision only; requires covariate balance at the cutoff (testable: fit each covariate as the outcome); covariate-aware bandwidths; collinear columns dropped with a warning (`covs_drop`). Cluster-robust variance is a documented follow-up. Alias `RDD`. - [StackedDiD](https://diff-diff.readthedocs.io/en/stable/api/stacked_did.html): Wing, Freedman & Hollingsworth (2024) stacked DiD with Q-weights and sub-experiments; optional covariate balancing (`balance="entropy"`, Ustyuzhanin 2026) - [EfficientDiD](https://diff-diff.readthedocs.io/en/stable/api/efficient_did.html): Chen, Sant'Anna & Xie (2025) efficient DiD with optimal weighting for tighter SEs - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html): Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment (absorbing by default; `non_absorbing=True` for on/off treatment, method='local') diff --git a/diff_diff/had.py b/diff_diff/had.py index a18aa7677..1f47fca07 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -72,6 +72,7 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.bootstrap_chunking import ( compute_block_size, iter_survey_multiplier_weight_blocks, @@ -1104,7 +1105,7 @@ def _validate_had_panel( if bool(ft_raw.isna().any()): n_nan = int(ft_raw.isna().sum()) raise ValueError( - f"first_treat_col={first_treat_col!r} contains " + f"first_treat={first_treat_col!r} contains " f"{n_nan} NaN value(s) at the row level. Use 0 for " f"never-treated units and t_post for treated, and drop " f"or impute any NaN rows before calling fit()." @@ -1117,7 +1118,7 @@ def _validate_had_panel( bad = sorted(observed_raw - valid_values, key=lambda x: str(x)) if bad: raise ValueError( - f"first_treat_col={first_treat_col!r} contains value(s) " + f"first_treat={first_treat_col!r} contains value(s) " f"{bad} outside the allowed set {{0, {t_post!r}}} for a " f"two-period HAD panel. Staggered timing with multiple " f"cohorts is Phase 2b." @@ -1130,7 +1131,7 @@ def _validate_had_panel( if (ft_per_unit_nunique > 1).any(): n_bad = int((ft_per_unit_nunique > 1).sum()) raise ValueError( - f"first_treat_col={first_treat_col!r} is not constant " + f"first_treat={first_treat_col!r} is not constant " f"within unit for {n_bad} unit(s). Each unit must have " f"a single first_treat value across both observed periods." ) @@ -1228,12 +1229,12 @@ def _validate_had_panel_event_study( ): raise ValueError( f"HAD aggregate='event_study' requires an ordered time " - f"column. time_col={time_col!r} has dtype={time_dtype!r}, " + f"column. time={time_col!r} has dtype={time_dtype!r}, " f"which has no defined chronological order; raw sort would " f"fall back to lexicographic ordering and silently misindex " f"event-time horizons (e.g., 'pre1'/'pre2'/'post1'/'post2' " f"sorts lexicographically but not chronologically). " - f"Convert time_col to numeric (e.g., integer year), " + f"Convert the time column to numeric (e.g., integer year), " f"datetime, or ordered categorical " f"(``pd.Categorical(..., ordered=True, categories=[...])``) " f"before calling fit() with aggregate='event_study'." @@ -1276,7 +1277,7 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: if bool(ft_raw.isna().any()): n_nan = int(ft_raw.isna().sum()) raise ValueError( - f"first_treat_col={first_treat_col!r} contains " + f"first_treat={first_treat_col!r} contains " f"{n_nan} NaN value(s) at the row level. Use 0 for " f"never-treated units and the treatment-start period " f"for treated units. Drop or impute any NaN rows " @@ -1287,7 +1288,7 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: if (ft_per_unit_nunique > 1).any(): n_bad = int((ft_per_unit_nunique > 1).sum()) raise ValueError( - f"first_treat_col={first_treat_col!r} is not constant " + f"first_treat={first_treat_col!r} is not constant " f"within unit for {n_bad} unit(s). Each unit must have " f"a single first_treat value across all observed periods." ) @@ -1320,13 +1321,13 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: if n_mismatch > 0: u, declared, actual = example_mismatch # type: ignore[misc] raise ValueError( - f"first_treat_col={first_treat_col!r} disagrees with the " + f"first_treat={first_treat_col!r} disagrees with the " f"observed dose path for {n_mismatch} unit(s). Example: " f"unit={u!r} declares first_treat={declared!r} but the " f"unit's first period with D>0 is {actual!r} " f"(None means never-treated). A mislabeled cohort column " f"would silently select the wrong cohort as F_last in the " - f"last-cohort auto-filter. Fix the first_treat_col values " + f"last-cohort auto-filter. Fix the first_treat values " f"to equal each unit's first positive-dose period (or 0 " f"for never-treated) before calling fit()." ) @@ -1529,12 +1530,12 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: # Sort chronologically via the validated time-column order. distinct_cohorts = sorted(cohort_labels, key=_sort_key) raise ValueError( - f"Staggered-timing panel detected (first_treat_col is " + f"Staggered-timing panel detected (first_treat= is " f"None): {len(distinct_cohorts)} distinct first-positive-" f"dose periods {distinct_cohorts!r} across units. HAD's " f"last-cohort auto-filter (paper Appendix B.2) only runs " - f"when first_treat_col is supplied so the estimator can " - f"identify cohorts. Pass first_treat_col= to " + f"when first_treat= is supplied so the estimator can " + f"identify cohorts. Pass first_treat= to " f"enable the auto-filter to the last cohort, or use " f"ChaisemartinDHaultfoeuille (did_multiplegt_dyn) for " f"full staggered support." @@ -2820,8 +2821,8 @@ class HeterogeneousAdoptionDiD(BaseEstimator): ... }) >>> est = HeterogeneousAdoptionDiD(design="auto") # doctest: +SKIP >>> result = est.fit( # doctest: +SKIP - ... data, outcome_col="outcome", dose_col="dose", - ... time_col="period", unit_col="unit", + ... data, outcome="outcome", dose="dose", + ... time="period", unit="unit", ... ) >>> result.design # doctest: +SKIP 'continuous_at_zero' @@ -2918,17 +2919,22 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome_col: str, - dose_col: str, - time_col: str, - unit_col: str, - first_treat_col: Optional[str] = None, + outcome: Any = NOT_SUPPLIED, + dose: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, + first_treat: Any = NOT_SUPPLIED, aggregate: str = "overall", *, cband: bool = True, survey_design: Any = None, trends_lin: bool = False, covariates: Any = None, + outcome_col: Any = NOT_SUPPLIED, + dose_col: Any = NOT_SUPPLIED, + time_col: Any = NOT_SUPPLIED, + unit_col: Any = NOT_SUPPLIED, + first_treat_col: Any = NOT_SUPPLIED, ) -> Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]: """Fit the HAD estimator. @@ -2955,9 +2961,9 @@ def fit( Parameters ---------- data : pd.DataFrame - outcome_col, dose_col, time_col, unit_col : str + outcome, dose, time, unit : str Column names. - first_treat_col : str or None + first_treat : str or None Optional first-treatment column (the period at which each unit first receives treatment; ``0`` for never-treated). For common-adoption panels the column is optional; when @@ -2965,13 +2971,13 @@ def fit( period ``F`` from the dose invariant. **Staggered-timing contract (HAD Appendix B.2):** - - **`first_treat_col` supplied + multiple cohorts detected**: + - **`first_treat` supplied + multiple cohorts detected**: auto-filter to the last-treatment cohort + never-treated units with a ``UserWarning`` naming kept / dropped counts. - - **`first_treat_col` omitted + multiple distinct first- + - **`first_treat` omitted + multiple distinct first- positive-dose cohorts inferred from the dose path**: the estimator FAIL-CLOSES with ``ValueError`` directing the - user to either pass ``first_treat_col`` (activates the + user to either pass ``first_treat`` (activates the auto-filter) or use :class:`ChaisemartinDHaultfoeuille` (``did_multiplegt_dyn``) for full staggered support. See REGISTRY § "Library extension: Staggered-timing fail- @@ -2986,7 +2992,7 @@ def fit( Appendix B.2). Requires more than two time periods. Pointwise CIs per horizon; joint cross-horizon covariance is deferred to a follow-up PR. Staggered-timing panels: see the - ``first_treat_col`` contract above (auto-filter to last + ``first_treat`` contract above (auto-filter to last cohort + never-treated with ``UserWarning`` when supplied; fail-closed ``ValueError`` when omitted on a staggered panel). @@ -3072,7 +3078,49 @@ def fit( for which a standard DiD may be more appropriate (de Chaisemartin et al. 2026, Section 2). The event-study path does not warn: it *requires* never-treated units per Appendix B.2. + + The keyword-only ``outcome_col`` / ``dose_col`` / ``time_col`` / + ``unit_col`` / ``first_treat_col`` parameters are deprecated + aliases for the bare names (rows M-035..M-039); each warns with + ``FutureWarning`` and will be removed in 4.0. """ + qualname = "HeterogeneousAdoptionDiD.fit" + outcome = resolve_renamed_kwarg( + qualname, + "outcome_col", + outcome_col, + "outcome", + outcome, + default=NOT_SUPPLIED, + ) + require_arg(qualname, "outcome", outcome) + dose = resolve_renamed_kwarg( + qualname, "dose_col", dose_col, "dose", dose, default=NOT_SUPPLIED + ) + require_arg(qualname, "dose", dose) + time = resolve_renamed_kwarg( + qualname, "time_col", time_col, "time", time, default=NOT_SUPPLIED + ) + require_arg(qualname, "time", time) + unit = resolve_renamed_kwarg( + qualname, "unit_col", unit_col, "unit", unit, default=NOT_SUPPLIED + ) + require_arg(qualname, "unit", unit) + first_treat = resolve_renamed_kwarg( + qualname, + "first_treat_col", + first_treat_col, + "first_treat", + first_treat, + default=None, + ) + # Body-local names; the public parameters are the bare names + # (M-035..M-039). + outcome_col = outcome + dose_col = dose + time_col = time + unit_col = unit + first_treat_col = first_treat # ---- aggregate / survey_design validation ---- if aggregate not in _VALID_AGGREGATES: raise ValueError( diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 49f87c411..381ce9719 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -76,6 +76,7 @@ import pandas as pd from scipy import stats +from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.bootstrap_utils import ( apply_stratum_centering, generate_survey_multiplier_weights_batch, @@ -3372,19 +3373,24 @@ def _resolve_pretest_unit_weights( def joint_pretrends_test( data: pd.DataFrame, - outcome_col: str, - dose_col: str, - time_col: str, - unit_col: str, - pre_periods: list, - base_period: Any, - first_treat_col: Optional[str] = None, + outcome: Any = NOT_SUPPLIED, + dose: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, + pre_periods: Any = NOT_SUPPLIED, + base_period: Any = NOT_SUPPLIED, + first_treat: Any = NOT_SUPPLIED, *, alpha: float = 0.05, n_bootstrap: int = 999, seed: Optional[int] = None, survey_design: Any = None, trends_lin: bool = False, + outcome_col: Any = NOT_SUPPLIED, + dose_col: Any = NOT_SUPPLIED, + time_col: Any = NOT_SUPPLIED, + unit_col: Any = NOT_SUPPLIED, + first_treat_col: Any = NOT_SUPPLIED, ) -> StuteJointResult: """Joint Stute pre-trends test (paper Section 4.2 step 2). @@ -3404,7 +3410,7 @@ def joint_pretrends_test( Parameters ---------- data : pd.DataFrame - outcome_col, dose_col, time_col, unit_col : str + outcome, dose, time, unit : str pre_periods : list Non-empty list of pre-period labels (all ``< base_period``, all with ``D = 0`` across every unit). Empty list raises; the @@ -3416,7 +3422,7 @@ def joint_pretrends_test( satisfy ``D = 0`` across every unit (reciprocal of the pre-period HAD invariant - base is itself a pre-period in the four-step workflow). - first_treat_col : str or None + first_treat : str or None Forwarded to the underlying panel validator; matched cohort handling follows the HAD contract (staggered auto-filter warns and proceeds on last cohort; solo cohort proceeds). @@ -3454,6 +3460,28 @@ def joint_pretrends_test( ------- StuteJointResult with ``null_form = "mean_independence"``. """ + _q = "joint_pretrends_test" + outcome = resolve_renamed_kwarg( + _q, "outcome_col", outcome_col, "outcome", outcome, default=NOT_SUPPLIED + ) + require_arg(_q, "outcome", outcome) + dose = resolve_renamed_kwarg(_q, "dose_col", dose_col, "dose", dose, default=NOT_SUPPLIED) + require_arg(_q, "dose", dose) + time = resolve_renamed_kwarg(_q, "time_col", time_col, "time", time, default=NOT_SUPPLIED) + require_arg(_q, "time", time) + unit = resolve_renamed_kwarg(_q, "unit_col", unit_col, "unit", unit, default=NOT_SUPPLIED) + require_arg(_q, "unit", unit) + require_arg(_q, "pre_periods", pre_periods) + require_arg(_q, "base_period", base_period) + first_treat = resolve_renamed_kwarg( + _q, "first_treat_col", first_treat_col, "first_treat", first_treat, default=None + ) + # Body-local names; the public parameters are the bare names. + outcome_col = outcome + dose_col = dose + time_col = time + unit_col = unit + first_treat_col = first_treat # Internal variable naming: downstream code reads `survey` (Phase 4.5 C # convention). survey = survey_design @@ -3758,19 +3786,24 @@ def joint_pretrends_test( def joint_homogeneity_test( data: pd.DataFrame, - outcome_col: str, - dose_col: str, - time_col: str, - unit_col: str, - post_periods: list, - base_period: Any, - first_treat_col: Optional[str] = None, + outcome: Any = NOT_SUPPLIED, + dose: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, + post_periods: Any = NOT_SUPPLIED, + base_period: Any = NOT_SUPPLIED, + first_treat: Any = NOT_SUPPLIED, *, alpha: float = 0.05, n_bootstrap: int = 999, seed: Optional[int] = None, survey_design: Any = None, trends_lin: bool = False, + outcome_col: Any = NOT_SUPPLIED, + dose_col: Any = NOT_SUPPLIED, + time_col: Any = NOT_SUPPLIED, + unit_col: Any = NOT_SUPPLIED, + first_treat_col: Any = NOT_SUPPLIED, ) -> StuteJointResult: """Joint Stute homogeneity-linearity test (paper Section 4.3 joint). @@ -3791,7 +3824,7 @@ def joint_homogeneity_test( Parameters ---------- data : pd.DataFrame - outcome_col, dose_col, time_col, unit_col : str + outcome, dose, time, unit : str post_periods : list Non-empty list of post-period labels (all strictly ``> base_period`` by chronological order; each with ``D > 0`` for @@ -3826,6 +3859,28 @@ def joint_homogeneity_test( ------- StuteJointResult with ``null_form = "linearity"``. """ + _q = "joint_homogeneity_test" + outcome = resolve_renamed_kwarg( + _q, "outcome_col", outcome_col, "outcome", outcome, default=NOT_SUPPLIED + ) + require_arg(_q, "outcome", outcome) + dose = resolve_renamed_kwarg(_q, "dose_col", dose_col, "dose", dose, default=NOT_SUPPLIED) + require_arg(_q, "dose", dose) + time = resolve_renamed_kwarg(_q, "time_col", time_col, "time", time, default=NOT_SUPPLIED) + require_arg(_q, "time", time) + unit = resolve_renamed_kwarg(_q, "unit_col", unit_col, "unit", unit, default=NOT_SUPPLIED) + require_arg(_q, "unit", unit) + require_arg(_q, "post_periods", post_periods) + require_arg(_q, "base_period", base_period) + first_treat = resolve_renamed_kwarg( + _q, "first_treat_col", first_treat_col, "first_treat", first_treat, default=None + ) + # Body-local names; the public parameters are the bare names. + outcome_col = outcome + dose_col = dose + time_col = time + unit_col = unit + first_treat_col = first_treat # Internal variable naming: downstream code reads `survey` (Phase 4.5 C # convention). survey = survey_design @@ -4192,11 +4247,11 @@ def _compose_verdict_event_study_survey( def did_had_pretest_workflow( data: pd.DataFrame, - outcome_col: str, - dose_col: str, - time_col: str, - unit_col: str, - first_treat_col: Optional[str] = None, + outcome: Any = NOT_SUPPLIED, + dose: Any = NOT_SUPPLIED, + time: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, + first_treat: Any = NOT_SUPPLIED, alpha: float = 0.05, n_bootstrap: int = 999, seed: Optional[int] = None, @@ -4204,6 +4259,11 @@ def did_had_pretest_workflow( aggregate: str = "overall", survey_design: Any = None, trends_lin: bool = False, + outcome_col: Any = NOT_SUPPLIED, + dose_col: Any = NOT_SUPPLIED, + time_col: Any = NOT_SUPPLIED, + unit_col: Any = NOT_SUPPLIED, + first_treat_col: Any = NOT_SUPPLIED, ) -> HADPretestReport: """Run the HAD pre-test workflow (paper Section 4.2-4.3). @@ -4251,8 +4311,8 @@ def did_had_pretest_workflow( >= 3 periods, an ordered time dtype (numeric, datetime, or ordered categorical), and the pre-period D=0 invariant across all pre-periods. - outcome_col, dose_col, time_col, unit_col : str - first_treat_col : str or None, default None + outcome, dose, time, unit : str + first_treat : str or None, default None Optional first-treatment-period column. Required on the ``aggregate="event_study"`` path when the panel is staggered (multi-cohort); the panel validator auto-filters to the last @@ -4378,6 +4438,26 @@ def did_had_pretest_workflow( de Chaisemartin et al. (2026), Section 4.2-4.3, Theorem 4, Appendix D, Theorem 7. """ + _q = "did_had_pretest_workflow" + outcome = resolve_renamed_kwarg( + _q, "outcome_col", outcome_col, "outcome", outcome, default=NOT_SUPPLIED + ) + require_arg(_q, "outcome", outcome) + dose = resolve_renamed_kwarg(_q, "dose_col", dose_col, "dose", dose, default=NOT_SUPPLIED) + require_arg(_q, "dose", dose) + time = resolve_renamed_kwarg(_q, "time_col", time_col, "time", time, default=NOT_SUPPLIED) + require_arg(_q, "time", time) + unit = resolve_renamed_kwarg(_q, "unit_col", unit_col, "unit", unit, default=NOT_SUPPLIED) + require_arg(_q, "unit", unit) + first_treat = resolve_renamed_kwarg( + _q, "first_treat_col", first_treat_col, "first_treat", first_treat, default=None + ) + # Body-local names; the public parameters are the bare names. + outcome_col = outcome + dose_col = dose + time_col = time + unit_col = unit + first_treat_col = first_treat if aggregate not in _VALID_AGGREGATES: raise ValueError( f"aggregate must be one of {list(_VALID_AGGREGATES)!r}; " f"got {aggregate!r}." @@ -4492,13 +4572,13 @@ def did_had_pretest_workflow( if len(earlier_pre) >= 1: pretrends_joint = joint_pretrends_test( data_filtered, - outcome_col=outcome_col, - dose_col=dose_col, - time_col=time_col, - unit_col=unit_col, + outcome=outcome_col, + dose=dose_col, + time=time_col, + unit=unit_col, pre_periods=earlier_pre, base_period=base_period, - first_treat_col=first_treat_col, + first_treat=first_treat_col, alpha=alpha, n_bootstrap=n_bootstrap, seed=seed, @@ -4511,13 +4591,13 @@ def did_had_pretest_workflow( # Step 3: joint homogeneity-linearity on post-periods. homogeneity_joint = joint_homogeneity_test( data_filtered, - outcome_col=outcome_col, - dose_col=dose_col, - time_col=time_col, - unit_col=unit_col, + outcome=outcome_col, + dose=dose_col, + time=time_col, + unit=unit_col, post_periods=list(t_post_list), base_period=base_period, - first_treat_col=first_treat_col, + first_treat=first_treat_col, alpha=alpha, n_bootstrap=n_bootstrap, seed=seed, diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 11f07389d..f48b76e79 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -920,14 +920,14 @@ def _handle_continuous(results: Any): "had = HeterogeneousAdoptionDiD()\n" "# Two-period panel (single cohort or 2 periods):\n" "had_results = had.fit(\n" - " data_had_2p, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d', first_treat_col='first_treat')\n" + " data_had_2p, outcome='y', unit='unit',\n" + " time='t', dose='d', first_treat='first_treat')\n" "\n" "# Multi-period panel: must set aggregate='event_study'\n" "# (on staggered panels this is auto-last-cohort-only WAS)\n" "had_es = had.fit(\n" - " data_had_mp, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d', first_treat_col='first_treat',\n" + " data_had_mp, outcome='y', unit='unit',\n" + " time='t', dose='d', first_treat='first_treat',\n" " aggregate='event_study')" ), step_name="estimator_selection", @@ -1080,9 +1080,9 @@ def _handle_had(results: Any): code=( "from diff_diff import did_had_pretest_workflow\n" "report = did_had_pretest_workflow(\n" - " data, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d',\n" - " first_treat_col='first_treat')\n" + " data, outcome='y', unit='unit',\n" + " time='t', dose='d',\n" + " first_treat='first_treat')\n" "print(report.summary())\n" "# verdict explicitly flags the Assumption 7 gap on the\n" "# overall path; aggregate='event_study' on a multi-period\n" @@ -1130,7 +1130,7 @@ def _handle_had(results: Any): "# ContinuousDiD requires a TIME-INVARIANT per-unit dose; HAD\n" "# uses realized per-period dose. Re-prepare the panel\n" "# (e.g. collapse each unit's positive dose to one value) and\n" - "# pass it as `data_cdid` with the time-invariant `dose_col`.\n" + "# pass it as `data_cdid` with the time-invariant `dose` column.\n" "cdid = ContinuousDiD()\n" "cdid_results = cdid.fit(\n" " data_cdid, outcome='y', unit='unit', time='t',\n" @@ -1180,9 +1180,9 @@ def _handle_had(results: Any): "# two-period panel was already consumed by `aggregate='overall'`.\n" "est = HeterogeneousAdoptionDiD()\n" "es = est.fit(\n" - " data_mp, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d',\n" - " first_treat_col='first_treat',\n" + " data_mp, outcome='y', unit='unit',\n" + " time='t', dose='d',\n" + " first_treat='first_treat',\n" " aggregate='event_study')" ), priority="medium", @@ -1265,9 +1265,9 @@ def _handle_had_event_study(results: Any): code=( "from diff_diff import did_had_pretest_workflow\n" "report = did_had_pretest_workflow(\n" - " data, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d',\n" - " first_treat_col='first_treat',\n" + " data, outcome='y', unit='unit',\n" + " time='t', dose='d',\n" + " first_treat='first_treat',\n" " aggregate='event_study')\n" "print(report.summary())" ), @@ -1352,9 +1352,9 @@ def _handle_had_event_study(results: Any): "est = HeterogeneousAdoptionDiD(\n" " n_bootstrap=999, seed=42, vcov_type='hc1')\n" "es = est.fit(\n" - " data, outcome_col='y', unit_col='unit',\n" - " time_col='t', dose_col='d',\n" - " first_treat_col='first_treat',\n" + " data, outcome='y', unit='unit',\n" + " time='t', dose='d',\n" + " first_treat='first_treat',\n" " aggregate='event_study',\n" " survey_design=sd, cband=True)\n" "es.cband_low, es.cband_high # simultaneous band endpoints" diff --git a/diff_diff/prep.py b/diff_diff/prep.py index 933996f72..09758424d 100644 --- a/diff_diff/prep.py +++ b/diff_diff/prep.py @@ -16,6 +16,7 @@ import pandas as pd # Re-export data generation functions from prep_dgp for backward compatibility +from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.prep_dgp import ( # noqa: F401 generate_continuous_did_data, generate_ddd_data, @@ -1312,10 +1313,11 @@ def _suggest_treatment_candidates( def trim_weights( data: pd.DataFrame, - weight_col: str, + weights: Any = NOT_SUPPLIED, upper: Optional[float] = None, quantile: Optional[float] = None, lower: Optional[float] = None, + weight_col: Any = NOT_SUPPLIED, ) -> pd.DataFrame: """Trim (winsorize) survey weights to reduce influence of extreme values. @@ -1327,7 +1329,7 @@ def trim_weights( ---------- data : pd.DataFrame Input DataFrame. - weight_col : str + weights : str Name of the weight column. upper : float, optional Absolute upper cap. Weights above this value are set to it. @@ -1338,6 +1340,9 @@ def trim_weights( lower : float, optional Absolute lower floor. Weights below this value are set to it. Can be combined with either ``upper`` or ``quantile``. + weight_col : str, optional + Deprecated alias for ``weights`` (row M-113); warns with + ``FutureWarning`` and will be removed in 4.0. Returns ------- @@ -1347,9 +1352,20 @@ def trim_weights( Raises ------ ValueError - If both ``upper`` and ``quantile`` are provided, or if ``weight_col`` + If both ``upper`` and ``quantile`` are provided, or if ``weights`` is not in the DataFrame. """ + weights = resolve_renamed_kwarg( + "trim_weights", + "weight_col", + weight_col, + "weights", + weights, + default=NOT_SUPPLIED, + ) + require_arg("trim_weights", "weights", weights) + # Body-local name; the public parameter is weights (M-113). + weight_col = weights if upper is not None and quantile is not None: raise ValueError("Specify either 'upper' or 'quantile', not both.") if weight_col not in data.columns: diff --git a/diff_diff/rdd.py b/diff_diff/rdd.py index fe926a79c..2d9bdcb45 100644 --- a/diff_diff/rdd.py +++ b/diff_diff/rdd.py @@ -7,7 +7,7 @@ Titiunik (2014). SHARP (default): treatment is assigned by ``running >= cutoff``; the effect is the jump in the conditional expectation of the outcome at the cutoff. FUZZY (pass -``fit(..., treatment_col=...)`` with the OBSERVED take-up column): +``fit(..., takeup=...)`` with the OBSERVED take-up column): crossing the cutoff shifts take-up rather than determining it, and the estimand is the local Wald ratio - the outcome jump divided by the take-up jump - which for BINARY take-up under monotonicity is the LATE @@ -34,8 +34,8 @@ inconsistent, and adjusting "for" imbalance cannot restore identification. Balance is testable with the estimator itself:: - balance = RegressionDiscontinuity().fit(df, outcome_col="z1", - running_col="x") + balance = RegressionDiscontinuity().fit(df, outcome="z1", + running="x") balance.p_value # small p = imbalance; do not adjust for z1 Bandwidths are covariate-AWARE (covariates propagate into selection, not @@ -75,7 +75,7 @@ ``kernel`` ``kernel`` (accepts "tri"/"epa"/"uni" too) ``masspoints`` ``masspoints`` ("adjust"/"check"/"off") ``nnmatch`` ``nnmatch`` -``treatment_col`` (fit) ``fuzzy`` (observed take-up variable) +``takeup`` (fit) ``fuzzy`` (observed take-up variable) ``sharpbw`` ``sharpbw`` (same default and semantics) ``covariates`` (fit) ``covs`` (column names instead of a matrix) ``covs_drop`` ``covs_drop`` (same default and semantics) @@ -112,6 +112,12 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import ( + NOT_SUPPLIED, + deprecated_field_property, + require_arg, + resolve_renamed_kwarg, +) from diff_diff._rdrobust_port import ( BWSELECT_OPTIONS, _fuzzy_identification_stop, @@ -225,15 +231,17 @@ class RegressionDiscontinuityResults(BaseResults): # cutoff)" for BINARY take-up; or "fuzzy (local Wald ratio at the # cutoff; non-binary take-up)" when the take-up column is not {0, 1} # (the complier-LATE reading does not apply to dose take-up). - # ``treatment_col`` is the fit-time take-up column name + # ``takeup`` is the fit-time take-up column name # (None on sharp fits; no ``_input`` suffix - that convention is # reserved for constructor arguments); ``sharpbw`` and ``covs_drop`` # echo the constructor flags. The estimand label deliberately does NOT # change under covariate adjustment: CCFT 2019 covariates target the # SAME estimand (precision only) - see ``covariates`` below. + # (The deprecated read-only alias ``treatment_col`` warns and returns + # ``takeup``; removed in 4.0 - row M-094.) estimand: str sharpbw: bool - treatment_col: Optional[str] + takeup: Optional[str] covs_drop: bool # First-stage (take-up jump) three-row mirror - fuzzy fits only, all @@ -281,6 +289,22 @@ class RegressionDiscontinuityResults(BaseResults): beta_t_p_left: Optional[np.ndarray] = field(repr=False, default=None) beta_t_p_right: Optional[np.ndarray] = field(repr=False, default=None) + # Deprecated read-only alias for ``takeup`` (row M-094; removed in 4.0). + # No annotation, so it stays a descriptor and never becomes a + # __dataclass_fields__ entry. + treatment_col = deprecated_field_property( + "RegressionDiscontinuityResults", "treatment_col", "takeup" + ) + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate pickles created before the ``treatment_col`` -> ``takeup`` + rename (row M-094): rewrite the key on load so both the new field + and the deprecated alias work on old pickles.""" + if "treatment_col" in state and "takeup" not in state: + state = dict(state) + state["takeup"] = state.pop("treatment_col") + self.__dict__.update(state) + def summary(self) -> str: """Human-readable summary with the three-row rdrobust table.""" width = 72 @@ -456,7 +480,10 @@ def to_dict(self) -> Dict[str, Any]: "rho_input": self.rho_input, "estimand": self.estimand, "sharpbw": self.sharpbw, - "treatment_col": self.treatment_col, + "takeup": self.takeup, + # Deprecated key mirroring ``takeup`` through the 3.9 shim + # window; dropped in 4.0 (row M-094, section 5 policy). + "treatment_col": self.takeup, "covs_drop": self.covs_drop, # List/dict-valued covariate echoes (None on unadjusted fits; # the lpdid/continuous_did echo convention). @@ -496,7 +523,7 @@ class RegressionDiscontinuity(BaseEstimator): SHARP (default): treatment is defined by the running variable crossing a known cutoff (``running >= cutoff`` treated, matching rdrobust: units exactly at the cutoff are treated). FUZZY: pass the observed - take-up column via ``fit(..., treatment_col=...)`` - the estimand + take-up column via ``fit(..., takeup=...)`` - the estimand becomes the local Wald ratio (complier LATE at the cutoff for binary take-up under monotonicity; the ``estimand`` results field says which reading applies) and the results gain a first-stage block. @@ -558,7 +585,7 @@ class RegressionDiscontinuity(BaseEstimator): Scale of the IK-style regularization in bandwidth selection (0 removes it). sharpbw : bool, default False - Fuzzy fits only (``fit(..., treatment_col=...)``): when True, + Fuzzy fits only (``fit(..., takeup=...)``): when True, bandwidths are selected for the SHARP reduced-form estimator on the outcome (rdrobust's "approach 1") instead of the default fuzzy-ratio objective. Automatically in effect - regardless of @@ -582,9 +609,9 @@ class RegressionDiscontinuity(BaseEstimator): Examples -------- >>> rd = RegressionDiscontinuity(cutoff=0.0) - >>> results = rd.fit(df, outcome_col="y", running_col="x") + >>> results = rd.fit(df, outcome="y", running="x") >>> results.att, results.conf_int # robust bias-corrected inference - >>> fuzzy = rd.fit(df, "y", "x", treatment_col="takeup") # fuzzy RD + >>> fuzzy = rd.fit(df, "y", "x", takeup="takeup") # fuzzy RD >>> fuzzy.att, fuzzy.first_stage # local Wald ratio + take-up jump """ @@ -702,10 +729,13 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome_col: str, - running_col: str, - treatment_col: Optional[str] = None, + outcome: Any = NOT_SUPPLIED, + running: Any = NOT_SUPPLIED, + takeup: Any = NOT_SUPPLIED, covariates: Optional[List[str]] = None, + outcome_col: Any = NOT_SUPPLIED, + running_col: Any = NOT_SUPPLIED, + treatment_col: Any = NOT_SUPPLIED, ) -> RegressionDiscontinuityResults: """Estimate the RD effect at the cutoff (sharp or fuzzy, optionally covariate-adjusted). @@ -714,9 +744,9 @@ def fit( ---------- data : pd.DataFrame Cross-sectional data. - outcome_col, running_col : str + outcome, running : str Column names of the outcome and the running variable. - treatment_col : str or None, default None + takeup : str or None, default None ``None`` (sharp design): treatment is derived as ``running >= cutoff``; no treatment column is needed. A column name activates the FUZZY design: the column holds the OBSERVED @@ -745,7 +775,43 @@ def fit( dropped with a warning under ``covs_drop=True``; see the ``covariates*`` results fields for the echo and the fitted projection coefficients. + outcome_col, running_col, treatment_col : str, optional + Deprecated aliases for ``outcome`` / ``running`` / ``takeup`` + (rows M-040..M-042); each warns with ``FutureWarning`` and + will be removed in 4.0. """ + qualname = "RegressionDiscontinuity.fit" + outcome = resolve_renamed_kwarg( + qualname, + "outcome_col", + outcome_col, + "outcome", + outcome, + default=NOT_SUPPLIED, + ) + require_arg(qualname, "outcome", outcome) + running = resolve_renamed_kwarg( + qualname, + "running_col", + running_col, + "running", + running, + default=NOT_SUPPLIED, + ) + require_arg(qualname, "running", running) + takeup = resolve_renamed_kwarg( + qualname, + "treatment_col", + treatment_col, + "takeup", + takeup, + default=None, + ) + # Body-local names; the public parameters are outcome/running/takeup + # (M-040..M-042). + outcome_col = outcome + running_col = running + treatment_col = takeup cols = [outcome_col, running_col] if treatment_col is not None: cols.append(treatment_col) @@ -778,7 +844,7 @@ def fit( # Deviation from R, which silently ignores sharpbw on sharp # fits (no-silent-failures policy; same pattern as b-without-h). warnings.warn( - "sharpbw has no effect without treatment_col (sharp design) " "and is ignored.", + "sharpbw has no effect without takeup (sharp design) " "and is ignored.", UserWarning, stacklevel=2, ) @@ -1166,7 +1232,7 @@ def fit( rho_input=None if self.rho is None else float(self.rho), estimand=estimand, sharpbw=bool(self.sharpbw), - treatment_col=treatment_col, + takeup=treatment_col, covs_drop=bool(self.covs_drop), covariates=None if covariates is None else list(covariates), covariates_dropped=covariates_dropped, diff --git a/diff_diff/rdplot.py b/diff_diff/rdplot.py index 42e2ef4c0..1cc63c5c6 100644 --- a/diff_diff/rdplot.py +++ b/diff_diff/rdplot.py @@ -37,6 +37,7 @@ from scipy import stats from ._base import BaseEstimator +from ._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from ._rdrobust_port import ( _covs_gamma, _normalize_kernel, @@ -475,9 +476,11 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome_col: str, - running_col: str, + outcome: Any = NOT_SUPPLIED, + running: Any = NOT_SUPPLIED, covariates: Optional[List[str]] = None, + outcome_col: Any = NOT_SUPPLIED, + running_col: Any = NOT_SUPPLIED, ) -> RDPlotResult: """Build the RD plot quantities. @@ -485,7 +488,7 @@ def fit( ---------- data : pd.DataFrame Cross-sectional data. - outcome_col, running_col : str + outcome, running : str Column names of the outcome and the running variable. covariates : list of str, optional Covariate-adjusted plot per rdplot's ``covs=`` (global fit @@ -503,10 +506,38 @@ def fit( same contract as ``RegressionDiscontinuity``). Per-name values are identical either way on full-rank covariates. + outcome_col, running_col : str, optional + Deprecated aliases for ``outcome`` / ``running`` (rows + M-088/M-089); each warns with ``FutureWarning`` and will be + removed in 4.0. + Returns ------- RDPlotResult """ + qualname = "RDPlot.fit" + outcome = resolve_renamed_kwarg( + qualname, + "outcome_col", + outcome_col, + "outcome", + outcome, + default=NOT_SUPPLIED, + ) + require_arg(qualname, "outcome", outcome) + running = resolve_renamed_kwarg( + qualname, + "running_col", + running_col, + "running", + running, + default=NOT_SUPPLIED, + ) + require_arg(qualname, "running", running) + # Body-local names; the public parameters are outcome/running + # (M-088/M-089). + outcome_col = outcome + running_col = running if covariates is not None: if isinstance(covariates, str): # A bare string would iterate characters; fail closed. diff --git a/diff_diff/wooldridge.py b/diff_diff/wooldridge.py index 72d6822bd..fce9415df 100644 --- a/diff_diff/wooldridge.py +++ b/diff_diff/wooldridge.py @@ -21,6 +21,7 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.linalg import ( compute_robust_vcov, effective_cluster_count, @@ -1102,11 +1103,12 @@ def fit( outcome: str, unit: str, time: str, - cohort: str, + first_treat: Any = NOT_SUPPLIED, exovar: Optional[List[str]] = None, xtvar: Optional[List[str]] = None, xgvar: Optional[List[str]] = None, survey_design=None, + cohort: Any = NOT_SUPPLIED, ) -> WooldridgeDiDResults: """Fit the ETWFE model. See class docstring for parameter details. @@ -1116,7 +1118,7 @@ def fit( outcome : outcome column name unit : unit identifier column time : time period column - cohort : first treatment period (0 or NaN = never treated) + first_treat : first treatment period column (0 or NaN = never treated) exovar : time-invariant covariates added without interaction/demeaning xtvar : time-varying covariates (demeaned within cohort×period cells when ``demean_covariates=True``) @@ -1126,7 +1128,21 @@ def fit( stratified, clustered, and weighted designs via Taylor Series Linearization (TSL). Replicate-weight designs raise ``NotImplementedError``. + cohort : str, optional + Deprecated alias for ``first_treat`` (row M-032); warns with + ``FutureWarning`` and will be removed in 4.0. """ + first_treat = resolve_renamed_kwarg( + "WooldridgeDiD.fit", + "cohort", + cohort, + "first_treat", + first_treat, + default=NOT_SUPPLIED, + ) + require_arg("WooldridgeDiD.fit", "first_treat", first_treat) + # Body-local name; the public parameter is first_treat (M-032). + cohort = first_treat df = data.copy() df = _warn_and_fill_nan_cohort(df, cohort, stacklevel=2) diff --git a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst index db0243eaf..50fc42056 100644 --- a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst +++ b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst @@ -85,7 +85,7 @@ ~ChaisemartinDHaultfoeuilleResults.placebo_conf_int ~ChaisemartinDHaultfoeuilleResults.placebo_available ~ChaisemartinDHaultfoeuilleResults.per_period_effects - ~ChaisemartinDHaultfoeuilleResults.groups + ~ChaisemartinDHaultfoeuilleResults.units ~ChaisemartinDHaultfoeuilleResults.time_periods ~ChaisemartinDHaultfoeuilleResults.n_obs ~ChaisemartinDHaultfoeuilleResults.n_treated_obs diff --git a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst index 1ab0c6144..66dfdf39c 100644 --- a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst +++ b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst @@ -90,6 +90,6 @@ ~RegressionDiscontinuityResults.rho_input ~RegressionDiscontinuityResults.estimand ~RegressionDiscontinuityResults.sharpbw - ~RegressionDiscontinuityResults.treatment_col + ~RegressionDiscontinuityResults.takeup ~RegressionDiscontinuityResults.covs_drop diff --git a/docs/api/chaisemartin_dhaultfoeuille.rst b/docs/api/chaisemartin_dhaultfoeuille.rst index 1ee5634d7..a88c5d102 100644 --- a/docs/api/chaisemartin_dhaultfoeuille.rst +++ b/docs/api/chaisemartin_dhaultfoeuille.rst @@ -210,7 +210,7 @@ Basic usage with reversible treatment:: results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -237,7 +237,7 @@ Multiplier bootstrap inference:: n_bootstrap=999, bootstrap_weights="rademacher", seed=42, ) results = est.fit( - data, outcome="outcome", group="group", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) # When n_bootstrap > 0, the top-level overall_*/joiners_*/leavers_* @@ -255,7 +255,7 @@ Standalone TWFE diagnostic (without fitting the full estimator):: from diff_diff import twowayfeweights diagnostic = twowayfeweights( - data, outcome="outcome", group="group", time="period", treatment="treatment", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) print(f"Plain TWFE coefficient: {diagnostic.beta_fe:.3f}") print(f"Fraction of negative weights: {diagnostic.fraction_negative:.3f}") diff --git a/docs/api/prep.rst b/docs/api/prep.rst index 8660e26cb..c9fd2d146 100644 --- a/docs/api/prep.rst +++ b/docs/api/prep.rst @@ -124,7 +124,7 @@ Example est = ChaisemartinDHaultfoeuille() results = est.fit( - data, outcome="outcome", group="group", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) diff --git a/docs/api/regression_discontinuity.rst b/docs/api/regression_discontinuity.rst index 84f6caae0..c16253e5a 100644 --- a/docs/api/regression_discontinuity.rst +++ b/docs/api/regression_discontinuity.rst @@ -9,7 +9,7 @@ parity-targeting R ``rdrobust`` 4.0.0. observed running variable (``running >= cutoff``; units exactly at the cutoff are treated, matching rdrobust). The effect is the jump in the conditional expectation of the outcome at the cutoff. **Fuzzy** (pass the -observed take-up column via ``fit(..., treatment_col=...)``): crossing +observed take-up column via ``fit(..., takeup=...)``): crossing the cutoff shifts take-up instead of determining it, and the estimand is the local Wald ratio - for binary take-up under monotonicity, the LATE for compliers at the cutoff; for non-binary take-up, the ratio of jumps diff --git a/docs/api/wooldridge_etwfe.rst b/docs/api/wooldridge_etwfe.rst index 440c79a95..26b50a664 100644 --- a/docs/api/wooldridge_etwfe.rst +++ b/docs/api/wooldridge_etwfe.rst @@ -111,7 +111,7 @@ Basic OLS (follows Stata ``jwdid y, ivar(unit) tvar(time) gvar(cohort)``):: df['first_treat'] = df['first_treat'].astype(int) m = WooldridgeDiD() - r = m.fit(df, outcome='lemp', unit='countyreal', time='year', cohort='first_treat') + r = m.fit(df, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat') r.aggregate('event').aggregate('group').aggregate('simple') print(r.summary('event')) @@ -148,7 +148,7 @@ Poisson QMLE for non-negative outcomes m_pois = WooldridgeDiD(method='poisson') r_pois = m_pois.fit(df, outcome='emp', unit='countyreal', - time='year', cohort='first_treat') + time='year', first_treat='first_treat') r_pois.aggregate('event').aggregate('group').aggregate('simple') print(r_pois.summary('simple')) @@ -157,7 +157,7 @@ Logit for binary outcomes m_logit = WooldridgeDiD(method='logit') r_logit = m_logit.fit(df, outcome='hi_emp', unit='countyreal', - time='year', cohort='first_treat') + time='year', first_treat='first_treat') r_logit.aggregate('group').aggregate('simple') print(r_logit.summary('group')) diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 701a18134..9d714ff32 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -290,7 +290,7 @@ integration on placebos, and survey support via Taylor-series linearization. results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -428,13 +428,13 @@ before estimation; see :doc:`api/had` for the full API and SE-regime contract. rows.append({'unit': g, 'period': t, 'y': y, 'dose': d}) had_data = pd.DataFrame(rows) - pretests = did_had_pretest_workflow(had_data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + pretests = did_had_pretest_workflow(had_data, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') est = HeterogeneousAdoptionDiD() - results = est.fit(had_data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + results = est.fit(had_data, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') # Event-study results: per-horizon WAS at each event time @@ -706,7 +706,7 @@ differences helps interpret results and choose appropriate inference. - Two SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=SurveyDesign(weights="col", ...)``** (the sole weighting entry as of the 3.7.0 ``survey=`` / ``weights=`` removal): both paths compose Binder (1983) Taylor-series linearization (``variance_formula="survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``); the mass-point survey path rejects ``vcov_type="classical"`` (requires ``hc1`` / ``robust=True``), and ``survey_design=`` + ``cluster=`` is rejected outright (route weighted clustering via ``SurveyDesign(weights=, psu=)``; a bare ``cluster=`` gives unweighted CR1). Per-horizon CIs are pointwise; sup-t bands available on the event-study path via ``cband=True`` whenever ``survey_design=`` or ``cluster=`` is supplied. * - ``RegressionDiscontinuity`` - Robust bias-corrected (CCT 2014, NN variance) - - Sharp, fuzzy, and covariate-adjusted RD with rdrobust-4.0.0-parity inference (fuzzy via ``fit(..., treatment_col=...)``: local Wald ratio with a linearized bias correction, first-stage block, and a weak-first-stage warning; covariates via ``fit(..., covariates=[...])``: same estimand, precision only, covariate-aware bandwidths). Canonical ``att``/``se``/``conf_int`` are the ROBUST bias-corrected row (``att`` = bias-corrected estimate, CI centered on it); the conventional estimate rdrobust prints as its headline is ``att_conventional`` with its own inference row. Only ``vcov_type="nn"`` in this release; cluster-robust RD variance is a documented follow-up. + - Sharp, fuzzy, and covariate-adjusted RD with rdrobust-4.0.0-parity inference (fuzzy via ``fit(..., takeup=...)``: local Wald ratio with a linearized bias correction, first-stage block, and a weak-first-stage warning; covariates via ``fit(..., covariates=[...])``: same estimand, precision only, covariate-aware bandwidths). Canonical ``att``/``se``/``conf_int`` are the ROBUST bias-corrected row (``att`` = bias-corrected estimate, CI centered on it); the conventional estimate rdrobust prints as its headline is ``att_conventional`` with its own inference row. Only ``vcov_type="nn"`` in this release; cluster-robust RD variance is a documented follow-up. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index d381ed61a..d26d4ee40 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1132,21 +1132,21 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param The guard is fired by `_survey_se_from_group_if` (analytical and replicate) and by `_unroll_target_to_cells` (bootstrap). **Unaffected paths**: Binder TSL under PSU-within-group-constant regimes (including PSU=group auto-inject) falls back to the legacy group-level allocator where the row-sum identity `sum_{c in g} U_centered_per_period[g, t] == U_centered[g]` makes the two statistically equivalent, and the bootstrap dispatcher routes the same regimes through the legacy group-level path. **Workaround:** pre-process the panel to remove terminal missingness (drop late-exit groups or trim to a balanced sub-panel). For Binder TSL, using an explicit `psu=` routes through the legacy group allocator. For replicate ATT and within-group-varying-PSU bootstrap, there is no allocator fallback — the panel itself must be pre-processed. The broader unbalanced-panel workaround (back-fill the baseline or drop late-entry groups before fitting, or use R `DIDmultiplegtDYN`) also applies. The Step 5b `ValueError` and `UserWarning` messages name the offending group IDs so you can locate them quickly. -- **Note (Phase 3 DID^X covariate adjustment):** When `controls` is set, `per_period_effects` (the Phase 1 per-period DID_M decomposition) remains **unadjusted** (computed on raw outcomes). The covariate residualization applies only to the per-group `DID_{g,l}` path (`L_max >= 1`), which produces `event_study_effects` and `overall_att`. This means `per_period_effects` and `event_study_effects[1]` may diverge when controls are active - by design (the per-period path uses binary joiner/leaver categorization and is not part of the DID^X contract). Implements the residualization-style covariate adjustment from Web Appendix Section 1.2 (Assumption 11). For each baseline treatment value `d`, estimates `theta_hat_d` via OLS of first-differenced outcomes on first-differenced covariates with time FEs, restricted to not-yet-treated observations. Residualizes at levels: `Y_tilde[g,t] = Y[g,t] - X[g,t] @ theta_hat_d`. All downstream DID computations use residualized outcomes. This is NOT doubly-robust, NOT IPW, NOT Callaway-Sant'Anna-style. Plug-in IF (treating `theta_hat` as fixed) is valid by FWL theorem. **Deviation from R `DIDmultiplegtDYN`:** The first-stage OLS uses equal cell weights (one observation per `(g,t)` cell), consistent with the library's cell-count weighting convention documented in Phase 1. R weights by `N_gt` (observation count per cell). On panels with 1 observation per cell (the common case), results are identical. When baseline-specific first stages fail (`n_obs = 0` or `n_obs < n_params`), the affected strata are excluded from the estimation (outcomes set to NaN) rather than retained unadjusted - matching R's "drop failed strata" behavior. Requires `L_max >= 1`. Activated via `controls=["col1", "col2"]` in `fit()`. +- **Note (Phase 3 DID^X covariate adjustment):** When `covariates` is set, `per_period_effects` (the Phase 1 per-period DID_M decomposition) remains **unadjusted** (computed on raw outcomes). The covariate residualization applies only to the per-group `DID_{g,l}` path (`L_max >= 1`), which produces `event_study_effects` and `overall_att`. This means `per_period_effects` and `event_study_effects[1]` may diverge when covariates are active - by design (the per-period path uses binary joiner/leaver categorization and is not part of the DID^X contract). Implements the residualization-style covariate adjustment from Web Appendix Section 1.2 (Assumption 11). For each baseline treatment value `d`, estimates `theta_hat_d` via OLS of first-differenced outcomes on first-differenced covariates with time FEs, restricted to not-yet-treated observations. Residualizes at levels: `Y_tilde[g,t] = Y[g,t] - X[g,t] @ theta_hat_d`. All downstream DID computations use residualized outcomes. This is NOT doubly-robust, NOT IPW, NOT Callaway-Sant'Anna-style. Plug-in IF (treating `theta_hat` as fixed) is valid by FWL theorem. **Deviation from R `DIDmultiplegtDYN`:** The first-stage OLS uses equal cell weights (one observation per `(g,t)` cell), consistent with the library's cell-count weighting convention documented in Phase 1. R weights by `N_gt` (observation count per cell). On panels with 1 observation per cell (the common case), results are identical. When baseline-specific first stages fail (`n_obs = 0` or `n_obs < n_params`), the affected strata are excluded from the estimation (outcomes set to NaN) rather than retained unadjusted - matching R's "drop failed strata" behavior. Requires `L_max >= 1`. Activated via `covariates=["col1", "col2"]` in `fit()`. - **Note (Phase 3 DID^{fd} linear trends):** Implements group-specific linear trends from Web Appendix Section 1.3 (Assumption 12, Lemma 6). Uses the Z_mat transformation: `Z[g,t] = Y[g,t] - Y[g,t-1]` (first-differenced outcomes). Since `DID_{g,l}(Z) = DID^{fd}_{g,l}` algebraically, the existing multi-horizon DID code produces trend-adjusted estimates when fed Z_mat. Requires F_g >= 3 (at least 2 pre-switch periods); groups with F_g < 3 are excluded with a `UserWarning`. Cumulated level effects `delta^{fd}_l = sum_{l'=1}^l DID^{fd}_{l'}` stored in `results.linear_trends_effects`. Cumulated SE uses conservative upper bound (sum of per-horizon SEs); cross-horizon covariance from IF vectors is a library extension (paper proves Theorem 1 per-horizon, not cross-horizon). When combined with DID^X, residualization is applied first, then first-differencing (per paper assumption ordering). **Suppressed surfaces under `trends_linear`:** `normalized_effects` (`DID^n_l`) and `cost_benefit_delta` are suppressed because they would operate on second-differences rather than level effects. Users should access cumulated level effects via `linear_trends_effects`. Activated via `trends_linear=True` in `fit()`. - **Note (Phase 3 state-set trends):** Implements state-set-specific trends from Web Appendix Section 1.4 (Assumptions 13-14). Restricts the control pool for each switcher to groups in the same set (e.g., same state in county-level data). The restriction applies in all four DID/IF paths: `_compute_multi_horizon_dids()`, `_compute_per_group_if_multi_horizon()`, `_compute_multi_horizon_placebos()`, and `_compute_per_group_if_placebo_horizon()`. Cohort structure stays as `(D_{g,1}, F_g, S_g)` triples (does not incorporate set membership). Set membership must be time-invariant per group. **Note on Assumption 14 (common support):** The paper requires a common last-untreated period across sets (`T_u^s` equal for all `s`). This implementation does NOT enforce Assumption 14 up front. Instead, when within-set controls are exhausted at a given horizon (because a set has shorter untreated support than others), the affected switcher/horizon pairs are silently excluded via the existing empty-control-pool mechanism. This means `N_l` may be smaller under `trends_nonparam` than without it, and the effective estimand is trimmed to the within-set support at each horizon. The existing multi-horizon A11 warning fires when exclusions occur. Activated via `trends_nonparam="state_column"` in `fit()`. -- **Note (Phase 3 heterogeneity testing - partial implementation):** Partial implementation of the heterogeneity test from Web Appendix Section 1.5 (Assumption 15, Lemma 7). Computes post-treatment saturated OLS regressions of `S_g * (Y_{g, F_g-1+l} - Y_{g, F_g-1})` on a time-invariant covariate `X_g` plus cohort indicator dummies. Standard OLS inference is valid (paper shows no DID error correction needed). **Deviation from R `predict_het`:** Python now matches R on per-horizon placebo regressions when the user sets `placebo=True` together with `heterogeneity=` (post-2026-05-15; see "Placebo predict_het" sub-note below for the full contract). The remaining gap is the joint null F-test that R aggregates across all `predict_het` rows — Python emits per-horizon `t_stat` / `p_value` / `conf_int` only and does NOT compute a joint Wald test across forward + placebo coefficients (tracked at REGISTRY note's "Per-horizon regressions only (no joint F-test)" rendering line). R also disallows combination with `controls`, which Python continues to enforce as an explicit `ValueError`. **Rejected combinations:** `controls` (matching R), `trends_linear` (heterogeneity test uses raw level changes, incompatible with second-differenced outcomes), and `trends_nonparam` (heterogeneity test does not thread state-set control-pool restrictions). Results stored in `results.heterogeneity_effects`. Activated via `heterogeneity="covariate_column"` in `fit()`. **Note (survey support):** Under `survey_design`, heterogeneity uses WLS with per-group weights `W_g = sum of obs-level survey weights in group g`, and the group-level WLS coefficient influence function is `ψ_g[X] = inv(X'WX)[1,:] @ x_g * W_g * r_g`. The group-level IF is then attributed to observation level via **one of two allocators, chosen by variance helper** so each path preserves byte-identity for its aggregation rule: (1) **Binder TSL** (`compute_survey_if_variance`) uses the **cell-period single-cell allocator** — at each horizon `l_h`, `ψ_g` is assigned in full to the post-period cell `(g, out_idx)` with `out_idx = first_switch_idx[g] - 1 + l_h` and expanded as `ψ_i = ψ_g * (w_i / W_{g, out_idx})` for obs in that cell, zero elsewhere (matches the DID_l post-period convention in the Survey IF expansion Note below). Under PSU=group per-observation distribution differs from the legacy `ψ_i = ψ_g * (w_i / W_g)`, but PSU-level aggregates telescope to the same `ψ_g` — so Binder TSL variance is byte-identical to the pre-cell-period release under PSU=group. Under within-group-varying PSU mass lands in the post-period PSU of the transition, which is what Binder TSL needs. An **empty post-period cell under zero-weight obs** (all obs at `(g, out_idx)` have `w_i = 0` despite `N > 0`) drops the group's contribution, matching the ATT cell allocator's convention; the pre-cell-period path diverged here by redistributing mass to other cells of the group. (2) **Rao-Wu replicate** (`compute_replicate_if_variance`) uses the **legacy group-level allocator** `ψ_i = ψ_g * (w_i / W_g)`. Replicate variance computes `θ_r = sum_i ratio_ir * ψ_i` at the observation level, so moving `ψ_g` mass onto the post-period cell only would silently change the replicate SE whenever a replicate column's ratios vary within group (the library accepts arbitrary per-row replicate matrices, not just PSU-aligned ones). Keeping the legacy allocator on this branch preserves byte-identity of replicate SE across every previously-supported fit; replicate + within-group-varying PSU is unreachable by construction (`SurveyDesign` rejects `replicate_weights` combined with explicit `strata/psu/fpc`). Inference uses the t-distribution with `df_survey` when provided. Under rank deficiency (any regression coefficient dropped by `solve_ols`'s R-style drop), all inference fields return NaN (conservative, matches the NaN-consistent contract). **Library extension (replicate weights):** Under a replicate-weight design (BRR/Fay/JK1/JKn/SDR), the heterogeneity regression dispatches to `compute_replicate_if_variance` (Rao-Wu weight-ratio rescaling) instead of the Binder TSL formula. The effective df is the shared `min(resolved_survey.df_survey, min(n_valid_across_sites) - 1)` used by the rest of the dCDH surfaces; if the base `df_survey` is undefined (QR-rank ≤ 1), heterogeneity inference is NaN regardless of the local `n_valid_het` (matching the dCDH top-level contract — per-site `n_valid` cannot rescue a rank-deficient design). **Library extension:** R `DIDmultiplegtDYN::predict_het` does not natively support survey weights. **Scope note (bootstrap):** Heterogeneity inference is analytical (no bootstrap path). When `n_bootstrap > 0` is combined with `heterogeneity=`, the main ATT surfaces receive bootstrap SE/CI (via the cell-level wild PSU bootstrap described in the survey + bootstrap contract Note below) while `heterogeneity_effects` continues to use the Binder TSL / Rao-Wu analytical SE described above. No gate; the two inference paths are independent. +- **Note (Phase 3 heterogeneity testing - partial implementation):** Partial implementation of the heterogeneity test from Web Appendix Section 1.5 (Assumption 15, Lemma 7). Computes post-treatment saturated OLS regressions of `S_g * (Y_{g, F_g-1+l} - Y_{g, F_g-1})` on a time-invariant covariate `X_g` plus cohort indicator dummies. Standard OLS inference is valid (paper shows no DID error correction needed). **Deviation from R `predict_het`:** Python now matches R on per-horizon placebo regressions when the user sets `placebo=True` together with `heterogeneity=` (post-2026-05-15; see "Placebo predict_het" sub-note below for the full contract). The remaining gap is the joint null F-test that R aggregates across all `predict_het` rows — Python emits per-horizon `t_stat` / `p_value` / `conf_int` only and does NOT compute a joint Wald test across forward + placebo coefficients (tracked at REGISTRY note's "Per-horizon regressions only (no joint F-test)" rendering line). R also disallows combination with `controls`, which Python continues to enforce as an explicit `ValueError`. **Rejected combinations:** `covariates` (matching R's `controls`), `trends_linear` (heterogeneity test uses raw level changes, incompatible with second-differenced outcomes), and `trends_nonparam` (heterogeneity test does not thread state-set control-pool restrictions). Results stored in `results.heterogeneity_effects`. Activated via `heterogeneity="covariate_column"` in `fit()`. **Note (survey support):** Under `survey_design`, heterogeneity uses WLS with per-group weights `W_g = sum of obs-level survey weights in group g`, and the group-level WLS coefficient influence function is `ψ_g[X] = inv(X'WX)[1,:] @ x_g * W_g * r_g`. The group-level IF is then attributed to observation level via **one of two allocators, chosen by variance helper** so each path preserves byte-identity for its aggregation rule: (1) **Binder TSL** (`compute_survey_if_variance`) uses the **cell-period single-cell allocator** — at each horizon `l_h`, `ψ_g` is assigned in full to the post-period cell `(g, out_idx)` with `out_idx = first_switch_idx[g] - 1 + l_h` and expanded as `ψ_i = ψ_g * (w_i / W_{g, out_idx})` for obs in that cell, zero elsewhere (matches the DID_l post-period convention in the Survey IF expansion Note below). Under PSU=group per-observation distribution differs from the legacy `ψ_i = ψ_g * (w_i / W_g)`, but PSU-level aggregates telescope to the same `ψ_g` — so Binder TSL variance is byte-identical to the pre-cell-period release under PSU=group. Under within-group-varying PSU mass lands in the post-period PSU of the transition, which is what Binder TSL needs. An **empty post-period cell under zero-weight obs** (all obs at `(g, out_idx)` have `w_i = 0` despite `N > 0`) drops the group's contribution, matching the ATT cell allocator's convention; the pre-cell-period path diverged here by redistributing mass to other cells of the group. (2) **Rao-Wu replicate** (`compute_replicate_if_variance`) uses the **legacy group-level allocator** `ψ_i = ψ_g * (w_i / W_g)`. Replicate variance computes `θ_r = sum_i ratio_ir * ψ_i` at the observation level, so moving `ψ_g` mass onto the post-period cell only would silently change the replicate SE whenever a replicate column's ratios vary within group (the library accepts arbitrary per-row replicate matrices, not just PSU-aligned ones). Keeping the legacy allocator on this branch preserves byte-identity of replicate SE across every previously-supported fit; replicate + within-group-varying PSU is unreachable by construction (`SurveyDesign` rejects `replicate_weights` combined with explicit `strata/psu/fpc`). Inference uses the t-distribution with `df_survey` when provided. Under rank deficiency (any regression coefficient dropped by `solve_ols`'s R-style drop), all inference fields return NaN (conservative, matches the NaN-consistent contract). **Library extension (replicate weights):** Under a replicate-weight design (BRR/Fay/JK1/JKn/SDR), the heterogeneity regression dispatches to `compute_replicate_if_variance` (Rao-Wu weight-ratio rescaling) instead of the Binder TSL formula. The effective df is the shared `min(resolved_survey.df_survey, min(n_valid_across_sites) - 1)` used by the rest of the dCDH surfaces; if the base `df_survey` is undefined (QR-rank ≤ 1), heterogeneity inference is NaN regardless of the local `n_valid_het` (matching the dCDH top-level contract — per-site `n_valid` cannot rescue a rank-deficient design). **Library extension:** R `DIDmultiplegtDYN::predict_het` does not natively support survey weights. **Scope note (bootstrap):** Heterogeneity inference is analytical (no bootstrap path). When `n_bootstrap > 0` is combined with `heterogeneity=`, the main ATT surfaces receive bootstrap SE/CI (via the cell-level wild PSU bootstrap described in the survey + bootstrap contract Note below) while `heterogeneity_effects` continues to use the Binder TSL / Rao-Wu analytical SE described above. No gate; the two inference paths are independent. - **Note (HonestDiD integration):** HonestDiD sensitivity analysis (Rambachan & Roth 2023) is available on the placebo + event study surface via `honest_did=True` in `fit()` or `compute_honest_did(results)` post-hoc. **Library extension:** dCDH HonestDiD uses `DID^{pl}_l` placebo estimates as pre-period coefficients rather than standard event-study pre-treatment coefficients. The Rambachan-Roth restrictions bound violations of the parallel trends assumption underlying the dCDH placebo estimand; interpretation differs from canonical event-study HonestDiD. A `UserWarning` is emitted at runtime. Uses diagonal variance (no full VCV available for dCDH). Relative magnitudes (DeltaRM) with Mbar=1.0 is the default when called from `fit()`, targeting the equal-weight average over all post-treatment horizons (`l_vec=None`). R's HonestDiD defaults to the first post/on-impact effect; use `compute_honest_did(results, ...)` with a custom `l_vec` to match that behavior. When `trends_linear=True`, bounds apply to the second-differenced estimand (parallel trends in first differences). Requires `L_max >= 1` for multi-horizon placebos. Gaps in the horizon grid from `trends_nonparam` support-trimming are handled by filtering to the largest consecutive block and warning. -- **Note (Phase 3 Design-2 switch-in/switch-out):** Convenience wrapper for Web Appendix Section 1.6 (Assumption 16). Identifies groups with exactly 2 treatment changes (join then leave), reports switch-in and switch-out mean effects. This is a descriptive summary, not a full re-estimation with specialized control pools as described in the paper. **Always uses raw (unadjusted) outcomes** regardless of active `controls`, `trends_linear`, or `trends_nonparam` options - those adjustments apply to the main estimator surface but not to the Design-2 descriptive block. For full adjusted Design-2 estimation with proper control pools, the paper recommends "running the command on a restricted subsample and using `trends_nonparam` for the entry-timing grouping." Activated via `design2=True` in `fit()`, requires `drop_larger_lower=False` to retain 2-switch groups. +- **Note (Phase 3 Design-2 switch-in/switch-out):** Convenience wrapper for Web Appendix Section 1.6 (Assumption 16). Identifies groups with exactly 2 treatment changes (join then leave), reports switch-in and switch-out mean effects. This is a descriptive summary, not a full re-estimation with specialized control pools as described in the paper. **Always uses raw (unadjusted) outcomes** regardless of active `covariates`, `trends_linear`, or `trends_nonparam` options - those adjustments apply to the main estimator surface but not to the Design-2 descriptive block. For full adjusted Design-2 estimation with proper control pools, the paper recommends "running the command on a restricted subsample and using `trends_nonparam` for the entry-timing grouping." Activated via `design2=True` in `fit()`, requires `drop_larger_lower=False` to retain 2-switch groups. -- **Note (Phase 3 `by_path` per-path event-study disaggregation):** Per-path disaggregation of the multi-horizon event study, mirroring R `did_multiplegt_dyn(..., by_path=k)`. Activated via `ChaisemartinDHaultfoeuille(by_path=k, drop_larger_lower=False)` where `k` is a positive integer (top-k most common observed paths by switcher-group frequency). **Window convention:** the path tuple for a switcher group `g` is `(D_{g, F_g-1}, D_{g, F_g}, ..., D_{g, F_g-1+L_max})` — length `L_max + 1`, matching R's window `[F_{g-1}, F_{g-1+l}]`. **Ranking:** paths are ranked by descending frequency; ties are broken lexicographically on the path tuple for deterministic ordering, so every selected path has a unique `frequency_rank`. If `by_path` exceeds the number of observed paths, all observed paths are returned with a `UserWarning`. **Per-path SE convention (joiners/leavers precedent):** the per-path influence function follows the joiners-only / leavers-only IF construction at `chaisemartin_dhaultfoeuille.py:5495-5504`: the switcher-side contribution `+S_g * (Y_{g,out} - Y_{g,ref})` is zeroed for groups whose observed trajectory is NOT the selected path; control contributions and the full cohort structure `(D_{g,1}, F_g, S_g)` are unchanged. After applying the singleton-baseline eligible mask and cohort-recentering with the original cohort IDs, the plug-in SE uses the path-specific divisor `N_l_path` (count of path switchers eligible at horizon `l`) — same pattern as `joiners_se` using `joiner_total`. This gives the **within-path mean** estimand `DID_{path,l}` as the within-path average of `DID_{g,l}`. **Degenerate-cohort behavior per path:** when a path's centered IF at some horizon is identically zero (every variance-eligible path switcher forms its own `(D_{g,1}, F_g, S_g)` cohort, or the path has a single contributing group), SE / t_stat / p_value / conf_int are NaN-consistent and a `UserWarning` is emitted scoped to `(path, horizon)`. This mirrors the overall-path degenerate-cohort surface and is common for rare paths with few contributing groups. **Empty-state contract:** `results.path_effects` distinguishes "not requested" (`None`) from "requested but empty" (`{}` — all switchers have windows outside the panel or unobserved cells). The empty-dict case emits a `UserWarning` at fit-time and renders as an explicit "no observed paths" notice in `summary()`; `to_dataframe(level="by_path")` returns an empty DataFrame with the canonical column set (mirrors the `linear_trends` pattern when `trends_linear=True` but no horizons survive). **Requirements:** `drop_larger_lower=False` (multi-switch groups are the object of interest; default `True` filters them out) and `L_max >= 1` (path window depends on the horizon). **Scope:** combinations with `design2` and `honest_did` remain gated behind explicit `NotImplementedError` (deferred to follow-up wave PRs); `heterogeneity` is supported per-path — see the **Per-path heterogeneity testing** paragraph below. `n_bootstrap > 0` is now supported — see the **Bootstrap SE** paragraph below. `survey_design` is supported under analytical Binder TSL and replicate-weight bootstrap — see the **Per-path survey-design SE** paragraph below; multiplier bootstrap (`n_bootstrap > 0`) under `survey_design + by_path/paths_of_interest` remains gated. `placebo=True` is now supported per-path — see the **Per-path placebos** paragraph below. **TWFE diagnostic** remains a sample-level summary (not computed per path) in this release. Results are exposed on `results.path_effects` as `Dict[Tuple[int, ...], Dict[str, Any]]` with nested `horizons` dicts per horizon `l`, and on `results.to_dataframe(level="by_path")` as a long-format table with columns `[path, frequency_rank, n_groups, horizon, effect, se, t_stat, p_value, conf_int_lower, conf_int_upper, n_obs, cband_lower, cband_upper, cumulated_effect, cumulated_se, het_beta, het_se, het_t_stat, het_p_value, het_conf_int_lower, het_conf_int_upper]` (the `cband_*` columns are added by the joint sup-t Note below, populated for positive-horizon rows of paths with a finite sup-t crit and NaN otherwise; the `cumulated_*` columns are added by the per-path linear-trends Note below, populated for positive-horizon rows when `trends_linear=True` is set and NaN otherwise). Gated tests live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathGates` / `::TestByPathBehavior` / `::TestByPathEdgeCases`. **R-parity** against `DIDmultiplegtDYN 2.3.3` is confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPath` via two scenarios: `mixed_single_switch_by_path` (2 paths, `by_path=2`) and `multi_path_reversible_by_path` (4 paths, `by_path=3`; path-assignment deterministic on `F_g` so each `(D_{g,1}, F_g, S_g)` cohort contains switchers from a single path). Per-path point estimates and per-path switcher counts match R exactly; per-path SE matches within the Phase 2 multi-horizon SE envelope (observed rtol ≤ 10.2% on the 2-path mixed scenario, ≤ 4.2% on the 4-path cohort-clean scenario). **Deviation from R (cross-path cohort-sharing SE):** our analytical SE is the marginal variance of the path-contribution estimator cohort-centered on the *full-panel* cohort structure (joiners/leavers precedent — non-path switchers contribute to cohort means via their zeroed switcher row). R's `did_multiplegt_dyn(..., by_path=k)` re-runs the estimator per path, so cohort means are computed over the path's own switchers only. When a cohort `(D_{g,1}, F_g, S_g)` spans multiple observed paths, Python and R SE diverge materially (our empirical probes with random post-window toggling saw rtol > 100%); when every cohort is single-path (scenario 13 by design, scenario 14 by construction), the two approaches coincide up to the documented Phase 2 envelope. Practitioners with cohort structures that mix paths should interpret the per-path SE as a within-full-panel marginal variance, not a per-path conditional variance. **Bootstrap SE:** when `n_bootstrap > 0` is set, the top-k paths are enumerated once on the observed data (R-faithful: matches `did_multiplegt_dyn(..., by_path=k, bootstrap=B)`'s path-stability convention — verified empirically against DIDmultiplegtDYN 2.3.3) and the multiplier bootstrap (`bootstrap_weights ∈ {"rademacher", "mammen", "webb"}`) runs per `(path, horizon)` target via the shared `_bootstrap_one_target` / `compute_effect_bootstrap_stats` helpers. Point estimates are unchanged from the analytical path. Bootstrap SE replaces the analytical SE in `path_effects[path]["horizons"][l]["se"]`, and `p_value` / `conf_int` are taken as the **bootstrap percentile** statistics, matching the Round-10 library convention for overall / joiners / leavers / multi-horizon bootstrap (see the `Note (bootstrap inference surface)` elsewhere in this file and the pinned regression `test_bootstrap_p_value_and_ci_propagated_to_top_level`). `t_stat` is SE-derived via `safe_inference` per the anti-pattern rule. Interpretation: inference is *conditional on the observed path set*. **SE inherits the analytical cross-path cohort-sharing deviation:** the bootstrap input is the exact same full-panel cohort-centered path IF that the analytical path computes (`_collect_path_bootstrap_inputs` reuses the same enumeration / cohort IDs / IF construction), so the bootstrap SE is a Monte Carlo analog of the analytical SE — it inherits the same cross-path cohort-sharing deviation from R's per-path re-run convention documented above. On single-path-cohort panels (scenarios 13 and 14 of the R-parity fixture, and any DGP where `(D_{g,1}, F_g, S_g)` cohorts never span multiple observed paths), bootstrap SE tracks analytical SE up to Monte Carlo noise and both coincide with R up to the Phase 2 envelope. On cross-path cohort panels, bootstrap SE inherits the >100% rtol divergence from R that analytical already has. **Deviation from R (CI method):** R's per-path CI is normal-theory around the bootstrap SE (half-width ≈ `1.96·se`); ours is the bootstrap percentile CI, intentionally diverging from R to keep the dCDH inference surface internally consistent across all bootstrap targets. Practitioners who want *unconditional* inference capturing path-selection uncertainty need a pairs-bootstrap (deferred — no R precedent). Positive regressions live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathBootstrap` (gated `@pytest.mark.slow`): point-estimate invariance, finite positive SE on non-degenerate panels, SE-within-30%-rtol of analytical on cohort-clean fixtures, degenerate-cohort NaN propagation, Rademacher/Mammen/Webb parity, seed reproducibility, and percentile-vs-normal-theory CI pinning. **Per-path placebos:** when `placebo=True` (and `L_max >= 1`) is combined with `by_path=k`, per-path backward-horizon placebos `DID^{pl}_{path, l}` for `l = 1..L_max` are computed using the same joiners/leavers IF precedent applied to `_compute_per_group_if_placebo_horizon` (with the new `switcher_subset_mask` parameter): switcher contributions are zeroed for groups not in the path; the control pool and the variance-eligible cohort structure `(D_{g,1}, F_g, S_g)` are unchanged. Plug-in SE uses the path-specific divisor `N^{pl}_{l, path}` (count of path switchers eligible at backward lag `l`). Surfaced on `results.path_placebo_event_study[path][-l]` with the same `{effect, se, t_stat, p_value, conf_int, n_obs}` shape as `placebo_event_study` (negative-int inner keys parallel the existing per-path event-study positive-int keys, so a unified forward+backward view is well-formed). **Inherits the cross-path cohort-sharing SE deviation from R** documented above for `path_effects` (same convention applied backward); tracks R within numerical tolerance on single-path-cohort panels and diverges on cohort-mixed panels. Multiplier bootstrap (when `n_bootstrap > 0`) runs per `(path, lag)` target via the same `_bootstrap_one_target` dispatch used for the per-path event-study, with the canonical NaN-on-invalid contract. The bootstrap SE is a Monte Carlo analog of the analytical placebo SE — same per-path centered IF input — and inherits the same deviation. Surfaced through `summary()` (negative-keyed rows rendered alongside positive-keyed event-study rows under each path block) and `to_dataframe(level="by_path")` (`horizon` column takes negative ints for placebo rows). **Empty-state contract:** `results.path_placebo_event_study` mirrors `path_effects` — `None` when `by_path + placebo` was not requested, `{}` when requested but no observed path has a complete window within the panel (same regime that returns `{}` for `path_effects`, with the same fit-time `UserWarning`). R-parity is confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathPlacebo` on the `multi_path_reversible_by_path_placebo` scenario; positive analytical + bootstrap invariants live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathPlacebo` (with the gated `::TestByPathPlacebo::TestBootstrap` subclass). **Per-path covariate residualization (DID^X):** when `controls=[...]` is set with `by_path=k`, the per-baseline OLS residualization (Web Appendix Section 1.2) runs once on the first-differenced outcome BEFORE path enumeration. All four downstream surfaces — analytical per-path SE, bootstrap SE, per-path placebos, and per-path joint sup-t bands — consume the residualized `Y_mat` automatically (Frisch-Waugh-Lovell). Per-period effects remain unadjusted, consistent with the existing `controls` + per-period DID contract (per-period DID does not support residualization). Failed-stratum baselines (rank-deficient X) zero out `N_mat` for affected groups, which the path enumeration treats as ineligible per its existing convention. **Deviation from R on multi-baseline switcher panels (point estimates):** R `did_multiplegt_dyn(..., by_path, controls)` re-runs the per-baseline residualization on each path's restricted subsample (`R/R/did_multiplegt_dyn.R` lines 401-405: rows of the path's switchers OR rows where `yet_to_switch=1 AND baseline matches the path's baseline`). The first-stage residualization sample R uses for path B equals: pre-switch rows of all switchers with matching baseline + all rows of never-switchers with matching baseline — bit-identical to our global first-stage sample under single-baseline switcher panels (every switcher shares the same `D_{g,1}`, regardless of how `F_g` or path identity varies across switchers). Per-path point estimates therefore coincide with R on those panels up to the existing **DID^X first-stage cell-weighting deviation** documented above in `Note (Phase 3 DID^X covariate adjustment)` (Python's first-stage OLS uses equal cell weights — one observation per `(g, t)` cell, consistent with the library's cell-aggregated input convention; R weights by `N_gt`). On panels with one observation per `(g, t)` cell (the common case after the cell-aggregation step in `fit()`), Python matches R bit-exactly: the `multi_path_reversible_by_path_controls` parity fixture has 4 paths with switcher `F_g` values spanning [0..6] under `D_{g,1}=0` and Python matches R to rtol ~1e-11. On multi-baseline switcher panels (some switchers have `D_{g,1}=0`, others have `D_{g,1}=1`) R's per-path subset drops switchers whose baseline differs from the path's baseline, so the per-baseline regression coefficients diverge per path under R and point estimates can diverge between Python and R — a `UserWarning` is emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R. The warning filters to switcher groups only; never-switchers (never-treated + always-treated controls) at multiple baseline values do NOT trigger the warning because they don't affect R's per-path subset construction. **Inherits the cross-path cohort-sharing SE deviation from R** documented above for `path_effects` — bootstrap SE, placebo SE, and sup-t crit are Monte Carlo / joint-distribution analogs of the same residualized analytical IF and carry the same deviation. R-parity is confirmed against `did_multiplegt_dyn(..., by_path=3, controls="X1")` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathControls` on the `multi_path_reversible_by_path_controls` scenario (single-baseline DGP, exact point-estimate match measured rtol ~1e-11); cross-surface inheritance and the multi-baseline warning are regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathControls` (analytical + bootstrap + placebo + sup-t + `to_dataframe(level="by_path")` cband columns + multi-baseline `UserWarning`). **Per-path linear-trends DID^{fd}:** when `trends_linear=True` is set with `by_path=k`, the first-differencing transform at `chaisemartin_dhaultfoeuille.py:1599-1630` runs once globally BEFORE path enumeration (replaces `Y_mat` with `Z_mat = Y_t - Y_{t-1}` and shrinks the time axis by one), so per-path raw second-differences `DID^{fd}_{path, l}` surface on `path_effects[path]["horizons"][l]` automatically. Per-path cumulated level effects `delta_{path, l} = sum_{l'=1..l} DID^{fd}_{path, l'}` (the quantity R returns under `did_multiplegt_dyn(..., by_path, trends_lin)` per the existing parity test pivot at `tests/test_chaisemartin_dhaultfoeuille_parity.py:403-409`) surface on the new `results.path_cumulated_event_study[path][l]` field — a per-group running sum of `DID^{fd}_{g, l'}` averaged over the path's switchers eligible at horizon `l`, mirroring the global `linear_trends_effects` cumulation logic at `chaisemartin_dhaultfoeuille.py:3340-3398`. SE on the cumulated layer is the conservative upper bound (sum of per-horizon component SEs from `path_effects[path]["horizons"][l]["se"]`, NaN-consistent: any non-finite component yields a NaN cumulated SE). **Post-bootstrap recomputation:** the cumulated layer is built AFTER the bootstrap propagation block at `chaisemartin_dhaultfoeuille.py:3034-3081` so it reads the FINAL post-bootstrap per-horizon SEs (mirrors the global `linear_trends_effects` placement). When `n_bootstrap > 0`, cumulated SE / t / p / CI are derived from bootstrap per-horizon SEs; when bootstrap produces non-finite SE (e.g., `n_bootstrap=1` degenerate distribution), the cumulated layer's full inference tuple is NaN per the library-wide NaN-on-invalid bootstrap contract. `to_dataframe(level="by_path")` exposes `cumulated_effect` and `cumulated_se` columns (always present, NaN-when-None — mirrors the `cband_*` always-present convention from PR #374). `summary()` renders a `Cumulated Level Effects (DID^{fd}, trends_linear)` sub-section under each per-path block. **Path enumeration uses the post-first-differenced `N_mat_fd`**: switchers with `F_g==2` fail the window-eligibility check and are dropped from path enumeration entirely (the existing global `F_g >= 3` warning at line 1620 surfaces the issue), so a path whose switchers all have `F_g < 3` is silently absent from `path_effects` rather than present-with-NaN. **F_g=3 boundary-case divergence (`by_path + trends_linear`):** `F_g=3` switchers have exactly 2 pre-switch periods, which after first-differencing and the `time==1` filter leaves only 1 valid pre-window Z value. R's per-path full-pipeline call handles this single-pre-period regime differently from Python's global-then-disaggregate architecture, producing 30%+ relative divergence on point estimates for paths whose switchers include `F_g=3` (empirically observed on the parity fixture's earlier `F_g=3` variant). A separate `UserWarning` fires at fit-time when the panel includes any `F_g=3` switcher AND `by_path + trends_linear` is set, mirroring the `F_g < 3` exclusion warning. The shipped parity fixture (`single_baseline_multi_path_by_path_trends_lin`) restricts to `F_g >= 4` exclusively to avoid this regime; per-path R parity is asserted only there. **Placebo under `trends_linear` returns RAW per-horizon values** (no per-path placebo cumulation surface) — verified empirically against the existing `joiners_only_trends_lin` parity fixture: R's per-path Placebo_l matches Python's `path_placebo_event_study[path][-l]` (raw) bit-exactly under non-`by_path` trends_lin. **Deviation from R on multi-baseline switcher panels (point estimates):** R `did_multiplegt_dyn(..., by_path, trends_lin)` re-runs the full pipeline (including first-differencing) on each path's restricted subsample, so it operates on different switcher samples per path when switchers have different baseline values `D_{g,1}`. Python first-differences once globally before path enumeration. On single-baseline switcher panels the two architectures coincide; on multi-baseline switcher panels per-path point estimates can diverge — a `UserWarning` is emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R (mirroring the analogous `by_path + controls` warning). Per-path R parity is confirmed against `did_multiplegt_dyn(..., by_path=3, trends_lin=TRUE, placebo=1)` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsLinear` on the `single_baseline_multi_path_by_path_trends_lin` scenario (single-baseline + cohort-single-path + `F_g >= 4` DGP designed to eliminate the multi-baseline divergence, the cross-path cohort-sharing deviation, and the F_g=3 boundary case under R's per-path full-pipeline call). Per-path cumulated point estimates match R bit-exactly (rtol ~1e-9) on event horizons under those conditions; cumulated SE_RTOL is widened to `0.20` (vs `0.12` used for non-cumulated by_path parity) because the conservative upper-bound SE compounds the cross-path cohort-sharing deviation under summation. **Placebo parity is intentionally skipped for `trends_linear`**: R's per-path placebo computation re-runs on the path-restricted subsample with different control eligibility than Python's global-then-disaggregate architecture surfaces, producing a sign-and-magnitude divergence on paths whose switchers have minimal pre-window depth (e.g., `F_g=4` switchers). Placebo under `by_path + trends_linear` is exercised via internal regression in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsLinear` (finite values, bootstrap inheritance) but not pinned to R bit-by-bit. Cross-surface invariants (analytical + bootstrap + placebo + sup-t + `path_cumulated_event_study` + `to_dataframe` columns + `summary()` rendering) are regression-tested at `TestByPathTrendsLinear`. **Per-path state-set trends:** when `trends_nonparam="state_col"` is set with `by_path=k`, the set membership column is validated and stored once globally as `set_ids_arr` (time-invariance, NaN rejection, partition-coarseness checks unchanged from the non-by_path path). The `set_ids` parameter is threaded through the four per-path IF helpers (`_compute_path_effects`, `_compute_path_placebos`, `_collect_path_bootstrap_inputs`, `_collect_path_placebo_bootstrap_inputs`) so per-path analytical SE, bootstrap, placebos, and sup-t bands all consume the set-restricted control pool automatically. R does NOT first-difference and does NOT cumulate under `trends_nonparam` (unlike `trends_lin`); per-horizon `Effect_l` is a normal DID with set-restricted controls. Per-path R parity is confirmed against `did_multiplegt_dyn(..., by_path=3, trends_nonparam="state", placebo=1)` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsNonparam` on the `multi_path_reversible_by_path_trends_nonparam` scenario; per-path point estimates AND placebos match R bit-exactly (rtol ~1e-9), per-path SE matches within the Phase 2 envelope (~13% rtol observed). Cross-surface invariants are regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsNonparam`. **Per-path non-binary treatment:** integer-coded discrete treatment (D in Z, e.g. ordinal {0, 1, 2}) is supported under `by_path=k` and `paths_of_interest`. Path tuples become integer-state tuples (`(0, 2, 2, 2)`) keyed bit-for-bit against R's comma-separated path strings (`"0,2,2,2"`) for D in {0..9}. Continuous D (e.g. `1.5`) raises `ValueError` at fit-time per the no-silent-failures contract — the existing `int(round(float(v)))` cast in `_enumerate_treatment_paths` is now defensive (no-op for integer-coded D). **Deviation from R for multi-character baseline states (D >= 10 or negative D):** R's `did_multiplegt_by_path` derives the per-path baseline via `path_index$baseline_XX <- substr(path_index$path, 1, 1)` (extracted 2026-05-03 via `Rscript -e 'cat(paste(deparse(DIDmultiplegtDYN:::did_multiplegt_by_path), collapse="\n"))'`), capturing only the first character of the comma-separated path string. For multi-character baselines this drops the rest of the value: for `path = "12,12,..."` it captures `"1"` instead of `"12"`; for `path = "-1,-1,..."` it captures `"-"` instead of `"-1"`. R's per-path control-pool subset is mis-allocated in both regimes. Python's tuple-key matching is correct in both — the per-path point estimates we compute are correct, R's per-path subset for the same path is buggy. The shipped R-parity scenarios stay in `D in {0, 1, 2}` to avoid the R bug; R-parity is asserted on that set at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathNonBinary` via the `multi_path_reversible_by_path_non_binary` scenario (78 switchers, 3 paths, single-baseline custom DGP, F_g >= 4). The string-encoding compatibility extends to all single-digit nonnegative D (`{0..9}`) since each value renders as a single character, but no R-parity scenario currently exercises D outside `{0, 1, 2}` — per-path point estimates match R bit-exactly (rtol ~1e-9 events; rtol+atol envelope for placebo near-zero values), SE inherits the documented cross-path cohort-sharing deviation (~5% rtol observed; SE_RTOL=0.15 envelope). Negative-integer treatment-state support is regression-tested in Python only (no R parity — R is the buggy side on multi-character baselines) at two sites: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary::test_negative_integer_D_supported` covers paths with negative values in non-baseline positions (e.g. `(0, -1, -1, -1)`), and `::test_negative_baseline_path_supported` covers paths starting with a negative baseline `D_{g,1} = -1` (e.g. `(-1, 0, 0, 0)`, `(-1, 1, 1, 1)`) — the exact regime that triggers R's `substr` bug. Cross-surface invariants regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary`. **Per-path survey-design SE** (analytical Binder TSL + replicate-weight bootstrap): under `by_path` / `paths_of_interest` + `survey_design`, the per-path per-horizon SE routes through `_survey_se_from_group_if` using the cell-period allocator. The per-path influence function `U_pp_l_path` is the per-period IF with non-path switcher-side contributions skipped — control contributions remain unchanged, matching the joiners/leavers IF convention from the **Per-path SE convention** paragraph above (the `switcher_subset_mask` zeroes the switcher row of the per-group IF, which trivially zeroes the corresponding row of the per-cell IF, preserving the row-sum identity `U_pp.sum(axis=1) == U`). The IF is cohort-recentered via `_cohort_recenter_per_period` and expanded to observations as `psi_i = U_pp[g_i, t_i] · (w_i / W_{g_i, t_i})`. Replicate-weight designs unconditionally route through the cell allocator (Class A contract, PR #323). Multiplier bootstrap (`n_bootstrap > 0`) under `survey_design + by_path/paths_of_interest` raises `NotImplementedError` at fit-time — the survey-aware perturbation pivot for path-restricted IFs is methodologically underived and deferred to a future wave; the global non-by_path TSL multiplier bootstrap is unaffected and continues to ship. **Path-enumeration ranking is unweighted** under `survey_design`: top-k selection uses group cardinality (`path_to_count[p]` = number of groups), not population-weight mass — survey weights do not affect which paths are selected as "top-k". A weighted-ranking variant (sum of survey weights per path) is deferred until concrete demand. **`df_survey` propagation:** under replicate weights, every per-path per-horizon fit contributes an `n_valid` count to the shared `_replicate_n_valid_list` accumulator and the final `_effective_df_survey = min(...) - 1` reflects all per-path replicate fits. A post-call `_refresh_path_inference` helper re-runs `safe_inference` on every populated entry so `multi_horizon_inference`, `placebo_horizon_inference`, `path_effects`, and `path_placebos` all use the same final df after per-path appends complete. **Lonely-PSU policy is sample-wide, not per-path** — the `lonely_psu` policy (`remove`/`certainty`/`adjust`) operates on the full design-level PSU/strata structure, not on path-restricted subsamples. **Telescope invariant:** on a single-path panel where every switcher follows the same trajectory and `eligible_groups` matches between by_path and non-by_path, per-path SE equals the global non-by_path survey SE bit-exactly — pinned at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignTelescope::test_telescope_analytical_TSL`. **Deviation from R:** none — R `did_multiplegt_dyn` does not support survey weighting, so this is a Python-only methodology extension (no R parity available; no R parity test class). Regression test anchor: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignAnalytical` covering analytical SE, replicate-weight SE, the `n_bootstrap` gate, the global anti-regression, per-path placebos, `trends_linear` composition, and unobserved-path warnings under survey. **Per-path heterogeneity testing** (analytical OLS / WLS + survey-aware Binder TSL + replicate-weight): under `by_path` / `paths_of_interest` + `heterogeneity=""`, the per-path per-horizon coefficient `beta_X^path_l` is computed by re-running `_compute_heterogeneity_test` on the path-restricted switcher subsample. The path filter (`path_groups: Optional[Set[int]]`) restricts eligibility to switchers ON path `p` inside the inner regression; the variance machinery (HC1-robust OLS vcov for non-survey via `solve_ols(..., return_vcov=True)` (`vcov_type="hc1"` default), WLS-on-pweights with cell-period IF allocator for analytical Binder TSL, group-level allocator for Rao-Wu replicate) is unchanged from the global heterogeneity path. **Cohort dummies absorb baseline by construction** — the cohort key `(D_{g,1}, F_g, S_g)` includes baseline, so multi-baseline switcher panels do not produce R-divergence (unlike `controls` / `trends_linear`); no parallel `UserWarning` is emitted. **R parity:** matches `did_multiplegt_dyn(..., by_path, predict_het)` per-by_level on the `multi_path_reversible_by_path_predict_het` scenario for `beta`, `se`, `t_stat`, and `n_obs` (`BETA_RTOL = 1e-6` on `beta`, `SE_RTOL = 1e-5` on `se` / `t_stat`; the SE tolerance is one decade looser than `BETA_RTOL` to absorb the small OLS denominator-and-cohort-recentering numerical drift observed on this fixture; `n_obs` matches exactly). Inherits the same tolerances as the new global `multi_path_reversible_predict_het` scenario (`TestDCDHDynRParityHeterogeneity`) since the per-path R call is `did_multiplegt_main(..., predict_het=...)` per path-restricted subsample with no additional numerical loss. **R parity (heterogeneity inference, post-2026-05-15 df threading):** Python now passes `df = n_obs - rank(design)` to `safe_inference` on the non-survey OLS path at `chaisemartin_dhaultfoeuille.py`'s `_compute_heterogeneity_test`, matching R's t-distribution with df from the OLS regression (`DIDmultiplegtDYN:::did_multiplegt_main` `t_stat <- qt(0.975, df.residual(model))` site). The numerical rank is computed via `_detect_rank_deficiency` (the same helper `solve_ols` calls internally); the small-sample short-circuit also uses `n_obs <= rank` rather than the pre-PR pre-drop `n_obs <= n_params`, so boundary cases where alias dropping leaves `n_obs > rank > 0` fit correctly instead of NaN-filling. Parity tolerance is `INFERENCE_RTOL = 1e-4` on `p_value` and `conf_int`; `beta` / `se` / `t_stat` continue to use `BETA_RTOL = 1e-6` / `SE_RTOL = 1e-5`. The `t_stat = beta / se` field is distribution-invariant. **Rank-deficient designs:** ``df = n_obs - rank(design)`` uses the post-drop numerical rank via the same ``_detect_rank_deficiency`` helper that ``solve_ols`` calls internally. For full-rank designs (``rank == n_params``) behavior is bit-identical to the pre-PR ``n_obs - n_params`` path; for near-rank-deficient designs that ``solve_ols`` retains rather than NaN-out (e.g., cohort-collinearity at high horizons), the post-drop rank is strictly lower and the post-PR ``df`` is strictly larger, matching R's ``lm()`` convention. Fully rank-deficient designs continue to NaN-fill via the rank-deficient short-circuit at ``_compute_heterogeneity_test``. R's `dont_drop_larger_lower=TRUE` is set in both fixture scenarios to match the Python `drop_larger_lower=False` requirement. **Survey composition:** inherits from the **Per-path survey-design SE** paragraph above — analytical Binder TSL routes through `_survey_se_from_group_if`'s cell-period allocator on the post-period of the transition; replicate-weights route through the group-level allocator. Multiplier bootstrap (`n_bootstrap > 0`) under `by_path + heterogeneity + survey_design` inherits the existing per-path multiplier-bootstrap-survey gate. **`df_survey` propagation:** every per-(path, horizon) replicate-weight fit appends `n_valid` to the shared `_replicate_n_valid_list` accumulator; per-path heterogeneity inference is refreshed with the FINAL `_effective_df_survey(...)` in the R2 P1b refresh block (separate dedicated loop because the schema shape is `{path: {l: {...}}}` rather than `{path: {"horizons": {l: {...}}}}`). **Result schema:** `results.path_heterogeneity_effects: Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]` keyed `{path: {l: {beta, se, t_stat, p_value, conf_int, n_obs}}}`. Empty-state contract mirrors `path_effects`: `None` when not requested, `{}` when requested but no path has eligible switchers. **DataFrame integration:** `to_dataframe(level="by_path")` adds always-present `het_*` columns (`het_beta`, `het_se`, `het_t_stat`, `het_p_value`, `het_conf_int_lower`, `het_conf_int_upper`), populated for positive-horizon rows when `heterogeneity` is set and NaN otherwise (mirrors the `cband_*` and `cumulated_*` always-present convention). **Per-path placebo heterogeneity (`placebo + predict_het + by_path`, post-2026-05-15):** R-verified — `did_multiplegt_dyn(by_path, predict_het, placebo)` emits per-path heterogeneity OLS results on backward (placebo) horizons via R's per-by_level dispatcher (`DIDmultiplegtDYN:::did_multiplegt_main` placebo block at the `effect = matrix(-i, ...)` rbind site). R's predict_het syntax: passing `predict_het = list("X", c(-1))` with `placebo > 0` triggers "compute heterogeneity for ALL forward (1..effects) AND ALL placebo (1..placebo) positions"; forward rows have positive `effect` values, placebo rows negative. Python mirrors via `_compute_heterogeneity_test(..., placebo=L_max)` (set when `self.placebo` is truthy) — the function iterates forward (1..L_max) and backward (-1..-L_max) horizons in a single loop with an explicit `out_idx < 0` eligibility guard for backward horizons whose `F_g` is too small (would otherwise silently misread `N_mat` via numpy negative indexing). Placebo rows in `to_dataframe(level="by_path")` have non-NaN `het_*` columns when `placebo=True` and `heterogeneity=` are both set; `path_heterogeneity_effects` uses negative-int keys for backward horizons, mirroring the existing `path_placebo_event_study` convention. **Survey gate (warn + skip):** `survey_design + placebo + heterogeneity` emits a `UserWarning` at fit-time and falls back to forward-horizon-only heterogeneity (codex R1 P1 #1: the eager raise broke the previously-supported forward-horizon survey + predict_het path under the default `placebo=True`) — the Binder TSL cell-period allocator's justification (Survey IF expansion Note above) is tied to **post-period** attribution (`out_idx = first_switch_idx[g] - 1 + l_h` with `l_h > 0`); backward-horizon attribution puts ψ_g mass on a pre-period cell, which is a separate library-extension claim that needs its own derivation. Forward-horizon `predict_het + survey_design` continues to work unchanged on both global and per-path surfaces. The function-level `_compute_heterogeneity_test` keeps a per-iteration backstop that raises `NotImplementedError` if a direct caller bypasses fit() and passes `survey + placebo > 0` (regression-tested at `test_compute_heterogeneity_test_direct_call_raises_on_backward_survey`). Pre-period allocator derivation is deferred to a follow-up methodology PR. R parity confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathHeterogeneityWithPlacebo` on the `multi_path_reversible_predict_het_with_placebo` fixture (scenario 22, `placebo=2, effects=3, by_path=3, predict_het=list("het_x", c(-1))`) AND `::TestDCDHDynRParityHeterogeneityWithPlacebo` on the global anchor (`multi_path_reversible_predict_het_with_placebo_global`, scenario 23, same DGP without by_path) — both surfaces emit forward + backward heterogeneity rows in matching parity. Pinned at `BETA_RTOL=1e-6` / `SE_RTOL=1e-5` for `beta` / `se` / `t_stat` / `n_obs`; `INFERENCE_RTOL=1e-4` for `p_value` / `conf_int`. Cross-surface invariants regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathPredictHetPlacebo`. Regression test anchors: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathHeterogeneity` (gate dispatch, behavior, telescope-to-global on single-path panel, zero-signal anti-regression, multi-baseline UserWarning anti-regression, DataFrame integration, edge cases) + `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityHeterogeneity` (global anchor, FIRST `predict_het` parity baseline) + `::TestDCDHDynRParityByPathHeterogeneity` (per-path). +- **Note (Phase 3 `by_path` per-path event-study disaggregation):** Per-path disaggregation of the multi-horizon event study, mirroring R `did_multiplegt_dyn(..., by_path=k)`. Activated via `ChaisemartinDHaultfoeuille(by_path=k, drop_larger_lower=False)` where `k` is a positive integer (top-k most common observed paths by switcher-group frequency). **Window convention:** the path tuple for a switcher group `g` is `(D_{g, F_g-1}, D_{g, F_g}, ..., D_{g, F_g-1+L_max})` — length `L_max + 1`, matching R's window `[F_{g-1}, F_{g-1+l}]`. **Ranking:** paths are ranked by descending frequency; ties are broken lexicographically on the path tuple for deterministic ordering, so every selected path has a unique `frequency_rank`. If `by_path` exceeds the number of observed paths, all observed paths are returned with a `UserWarning`. **Per-path SE convention (joiners/leavers precedent):** the per-path influence function follows the joiners-only / leavers-only IF construction at `chaisemartin_dhaultfoeuille.py:5495-5504`: the switcher-side contribution `+S_g * (Y_{g,out} - Y_{g,ref})` is zeroed for groups whose observed trajectory is NOT the selected path; control contributions and the full cohort structure `(D_{g,1}, F_g, S_g)` are unchanged. After applying the singleton-baseline eligible mask and cohort-recentering with the original cohort IDs, the plug-in SE uses the path-specific divisor `N_l_path` (count of path switchers eligible at horizon `l`) — same pattern as `joiners_se` using `joiner_total`. This gives the **within-path mean** estimand `DID_{path,l}` as the within-path average of `DID_{g,l}`. **Degenerate-cohort behavior per path:** when a path's centered IF at some horizon is identically zero (every variance-eligible path switcher forms its own `(D_{g,1}, F_g, S_g)` cohort, or the path has a single contributing group), SE / t_stat / p_value / conf_int are NaN-consistent and a `UserWarning` is emitted scoped to `(path, horizon)`. This mirrors the overall-path degenerate-cohort surface and is common for rare paths with few contributing groups. **Empty-state contract:** `results.path_effects` distinguishes "not requested" (`None`) from "requested but empty" (`{}` — all switchers have windows outside the panel or unobserved cells). The empty-dict case emits a `UserWarning` at fit-time and renders as an explicit "no observed paths" notice in `summary()`; `to_dataframe(level="by_path")` returns an empty DataFrame with the canonical column set (mirrors the `linear_trends` pattern when `trends_linear=True` but no horizons survive). **Requirements:** `drop_larger_lower=False` (multi-switch groups are the object of interest; default `True` filters them out) and `L_max >= 1` (path window depends on the horizon). **Scope:** combinations with `design2` and `honest_did` remain gated behind explicit `NotImplementedError` (deferred to follow-up wave PRs); `heterogeneity` is supported per-path — see the **Per-path heterogeneity testing** paragraph below. `n_bootstrap > 0` is now supported — see the **Bootstrap SE** paragraph below. `survey_design` is supported under analytical Binder TSL and replicate-weight bootstrap — see the **Per-path survey-design SE** paragraph below; multiplier bootstrap (`n_bootstrap > 0`) under `survey_design + by_path/paths_of_interest` remains gated. `placebo=True` is now supported per-path — see the **Per-path placebos** paragraph below. **TWFE diagnostic** remains a sample-level summary (not computed per path) in this release. Results are exposed on `results.path_effects` as `Dict[Tuple[int, ...], Dict[str, Any]]` with nested `horizons` dicts per horizon `l`, and on `results.to_dataframe(level="by_path")` as a long-format table with columns `[path, frequency_rank, n_groups, horizon, effect, se, t_stat, p_value, conf_int_lower, conf_int_upper, n_obs, cband_lower, cband_upper, cumulated_effect, cumulated_se, het_beta, het_se, het_t_stat, het_p_value, het_conf_int_lower, het_conf_int_upper]` (the `cband_*` columns are added by the joint sup-t Note below, populated for positive-horizon rows of paths with a finite sup-t crit and NaN otherwise; the `cumulated_*` columns are added by the per-path linear-trends Note below, populated for positive-horizon rows when `trends_linear=True` is set and NaN otherwise). Gated tests live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathGates` / `::TestByPathBehavior` / `::TestByPathEdgeCases`. **R-parity** against `DIDmultiplegtDYN 2.3.3` is confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPath` via two scenarios: `mixed_single_switch_by_path` (2 paths, `by_path=2`) and `multi_path_reversible_by_path` (4 paths, `by_path=3`; path-assignment deterministic on `F_g` so each `(D_{g,1}, F_g, S_g)` cohort contains switchers from a single path). Per-path point estimates and per-path switcher counts match R exactly; per-path SE matches within the Phase 2 multi-horizon SE envelope (observed rtol ≤ 10.2% on the 2-path mixed scenario, ≤ 4.2% on the 4-path cohort-clean scenario). **Deviation from R (cross-path cohort-sharing SE):** our analytical SE is the marginal variance of the path-contribution estimator cohort-centered on the *full-panel* cohort structure (joiners/leavers precedent — non-path switchers contribute to cohort means via their zeroed switcher row). R's `did_multiplegt_dyn(..., by_path=k)` re-runs the estimator per path, so cohort means are computed over the path's own switchers only. When a cohort `(D_{g,1}, F_g, S_g)` spans multiple observed paths, Python and R SE diverge materially (our empirical probes with random post-window toggling saw rtol > 100%); when every cohort is single-path (scenario 13 by design, scenario 14 by construction), the two approaches coincide up to the documented Phase 2 envelope. Practitioners with cohort structures that mix paths should interpret the per-path SE as a within-full-panel marginal variance, not a per-path conditional variance. **Bootstrap SE:** when `n_bootstrap > 0` is set, the top-k paths are enumerated once on the observed data (R-faithful: matches `did_multiplegt_dyn(..., by_path=k, bootstrap=B)`'s path-stability convention — verified empirically against DIDmultiplegtDYN 2.3.3) and the multiplier bootstrap (`bootstrap_weights ∈ {"rademacher", "mammen", "webb"}`) runs per `(path, horizon)` target via the shared `_bootstrap_one_target` / `compute_effect_bootstrap_stats` helpers. Point estimates are unchanged from the analytical path. Bootstrap SE replaces the analytical SE in `path_effects[path]["horizons"][l]["se"]`, and `p_value` / `conf_int` are taken as the **bootstrap percentile** statistics, matching the Round-10 library convention for overall / joiners / leavers / multi-horizon bootstrap (see the `Note (bootstrap inference surface)` elsewhere in this file and the pinned regression `test_bootstrap_p_value_and_ci_propagated_to_top_level`). `t_stat` is SE-derived via `safe_inference` per the anti-pattern rule. Interpretation: inference is *conditional on the observed path set*. **SE inherits the analytical cross-path cohort-sharing deviation:** the bootstrap input is the exact same full-panel cohort-centered path IF that the analytical path computes (`_collect_path_bootstrap_inputs` reuses the same enumeration / cohort IDs / IF construction), so the bootstrap SE is a Monte Carlo analog of the analytical SE — it inherits the same cross-path cohort-sharing deviation from R's per-path re-run convention documented above. On single-path-cohort panels (scenarios 13 and 14 of the R-parity fixture, and any DGP where `(D_{g,1}, F_g, S_g)` cohorts never span multiple observed paths), bootstrap SE tracks analytical SE up to Monte Carlo noise and both coincide with R up to the Phase 2 envelope. On cross-path cohort panels, bootstrap SE inherits the >100% rtol divergence from R that analytical already has. **Deviation from R (CI method):** R's per-path CI is normal-theory around the bootstrap SE (half-width ≈ `1.96·se`); ours is the bootstrap percentile CI, intentionally diverging from R to keep the dCDH inference surface internally consistent across all bootstrap targets. Practitioners who want *unconditional* inference capturing path-selection uncertainty need a pairs-bootstrap (deferred — no R precedent). Positive regressions live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathBootstrap` (gated `@pytest.mark.slow`): point-estimate invariance, finite positive SE on non-degenerate panels, SE-within-30%-rtol of analytical on cohort-clean fixtures, degenerate-cohort NaN propagation, Rademacher/Mammen/Webb parity, seed reproducibility, and percentile-vs-normal-theory CI pinning. **Per-path placebos:** when `placebo=True` (and `L_max >= 1`) is combined with `by_path=k`, per-path backward-horizon placebos `DID^{pl}_{path, l}` for `l = 1..L_max` are computed using the same joiners/leavers IF precedent applied to `_compute_per_group_if_placebo_horizon` (with the new `switcher_subset_mask` parameter): switcher contributions are zeroed for groups not in the path; the control pool and the variance-eligible cohort structure `(D_{g,1}, F_g, S_g)` are unchanged. Plug-in SE uses the path-specific divisor `N^{pl}_{l, path}` (count of path switchers eligible at backward lag `l`). Surfaced on `results.path_placebo_event_study[path][-l]` with the same `{effect, se, t_stat, p_value, conf_int, n_obs}` shape as `placebo_event_study` (negative-int inner keys parallel the existing per-path event-study positive-int keys, so a unified forward+backward view is well-formed). **Inherits the cross-path cohort-sharing SE deviation from R** documented above for `path_effects` (same convention applied backward); tracks R within numerical tolerance on single-path-cohort panels and diverges on cohort-mixed panels. Multiplier bootstrap (when `n_bootstrap > 0`) runs per `(path, lag)` target via the same `_bootstrap_one_target` dispatch used for the per-path event-study, with the canonical NaN-on-invalid contract. The bootstrap SE is a Monte Carlo analog of the analytical placebo SE — same per-path centered IF input — and inherits the same deviation. Surfaced through `summary()` (negative-keyed rows rendered alongside positive-keyed event-study rows under each path block) and `to_dataframe(level="by_path")` (`horizon` column takes negative ints for placebo rows). **Empty-state contract:** `results.path_placebo_event_study` mirrors `path_effects` — `None` when `by_path + placebo` was not requested, `{}` when requested but no observed path has a complete window within the panel (same regime that returns `{}` for `path_effects`, with the same fit-time `UserWarning`). R-parity is confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathPlacebo` on the `multi_path_reversible_by_path_placebo` scenario; positive analytical + bootstrap invariants live in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathPlacebo` (with the gated `::TestByPathPlacebo::TestBootstrap` subclass). **Per-path covariate residualization (DID^X):** when `covariates=[...]` is set with `by_path=k`, the per-baseline OLS residualization (Web Appendix Section 1.2) runs once on the first-differenced outcome BEFORE path enumeration. All four downstream surfaces — analytical per-path SE, bootstrap SE, per-path placebos, and per-path joint sup-t bands — consume the residualized `Y_mat` automatically (Frisch-Waugh-Lovell). Per-period effects remain unadjusted, consistent with the existing `covariates` + per-period DID contract (per-period DID does not support residualization). Failed-stratum baselines (rank-deficient X) zero out `N_mat` for affected groups, which the path enumeration treats as ineligible per its existing convention. **Deviation from R on multi-baseline switcher panels (point estimates):** R `did_multiplegt_dyn(..., by_path, controls)` re-runs the per-baseline residualization on each path's restricted subsample (`R/R/did_multiplegt_dyn.R` lines 401-405: rows of the path's switchers OR rows where `yet_to_switch=1 AND baseline matches the path's baseline`). The first-stage residualization sample R uses for path B equals: pre-switch rows of all switchers with matching baseline + all rows of never-switchers with matching baseline — bit-identical to our global first-stage sample under single-baseline switcher panels (every switcher shares the same `D_{g,1}`, regardless of how `F_g` or path identity varies across switchers). Per-path point estimates therefore coincide with R on those panels up to the existing **DID^X first-stage cell-weighting deviation** documented above in `Note (Phase 3 DID^X covariate adjustment)` (Python's first-stage OLS uses equal cell weights — one observation per `(g, t)` cell, consistent with the library's cell-aggregated input convention; R weights by `N_gt`). On panels with one observation per `(g, t)` cell (the common case after the cell-aggregation step in `fit()`), Python matches R bit-exactly: the `multi_path_reversible_by_path_controls` parity fixture has 4 paths with switcher `F_g` values spanning [0..6] under `D_{g,1}=0` and Python matches R to rtol ~1e-11. On multi-baseline switcher panels (some switchers have `D_{g,1}=0`, others have `D_{g,1}=1`) R's per-path subset drops switchers whose baseline differs from the path's baseline, so the per-baseline regression coefficients diverge per path under R and point estimates can diverge between Python and R — a `UserWarning` is emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R. The warning filters to switcher groups only; never-switchers (never-treated + always-treated controls) at multiple baseline values do NOT trigger the warning because they don't affect R's per-path subset construction. **Inherits the cross-path cohort-sharing SE deviation from R** documented above for `path_effects` — bootstrap SE, placebo SE, and sup-t crit are Monte Carlo / joint-distribution analogs of the same residualized analytical IF and carry the same deviation. R-parity is confirmed against `did_multiplegt_dyn(..., by_path=3, controls="X1")` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathControls` on the `multi_path_reversible_by_path_controls` scenario (single-baseline DGP, exact point-estimate match measured rtol ~1e-11); cross-surface inheritance and the multi-baseline warning are regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathControls` (analytical + bootstrap + placebo + sup-t + `to_dataframe(level="by_path")` cband columns + multi-baseline `UserWarning`). **Per-path linear-trends DID^{fd}:** when `trends_linear=True` is set with `by_path=k`, the first-differencing transform at `chaisemartin_dhaultfoeuille.py:1599-1630` runs once globally BEFORE path enumeration (replaces `Y_mat` with `Z_mat = Y_t - Y_{t-1}` and shrinks the time axis by one), so per-path raw second-differences `DID^{fd}_{path, l}` surface on `path_effects[path]["horizons"][l]` automatically. Per-path cumulated level effects `delta_{path, l} = sum_{l'=1..l} DID^{fd}_{path, l'}` (the quantity R returns under `did_multiplegt_dyn(..., by_path, trends_lin)` per the existing parity test pivot at `tests/test_chaisemartin_dhaultfoeuille_parity.py:403-409`) surface on the new `results.path_cumulated_event_study[path][l]` field — a per-group running sum of `DID^{fd}_{g, l'}` averaged over the path's switchers eligible at horizon `l`, mirroring the global `linear_trends_effects` cumulation logic at `chaisemartin_dhaultfoeuille.py:3340-3398`. SE on the cumulated layer is the conservative upper bound (sum of per-horizon component SEs from `path_effects[path]["horizons"][l]["se"]`, NaN-consistent: any non-finite component yields a NaN cumulated SE). **Post-bootstrap recomputation:** the cumulated layer is built AFTER the bootstrap propagation block at `chaisemartin_dhaultfoeuille.py:3034-3081` so it reads the FINAL post-bootstrap per-horizon SEs (mirrors the global `linear_trends_effects` placement). When `n_bootstrap > 0`, cumulated SE / t / p / CI are derived from bootstrap per-horizon SEs; when bootstrap produces non-finite SE (e.g., `n_bootstrap=1` degenerate distribution), the cumulated layer's full inference tuple is NaN per the library-wide NaN-on-invalid bootstrap contract. `to_dataframe(level="by_path")` exposes `cumulated_effect` and `cumulated_se` columns (always present, NaN-when-None — mirrors the `cband_*` always-present convention from PR #374). `summary()` renders a `Cumulated Level Effects (DID^{fd}, trends_linear)` sub-section under each per-path block. **Path enumeration uses the post-first-differenced `N_mat_fd`**: switchers with `F_g==2` fail the window-eligibility check and are dropped from path enumeration entirely (the existing global `F_g >= 3` warning at line 1620 surfaces the issue), so a path whose switchers all have `F_g < 3` is silently absent from `path_effects` rather than present-with-NaN. **F_g=3 boundary-case divergence (`by_path + trends_linear`):** `F_g=3` switchers have exactly 2 pre-switch periods, which after first-differencing and the `time==1` filter leaves only 1 valid pre-window Z value. R's per-path full-pipeline call handles this single-pre-period regime differently from Python's global-then-disaggregate architecture, producing 30%+ relative divergence on point estimates for paths whose switchers include `F_g=3` (empirically observed on the parity fixture's earlier `F_g=3` variant). A separate `UserWarning` fires at fit-time when the panel includes any `F_g=3` switcher AND `by_path + trends_linear` is set, mirroring the `F_g < 3` exclusion warning. The shipped parity fixture (`single_baseline_multi_path_by_path_trends_lin`) restricts to `F_g >= 4` exclusively to avoid this regime; per-path R parity is asserted only there. **Placebo under `trends_linear` returns RAW per-horizon values** (no per-path placebo cumulation surface) — verified empirically against the existing `joiners_only_trends_lin` parity fixture: R's per-path Placebo_l matches Python's `path_placebo_event_study[path][-l]` (raw) bit-exactly under non-`by_path` trends_lin. **Deviation from R on multi-baseline switcher panels (point estimates):** R `did_multiplegt_dyn(..., by_path, trends_lin)` re-runs the full pipeline (including first-differencing) on each path's restricted subsample, so it operates on different switcher samples per path when switchers have different baseline values `D_{g,1}`. Python first-differences once globally before path enumeration. On single-baseline switcher panels the two architectures coincide; on multi-baseline switcher panels per-path point estimates can diverge — a `UserWarning` is emitted at fit-time when this configuration is detected so practitioners do not silently consume estimates that disagree with R (mirroring the analogous `by_path + covariates` warning). Per-path R parity is confirmed against `did_multiplegt_dyn(..., by_path=3, trends_lin=TRUE, placebo=1)` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsLinear` on the `single_baseline_multi_path_by_path_trends_lin` scenario (single-baseline + cohort-single-path + `F_g >= 4` DGP designed to eliminate the multi-baseline divergence, the cross-path cohort-sharing deviation, and the F_g=3 boundary case under R's per-path full-pipeline call). Per-path cumulated point estimates match R bit-exactly (rtol ~1e-9) on event horizons under those conditions; cumulated SE_RTOL is widened to `0.20` (vs `0.12` used for non-cumulated by_path parity) because the conservative upper-bound SE compounds the cross-path cohort-sharing deviation under summation. **Placebo parity is intentionally skipped for `trends_linear`**: R's per-path placebo computation re-runs on the path-restricted subsample with different control eligibility than Python's global-then-disaggregate architecture surfaces, producing a sign-and-magnitude divergence on paths whose switchers have minimal pre-window depth (e.g., `F_g=4` switchers). Placebo under `by_path + trends_linear` is exercised via internal regression in `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsLinear` (finite values, bootstrap inheritance) but not pinned to R bit-by-bit. Cross-surface invariants (analytical + bootstrap + placebo + sup-t + `path_cumulated_event_study` + `to_dataframe` columns + `summary()` rendering) are regression-tested at `TestByPathTrendsLinear`. **Per-path state-set trends:** when `trends_nonparam="state_col"` is set with `by_path=k`, the set membership column is validated and stored once globally as `set_ids_arr` (time-invariance, NaN rejection, partition-coarseness checks unchanged from the non-by_path path). The `set_ids` parameter is threaded through the four per-path IF helpers (`_compute_path_effects`, `_compute_path_placebos`, `_collect_path_bootstrap_inputs`, `_collect_path_placebo_bootstrap_inputs`) so per-path analytical SE, bootstrap, placebos, and sup-t bands all consume the set-restricted control pool automatically. R does NOT first-difference and does NOT cumulate under `trends_nonparam` (unlike `trends_lin`); per-horizon `Effect_l` is a normal DID with set-restricted controls. Per-path R parity is confirmed against `did_multiplegt_dyn(..., by_path=3, trends_nonparam="state", placebo=1)` at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathTrendsNonparam` on the `multi_path_reversible_by_path_trends_nonparam` scenario; per-path point estimates AND placebos match R bit-exactly (rtol ~1e-9), per-path SE matches within the Phase 2 envelope (~13% rtol observed). Cross-surface invariants are regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathTrendsNonparam`. **Per-path non-binary treatment:** integer-coded discrete treatment (D in Z, e.g. ordinal {0, 1, 2}) is supported under `by_path=k` and `paths_of_interest`. Path tuples become integer-state tuples (`(0, 2, 2, 2)`) keyed bit-for-bit against R's comma-separated path strings (`"0,2,2,2"`) for D in {0..9}. Continuous D (e.g. `1.5`) raises `ValueError` at fit-time per the no-silent-failures contract — the existing `int(round(float(v)))` cast in `_enumerate_treatment_paths` is now defensive (no-op for integer-coded D). **Deviation from R for multi-character baseline states (D >= 10 or negative D):** R's `did_multiplegt_by_path` derives the per-path baseline via `path_index$baseline_XX <- substr(path_index$path, 1, 1)` (extracted 2026-05-03 via `Rscript -e 'cat(paste(deparse(DIDmultiplegtDYN:::did_multiplegt_by_path), collapse="\n"))'`), capturing only the first character of the comma-separated path string. For multi-character baselines this drops the rest of the value: for `path = "12,12,..."` it captures `"1"` instead of `"12"`; for `path = "-1,-1,..."` it captures `"-"` instead of `"-1"`. R's per-path control-pool subset is mis-allocated in both regimes. Python's tuple-key matching is correct in both — the per-path point estimates we compute are correct, R's per-path subset for the same path is buggy. The shipped R-parity scenarios stay in `D in {0, 1, 2}` to avoid the R bug; R-parity is asserted on that set at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathNonBinary` via the `multi_path_reversible_by_path_non_binary` scenario (78 switchers, 3 paths, single-baseline custom DGP, F_g >= 4). The string-encoding compatibility extends to all single-digit nonnegative D (`{0..9}`) since each value renders as a single character, but no R-parity scenario currently exercises D outside `{0, 1, 2}` — per-path point estimates match R bit-exactly (rtol ~1e-9 events; rtol+atol envelope for placebo near-zero values), SE inherits the documented cross-path cohort-sharing deviation (~5% rtol observed; SE_RTOL=0.15 envelope). Negative-integer treatment-state support is regression-tested in Python only (no R parity — R is the buggy side on multi-character baselines) at two sites: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary::test_negative_integer_D_supported` covers paths with negative values in non-baseline positions (e.g. `(0, -1, -1, -1)`), and `::test_negative_baseline_path_supported` covers paths starting with a negative baseline `D_{g,1} = -1` (e.g. `(-1, 0, 0, 0)`, `(-1, 1, 1, 1)`) — the exact regime that triggers R's `substr` bug. Cross-surface invariants regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary`. **Per-path survey-design SE** (analytical Binder TSL + replicate-weight bootstrap): under `by_path` / `paths_of_interest` + `survey_design`, the per-path per-horizon SE routes through `_survey_se_from_group_if` using the cell-period allocator. The per-path influence function `U_pp_l_path` is the per-period IF with non-path switcher-side contributions skipped — control contributions remain unchanged, matching the joiners/leavers IF convention from the **Per-path SE convention** paragraph above (the `switcher_subset_mask` zeroes the switcher row of the per-group IF, which trivially zeroes the corresponding row of the per-cell IF, preserving the row-sum identity `U_pp.sum(axis=1) == U`). The IF is cohort-recentered via `_cohort_recenter_per_period` and expanded to observations as `psi_i = U_pp[g_i, t_i] · (w_i / W_{g_i, t_i})`. Replicate-weight designs unconditionally route through the cell allocator (Class A contract, PR #323). Multiplier bootstrap (`n_bootstrap > 0`) under `survey_design + by_path/paths_of_interest` raises `NotImplementedError` at fit-time — the survey-aware perturbation pivot for path-restricted IFs is methodologically underived and deferred to a future wave; the global non-by_path TSL multiplier bootstrap is unaffected and continues to ship. **Path-enumeration ranking is unweighted** under `survey_design`: top-k selection uses group cardinality (`path_to_count[p]` = number of groups), not population-weight mass — survey weights do not affect which paths are selected as "top-k". A weighted-ranking variant (sum of survey weights per path) is deferred until concrete demand. **`df_survey` propagation:** under replicate weights, every per-path per-horizon fit contributes an `n_valid` count to the shared `_replicate_n_valid_list` accumulator and the final `_effective_df_survey = min(...) - 1` reflects all per-path replicate fits. A post-call `_refresh_path_inference` helper re-runs `safe_inference` on every populated entry so `multi_horizon_inference`, `placebo_horizon_inference`, `path_effects`, and `path_placebos` all use the same final df after per-path appends complete. **Lonely-PSU policy is sample-wide, not per-path** — the `lonely_psu` policy (`remove`/`certainty`/`adjust`) operates on the full design-level PSU/strata structure, not on path-restricted subsamples. **Telescope invariant:** on a single-path panel where every switcher follows the same trajectory and `eligible_groups` matches between by_path and non-by_path, per-path SE equals the global non-by_path survey SE bit-exactly — pinned at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignTelescope::test_telescope_analytical_TSL`. **Deviation from R:** none — R `did_multiplegt_dyn` does not support survey weighting, so this is a Python-only methodology extension (no R parity available; no R parity test class). Regression test anchor: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSurveyDesignAnalytical` covering analytical SE, replicate-weight SE, the `n_bootstrap` gate, the global anti-regression, per-path placebos, `trends_linear` composition, and unobserved-path warnings under survey. **Per-path heterogeneity testing** (analytical OLS / WLS + survey-aware Binder TSL + replicate-weight): under `by_path` / `paths_of_interest` + `heterogeneity=""`, the per-path per-horizon coefficient `beta_X^path_l` is computed by re-running `_compute_heterogeneity_test` on the path-restricted switcher subsample. The path filter (`path_groups: Optional[Set[int]]`) restricts eligibility to switchers ON path `p` inside the inner regression; the variance machinery (HC1-robust OLS vcov for non-survey via `solve_ols(..., return_vcov=True)` (`vcov_type="hc1"` default), WLS-on-pweights with cell-period IF allocator for analytical Binder TSL, group-level allocator for Rao-Wu replicate) is unchanged from the global heterogeneity path. **Cohort dummies absorb baseline by construction** — the cohort key `(D_{g,1}, F_g, S_g)` includes baseline, so multi-baseline switcher panels do not produce R-divergence (unlike `covariates` / `trends_linear`); no parallel `UserWarning` is emitted. **R parity:** matches `did_multiplegt_dyn(..., by_path, predict_het)` per-by_level on the `multi_path_reversible_by_path_predict_het` scenario for `beta`, `se`, `t_stat`, and `n_obs` (`BETA_RTOL = 1e-6` on `beta`, `SE_RTOL = 1e-5` on `se` / `t_stat`; the SE tolerance is one decade looser than `BETA_RTOL` to absorb the small OLS denominator-and-cohort-recentering numerical drift observed on this fixture; `n_obs` matches exactly). Inherits the same tolerances as the new global `multi_path_reversible_predict_het` scenario (`TestDCDHDynRParityHeterogeneity`) since the per-path R call is `did_multiplegt_main(..., predict_het=...)` per path-restricted subsample with no additional numerical loss. **R parity (heterogeneity inference, post-2026-05-15 df threading):** Python now passes `df = n_obs - rank(design)` to `safe_inference` on the non-survey OLS path at `chaisemartin_dhaultfoeuille.py`'s `_compute_heterogeneity_test`, matching R's t-distribution with df from the OLS regression (`DIDmultiplegtDYN:::did_multiplegt_main` `t_stat <- qt(0.975, df.residual(model))` site). The numerical rank is computed via `_detect_rank_deficiency` (the same helper `solve_ols` calls internally); the small-sample short-circuit also uses `n_obs <= rank` rather than the pre-PR pre-drop `n_obs <= n_params`, so boundary cases where alias dropping leaves `n_obs > rank > 0` fit correctly instead of NaN-filling. Parity tolerance is `INFERENCE_RTOL = 1e-4` on `p_value` and `conf_int`; `beta` / `se` / `t_stat` continue to use `BETA_RTOL = 1e-6` / `SE_RTOL = 1e-5`. The `t_stat = beta / se` field is distribution-invariant. **Rank-deficient designs:** ``df = n_obs - rank(design)`` uses the post-drop numerical rank via the same ``_detect_rank_deficiency`` helper that ``solve_ols`` calls internally. For full-rank designs (``rank == n_params``) behavior is bit-identical to the pre-PR ``n_obs - n_params`` path; for near-rank-deficient designs that ``solve_ols`` retains rather than NaN-out (e.g., cohort-collinearity at high horizons), the post-drop rank is strictly lower and the post-PR ``df`` is strictly larger, matching R's ``lm()`` convention. Fully rank-deficient designs continue to NaN-fill via the rank-deficient short-circuit at ``_compute_heterogeneity_test``. R's `dont_drop_larger_lower=TRUE` is set in both fixture scenarios to match the Python `drop_larger_lower=False` requirement. **Survey composition:** inherits from the **Per-path survey-design SE** paragraph above — analytical Binder TSL routes through `_survey_se_from_group_if`'s cell-period allocator on the post-period of the transition; replicate-weights route through the group-level allocator. Multiplier bootstrap (`n_bootstrap > 0`) under `by_path + heterogeneity + survey_design` inherits the existing per-path multiplier-bootstrap-survey gate. **`df_survey` propagation:** every per-(path, horizon) replicate-weight fit appends `n_valid` to the shared `_replicate_n_valid_list` accumulator; per-path heterogeneity inference is refreshed with the FINAL `_effective_df_survey(...)` in the R2 P1b refresh block (separate dedicated loop because the schema shape is `{path: {l: {...}}}` rather than `{path: {"horizons": {l: {...}}}}`). **Result schema:** `results.path_heterogeneity_effects: Dict[Tuple[int, ...], Dict[int, Dict[str, Any]]]` keyed `{path: {l: {beta, se, t_stat, p_value, conf_int, n_obs}}}`. Empty-state contract mirrors `path_effects`: `None` when not requested, `{}` when requested but no path has eligible switchers. **DataFrame integration:** `to_dataframe(level="by_path")` adds always-present `het_*` columns (`het_beta`, `het_se`, `het_t_stat`, `het_p_value`, `het_conf_int_lower`, `het_conf_int_upper`), populated for positive-horizon rows when `heterogeneity` is set and NaN otherwise (mirrors the `cband_*` and `cumulated_*` always-present convention). **Per-path placebo heterogeneity (`placebo + predict_het + by_path`, post-2026-05-15):** R-verified — `did_multiplegt_dyn(by_path, predict_het, placebo)` emits per-path heterogeneity OLS results on backward (placebo) horizons via R's per-by_level dispatcher (`DIDmultiplegtDYN:::did_multiplegt_main` placebo block at the `effect = matrix(-i, ...)` rbind site). R's predict_het syntax: passing `predict_het = list("X", c(-1))` with `placebo > 0` triggers "compute heterogeneity for ALL forward (1..effects) AND ALL placebo (1..placebo) positions"; forward rows have positive `effect` values, placebo rows negative. Python mirrors via `_compute_heterogeneity_test(..., placebo=L_max)` (set when `self.placebo` is truthy) — the function iterates forward (1..L_max) and backward (-1..-L_max) horizons in a single loop with an explicit `out_idx < 0` eligibility guard for backward horizons whose `F_g` is too small (would otherwise silently misread `N_mat` via numpy negative indexing). Placebo rows in `to_dataframe(level="by_path")` have non-NaN `het_*` columns when `placebo=True` and `heterogeneity=` are both set; `path_heterogeneity_effects` uses negative-int keys for backward horizons, mirroring the existing `path_placebo_event_study` convention. **Survey gate (warn + skip):** `survey_design + placebo + heterogeneity` emits a `UserWarning` at fit-time and falls back to forward-horizon-only heterogeneity (codex R1 P1 #1: the eager raise broke the previously-supported forward-horizon survey + predict_het path under the default `placebo=True`) — the Binder TSL cell-period allocator's justification (Survey IF expansion Note above) is tied to **post-period** attribution (`out_idx = first_switch_idx[g] - 1 + l_h` with `l_h > 0`); backward-horizon attribution puts ψ_g mass on a pre-period cell, which is a separate library-extension claim that needs its own derivation. Forward-horizon `predict_het + survey_design` continues to work unchanged on both global and per-path surfaces. The function-level `_compute_heterogeneity_test` keeps a per-iteration backstop that raises `NotImplementedError` if a direct caller bypasses fit() and passes `survey + placebo > 0` (regression-tested at `test_compute_heterogeneity_test_direct_call_raises_on_backward_survey`). Pre-period allocator derivation is deferred to a follow-up methodology PR. R parity confirmed at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathHeterogeneityWithPlacebo` on the `multi_path_reversible_predict_het_with_placebo` fixture (scenario 22, `placebo=2, effects=3, by_path=3, predict_het=list("het_x", c(-1))`) AND `::TestDCDHDynRParityHeterogeneityWithPlacebo` on the global anchor (`multi_path_reversible_predict_het_with_placebo_global`, scenario 23, same DGP without by_path) — both surfaces emit forward + backward heterogeneity rows in matching parity. Pinned at `BETA_RTOL=1e-6` / `SE_RTOL=1e-5` for `beta` / `se` / `t_stat` / `n_obs`; `INFERENCE_RTOL=1e-4` for `p_value` / `conf_int`. Cross-surface invariants regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathPredictHetPlacebo`. Regression test anchors: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathHeterogeneity` (gate dispatch, behavior, telescope-to-global on single-path panel, zero-signal anti-regression, multi-baseline UserWarning anti-regression, DataFrame integration, edge cases) + `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityHeterogeneity` (global anchor, FIRST `predict_het` parity baseline) + `::TestDCDHDynRParityByPathHeterogeneity` (per-path). -**Per-path user-specified path selection (`paths_of_interest`):** Python-only API extension — R's `did_multiplegt_dyn(..., by_path=k)` only accepts a positive int (top-k automatic ranking) or `-1` (all observed paths) and provides no list-based selection. Activated via `ChaisemartinDHaultfoeuille(paths_of_interest=[(0, 1, 1, 1), (0, 1, 0, 0)], drop_larger_lower=False)` as an alternative to `by_path=k`; the two are **mutually exclusive** (setting both raises `ValueError` at `__init__` and `set_params` time). Each path tuple must have length `L_max + 1`; the type / element / non-empty / length-uniformity checks fire at `__init__`, the length-vs-L_max check fires at fit-time. `bool` and `np.bool_` are explicitly rejected; `np.integer` is accepted and canonicalized to Python `int` for tuple-key consistency. Duplicates emit a `UserWarning` and are deduplicated; paths not observed in the panel emit a `UserWarning` and are omitted from `path_effects`. Paths appear in `results.path_effects` in the user-specified order, modulo deduplication and unobserved-path filtering. Composes with non-binary D and all downstream `by_path` surfaces (bootstrap, per-path placebos, per-path joint sup-t bands, `controls`, `trends_linear`, `trends_nonparam`) — mechanical filter on observed paths, no methodology change. Behavior + cross-feature regressions live at `tests/test_chaisemartin_dhaultfoeuille.py::TestPathsOfInterest`. +**Per-path user-specified path selection (`paths_of_interest`):** Python-only API extension — R's `did_multiplegt_dyn(..., by_path=k)` only accepts a positive int (top-k automatic ranking) or `-1` (all observed paths) and provides no list-based selection. Activated via `ChaisemartinDHaultfoeuille(paths_of_interest=[(0, 1, 1, 1), (0, 1, 0, 0)], drop_larger_lower=False)` as an alternative to `by_path=k`; the two are **mutually exclusive** (setting both raises `ValueError` at `__init__` and `set_params` time). Each path tuple must have length `L_max + 1`; the type / element / non-empty / length-uniformity checks fire at `__init__`, the length-vs-L_max check fires at fit-time. `bool` and `np.bool_` are explicitly rejected; `np.integer` is accepted and canonicalized to Python `int` for tuple-key consistency. Duplicates emit a `UserWarning` and are deduplicated; paths not observed in the panel emit a `UserWarning` and are omitted from `path_effects`. Paths appear in `results.path_effects` in the user-specified order, modulo deduplication and unobserved-path filtering. Composes with non-binary D and all downstream `by_path` surfaces (bootstrap, per-path placebos, per-path joint sup-t bands, `covariates`, `trends_linear`, `trends_nonparam`) — mechanical filter on observed paths, no methodology change. Behavior + cross-feature regressions live at `tests/test_chaisemartin_dhaultfoeuille.py::TestPathsOfInterest`. - **Note (Phase 3 `by_path` per-path joint sup-t bands):** When `n_bootstrap > 0` is set with `by_path=k`, per-path joint sup-t simultaneous confidence bands are computed across horizons `1..L_max` within each path. **Methodology:** a single `(n_bootstrap, n_eligible)` multiplier weight matrix (using the estimator's configured `bootstrap_weights` — Rademacher / Mammen / Webb) is drawn per path and broadcast across all horizons of that path, producing correlated bootstrap distributions across horizons within the path. The path-specific critical value `c_p = quantile(max_l |t_l|, 1 - α)` is then used to construct symmetric joint bands `effect_l ± c_p · se_l` per horizon, surfaced in `path_effects[path]["horizons"][l]["cband_conf_int"]` and at top-level `results.path_sup_t_bands[path] = {"crit_value", "alpha", "n_bootstrap", "method", "n_valid_horizons"}`. **Gates:** a path must have `>= 2` valid horizons (finite bootstrap SE > 0) AND a strict majority (more than 50%) of finite sup-t draws to receive a band; otherwise the path is absent from `path_sup_t_bands`. Both gates mirror the OVERALL `event_study_sup_t_bands` semantics at `chaisemartin_dhaultfoeuille_bootstrap.py:605,612`: `len(valid_horizons) >= 2` AND `finite_mask.sum() > 0.5 * n_bootstrap`. Exactly half-finite draws are NOT enough — the gate is strictly greater than half. **Empty-state contract:** `path_sup_t_bands is None` when not requested (no bootstrap, or both `by_path` and `paths_of_interest` are `None`); `{}` when requested but no path passes both gates. **`to_dataframe(level="by_path")` integration:** the table now includes `cband_lower` / `cband_upper` columns for parity with OVERALL `level="event_study"`; populated for positive-horizon rows of paths with a finite sup-t crit, NaN for placebo rows / unbanded paths / the requested-but-empty fallback DataFrame. **Methodology asymmetry vs OVERALL:** OVERALL sup-t reuses the same multi-horizon shared-draw distribution for both the SE in the t-stat denominator and the bootstrap distribution in the numerator. The per-path sup-t draws a fresh shared weight matrix per path AFTER the per-path SE bootstrap block has already populated `results.path_ses` via independent per-(path, horizon) draws — numerator: fresh shared draws, denominator: bootstrap SEs from the earlier independent draws. Asymptotically equivalent to OVERALL's self-consistent reuse, but NOT bit-identical. The fresh draw is intentional: it preserves RNG-state isolation and keeps every existing per-path SE seed-reproducibility test bit-stable post-implementation. **Inherited deviation from R:** the bootstrap SE used as the t-stat denominator carries the cross-path cohort-sharing SE deviation from R documented for `path_effects` above; the per-path sup-t crit therefore inherits the same deviation. **Interpretation:** the band covers joint inference *within a single path across horizons*; it does NOT provide simultaneous coverage *across paths* (a different inference target requiring a `path × horizon` re-derivation, deferred to a future wave). **Deviation from R:** `did_multiplegt_dyn` provides no joint / sup-t / simultaneous bands at any surface — this is a Python-only methodology extension, consistent with the existing OVERALL `event_study_sup_t_bands` (also Python-only). Regression test anchor: `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathSupTBands`. @@ -1156,7 +1156,7 @@ The guard is fired by `_survey_se_from_group_if` (analytical and replicate) and **Requirements checklist:** - [x] Single class `ChaisemartinDHaultfoeuille` (alias `DCDH`); not a family -- [x] Forward-compat `fit()` signature with `NotImplementedError` gate for `aggregate`; survey_design now supported (pweight + strata/PSU/FPC via TSL); Phase 3 gates lifted for `controls`, `trends_linear`, `trends_nonparam`, `honest_did` +- [x] Forward-compat `fit()` signature with `NotImplementedError` gate for `aggregate`; survey_design now supported (pweight + strata/PSU/FPC via TSL); Phase 3 gates lifted for `covariates` (formerly `controls`), `trends_linear`, `trends_nonparam`, `honest_did` - [x] `DID_M` point estimate with cohort-recentered analytical SE - [x] Joiners-only `DID_+` and leavers-only `DID_-` decompositions with their own inference - [x] Single-lag placebo `DID_M^pl` (point estimate; SE deferred to Phase 2) @@ -3640,7 +3640,7 @@ Under `survey_design=SurveyDesign(weights, strata, psu, fpc)`, the variance comp - **Extensive-margin effects**: ruled out by Assumption 3. If a jump `Y_2(0) ≠ Y_2(0+)` is suspected, the target parameter and estimator are not appropriate. - **Partial identification of WAS_{d̲}**: only identified up to a positive constant offset `≤ ε` by the bound in Equation 22 (Jensen inequality argument in Appendix C.3). - **Density at boundary**: Assumption 4 requires `f_{D_2}(0) > 0`. This is a non-trivial assumption since 0 is on the boundary of `Supp(D_2)`. -- **Variation in treatment timing**: Appendix B.2 - "in designs with variation in treatment timing, there must be an untreated group, at least till the period where the last cohort gets treated." In Phase 2b (`aggregate="event_study"`) the implementation auto-filters to the last-treatment cohort plus never-treated units with a `UserWarning` when `first_treat_col` is supplied (see Phase 2b last-cohort filter note below); when `first_treat_col` is omitted the estimator detects multiple first-positive-dose cohorts from the dose path and raises a front-door `ValueError` directing users to pass `first_treat_col` or use `ChaisemartinDHaultfoeuille`. +- **Variation in treatment timing**: Appendix B.2 - "in designs with variation in treatment timing, there must be an untreated group, at least till the period where the last cohort gets treated." In Phase 2b (`aggregate="event_study"`) the implementation auto-filters to the last-treatment cohort plus never-treated units with a `UserWarning` when `first_treat` is supplied (see Phase 2b last-cohort filter note below); when `first_treat` is omitted the estimator detects multiple first-positive-dose cohorts from the dose path and raises a front-door `ValueError` directing users to pass `first_treat` or use `ChaisemartinDHaultfoeuille`. - **Mechanical zero at reference period under linear trends (Footnote 13, main text p. 31)**: with industry/unit-specific linear trends, the pre-trends estimator is mechanically zero in the second-to-last pre-period (the slope anchor year). Practical consequence: that year is not an informative placebo check. *Algorithm (Design 1' nonparametric - summarized from Section 3.1.3-3.1.4 and Equations 7-8):* @@ -3772,7 +3772,7 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs when `aggregate="event_study"` AND `cband=True` (default) AND either `survey_design=` is supplied (survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. - **Note:** Pierce-Schott (2016) Figure 2 replication harness deferred. The paper's empirical application self-acknowledges (Section 5.2; mirrored in `dechaisemartin-2026-review.md:321`) that "NP estimators are too noisy to be informative" on the LBD-restricted PNTR panel. R parity at `atol=1e-8` on 3 DGPs × 5 method combos via `tests/test_did_had_parity.py` (bit-exact, `rtol=0`) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. **Scope caveat:** R parity locks point estimate, SE, and CI bounds bit-exactly to R's bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery. - **Note:** Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at `G ∈ {100, 500, 2500}` on DGPs 1/2/3. The existing `tests/test_did_had_parity.py` R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. **Scope caveat (mirrors above):** R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1's 89% / 93% / 95% under-coverage band is valid for both R and Python. -- **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat_col`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat_col` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. +- **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. - **Note:** Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only "warn" with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at `f_{D_2}(0) = 0`). The library therefore emits a `UserWarning` at `HeterogeneousAdoptionDiD.fit()` time only when the fraction of units with EXACTLY-zero post-period dose is `>= 0.10` (`_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRAC` in `diff_diff/had.py`) — a 10% library-convention cutoff chosen to sit ~25× above the paper's kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. **Overall path only:** the warning is emitted after the `aggregate="event_study"` dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only via `qug_test()`'s zero-dose `UserWarning` (which fires only when the user runs the pretests). Lock in `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. - **Note:** `covariates=` is reserved but NOT implemented. `HeterogeneousAdoptionDiD.fit(covariates=...)` raises `NotImplementedError` — an explicit keyword-only param, so the message points to the deferred extension instead of letting an unknown kwarg surface as a bare `TypeError`. Covariate-adjusted HAD identification is the paper's Appendix B.1 / Theorem 6 multivariate-covariate extension (a multivariate nonparametric regression of ΔY on (D, X) at the dose boundary), which is not derived in the library. Workaround: pre-residualize the outcome on the covariates before calling `fit()`, or omit `covariates=` for the unconditional WAS estimand. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented`. @@ -3803,7 +3803,7 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **Note (panel-only):** The paper (Section 2) defines HAD on *panel or repeated cross-section* data, but both the overall and event-study paths ship a panel-only implementation: `HeterogeneousAdoptionDiD.fit()` requires a balanced panel with a unit identifier so that unit-level first differences `ΔY_{g,t} = Y_{g,t} - Y_{g,t_anchor}` can be formed. Repeated-cross-section inputs (disjoint unit IDs between periods) are rejected by the balanced-panel validator. RCS support is queued for a follow-up PR (tracked in `DEFERRED.md`); it will need a separate identification path based on pre/post cell means rather than unit-level differences. - [x] Phase 2b: Multi-period event-study extension (Appendix B.2). `aggregate="event_study"` produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (initially deferred; shipped in Phase 3 via `stute_joint_pretest()` — see the Phase 3 entries below). - [x] Phase 2b: event-study `cluster=` threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of `_sup_t_multiplier_bootstrap` (continuous scale 1.0; mass-point `√(G/(G-1))`). Closes the former "cluster ignored on the nonparametric path" deferral (`TODO.md`). See "Note (HAD clustered event-study sup-t band)". `cluster=` + `survey_design=` remains rejected (for weighted clustering route through `survey_design=SurveyDesign(weights=..., psu=...)`). - - **Note (Phase 2b last-cohort filter):** When `first_treat_col` indicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (`F_last = max(cohorts)`) **plus never-treated units** (`first_treat = 0`), with a `UserWarning` naming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD "may be used only for the LAST treatment cohort in a staggered design"; the auto-filter implements this prescription, retaining never-treated units per the paper's "there must be an untreated group, at least till the period where the last cohort gets treated" requirement. Only earlier-cohort units (with `first_treat > 0` and `< F_last`) are dropped — never-treated units satisfy the dose invariant at every period (`D = 0` throughout) and preserve Design 1' identifiability (boundary at `0`) when last-cohort doses are uniformly positive. When `first_treat_col` is omitted on a >2-period panel, the validator infers each unit's first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-door `ValueError` directing users to pass `first_treat_col` (which activates the auto-filter) or use `ChaisemartinDHaultfoeuille` for full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged with `F` inferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer to `ChaisemartinDHaultfoeuille`. + - **Note (Phase 2b last-cohort filter):** When `first_treat` indicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (`F_last = max(cohorts)`) **plus never-treated units** (`first_treat = 0`), with a `UserWarning` naming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD "may be used only for the LAST treatment cohort in a staggered design"; the auto-filter implements this prescription, retaining never-treated units per the paper's "there must be an untreated group, at least till the period where the last cohort gets treated" requirement. Only earlier-cohort units (with `first_treat > 0` and `< F_last`) are dropped — never-treated units satisfy the dose invariant at every period (`D = 0` throughout) and preserve Design 1' identifiability (boundary at `0`) when last-cohort doses are uniformly positive. When `first_treat` is omitted on a >2-period panel, the validator infers each unit's first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-door `ValueError` directing users to pass `first_treat` (which activates the auto-filter) or use `ChaisemartinDHaultfoeuille` for full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged with `F` inferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer to `ChaisemartinDHaultfoeuille`. - **Note (Phase 2b constant-dose requirement):** The event-study aggregation uses `D_{g, F}` (first-treatment-period dose) as the single regressor for every event-time horizon, per paper Appendix B.2's "once treated, stay treated with the same dose" convention. The validator REJECTS panels where a unit has time-varying dose across post-treatment periods (`D_{g, t} != D_{g, F}` for any `t >= F` within-unit, beyond float tolerance) with a front-door `ValueError`, directing users with genuinely time-varying post-treatment doses to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Silent acceptance would misattribute later-horizon treatment-effect heterogeneity to the period-F dose. A follow-up PR could implement a time-varying-dose estimator; tracked in `DEFERRED.md`. - **Note (Phase 2b per-horizon SE):** Each event-time horizon uses an INDEPENDENT sandwich computed on that horizon's first differences: continuous paths use the CCT-2014 robust SE from Phase 1c divided by `|den|`; mass-point path uses the structural-residual 2SLS sandwich from Phase 2a. This produces pointwise CIs per horizon, matching the paper's Pierce-Schott application (Section 5.2, Figure 2: "nonparametric pointwise CIs"). Joint cross-horizon covariance (IF-based stacking or block bootstrap) is NOT computed — the paper does not derive it and all reported CIs are pointwise. Follow-up PRs may add joint covariance for cross-horizon hypothesis tests; current tracking in `DEFERRED.md`. - **Note (Phase 2b baseline convention):** All event-time horizons use a uniform `F-1` anchor: `ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every `t`. This is consistent with the paper's Garrett-et-al. application (Section 5.1: "outcome `Y_{g,t} - Y_{g,2001}`" where `F = 2002`), simplifies event-time indexing (`e = t - F` so `e = -1` is the anchor, skipped), and keeps the implementation symmetric for pre- and post-period horizons. The paper review text's asymmetric "`Y_{g,t} - Y_{g,1}` for pre" / "`Y_{g,t} - Y_{g,F-1}` for post" phrasing is covered by the uniform convention since both give the same placebo interpretation under parallel trends (the paper's own applications use the uniform anchor). @@ -3858,7 +3858,7 @@ Defaults reproduce `rdrobust(y, x)`: `p=1`, `q=2`, triangular kernel, `bwselect="mserd"`, `vcov_type="nn"` (J=3), `masspoints="adjust"`, normal-quantile CIs. -**Estimand and estimator (fuzzy RD; `fit(..., treatment_col=...)`):** +**Estimand and estimator (fuzzy RD; `fit(..., takeup=...)`):** crossing the cutoff shifts OBSERVED take-up `T` instead of determining it; the estimand is the local Wald ratio `tau_FRD = tau_Y,SRD / tau_T,SRD` (CCT 2014 Section 3.2; identification @@ -3904,7 +3904,7 @@ BALANCE at the cutoff (`tau_Z = 0`, the testable sufficient condition of Lemma 1: the plim shifts by `[mu_Z+ - mu_Z-]' gamma_Y` under imbalance, and adjusting "for" imbalance cannot restore identification without functional-form assumptions). The balance placebo is the estimator -itself - fit each covariate as `outcome_col` and inspect its RD p-value +itself - fit each covariate as `outcome` and inspect its RD p-value - documented in the module docstring; a packaged `covariate_balance` helper is a named follow-up (diagnostics wave). Implementation stacks `Z` after `(Y, T)` as extra response columns through the SAME @@ -4943,7 +4943,7 @@ should be a deliberate user choice. | QDiD | qte | `QDiD()` | | BaconDecomposition | bacondecomp | `bacon()` | | HonestDiD | HonestDiD | `createSensitivityResults()` | -| RegressionDiscontinuity | rdrobust | `rdrobust()` + `rdbwselect()` (4.0.0; sharp + fuzzy + covariate-adjusted, nn path; `treatment_col` = R's `fuzzy=`, `covariates` = R's `covs=`) | +| RegressionDiscontinuity | rdrobust | `rdrobust()` + `rdbwselect()` (4.0.0; sharp + fuzzy + covariate-adjusted, nn path; `takeup` = R's `fuzzy=`, `covariates` = R's `covs=`) | | PreTrendsPower | pretrends | `pretrends()` | | PowerAnalysis | pwr / DeclareDesign / pcpanel | `pwr::pwr.norm.test` (analytical, normal-based — D1) + `pcpanel` (Burlig 2020 panel, equicorrelated case) + simulation. The analytical multiplier is normal (z), so `pwr.t.test` is **not** the faithful parity target. | diff --git a/docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md b/docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md index 3423437e7..96d1036bb 100644 --- a/docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md +++ b/docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md @@ -92,7 +92,7 @@ where `P^bc_+/-` are computable from the data and the only unknowns are the `n(1 - [x] Covariate-aware MSE-optimal bandwidth constants (NOT the no-covariate constants with covariates bolted on) - [x] Joint `(Y, Z)` NN / plug-in-residual variance for the `s' ⊗ P^bc` sandwich (heteroskedastic NN form; cluster variance remains a documented v1 seam alongside the RD estimator's other cluster paths) - [x] Partial-out identity test: `tau_tilde = tau_hat - gamma_tilde' tau_hat_Z` (up to the WLS algebra) as an internal consistency check (`tests/test_rdd_methodology.py::TestCovariates::test_partial_out_identity_exact` - exact at common manual (h, b), both conventional and bias-corrected rows) -- [x] Covariate balance placebo diagnostic + warning on rejection - the RECIPE is documented (module docstring + REGISTRY: fit each covariate as `outcome_col`); a packaged `covariate_balance` helper with automatic warning stays a named follow-up (diagnostics wave), matching rdrobust's scope (R does not auto-test balance either) +- [x] Covariate balance placebo diagnostic + warning on rejection - the RECIPE is documented (module docstring + REGISTRY: fit each covariate as `outcome`); a packaged `covariate_balance` helper with automatic warning stays a named follow-up (diagnostics wave), matching rdrobust's scope (R does not auto-test balance either) - [x] CER rescaling `n^{-1/20}` (p = 1) applies unchanged to the covariate-adjusted bandwidth (the `cer*` selectors rescale the covariate-aware MSE `h`; `covs_cercomb2` golden config) - [ ] Head Start numbers as parity smoke test: standard `tau_hat = -2.41` (h = 6.81, b = 10.72, n- = 234, n+ = 180); covariate-adjusted with covariate-aware bandwidths `tau_tilde = -2.47`, robust 95% CI `[-5.21, -0.37]`, h = 6.98, b = 11.64, n- = 240, n+ = 184 (Table 1; triangular kernel, NN het-robust variance, 9 Census covariates) - NOT shipped: needs the external replication dataset; the library's parity policy prefers live-R end-to-end goldens (9 covariate configs vs installed rdrobust 4.0.0) over published-number replication diff --git a/docs/methodology/papers/dechaisemartin-2026-review.md b/docs/methodology/papers/dechaisemartin-2026-review.md index 375a3e1a4..1a14dc35e 100644 --- a/docs/methodology/papers/dechaisemartin-2026-review.md +++ b/docs/methodology/papers/dechaisemartin-2026-review.md @@ -187,10 +187,10 @@ Alternative to Stute when `G` is large or heteroskedasticity is suspected. - [x] Stute Cramér-von Mises test with Mammen wild bootstrap. **Phase 3 implementation (2026-04):** `stute_test()` in `diff_diff/had_pretests.py`. Literal per-iteration OLS refit per paper Appendix D Algorithm. `n_bootstrap=999` default, `n_bootstrap >= 99` validated. - [x] Yatchew heteroskedasticity-robust linearity test. **Phase 3 implementation (2026-04):** `yatchew_hr_test()` in `diff_diff/had_pretests.py`. Test statistic `T_hr = sqrt(G)·(σ²_lin - σ²_diff)/σ²_W` from paper Equation 29. `σ²_diff` normalizes by `2G` (paper-literal), NOT `2(G-1)` (finite-sample equivalent but tests pin the paper-literal form). Standard-normal critical value, one-sided. - [x] Composite workflow `did_had_pretest_workflow()` (paper Section 4.2-4.3). **Phase 3 implementation (2026-04):** `aggregate="overall"` (default, two-period) runs QUG + Stute + Yatchew on a two-period panel; step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon. **Phase 3 follow-up (2026-04):** `aggregate="event_study"` (multi-period) runs QUG at F + joint pre-trends Stute + joint homogeneity-linearity Stute; closes the paper step-2 gap. -- [x] Warnings for staggered treatment timing (direct users to existing `ChaisemartinDHaultfoeuille` in diff-diff). **Phase 4 closure (2026-05-20):** fail-closed `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat_col`; the error message directs the user to either supply `first_treat_col` (which activates the last-cohort + never-treated auto-filter per Appendix B.2) or to use `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`) for full staggered support. The fail-closed choice (over `UserWarning`) is documented in REGISTRY Deviations § "Staggered-timing fail-closed" as a library extension toward stricter safety than the paper's "Warn" prescription. +- [x] Warnings for staggered treatment timing (direct users to existing `ChaisemartinDHaultfoeuille` in diff-diff). **Phase 4 closure (2026-05-20):** fail-closed `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat`; the error message directs the user to either supply `first_treat` (which activates the last-cohort + never-treated auto-filter per Appendix B.2) or to use `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`) for full staggered support. The fail-closed choice (over `UserWarning`) is documented in REGISTRY Deviations § "Staggered-timing fail-closed" as a library extension toward stricter safety than the paper's "Warn" prescription. - [x] Warnings for extensive-margin effects / positive mass of untreated (not fatal; suggests running existing DiD). **Closed 2026-06-01:** `HeterogeneousAdoptionDiD.fit()` now emits a fit-time `UserWarning` on the **overall** path when `>= 10%` of units have an exactly-zero post-period dose — pointing the user to a standard DiD per the Section 4 recommendation. The 10% cutoff is a library convention (the paper prescribes "warn" with NO numeric threshold and explicitly retains small untreated shares, e.g. Garrett et al.'s 12/2954 ≈ 0.4% with close-to-nominal coverage), chosen ~25× above that kept example. Overall-path-only because the event-study path *requires* never-treated units per Appendix B.2 (so an untreated mass is expected there, not a misuse signal). This complements the pre-existing `qug_test()` zero-dose `UserWarning`, which surfaces the *presence* of extensive-margin / positive-mass-of-untreated units only when the user runs the pre-tests. Documented in REGISTRY § HeterogeneousAdoptionDiD ("Note (Extensive-margin / positive-untreated-mass fit-time warning)"); locked by `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. - [x] Documentation of non-testability of Assumptions 5 and 6. **Phase 4 closure (2026-05-20):** `HeterogeneousAdoptionDiD.fit()` emits a `UserWarning` at fit time when `resolved_design ∈ {continuous_near_d_lower, mass_point}` (Design 1 family) explicitly flagging that point identification of `WAS_{d_lower}` requires Assumption 6, sign identification requires Assumption 5, and NEITHER is testable via pre-trends (`diff_diff/had.py`, search for "---- Assumption 5/6 warning on Design 1 paths ----"). The `HeterogeneousAdoptionDiD` class docstring + `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections cross-reference this and explicitly state that the available pre-tests verify ADJACENT identifying conditions: QUG tests the Theorem 4 / Design 1' support-infimum null `d_lower = 0` — adjacent evidence on the `d_lower = 0` clause of Assumption 4 only, NOT a test of full Assumption 4's boundary-density / conditional-mean smoothness / variance regularity statement; the raw `stute_test` / `yatchew_hr_test` helpers test Assumption 8 linearity (residuals from `dy ~ 1 + d`); `joint_pretrends_test` tests Assumption 7 mean-independence (intercept-only residuals via `null_form="mean_independence"`). None of these test Assumptions 5 or 6 directly. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-period `aggregate="overall"` path. The Assumption 5/6 caveat is surfaced separately by the Design 1 fit-time `UserWarning` and by T21 tutorial prose. -- [x] Multi-period event-study extension (Appendix B.2). **Phase 2b implementation (2026-04):** `aggregate="event_study"` returns per-event-time WAS estimates using uniform `F-1` anchor. Staggered-timing contract (see L190 closure for full statement): when `first_treat_col` is supplied, the panel auto-filters to last-cohort + never-treated units with a `UserWarning` per Appendix B.2 prescription; when omitted on a multi-cohort panel, the estimator raises `ValueError` (fail-closed, see REGISTRY § "Library extension: Staggered-timing fail-closed"). Pointwise CIs per horizon (no joint cross-horizon covariance; matches paper's Pierce-Schott Figure 2). Pre-period placebos at `e <= -2`; the anchor `e = -1` is skipped since `ΔY = 0` there by construction. +- [x] Multi-period event-study extension (Appendix B.2). **Phase 2b implementation (2026-04):** `aggregate="event_study"` returns per-event-time WAS estimates using uniform `F-1` anchor. Staggered-timing contract (see L190 closure for full statement): when `first_treat` is supplied, the panel auto-filters to last-cohort + never-treated units with a `UserWarning` per Appendix B.2 prescription; when omitted on a multi-cohort panel, the estimator raises `ValueError` (fail-closed, see REGISTRY § "Library extension: Staggered-timing fail-closed"). Pointwise CIs per horizon (no joint cross-horizon covariance; matches paper's Pierce-Schott Figure 2). Pre-period placebos at `e <= -2`; the anchor `e = -1` is skipped since `ΔY = 0` there by construction. - [x] Joint Stute tests (paper Section 4.2 step 2 + Section 4.3 joint extension, pages 23-25 + 32). **Phase 3 follow-up (2026-04):** `stute_joint_pretest()` (residuals-in core) + `joint_pretrends_test()` (mean-independence null) + `joint_homogeneity_test()` (linearity null) in `diff_diff/had_pretests.py`. Sum-of-CvMs aggregation, shared-η Mammen wild bootstrap across horizons (Delgado-Manteiga 2001), per-horizon exact-linear short-circuit. **Eq (18) linear-trend detrending variant SHIPPED (PR #389):** the `trends_lin: bool = False` keyword-only kwarg on `HeterogeneousAdoptionDiD.fit(aggregate="event_study")`, `joint_pretrends_test`, and `joint_homogeneity_test` applies the per-group linear-trend slope `Y[g, F-1] - Y[g, F-2]` adjustment. R parity validated against `DIDHAD::did_had(..., trends_lin=TRUE)` v2.0.0 (`Credible-Answers/did_had`) — see REGISTRY § "Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped)". The Pierce-Schott (2016) NUMERICAL REPLICATION against the published p=0.51 anchor on the LBD-restricted panel is waived per REGISTRY Deviations Note #3. **Eq (18) transcription (paper page 31):** The Pierce-Schott linear-trend-detrended joint Stute test of pre-trends reads diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index b7055522d..7ccf529e7 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -178,7 +178,7 @@ assumption — also handle non-absorbing treatment, under stronger assumptions.) est = ChaisemartinDHaultfoeuille() results = est.fit( - data, outcome="outcome", group="group", + data, outcome="outcome", unit="group", time="period", treatment="treatment", ) results.print_summary() @@ -304,15 +304,15 @@ identification rests on stronger structural assumptions (Design 1). # identification assumptions (it does NOT pick the design path; the # estimator does that internally from the dose support). pretests = did_had_pretest_workflow( - data, outcome_col="y", unit_col="unit", - time_col="period", dose_col="dose", + data, outcome="y", unit="unit", + time="period", dose="dose", ) print(pretests) est = HeterogeneousAdoptionDiD() results = est.fit( - data, outcome_col="y", unit_col="unit", - time_col="period", dose_col="dose", + data, outcome="y", unit="unit", + time="period", dose="dose", ) print(f"Resolved estimand: {results.target_parameter}") print(f"Average lift per unit of dose: {results.att:.2f}") diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index 9cea29bf3..c85a999de 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -256,8 +256,8 @@ identification assumptions (the design path is auto-detected separately by had_data = pd.DataFrame(rows) est = HeterogeneousAdoptionDiD() - results = est.fit(had_data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + results = est.fit(had_data, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') Key Differences diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 03c839a11..41105e422 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -533,8 +533,8 @@ exceeding 2% routes to ``mass_point``; otherwise # Check the resolved estimand after fitting est = HeterogeneousAdoptionDiD() - results = est.fit(had_data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + results = est.fit(had_data, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') print(f"Resolved: {results.target_parameter}") @@ -590,8 +590,8 @@ SE path is not used here). had_data = pd.DataFrame(rows) est = HeterogeneousAdoptionDiD() - results = est.fit(had_data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + results = est.fit(had_data, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') # Inspect the resolved design @@ -640,12 +640,12 @@ full SE-regime contract. raises on a staggered panel. **Cause:** The Appendix B.2 event-study extension requires either a -common-adoption panel (single first-treat period; ``first_treat_col`` is +common-adoption panel (single first-treat period; ``first_treat`` is then optional and the period is inferred from the dose invariant) or a -staggered panel with ``first_treat_col`` provided so the estimator can +staggered panel with ``first_treat`` provided so the estimator can auto-filter to the last-treatment cohort plus never-treated units (with a ``UserWarning``). The fit raises only when the panel is staggered -**and** ``first_treat_col`` is missing. +**and** ``first_treat`` is missing. **Solutions:** @@ -675,12 +675,12 @@ a ``UserWarning``). The fit raises only when the panel is staggered rows.append((u, t, d_ut, ft, y_ut)) data = pd.DataFrame(rows, columns=["unit", "period", "dose", "first_treat", "y"]) - # Primary remedy: pass `first_treat_col` so the estimator auto-filters + # Primary remedy: pass `first_treat` so the estimator auto-filters # to the last-treatment cohort + never-treated and emits a UserWarning. est = HeterogeneousAdoptionDiD() - results = est.fit(data, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', - first_treat_col='first_treat', + results = est.fit(data, outcome='y', unit='unit', + time='period', dose='dose', + first_treat='first_treat', aggregate='event_study') # Equivalent: subset to the last-treatment cohort + never-treated @@ -688,8 +688,8 @@ a ``UserWarning``). The fit raises only when the panel is staggered last_cohort = data['first_treat'].max() subset = data[(data['first_treat'] == last_cohort) | (data['first_treat'] == 0)] - results = est.fit(subset, outcome_col='y', unit_col='unit', - time_col='period', dose_col='dose', + results = est.fit(subset, outcome='y', unit='unit', + time='period', dose='dose', aggregate='event_study') Imputation / Two-Stage DiD Issues diff --git a/docs/tutorials/16_wooldridge_etwfe.ipynb b/docs/tutorials/16_wooldridge_etwfe.ipynb index 133c9ddd4..6d8ffa52d 100644 --- a/docs/tutorials/16_wooldridge_etwfe.ipynb +++ b/docs/tutorials/16_wooldridge_etwfe.ipynb @@ -73,7 +73,7 @@ "outputs": [], "source": [ "m = WooldridgeDiD() # default: method='ols'\n", - "r = m.fit(data, outcome='outcome', unit='unit', time='period', cohort='first_treat')\n", + "r = m.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", "\n", "# Compute aggregations\n", "r.aggregate('event').aggregate('group').aggregate('simple')\n", @@ -114,7 +114,7 @@ "id": "c9d0e1f2", "metadata": {}, "outputs": [], - "source": "# Placebo cells exist only under control_group='never_treated' (the lead-and-lag\n# specification). The default not_yet_treated fit `r` above has none.\nprint(f\"default (not_yet_treated) pre-treatment cells: \"\n f\"{sorted(k for k in r.group_time_effects if k[1] < k[0])}\")\n\nr_never = WooldridgeDiD(control_group='never_treated').fit(\n data, outcome='outcome', unit='unit', time='period', cohort='first_treat')\n\nprint(\"\\nPre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\")\nprint(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\nprint(\"-\" * 60)\n\nfor (g, t), v in sorted(r_never.group_time_effects.items()):\n if t >= g:\n continue\n print(\"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']))\n\n# One cell per cohort is absent: the omitted reference period (g-1 here, since\n# this panel is balanced). Every coefficient above is measured against it.\nfor g in sorted(c for c in data['first_treat'].unique() if c > 0):\n present = {t for (gg, t) in r_never.group_time_effects if gg == g}\n missing = sorted(set(data['period'].unique()) - present)\n print(f\"cohort {g}: omitted reference cell -> {[(g, t) for t in missing]}\")" + "source": "# Placebo cells exist only under control_group='never_treated' (the lead-and-lag\n# specification). The default not_yet_treated fit `r` above has none.\nprint(f\"default (not_yet_treated) pre-treatment cells: \"\n f\"{sorted(k for k in r.group_time_effects if k[1] < k[0])}\")\n\nr_never = WooldridgeDiD(control_group='never_treated').fit(\n data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nprint(\"\\nPre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\")\nprint(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\nprint(\"-\" * 60)\n\nfor (g, t), v in sorted(r_never.group_time_effects.items()):\n if t >= g:\n continue\n print(\"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']))\n\n# One cell per cohort is absent: the omitted reference period (g-1 here, since\n# this panel is balanced). Every coefficient above is measured against it.\nfor g in sorted(c for c in data['first_treat'].unique() if c > 0):\n present = {t for (gg, t) in r_never.group_time_effects if gg == g}\n missing = sorted(set(data['period'].unique()) - present)\n print(f\"cohort {g}: omitted reference cell -> {[(g, t) for t in missing]}\")" }, { "cell_type": "markdown", @@ -228,7 +228,7 @@ "data_pois['emp'] = np.exp(data_pois['outcome'] / 4 + 3) # positive outcome\n", "\n", "m_pois = WooldridgeDiD(method='poisson')\n", - "r_pois = m_pois.fit(data_pois, outcome='emp', unit='unit', time='period', cohort='first_treat')\n", + "r_pois = m_pois.fit(data_pois, outcome='emp', unit='unit', time='period', first_treat='first_treat')\n", "r_pois.aggregate('event').aggregate('group').aggregate('simple')\n", "\n", "print(r_pois.summary('simple'))" @@ -297,7 +297,7 @@ "print(f\"Binary outcome mean: {data_logit['hi_outcome'].mean():.3f}\")\n", "\n", "m_logit = WooldridgeDiD(method='logit')\n", - "r_logit = m_logit.fit(data_logit, outcome='hi_outcome', unit='unit', time='period', cohort='first_treat')\n", + "r_logit = m_logit.fit(data_logit, outcome='hi_outcome', unit='unit', time='period', first_treat='first_treat')\n", "r_logit.aggregate('event').aggregate('group').aggregate('simple')\n", "\n", "print(r_logit.summary('simple'))" @@ -333,7 +333,7 @@ "id": "f4a5b6c7", "metadata": {}, "outputs": [], - "source": "# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)\nm_ols = WooldridgeDiD(method='ols')\nr_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', cohort='first_treat')\nr_ols.aggregate('event').aggregate('group').aggregate('simple')\nprint(r_ols.summary('event'))" + "source": "# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)\nm_ols = WooldridgeDiD(method='ols')\nr_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat')\nr_ols.aggregate('event').aggregate('group').aggregate('simple')\nprint(r_ols.summary('event'))" }, { "cell_type": "code", @@ -349,7 +349,7 @@ "id": "b6c7d8e9", "metadata": {}, "outputs": [], - "source": "# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)\nmpdta['emp'] = np.exp(mpdta['lemp'])\n\nm_pois2 = WooldridgeDiD(method='poisson')\nr_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', cohort='first_treat')\nr_pois2.aggregate('event').aggregate('group').aggregate('simple')\n\nprint(r_pois2.summary('event'))\nprint(r_pois2.summary('group'))\nprint(r_pois2.summary('simple'))" + "source": "# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)\nmpdta['emp'] = np.exp(mpdta['lemp'])\n\nm_pois2 = WooldridgeDiD(method='poisson')\nr_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', first_treat='first_treat')\nr_pois2.aggregate('event').aggregate('group').aggregate('simple')\n\nprint(r_pois2.summary('event'))\nprint(r_pois2.summary('group'))\nprint(r_pois2.summary('simple'))" }, { "cell_type": "markdown", @@ -375,7 +375,7 @@ "id": "d8e9f0a1", "metadata": {}, "outputs": [], - "source": "# Compare overall ATT: ETWFE vs Callaway-Sant'Anna\ncs = CallawaySantAnna()\nr_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nm_etwfe = WooldridgeDiD(method='ols')\nr_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', cohort='first_treat')\nr_etwfe.aggregate('event').aggregate('simple')\n\nprint(\"Overall ATT Comparison (true effect = 2.0)\")\nprint(\"=\" * 60)\nprint(\"{:<25} {:>10} {:>10} {:>12}\".format(\"Estimator\", \"ATT\", \"SE\", \"95% CI\"))\nprint(\"-\" * 60)\n\nfor name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n ci = est_r.overall_conf_int\n print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n ))" + "source": "# Compare overall ATT: ETWFE vs Callaway-Sant'Anna\ncs = CallawaySantAnna()\nr_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nm_etwfe = WooldridgeDiD(method='ols')\nr_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\nr_etwfe.aggregate('event').aggregate('simple')\n\nprint(\"Overall ATT Comparison (true effect = 2.0)\")\nprint(\"=\" * 60)\nprint(\"{:<25} {:>10} {:>10} {:>12}\".format(\"Estimator\", \"ATT\", \"SE\", \"95% CI\"))\nprint(\"-\" * 60)\n\nfor name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n ci = est_r.overall_conf_int\n print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n ))" }, { "cell_type": "code", @@ -468,7 +468,7 @@ "with warnings.catch_warnings(record=True) as caught:\n", " warnings.simplefilter(\"always\")\n", " res_at = WooldridgeDiD(control_group=\"not_yet_treated\").fit(\n", - " all_treated, outcome=\"y\", unit=\"unit\", time=\"time\", cohort=\"cohort\"\n", + " all_treated, outcome=\"y\", unit=\"unit\", time=\"time\", first_treat=\"cohort\"\n", " )\n", "\n", "for w in caught:\n", diff --git a/docs/tutorials/19_dcdh_marketing_pulse.ipynb b/docs/tutorials/19_dcdh_marketing_pulse.ipynb index 7a5f260f1..a9be989e8 100644 --- a/docs/tutorials/19_dcdh_marketing_pulse.ipynb +++ b/docs/tutorials/19_dcdh_marketing_pulse.ipynb @@ -141,7 +141,7 @@ "results = model.fit(\n", " df,\n", " outcome=\"sessions\",\n", - " group=\"market_id\",\n", + " unit=\"market_id\",\n", " time=\"week\",\n", " treatment=\"promo_on\",\n", ")\n", @@ -232,7 +232,7 @@ " results_es = model_es.fit(\n", " df,\n", " outcome=\"sessions\",\n", - " group=\"market_id\",\n", + " unit=\"market_id\",\n", " time=\"week\",\n", " treatment=\"promo_on\",\n", " L_max=2,\n", diff --git a/docs/tutorials/20_had_brand_campaign.ipynb b/docs/tutorials/20_had_brand_campaign.ipynb index 953c7340f..d2d70f8f8 100644 --- a/docs/tutorials/20_had_brand_campaign.ipynb +++ b/docs/tutorials/20_had_brand_campaign.ipynb @@ -209,10 +209,10 @@ "est = HAD(design='auto')\n", "result = est.fit(\n", " panel_2pd,\n", - " outcome_col='weekly_visits',\n", - " dose_col='regional_spend_k',\n", - " time_col='period',\n", - " unit_col='dma_id',\n", + " outcome='weekly_visits',\n", + " dose='regional_spend_k',\n", + " time='period',\n", + " unit='dma_id',\n", ")\n", "print(result.summary())" ] @@ -288,11 +288,11 @@ " est_es = HAD(design='auto')\n", " result_es = est_es.fit(\n", " panel,\n", - " outcome_col='weekly_visits',\n", - " dose_col='regional_spend_k',\n", - " time_col='week',\n", - " unit_col='dma_id',\n", - " first_treat_col='first_treat',\n", + " outcome='weekly_visits',\n", + " dose='regional_spend_k',\n", + " time='week',\n", + " unit='dma_id',\n", + " first_treat='first_treat',\n", " aggregate='event_study',\n", " )\n", "print(result_es.summary())" diff --git a/docs/tutorials/21_had_pretest_workflow.ipynb b/docs/tutorials/21_had_pretest_workflow.ipynb index edfb35672..06a9b416d 100644 --- a/docs/tutorials/21_had_pretest_workflow.ipynb +++ b/docs/tutorials/21_had_pretest_workflow.ipynb @@ -170,11 +170,11 @@ "\n", "overall_report = did_had_pretest_workflow(\n", " data=two_period,\n", - " outcome_col=\"weekly_visits\",\n", - " dose_col=\"regional_spend_k\",\n", - " time_col=\"period\",\n", - " unit_col=\"dma_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"weekly_visits\",\n", + " dose=\"regional_spend_k\",\n", + " time=\"period\",\n", + " unit=\"dma_id\",\n", + " first_treat=\"first_treat\",\n", " alpha=0.05,\n", " n_bootstrap=999,\n", " seed=21,\n", @@ -326,11 +326,11 @@ "source": [ "es_report = did_had_pretest_workflow(\n", " data=panel,\n", - " outcome_col=\"weekly_visits\",\n", - " dose_col=\"regional_spend_k\",\n", - " time_col=\"week\",\n", - " unit_col=\"dma_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"weekly_visits\",\n", + " dose=\"regional_spend_k\",\n", + " time=\"week\",\n", + " unit=\"dma_id\",\n", + " first_treat=\"first_treat\",\n", " alpha=0.05,\n", " n_bootstrap=999,\n", " seed=21,\n", diff --git a/docs/tutorials/22_had_survey_design.ipynb b/docs/tutorials/22_had_survey_design.ipynb index aadfd1b01..d1f8d96da 100644 --- a/docs/tutorials/22_had_survey_design.ipynb +++ b/docs/tutorials/22_had_survey_design.ipynb @@ -348,17 +348,17 @@ " # as redundant.\n", " naive = HAD(design=\"auto\").fit(\n", " panel_2p,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"period\",\n", - " unit_col=\"state_id\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"period\",\n", + " unit=\"state_id\",\n", " )\n", " survey = HAD(design=\"auto\").fit(\n", " panel_2p,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"period\",\n", - " unit_col=\"state_id\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"period\",\n", + " unit=\"state_id\",\n", " survey_design=sd,\n", " )\n", "print(f\"design auto-detected: naive={naive.design}, survey={survey.design}\")\n", @@ -474,20 +474,20 @@ " )\n", " naive_es = HAD(design=\"auto\").fit(\n", " panel,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"week\",\n", - " unit_col=\"state_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"week\",\n", + " unit=\"state_id\",\n", + " first_treat=\"first_treat\",\n", " aggregate=\"event_study\",\n", " )\n", " survey_es_for_ratio = HAD(design=\"auto\").fit(\n", " panel,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"week\",\n", - " unit_col=\"state_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"week\",\n", + " unit=\"state_id\",\n", + " first_treat=\"first_treat\",\n", " aggregate=\"event_study\",\n", " survey_design=sd,\n", " )\n", @@ -558,11 +558,11 @@ " )\n", " es = HAD(design=\"auto\").fit(\n", " panel,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"week\",\n", - " unit_col=\"state_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"week\",\n", + " unit=\"state_id\",\n", + " first_treat=\"first_treat\",\n", " aggregate=\"event_study\",\n", " survey_design=sd,\n", " cband=True,\n", @@ -667,10 +667,10 @@ " )\n", " overall_report = did_had_pretest_workflow(\n", " panel_2p,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"period\",\n", - " unit_col=\"state_id\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"period\",\n", + " unit=\"state_id\",\n", " survey_design=sd,\n", " aggregate=\"overall\",\n", " n_bootstrap=N_BOOTSTRAP,\n", @@ -717,11 +717,11 @@ " )\n", " es_report = did_had_pretest_workflow(\n", " panel,\n", - " outcome_col=\"screening_uptake\",\n", - " dose_col=\"spend_k\",\n", - " time_col=\"week\",\n", - " unit_col=\"state_id\",\n", - " first_treat_col=\"first_treat\",\n", + " outcome=\"screening_uptake\",\n", + " dose=\"spend_k\",\n", + " time=\"week\",\n", + " unit=\"state_id\",\n", + " first_treat=\"first_treat\",\n", " survey_design=sd,\n", " aggregate=\"event_study\",\n", " n_bootstrap=N_BOOTSTRAP,\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 73588d794..ab1ab81da 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -354,9 +354,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/wooldridge.py, docs/methodology/REGISTRY.md, docs/methodology/papers/wooldridge-2023-review.md, docs/methodology/papers/wooldridge-2025-review.md, diff_diff/guides/llms-full.txt] notes: "Only estimator using 'cohort' for the first-treatment column; 12 others use first_treat." - id: M-033 @@ -367,10 +368,11 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning - code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt] + test_ref: tests/test_v4_rename_mechanical.py + code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/api/chaisemartin_dhaultfoeuille.rst] notes: "'group' here means the unit id (R DIDmultiplegt vocabulary) but means the treated-group 0/1 indicator in TripleDifference - name-carries-altered-meaning violation." - id: M-034 kind: param @@ -380,9 +382,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-full.txt] notes: "Majority spelling is covariates." - id: M-035 @@ -393,9 +396,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt, docs/methodology/REGISTRY.md, docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2019-review.md] notes: "_col suffix used only by HAD and RDD; 22 other estimators use bare names." - id: M-036 @@ -406,9 +410,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt] notes: "Matches ContinuousDiD's bare 'dose'." - id: M-037 @@ -419,9 +424,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt] notes: "" - id: M-038 @@ -432,9 +438,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt] notes: "" - id: M-039 @@ -445,9 +452,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had.py, diff_diff/practitioner.py, diff_diff/agent_workflow.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-autonomous.txt, docs/methodology/REGISTRY.md, docs/methodology/papers/dechaisemartin-2026-review.md] notes: "" - id: M-040 @@ -458,9 +466,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdd.py] notes: "" - id: M-041 @@ -471,9 +480,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdd.py, diff_diff/guides/llms-full.txt] notes: "" - id: M-042 @@ -484,9 +494,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdd.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt, diff_diff/guides/llms.txt] notes: "Observed take-up column for fuzzy RD. Target RETARGETED from 'treatment' to 'takeup' by the gating-completeness amendment: the column accepts NON-BINARY (dose) take-up, matching R's fuzzy= (REGISTRY 'Estimand and estimator (fuzzy RD)'; the estimand label is data-dependent - complier LATE only when values are in {0,1}, otherwise a bare local Wald ratio). Section 8 rule 7 reserves 'treatment' for a 0/1 indicator and never a treatment level, so 'treatment' would have carried altered meaning here. 'takeup' is already the library's own vocabulary for it (the RD docstring example passes treatment_col='takeup'). No shim had shipped, so the retarget costs nothing." - id: M-094 @@ -497,9 +508,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdd.py] notes: "Results-side mirror of [M-042]: a public dataclass field carrying the fit param's name, emitted by to_dict(). Renaming the param alone would leave the _col spelling on the serialized surface, which spec section 5 forbids from 4.0 (canonical names only). Target is 'takeup', not 'treatment', for the reason recorded on [M-042] (non-binary take-up is accepted, so 'treatment' would violate section 8 rule 7). Flips WITH [M-042] in the same Phase 2c diff." - id: M-043 @@ -905,9 +917,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdplot.py] notes: "RDPlot shipped post-ledger-freeze with _col suffixes; the spec section 8 missed-rename clause applies (matches RegressionDiscontinuity [M-040])." - id: M-089 @@ -918,9 +931,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/rdplot.py] notes: "Companion to [M-088]; matches [M-041]." - id: M-090 @@ -1071,9 +1085,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py] notes: "Companion to [M-033] on the RETAINED diagnostic function (spec section 3.3 keeps twowayfeweights past 4.0 - it is not a class duplicate, so no wrapper removal mitigates this). Same meaning as dCDH's fit[group]: a unit identifier, which section 8 rule 3 reserves 'group' against. Unlike the chaisemartin_dhaultfoeuille() wrapper hit, which dies with [M-077]." - id: M-098 @@ -1084,9 +1099,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Public retained HAD diagnostic; section 8 rule 5. Mirrors the estimator-side [M-035]. The three exported HAD pretest entry points share one 5-param block ([M-098]..[M-112]) and shim together." - id: M-099 @@ -1097,9 +1113,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-036]; see [M-098]." - id: M-100 @@ -1110,9 +1127,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-037]; see [M-098]." - id: M-101 @@ -1123,9 +1141,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-038]; see [M-098]." - id: M-102 @@ -1136,9 +1155,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-039]; see [M-098]." - id: M-103 @@ -1149,9 +1169,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-035]; see [M-098]." - id: M-104 @@ -1162,9 +1183,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-036]; see [M-098]." - id: M-105 @@ -1175,9 +1197,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-037]; see [M-098]." - id: M-106 @@ -1188,9 +1211,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-038]; see [M-098]." - id: M-107 @@ -1201,9 +1225,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-039]; see [M-098]." - id: M-108 @@ -1214,9 +1239,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-035]; see [M-098]." - id: M-109 @@ -1227,9 +1253,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-036]; see [M-098]." - id: M-110 @@ -1240,9 +1267,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-037]; see [M-098]." - id: M-111 @@ -1253,9 +1281,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-038]; see [M-098]." - id: M-112 @@ -1266,9 +1295,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/had_pretests.py] notes: "Mirrors [M-039]; see [M-098]." - id: M-113 @@ -1279,9 +1309,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/prep.py] notes: "Public prep helper; section 8 rule 5. Target spelling is 'weights', the established library name for a weight column (5 public class surfaces use it) - NOT bare 'weight'." - id: M-114 @@ -1292,9 +1323,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_mechanical.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/chaisemartin_dhaultfoeuille.py, docs/methodology/REGISTRY.md] notes: "Results-side mirror of [M-033] under section 8 rule 9. dCDH's 'groups' are UNIT identifiers ('Group identifiers in the post-filter sample'), the same column fit[group] renames to unit - distinct from the CS-family 'groups', which are ATT(g,t) cohorts and stay as domain vocabulary. summary()/repr() labels follow; needs __setstate__ pickle migration per spec section 5. The n_groups_dropped_* counters are NOT renamed by this row (they count dropped units but read as diagnostics; re-audit at 4.0 if the vocabulary drifts)." - id: M-115 diff --git a/tests/test_chaisemartin_dhaultfoeuille.py b/tests/test_chaisemartin_dhaultfoeuille.py index 4f42e6c3e..26582b5c4 100644 --- a/tests/test_chaisemartin_dhaultfoeuille.py +++ b/tests/test_chaisemartin_dhaultfoeuille.py @@ -40,7 +40,7 @@ def test_fit_returns_results_object(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -60,7 +60,7 @@ def test_fit_recovers_homogeneous_effect_single_switch(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -84,7 +84,7 @@ def test_fit_with_joiners_only_pattern(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -112,7 +112,7 @@ def test_fit_with_leavers_only_pattern(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -127,7 +127,7 @@ def test_missing_column_raises_value_error(self): est.fit( data, outcome="bogus", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -147,7 +147,7 @@ def test_non_binary_treatment_requires_lmax(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -172,7 +172,7 @@ def test_non_binary_treatment_with_lmax(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -206,7 +206,7 @@ def test_convenience_function_matches_class(self): results_class = ChaisemartinDHaultfoeuille(seed=1).fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -239,7 +239,7 @@ def test_convenience_function_routes_paths_of_interest_to_init(self): r_class = results_class.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -275,7 +275,7 @@ def test_minimal_computation_path(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -312,7 +312,7 @@ def test_aggregate_simple_raises_not_implemented(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", aggregate="simple", @@ -323,7 +323,7 @@ def test_aggregate_event_study_raises_not_implemented(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", aggregate="event_study", @@ -337,7 +337,7 @@ def test_L_max_validation(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=0, @@ -346,7 +346,7 @@ def test_L_max_validation(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=-1, @@ -356,7 +356,7 @@ def test_L_max_validation(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max="5", @@ -366,7 +366,7 @@ def test_L_max_validation(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=100, @@ -375,7 +375,7 @@ def test_L_max_validation(self, data): results = self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -388,10 +388,10 @@ def test_controls_requires_lmax(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["outcome"], # reuse existing column as dummy covariate + covariates=["outcome"], # reuse existing column as dummy covariate ) def test_trends_linear_requires_lmax(self, data): @@ -400,7 +400,7 @@ def test_trends_linear_requires_lmax(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -412,7 +412,7 @@ def test_trends_nonparam_requires_lmax(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -423,7 +423,7 @@ def test_honest_did_requires_lmax(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", honest_did=True, @@ -440,7 +440,7 @@ def test_survey_design_rejects_fweight(self, data): self._est().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -523,7 +523,7 @@ def test_rank_deficient_action_error_raises_on_fitted_twfe(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -536,7 +536,7 @@ def test_rank_deficient_action_error_raises_on_fitted_twfe(self): est_warn.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -582,13 +582,13 @@ def test_default_drops_a5_violators_with_warning(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) # The multi-switch group should be dropped assert results.n_groups_dropped_crossers >= 1 - assert 9999 not in results.groups + assert 9999 not in results.units # A drop_larger_lower warning should fire assert any("drop_larger_lower" in str(wi.message) for wi in w) @@ -605,7 +605,7 @@ def test_drop_larger_lower_false_emits_inconsistency_warning(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -623,7 +623,7 @@ def test_drop_larger_lower_true_no_op_on_single_switch_data(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -665,7 +665,7 @@ def test_singleton_baseline_filter_variance_only(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -673,8 +673,8 @@ def test_singleton_baseline_filter_variance_only(self): assert results.n_groups_dropped_singleton_baseline >= 1 # Per the variance-only filter, the group is RETAINED in the # point-estimate sample (it can serve as a period-based stable - # control), so it appears in results.groups. - assert 9999 in results.groups + # control), so it appears in results.units. + assert 9999 in results.units # The warning text mentions the variance-only scope. assert any("Singleton-baseline" in str(wi.message) for wi in w) assert any( @@ -695,7 +695,7 @@ def test_missing_baseline_period_raises_value_error(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -717,16 +717,16 @@ def test_interior_gap_drops_group_with_warning(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) # Group 3 was dropped from the post-filter sample - assert 3 not in results.groups + assert 3 not in results.units # The interior-gap warning fired assert any("interior period gaps" in str(wi.message) for wi in w) # Other groups still present - assert len(results.groups) == 9 + assert len(results.units) == 9 def test_terminal_missingness_retained(self): """ @@ -756,14 +756,14 @@ def test_terminal_missingness_retained(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) # Group 5 is RETAINED in the post-filter sample (NOT dropped) - assert 5 in results.groups + assert 5 in results.units # All 10 groups remain - assert len(results.groups) == 10 + assert len(results.units) == 10 # The point estimate is well-defined (not NaN) assert np.isfinite(results.overall_att) # Per-period DIDs were computed (the structure of per_period_effects @@ -813,12 +813,12 @@ def test_global_period_gap_treated_as_adjacent(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) # All 4 groups present - assert len(results.groups) == 4 + assert len(results.units) == 4 # Point estimate is finite assert np.isfinite(results.overall_att) # Per-period effects include the transition at t=3 (treated as @@ -891,7 +891,7 @@ def test_cell_count_weighting_unbalanced_input(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") results = est.fit( - df, outcome="outcome", group="group", time="period", treatment="treatment" + df, outcome="outcome", unit="group", time="period", treatment="treatment" ) # Expected under CELL weighting: @@ -941,7 +941,7 @@ def test_a11_violation_zero_in_numerator_retain_in_denominator(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -997,7 +997,7 @@ def test_placebo_a11_violation_emits_warning(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1026,7 +1026,7 @@ def test_a11_natural_zero_no_switchers_does_not_zero_flag(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1049,7 +1049,7 @@ def test_empty_dataframe_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1071,7 +1071,7 @@ def test_no_switchers_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1092,7 +1092,7 @@ def test_bootstrap_zero_uses_analytical(self, data): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1109,7 +1109,7 @@ def test_bootstrap_rademacher(self, data, ci_params): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1130,7 +1130,7 @@ def test_bootstrap_mammen(self, data, ci_params): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1147,7 +1147,7 @@ def test_bootstrap_webb(self, data, ci_params): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1172,7 +1172,7 @@ def test_placebo_se_nan_for_phase1_per_period(self, data, ci_params): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1230,7 +1230,7 @@ def test_bootstrap_p_value_and_ci_propagated_to_top_level(self, data, ci_params) results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1285,14 +1285,14 @@ def test_bootstrap_seed_reproducibility(self, data, ci_params): r1 = ChaisemartinDHaultfoeuille(n_bootstrap=n_boot, seed=42).fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) r2 = ChaisemartinDHaultfoeuille(n_bootstrap=n_boot, seed=42).fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1311,7 +1311,7 @@ def results(self): return ChaisemartinDHaultfoeuille().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1436,7 +1436,7 @@ def test_coef_var_nan_safe_on_non_finite_se(self): placebo_conf_int=(float("nan"), float("nan")), placebo_available=False, per_period_effects={}, - groups=[1], + units=[1], time_periods=[0, 1], n_obs=2, n_treated_obs=1, @@ -1478,7 +1478,7 @@ def test_coef_var_nan_safe_on_non_finite_se(self): placebo_conf_int=(float("nan"), float("nan")), placebo_available=False, per_period_effects={}, - groups=[1], + units=[1], time_periods=[0, 1], n_obs=2, n_treated_obs=1, @@ -1502,7 +1502,7 @@ def test_standalone_function_runs(self): result = twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1519,7 +1519,7 @@ def test_standalone_function_equals_fitted_diagnostic(self): standalone = twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1527,7 +1527,7 @@ def test_standalone_function_equals_fitted_diagnostic(self): results = ChaisemartinDHaultfoeuille().fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1563,7 +1563,7 @@ def test_twfe_pre_filter_contract_with_interior_gap_drop(self): standalone = twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1575,7 +1575,7 @@ def test_twfe_pre_filter_contract_with_interior_gap_drop(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1585,8 +1585,8 @@ def test_twfe_pre_filter_contract_with_interior_gap_drop(self): assert results.twfe_fraction_negative == pytest.approx(standalone.fraction_negative) # The estimation sample is smaller (group 3 was dropped) - assert 3 not in results.groups - assert len(results.groups) == 9 + assert 3 not in results.units + assert len(results.units) == 9 # The divergence warning fired with the expected counts div_warnings = [ @@ -1631,7 +1631,7 @@ def test_twfe_pre_filter_contract_with_multi_switch_drop(self): standalone = twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1643,7 +1643,7 @@ def test_twfe_pre_filter_contract_with_multi_switch_drop(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1654,7 +1654,7 @@ def test_twfe_pre_filter_contract_with_multi_switch_drop(self): assert results.twfe_fraction_negative == pytest.approx(standalone.fraction_negative) # The estimation sample dropped the crosser - assert 9999 not in results.groups + assert 9999 not in results.units assert results.n_groups_dropped_crossers >= 1 # The divergence warning fired with the expected counts @@ -1685,14 +1685,14 @@ def test_twfe_no_divergence_warning_on_clean_panel(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) # No filter drops on a clean panel assert results.n_groups_dropped_crossers == 0 - assert len(results.groups) == 20 + assert len(results.units) == 20 # The divergence warning did NOT fire div_warnings = [ @@ -1716,7 +1716,7 @@ def test_twowayfeweights_rejects_nan_treatment(self): twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1728,7 +1728,7 @@ def test_twowayfeweights_rejects_nan_outcome(self): twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1741,7 +1741,7 @@ def test_twowayfeweights_rejects_non_binary_treatment(self): twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1753,7 +1753,7 @@ def test_twowayfeweights_rejects_nan_group(self): twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1765,7 +1765,7 @@ def test_twowayfeweights_rejects_nan_time(self): twowayfeweights( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1778,7 +1778,7 @@ def test_fit_rejects_nan_group(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1791,7 +1791,7 @@ def test_fit_rejects_nan_time(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1802,7 +1802,7 @@ def test_twowayfeweights_rejects_empty_input(self): twowayfeweights( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1827,7 +1827,7 @@ def test_twowayfeweights_rejects_within_cell_varying_treatment(self): twowayfeweights( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1850,7 +1850,7 @@ def test_fit_rejects_within_cell_varying_treatment(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -1873,7 +1873,7 @@ def data(self): def test_L_max_none_preserves_phase1_behavior(self, data): """L_max=None must produce identical results to Phase 1.""" est = ChaisemartinDHaultfoeuille(placebo=False, twfe_diagnostic=False) - r = est.fit(data, outcome="outcome", group="group", time="period", treatment="treatment") + r = est.fit(data, outcome="outcome", unit="group", time="period", treatment="treatment") assert len(r.event_study_effects) == 1 assert 1 in r.event_study_effects assert r.L_max is None @@ -1893,7 +1893,7 @@ def test_L_max_1_bootstrap_overall_matches_es1(self, data, ci_params): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1916,7 +1916,7 @@ def test_L_max_1_suppresses_joiner_leaver_decomposition(self): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1946,7 +1946,7 @@ def test_L_max_1_bootstrap_results_overall_synced(self, data, ci_params): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1970,7 +1970,7 @@ def test_L_max_1_uses_per_group_path(self, data): r_one = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1988,7 +1988,7 @@ def test_L_max_populates_event_study_effects(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2010,7 +2010,7 @@ def test_did_l1_uses_per_group_path_when_L_max(self, data): r_multi = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2025,7 +2025,7 @@ def test_N_l_decreases_with_horizon(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=5, @@ -2044,7 +2044,7 @@ def test_N_l_zero_at_far_horizon_produces_nan(self): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -2065,7 +2065,7 @@ def test_switcher_fraction_warning(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=4, @@ -2079,7 +2079,7 @@ def test_overall_att_is_cost_benefit_delta_when_L_max_gt_1(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2104,7 +2104,7 @@ def test_placebo_event_study_populated(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2119,7 +2119,7 @@ def test_placebo_horizons_negative_keys(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2137,7 +2137,7 @@ def test_placebo_se_finite_multi_horizon(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2165,7 +2165,7 @@ def test_placebo_bootstrap_se_multi_horizon(self, data, ci_params): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2192,7 +2192,7 @@ def test_normalized_populated_when_L_max(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2212,7 +2212,7 @@ def test_normalized_equals_did_over_l_binary(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2240,7 +2240,7 @@ def test_delta_weights_sum_to_one(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2255,7 +2255,7 @@ def test_delta_is_consistent(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2283,7 +2283,7 @@ def test_sup_t_requires_bootstrap(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2297,7 +2297,7 @@ def test_cband_wider_than_pointwise(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2327,7 +2327,7 @@ def test_event_study_level(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2344,7 +2344,7 @@ def test_normalized_level(self, data): r = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -2383,7 +2383,7 @@ def test_controls_requires_lmax(self): df = self._make_panel_with_covariates() with pytest.raises(ValueError, match="requires L_max >= 1"): ChaisemartinDHaultfoeuille(seed=1).fit( - df, "outcome", "group", "period", "treatment", controls=["X1"] + df, "outcome", "group", "period", "treatment", covariates=["X1"] ) def test_controls_missing_column(self): @@ -2396,7 +2396,7 @@ def test_controls_missing_column(self): "group", "period", "treatment", - controls=["nonexistent"], + covariates=["nonexistent"], L_max=1, ) @@ -2414,7 +2414,7 @@ def test_covariate_residualization_basic(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=1, ) @@ -2439,7 +2439,7 @@ def test_multiple_covariates(self): "group", "period", "treatment", - controls=["X1", "X2"], + covariates=["X1", "X2"], L_max=1, ) assert r.covariate_residuals is not None @@ -2455,7 +2455,7 @@ def test_covariate_residuals_diagnostics(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=2, ) diag = r.covariate_residuals @@ -2492,7 +2492,7 @@ def test_controls_with_nonbinary_treatment(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=1, ) assert np.isfinite(r.overall_att) @@ -2507,7 +2507,7 @@ def test_controls_with_multi_horizon(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=2, ) assert r.event_study_effects is not None @@ -2532,7 +2532,7 @@ def test_controls_lmax1_estimand_contract(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=1, ) @@ -2667,7 +2667,7 @@ def test_trends_with_covariates(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=2, trends_linear=True, ) @@ -2872,7 +2872,7 @@ def test_nonparam_with_covariates(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=1, trends_nonparam="state", ) @@ -3093,7 +3093,7 @@ def test_heterogeneity_rejects_controls(self): """heterogeneity + controls raises ValueError (matching R predict_het).""" df = self._make_panel_with_het() df["X1"] = np.random.RandomState(42).normal(0, 1, len(df)) - with pytest.raises(ValueError, match="cannot be combined with controls"): + with pytest.raises(ValueError, match="cannot be combined with covariates"): ChaisemartinDHaultfoeuille(seed=1).fit( df, "outcome", @@ -3102,7 +3102,7 @@ def test_heterogeneity_rejects_controls(self): "treatment", L_max=1, heterogeneity="het_x", - controls=["X1"], + covariates=["X1"], ) def test_heterogeneity_requires_lmax(self): @@ -3267,7 +3267,7 @@ def test_ordinal_treatment(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -3286,7 +3286,7 @@ def test_within_cell_heterogeneity_rejected_nonbinary(self): ) est = ChaisemartinDHaultfoeuille() with pytest.raises(ValueError, match="Within-cell-varying treatment"): - est.fit(df, outcome="outcome", group="group", time="period", treatment="treatment") + est.fit(df, outcome="outcome", unit="group", time="period", treatment="treatment") def test_single_large_dose_not_flagged_multi_switch(self): """A single jump 0->3 should NOT be flagged as multi-switch.""" @@ -3310,7 +3310,7 @@ def test_single_large_dose_not_flagged_multi_switch(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3340,7 +3340,7 @@ def test_true_multi_switch_detected_nonbinary(self): warnings.simplefilter("ignore") # Binary groups work at L_max=None; the multi-switch group # (0->2->1) should be detected and dropped. - r = est.fit(df, outcome="outcome", group="group", time="period", treatment="treatment") + r = est.fit(df, outcome="outcome", unit="group", time="period", treatment="treatment") assert r.n_groups_dropped_crossers >= 1 def test_monotone_multi_step_dropped(self): @@ -3364,7 +3364,7 @@ def test_monotone_multi_step_dropped(self): est = ChaisemartinDHaultfoeuille(twfe_diagnostic=False) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = est.fit(df, outcome="outcome", group="group", time="period", treatment="treatment") + r = est.fit(df, outcome="outcome", unit="group", time="period", treatment="treatment") # Group 0 (0->1->2, 2 change periods) should be dropped assert r.n_groups_dropped_crossers >= 1 @@ -3398,7 +3398,7 @@ def test_mixed_binary_nonbinary_panel_lmax1(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3425,7 +3425,7 @@ def test_constant_nonbinary_treatment_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3453,7 +3453,7 @@ def test_nonbinary_bootstrap(self, ci_params): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3489,7 +3489,7 @@ def test_nonbinary_lmax1_renderer_contract(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3533,7 +3533,7 @@ def test_twfe_diagnostic_skipped_nonbinary(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -3564,7 +3564,7 @@ def test_normalized_effects_general_formula(self): r = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -3676,7 +3676,7 @@ def test_honest_did_with_controls(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=2, honest_did=True, ) @@ -4000,7 +4000,7 @@ def test_summary_renders_covariate_diagnostics(self): "group", "period", "treatment", - controls=["X1"], + covariates=["X1"], L_max=1, ) text = r.summary() @@ -4166,7 +4166,7 @@ def _fit_by_path(data: pd.DataFrame, by_path: int, L_max: int = 3): return est, est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -4180,7 +4180,7 @@ def test_default_leaves_path_effects_none(self): data = generate_reversible_did_data(n_groups=40, n_periods=5, seed=1) est = ChaisemartinDHaultfoeuille() results = est.fit( - data, outcome="outcome", group="group", time="period", treatment="treatment" + data, outcome="outcome", unit="group", time="period", treatment="treatment" ) assert results.path_effects is None @@ -4209,7 +4209,7 @@ def test_requires_drop_larger_lower_false(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -4224,7 +4224,7 @@ def test_requires_lmax(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -4256,7 +4256,7 @@ def test_forbids_phase3_fit_kwargs(self, fit_kwargs, msg): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -4296,7 +4296,7 @@ def test_overflow_returns_all_with_warning(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -4431,7 +4431,7 @@ def test_to_dataframe_raises_when_not_requested(self): data = generate_reversible_did_data(n_groups=40, n_periods=5, seed=1) est = ChaisemartinDHaultfoeuille() results = est.fit( - data, outcome="outcome", group="group", time="period", treatment="treatment" + data, outcome="outcome", unit="group", time="period", treatment="treatment" ) with pytest.raises(ValueError, match="by_path"): results.to_dataframe(level="by_path") @@ -4495,7 +4495,7 @@ def test_empty_path_surface_when_no_complete_window(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -4586,7 +4586,7 @@ def test_degenerate_cohort_path_nan_inference_and_warning(self): results = est.fit( panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -4660,7 +4660,7 @@ def _fit_with_bootstrap( results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -5034,7 +5034,7 @@ def test_overflow_warning_fires_exactly_once_under_bootstrap(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -5174,7 +5174,7 @@ def test_nan_contract_extends_to_placebo_event_study_horizons(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -5308,7 +5308,7 @@ def test_nan_contract_extends_to_overall_and_event_study_horizons(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -5391,7 +5391,7 @@ def test_degenerate_bootstrap_distribution_yields_nan_tuple(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -5504,7 +5504,7 @@ def _fit_by_path_with_placebo( return est, est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -5776,7 +5776,7 @@ def test_empty_path_placebo_surface_when_no_complete_window(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -5981,7 +5981,7 @@ def _fit_with_bootstrap( results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -6010,7 +6010,7 @@ def test_path_sup_t_bands_attr_none_when_no_by_path(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -6307,7 +6307,7 @@ def test_path_sup_t_bands_empty_dict_when_no_complete_window(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -6388,7 +6388,7 @@ def test_path_sup_t_to_dataframe_empty_path_fallback_has_cband_columns(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -6453,7 +6453,7 @@ def fake_generator(n_bootstrap, n_groups_target, weight_type, rng, group_to_psu_ res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -6576,10 +6576,10 @@ def test_no_longer_raises(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) assert res.path_effects is not None @@ -6597,7 +6597,7 @@ def test_residualization_changes_per_path_estimates(self): res_no = est_no.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -6606,10 +6606,10 @@ def test_residualization_changes_per_path_estimates(self): res_yes = est_yes.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) @@ -6640,10 +6640,10 @@ def test_path_enumeration_unaffected_by_controls(self): res_yes = est_yes.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) @@ -6671,10 +6671,10 @@ def test_multi_covariate_works(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1", "X2"], + covariates=["X1", "X2"], L_max=3, ) assert res.path_effects is not None @@ -6699,10 +6699,10 @@ def test_bootstrap_with_controls_finite_se(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) any_finite = False @@ -6724,10 +6724,10 @@ def test_bootstrap_point_estimates_unchanged(self): res_a = est_a.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) est_b = ChaisemartinDHaultfoeuille( @@ -6736,10 +6736,10 @@ def test_bootstrap_point_estimates_unchanged(self): res_b = est_b.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) for path, entry_a in res_a.path_effects.items(): @@ -6769,10 +6769,10 @@ def test_per_path_placebos_with_controls_present(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) assert res.path_placebo_event_study is not None @@ -6803,10 +6803,10 @@ def test_per_path_placebos_with_controls_bootstrap(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) assert res.path_placebo_event_study is not None @@ -6835,10 +6835,10 @@ def test_sup_t_bands_with_controls_finite_crit(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) assert res.path_sup_t_bands is not None @@ -6869,10 +6869,10 @@ def test_per_period_effects_unadjusted_with_by_path_controls(self): res_yes = est_yes.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) # Per-period DID is unaffected by controls residualization @@ -6913,10 +6913,10 @@ def test_covariate_residuals_round_trip_with_by_path(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) assert res.covariate_residuals is not None @@ -6939,10 +6939,10 @@ def test_to_dataframe_by_path_with_controls_and_bootstrap(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) df_long = res.to_dataframe(level="by_path") @@ -7001,10 +7001,10 @@ def _add(group, treatment_path): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) @@ -7034,10 +7034,10 @@ def test_single_baseline_panel_does_not_emit_r_deviation_warning(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) deviation_msgs = [ @@ -7110,10 +7110,10 @@ def test_single_baseline_heterogeneous_F_g_does_not_warn(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) @@ -7339,7 +7339,7 @@ def test_no_longer_raises(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7355,7 +7355,7 @@ def test_path_effects_present_under_trends_linear(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7375,7 +7375,7 @@ def test_path_cumulated_event_study_present(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7403,7 +7403,7 @@ def test_path_cumulated_is_none_without_trends_linear(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -7419,7 +7419,7 @@ def test_path_cumulated_se_is_conservative_upper_bound(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7448,7 +7448,7 @@ def test_path_cumulated_recovers_per_group_running_sum(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7477,7 +7477,7 @@ def test_to_dataframe_by_path_with_trends_linear(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7499,7 +7499,7 @@ def test_to_dataframe_cumulated_columns_nan_when_no_trends_linear(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -7519,7 +7519,7 @@ def test_summary_renders_path_cumulated_block(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7546,7 +7546,7 @@ def test_per_period_effects_unaffected_by_trends_linear_by_path(self): res_no_bp = est_no_bp.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7556,7 +7556,7 @@ def test_per_period_effects_unaffected_by_trends_linear_by_path(self): res_bp = est_bp.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7608,7 +7608,7 @@ def test_bootstrap_with_trends_linear_finite_se(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7635,7 +7635,7 @@ def test_per_path_placebos_with_trends_linear_present(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7685,7 +7685,7 @@ def test_per_path_placebos_with_trends_linear_bootstrap_inference(self): res_a = est_a.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7701,7 +7701,7 @@ def test_per_path_placebos_with_trends_linear_bootstrap_inference(self): res_b = est_b.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7755,7 +7755,7 @@ def test_per_path_placebos_with_trends_linear_bootstrap_nan_consistent(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7792,7 +7792,7 @@ def test_sup_t_bands_with_trends_linear_finite_crit(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7833,7 +7833,7 @@ def test_bootstrap_cumulated_uses_post_bootstrap_per_horizon_se(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7909,7 +7909,7 @@ def _add(group, treatment_path): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7940,7 +7940,7 @@ def test_single_baseline_panel_does_not_emit_r_deviation_warning(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -7995,7 +7995,7 @@ def _add(group, treatment_path): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -8042,7 +8042,7 @@ def test_single_baseline_heterogeneous_F_g_does_not_warn(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -8079,7 +8079,7 @@ def test_bootstrap_cumulated_nan_consistent_when_n_bootstrap_one(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -8130,7 +8130,7 @@ def test_no_longer_raises(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8146,7 +8146,7 @@ def test_set_restriction_changes_per_path_estimates(self): res_no = est_no_set.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8155,7 +8155,7 @@ def test_set_restriction_changes_per_path_estimates(self): res_set = est_set.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8191,7 +8191,7 @@ def test_per_path_se_finite(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8215,7 +8215,7 @@ def test_time_varying_set_with_by_path_raises(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8232,7 +8232,7 @@ def test_missing_set_column_with_by_path_raises(self): est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="missing_column", @@ -8251,7 +8251,7 @@ def test_bootstrap_with_trends_nonparam_finite_se(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8274,7 +8274,7 @@ def test_per_period_effects_unaffected_by_trends_nonparam_by_path(self): res_no_bp = est_no_bp.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8284,7 +8284,7 @@ def test_per_period_effects_unaffected_by_trends_nonparam_by_path(self): res_bp = est_bp.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8334,7 +8334,7 @@ def test_sup_t_bands_with_trends_nonparam_finite_crit(self): res = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8393,7 +8393,7 @@ def test_per_path_placebos_with_trends_nonparam_bootstrap_inference(self): res_no = est_no_set.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8408,7 +8408,7 @@ def test_per_path_placebos_with_trends_nonparam_bootstrap_inference(self): res_set = est_set.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -8529,7 +8529,7 @@ def test_no_longer_raises_on_non_binary(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8557,7 +8557,7 @@ def test_non_integer_D_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8598,7 +8598,7 @@ def test_negative_integer_D_supported(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8662,7 +8662,7 @@ def test_negative_baseline_path_supported(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8695,7 +8695,7 @@ def test_path_effects_present_under_non_binary(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8726,7 +8726,7 @@ def test_per_period_effects_unaffected_by_non_binary_by_path(self): res_no = est_no.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8734,7 +8734,7 @@ def test_per_period_effects_unaffected_by_non_binary_by_path(self): res_bp = est_bp.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8761,7 +8761,7 @@ def test_to_dataframe_by_path_with_non_binary(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8799,7 +8799,7 @@ def test_continuous_D_without_by_path_unaffected(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -8822,7 +8822,7 @@ def test_bootstrap_with_non_binary_finite_se(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8847,7 +8847,7 @@ def test_per_path_placebos_with_non_binary_present(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -8881,7 +8881,7 @@ def test_sup_t_bands_with_non_binary_finite_crit(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9011,7 +9011,7 @@ def test_canonicalized_duplicates_dedup_warn(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9033,7 +9033,7 @@ def test_wrong_length_raises_at_fit(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9053,7 +9053,7 @@ def test_paths_of_interest_requires_L_max(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -9071,7 +9071,7 @@ def test_paths_of_interest_requires_drop_larger_lower_false(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9092,7 +9092,7 @@ def test_paths_of_interest_selects_user_paths(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9114,7 +9114,7 @@ def test_paths_of_interest_preserves_user_order(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9141,7 +9141,7 @@ def test_paths_of_interest_order_preserved_in_to_dataframe(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9173,7 +9173,7 @@ def test_paths_of_interest_order_preserved_in_summary(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9208,7 +9208,7 @@ def test_paths_of_interest_frequency_rank_is_true_frequency(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9236,7 +9236,7 @@ def test_paths_of_interest_all_unobserved_summary_distinct_text(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9266,7 +9266,7 @@ def test_paths_of_interest_all_unobserved_emits_distinct_warning(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9301,7 +9301,7 @@ def test_unobserved_path_warns_and_omits(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9323,7 +9323,7 @@ def test_paths_of_interest_with_non_binary_D(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9350,11 +9350,11 @@ def test_paths_of_interest_with_controls(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, - controls=["X1"], + covariates=["X1"], ) assert len(res.path_effects) == 2 for path, entry in res.path_effects.items(): @@ -9375,7 +9375,7 @@ def test_paths_of_interest_with_trends_linear(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9400,7 +9400,7 @@ def test_paths_of_interest_with_trends_nonparam(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9433,7 +9433,7 @@ def test_paths_of_interest_non_binary_bootstrap_placebo(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9496,7 +9496,7 @@ def test_paths_of_interest_trends_linear_bootstrap_placebo(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9548,7 +9548,7 @@ def test_paths_of_interest_trends_nonparam_bootstrap_placebo(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9596,7 +9596,7 @@ def test_bootstrap_with_paths_of_interest_finite_se(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9620,7 +9620,7 @@ def test_per_path_placebos_with_paths_of_interest_present(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9765,7 +9765,7 @@ def test_no_longer_raises_on_survey(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9788,7 +9788,7 @@ def test_paths_of_interest_with_survey_no_longer_raises(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9812,7 +9812,7 @@ def test_survey_design_plus_n_bootstrap_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9836,7 +9836,7 @@ def test_survey_design_plus_paths_of_interest_plus_n_bootstrap_raises(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9865,7 +9865,7 @@ def test_global_survey_plus_n_bootstrap_still_works(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9887,7 +9887,7 @@ def test_per_path_analytical_se_finite_under_survey(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9921,7 +9921,7 @@ def test_per_path_se_telescope_to_global_on_single_path(self): res_g = est_g.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9930,7 +9930,7 @@ def test_per_path_se_telescope_to_global_on_single_path(self): res_p = est_p.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9981,7 +9981,7 @@ def test_per_path_se_within_envelope_of_unweighted(self): res_survey = est_p.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -9990,7 +9990,7 @@ def test_per_path_se_within_envelope_of_unweighted(self): res_plain = est_p_no_survey.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10052,7 +10052,7 @@ def test_per_path_replicate_se_finite(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10146,7 +10146,7 @@ def wrapped_compute_se(*args, **kwargs): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10268,7 +10268,7 @@ def test_refresh_path_inference_called_from_final_block(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10336,7 +10336,7 @@ def test_per_path_inference_uses_final_df_after_all_appends(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10422,7 +10422,7 @@ def test_per_path_replicate_n_valid_propagates_to_df_survey(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10447,7 +10447,7 @@ def test_per_path_placebo_se_finite_under_survey(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10520,7 +10520,7 @@ def test_per_path_cumulated_se_inherits_survey(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10547,7 +10547,7 @@ def test_path_unobserved_under_survey_warns_omits(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10599,7 +10599,7 @@ def test_paths_of_interest_replicate_weight_per_path_se_finite(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10686,7 +10686,7 @@ def test_paths_of_interest_survey_design_placebo_replicate_weight(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10741,7 +10741,7 @@ def test_telescope_analytical_TSL(self): res_g = est_g.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10750,7 +10750,7 @@ def test_telescope_analytical_TSL(self): res_p = est_p.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10847,7 +10847,7 @@ def test_no_longer_raises_on_heterogeneity(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10867,7 +10867,7 @@ def test_paths_of_interest_with_heterogeneity_no_longer_raises(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10879,16 +10879,16 @@ def test_heterogeneity_still_rejects_controls_under_by_path(self): """``heterogeneity + controls`` mutex still fires under by_path.""" df = _by_path_het_data() df["X1"] = np.random.RandomState(42).normal(0, 1, len(df)) - with pytest.raises(ValueError, match="cannot be combined with controls"): + with pytest.raises(ValueError, match="cannot be combined with covariates"): ChaisemartinDHaultfoeuille(drop_larger_lower=False, by_path=2).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, heterogeneity="het_x", - controls=["X1"], + covariates=["X1"], ) def test_heterogeneity_still_rejects_trends_linear_under_by_path(self): @@ -10898,7 +10898,7 @@ def test_heterogeneity_still_rejects_trends_linear_under_by_path(self): ChaisemartinDHaultfoeuille(drop_larger_lower=False, by_path=2).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10914,7 +10914,7 @@ def test_heterogeneity_still_rejects_trends_nonparam_under_by_path(self): ChaisemartinDHaultfoeuille(drop_larger_lower=False, by_path=2).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10934,7 +10934,7 @@ def test_per_path_heterogeneity_finite_under_known_signal(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -10972,7 +10972,7 @@ def test_per_path_heterogeneity_inference_local_invariants(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11049,7 +11049,7 @@ def test_per_path_heterogeneity_telescope_to_global_on_single_path(self): res_p = est_p.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11062,7 +11062,7 @@ def test_per_path_heterogeneity_telescope_to_global_on_single_path(self): res_g = est_g.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11141,7 +11141,7 @@ def test_per_path_heterogeneity_zero_signal_yields_small_beta(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11229,7 +11229,7 @@ def test_path_with_too_few_eligible_yields_nan(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11325,7 +11325,7 @@ def test_per_path_heterogeneity_no_multi_baseline_warning(self): ).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11464,7 +11464,7 @@ def test_per_path_heterogeneity_under_survey_finite(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11512,7 +11512,7 @@ def test_per_path_heterogeneity_replicate_weights_propagates_n_valid(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11604,7 +11604,7 @@ def test_paths_of_interest_heterogeneity_survey_design_analytical(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11654,7 +11654,7 @@ def test_paths_of_interest_heterogeneity_survey_n_bootstrap_gate(self): est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11683,7 +11683,7 @@ def test_survey_design_plus_n_bootstrap_with_heterogeneity_still_raises( est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11705,7 +11705,7 @@ def test_to_dataframe_by_path_includes_heterogeneity_columns(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11746,7 +11746,7 @@ def test_per_path_heterogeneity_renders_in_summary(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11793,7 +11793,7 @@ def test_path_unobserved_under_heterogeneity_warns_omits(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11884,7 +11884,7 @@ def test_to_dataframe_by_path_emits_het_columns_on_placebo_rows(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -11932,7 +11932,7 @@ def test_predict_het_placebo_survey_design_warns_and_skips_backward(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -12046,7 +12046,7 @@ def test_predict_het_placebo_survey_forward_only_still_works(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -12121,7 +12121,7 @@ def test_predict_het_placebo_eligible_filter(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -12161,7 +12161,7 @@ def test_path_heterogeneity_telescopes_to_global_on_single_path_panel( res_g = est_g.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -12177,7 +12177,7 @@ def test_path_heterogeneity_telescopes_to_global_on_single_path_panel( res_p = est_p.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -12218,7 +12218,7 @@ def test_summary_renders_placebo_het_rows(self): res = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, diff --git a/tests/test_chaisemartin_dhaultfoeuille_parity.py b/tests/test_chaisemartin_dhaultfoeuille_parity.py index 247e013c3..0f003b703 100644 --- a/tests/test_chaisemartin_dhaultfoeuille_parity.py +++ b/tests/test_chaisemartin_dhaultfoeuille_parity.py @@ -95,7 +95,7 @@ def _fit_dcdh_l1(df: pd.DataFrame): return est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -236,7 +236,7 @@ def _fit_dcdh_multi(df: pd.DataFrame, L_max: int): return est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -443,11 +443,11 @@ def _check_phase3_scenario( results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, - controls=controls, + covariates=controls, trends_linear=trends_linear, ) r_results = scenario["results"] @@ -584,7 +584,7 @@ def _compare_by_path(self, scenario, by_path, L_max, point_rtol, se_rtol): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=L_max, @@ -741,7 +741,7 @@ def test_parity_multi_path_reversible_by_path_placebo(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -862,10 +862,10 @@ def test_parity_multi_path_reversible_by_path_controls(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["X1"], + covariates=["X1"], L_max=3, ) @@ -1002,7 +1002,7 @@ def test_parity_single_baseline_multi_path_trends_lin(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_linear=True, @@ -1117,7 +1117,7 @@ def test_parity_multi_path_reversible_by_path_trends_nonparam(self, golden_value results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", trends_nonparam="state", @@ -1239,7 +1239,7 @@ def test_parity_multi_path_reversible_by_path_non_binary(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -1342,7 +1342,7 @@ def test_parity_multi_path_reversible_predict_het(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -1434,7 +1434,7 @@ def test_parity_multi_path_reversible_by_path_predict_het(self, golden_values): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -1550,7 +1550,7 @@ def test_parity_multi_path_reversible_predict_het_with_placebo(self, golden_valu results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, @@ -1665,7 +1665,7 @@ def test_parity_multi_path_reversible_predict_het_with_placebo_global(self, gold results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, diff --git a/tests/test_conley_vcov.py b/tests/test_conley_vcov.py index f983c76ab..451de7834 100644 --- a/tests/test_conley_vcov.py +++ b/tests/test_conley_vcov.py @@ -3412,7 +3412,7 @@ def test_panel_finite_se_and_metadata(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(res.overall_att) assert np.isfinite(res.overall_se) and res.overall_se > 0 assert res.vcov_type == "conley" @@ -3428,7 +3428,7 @@ def test_cross_sectional_finite_se(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=0, - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(res.overall_se) and res.overall_se > 0 assert res.conley_lag_cutoff == 0 @@ -3442,7 +3442,7 @@ def test_fwl_composability_vs_full_dummy(self): conley_coords=("lat", "lon"), conley_cutoff_km=2500.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") ref_coef, ref_se = _full_dummy_conley_treatment(df, 2500.0, 1) assert res.overall_att == pytest.approx(ref_coef, abs=1e-8) assert res.overall_se == pytest.approx(ref_se, abs=1e-7) @@ -3451,7 +3451,7 @@ def test_att_bit_identical_across_vcov(self): from diff_diff import WooldridgeDiD df = _two_group_panel(seed=34) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") r_conley = WooldridgeDiD( vcov_type="conley", conley_coords=("lat", "lon"), @@ -3465,7 +3465,7 @@ def test_conley_plus_cluster_product_kernel(self): from diff_diff import WooldridgeDiD df = _two_group_panel(seed=35) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") plain = WooldridgeDiD( vcov_type="conley", conley_coords=("lat", "lon"), @@ -3500,7 +3500,7 @@ def test_reject_survey_design(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) @@ -3515,7 +3515,7 @@ def test_reject_n_bootstrap(self): conley_cutoff_km=2000.0, conley_lag_cutoff=1, n_bootstrap=20, - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") def test_reject_logit_plus_conley_at_init(self): from diff_diff import WooldridgeDiD @@ -3530,7 +3530,7 @@ def test_reject_missing_cutoff(self): with pytest.raises(ValueError): WooldridgeDiD( vcov_type="conley", conley_coords=("lat", "lon"), conley_lag_cutoff=1 - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") def test_unbalanced_panel_alignment(self): from diff_diff import WooldridgeDiD @@ -3543,7 +3543,7 @@ def test_unbalanced_panel_alignment(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(res.overall_se) and res.overall_se > 0 def test_atomic_set_params_round_trip(self): @@ -3570,7 +3570,7 @@ def test_conley_cohort_trends_full_dummy(self): from diff_diff import WooldridgeDiD df = _staggered_panel(seed=51) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") ck = dict(conley_coords=("lat", "lon"), conley_cutoff_km=3000.0, conley_lag_cutoff=1) r_conley = WooldridgeDiD(vcov_type="conley", cohort_trends=True, **ck).fit(df, **kw) r_hc1 = WooldridgeDiD(vcov_type="hc1", cohort_trends=True).fit(df, **kw) @@ -3585,7 +3585,7 @@ def test_conley_aggregations_finite(self): finite overall + per-key SEs (no NaN/crash on the conley path).""" from diff_diff import WooldridgeDiD - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") ck = dict(conley_coords=("lat", "lon"), conley_cutoff_km=3000.0, conley_lag_cutoff=1) for agg, field in ( ("group", "group_effects"), @@ -3607,7 +3607,7 @@ def test_conley_cohort_trends_aggregations_finite(self): per-key SEs.""" from diff_diff import WooldridgeDiD - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") ck = dict(conley_coords=("lat", "lon"), conley_cutoff_km=3000.0, conley_lag_cutoff=1) for agg, field in ( ("group", "group_effects"), @@ -3631,7 +3631,7 @@ def test_conley_control_group_never_treated(self): from diff_diff import WooldridgeDiD df = _staggered_panel(seed=62) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") ck = dict(conley_coords=("lat", "lon"), conley_cutoff_km=3000.0, conley_lag_cutoff=1) r_conley = WooldridgeDiD(vcov_type="conley", control_group="never_treated", **ck).fit( df, **kw diff --git a/tests/test_dcdh_bootstrap_cell_period_coverage.py b/tests/test_dcdh_bootstrap_cell_period_coverage.py index 9934c2e09..e2fc681c9 100644 --- a/tests/test_dcdh_bootstrap_cell_period_coverage.py +++ b/tests/test_dcdh_bootstrap_cell_period_coverage.py @@ -141,7 +141,7 @@ def test_bootstrap_cell_period_coverage_varying_psu(): ).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, diff --git a/tests/test_dcdh_cell_period_coverage.py b/tests/test_dcdh_cell_period_coverage.py index 719b1fa4c..02b4f151b 100644 --- a/tests/test_dcdh_cell_period_coverage.py +++ b/tests/test_dcdh_cell_period_coverage.py @@ -108,7 +108,7 @@ def test_cell_period_allocator_coverage_within_group_varying_psu(): res = ChaisemartinDHaultfoeuille(seed=r + 1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, diff --git a/tests/test_dcdh_heterogeneity_cell_period_coverage.py b/tests/test_dcdh_heterogeneity_cell_period_coverage.py index f40125720..da8129e08 100644 --- a/tests/test_dcdh_heterogeneity_cell_period_coverage.py +++ b/tests/test_dcdh_heterogeneity_cell_period_coverage.py @@ -121,7 +121,7 @@ def test_heterogeneity_cell_period_null_coverage_varying_psu(): res = ChaisemartinDHaultfoeuille(seed=r + 1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", heterogeneity="x_het", diff --git a/tests/test_did_had_parity.py b/tests/test_did_had_parity.py index 466aaef9a..f3a1d15eb 100644 --- a/tests/test_did_had_parity.py +++ b/tests/test_did_had_parity.py @@ -139,18 +139,18 @@ def _python_fit( panel_2p = panel[panel["t"].isin([F - 1, F])].copy() return est.fit( panel_2p, - outcome_col="y", - dose_col="d", - time_col="t", - unit_col="g", + outcome="y", + dose="d", + time="t", + unit="g", aggregate="overall", ) return est.fit( panel, - outcome_col="y", - dose_col="d", - time_col="t", - unit_col="g", + outcome="y", + dose="d", + time="t", + unit="g", aggregate="event_study", trends_lin=trends_lin, ) diff --git a/tests/test_etwfe_cs_stata_parity.py b/tests/test_etwfe_cs_stata_parity.py index c6244b134..f29d9b8c2 100644 --- a/tests/test_etwfe_cs_stata_parity.py +++ b/tests/test_etwfe_cs_stata_parity.py @@ -102,7 +102,7 @@ def fits(): with warnings.catch_warnings(): warnings.simplefilter("ignore") etwfe = WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) cs = CallawaySantAnna(control_group="not_yet_treated").fit( df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" @@ -158,7 +158,7 @@ def never_fit(): with warnings.catch_warnings(): warnings.simplefilter("ignore") return WooldridgeDiD(method="ols", control_group="never_treated").fit( - _panel(), outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + _panel(), outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) @@ -202,7 +202,7 @@ def test_no_rank_deficiency(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") WooldridgeDiD(method="ols", control_group="never_treated").fit( - _panel(), outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + _panel(), outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) rank = [w for w in caught if "Rank-deficient" in str(w.message)] assert rank == [], f"unexpected rank deficiency: {[str(w.message)[:120] for w in rank]}" @@ -359,7 +359,7 @@ def alltreated_fit(): with warnings.catch_warnings(): warnings.simplefilter("ignore") return WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - sub, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + sub, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) @@ -479,7 +479,7 @@ def test_se_and_att_match_jwdid_at_every_cluster_count(self, ladder, n_per_cohor with warnings.catch_warnings(): warnings.simplefilter("ignore") fit = WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - sub, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + sub, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) stata = _stata_jwdid_cells({"rung": rung["cells"]}, "rung") assert len(stata) == 7, f"expected 7 cells at G={rung['G']}, got {len(stata)}" diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index b1e8b4343..8caa47ce8 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -298,7 +298,7 @@ def surfaces(): # 9. WooldridgeDiD (post-fit aggregate) mp = load_mpdta() wd = WooldridgeDiD(control_group="never_treated").fit( - mp, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + mp, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) wd.aggregate("event") out["WooldridgeDiD"] = (wd, build_event_study_surface(wd)) @@ -342,7 +342,7 @@ def surfaces(): dcdh = ChaisemartinDHaultfoeuille(twfe_diagnostic=False).fit( dcdh_data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=3, diff --git a/tests/test_had.py b/tests/test_had.py index 3b27483cb..628bfb43b 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -635,7 +635,7 @@ def test_three_periods_with_first_treat_raises(self): "dose", "period", "unit", - first_treat_col="ft", + first_treat="ft", ) def test_single_period_raises(self): @@ -684,14 +684,14 @@ def test_first_treat_col_invalid_cohort_raises(self): ft_unit = np.where(np.arange(200) % 2 == 0, 0, 5) panel["ft"] = np.repeat(ft_unit, 2) est = HeterogeneousAdoptionDiD() - with pytest.raises(ValueError, match=r"first_treat_col"): + with pytest.raises(ValueError, match=r"first_treat"): est.fit( panel, "outcome", "dose", "period", "unit", - first_treat_col="ft", + first_treat="ft", ) def test_first_treat_col_mixed_row_nan_raises(self): @@ -710,7 +710,7 @@ def test_first_treat_col_mixed_row_nan_raises(self): panel.loc[unit0_post_idx, "ft"] = np.nan est = HeterogeneousAdoptionDiD() with pytest.raises(ValueError, match="NaN"): - est.fit(panel, "outcome", "dose", "period", "unit", first_treat_col="ft") + est.fit(panel, "outcome", "dose", "period", "unit", first_treat="ft") def test_first_treat_col_mixed_row_invalid_value_raises(self): """Per-unit rows like [valid, invalid_value] must be rejected.""" @@ -721,8 +721,8 @@ def test_first_treat_col_mixed_row_invalid_value_raises(self): unit0_post_idx = panel[(panel["unit"] == 0) & (panel["period"] == 2)].index[0] panel.loc[unit0_post_idx, "ft"] = 999.0 est = HeterogeneousAdoptionDiD() - with pytest.raises(ValueError, match=r"first_treat_col.*999"): - est.fit(panel, "outcome", "dose", "period", "unit", first_treat_col="ft") + with pytest.raises(ValueError, match=r"first_treat.*999"): + est.fit(panel, "outcome", "dose", "period", "unit", first_treat="ft") # ============================================================================= @@ -1884,7 +1884,7 @@ def test_first_treat_col_dtype_agnostic_rejects_invalid_string(self): # Invalid: "Z" is neither 0 nor "B" ft_unit = np.array([0 if i % 2 == 0 else "Z" for i in range(100)], dtype=object) panel["ft"] = np.repeat(ft_unit, 2) - with pytest.raises(ValueError, match="first_treat_col"): + with pytest.raises(ValueError, match="first_treat"): _validate_had_panel(panel, "outcome", "dose", "period", "unit", "ft") def test_semantic_pre_post_labels_not_lexicographic(self): @@ -2414,7 +2414,7 @@ def test_staggered_filter_warning(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) filter_warnings = [msg for msg in w if "Staggered" in str(msg.message)] @@ -2430,7 +2430,7 @@ def test_staggered_filter_info_populated(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) assert result.filter_info is not None @@ -2455,7 +2455,7 @@ def test_staggered_filter_keeps_last_cohort_and_never_treated(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) # Paper Appendix B.2: staggered HAD applies to last cohort + keeps @@ -2483,7 +2483,7 @@ def test_staggered_filter_retains_never_treated_units(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) # The fit ran successfully with never-treated retained. Verify @@ -2511,7 +2511,7 @@ def test_no_filter_on_single_cohort(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) filter_warnings = [msg for msg in w if "Staggered" in str(msg.message)] @@ -2807,7 +2807,7 @@ def test_nan_in_first_treat_col_rejected(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) @@ -2918,7 +2918,7 @@ def test_staggered_ordered_categorical_chooses_chronological_last(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) @@ -2971,7 +2971,7 @@ def test_first_treat_col_mismatch_with_dose_raises(self): "dose", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", ) @@ -5079,7 +5079,7 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "period", "unit", aggregate="event_study", - first_treat_col="first_treat", + first_treat="first_treat", ) r_uni = est.fit( panel.assign(w=1.0), @@ -5088,7 +5088,7 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "period", "unit", aggregate="event_study", - first_treat_col="first_treat", + first_treat="first_treat", survey_design=SurveyDesign(weights="w"), cband=False, ) @@ -5103,7 +5103,7 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): "period", "unit", aggregate="event_study", - first_treat_col="first_treat", + first_treat="first_treat", survey_design=SurveyDesign(weights="w"), cband=False, ) diff --git a/tests/test_had_pretests.py b/tests/test_had_pretests.py index c7fb50b7b..70478f83d 100644 --- a/tests/test_had_pretests.py +++ b/tests/test_had_pretests.py @@ -634,10 +634,10 @@ def test_all_pass_on_linear_flags_assumption7_gap(self): panel = _make_two_period_panel(200, d, dy, seed=42) report = did_had_pretest_workflow( panel, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", alpha=0.05, n_bootstrap=199, seed=42, @@ -667,10 +667,10 @@ def test_rejects_on_quadratic_plus_shifted_support(self): panel = _make_two_period_panel(G, d, dy, seed=42) report = did_had_pretest_workflow( panel, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", alpha=0.05, n_bootstrap=199, seed=42, @@ -2019,7 +2019,7 @@ def test_staggered_panel_with_first_treat_col_warns_and_filters(self): "unit", pre_periods=[1997, 1998], base_period=1999, - first_treat_col="first_treat", + first_treat="first_treat", n_bootstrap=199, seed=0, ) @@ -2539,7 +2539,7 @@ def test_staggered_auto_filter_warns(self): "d", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", n_bootstrap=199, seed=0, @@ -2649,7 +2649,7 @@ def test_event_study_small_panel_after_filter_inconclusive_not_crash(self): "d", "period", "unit", - first_treat_col="first_treat", + first_treat="first_treat", aggregate="event_study", n_bootstrap=199, seed=0, @@ -3903,7 +3903,7 @@ def test_workflow_staggered_event_study_weights_subset_correctly(self): "d", "time", "unit", - first_treat_col="F", + first_treat="F", aggregate="event_study", survey_design=SurveyDesign(weights="_wcol"), n_bootstrap=199, @@ -3933,7 +3933,7 @@ def test_joint_pretrends_test_staggered_weights_subset(self): "unit", pre_periods=[0, 1], base_period=2, - first_treat_col="F", + first_treat="F", n_bootstrap=199, seed=0, survey_design=SurveyDesign(weights="_wcol"), @@ -3954,7 +3954,7 @@ def test_joint_homogeneity_test_staggered_weights_subset(self): "unit", post_periods=[3], base_period=2, - first_treat_col="F", + first_treat="F", n_bootstrap=199, seed=0, survey_design=SurveyDesign(weights="_wcol"), @@ -4409,7 +4409,7 @@ def test_workflow_event_study_zero_weights_on_dropped_cohort(self): "d", "time", "unit", - first_treat_col="F", + first_treat="F", aggregate="event_study", survey_design=SurveyDesign(weights="_wcol"), n_bootstrap=199, @@ -5255,18 +5255,18 @@ def test_pretrends_consumed_e_minus_2_dropped_in_HAD_fit(self): df = self._panel(rng_seed=18) est_no = HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", ) est_yes = HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=True, ) @@ -5306,18 +5306,18 @@ def test_fit_default_bit_exact_backcompat(self): df = self._panel(rng_seed=22) r1 = HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", ) r2 = HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=False, ) @@ -5331,10 +5331,10 @@ def test_fit_aggregate_overall_with_trends_lin_raises(self): with pytest.raises(NotImplementedError, match="aggregate='event_study'"): HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="overall", trends_lin=True, ) @@ -5348,10 +5348,10 @@ def test_fit_F2_with_trends_lin_raises(self): with pytest.raises(ValueError, match="F >= 3"): HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=True, ) @@ -5364,10 +5364,10 @@ def test_fit_with_survey_design_and_trends_lin_raises(self): with pytest.raises(NotImplementedError, match="trends_lin=True.*survey"): HeterogeneousAdoptionDiD().fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=True, survey_design=SurveyDesign(weights="w"), @@ -5382,19 +5382,19 @@ def test_fit_idempotence_with_trends_lin(self): est = HeterogeneousAdoptionDiD() r1 = est.fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=True, ) r2 = est.fit( df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", + outcome="y", + dose="d", + time="time", + unit="unit", aggregate="event_study", trends_lin=True, ) @@ -5541,11 +5541,11 @@ def test_workflow_stratified_event_study_end_to_end_smoke(self): with pytest.warns(UserWarning, match="QUG step skipped"): report = did_had_pretest_workflow( data=df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", - first_treat_col="F", + outcome="y", + dose="d", + time="time", + unit="unit", + first_treat="F", survey_design=sd, aggregate="event_study", n_bootstrap=199, diff --git a/tests/test_methodology_chaisemartin_dhaultfoeuille.py b/tests/test_methodology_chaisemartin_dhaultfoeuille.py index 06be4fef7..465ff3ba0 100644 --- a/tests/test_methodology_chaisemartin_dhaultfoeuille.py +++ b/tests/test_methodology_chaisemartin_dhaultfoeuille.py @@ -63,7 +63,7 @@ def test_hand_calculable_4group_3period_joiners_and_leavers(self, panel): results = est.fit( panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -98,7 +98,7 @@ def test_worked_example_se_is_unidentified_with_warning(self, panel): results = est.fit( panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -121,7 +121,7 @@ def test_per_period_decomposition_matches_hand_arithmetic(self, panel): results = est.fit( panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -148,7 +148,7 @@ def test_no_groups_dropped_in_clean_panel(self, panel): results = est.fit( panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -194,7 +194,7 @@ def test_placebo_zero_under_constant_trends(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -298,7 +298,7 @@ def test_cohort_recentering_not_grand_mean(self): results = est.fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -401,7 +401,7 @@ def test_iid_data_finite_variance(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -430,7 +430,7 @@ def test_twfe_diagnostic_runs_on_real_data(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -486,7 +486,7 @@ def test_twfe_diagnostic_hand_checkable_sigma_fe(self): result = twowayfeweights( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -505,7 +505,7 @@ def test_twfe_disabled_means_none(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -536,7 +536,7 @@ def test_recovery_single_switch_n200(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -559,7 +559,7 @@ def test_recovery_joiners_only_n200(self): results = est.fit( data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) diff --git a/tests/test_methodology_had.py b/tests/test_methodology_had.py index 42451c54b..c2381a33c 100644 --- a/tests/test_methodology_had.py +++ b/tests/test_methodology_had.py @@ -171,10 +171,10 @@ def _fit_overall(panel: pd.DataFrame, **kwargs) -> HeterogeneousAdoptionDiDResul ) result = est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", ) assert isinstance(result, HeterogeneousAdoptionDiDResults) return result @@ -422,10 +422,10 @@ def test_explicit_mass_point_on_continuous_sample_rejects(self) -> None: with pytest.raises(ValueError, match=r"(mass[_-]point|d_lower|modal)"): est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", ) def test_mass_point_n_at_d_lower_and_above_populated(self) -> None: @@ -824,10 +824,10 @@ def test_joint_pretrends_fails_to_reject_under_h0(self) -> None: ) res = joint_pretrends_test( data=panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", pre_periods=[1, 2], base_period=3, n_bootstrap=199, @@ -852,10 +852,10 @@ def test_joint_homogeneity_fails_to_reject_under_linear_dgp(self) -> None: ) res = joint_homogeneity_test( data=panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", post_periods=[4, 5], base_period=3, n_bootstrap=199, @@ -879,10 +879,10 @@ def test_joint_homogeneity_rejects_under_nonlinear_dgp(self) -> None: ) res = joint_homogeneity_test( data=panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", post_periods=[4, 5], base_period=3, n_bootstrap=199, @@ -906,10 +906,10 @@ def test_n_bootstrap_lower_bound_validates(self) -> None: with pytest.raises(ValueError, match=r"n_bootstrap.*99"): joint_pretrends_test( data=panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", pre_periods=[1, 2], base_period=3, n_bootstrap=49, @@ -929,10 +929,10 @@ def test_per_horizon_stats_dict_populated(self) -> None: ) res = joint_pretrends_test( data=panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", pre_periods=[1, 2], base_period=3, n_bootstrap=199, @@ -1049,10 +1049,10 @@ def test_sup_t_bootstrap_skipped_when_cband_false(self) -> None: warnings.filterwarnings("ignore", category=DeprecationWarning) result = est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", aggregate="event_study", survey_design=SurveyDesign(weights="w"), cband=False, @@ -1076,10 +1076,10 @@ def test_sup_t_bootstrap_skipped_when_overall_aggregate(self) -> None: warnings.filterwarnings("ignore", category=DeprecationWarning) _ = est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", aggregate="overall", survey_design=SurveyDesign(weights="w"), cband=True, # request cband on overall — should be ignored @@ -1118,15 +1118,15 @@ class through (only the last cohort is identified under with pytest.raises(ValueError, match=r"(staggered|cohort|first_treat_col|HAD)"): est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", aggregate="event_study", ) def test_first_treat_col_activates_last_cohort_auto_filter(self) -> None: - """``first_treat_col=`` activates last-cohort + never-treated auto-filter.""" + """``first_treat=`` activates last-cohort + never-treated auto-filter.""" rng = np.random.default_rng(_BASE_SEED_DEVIATIONS + 4) # G large enough that the surviving (last-cohort + never-treated) # subset of ~2/3 of G has enough distinct dose values for the @@ -1163,11 +1163,11 @@ def test_first_treat_col_activates_last_cohort_auto_filter(self) -> None: warnings.filterwarnings("ignore", category=UserWarning) result = est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", - first_treat_col="first_treat", + outcome="outcome", + dose="dose", + time="period", + unit="unit", + first_treat="first_treat", aggregate="event_study", ) # Should produce a valid event-study result (no raise). @@ -1212,10 +1212,10 @@ def test_assumption_5_6_userwarning_fires_on_design_1_family(self) -> None: with pytest.warns(UserWarning, match=r"Assumption [56]"): est.fit( panel, - outcome_col="outcome", - dose_col="dose", - time_col="period", - unit_col="unit", + outcome="outcome", + dose="dose", + time="period", + unit="unit", ) def test_safe_inference_no_partial_nan_on_degenerate_panel(self) -> None: diff --git a/tests/test_methodology_wooldridge.py b/tests/test_methodology_wooldridge.py index 589deb5f3..80e529121 100644 --- a/tests/test_methodology_wooldridge.py +++ b/tests/test_methodology_wooldridge.py @@ -370,7 +370,7 @@ def test_etwfe_recovers_constant_tau_under_homogeneous_te_dgp(self) -> None: rng, n_per_cohort=200, tau_constant=1.0, sigma=0.05 ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # With n_per_cohort=200 + sigma=0.05, expected std error per cell ≈ # sigma / sqrt(200) ≈ 0.0035; 3-sigma band ≈ 0.011. @@ -394,7 +394,7 @@ def test_etwfe_recovers_cell_specific_tau_under_heterogeneous_te_dgp(self) -> No rng, n_per_cohort=300, tau_by_gt=tau_by_gt, sigma=0.05 ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) for (g, t), expected_tau in tau_by_gt.items(): assert (g, t) in res.group_time_effects @@ -422,7 +422,7 @@ def test_overall_att_equals_cell_count_weighted_average_of_gt_atts(self) -> None rng, n_per_cohort=200, tau_by_gt=tau_by_gt, sigma=0.05 ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Manual cell-count-weighted average using the populated _gt_weights # field (per Eq. 5.16 weighting). Filter to treated cells only @@ -455,7 +455,7 @@ def test_constant_outcome_panel_returns_nan_inference(self) -> None: warnings.filterwarnings("ignore", category=RuntimeWarning) try: res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) except (ValueError, np.linalg.LinAlgError): # Acceptable: library may reject degenerate fit at the @@ -526,7 +526,7 @@ def test_multi_cohort_panel_recovers_per_cell_atts(self) -> None: rng, n_per_cohort=150, tau_by_gt=tau_by_gt, sigma=0.05 ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) for (g, t), expected in tau_by_gt.items(): est = res.group_time_effects[(g, t)]["att"] @@ -547,7 +547,7 @@ def test_event_aggregation_matches_per_cell_average_at_same_event_time(self) -> rng = np.random.default_rng(_BASE_SEED_PROP51 + 2) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=120, sigma=0.08) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -591,7 +591,7 @@ def test_pols_runs_with_never_treated_control_group(self) -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Surface-level invariants: fit completed cleanly + the result # object is well-formed. @@ -619,7 +619,7 @@ def test_simple_aggregate_matches_overall_att_at_fit_time(self) -> None: rng = np.random.default_rng(_BASE_SEED_PROP51 + 4) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) before_att = res.overall_att before_se = res.overall_se @@ -655,7 +655,7 @@ def test_event_aggregation_indexed_by_k_eq_t_minus_g(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION6 + 1) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.08) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -689,7 +689,7 @@ def test_event_aggregate_recovers_homogeneous_event_time_atts(self) -> None: rng, n_per_cohort=200, tau_by_gt=tau_by_gt, sigma=0.05 ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -708,7 +708,7 @@ def test_event_aggregate_se_is_finite_under_balanced_panel(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION6 + 3) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=100, sigma=0.1) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -727,7 +727,7 @@ def test_event_aggregate_inference_consistent_under_safe_inference(self) -> None rng = np.random.default_rng(_BASE_SEED_SECTION6 + 4) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=100, sigma=0.1) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -779,7 +779,7 @@ def test_aggregate_weights_cell_default_matches_jwdid_estat(self) -> None: cohort_unit_counts={0: 100, 2: 50, 3: 200}, ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Manual cell-count weighted overall ATT gt = {k: v for k, v in res.group_time_effects.items() if k[0] > 0 and k[1] >= k[0]} @@ -813,7 +813,7 @@ def test_aggregate_simple_weights_cohort_share_matches_paper_eq74(self) -> None: cohort_unit_counts={0: 100, 2: 50, 3: 200}, ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Hand-calc paper Eq. 7.4: ATT_simple = Σ_{(g,t):t≥g} N_g · τ_{g,t} / Σ_{(g,t):t≥g} N_g # = (50·1 + 50·1 + 50·1 + 200·2 + 200·2) / (50·3 + 200·2) @@ -856,7 +856,7 @@ def test_aggregate_event_weights_cohort_share_matches_paper_eq76(self) -> None: cohort_unit_counts={0: 100, 2: 50, 3: 200}, ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event", weights="cohort_share") assert res.event_study_effects is not None @@ -903,7 +903,7 @@ def test_aggregate_weights_cohort_share_balanced_panel_equals_cell(self) -> None sigma=0.05, ) # No cohort_unit_counts override → uniform 80 per cohort res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) cell_att = res.overall_att res.aggregate("simple", weights="cohort_share") @@ -918,7 +918,7 @@ def test_aggregate_weights_cohort_share_raises_on_group_aggregation(self) -> Non rng = np.random.default_rng(_BASE_SEED_SECTION7 + 5) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=50, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.raises(ValueError, match=r"cohort_share.*group|simple.*event"): res.aggregate("group", weights="cohort_share") @@ -928,7 +928,7 @@ def test_aggregate_weights_cohort_share_raises_on_calendar_aggregation(self) -> rng = np.random.default_rng(_BASE_SEED_SECTION7 + 6) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=50, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.raises(ValueError, match=r"cohort_share.*calendar|simple.*event"): res.aggregate("calendar", weights="cohort_share") @@ -950,7 +950,7 @@ def test_aggregate_event_weights_cohort_share_restricts_to_k_geq_0(self) -> None with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # DGP precondition: never_treated + OLS exposes k<0 placebo cells all_k_cells = sorted({t - g for (g, t) in res.group_time_effects.keys()}) @@ -995,7 +995,7 @@ def test_aggregate_weights_cohort_share_rejects_survey_design(self) -> None: outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=survey, ) assert res.survey_metadata is not None @@ -1009,7 +1009,7 @@ def test_aggregate_weights_invalid_value_raises(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION7 + 7) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=50, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.raises(ValueError, match=r"weights must be one of"): res.aggregate("simple", weights="random_string") @@ -1052,7 +1052,7 @@ def test_aggregate_weights_cohort_share_poisson_path(self) -> None: outcome="y_count", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) # simple aggregation with pytest.warns(UserWarning, match=r"cohort_share.*conditional-on-shares"): @@ -1114,7 +1114,7 @@ def test_aggregate_weights_cohort_share_logit_path(self) -> None: outcome="y_binary", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) except (ValueError, np.linalg.LinAlgError): pytest.skip( @@ -1174,7 +1174,7 @@ def test_aggregate_weights_cohort_share_inference_fail_closed_with_warning( cohort_unit_counts={0: 100, 2: 50, 3: 200}, ) res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.warns(UserWarning, match=r"cohort_share.*conditional-on-shares"): res.aggregate("simple", weights="cohort_share") @@ -1209,7 +1209,7 @@ def test_aggregate_simple_weights_cell_idempotent_with_fit_time_overall_att(self cohort_unit_counts={0: 80, 2: 40, 3: 200}, ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) fit_time_att = res.overall_att fit_time_se = res.overall_se @@ -1238,7 +1238,7 @@ def test_aggregate_group_calendar_use_cell_count_weights(self) -> None: sigma=0.05, ) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("group") assert res.group_effects is not None @@ -1298,10 +1298,10 @@ def test_cohort_trends_false_default_matches_pre_pr_baseline(self) -> None: sigma=0.05, ) res_default = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res_explicit_off = WooldridgeDiD(method="ols", cohort_trends=False).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Bit-equal across both invocations (tolerance handles sub-ULP # float-aggregation-order noise from the Python-level @@ -1321,7 +1321,7 @@ def test_cohort_trends_true_populates_cohort_trend_coefs(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 1) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Treated cohorts in the heterogeneous-trends DGP are g=3 and g=4 assert set(res.cohort_trend_coefs.keys()) == {3, 4} @@ -1349,11 +1349,11 @@ def test_cohort_trends_true_recovers_tau_under_heterogeneous_trends_dgp(self) -> ) # Without cohort_trends → cells deviate from tau=1.0 res_off = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # With cohort_trends → each post-treatment cell ≈ tau res_on = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # On the heterogeneous-trends DGP, cohort 3's cells (positive trend) # under cohort_trends=False are biased upward; under @@ -1414,7 +1414,7 @@ def test_cohort_trends_true_compatible_with_vcov_type_hc2_bm(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 3) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True, vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # All identified treated cells have finite ATT + SE finite_count = 0 @@ -1438,7 +1438,7 @@ def test_cohort_trends_true_compatible_with_vcov_type_hc1_via_auto_route(self) - panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) # vcov_type='hc1' is the default; cohort_trends=True should not raise res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.vcov_type == "hc1" assert any(np.isfinite(s) for s in res.cohort_trend_coefs.values()) @@ -1455,7 +1455,7 @@ def test_cohort_trends_true_aggregate_event_finite_inference(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 5) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -1479,7 +1479,7 @@ def test_cohort_trends_true_plus_weights_cohort_share_simple_excludes_trend_colu rng = np.random.default_rng(_BASE_SEED_SECTION8 + 6) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("simple", weights="cohort_share") assert np.isfinite(res.overall_att) @@ -1511,7 +1511,7 @@ def test_cohort_trends_true_rejects_never_treated_control_group(self) -> None: outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) def test_cohort_trends_true_rejects_survey_design(self) -> None: @@ -1536,7 +1536,7 @@ def test_cohort_trends_true_rejects_survey_design(self) -> None: outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=survey, ) @@ -1551,7 +1551,7 @@ def test_cohort_trends_true_plus_aggregate_group(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 13) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("group") assert res.group_effects is not None @@ -1567,7 +1567,7 @@ def test_cohort_trends_true_plus_aggregate_calendar(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 14) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("calendar") assert res.calendar_effects is not None @@ -1592,7 +1592,7 @@ def test_plot_event_study_cohort_share_suppresses_error_bars(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 19) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # cohort_share path: plot helper must receive NaN SEs with warnings.catch_warnings(): @@ -1629,12 +1629,12 @@ def test_results_metadata_records_cohort_trends_and_per_surface_weights( rng = np.random.default_rng(_BASE_SEED_SECTION8 + 20) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res_default = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res_default.cohort_trends is False assert res_default.aggregation_weights == {"simple": "cell"} res_trends = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res_trends.cohort_trends is True assert res_trends.aggregation_weights == {"simple": "cell"} @@ -1659,7 +1659,7 @@ def test_aggregation_weights_per_surface_mixed_order(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 21) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) @@ -1684,7 +1684,7 @@ def test_aggregation_weights_failed_cohort_share_leaves_metadata_unchanged( rng = np.random.default_rng(_BASE_SEED_SECTION8 + 22) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) snap = dict(res.aggregation_weights) with pytest.raises(ValueError, match=r"cohort_share.*simple.*event"): @@ -1716,7 +1716,7 @@ def test_plot_event_study_propagates_weights_kwarg(self) -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Default plot — uses weights="cell" with patch("diff_diff.visualization.plot_event_study") as mock_plot: @@ -1796,7 +1796,7 @@ def test_cohort_trends_true_all_treated_panel_estimates(self) -> None: outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) # G - 1 = 1 trend coefficient, the last cohort deliberately absent. @@ -1824,7 +1824,7 @@ def test_cohort_trends_true_with_never_treated_keeps_all_cohort_trends(self) -> 0 in panel["cohort"].unique() ), "DGP precondition: panel must include cohort=0 (never-treated)" res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # All treated cohorts (g=3 and g=4) keep their trend columns assert set(res.cohort_trend_coefs.keys()) == {3, 4}, ( @@ -1862,7 +1862,7 @@ def test_plot_event_study_cohort_share_to_cell_round_trip_restores_placebo_leads with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Step 1: plot under cohort_share — caches k>=0 keys with warnings.catch_warnings(): @@ -1900,7 +1900,7 @@ def test_cohort_trends_true_plus_bootstrap_preserves_bootstrap_se(self) -> None: rng = np.random.default_rng(_BASE_SEED_SECTION8 + 12) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True, n_bootstrap=20, seed=42).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res._bootstrap_used is True boot_se = res.overall_se @@ -1920,7 +1920,7 @@ def test_cohort_trends_true_plus_weights_cohort_share_event_excludes_trend_colum rng = np.random.default_rng(_BASE_SEED_SECTION8 + 7) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res = WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event", weights="cohort_share") assert res.event_study_effects is not None @@ -1943,7 +1943,7 @@ def test_cohort_trends_true_rejects_insufficient_pre_periods(self) -> None: match=r"cohort_trends=True requires at least 2 pre-treatment periods", ): WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_cohort_trends_true_rejects_unbalanced_cohort_with_one_observed_pre_period( @@ -1998,7 +1998,7 @@ def test_cohort_trends_true_rejects_unbalanced_cohort_with_one_observed_pre_peri match=r"OBSERVED FOR EACH TREATED COHORT.*Cohort g=4 has only 1", ): WooldridgeDiD(method="ols", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_cohort_trends_true_hc1_uses_full_dummy_finite_sample_factor(self) -> None: @@ -2019,10 +2019,10 @@ def test_cohort_trends_true_hc1_uses_full_dummy_finite_sample_factor(self) -> No rng = np.random.default_rng(_BASE_SEED_SECTION8 + 9) panel = _make_heterogeneous_trends_panel(rng, n_per_cohort=80, sigma=0.05) res_off = WooldridgeDiD(method="ols", vcov_type="hc1").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res_on = WooldridgeDiD(method="ols", vcov_type="hc1", cohort_trends=True).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) for (g, t), eff in res_off.group_time_effects.items(): if g > 0 and t >= g and np.isfinite(eff["se"]): @@ -2065,7 +2065,7 @@ def test_unbalanced_panel_with_random_missingness_runs_without_error(self) -> No # smoke test. warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Sanity: at least one treated cell estimated; overall_att finite. assert any(g > 0 for (g, _t) in res.group_time_effects) @@ -2085,7 +2085,7 @@ def test_unbalanced_panel_recovers_constant_tau_approximately(self) -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # All treated cells should estimate τ̂ close to 1.0 (relaxed MC # band due to unbalanced panel + smaller effective N per cell). @@ -2119,7 +2119,7 @@ def test_time_varying_covariate_via_xtvar_with_demean(self) -> None: outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", xtvar=["xvar"], ) # τ̂ should still be ≈ 1.0 (the covariate is uncorrelated with treatment). @@ -2176,13 +2176,13 @@ def test_hc1_within_transform_se_differs_from_naive_full_design(self) -> None: rng = np.random.default_rng(_BASE_SEED_DEVIATIONS + 1) panel = _make_three_cohort_four_period_panel(rng, n_per_cohort=40, sigma=0.1) res_hc1 = WooldridgeDiD(method="ols", vcov_type="hc1").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # vcov_type='classical' uses the full-dummy design with no # robust adjustment (R lm() summary equivalent). On the same # panel, the SEs differ from hc1 in a documented way. res_classical = WooldridgeDiD(method="ols", vcov_type="classical").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Pick a representative treated cell present in both sample_key = next( @@ -2239,7 +2239,7 @@ def test_qmle_sandwich_inflates_se_vs_stata_jwdid_reference(self) -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res_poisson = WooldridgeDiD(method="poisson").fit( - panel, outcome="y_count", unit="unit", time="time", cohort="cohort" + panel, outcome="y_count", unit="unit", time="time", first_treat="cohort" ) # All treated cells should have finite + positive SEs for (g, t), eff in res_poisson.group_time_effects.items(): @@ -2273,7 +2273,7 @@ def test_nonlinear_methods_use_direct_qmle_not_fixest_backend(self) -> None: warnings.filterwarnings("ignore", category=UserWarning) try: res_logit = WooldridgeDiD(method="logit").fit( - panel, outcome="y_binary", unit="unit", time="time", cohort="cohort" + panel, outcome="y_binary", unit="unit", time="time", first_treat="cohort" ) except (np.linalg.LinAlgError, ValueError): # Logit may not converge on every random panel; treat as @@ -2310,7 +2310,7 @@ def test_logit_design_uses_cohort_time_additive_dummies(self) -> None: warnings.filterwarnings("ignore", category=UserWarning) try: res_logit = WooldridgeDiD(method="logit").fit( - panel, outcome="y_binary", unit="unit", time="time", cohort="cohort" + panel, outcome="y_binary", unit="unit", time="time", first_treat="cohort" ) except (np.linalg.LinAlgError, ValueError): # Small-N logit may fail to converge — acceptable as long @@ -2349,7 +2349,7 @@ def test_anticipation_window_leads_excluded_from_overall_att(self) -> None: ) # Fit with anticipation=1: lead cells (g-1, g) get estimated res = WooldridgeDiD(method="ols", anticipation=1).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # The overall_att should be cell-count-weighted average of cells # with t >= g ONLY (anticipation lead cells excluded). @@ -2392,7 +2392,7 @@ def test_bootstrap_preserved_under_aggregate_simple_weights_cell(self) -> None: sigma=0.05, ) res = WooldridgeDiD(method="ols", n_bootstrap=20, seed=42).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # _bootstrap_used was set assert res._bootstrap_used is True @@ -2424,7 +2424,7 @@ def test_bootstrap_rejects_aggregate_simple_weights_cohort_share(self) -> None: cohort_unit_counts={0: 100, 2: 50, 3: 200}, ) res = WooldridgeDiD(method="ols", n_bootstrap=20, seed=42).fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.raises( ValueError, @@ -2443,7 +2443,7 @@ def test_safe_inference_joint_nan_invariant_on_degenerate_se(self) -> None: rng = np.random.default_rng(_BASE_SEED_DEVIATIONS + 6) panel = _make_two_cohort_three_period_panel(rng, n_per_cohort=50) res = WooldridgeDiD(method="ols").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Verify every cell satisfies the joint invariant for (g, t), eff in res.group_time_effects.items(): @@ -2479,7 +2479,7 @@ def test_interaction_coefs_match_lm(self, golden: dict, panel: pd.DataFrame) -> `vcov_type='hc2_bm'` (full-dummy branch). """ res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_keys = [(d["g"], d["t"]) for d in golden["point_estimates"]["gt_keys"]] r_coefs = golden["point_estimates"]["interaction_coefs"] @@ -2494,7 +2494,7 @@ def test_hc2_bm_per_coef_se_matches_clubsandwich_cr2( ) -> None: """Per-treatment-cell CR2-BM SE matches `clubSandwich::vcovCR(..., type="CR2")` at atol=1e-10.""" res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_keys = [(d["g"], d["t"]) for d in golden["point_estimates"]["gt_keys"]] r_ses = golden["hc2_bm"]["per_coef_se"] @@ -2516,7 +2516,7 @@ def test_hc2_bm_per_coef_df_satt_matches_coef_test( SE); brentq inversion adds the only material tolerance. """ res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_keys = [(d["g"], d["t"]) for d in golden["point_estimates"]["gt_keys"]] r_dfs = golden["hc2_bm"]["per_coef_df_satt"] @@ -2532,7 +2532,7 @@ def test_hc2_bm_overall_att_se_matches_clubsandwich_cr2( ) -> None: """Overall ATT SE matches the linear-combination SE from `clubSandwich::vcovCR(..., type="CR2")`.""" res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_se = golden["hc2_bm"]["overall_att_se"] assert res.overall_se == pytest.approx(r_se, abs=1e-10) @@ -2548,7 +2548,7 @@ def test_hc2_bm_overall_att_contrast_dof_matches_wald_test_htz( StackedDiD PR #479). """ res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) py_dof = _recover_dof_from_ci( res.overall_att, res.overall_se, res.overall_conf_int[1], res.alpha @@ -2562,7 +2562,7 @@ def test_hc2_bm_overall_att_contrast_dof_matches_wald_test_htz( def test_classical_se_matches_lm_summary(self, golden: dict, panel: pd.DataFrame) -> None: """`vcov_type='classical'` (drops auto-cluster) matches `summary(lm(...))$coefficients` SE.""" res = WooldridgeDiD(method="ols", vcov_type="classical").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_keys = [(d["g"], d["t"]) for d in golden["point_estimates"]["gt_keys"]] r_ses = golden["classical"]["per_coef_se"] @@ -2574,7 +2574,7 @@ def test_classical_se_matches_lm_summary(self, golden: dict, panel: pd.DataFrame def test_hc2_se_matches_sandwich_vcovhc(self, golden: dict, panel: pd.DataFrame) -> None: """`vcov_type='hc2'` (drops auto-cluster) matches `sandwich::vcovHC(type="HC2")` SE.""" res = WooldridgeDiD(method="ols", vcov_type="hc2").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) r_keys = [(d["g"], d["t"]) for d in golden["point_estimates"]["gt_keys"]] r_ses = golden["hc2"]["per_coef_se"] @@ -2595,7 +2595,7 @@ def test_classical_per_cell_inference_uses_residual_df( = 51 columns, all kept (full rank). Residual df = 240 - 51 = 189. """ res = WooldridgeDiD(method="ols", vcov_type="classical").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) expected_df = float(panel.shape[0] - 51) # 189 for (g, t), eff in res.group_time_effects.items(): @@ -2613,7 +2613,7 @@ def test_hc2_per_cell_inference_uses_residual_df( DOF (matches R ``coef_test(fit, vcov=vcovHC(type="HC2"))`` t-distribution default) rather than normal-theory.""" res = WooldridgeDiD(method="ols", vcov_type="hc2").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) expected_df = float(panel.shape[0] - 51) for (g, t), eff in res.group_time_effects.items(): @@ -2629,7 +2629,7 @@ def test_aggregate_group_bm_dof_matches_wald_test_htz( ``clubSandwich::Wald_test(test="HTZ")$df_denom`` at atol=1e-6 (CI inversion tolerance).""" res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("group") r_dofs = golden["hc2_bm"]["aggregate_group_dof"] @@ -2649,7 +2649,7 @@ def test_aggregate_calendar_bm_dof_matches_wald_test_htz( """``aggregate('calendar')`` BM contrast DOF per treated time period matches R `Wald_test(test="HTZ")$df_denom` at atol=1e-6.""" res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("calendar") r_dofs = golden["hc2_bm"]["aggregate_calendar_dof"] @@ -2669,7 +2669,7 @@ def test_aggregate_event_bm_dof_matches_wald_test_htz( """``aggregate('event')`` BM contrast DOF per relative-period k matches R `Wald_test(test="HTZ")$df_denom` at atol=1e-6.""" res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - panel, outcome="y", unit="unit", time="time", cohort="cohort" + panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") r_dofs = golden["hc2_bm"]["aggregate_event_dof"] @@ -2745,7 +2745,7 @@ def test_poisson_fit_completes_with_finite_atts( with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) res = WooldridgeDiD(method="poisson").fit( - panel, outcome="y_pois", unit="unit", time="time", cohort="cohort" + panel, outcome="y_pois", unit="unit", time="time", first_treat="cohort" ) finite_atts = [ res.group_time_effects[k]["att"] @@ -2810,7 +2810,7 @@ def test_logit_fit_completes_with_finite_atts(self, golden: dict, panel: pd.Data outcome="y_logit", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) except (ValueError, np.linalg.LinAlgError): pytest.skip( diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index eb8468ce1..e24513702 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -467,6 +467,7 @@ def _build_rowed_index(): "EfficientDiD.fit[time]", "EfficientDiD.hausman_pretest[time]", "GroupTimeEffect.time", + "HeterogeneousAdoptionDiD.fit[time]", "ImputationDiD.fit[time]", "LPDiD.fit[time]", "SpilloverDiD.fit[time]", @@ -481,7 +482,10 @@ def _build_rowed_index(): "agent_workflow[time]", "check_parallel_trends[time]", "check_parallel_trends_robust[time]", + "did_had_pretest_workflow[time]", "equivalence_test_trends[time]", + "joint_homogeneity_test[time]", + "joint_pretrends_test[time]", "leave_one_out_test[time]", "permutation_test[time]", "placebo_group_test[time]", diff --git a/tests/test_rdd.py b/tests/test_rdd.py index 0ec9d18c4..bc75ec33a 100644 --- a/tests/test_rdd.py +++ b/tests/test_rdd.py @@ -381,25 +381,25 @@ def _fuzzy_df(n=1200, seed=5): class TestFuzzyAPI: def test_missing_treatment_col_raises(self): with pytest.raises(ValueError, match="not found"): - RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", treatment_col="takeup") + RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", takeup="takeup") def test_nan_in_treatment_counted_in_drop(self): df = _fuzzy_df(200) df.loc[3, "t"] = np.nan df.loc[7, "y"] = np.nan with pytest.warns(UserWarning, match="Dropping 2 row"): - r = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + r = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") assert r.n_obs == 198 assert r.n_dropped == 2 def test_estimand_echo(self): df = _fuzzy_df() sharp = RegressionDiscontinuity().fit(df, "y", "x") - fz = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + fz = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") assert sharp.estimand == "sharp (ATE at the cutoff)" assert fz.estimand == "fuzzy (LATE for compliers at the cutoff)" - assert sharp.treatment_col is None - assert fz.treatment_col == "t" + assert sharp.takeup is None + assert fz.takeup == "t" def test_estimand_label_non_binary_takeup(self): # Dose take-up is accepted (R's fuzzy= semantics) but must NOT be @@ -410,7 +410,7 @@ def test_estimand_label_non_binary_takeup(self): df["dose"] = df["t"] * rng.uniform(0.5, 2.0, size=len(df)) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="dose") + r = RegressionDiscontinuity().fit(df, "y", "x", takeup="dose") assert r.estimand == "fuzzy (local Wald ratio at the cutoff; non-binary take-up)" assert r.first_stage is not None @@ -439,7 +439,7 @@ def test_first_stage_fields_none_on_sharp(self): assert d["first_stage_conf_int_lower"] is None # None-safe CI split def test_first_stage_populated_and_coherent_on_fuzzy(self): - r = RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", treatment_col="t") + r = RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", takeup="t") assert r.first_stage is not None and r.first_stage_se is not None assert r.first_stage_t_stat == pytest.approx(r.first_stage / r.first_stage_se, rel=1e-14) lo, hi = r.first_stage_conf_int @@ -456,7 +456,7 @@ def test_sharpbw_on_sharp_fit_warns_and_ignored(self): def test_summary_first_stage_block_fuzzy_only(self): df = _fuzzy_df() - fz = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + fz = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") sharp = RegressionDiscontinuity().fit(df, "y", "x") assert "First-stage estimates" in fz.summary() assert "Fuzzy Regression Discontinuity" in fz.summary() @@ -465,7 +465,7 @@ def test_summary_first_stage_block_fuzzy_only(self): assert "Estimand:" in sharp.summary() def test_canonical_identities_hold_on_fuzzy(self): - r = RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", treatment_col="t") + r = RegressionDiscontinuity().fit(_fuzzy_df(), "y", "x", takeup="t") assert r.t_stat == pytest.approx(r.att / r.se, rel=1e-14) lo, hi = r.conf_int assert (lo + hi) / 2 == pytest.approx(r.att, rel=1e-12) @@ -525,7 +525,7 @@ def test_collision_with_structural_columns_rejected(self): with pytest.raises(ValueError, match="collide"): RegressionDiscontinuity().fit(df, "y", "x", covariates=[clash]) with pytest.raises(ValueError, match="collide"): - RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t", covariates=["t"]) + RegressionDiscontinuity().fit(df, "y", "x", takeup="t", covariates=["t"]) def test_echo_fields_adjusted_fit(self): df = _covs_df() diff --git a/tests/test_rdd_methodology.py b/tests/test_rdd_methodology.py index 58600eb38..65c27129a 100644 --- a/tests/test_rdd_methodology.py +++ b/tests/test_rdd_methodology.py @@ -231,7 +231,7 @@ def test_perfect_compliance_reproduces_sharp_exactly(self): df["t"] = (df["x"] >= 0).astype(float) with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") - fz = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + fz = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") sharp = RegressionDiscontinuity().fit(df, "y", "x") # Bandwidths are BIT-identical (perf_comp nulls T, so selection is # the same sharp arithmetic); the estimates agree to the ULP - the @@ -264,7 +264,7 @@ def test_one_sided_compliance_selects_sharp_bandwidths(self): # estimation stays fuzzy. df = _fuzzy_df(1200, seed=12) df.loc[df["x"] < 0, "t"] = 0.0 - fz = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + fz = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") sharp = RegressionDiscontinuity().fit(df, "y", "x") assert fz.h_left == sharp.h_left and fz.b_right == sharp.b_right assert fz.first_stage is not None and fz.first_stage > 0 @@ -272,8 +272,8 @@ def test_one_sided_compliance_selects_sharp_bandwidths(self): def test_sharpbw_true_selects_sharp_bandwidths(self): df = _fuzzy_df(1200, seed=14) - fz_sbw = RegressionDiscontinuity(sharpbw=True).fit(df, "y", "x", treatment_col="t") - fz_def = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + fz_sbw = RegressionDiscontinuity(sharpbw=True).fit(df, "y", "x", takeup="t") + fz_def = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") sharp = RegressionDiscontinuity().fit(df, "y", "x") assert fz_sbw.h_left == sharp.h_left assert fz_sbw.h_left != fz_def.h_left # fuzzy objective differs @@ -281,8 +281,8 @@ def test_sharpbw_true_selects_sharp_bandwidths(self): def test_outcome_scaling_scales_ratio_not_first_stage(self): df = _fuzzy_df(900, seed=15) scaled = df.assign(y=df.y * 7.0) - a = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", treatment_col="t") - b = RegressionDiscontinuity(h=0.3).fit(scaled, "y", "x", treatment_col="t") + a = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", takeup="t") + b = RegressionDiscontinuity(h=0.3).fit(scaled, "y", "x", takeup="t") assert b.att == pytest.approx(7.0 * a.att, rel=1e-12) assert b.se == pytest.approx(7.0 * a.se, rel=1e-12) # The take-up fits never see y: bit-identical first stage. @@ -299,7 +299,7 @@ def test_no_variation_no_jump_raises(self): with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") with pytest.raises(ValueError, match="no variation and no jump"): - RegressionDiscontinuity(masspoints="check").fit(df, "y", "x", treatment_col="t") + RegressionDiscontinuity(masspoints="check").fit(df, "y", "x", takeup="t") assert not any("Mass points detected" in str(w.message) for w in rec) def test_weak_first_stage_warns(self): @@ -310,13 +310,13 @@ def test_weak_first_stage_warns(self): df = _fuzzy_df(800, seed=17) df["t"] = (rng.uniform(size=800) < 0.4).astype(float) with pytest.warns(UserWarning, match="Weak first stage"): - RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + RegressionDiscontinuity().fit(df, "y", "x", takeup="t") def test_strong_first_stage_no_warning(self): df = _fuzzy_df(1500, seed=18) with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") - RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + RegressionDiscontinuity().fit(df, "y", "x", takeup="t") assert not any("Weak first stage" in str(w.message) for w in rec) def test_degenerate_pilot_first_stage_fails_closed(self): @@ -333,7 +333,7 @@ def test_degenerate_pilot_first_stage_fails_closed(self): with pytest.raises(ValueError, match="non-finite pilot bandwidth"): with warnings.catch_warnings(): warnings.simplefilter("ignore") - RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + RegressionDiscontinuity().fit(df, "y", "x", takeup="t") def test_manual_h_zero_first_stage_nan_gates(self): # Same construction with a manual h: estimation-time tau_T == 0 @@ -348,7 +348,7 @@ def test_manual_h_zero_first_stage_nan_gates(self): df = pd.DataFrame({"x": x, "y": y, "t": t}) with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") - r = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", treatment_col="t") + r = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", takeup="t") assert np.isnan(r.att) assert_nan_inference( {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} @@ -365,7 +365,7 @@ def test_n_below_20_fuzzy_estimates_fuzzy(self): y = 0.5 * x + 1.0 * t + rng.standard_normal(15) * 0.1 df = pd.DataFrame({"x": x, "y": y, "t": t}) with pytest.warns(UserWarning, match="entire sample"): - r = RegressionDiscontinuity().fit(df, "y", "x", treatment_col="t") + r = RegressionDiscontinuity().fit(df, "y", "x", takeup="t") assert r.bwselect == "Manual" assert r.first_stage is not None assert r.estimand.startswith("fuzzy") @@ -377,8 +377,8 @@ def test_manual_h_sharpbw_is_silent_noop(self): df = _fuzzy_df(900, seed=20) with warnings.catch_warnings(record=True) as rec: warnings.simplefilter("always") - a = RegressionDiscontinuity(h=0.3, sharpbw=True).fit(df, "y", "x", treatment_col="t") - b = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", treatment_col="t") + a = RegressionDiscontinuity(h=0.3, sharpbw=True).fit(df, "y", "x", takeup="t") + b = RegressionDiscontinuity(h=0.3).fit(df, "y", "x", takeup="t") assert a.att == b.att and a.se == b.se assert not any("sharpbw" in str(w.message) for w in rec) @@ -397,7 +397,7 @@ def test_constant_outcome_fuzzy_manual_h(self): df = pd.DataFrame({"x": x, "y": np.ones(n), "t": t}) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = RegressionDiscontinuity(h=0.5).fit(df, "y", "x", treatment_col="t") + r = RegressionDiscontinuity(h=0.5).fit(df, "y", "x", takeup="t") assert r.att == pytest.approx(0.0, abs=1e-12) assert abs(r.se) < 1e-12 # O(eps) scale, may not be exactly zero assert np.isfinite(r.first_stage) and np.isfinite(r.first_stage_se) @@ -563,9 +563,7 @@ def test_fuzzy_covs_canonical_identities_and_first_stage(self): df = self._cov_df() with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = RegressionDiscontinuity().fit( - df, "y", "x", treatment_col="t", covariates=["zlong", "zb"] - ) + r = RegressionDiscontinuity().fit(df, "y", "x", takeup="t", covariates=["zlong", "zb"]) assert r.t_stat == pytest.approx(r.att / r.se, rel=1e-14) mid = 0.5 * (r.conf_int[0] + r.conf_int[1]) assert mid == pytest.approx(r.att, rel=1e-12) @@ -584,7 +582,7 @@ def test_perfect_compliance_covs_reproduces_adjusted_sharp(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") fz = RegressionDiscontinuity().fit( - df, "y", "x", treatment_col="t_det", covariates=["zlong", "zb"] + df, "y", "x", takeup="t_det", covariates=["zlong", "zb"] ) sh = RegressionDiscontinuity().fit(df, "y", "x", covariates=["zlong", "zb"]) assert fz.h_left == sh.h_left and fz.b_left == sh.b_left @@ -601,7 +599,7 @@ def test_sharpbw_covs_keeps_covariates_in_selection(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") fz = RegressionDiscontinuity(sharpbw=True).fit( - df, "y", "x", treatment_col="t", covariates=["zlong", "zb"] + df, "y", "x", takeup="t", covariates=["zlong", "zb"] ) sh_adj = RegressionDiscontinuity().fit(df, "y", "x", covariates=["zlong", "zb"]) sh_plain = RegressionDiscontinuity().fit(df, "y", "x") diff --git a/tests/test_rdd_parity.py b/tests/test_rdd_parity.py index 7317ddb56..0ab0c9e84 100644 --- a/tests/test_rdd_parity.py +++ b/tests/test_rdd_parity.py @@ -100,7 +100,7 @@ def _fit(golden, dgp_name, cfg, cfg_name=None): df, "y", "x", - treatment_col=treatment_col, + takeup=treatment_col, covariates=covariates, ) diff --git a/tests/test_results_serialization.py b/tests/test_results_serialization.py index dba60b795..1c0779e36 100644 --- a/tests/test_results_serialization.py +++ b/tests/test_results_serialization.py @@ -116,7 +116,7 @@ def fitted_results(): ).fit(cd_data, "outcome", "unit", "period", "first_treat", "dose") out["WooldridgeDiDResults"] = WooldridgeDiD().fit( - load_mpdta(), outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + load_mpdta(), outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) out["ChaisemartinDHaultfoeuilleResults"] = _dcdh_fit() @@ -280,5 +280,5 @@ def _dcdh_fit(): data = generate_reversible_did_data(n_groups=50, n_periods=8, pattern="joiners_only", seed=42) return ChaisemartinDHaultfoeuille(twfe_diagnostic=False).fit( - data, outcome="outcome", group="group", time="period", treatment="treatment", L_max=1 + data, outcome="outcome", unit="group", time="period", treatment="treatment", L_max=1 ) diff --git a/tests/test_survey_dcdh.py b/tests/test_survey_dcdh.py index fa4e825f6..ab668d5f2 100644 --- a/tests/test_survey_dcdh.py +++ b/tests/test_survey_dcdh.py @@ -51,7 +51,7 @@ def test_no_survey_unchanged(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( base_data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=None, @@ -75,14 +75,14 @@ def test_uniform_weights_match_unweighted(self, base_data): result_plain = ChaisemartinDHaultfoeuille(seed=1).fit( base_data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) result_survey = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -127,14 +127,14 @@ def test_nonuniform_weights_change_att(self, base_data): result_plain = ChaisemartinDHaultfoeuille(seed=1).fit( multi, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) result_survey = ChaisemartinDHaultfoeuille(seed=1).fit( multi, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -164,7 +164,7 @@ def test_weight_scale_invariance(self, data_with_survey): r1 = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd1, @@ -172,7 +172,7 @@ def test_weight_scale_invariance(self, data_with_survey): r2 = ChaisemartinDHaultfoeuille(seed=1).fit( df2, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd2, @@ -194,7 +194,7 @@ def test_strata_psu_changes_se(self, data_with_survey): r_w = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_weights_only, @@ -202,7 +202,7 @@ def test_strata_psu_changes_se(self, data_with_survey): r_full = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_full, @@ -218,7 +218,7 @@ def test_survey_metadata_populated(self, data_with_survey): result = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -239,7 +239,7 @@ def test_rejects_fweight(self, base_data): ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -253,7 +253,7 @@ def test_rejects_aweight(self, base_data): ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -274,7 +274,7 @@ def test_varying_weights_within_group_accepted(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( multi, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -300,14 +300,14 @@ def test_varying_weights_change_att(self, base_data): result_plain = ChaisemartinDHaultfoeuille(seed=1).fit( multi, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) result_survey = ChaisemartinDHaultfoeuille(seed=1).fit( multi, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -339,7 +339,7 @@ def test_rejects_replicate_weights_with_bootstrap(self, base_data): ChaisemartinDHaultfoeuille(n_bootstrap=100, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -357,7 +357,7 @@ def test_multi_horizon_survey_runs(self, data_with_survey): result = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -395,7 +395,7 @@ def test_bootstrap_survey_auto_inject_no_warning(self, data_with_survey): ChaisemartinDHaultfoeuille(n_bootstrap=50, seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -425,14 +425,14 @@ def test_uniform_survey_se_matches_plugin(self, base_data): r_plain = ChaisemartinDHaultfoeuille(seed=1).fit( base_data, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) r_survey = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -468,7 +468,7 @@ def test_zero_weight_cell_excluded(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -491,7 +491,7 @@ def test_survey_delta_uses_survey_df(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -527,7 +527,7 @@ def test_survey_delta_t_differs_from_z(self, base_data): r = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -566,10 +566,10 @@ def test_survey_plus_controls_runs(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", - controls=["x"], + covariates=["x"], L_max=1, survey_design=sd, ) @@ -595,7 +595,7 @@ def test_survey_honest_did_propagates_df(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -630,7 +630,7 @@ def test_uniform_weights_het_matches_unweighted(self, base_data): r_plain = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -640,7 +640,7 @@ def test_uniform_weights_het_matches_unweighted(self, base_data): r_survey = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -669,7 +669,7 @@ def test_nonuniform_het_changes_beta(self, base_data): r_plain = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -678,7 +678,7 @@ def test_nonuniform_het_changes_beta(self, base_data): r_survey = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -708,7 +708,7 @@ def test_survey_het_uses_survey_df(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -742,7 +742,7 @@ def test_twfe_helper_matches_fit_under_survey(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -750,7 +750,7 @@ def test_twfe_helper_matches_fit_under_survey(self, data_with_survey): helper = twowayfeweights( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -774,7 +774,7 @@ def test_twfe_helper_rejects_non_pweight(self, base_data): twowayfeweights( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -799,7 +799,7 @@ def test_twfe_helper_accepts_replicate_weights(self, base_data): result = twowayfeweights( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -824,7 +824,7 @@ def test_survey_twfe_matches_obs_level_pweighted_ols(self, data_with_survey): helper = twowayfeweights( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -901,7 +901,7 @@ def test_mixed_zero_weight_row_excluded_from_validation(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -922,7 +922,7 @@ def test_zero_weight_row_with_nan_outcome(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -945,7 +945,7 @@ def test_zero_weight_row_with_nan_group_id(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -969,11 +969,11 @@ def test_zero_weight_row_with_nan_control(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, - controls=["x"], + covariates=["x"], survey_design=sd, ) assert np.isfinite(result.overall_att) @@ -997,7 +997,7 @@ def test_zero_weight_row_with_nan_heterogeneity(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1018,7 +1018,7 @@ def test_survey_trends_linear_runs(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -1051,7 +1051,7 @@ def test_survey_trends_nonparam_runs(self, data_with_survey): r = ChaisemartinDHaultfoeuille(seed=1).fit( data_with_survey, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -1105,7 +1105,7 @@ def test_survey_design2_runs(self): r = ChaisemartinDHaultfoeuille(seed=1, drop_larger_lower=False).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=1, @@ -1151,7 +1151,7 @@ def test_accepts_varying_psu_within_group(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1169,7 +1169,7 @@ def test_accepts_varying_psu_within_group(self, base_data): r_const = ChaisemartinDHaultfoeuille(seed=1).fit( df_const, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_const, @@ -1196,7 +1196,7 @@ def test_accepts_varying_strata_within_group(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1224,7 +1224,7 @@ def test_heterogeneity_with_varying_psu_succeeds(self, base_data): res = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", heterogeneity="x_het", @@ -1258,7 +1258,7 @@ def test_bootstrap_with_varying_psu_succeeds(self, base_data): res = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1302,7 +1302,7 @@ def test_auto_inject_with_varying_strata_nest_true_succeeds(self, base_data): r_auto = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_auto, @@ -1311,7 +1311,7 @@ def test_auto_inject_with_varying_strata_nest_true_succeeds(self, base_data): r_explicit = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_explicit, @@ -1341,7 +1341,7 @@ def test_heterogeneity_auto_inject_with_varying_strata_nest_true_succeeds(self, res = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", heterogeneity="x_het", @@ -1370,7 +1370,7 @@ def test_heterogeneity_multi_horizon_varying_psu_succeeds(self, base_data): res = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", heterogeneity="x_het", @@ -1402,7 +1402,7 @@ def test_auto_inject_with_varying_strata_raises(self, base_data): ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1427,7 +1427,7 @@ def test_within_cell_psu_variation_rejected(self, base_data): ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1450,7 +1450,7 @@ def test_within_cell_strata_variation_rejected(self, base_data): ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1466,7 +1466,7 @@ def test_accepts_varying_weights_within_group(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1482,7 +1482,7 @@ def test_auto_inject_psu_matches_explicit_group_psu(self, base_data): r_no_psu = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=SurveyDesign(weights="pw"), @@ -1490,7 +1490,7 @@ def test_auto_inject_psu_matches_explicit_group_psu(self, base_data): r_explicit = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=SurveyDesign(weights="pw", psu="group"), @@ -1535,7 +1535,7 @@ def test_degenerate_cohort_survey_se_is_nan(self): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1569,7 +1569,7 @@ def test_subpopulation_preserves_full_design_df_survey(self, base_data): r_subpop = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1580,7 +1580,7 @@ def test_subpopulation_preserves_full_design_df_survey(self, base_data): r_explicit = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=SurveyDesign(weights="pw", psu="group"), @@ -1606,7 +1606,7 @@ def test_off_horizon_row_duplication_does_not_change_se(self, base_data): r_base = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -1622,7 +1622,7 @@ def test_off_horizon_row_duplication_does_not_change_se(self, base_data): r_dup = ChaisemartinDHaultfoeuille(seed=1).fit( df_dup, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", L_max=2, @@ -1741,7 +1741,7 @@ def test_within_cell_check_excludes_zero_weight_rows(self, base_data): result = ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2012,7 +2012,7 @@ def test_bootstrap_se_matches_pre_pr4_baseline(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=500, seed=42).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2191,7 +2191,7 @@ def test_bootstrap_cell_level_with_all_zero_weight_group_does_not_crash(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=50, seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2247,7 +2247,7 @@ def _make(include_zero_group: bool) -> pd.DataFrame: res_a = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=7).fit( _make(include_zero_group=True), outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2255,7 +2255,7 @@ def _make(include_zero_group: bool) -> pd.DataFrame: res_b = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=7).fit( _make(include_zero_group=False), outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2345,7 +2345,7 @@ def test_fit_raises_on_terminal_missingness_with_varying_psu(self): ChaisemartinDHaultfoeuille(n_bootstrap=0, seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2363,7 +2363,7 @@ def test_fit_raises_on_terminal_missingness_with_varying_psu(self): ChaisemartinDHaultfoeuille(n_bootstrap=50, seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2419,7 +2419,7 @@ def test_fit_succeeds_on_terminal_missingness_with_psu_group(self): ).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -2492,7 +2492,7 @@ def _spy(**kwargs): est.fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, diff --git a/tests/test_survey_dcdh_replicate_psu.py b/tests/test_survey_dcdh_replicate_psu.py index 18d19a9bd..f0696acdd 100644 --- a/tests/test_survey_dcdh_replicate_psu.py +++ b/tests/test_survey_dcdh_replicate_psu.py @@ -160,7 +160,7 @@ def test_overall_se_finite(self, base_panel, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -222,7 +222,7 @@ def test_att_raises_on_terminal_missingness_replicate_path(self): ChaisemartinDHaultfoeuille(seed=1).fit( df_, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -278,7 +278,7 @@ def _spy(psi, resolved): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -360,7 +360,7 @@ def test_inference_fields_finite(self, base_panel, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -377,7 +377,7 @@ def test_df_survey_reflects_n_valid(self, base_panel, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -401,7 +401,7 @@ def test_jk1_converges_to_tsl(self, replicate_design): res_rep = ChaisemartinDHaultfoeuille(seed=1).fit( df_rep, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_rep, @@ -409,7 +409,7 @@ def test_jk1_converges_to_tsl(self, replicate_design): res_tsl = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_tsl, @@ -435,7 +435,7 @@ def test_multi_horizon_under_replicate(self, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -459,7 +459,7 @@ def test_placebo_under_replicate(self, replicate_design): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -491,11 +491,11 @@ def test_did_x_replicate(self, base_panel, replicate_design): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, - controls=["cov1"], + covariates=["cov1"], L_max=1, ) assert np.isfinite(res.overall_att) @@ -518,7 +518,7 @@ def test_heterogeneity_se_finite(self, base_panel, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -543,7 +543,7 @@ def test_twowayfeweights_accepts_replicate(self, base_panel, replicate_design): res_rep = twowayfeweights( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_rep, @@ -551,7 +551,7 @@ def test_twowayfeweights_accepts_replicate(self, base_panel, replicate_design): res_plain = twowayfeweights( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_plain, @@ -581,7 +581,7 @@ def test_auto_inject_bit_identical_to_group_level(self, base_panel): r_auto = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_auto, @@ -589,7 +589,7 @@ def test_auto_inject_bit_identical_to_group_level(self, base_panel): r_explicit = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd_explicit, @@ -607,7 +607,7 @@ def test_coarser_psu_produces_finite_se(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=500, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -625,7 +625,7 @@ def test_no_warning_under_auto_inject(self, base_panel): ChaisemartinDHaultfoeuille(n_bootstrap=100, seed=1).fit( base_panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -647,7 +647,7 @@ def test_warning_under_coarser_psu(self): ChaisemartinDHaultfoeuille(n_bootstrap=100, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -666,7 +666,7 @@ def test_all_weight_types_under_psu(self, weight_type): ).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -682,7 +682,7 @@ def test_multi_horizon_psu_bootstrap(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=300, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -704,7 +704,7 @@ def test_multi_horizon_shared_draw_under_coarser_psu(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=500, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -727,7 +727,7 @@ def test_sup_t_under_coarser_psu(self): res = ChaisemartinDHaultfoeuille(n_bootstrap=500, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -755,7 +755,7 @@ def test_replicate_plus_bootstrap_rejected(self, base_panel, replicate_design): ChaisemartinDHaultfoeuille(n_bootstrap=50, seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -767,14 +767,14 @@ def test_non_survey_unchanged(self, base_panel): r1 = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=1).fit( base_panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) r2 = ChaisemartinDHaultfoeuille(n_bootstrap=200, seed=1).fit( base_panel, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", ) @@ -887,7 +887,7 @@ def test_honest_did_under_replicate(self, base_panel, replicate_design, method): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -931,7 +931,7 @@ def test_rank_deficient_replicate_uses_design_df(self, base_panel): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -976,7 +976,7 @@ def test_dropped_replicate_reduces_df(self, base_panel): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1021,7 +1021,7 @@ def test_rank_1_replicate_forces_nan_inference(self, base_panel): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1075,7 +1075,7 @@ def test_heterogeneity_replicate_cross_surface_df_consistency( res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1153,7 +1153,7 @@ def count_only(psi, resolved_arg): ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1183,7 +1183,7 @@ def reduce_after_main(psi, resolved_arg): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1319,7 +1319,7 @@ def count_only(psi, resolved_arg): ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1345,7 +1345,7 @@ def reduce_to_one(psi, resolved_arg): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1404,7 +1404,7 @@ def test_phase1_event_study_row_matches_refreshed_overall(self, base_panel): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, @@ -1435,7 +1435,7 @@ def test_phase1_surface_df_matches_refreshed_survey_df(self, base_panel): res = ChaisemartinDHaultfoeuille(seed=1).fit( df, outcome="outcome", - group="group", + unit="group", time="period", treatment="treatment", survey_design=sd, diff --git a/tests/test_t19_marketing_pulse_drift.py b/tests/test_t19_marketing_pulse_drift.py index 335fdbbd3..ea91b4b79 100644 --- a/tests/test_t19_marketing_pulse_drift.py +++ b/tests/test_t19_marketing_pulse_drift.py @@ -68,7 +68,7 @@ def phase1_results(panel): return model.fit( panel, outcome="sessions", - group="market_id", + unit="market_id", time="week", treatment="promo_on", ) @@ -96,7 +96,7 @@ def event_study_results(panel): return model.fit( panel, outcome="sessions", - group="market_id", + unit="market_id", time="week", treatment="promo_on", L_max=2, @@ -199,7 +199,7 @@ def test_assumption7_warning_fires_as_expected(panel): model.fit( panel, outcome="sessions", - group="market_id", + unit="market_id", time="week", treatment="promo_on", L_max=2, @@ -236,7 +236,7 @@ def test_event_study_warning_policy_matches_notebook(panel): model.fit( panel, outcome="sessions", - group="market_id", + unit="market_id", time="week", treatment="promo_on", L_max=2, @@ -286,7 +286,7 @@ def test_a11_warning_does_not_fire(): DCDH(twfe_diagnostic=False, placebo=False, seed=42).fit( df, outcome="sessions", - group="market_id", + unit="market_id", time="week", treatment="promo_on", ) diff --git a/tests/test_t20_had_brand_campaign_drift.py b/tests/test_t20_had_brand_campaign_drift.py index 83fb94855..de18496ff 100644 --- a/tests/test_t20_had_brand_campaign_drift.py +++ b/tests/test_t20_had_brand_campaign_drift.py @@ -92,10 +92,10 @@ def overall_result(panel_2pd): est = HAD(design="auto") return est.fit( panel_2pd, - outcome_col="weekly_visits", - dose_col="regional_spend_k", - time_col="period", - unit_col="dma_id", + outcome="weekly_visits", + dose="regional_spend_k", + time="period", + unit="dma_id", ) @@ -111,11 +111,11 @@ def event_study_result(panel): est = HAD(design="auto") return est.fit( panel, - outcome_col="weekly_visits", - dose_col="regional_spend_k", - time_col="week", - unit_col="dma_id", - first_treat_col="first_treat", + outcome="weekly_visits", + dose="regional_spend_k", + time="week", + unit="dma_id", + first_treat="first_treat", aggregate="event_study", ) diff --git a/tests/test_t21_had_pretest_workflow_drift.py b/tests/test_t21_had_pretest_workflow_drift.py index c2b588b5b..20b59b57b 100644 --- a/tests/test_t21_had_pretest_workflow_drift.py +++ b/tests/test_t21_had_pretest_workflow_drift.py @@ -103,11 +103,11 @@ def two_period(panel): def overall_report(two_period): return did_had_pretest_workflow( data=two_period, - outcome_col="weekly_visits", - dose_col="regional_spend_k", - time_col="period", - unit_col="dma_id", - first_treat_col="first_treat", + outcome="weekly_visits", + dose="regional_spend_k", + time="period", + unit="dma_id", + first_treat="first_treat", alpha=0.05, n_bootstrap=999, seed=WORKFLOW_SEED, @@ -119,11 +119,11 @@ def overall_report(two_period): def event_study_report(panel): return did_had_pretest_workflow( data=panel, - outcome_col="weekly_visits", - dose_col="regional_spend_k", - time_col="week", - unit_col="dma_id", - first_treat_col="first_treat", + outcome="weekly_visits", + dose="regional_spend_k", + time="week", + unit="dma_id", + first_treat="first_treat", alpha=0.05, n_bootstrap=999, seed=WORKFLOW_SEED, @@ -323,10 +323,10 @@ def test_had_design_auto_lands_on_continuous_at_zero(two_period): est = HAD(design="auto") result = est.fit( two_period, - outcome_col="weekly_visits", - dose_col="regional_spend_k", - time_col="period", - unit_col="dma_id", + outcome="weekly_visits", + dose="regional_spend_k", + time="period", + unit="dma_id", ) assert result.design == "continuous_at_zero", result.design assert result.target_parameter == "WAS", result.target_parameter diff --git a/tests/test_t22_had_survey_design_drift.py b/tests/test_t22_had_survey_design_drift.py index 237ea775a..78b82d7fe 100644 --- a/tests/test_t22_had_survey_design_drift.py +++ b/tests/test_t22_had_survey_design_drift.py @@ -218,10 +218,10 @@ def naive_overall_result(panel_2p: pd.DataFrame): warnings.filterwarnings("ignore", category=UserWarning) return HAD(design="auto").fit( panel_2p, - outcome_col="screening_uptake", - dose_col="spend_k", - time_col="period", - unit_col="state_id", + outcome="screening_uptake", + dose="spend_k", + time="period", + unit="state_id", ) @@ -231,10 +231,10 @@ def survey_overall_result(panel_2p: pd.DataFrame, survey_design: SurveyDesign): warnings.filterwarnings("ignore", category=UserWarning) return HAD(design="auto").fit( panel_2p, - outcome_col="screening_uptake", - dose_col="spend_k", - time_col="period", - unit_col="state_id", + outcome="screening_uptake", + dose="spend_k", + time="period", + unit="state_id", survey_design=survey_design, ) @@ -245,11 +245,11 @@ def survey_event_study_result(panel: pd.DataFrame, survey_design: SurveyDesign): warnings.filterwarnings("ignore", category=UserWarning) return HAD(design="auto").fit( panel, - outcome_col="screening_uptake", - dose_col="spend_k", - time_col="week", - unit_col="state_id", - first_treat_col="first_treat", + outcome="screening_uptake", + dose="spend_k", + time="week", + unit="state_id", + first_treat="first_treat", aggregate="event_study", survey_design=survey_design, cband=True, @@ -262,10 +262,10 @@ def overall_report(panel_2p: pd.DataFrame, survey_design: SurveyDesign): warnings.filterwarnings("ignore", category=UserWarning) return did_had_pretest_workflow( panel_2p, - outcome_col="screening_uptake", - dose_col="spend_k", - time_col="period", - unit_col="state_id", + outcome="screening_uptake", + dose="spend_k", + time="period", + unit="state_id", survey_design=survey_design, aggregate="overall", n_bootstrap=N_BOOTSTRAP, @@ -279,11 +279,11 @@ def event_study_report(panel: pd.DataFrame, survey_design: SurveyDesign): warnings.filterwarnings("ignore", category=UserWarning) return did_had_pretest_workflow( panel, - outcome_col="screening_uptake", - dose_col="spend_k", - time_col="week", - unit_col="state_id", - first_treat_col="first_treat", + outcome="screening_uptake", + dose="spend_k", + time="week", + unit="state_id", + first_treat="first_treat", survey_design=survey_design, aggregate="event_study", n_bootstrap=N_BOOTSTRAP, diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py index 9019a11fb..db4d68bb0 100644 --- a/tests/test_target_parameter.py +++ b/tests/test_target_parameter.py @@ -651,7 +651,7 @@ def test_dcdh_did_m_fit_headline_attribute_is_overall_att(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", ) @@ -680,7 +680,7 @@ def test_dcdh_did_1_fit_overall_att_real(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", L_max=1, @@ -704,7 +704,7 @@ def test_dcdh_delta_fit_real(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", L_max=2, @@ -732,7 +732,7 @@ def test_dcdh_trends_linear_no_scalar_propagates_through_br(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", L_max=2, @@ -767,7 +767,7 @@ def test_dcdh_trends_linear_no_scalar_propagates_through_dr(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", L_max=2, @@ -859,7 +859,7 @@ def _empty_surface_stub(self): placebo_conf_int=(float("nan"), float("nan")), placebo_available=False, per_period_effects={}, - groups=[1, 2, 3], + units=[1, 2, 3], time_periods=[1, 2, 3, 4], n_obs=100, n_treated_obs=50, @@ -997,7 +997,7 @@ def _empty_surface_stub_with_controls(self): placebo_conf_int=(float("nan"), float("nan")), placebo_available=False, per_period_effects={}, - groups=[1, 2, 3], + units=[1, 2, 3], time_periods=[1, 2, 3, 4], n_obs=100, n_treated_obs=50, @@ -1167,7 +1167,7 @@ def test_dcdh_empty_surface_propagates_to_assumption_and_native_label(self): placebo_conf_int=(float("nan"), float("nan")), placebo_available=False, per_period_effects={}, - groups=[1, 2, 3], + units=[1, 2, 3], time_periods=[1, 2, 3, 4], n_obs=100, n_treated_obs=50, @@ -1231,7 +1231,7 @@ def test_dcdh_trends_linear_with_l_max_geq_2_fit_real(self): fit = ChaisemartinDHaultfoeuille().fit( df, outcome="outcome", - group="unit", + unit="unit", time="period", treatment="treated", L_max=2, diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py new file mode 100644 index 000000000..dc4367da5 --- /dev/null +++ b/tests/test_v4_rename_mechanical.py @@ -0,0 +1,743 @@ +"""Behavioral pins for the 2(c)-ii mechanical rename wave (3.9 shims). + +The dedicated shim test file required by the ledger rows this PR flips +(``test_ref`` on M-032, M-033..M-042, M-088/M-089, M-094, M-097..M-114 in +``docs/v4-deprecations.yaml``). Per the section 2 per-PR gate, every shimmed +surface pins here: + +- ``pytest.warns(FutureWarning, match=...)`` on the OLD spelling with the + migration message; +- zero warnings on the canonical spelling (and on positional calls, which + bind to the new name in the old position); +- the both-supplied ``ValueError``; +- the sentinel-restored missing-argument ``TypeError`` naming the NEW param; +- bit-exact routing parity: the deprecated path returns the same numbers as + the canonical path; +- for renamed results FIELDS: the new name is the dataclass field, the old + name is a read-only warning property returning the same object, pickles + from the old field name migrate via ``__setstate__``, and ``to_dict()`` + emits the dual keys. +""" + +import pickle +import warnings +from dataclasses import dataclass, fields + +import numpy as np +import pandas as pd +import pytest + +from diff_diff._deprecation import ( + NOT_SUPPLIED, + _NotSupplied, + deprecated_field_property, + deprecated_kwarg_message, + require_arg, + resolve_renamed_kwarg, +) + + +def _assert_no_future_warning(record): + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert fw == [], [str(w.message) for w in fw] + + +# --------------------------------------------------------------------------- +# The shared helper module +# --------------------------------------------------------------------------- + + +class TestDeprecationHelpers: + def test_sentinel_repr(self): + assert repr(NOT_SUPPLIED) == "" + assert isinstance(NOT_SUPPLIED, _NotSupplied) + + def test_message_template(self): + msg = deprecated_kwarg_message("Cls.fit", "old", "use new= instead") + assert msg == ( + "Cls.fit(old=) is deprecated and will be removed in 4.0; " "use new= instead." + ) + + def test_resolve_neither_returns_default(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = resolve_renamed_kwarg( + "Cls.fit", "old", NOT_SUPPLIED, "new", NOT_SUPPLIED, default="d" + ) + assert out == "d" + + def test_resolve_new_silent(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + out = resolve_renamed_kwarg("Cls.fit", "old", NOT_SUPPLIED, "new", "value") + assert out == "value" + + def test_resolve_old_warns_and_maps(self): + with pytest.warns(FutureWarning, match=r"Cls\.fit\(old=\) is deprecated"): + out = resolve_renamed_kwarg("Cls.fit", "old", "value", "new", NOT_SUPPLIED) + assert out == "value" + + def test_resolve_old_extra_appended(self): + with pytest.warns(FutureWarning, match="calendar column"): + resolve_renamed_kwarg( + "Cls.fit", + "old", + "value", + "new", + NOT_SUPPLIED, + extra="From 4.0, old= means the calendar column only.", + ) + + def test_resolve_both_raises(self): + with pytest.raises(ValueError, match=r"pass only new="): + resolve_renamed_kwarg("Cls.fit", "old", "a", "new", "b") + + def test_require_arg(self): + require_arg("Cls.fit", "new", "value") # no raise + with pytest.raises(TypeError, match=r"missing required argument: 'new'"): + require_arg("Cls.fit", "new", NOT_SUPPLIED) + + def test_deprecated_field_property_shape(self): + @dataclass + class Toy: + new_name: int = 3 + old_name = deprecated_field_property("Toy", "old_name", "new_name") + + toy = Toy() + assert "new_name" in {f.name for f in fields(toy)} + assert "old_name" not in {f.name for f in fields(toy)} + with pytest.warns(FutureWarning, match=r"Toy\.old_name is deprecated"): + assert toy.old_name == 3 + with pytest.raises(AttributeError): + toy.old_name = 5 + + +# --------------------------------------------------------------------------- +# M-032: WooldridgeDiD.fit cohort= -> first_treat= +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def etwfe_panel(): + rng = np.random.default_rng(0) + rows = [] + for u in range(40): + g = 3 if u < 20 else 0 + for t in range(1, 6): + y = 1.0 + 0.5 * t + (1.5 if g and t >= g else 0.0) + rng.normal(0, 0.3) + rows.append((u, t, g, y)) + return pd.DataFrame(rows, columns=["unit", "time", "g", "y"]) + + +class TestWooldridgeCohortRename: + """M-032.""" + + def test_canonical_and_positional_silent(self, etwfe_panel): + from diff_diff import WooldridgeDiD + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_kw = WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time", first_treat="g") + r_pos = WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time", "g") + _assert_no_future_warning(record) + assert r_kw.att == r_pos.att + + def test_old_name_warns_and_routes_identically(self, etwfe_panel): + from diff_diff import WooldridgeDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + r_new = WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time", first_treat="g") + with pytest.warns( + FutureWarning, + match=r"WooldridgeDiD\.fit\(cohort=\) is deprecated and will be " + r"removed in 4\.0; use first_treat= instead\.", + ): + r_old = WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time", cohort="g") + assert r_old.att == r_new.att + assert r_old.se == r_new.se + + def test_both_supplied_raises(self, etwfe_panel): + from diff_diff import WooldridgeDiD + + with pytest.raises(ValueError, match=r"pass only first_treat="): + WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time", first_treat="g", cohort="g") + + def test_missing_raises_typeerror(self, etwfe_panel): + from diff_diff import WooldridgeDiD + + with pytest.raises(TypeError, match=r"missing required argument: 'first_treat'"): + WooldridgeDiD().fit(etwfe_panel, "y", "unit", "time") + + +# --------------------------------------------------------------------------- +# M-033/M-034: ChaisemartinDHaultfoeuille.fit group= -> unit=, controls= -> +# covariates=; M-097: twowayfeweights group= -> unit=; M-114: +# ChaisemartinDHaultfoeuilleResults.groups -> units +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def dcdh_panel(): + rng = np.random.default_rng(1) + rows = [] + for g in range(12): + switch = 3 if g < 6 else 99 + for t in range(1, 6): + d = 1.0 if t >= switch else 0.0 + y = 0.3 * t + 1.2 * d + rng.normal(0, 0.2) + rows.append((g, t, d, y)) + return pd.DataFrame(rows, columns=["g", "t", "d", "y"]) + + +class TestDCDHRenames: + """M-033 / M-034 / M-097 / M-114.""" + + def test_canonical_and_positional_silent(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_kw = ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "y", unit="g", time="t", treatment="d" + ) + r_pos = ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", "g", "t", "d") + _assert_no_future_warning(record) + assert r_kw.overall_att == r_pos.overall_att + + def test_group_warns_and_routes_identically(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "y", unit="g", time="t", treatment="d" + ) + with pytest.warns( + FutureWarning, + match=r"ChaisemartinDHaultfoeuille\.fit\(group=\) is deprecated and " + r"will be removed in 4\.0; use unit= instead\.", + ): + r_old = ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "y", group="g", time="t", treatment="d" + ) + assert r_old.overall_att == r_new.overall_att + assert r_old.overall_se == r_new.overall_se + + def test_controls_warns_and_routes_identically(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + rng = np.random.default_rng(9) + df = dcdh_panel.assign(x=rng.normal(size=len(dcdh_panel))) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = ChaisemartinDHaultfoeuille().fit( + df, "y", "g", "t", "d", L_max=1, covariates=["x"] + ) + with pytest.warns(FutureWarning, match=r"fit\(controls=\) is deprecated"): + r_old = ChaisemartinDHaultfoeuille().fit( + df, "y", "g", "t", "d", L_max=1, controls=["x"] + ) + assert r_old.overall_att == r_new.overall_att + + def test_both_supplied_raises(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + with pytest.raises(ValueError, match=r"pass only unit="): + ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "y", unit="g", group="g", time="t", treatment="d" + ) + + def test_missing_followers_raise(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + with pytest.raises(TypeError, match=r"missing required argument: 'unit'"): + ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y") + with pytest.raises(TypeError, match=r"missing required argument: 'time'"): + ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", unit="g") + with pytest.raises(TypeError, match=r"missing required argument: 'treatment'"): + ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", unit="g", time="t") + + def test_twowayfeweights_rename(self, dcdh_panel): + from diff_diff import twowayfeweights + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + tw_kw = twowayfeweights(dcdh_panel, "y", unit="g", time="t", treatment="d") + tw_pos = twowayfeweights(dcdh_panel, "y", "g", "t", "d") + _assert_no_future_warning(record) + with pytest.warns(FutureWarning, match=r"twowayfeweights\(group=\) is deprecated"): + tw_old = twowayfeweights(dcdh_panel, "y", group="g", time="t", treatment="d") + assert tw_kw.beta_fe == tw_old.beta_fe == tw_pos.beta_fe + with pytest.raises(TypeError, match=r"missing required argument: 'unit'"): + twowayfeweights(dcdh_panel, "y") + + def test_units_field_and_groups_alias(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + from diff_diff.chaisemartin_dhaultfoeuille_results import ( + ChaisemartinDHaultfoeuilleResults, + ) + + assert "units" in ChaisemartinDHaultfoeuilleResults.__dataclass_fields__ + assert "groups" not in ChaisemartinDHaultfoeuilleResults.__dataclass_fields__ + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", "g", "t", "d") + with pytest.warns( + FutureWarning, + match=r"ChaisemartinDHaultfoeuilleResults\.groups is deprecated", + ): + alias = res.groups + assert alias is res.units + with pytest.raises(AttributeError): + res.groups = [1] + assert "n_units=" in repr(res) + assert "Units (post-filter):" in res.summary() + + def test_to_dict_never_serialized_the_unit_list(self, dcdh_panel): + """dCDH ``to_dict()`` is a hand-built headline dict: it serialized + neither ``groups`` (pre-M-114) nor ``units`` (post), so no dual-key + window applies here - unlike M-094's ``treatment_col``, which WAS + an emitted key. Serialization must also never touch the warning + property.""" + from diff_diff import ChaisemartinDHaultfoeuille + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", "g", "t", "d") + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + d = res.to_dict() + assert "groups" not in d + assert "units" not in d + + def test_pickle_migration(self, dcdh_panel): + from diff_diff import ChaisemartinDHaultfoeuille + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = ChaisemartinDHaultfoeuille().fit(dcdh_panel, "y", "g", "t", "d") + clone = pickle.loads(pickle.dumps(res)) + assert clone.units == res.units + # Simulate a 3.8-era pickle payload keyed on the old field name. + state = dict(res.__dict__) + state["groups"] = state.pop("units") + old_style = object.__new__(type(res)) + old_style.__setstate__(state) + assert old_style.units == res.units + + +# --------------------------------------------------------------------------- +# M-035..M-039: HeterogeneousAdoptionDiD.fit *_col -> bare names +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def had_panel(): + rng = np.random.default_rng(2) + rows = [] + for u in range(120): + d = max(0.0, rng.normal(1.0, 0.6)) + for t in (0, 1): + y = 0.5 + 0.4 * t + 1.1 * d * t + rng.normal(0, 0.3) + rows.append((u, t, d if t == 1 else 0.0, y)) + return pd.DataFrame(rows, columns=["u", "t", "dose", "y"]) + + +class TestHADColRenames: + """M-035..M-039.""" + + def test_canonical_and_positional_silent(self, had_panel): + from diff_diff import HeterogeneousAdoptionDiD + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_pos = HeterogeneousAdoptionDiD().fit(had_panel, "y", "dose", "t", "u") + r_kw = HeterogeneousAdoptionDiD().fit( + had_panel, outcome="y", dose="dose", time="t", unit="u" + ) + _assert_no_future_warning(record) + assert r_pos.att == r_kw.att + + def test_old_names_warn_once_each_and_route_identically(self, had_panel): + from diff_diff import HeterogeneousAdoptionDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = HeterogeneousAdoptionDiD().fit(had_panel, "y", "dose", "t", "u") + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_old = HeterogeneousAdoptionDiD().fit( + had_panel, + outcome_col="y", + dose_col="dose", + time_col="t", + unit_col="u", + ) + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert len(fw) == 4 + messages = "\n".join(str(w.message) for w in fw) + for old in ("outcome_col", "dose_col", "time_col", "unit_col"): + assert f"HeterogeneousAdoptionDiD.fit({old}=) is deprecated" in messages + assert r_old.att == r_new.att + assert r_old.se == r_new.se + + def test_both_supplied_raises(self, had_panel): + from diff_diff import HeterogeneousAdoptionDiD + + with pytest.raises(ValueError, match=r"pass only unit="): + HeterogeneousAdoptionDiD().fit(had_panel, "y", "dose", "t", "u", unit_col="u") + + def test_missing_raises_typeerror(self, had_panel): + from diff_diff import HeterogeneousAdoptionDiD + + with pytest.raises(TypeError, match=r"missing required argument: 'time'"): + HeterogeneousAdoptionDiD().fit(had_panel, "y", "dose") + + +# --------------------------------------------------------------------------- +# M-040..M-042: RegressionDiscontinuity.fit renames; M-094: results field +# treatment_col -> takeup; M-088/M-089: RDPlot.fit renames +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def rd_data(): + rng = np.random.default_rng(3) + n = 500 + x = rng.uniform(-1, 1, n) + y = 0.5 + 0.8 * (x >= 0) + 1.2 * x + rng.normal(0, 0.3, n) + tk = ((x >= 0) & (rng.uniform(size=n) > 0.2)).astype(float) + return pd.DataFrame({"x": x, "y": y, "takeup": tk}) + + +class TestRDDRenames: + """M-040..M-042 + M-094.""" + + def test_canonical_and_positional_silent(self, rd_data): + from diff_diff import RegressionDiscontinuity + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_pos = RegressionDiscontinuity().fit(rd_data, "y", "x") + r_fz = RegressionDiscontinuity().fit(rd_data, "y", "x", takeup="takeup") + _assert_no_future_warning(record) + assert r_pos.att != r_fz.att # sharp vs fuzzy are different fits + + def test_old_names_warn_and_route_identically(self, rd_data): + from diff_diff import RegressionDiscontinuity + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = RegressionDiscontinuity().fit(rd_data, "y", "x", takeup="takeup") + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_old = RegressionDiscontinuity().fit( + rd_data, outcome_col="y", running_col="x", treatment_col="takeup" + ) + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert len(fw) == 3 + messages = "\n".join(str(w.message) for w in fw) + assert "use takeup= instead" in messages + assert r_old.att == r_new.att + assert r_old.se == r_new.se + + def test_both_supplied_raises(self, rd_data): + from diff_diff import RegressionDiscontinuity + + with pytest.raises(ValueError, match=r"pass only takeup="): + RegressionDiscontinuity().fit( + rd_data, "y", "x", takeup="takeup", treatment_col="takeup" + ) + + def test_missing_raises_typeerror(self, rd_data): + from diff_diff import RegressionDiscontinuity + + with pytest.raises(TypeError, match=r"missing required argument: 'running'"): + RegressionDiscontinuity().fit(rd_data, "y") + + def test_takeup_field_alias_and_dual_keys(self, rd_data): + from diff_diff import RegressionDiscontinuity + from diff_diff.rdd import RegressionDiscontinuityResults + + assert "takeup" in RegressionDiscontinuityResults.__dataclass_fields__ + assert "treatment_col" not in RegressionDiscontinuityResults.__dataclass_fields__ + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = RegressionDiscontinuity().fit(rd_data, "y", "x", takeup="takeup") + assert res.takeup == "takeup" + with pytest.warns( + FutureWarning, + match=r"RegressionDiscontinuityResults\.treatment_col is deprecated", + ): + alias = res.treatment_col + assert alias == "takeup" + with pytest.raises(AttributeError): + res.treatment_col = "x" + d = res.to_dict() + assert d["takeup"] == "takeup" + assert d["treatment_col"] == "takeup" + + def test_pickle_migration(self, rd_data): + from diff_diff import RegressionDiscontinuity + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = RegressionDiscontinuity().fit(rd_data, "y", "x", takeup="takeup") + clone = pickle.loads(pickle.dumps(res)) + assert clone.takeup == res.takeup + state = dict(res.__dict__) + state["treatment_col"] = state.pop("takeup") + old_style = object.__new__(type(res)) + old_style.__setstate__(state) + assert old_style.takeup == "takeup" + + +class TestRDPlotRenames: + """M-088/M-089.""" + + def test_rename_pair(self, rd_data): + from diff_diff import RDPlot + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + p_new = RDPlot().fit(rd_data, "y", "x") + _assert_no_future_warning(record) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + p_old = RDPlot().fit(rd_data, outcome_col="y", running_col="x") + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert len(fw) == 2 + assert "RDPlot.fit(outcome_col=) is deprecated" in str(fw[0].message) + np.testing.assert_array_equal(p_new.coef, p_old.coef) + pd.testing.assert_frame_equal(p_new.vars_bins, p_old.vars_bins) + with pytest.raises(ValueError, match=r"pass only running="): + RDPlot().fit(rd_data, "y", "x", running_col="x") + with pytest.raises(TypeError, match=r"missing required argument: 'outcome'"): + RDPlot().fit(rd_data) + + +# --------------------------------------------------------------------------- +# M-098..M-112: the three HAD pretest entry points; M-113: trim_weights +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def had_multi_panel(): + rng = np.random.default_rng(4) + rows = [] + for u in range(80): + d = max(0.0, rng.normal(1.0, 0.5)) + for t in (0, 1, 2, 3): + dose = d if t >= 2 else 0.0 + yv = 0.4 * t + 0.9 * dose + rng.normal(0, 0.3) + rows.append((u, t, dose, yv)) + return pd.DataFrame(rows, columns=["u", "t", "dose", "y"]) + + +class TestPretestFunctionRenames: + """M-098..M-112 (three functions, five params each).""" + + def test_joint_pretrends_rename(self, had_multi_panel): + from diff_diff import joint_pretrends_test + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + j_new = joint_pretrends_test( + had_multi_panel, + "y", + "dose", + "t", + "u", + pre_periods=[0], + base_period=1, + n_bootstrap=99, + seed=7, + ) + _assert_no_future_warning(record) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + j_old = joint_pretrends_test( + had_multi_panel, + outcome_col="y", + dose_col="dose", + time_col="t", + unit_col="u", + pre_periods=[0], + base_period=1, + n_bootstrap=99, + seed=7, + ) + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert len(fw) == 4 + assert "joint_pretrends_test(outcome_col=) is deprecated" in str(fw[0].message) + assert j_new.cvm_stat_joint == j_old.cvm_stat_joint + assert j_new.p_value == j_old.p_value + with pytest.raises(TypeError, match=r"missing required argument: 'pre_periods'"): + joint_pretrends_test(had_multi_panel, "y", "dose", "t", "u", base_period=1) + + def test_joint_homogeneity_rename(self, had_multi_panel): + from diff_diff import joint_homogeneity_test + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + j_new = joint_homogeneity_test( + had_multi_panel, + "y", + "dose", + "t", + "u", + post_periods=[2, 3], + base_period=1, + n_bootstrap=99, + seed=7, + ) + _assert_no_future_warning(record) + with pytest.warns( + FutureWarning, + match=r"joint_homogeneity_test\(dose_col=\) is deprecated", + ): + j_old = joint_homogeneity_test( + had_multi_panel, + "y", + dose_col="dose", + time="t", + unit="u", + post_periods=[2, 3], + base_period=1, + n_bootstrap=99, + seed=7, + ) + assert j_new.cvm_stat_joint == j_old.cvm_stat_joint + with pytest.raises(TypeError, match=r"missing required argument: 'post_periods'"): + joint_homogeneity_test(had_multi_panel, "y", "dose", "t", "u", base_period=1) + + def test_workflow_rename(self, had_multi_panel): + from diff_diff import did_had_pretest_workflow + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_new = did_had_pretest_workflow( + had_multi_panel, + "y", + "dose", + "t", + "u", + n_bootstrap=99, + seed=7, + aggregate="event_study", + ) + _assert_no_future_warning(record) + with pytest.warns( + FutureWarning, + match=r"did_had_pretest_workflow\(unit_col=\) is deprecated", + ): + r_old = did_had_pretest_workflow( + had_multi_panel, + "y", + "dose", + "t", + unit_col="u", + n_bootstrap=99, + seed=7, + aggregate="event_study", + ) + assert r_new.homogeneity_joint.cvm_stat_joint == r_old.homogeneity_joint.cvm_stat_joint + with pytest.raises(ValueError, match=r"pass only time="): + did_had_pretest_workflow(had_multi_panel, "y", "dose", "t", "u", time_col="t") + + +class TestFirstTreatOptionalAliasPerSurface: + """M-039 / M-102 / M-107 / M-112: the OPTIONAL first_treat_col alias, + pinned per surface (warn + routing parity + both-supplied rejection).""" + + @staticmethod + def _with_ft(panel): + ft = panel.groupby("u")["dose"].transform(lambda s: 2 if (s > 0).any() else 0) + return panel.assign(ft=ft) + + def test_had_fit_first_treat_col(self, had_multi_panel): + from diff_diff import HeterogeneousAdoptionDiD + + df = self._with_ft(had_multi_panel) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = HeterogeneousAdoptionDiD().fit( + df, "y", "dose", "t", "u", first_treat="ft", aggregate="event_study" + ) + with pytest.warns( + FutureWarning, + match=r"HeterogeneousAdoptionDiD\.fit\(first_treat_col=\) is deprecated", + ): + r_old = HeterogeneousAdoptionDiD().fit( + df, + "y", + "dose", + "t", + "u", + first_treat_col="ft", + aggregate="event_study", + ) + np.testing.assert_array_equal(r_old.att, r_new.att) + with pytest.raises(ValueError, match=r"pass only first_treat="): + HeterogeneousAdoptionDiD().fit( + df, + "y", + "dose", + "t", + "u", + first_treat="ft", + first_treat_col="ft", + aggregate="event_study", + ) + + @pytest.mark.parametrize( + "func_name,extra", + [ + ("joint_pretrends_test", {"pre_periods": [0], "base_period": 1}), + ("joint_homogeneity_test", {"post_periods": [2, 3], "base_period": 1}), + ("did_had_pretest_workflow", {"aggregate": "event_study"}), + ], + ) + def test_pretest_functions_first_treat_col(self, had_multi_panel, func_name, extra): + import diff_diff + + func = getattr(diff_diff, func_name) + df = self._with_ft(had_multi_panel) + kw = dict(n_bootstrap=99, seed=7, **extra) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = func(df, "y", "dose", "t", "u", first_treat="ft", **kw) + with pytest.warns( + FutureWarning, + match=rf"{func_name}\(first_treat_col=\) is deprecated", + ): + r_old = func(df, "y", "dose", "t", "u", first_treat_col="ft", **kw) + new_p = r_new.p_value if hasattr(r_new, "p_value") else r_new.homogeneity_joint.p_value + old_p = r_old.p_value if hasattr(r_old, "p_value") else r_old.homogeneity_joint.p_value + assert new_p == old_p + with pytest.raises(ValueError, match=r"pass only first_treat="): + func(df, "y", "dose", "t", "u", first_treat="ft", first_treat_col="ft", **kw) + + +class TestTrimWeightsRename: + """M-113.""" + + def test_rename_pair(self): + from diff_diff import trim_weights + + df = pd.DataFrame({"w": [1.0, 2.0, 50.0], "y": [1, 2, 3]}) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + out_pos = trim_weights(df, "w", upper=10.0) + out_kw = trim_weights(df, weights="w", upper=10.0) + _assert_no_future_warning(record) + with pytest.warns(FutureWarning, match=r"trim_weights\(weight_col=\) is deprecated"): + out_old = trim_weights(df, weight_col="w", upper=10.0) + assert (out_pos["w"] == out_old["w"]).all() + assert (out_kw["w"] == out_old["w"]).all() + assert out_old["w"].max() == 10.0 + with pytest.raises(ValueError, match=r"pass only weights="): + trim_weights(df, "w", weight_col="w") + with pytest.raises(TypeError, match=r"missing required argument: 'weights'"): + trim_weights(df, upper=10.0) diff --git a/tests/test_variance_conventions.py b/tests/test_variance_conventions.py index bab7c5de8..2a8f5b7b5 100644 --- a/tests/test_variance_conventions.py +++ b/tests/test_variance_conventions.py @@ -239,7 +239,7 @@ def snapshot(self): dict( key="wooldridge_hc1_within", fit=lambda df: diff_diff.WooldridgeDiD(method="ols").fit( - df, outcome="y", unit="unit", time="time", cohort="first_treat" + df, outcome="y", unit="unit", time="time", first_treat="first_treat" ), cr1_k=(15,), tail_df=(286.0,) * 10, diff --git a/tests/test_wooldridge.py b/tests/test_wooldridge.py index 158bfd93c..59a79bb25 100644 --- a/tests/test_wooldridge.py +++ b/tests/test_wooldridge.py @@ -291,25 +291,29 @@ def mpdta(self): def test_fit_returns_results(self, mpdta): est = WooldridgeDiD() results = est.fit( - mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) assert isinstance(results, WooldridgeDiDResults) def test_fit_sets_is_fitted(self, mpdta): est = WooldridgeDiD() - est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat") assert est.is_fitted_ def test_overall_att_finite(self, mpdta): est = WooldridgeDiD() - r = est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) assert r.overall_se > 0 def test_group_time_effects_populated(self, mpdta): est = WooldridgeDiD() - r = est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert len(r.group_time_effects) > 0 for (g, t), eff in r.group_time_effects.items(): assert t >= g @@ -318,19 +322,25 @@ def test_group_time_effects_populated(self, mpdta): def test_all_inference_fields_finite(self, mpdta): """No inference field should be NaN in normal data.""" est = WooldridgeDiD() - r = est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert np.isfinite(r.overall_t_stat) assert np.isfinite(r.overall_p_value) assert all(np.isfinite(c) for c in r.overall_conf_int) def test_never_treated_control_group(self, mpdta): est = WooldridgeDiD(control_group="never_treated") - r = est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert len(r.group_time_effects) > 0 def test_metadata_correct(self, mpdta): est = WooldridgeDiD() - r = est.fit(mpdta, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + mpdta, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert r.method == "ols" assert r.n_obs > 0 assert r.n_treated_units > 0 @@ -344,7 +354,9 @@ def fitted(self): df = load_mpdta() est = WooldridgeDiD() - return est.fit(df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + return est.fit( + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) def test_simple_matches_manual_weighted_average(self, fitted): """simple ATT must equal manually computed weighted average of ATT(g,t).""" @@ -404,23 +416,23 @@ def binary_panel(self): def test_logit_fit_runs(self, binary_panel): est = WooldridgeDiD(method="logit") - r = est.fit(binary_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(binary_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert isinstance(r, WooldridgeDiDResults) def test_logit_att_sign(self, binary_panel): """ATT should be positive (treatment increases binary outcome).""" est = WooldridgeDiD(method="logit") - r = est.fit(binary_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(binary_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert r.overall_att > 0 def test_logit_se_positive(self, binary_panel): est = WooldridgeDiD(method="logit") - r = est.fit(binary_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(binary_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert r.overall_se > 0 def test_logit_method_stored(self, binary_panel): est = WooldridgeDiD(method="logit") - r = est.fit(binary_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(binary_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert r.method == "logit" @@ -441,17 +453,17 @@ def count_panel(self): def test_poisson_fit_runs(self, count_panel): est = WooldridgeDiD(method="poisson") - r = est.fit(count_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(count_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert isinstance(r, WooldridgeDiDResults) def test_poisson_att_sign(self, count_panel): est = WooldridgeDiD(method="poisson") - r = est.fit(count_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(count_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert r.overall_att > 0 def test_poisson_se_positive(self, count_panel): est = WooldridgeDiD(method="poisson") - r = est.fit(count_panel, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(count_panel, outcome="y", unit="unit", time="time", first_treat="cohort") assert r.overall_se > 0 @@ -464,7 +476,7 @@ def test_multiplier_bootstrap_ols(self, ci_params): df = load_mpdta() n_boot = ci_params.bootstrap(50, min_n=19) est = WooldridgeDiD(n_bootstrap=n_boot, seed=42) - r = est.fit(df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit(df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat") assert abs(r.overall_se - r.overall_att) / max(abs(r.overall_att), 1e-8) < 10 def test_bootstrap_zero_disables(self): @@ -472,7 +484,7 @@ def test_bootstrap_zero_disables(self): df = load_mpdta() est = WooldridgeDiD(n_bootstrap=0) - r = est.fit(df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit(df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat") assert np.isfinite(r.overall_se) @@ -489,7 +501,9 @@ def test_ols_att_sign_direction(self): est = WooldridgeDiD(control_group="never_treated") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - r = est.fit(df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" + ) assert np.isfinite(r.overall_att) rank = [w for w in caught if "Rank-deficient" in str(w.message)] assert rank == [], f"unexpected rank deficiency: {[str(w.message)[:100] for w in rank]}" @@ -501,7 +515,7 @@ def test_never_treated_produces_event_effects_with_placebo_leads(self): df = load_mpdta() est = WooldridgeDiD(control_group="never_treated") - r = est.fit(df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit(df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat") r.aggregate("event") assert r.event_study_effects is not None assert len(r.event_study_effects) > 0 @@ -525,7 +539,7 @@ def test_single_cohort_degenerates_to_simple_did(self): y = 1.0 * treated + rng.standard_normal() rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) - r = WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert len(r.group_time_effects) == 1 assert abs(r.overall_att - 1.0) < 0.5 @@ -535,7 +549,7 @@ def test_aggregation_weights_sum_to_one(self): df = load_mpdta() r = WooldridgeDiD().fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) w = r._gt_weights post_keys = [(g, t) for (g, t) in w if t >= g] @@ -552,7 +566,7 @@ def test_logit_delta_method_se_finite(self): est = WooldridgeDiD(method="logit") results = est.fit( - df, outcome="lemp_bin", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp_bin", unit="countyreal", time="year", first_treat="first_treat" ) assert len(results.group_time_effects) > 0 @@ -569,7 +583,7 @@ def test_poisson_delta_method_se_finite(self): est = WooldridgeDiD(method="poisson") results = est.fit( - df, outcome="emp_count", unit="countyreal", time="year", cohort="first_treat" + df, outcome="emp_count", unit="countyreal", time="year", first_treat="first_treat" ) assert len(results.group_time_effects) > 0 @@ -603,7 +617,7 @@ def test_anticipation_includes_pre_treatment_cells(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) est = WooldridgeDiD(anticipation=1) - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") # With anticipation=1, should have cells for t >= g-1 = 2 keys = list(r.group_time_effects.keys()) min_t = min(t for (g, t) in keys) @@ -622,7 +636,7 @@ def test_anticipation_aware_identification_rejects_pseudo_controls(self): df = pd.DataFrame(rows) with pytest.raises(ValueError, match="no untreated comparison"): WooldridgeDiD(anticipation=1, control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_anticipation_aggregate_semantics(self): @@ -638,7 +652,7 @@ def test_anticipation_aggregate_semantics(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) est = WooldridgeDiD(anticipation=1) - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") r.aggregate("event").aggregate("group").aggregate("simple") assert np.isfinite(r.overall_att) assert r.event_study_effects is not None @@ -671,7 +685,7 @@ def test_xgvar_fit_runs(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y, "x1": x1}) df = pd.DataFrame(rows) est = WooldridgeDiD() - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort", xgvar=["x1"]) + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort", xgvar=["x1"]) assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) assert r.overall_se > 0 @@ -715,7 +729,7 @@ def test_no_never_treated_not_yet_treated_control(self): df = pd.DataFrame(rows) est = WooldridgeDiD(control_group="not_yet_treated") with pytest.warns(UserWarning, match="no eligible comparison group"): - res = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + res = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") # Eq. 5.15 on a balanced panel: cells {(g, t) : g <= G_max - 1, # g <= t <= G_max - 1}. G_max = 8, so periods 8 and 9 are dropped and @@ -765,7 +779,7 @@ def test_all_treated_names_the_zero_cell_cohort(self): df = pd.DataFrame(rows) with pytest.warns(UserWarning, match=r"Cohort\(s\) 6 have NO estimated cells"): WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) @@ -781,7 +795,7 @@ def test_sparse_panel_no_crash(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) est = WooldridgeDiD() - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(r.overall_att) r.aggregate("event") assert r.event_study_effects is not None @@ -799,7 +813,9 @@ def test_logit_on_mpdta(self, mpdta): df = mpdta.copy() df["lemp_bin"] = (df["lemp"] > df["lemp"].median()).astype(int) est = WooldridgeDiD(method="logit") - r = est.fit(df, outcome="lemp_bin", unit="countyreal", time="year", cohort="first_treat") + r = est.fit( + df, outcome="lemp_bin", unit="countyreal", time="year", first_treat="first_treat" + ) assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) assert r.overall_se > 0 @@ -811,7 +827,7 @@ def test_poisson_on_mpdta(self, mpdta): df = mpdta.copy() df["emp"] = np.exp(df["lemp"]) est = WooldridgeDiD(method="poisson") - r = est.fit(df, outcome="emp", unit="countyreal", time="year", cohort="first_treat") + r = est.fit(df, outcome="emp", unit="countyreal", time="year", first_treat="first_treat") assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) assert r.overall_se > 0 @@ -829,10 +845,10 @@ def test_never_treated_differs_from_not_yet_treated(self): df = load_mpdta() r_nyt = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) r_nt = WooldridgeDiD(control_group="never_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) assert np.isfinite(r_nyt.overall_att) assert np.isfinite(r_nt.overall_att) @@ -849,10 +865,10 @@ def test_never_treated_more_interaction_terms(self): df = load_mpdta() r_nyt = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) r_nt = WooldridgeDiD(control_group="never_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) # Same sample (all obs kept), but more (g,t) cells for never_treated assert r_nt.n_obs == r_nyt.n_obs @@ -866,7 +882,7 @@ def test_no_treated_raises(self): {"unit": [1, 1, 2, 2], "time": [1, 2, 1, 2], "cohort": [0, 0, 0, 0], "y": [1, 2, 3, 4]} ) with pytest.raises(ValueError, match="No treated cohorts"): - WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") def test_never_treated_no_controls_raises(self): """never_treated with no cohort==0 units should raise ValueError.""" @@ -875,7 +891,7 @@ def test_never_treated_no_controls_raises(self): ) with pytest.raises(ValueError, match="no never-treated"): WooldridgeDiD(control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) @@ -895,11 +911,11 @@ def test_bootstrap_nonlinear_raises(self): df = pd.DataFrame(rows) with pytest.raises(ValueError, match="Bootstrap inference is only supported"): WooldridgeDiD(method="logit", n_bootstrap=50).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) with pytest.raises(ValueError, match="Bootstrap inference is only supported"): WooldridgeDiD(method="poisson", n_bootstrap=50).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) @@ -920,11 +936,11 @@ def test_bootstrap_with_coarser_cluster(self): # Bootstrap at unit level (default) r_unit = WooldridgeDiD(n_bootstrap=99, seed=0).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Bootstrap at region level (coarser) r_region = WooldridgeDiD(n_bootstrap=99, seed=0, cluster="region").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(r_unit.overall_se) assert np.isfinite(r_region.overall_se) @@ -950,7 +966,7 @@ def test_logit_rank_deficient_design(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) est = WooldridgeDiD(method="logit", rank_deficient_action="silent") - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert len(r.group_time_effects) > 0 for cell in r.group_time_effects.values(): assert np.isfinite(cell["se"]), "SE should be finite for estimable cells" @@ -970,7 +986,7 @@ def test_poisson_rank_deficient_design(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": float(y)}) df = pd.DataFrame(rows) est = WooldridgeDiD(method="poisson", rank_deficient_action="silent") - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert len(r.group_time_effects) > 0 for cell in r.group_time_effects.values(): assert np.isfinite(cell["se"]), "SE should be finite for estimable cells" @@ -991,10 +1007,10 @@ def test_logit_with_covariates(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y, "x1": x1}) df = pd.DataFrame(rows) r_cov = WooldridgeDiD(method="logit").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x1"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x1"] ) r_nocov = WooldridgeDiD(method="logit").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(r_cov.overall_att) assert np.isfinite(r_cov.overall_se) @@ -1016,10 +1032,10 @@ def test_poisson_with_covariates(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": float(y), "x1": x1}) df = pd.DataFrame(rows) r_cov = WooldridgeDiD(method="poisson").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x1"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x1"] ) r_nocov = WooldridgeDiD(method="poisson").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(r_cov.overall_att) assert np.isfinite(r_cov.overall_se) @@ -1039,7 +1055,7 @@ def test_varying_cohort_raises(self): } ) with pytest.raises(ValueError, match="not time-invariant"): - WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") class TestAnticipationEventLabels: @@ -1054,7 +1070,7 @@ def test_event_summary_labels_anticipation_cells(self): rows.append({"unit": u, "time": t, "cohort": cohort, "y": y}) df = pd.DataFrame(rows) est = WooldridgeDiD(anticipation=1) - r = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") r.aggregate("event") summary = r.summary("event") # k=-1 should be labeled [antic] (within anticipation window) @@ -1074,7 +1090,7 @@ def test_logit_rejects_out_of_range(self): ) with pytest.raises(ValueError, match="outcomes in \\[0, 1\\]"): WooldridgeDiD(method="logit").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_poisson_rejects_negative(self): @@ -1089,7 +1105,7 @@ def test_poisson_rejects_negative(self): ) with pytest.raises(ValueError, match="non-negative"): WooldridgeDiD(method="poisson").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) @@ -1120,7 +1136,7 @@ def test_parity_with_dummy_ols_not_yet_treated(self, unbalanced_data): df = unbalanced_data r = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Build explicit dummy regression on same sample @@ -1159,7 +1175,7 @@ def test_never_treated_unbalanced_finite(self, unbalanced_data): """ df = unbalanced_data r = WooldridgeDiD(control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) @@ -1202,7 +1218,7 @@ def count_data(self): def test_logit_never_treated_post_treatment_only(self, binary_data): r = WooldridgeDiD(method="logit", control_group="never_treated").fit( - binary_data, outcome="y", unit="unit", time="time", cohort="cohort" + binary_data, outcome="y", unit="unit", time="time", first_treat="cohort" ) # All cells should be post-treatment for g, t in r.group_time_effects: @@ -1214,7 +1230,7 @@ def test_logit_never_treated_post_treatment_only(self, binary_data): def test_poisson_never_treated_post_treatment_only(self, count_data): r = WooldridgeDiD(method="poisson", control_group="never_treated").fit( - count_data, outcome="y", unit="unit", time="time", cohort="cohort" + count_data, outcome="y", unit="unit", time="time", first_treat="cohort" ) for g, t in r.group_time_effects: assert t >= g, f"Pre-treatment cell ({g},{t}) in nonlinear never_treated" @@ -1249,7 +1265,7 @@ def test_ols_never_treated_still_has_pre_treatment(self): df = load_mpdta() r = WooldridgeDiD(control_group="never_treated").fit( - df, outcome="lemp", unit="countyreal", time="year", cohort="first_treat" + df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat" ) # OLS never_treated should have pre-treatment cells pre_treatment = [(g, t) for (g, t) in r.group_time_effects if t < g] @@ -1279,7 +1295,7 @@ def test_ols_covariate_parity_with_full_basis_dummy_ols(self, cov_data): df = cov_data r = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x1"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x1"] ) # Build explicit-dummy regression with full basis @@ -1343,9 +1359,11 @@ def test_covariates_affect_ols_att(self, cov_data): """OLS with covariates should produce different ATT than without.""" df = cov_data r_cov = WooldridgeDiD().fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x1"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x1"] + ) + r_nocov = WooldridgeDiD().fit( + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) - r_nocov = WooldridgeDiD().fit(df, outcome="y", unit="unit", time="time", cohort="cohort") assert ( r_cov.overall_att != r_nocov.overall_att ), "Covariate-adjusted ATT should differ from unadjusted" @@ -1397,7 +1415,7 @@ def test_ols_survey_runs(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1414,7 +1432,7 @@ def test_ols_survey_se_differs_from_naive(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) r_naive = WooldridgeDiD().fit( @@ -1422,7 +1440,7 @@ def test_ols_survey_se_differs_from_naive(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) assert r_survey.overall_se != r_naive.overall_se @@ -1436,7 +1454,7 @@ def test_logit_survey_runs(self, survey_panel): outcome="y_bin", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1453,7 +1471,7 @@ def test_poisson_survey_runs(self, survey_panel): outcome="y_count", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1473,7 +1491,7 @@ def test_bootstrap_survey_rejected(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1487,7 +1505,7 @@ def test_weights_only_survey(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1504,7 +1522,7 @@ def test_survey_metadata_present(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) sm = r.survey_metadata @@ -1534,7 +1552,7 @@ def test_replicate_weights_rejected(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1548,7 +1566,7 @@ def test_weights_only_plus_cluster(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) # Cluster should have been injected as PSU @@ -1562,7 +1580,7 @@ def test_weights_only_plus_cluster(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert r.overall_se != r_no_cluster.overall_se @@ -1577,7 +1595,7 @@ def test_survey_gt_weights_are_counts(self, survey_panel): outcome="y_bin", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) for k, w in r._gt_weights.items(): @@ -1596,7 +1614,7 @@ def test_weights_only_no_cluster_implicit_psu(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) # n_psu should equal n_obs in the filtered sample (not n_units) @@ -1626,7 +1644,7 @@ def test_fweight_rejected(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1648,7 +1666,7 @@ def test_poisson_zero_weight_cell(self, survey_panel): outcome="y_count", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1681,7 +1699,7 @@ def test_ols_survey_all_treated_refuses_period_filtering(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1700,7 +1718,7 @@ def test_survey_refusal_is_conditional_on_rows_actually_dropping(self, survey_pa outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(res.overall_att) @@ -1719,7 +1737,7 @@ def test_ols_survey_zero_weight_unit_rejected(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1741,7 +1759,7 @@ def test_logit_survey_zero_weight_cell(self, survey_panel): outcome="y_bin", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) assert np.isfinite(r.overall_att) @@ -1761,7 +1779,7 @@ def test_ols_survey_non_range_index(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -1775,7 +1793,7 @@ def test_survey_aggregate_and_summary(self, survey_panel): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) # aggregate() should use t-distribution with survey df @@ -1815,7 +1833,7 @@ def test_fit_warns_on_nan_cohort_with_count(self): est = WooldridgeDiD(method="ols") with pytest.warns(UserWarning, match=r"8 row\(s\) have NaN cohort values"): try: - est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") except Exception: pass @@ -1828,7 +1846,7 @@ def test_fit_silent_on_clean_cohort(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") try: - est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") except Exception: pass nan_warnings = [x for x in w if "NaN cohort values" in str(x.message)] @@ -1896,7 +1914,7 @@ def test_hc1_se_bit_equal_to_pre_pr_baseline(self): """ df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc1").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.overall_att == pytest.approx(0.9178849934516233, abs=1e-14) assert res.overall_se == pytest.approx(0.031906603167527435, abs=1e-14) @@ -1904,10 +1922,10 @@ def test_hc1_se_bit_equal_to_pre_pr_baseline(self): def test_hc2_bm_finite_and_inflates_over_hc1(self): df = _make_vcov_panel() res_hc1 = WooldridgeDiD(method="ols", vcov_type="hc1").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) res_bm = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) for k, eff in res_bm.group_time_effects.items(): assert np.isfinite(eff["se"]) @@ -1923,7 +1941,7 @@ def test_atts_identical_across_vcov_branches(self): results = {} for vt in ("hc1", "hc2_bm", "hc2", "classical"): results[vt] = WooldridgeDiD(method="ols", vcov_type=vt).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) ref = results["hc1"] for vt in ("hc2_bm", "hc2", "classical"): @@ -1937,12 +1955,12 @@ def test_classical_with_explicit_user_cluster_rejected_by_linalg(self): df = _make_vcov_panel() est = WooldridgeDiD(method="ols", vcov_type="classical", cluster="unit") with pytest.raises(ValueError): - est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") def test_classical_drops_auto_cluster(self): df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="classical").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_se) assert res.cluster_name is None @@ -1951,7 +1969,7 @@ def test_classical_drops_auto_cluster(self): def test_hc2_drops_auto_cluster(self): df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_se) assert res.cluster_name is None @@ -1998,7 +2016,7 @@ def test_survey_design_plus_hc2_bm_rejected(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=design, ) @@ -2015,7 +2033,7 @@ def test_survey_design_plus_classical_rejected(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=design, ) @@ -2030,14 +2048,14 @@ def test_bootstrap_plus_one_way_rejected_regardless_of_cluster(self): # Case 1: cluster=None (default) — bootstrap reject fires est = WooldridgeDiD(method="ols", vcov_type="classical", n_bootstrap=10, seed=0) with pytest.raises(ValueError, match=r"multiplier bootstrap"): - est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") # Case 2: cluster=X — also rejected at the estimator boundary (would # otherwise hit the linalg validator with a less-informative message) est_cl = WooldridgeDiD( method="ols", vcov_type="hc2", n_bootstrap=10, cluster="unit", seed=0 ) with pytest.raises(ValueError, match=r"multiplier bootstrap"): - est_cl.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + est_cl.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") def test_hc2_bm_plus_bootstrap_finite_inference(self): """Positive regression: ``vcov_type='hc2_bm'`` + ``n_bootstrap > 0`` @@ -2050,11 +2068,11 @@ def test_hc2_bm_plus_bootstrap_finite_inference(self): df = _make_vcov_panel() # Analytical hc2_bm fit for ATT reference. res_analytical = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Bootstrap fit on the same data + seed. res_boot = WooldridgeDiD(method="ols", vcov_type="hc2_bm", n_bootstrap=50, seed=0).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # ATT is unchanged by the bootstrap (only SE is overridden) assert res_boot.overall_att == pytest.approx(res_analytical.overall_att, abs=1e-10) @@ -2123,7 +2141,7 @@ def test_hc2_bm_plus_bootstrap_rank_deficient(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", exovar=["xc", "xc_dup"], ) assert np.isfinite(res.overall_att) @@ -2197,7 +2215,7 @@ def test_survey_design_clears_cluster_metadata(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=design, ) assert res.survey_metadata is not None @@ -2215,14 +2233,14 @@ def test_set_params_updates_vcov_type_explicit_flag(self): def test_results_carries_vcov_type(self): df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.vcov_type == "hc2_bm" def test_results_carries_cluster_name_for_clustered_fit(self): df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc1").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.cluster_name == "unit" assert res.n_clusters is not None @@ -2234,7 +2252,7 @@ def test_explicit_user_cluster_preserved_under_hc1(self): # 40-unit panel df["state"] = (df["unit"] // 10).astype(int) res = WooldridgeDiD(method="ols", vcov_type="hc1", cluster="state").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.cluster_name == "state" assert res.n_clusters == 4 @@ -2243,9 +2261,9 @@ def test_fit_clone_idempotent_on_vcov_type(self): """fit, clone via get_params, refit — SE must be bit-equal.""" df = _make_vcov_panel() est = WooldridgeDiD(method="ols", vcov_type="hc2_bm") - res1 = est.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + res1 = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") est2 = WooldridgeDiD(**est.get_params()) - res2 = est2.fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + res2 = est2.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert res1.overall_se == pytest.approx(res2.overall_se, abs=1e-14) assert res1.overall_att == pytest.approx(res2.overall_att, abs=1e-14) @@ -2263,7 +2281,7 @@ def _fake_dof(X, cluster_ids, bread, contrasts): monkeypatch.setattr(linalg_mod, "_compute_cr2_bm_contrast_dof", _fake_dof) res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Overall: att + se preserved (sandwich is finite); inference NaN assert np.isfinite(res.overall_att) @@ -2287,7 +2305,7 @@ def test_aggregate_group_under_hc2_bm_uses_bm_contrast_dof(self): induced fail-closed.""" df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("group") assert res.group_effects is not None @@ -2304,7 +2322,7 @@ def test_aggregate_event_under_hc2_bm_uses_bm_contrast_dof(self): Bell-McCaffrey contrast DOFs.""" df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("event") assert res.event_study_effects is not None @@ -2323,7 +2341,7 @@ def test_aggregate_calendar_under_hc2_bm_uses_bm_contrast_dof(self): Bell-McCaffrey contrast DOFs.""" df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) res.aggregate("calendar") assert res.calendar_effects is not None @@ -2376,7 +2394,7 @@ def test_hc2_bm_handles_rank_deficient_reduced_design(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", exovar=["xc", "xc_dup"], ) # Per-cell inference: all identified cells finite (att + se + p + @@ -2435,7 +2453,7 @@ def test_hc2_bm_handles_rank_deficient_with_unit_invariant_exovar(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", exovar=["x_unit"], ) # Per-cell + overall inference finite on identified cells @@ -2468,7 +2486,7 @@ def test_aggregate_under_hc2_bm_fail_closed_on_dof_helper_error(self, monkeypatc att + se preserved.""" df = _make_vcov_panel() res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Patch the helper AFTER fit so that aggregate() retry fails. import diff_diff.linalg as linalg_mod @@ -2644,7 +2662,7 @@ def test_unit_constant_exovar_snaps_with_cause_warning(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", exovar=["xc"], ) assert np.isfinite(res.overall_att) @@ -2698,7 +2716,7 @@ def test_gapped_cohort_keeps_every_post_cell(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) post = {k for k in res.group_time_effects if k[1] >= k[0]} assert post == {(4, 4), (4, 5)} @@ -2710,7 +2728,7 @@ def test_unobserved_pairs_are_skipped_and_reported(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) skipped = [w for w in caught if "no observations" in str(w.message)] assert len(skipped) == 1 @@ -2724,7 +2742,7 @@ def test_skip_warning_respects_rank_deficient_action_silent(self): warnings.simplefilter("always") WooldridgeDiD( method="ols", control_group="never_treated", rank_deficient_action="silent" - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert [w for w in caught if "no observations" in str(w.message)] == [] def test_cohort_without_a_reference_is_excluded_not_silently_rebaselined(self): @@ -2737,7 +2755,7 @@ def test_cohort_without_a_reference_is_excluded_not_silently_rebaselined(self): was removed by hand -- asserting only the warning would not catch this. """ df = self._panel([0, 3, 5], [1, 2, 3, 4, 5, 6]) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") auto = WooldridgeDiD(method="ols", control_group="never_treated", anticipation=2).fit( @@ -2775,7 +2793,7 @@ def test_excluding_every_comparison_fails_closed(self): warnings.simplefilter("ignore") with pytest.raises(ValueError, match="no comparison observations remain"): WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_no_post_treatment_cell_fails_closed(self): @@ -2802,7 +2820,7 @@ def test_no_post_treatment_cell_fails_closed(self): warnings.simplefilter("ignore") with pytest.raises(ValueError, match="No estimable post-treatment cells"): WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_not_yet_treated_is_also_fixed(self): @@ -2813,7 +2831,7 @@ def test_not_yet_treated_is_also_fixed(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") res = WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert [w for w in caught if "Rank-deficient" in str(w.message)] == [] assert {k for k in res.group_time_effects if k[1] >= k[0]} @@ -2826,7 +2844,7 @@ def test_covariate_path_keeps_every_cell(self, control_group): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") res = WooldridgeDiD(method="ols", control_group=control_group).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x1"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x1"] ) assert [w for w in caught if "Rank-deficient" in str(w.message)] == [] assert {k for k in res.group_time_effects if k[1] >= k[0]} @@ -2841,7 +2859,7 @@ def test_nonlinear_methods_unaffected(self, method): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(method=method, control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert {k for k in res.group_time_effects if k[1] >= k[0]} @@ -2885,7 +2903,7 @@ def test_zero_weight_reference_cell_does_not_lose_a_post_cell(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) post = {k for k in res.group_time_effects if k[1] >= k[0]} @@ -2916,7 +2934,7 @@ def test_excluded_cohort_leaves_no_stale_derived_state(self): ) uid += 1 df = pd.DataFrame(rows) - kw = dict(outcome="y", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="y", unit="unit", time="time", first_treat="cohort") with warnings.catch_warnings(): warnings.simplefilter("ignore") @@ -2986,7 +3004,7 @@ def test_all_post_columns_rank_dropped_fails_closed(self): warnings.simplefilter("ignore") with pytest.raises(ValueError, match="No estimable post-treatment cells found"): WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_two_cohorts_without_same_period_controls_fail_closed(self): @@ -3002,7 +3020,7 @@ def test_two_cohorts_without_same_period_controls_fail_closed(self): warnings.simplefilter("ignore") with pytest.raises(ValueError, match="not identified and were removed"): WooldridgeDiD(method="ols", control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_only_anticipation_window_cells_fails_closed(self): @@ -3027,7 +3045,7 @@ def test_only_anticipation_window_cells_fails_closed(self): warnings.simplefilter("ignore") with pytest.raises(ValueError, match="No cohort-time cell contributes"): WooldridgeDiD(method="ols", control_group="never_treated", anticipation=2).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_valid_fit_is_unaffected(self): @@ -3036,7 +3054,7 @@ def test_valid_fit_is_unaffected(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_att) assert np.isfinite(res.overall_se) @@ -3106,7 +3124,7 @@ def test_invalid_weights_raise_instead_of_excluding_the_cohort( outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) @@ -3125,7 +3143,7 @@ def test_rejection_precedes_any_exclusion_warning(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) assert [w for w in caught if "no pre-treatment period" in str(w.message)] == [] @@ -3142,7 +3160,7 @@ def test_valid_weights_still_fit(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) assert np.isfinite(res.overall_att) @@ -3187,7 +3205,7 @@ def test_zero_weights_still_shape_support_without_excluding(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w"), ) assert np.isfinite(res.overall_att) @@ -3241,7 +3259,7 @@ def test_survey_plus_unidentified_cohort_is_refused(self, method): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -3305,7 +3323,7 @@ def test_invalid_survey_structure_cannot_hide_in_an_excluded_cohort( outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sd, ) @@ -3350,7 +3368,7 @@ def test_fractional_fweight_is_rejected_before_exclusion(self, control_group): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=SurveyDesign(weights="w", weight_type="fweight"), ) assert [w for w in caught if "no pre-treatment period" in str(w.message)] == [] @@ -3402,7 +3420,7 @@ def test_zero_weight_units_cannot_be_deleted_by_cohort_exclusion(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=sub_design, ) assert [w for w in caught if "no pre-treatment period" in str(w.message)] == [] @@ -3438,14 +3456,14 @@ def test_error_raises_naming_the_skipped_pair(self): with pytest.raises(ValueError, match=r"Skipped 1 cohort-time cell\(s\).*\(4, 3\)"): WooldridgeDiD( method="ols", control_group="never_treated", rank_deficient_action="error" - ).fit(self._gapped(), outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(self._gapped(), outcome="y", unit="unit", time="time", first_treat="cohort") def test_warn_still_fits_and_warns(self): with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") res = WooldridgeDiD( method="ols", control_group="never_treated", rank_deficient_action="warn" - ).fit(self._gapped(), outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(self._gapped(), outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(res.overall_att) assert [w for w in caught if "Skipped 1 cohort-time" in str(w.message)] != [] @@ -3454,7 +3472,7 @@ def test_silent_fits_without_warning(self): warnings.simplefilter("always") res = WooldridgeDiD( method="ols", control_group="never_treated", rank_deficient_action="silent" - ).fit(self._gapped(), outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(self._gapped(), outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(res.overall_att) assert [w for w in caught if "Skipped" in str(w.message)] == [] @@ -3488,7 +3506,7 @@ def test_error_is_honored_on_the_nonlinear_paths_too(self, method): with pytest.raises(ValueError, match=r"Skipped 1 cohort-time cell\(s\).*\(4, 5\)"): WooldridgeDiD( method=method, control_group="never_treated", rank_deficient_action="error" - ).fit(df, outcome="y", unit="unit", time="time", cohort="cohort") + ).fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") class TestWithinCohortSupportConnectivity: @@ -3534,7 +3552,7 @@ def test_disconnected_component_fails_closed(self): returned overall_att=0.0589 from the single surviving post cell.""" with pytest.raises(ValueError, match="observed but NOT identified"): WooldridgeDiD(method="ols", control_group="never_treated").fit( - self._split_support(), outcome="y", unit="unit", time="time", cohort="cohort" + self._split_support(), outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_the_builder_names_the_unidentified_cells(self): @@ -3577,7 +3595,7 @@ def test_connected_unbalanced_panel_is_not_flagged(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_att) assert (4, 5) in res.group_time_effects @@ -3613,7 +3631,7 @@ def test_nonlinear_paths_are_not_subject_to_the_unit_fe_condition(self, method): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(method=method, control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert {k for k in res.group_time_effects if k[1] >= k[0]} == {(4, 4), (4, 5)} assert np.isfinite(res.overall_att) @@ -3639,7 +3657,7 @@ def test_ols_still_refuses_the_same_panel(self): uid += 1 with pytest.raises(ValueError, match="observed but NOT identified"): WooldridgeDiD(method="ols", control_group="never_treated").fit( - pd.DataFrame(rows), outcome="y", unit="unit", time="time", cohort="cohort" + pd.DataFrame(rows), outcome="y", unit="unit", time="time", first_treat="cohort" ) @@ -3702,7 +3720,7 @@ def test_partial_cell_loss_is_now_filtered_for_every_rank_deficient_action(self, outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) assert sorted((int(g), int(t)) for g, t in res.group_time_effects) == [ (3, 1), @@ -3731,7 +3749,7 @@ def test_the_drop_warning_names_the_period_and_row_count(self): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", ) msgs = [str(w.message) for w in rec] drop = [m for m in msgs if "no eligible comparison group" in m] @@ -3750,7 +3768,7 @@ def test_the_same_panel_fits_once_the_bad_period_is_removed(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_att) # True ATT(3,3)=ATT(3,4)=1.0, ATT(4,4)=3.0 -- recovered, not contrasts. @@ -3852,7 +3870,7 @@ def test_eq_5_15_cell_set_on_a_balanced_panel(self, anticipation, expected_kept) with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(control_group="not_yet_treated", anticipation=anticipation).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) kept = sorted({int(t) for (_g, t) in res.group_time_effects}) @@ -3896,7 +3914,7 @@ def test_no_op_when_every_period_has_a_comparison(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert not [m for m in w if "no eligible comparison group" in str(m.message)] assert res.n_obs == len(df) @@ -3933,7 +3951,7 @@ def test_branch_one_reference_move_warns_by_name(self): df = pd.DataFrame(rows) 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", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.overall_att == pytest.approx(1.0171, abs=5e-3) @@ -3971,7 +3989,7 @@ def test_branch_one_no_move_when_never_treated_covers_the_period(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = WooldridgeDiD(method="ols", control_group="never_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert not [m for m in w if "reference period moved" in str(m.message)] assert res.overall_att == pytest.approx(0.9951, abs=5e-3) @@ -3987,7 +4005,7 @@ def test_branch_two_references_are_invariant(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") WooldridgeDiD(control_group="not_yet_treated").fit( - self._all_treated(), outcome="y", unit="unit", time="time", cohort="cohort" + self._all_treated(), outcome="y", unit="unit", time="time", first_treat="cohort" ) assert not [m for m in w if "reference period moved" in str(m.message)] @@ -4024,7 +4042,7 @@ def test_nonlinear_all_treated_paths_filter_identically(self, kind): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = WooldridgeDiD(method=kind, control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # Same cell set as the OLS path: cohorts 3 and 5 over t <= 7, none for 8. assert sorted((int(g), int(t)) for g, t in res.group_time_effects) == [ @@ -4063,7 +4081,7 @@ def test_nonlinear_survey_refusal_also_fires(self, kind): outcome="y", unit="unit", time="time", - cohort="cohort", + first_treat="cohort", survey_design=design, ) @@ -4087,7 +4105,7 @@ def test_warning_a_survives_a_downstream_raise(self): with pytest.warns(UserWarning, match="no eligible comparison group"): with pytest.raises(ValueError): WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) def test_zero_cell_cohort_is_reported_once_not_twice(self): @@ -4102,7 +4120,7 @@ def test_zero_cell_cohort_is_reported_once_not_twice(self): warnings.simplefilter("always") try: WooldridgeDiD(control_group="not_yet_treated", anticipation=2).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) except ValueError: pass @@ -4144,7 +4162,7 @@ def test_zero_cell_warning_does_not_misattribute_the_cause(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") res = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert 7 not in res.groups zero = [m for m in w if "have NO estimated cells" in str(m.message)] @@ -4158,7 +4176,7 @@ def test_zero_cell_warning_does_not_misattribute_the_cause(self): with warnings.catch_warnings(record=True) as w2: warnings.simplefilter("always") WooldridgeDiD(control_group="not_yet_treated").fit( - self._all_treated(), outcome="y", unit="unit", time="time", cohort="cohort" + self._all_treated(), outcome="y", unit="unit", time="time", first_treat="cohort" ) sec54 = [m for m in w2 if "have NO estimated cells" in str(m.message)] assert len(sec54) == 1 @@ -4213,7 +4231,7 @@ def add(u, c, ts, eff): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) # 90 of cohort 2's 100 units lived only in the dropped period. assert res._cohort_units_dropped == {2: 90} @@ -4239,7 +4257,7 @@ def test_every_aggregation_surface_reads_the_filtered_cell_set(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(control_group="not_yet_treated").fit( - self._all_treated(), outcome="y", unit="unit", time="time", cohort="cohort" + self._all_treated(), outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res.time_periods == [1, 2, 3, 4, 5, 6, 7], "dropped periods leaked into metadata" @@ -4271,7 +4289,7 @@ def test_cohort_share_still_works_when_no_unit_is_lost(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(control_group="not_yet_treated").fit( - self._all_treated(), outcome="y", unit="unit", time="time", cohort="cohort" + self._all_treated(), outcome="y", unit="unit", time="time", first_treat="cohort" ) assert res._cohort_units_dropped == {} agg = res.aggregate(type="simple", weights="cohort_share") @@ -4298,12 +4316,12 @@ def test_cells_derived_groups_did_not_leak_into_the_design(self): with pytest.raises(ValueError, match="rank-deficient"): WooldridgeDiD(control_group="not_yet_treated", rank_deficient_action="error").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", exovar=["x"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", exovar=["x"] ) # xtvar is full rank under the default demeaning and must stay that way. res = WooldridgeDiD(control_group="not_yet_treated").fit( - df, outcome="y", unit="unit", time="time", cohort="cohort", xtvar=["x"] + df, outcome="y", unit="unit", time="time", first_treat="cohort", xtvar=["x"] ) assert np.isfinite(res.overall_att) @@ -4317,7 +4335,7 @@ def test_bootstrap_runs_on_a_filtered_fit(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = WooldridgeDiD(control_group="not_yet_treated", n_bootstrap=49, seed=42).fit( - df, outcome="y", unit="unit", time="time", cohort="cohort" + df, outcome="y", unit="unit", time="time", first_treat="cohort" ) assert np.isfinite(res.overall_att) assert np.isfinite(res.overall_se) @@ -4344,7 +4362,7 @@ def _panel(seed=5): ) return pd.DataFrame(rows) - _kw = dict(outcome="outcome", unit="unit", time="time", cohort="cohort") + _kw = dict(outcome="outcome", unit="unit", time="time", first_treat="cohort") @staticmethod def _t_p(t_stat, df): @@ -4458,7 +4476,7 @@ def test_bootstrap_aggregate_group_stays_analytical_t(self): def test_glm_warns_on_explicit_nondefault_only(self): data = self._panel() data["bin"] = (data["outcome"] > data["outcome"].median()).astype(int) - kw = dict(outcome="bin", unit="unit", time="time", cohort="cohort") + kw = dict(outcome="bin", unit="unit", time="time", first_treat="cohort") with pytest.warns(UserWarning, match="no effect on the logit/poisson"): WooldridgeDiD(method="logit", df_convention="cluster").fit(data, **kw) with warnings.catch_warnings(record=True) as caught: From d31b30c3547bcf12a740d2553eb80fbc7163a8f5 Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 1 Aug 2026 23:15:57 -0400 Subject: [PATCH 2/5] fix(review): CI R1 - remaining old-name doc example and validation messages + message-regression pin --- diff_diff/chaisemartin_dhaultfoeuille.py | 2 +- diff_diff/had.py | 6 +++--- diff_diff/had_pretests.py | 6 +++--- tests/test_v4_rename_mechanical.py | 27 ++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index 2e611f235..fe13cc3a2 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -721,7 +721,7 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE >>> data = generate_reversible_did_data(n_groups=80, n_periods=6, seed=42) >>> est = ChaisemartinDHaultfoeuille() >>> results = est.fit( - ... data, outcome="outcome", group="group", + ... data, outcome="outcome", unit="group", ... time="period", treatment="treatment", ... ) >>> abs(results.overall_att - 2.0) < 1.0 # close to the true effect diff --git a/diff_diff/had.py b/diff_diff/had.py index 1f47fca07..ef2787288 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -1345,7 +1345,7 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: ) if len(cohorts) == 0: raise ValueError( - f"first_treat_col={first_treat_col!r} has no nonzero " + f"first_treat={first_treat_col!r} has no nonzero " f"cohort values (all units appear never-treated). HAD " f"requires at least one treated cohort with " f"first_treat > 0 to identify a WAS effect." @@ -1374,7 +1374,7 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: raise ValueError( f"Staggered auto-filter to last cohort " f"(F_last={F_last!r}) left 0 units. Verify " - f"first_treat_col={first_treat_col!r} contains the " + f"first_treat={first_treat_col!r} contains the " f"expected cohort labels." ) filter_info = { @@ -1385,7 +1385,7 @@ def _sort_key(x: Any) -> Tuple[bool, Any]: } warnings.warn( f"Staggered-timing panel detected: {len(cohorts)} distinct " - f"nonzero cohorts in first_treat_col={first_treat_col!r} " + f"nonzero cohorts in first_treat={first_treat_col!r} " f"({cohorts!r}). Auto-filtering to the last cohort " f"(F_last={F_last!r}) plus never-treated units " f"(first_treat=0): {n_kept} units kept, {n_dropped} " diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 381ce9719..7de8acd76 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -2460,12 +2460,12 @@ def _validate_multi_period_panel( Thin wrapper over :func:`_validate_had_panel_event_study` (had.py) that inherits the full contract: - - ``first_treat_col=None`` combined with a staggered panel → raises + - ``first_treat=None`` combined with a staggered panel → raises ``ValueError`` (the had.py helper does NOT silently accept; it requires an explicit first-treatment column to identify cohorts). - - ``first_treat_col`` provided but identifies only one cohort → no + - ``first_treat`` provided but identifies only one cohort → no auto-filter, proceeds. - - ``first_treat_col`` provided with multiple cohorts → auto-filters + - ``first_treat`` provided with multiple cohorts → auto-filters to last-cohort + never-treated, emits ``UserWarning`` with ``filter_info`` summary. - Requires ≥ 3 time periods, balanced panel, ordered time dtype, and diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index dc4367da5..5aef590c7 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -647,6 +647,33 @@ def test_workflow_rename(self, had_multi_panel): did_had_pretest_workflow(had_multi_panel, "y", "dose", "t", "u", time_col="t") +class TestValidationMessagesRecommendCanonicalNames: + """User-facing validation messages must steer callers to the NEW + spellings (CI review R1 on PR #742): the staggered fail-closed error + and the cohort-mismatch family recommend first_treat=, never the + deprecated first_treat_col=.""" + + def test_staggered_fail_closed_recommends_first_treat(self, had_multi_panel): + from diff_diff import HeterogeneousAdoptionDiD + + rng = np.random.default_rng(11) + rows = [] + for u in range(60): + start = 1 if u % 2 == 0 else 2 + d = max(0.1, rng.normal(1.0, 0.4)) + for t in (0, 1, 2, 3): + dose = d if t >= start else 0.0 + rows.append((u, t, dose, 0.3 * t + 0.8 * dose + rng.normal(0, 0.2))) + staggered = pd.DataFrame(rows, columns=["u", "t", "dose", "y"]) + with pytest.raises(ValueError) as exc: + HeterogeneousAdoptionDiD().fit( + staggered, "y", "dose", "t", "u", aggregate="event_study" + ) + message = str(exc.value) + assert "Pass first_treat=" in message + assert "first_treat_col" not in message + + class TestFirstTreatOptionalAliasPerSurface: """M-039 / M-102 / M-107 / M-112: the OPTIONAL first_treat_col alias, pinned per surface (warn + routing parity + both-supplied rejection).""" From 0ab60b3d3970422e255f87767d2f43fb12d7939a Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 1 Aug 2026 23:47:54 -0400 Subject: [PATCH 3/5] fix(review): CI R2 - two residual old-name doc references --- diff_diff/had_pretests.py | 2 +- docs/methodology/REGISTRY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 7de8acd76..45e101809 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -3832,7 +3832,7 @@ def joint_homogeneity_test( base_period : period label The reference period (last pre-period in the event-study convention). Must not be in ``post_periods``. - first_treat_col : str or None + first_treat : str or None Forwarded to the underlying panel validator. alpha, n_bootstrap, seed : as in :func:`stute_test`. survey_design : SurveyDesign or None, keyword-only, default None diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index d26d4ee40..c064f9595 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1115,7 +1115,7 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param - **Note:** The TWFE diagnostic (`twfe_diagnostic=True` in `fit()` and the standalone `twowayfeweights()`) requires binary `{0, 1}` treatment. On non-binary data, `fit()` emits a `UserWarning` and skips the diagnostic (all `twfe_*` fields are `None`), while `twowayfeweights()` raises `ValueError`. The diagnostic uses `d_gt == 1` as the treated-cell mask per Theorem 1 of AER 2020, which is undefined for non-binary treatment. -- **Note (TWFE diagnostic sample contract):** The fitted `results.twfe_weights` / `results.twfe_fraction_negative` / `results.twfe_sigma_fe` / `results.twfe_beta_fe` are computed on the **FULL pre-filter cell sample** — the data the user passed in, after `_validate_and_aggregate_to_cells()` runs but **before** the ragged-panel validation (Step 5b) and the multi-switch filter (`drop_larger_lower`, Step 6). They do NOT describe the post-filter estimation sample used by `overall_att`, `results.groups`, and the inference fields. `fit()` has three sample-shaping filters in total: (1) interior-gap drops in Step 5b, (2) multi-switch drops in Step 6, and (3) the singleton-baseline filter in Step 7. Filters (1) and (2) actually shrink the point-estimate sample, so when either fires, the fitted TWFE diagnostic and `overall_att` describe **different samples** and the estimator emits a `UserWarning` explaining the divergence with explicit counts. Filter (3) is **variance-only** — singleton-baseline groups remain in the point-estimate sample as period-based stable controls (see the singleton-baseline Note above) — so it does NOT create a fitted-vs-`overall_att` mismatch and does NOT trigger the divergence warning. Rationale for the pre-filter design: the TWFE diagnostic answers "what would the plain TWFE estimator say on the data you passed in?" — not "what would TWFE say on the data dCDH actually used after filtering?" — so users comparing TWFE vs dCDH on a fixed input can do so without an interaction effect from the dCDH-specific filters. The standalone `twowayfeweights()` function uses the same pre-filter sample and accepts the same `survey_design` parameter as `fit()`, so the fitted and standalone APIs always produce identical numbers on the same input — including survey-weighted cell aggregation (`twowayfeweights(data, ..., survey_design=sd)` matches `fit(data, ..., survey_design=sd).twfe_*`). To reproduce the dCDH estimation sample for an external TWFE comparison, pre-process your data to drop the multi-switch and interior-gap groups before fitting (the warning lists offending IDs). The matching tests are `test_twfe_pre_filter_contract_with_interior_gap_drop` and `test_twfe_pre_filter_contract_with_multi_switch_drop` in `tests/test_chaisemartin_dhaultfoeuille.py`. +- **Note (TWFE diagnostic sample contract):** The fitted `results.twfe_weights` / `results.twfe_fraction_negative` / `results.twfe_sigma_fe` / `results.twfe_beta_fe` are computed on the **FULL pre-filter cell sample** — the data the user passed in, after `_validate_and_aggregate_to_cells()` runs but **before** the ragged-panel validation (Step 5b) and the multi-switch filter (`drop_larger_lower`, Step 6). They do NOT describe the post-filter estimation sample used by `overall_att`, `results.units`, and the inference fields. `fit()` has three sample-shaping filters in total: (1) interior-gap drops in Step 5b, (2) multi-switch drops in Step 6, and (3) the singleton-baseline filter in Step 7. Filters (1) and (2) actually shrink the point-estimate sample, so when either fires, the fitted TWFE diagnostic and `overall_att` describe **different samples** and the estimator emits a `UserWarning` explaining the divergence with explicit counts. Filter (3) is **variance-only** — singleton-baseline groups remain in the point-estimate sample as period-based stable controls (see the singleton-baseline Note above) — so it does NOT create a fitted-vs-`overall_att` mismatch and does NOT trigger the divergence warning. Rationale for the pre-filter design: the TWFE diagnostic answers "what would the plain TWFE estimator say on the data you passed in?" — not "what would TWFE say on the data dCDH actually used after filtering?" — so users comparing TWFE vs dCDH on a fixed input can do so without an interaction effect from the dCDH-specific filters. The standalone `twowayfeweights()` function uses the same pre-filter sample and accepts the same `survey_design` parameter as `fit()`, so the fitted and standalone APIs always produce identical numbers on the same input — including survey-weighted cell aggregation (`twowayfeweights(data, ..., survey_design=sd)` matches `fit(data, ..., survey_design=sd).twfe_*`). To reproduce the dCDH estimation sample for an external TWFE comparison, pre-process your data to drop the multi-switch and interior-gap groups before fitting (the warning lists offending IDs). The matching tests are `test_twfe_pre_filter_contract_with_interior_gap_drop` and `test_twfe_pre_filter_contract_with_multi_switch_drop` in `tests/test_chaisemartin_dhaultfoeuille.py`. - **Note:** By default (`drop_larger_lower=True`), the estimator drops groups whose treatment switches more than once before estimation. This matches R `DIDmultiplegtDYN`'s default and is required for the analytical variance formula (Web Appendix Section 3.7.3 of the dynamic paper, which assumes Assumption 5 / no-crossing) to be consistent with the AER 2020 Theorem 3 point estimate. Both formulas operate on the same post-drop dataset. Setting `drop_larger_lower=False` is supported for diagnostic comparison but produces an inconsistent estimator-variance pairing for any multi-switch groups present, and emits an explicit warning. From b35b77442366bde00f96012569b2a2e788ba4716 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 2 Aug 2026 00:20:14 -0400 Subject: [PATCH 4/5] fix(review): CI R3 - canonical time wording in pretest validation errors + regression pin --- diff_diff/chaisemartin_dhaultfoeuille.py | 2 +- diff_diff/had_pretests.py | 10 +++++----- tests/test_v4_rename_mechanical.py | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index fe13cc3a2..690afee0c 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -1361,7 +1361,7 @@ def fit( missing_controls = [c for c in controls if c not in data.columns] if missing_controls: raise ValueError( - f"Control column(s) {missing_controls!r} not found in " + f"Covariate column(s) {missing_controls!r} not found in " f"data. Available columns: {list(data.columns)}" ) # SurveyDesign.subpopulation() contract: zero-weight rows are diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 45e101809..46d4a1ca6 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -2565,7 +2565,7 @@ def _aggregate_for_joint_test( missing_periods = [t for t in needed_periods if t not in data_periods] if missing_periods: raise ValueError( - f"Period(s) {missing_periods} not found in time_col " + f"Period(s) {missing_periods} not found in the time column " f"{time_col!r}. Available periods: " f"{sorted(data_periods, key=lambda x: (x is None, x))}." ) @@ -3520,7 +3520,7 @@ def joint_pretrends_test( period_rank = _build_period_rank(data, time_col) if base_period not in period_rank: raise ValueError( - f"base_period={base_period!r} not found in time_col " + f"base_period={base_period!r} not found in the time column " f"{time_col!r}. Available: " f"{sorted(period_rank.keys(), key=lambda t: period_rank[t])!r}." ) @@ -3528,7 +3528,7 @@ def joint_pretrends_test( if missing_pre_in_data: raise ValueError( f"pre_periods entries {missing_pre_in_data!r} not found in " - f"time_col {time_col!r}. Available: " + f"the time column {time_col!r}. Available: " f"{sorted(period_rank.keys(), key=lambda t: period_rank[t])!r}." ) base_rank = period_rank[base_period] @@ -3917,7 +3917,7 @@ def joint_homogeneity_test( period_rank = _build_period_rank(data, time_col) if base_period not in period_rank: raise ValueError( - f"base_period={base_period!r} not found in time_col " + f"base_period={base_period!r} not found in the time column " f"{time_col!r}. Available: " f"{sorted(period_rank.keys(), key=lambda t: period_rank[t])!r}." ) @@ -3925,7 +3925,7 @@ def joint_homogeneity_test( if missing_post_in_data: raise ValueError( f"post_periods entries {missing_post_in_data!r} not found in " - f"time_col {time_col!r}. Available: " + f"the time column {time_col!r}. Available: " f"{sorted(period_rank.keys(), key=lambda t: period_rank[t])!r}." ) base_rank = period_rank[base_period] diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index 5aef590c7..688f0f1c7 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -674,6 +674,24 @@ def test_staggered_fail_closed_recommends_first_treat(self, had_multi_panel): assert "first_treat_col" not in message +class TestPretestValidationMessagesUseCanonicalNames: + """CI review R3 on PR #742: invalid-period errors from the renamed + pretest APIs must describe the canonical ``time`` parameter, never the + deprecated ``time_col`` spelling.""" + + def test_invalid_period_messages_exclude_time_col(self, had_multi_panel): + from diff_diff import joint_homogeneity_test, joint_pretrends_test + + for func, kw in ( + (joint_pretrends_test, {"pre_periods": [99], "base_period": 1}), + (joint_homogeneity_test, {"post_periods": [99], "base_period": 1}), + ): + with pytest.raises(ValueError) as exc: + func(had_multi_panel, "y", "dose", "t", "u", n_bootstrap=9, **kw) + message = str(exc.value) + assert "time_col" not in message, message + + class TestFirstTreatOptionalAliasPerSurface: """M-039 / M-102 / M-107 / M-112: the OPTIONAL first_treat_col alias, pinned per surface (warn + routing parity + both-supplied rejection).""" From f44a0f90231e84948dd2ef6f8b4bc1d8adbe5d0a Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 2 Aug 2026 06:54:20 -0400 Subject: [PATCH 5/5] fix(ci): update stale first_treat_col guide pin in test_profile_panel + docstring quote (M-039 canonical name) --- tests/test_practitioner.py | 2 +- tests/test_profile_panel.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index fb73b5ef2..daa806349 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -825,7 +825,7 @@ def test_handle_continuous_step_4_recodes_first_treat_inf_for_had( {0, t_post} at the front door (had.py:1096-1102). Without the recode, a copy-paste of the advertised handoff on a valid inf-encoded ContinuousDiD panel raises - `ValueError: first_treat_col='first_treat' contains value(s) + `ValueError: first_treat='first_treat' contains value(s) [inf] outside the allowed set {0, t_post}`. """ output = practitioner_next_steps(mock_continuous_results, verbose=False) diff --git a/tests/test_profile_panel.py b/tests/test_profile_panel.py index ff1d4f908..601f7b9b7 100644 --- a/tests/test_profile_panel.py +++ b/tests/test_profile_panel.py @@ -739,17 +739,17 @@ def test_guide_api_strings_resolve_against_public_api(): ) # HeterogeneousAdoptionDiD staggered support is `partial` and - # specifically last-cohort-only (Appendix B.2): with first_treat_col + # specifically last-cohort-only (Appendix B.2): with first_treat # supplied, fit() auto-filters to F_last + never-treated; without - # first_treat_col, a multi-cohort panel raises. Guide must surface + # first_treat, a multi-cohort panel raises. Guide must surface # this explicitly so agents don't route a general staggered panel # to HAD expecting a multi-cohort estimand. assert "last-cohort-only" in text or "last cohort" in text.lower(), ( "Guide must name the last-cohort-only restriction on HAD " "staggered support (Appendix B.2)" ) - assert "first_treat_col" in text, ( - "Guide must mention that first_treat_col is required to activate " + assert "first_treat" in text, ( + "Guide must mention that first_treat is required to activate " "HAD's staggered last-cohort auto-filter" ) assert "ChaisemartinDHaultfoeuille" in text, (