diff --git a/.claude/memory.md b/.claude/memory.md index 79d5acd03..1e43186a0 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -93,18 +93,23 @@ everywhere; `to_dict()` / `to_dataframe()` on most classes. See Target contract: `docs/v4-design.md` section 5; per-surface lifecycle: `docs/v4-deprecations.yaml` (CI-enforced by `tests/test_v4_matrix.py`). -### Column Naming — CURRENT (3.x) -- `unit` unit id (`unit_col` on HAD; `group` on dCDH — both slated for 4.0) -- `time` calendar period, EXCEPT DifferenceInDifferences / TripleDifference / - static TwoWayFixedEffects, where `time` is the 0/1 post dummy (TWFE warns - on >2 unique values; 4.0 renames these to `post`, and TWFE's `time` - becomes the event-study calendar column) +### Column Naming — CURRENT (4.0) +- `unit` unit id everywhere (the 3.x `unit_col` on HAD and `group` on dCDH + were removed at 4.0) +- `time` calendar period everywhere; DifferenceInDifferences / + TripleDifference take the 0/1 post dummy as `post` (the 3.x `time=` + spelling on DiD was removed at 4.0, M-030; on TripleDifference it is + still a warning shim, M-031, because `time` persists as the merged + class's staggered calendar column - its 4.0 enforcement is M-085); + static TwoWayFixedEffects takes `post` and its `time` is the event-study + calendar column (the static `time=` alias on TWFE is the same kind of + surviving shim, M-082, enforced by M-083) - `treatment` 0/1 treated-group indicator; `first_treat` cohort column - (`cohort` on WooldridgeDiD — slated for 4.0) -- `covariates` covariate list (`controls` on dCDH — slated for 4.0) + everywhere (the 3.x `cohort` on WooldridgeDiD was removed at 4.0) +- `covariates` covariate list everywhere (the 3.x `controls` on dCDH was + removed at 4.0) -### Column Naming — 4.0 TARGET (do not write against this pre-4.0) -Target vocabulary and rules: `docs/v4-design.md` section 8. +Vocabulary and rules: `docs/v4-design.md` section 8. ## Session Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6421e4c7b..7ccf78cb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,8 +205,8 @@ A new parameter is only complete when it is: forwards via `super().__init__(*args, **kwargs)` with an import-time `__signature__` mirror - so both inherit a new `DifferenceInDifferences` constructor parameter automatically; - `SyntheticDiD` defines its OWN signature (it forwards only - `robust`/`cluster`/`alpha` to `super().__init__`), so a new parent + `SyntheticDiD` defines its OWN signature (it forwards only `alpha` to + `super().__init__`, pinning `cluster=None`), so a new parent parameter must be explicitly mirrored — or explicitly rejected, like its `vcov_type`/`conley_*` guards — there, and `BaseEstimator` introspects the concrete signature, so an unmirrored parent param simply won't appear in diff --git a/benchmarks/python/benchmark_basic.py b/benchmarks/python/benchmark_basic.py index 12a010c2b..7f74b9950 100644 --- a/benchmarks/python/benchmark_basic.py +++ b/benchmarks/python/benchmark_basic.py @@ -94,12 +94,12 @@ def main(): if args.warmup: print("Warm-up fit (untimed)...") - DifferenceInDifferences(robust=True, cluster=args.cluster).fit( + DifferenceInDifferences(cluster=args.cluster).fit( data, formula="outcome ~ treated * post" ) # Use DifferenceInDifferences with formula to match R's fixest::feols - did = DifferenceInDifferences(robust=True, cluster=args.cluster) + did = DifferenceInDifferences(cluster=args.cluster) with Timer() as timer: results = did.fit( diff --git a/benchmarks/python/benchmark_multiperiod.py b/benchmarks/python/benchmark_multiperiod.py index 2122f2422..6080f1a1e 100644 --- a/benchmarks/python/benchmark_multiperiod.py +++ b/benchmarks/python/benchmark_multiperiod.py @@ -102,7 +102,7 @@ def main(): if args.warmup: print("Warm-up fit (untimed)...") - MultiPeriodDiD(robust=True, cluster=args.cluster).fit( + MultiPeriodDiD(cluster=args.cluster).fit( data, outcome="outcome", treatment="treated", @@ -112,7 +112,7 @@ def main(): absorb=["unit"], ) - did = MultiPeriodDiD(robust=True, cluster=args.cluster) + did = MultiPeriodDiD(cluster=args.cluster) with Timer() as timer: results = did.fit( diff --git a/benchmarks/python/benchmark_twfe.py b/benchmarks/python/benchmark_twfe.py index 575a92cfc..9bae1afb3 100644 --- a/benchmarks/python/benchmark_twfe.py +++ b/benchmarks/python/benchmark_twfe.py @@ -79,11 +79,11 @@ def main(): if args.warmup: print("Warm-up fit (untimed)...") - TwoWayFixedEffects(robust=True).fit( + TwoWayFixedEffects().fit( data, outcome="outcome", treatment="treated", post="post", unit="unit" ) - twfe = TwoWayFixedEffects(robust=True) # auto-clusters at unit level + twfe = TwoWayFixedEffects() # auto-clusters at unit level with Timer() as timer: results = twfe.fit( diff --git a/benchmarks/python/benchmark_wooldridge.py b/benchmarks/python/benchmark_wooldridge.py index 7930a39b9..e38963d47 100644 --- a/benchmarks/python/benchmark_wooldridge.py +++ b/benchmarks/python/benchmark_wooldridge.py @@ -74,13 +74,13 @@ def main(): outcome="lemp", unit="countyreal", time="year", - cohort="first_treat", + first_treat="first_treat", ) estimation_time = estimation_timer.elapsed # Compute event study aggregation - results.aggregate("event") + results.aggregate("event_study") total_time = estimation_timer.elapsed # Store data info diff --git a/benchmarks/speed_review/bench_brand_awareness_survey.py b/benchmarks/speed_review/bench_brand_awareness_survey.py index ccd7620cc..f4178f0ea 100644 --- a/benchmarks/speed_review/bench_brand_awareness_survey.py +++ b/benchmarks/speed_review/bench_brand_awareness_survey.py @@ -73,13 +73,13 @@ def naive_fit(): # accounted for the sampling structure. The SE-inflation story # only shows up if this step is as untreated-for-design as # practitioners actually start. - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results["naive"] = did.fit( data, outcome="outcome", treatment="treat_unit", post="post", ) def tsl_fit(): - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results["tsl"] = did.fit( data, outcome="outcome", treatment="treat_unit", post="post", survey_design=sd_tsl, @@ -92,7 +92,7 @@ def replicate_fit(): weights="weight", replicate_weights=rw_cols, replicate_method="JK1", ) - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results["replicate"] = did.fit( data, outcome="outcome", treatment="treat_unit", post="post", survey_design=sd, @@ -101,7 +101,7 @@ def replicate_fit(): def multi_outcome_loop(): out = {} for y in ("outcome", "consideration", "purchase_intent"): - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() out[y] = did.fit( data, outcome=y, treatment="treat_unit", post="post", survey_design=sd_tsl, @@ -118,10 +118,10 @@ def pretrends(): def placebo_refit(): pre = data[data["period"] < 7].copy() pre["placebo_post"] = (pre["period"] >= 4).astype(int) - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results["placebo"] = did.fit( pre, outcome="outcome", treatment="treat_unit", - time="placebo_post", survey_design=sd_tsl, + post="placebo_post", survey_design=sd_tsl, ) def honest_did_grid(): diff --git a/benchmarks/speed_review/bench_dose_response.py b/benchmarks/speed_review/bench_dose_response.py index c30024d0a..6921f1ad2 100644 --- a/benchmarks/speed_review/bench_dose_response.py +++ b/benchmarks/speed_review/bench_dose_response.py @@ -84,7 +84,7 @@ def binarized_comparison(): data_bin = data.copy() data_bin["treated_any"] = (data_bin["dose"] > 0).astype(int) data_bin["post"] = (data_bin["period"] >= treated_cohort).astype(int) - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results["binarized"] = did.fit( data_bin, outcome="outcome", treatment="treated_any", post="post", ) diff --git a/benchmarks/speed_review/bench_memory_scaling.py b/benchmarks/speed_review/bench_memory_scaling.py index 6bf721ae3..6e0e4ced8 100644 --- a/benchmarks/speed_review/bench_memory_scaling.py +++ b/benchmarks/speed_review/bench_memory_scaling.py @@ -135,7 +135,7 @@ def _run_one(cfg): from diff_diff.twfe import TwoWayFixedEffects ncov = cfg.get("ncov", 6) - TwoWayFixedEffects(robust=True).fit( + TwoWayFixedEffects().fit( _twfe_panel(nu, ncov=ncov), outcome="outcome", treatment="treated", diff --git a/benchmarks/speed_review/bench_reversible_dcdh.py b/benchmarks/speed_review/bench_reversible_dcdh.py index 5788b02c3..1320adcf3 100644 --- a/benchmarks/speed_review/bench_reversible_dcdh.py +++ b/benchmarks/speed_review/bench_reversible_dcdh.py @@ -52,7 +52,7 @@ def main(): results = {} fit_kwargs = dict( - data=data, outcome="outcome", group="group", time="period", + data=data, outcome="outcome", unit="group", time="period", treatment="treatment", ) sd = SurveyDesign( diff --git a/changelog.d/20261018-v4-pr2-shim-removals.md b/changelog.d/20261018-v4-pr2-shim-removals.md new file mode 100644 index 000000000..1abe34680 --- /dev/null +++ b/changelog.d/20261018-v4-pr2-shim-removals.md @@ -0,0 +1,59 @@ +### Removed +- **The 3.9 rename shims are gone (ledger rows flipped to `removed`).** Old keyword spellings + now raise `TypeError`: `DifferenceInDifferences.fit(time=)` -> `post=` (`M-030`), + `permutation_test(time=)` / `leave_one_out_test(time=)` -> `post=` (`M-137`, `M-138`), + `WooldridgeDiD.fit(cohort=)` -> `first_treat=` (`M-032`), `ChaisemartinDHaultfoeuille.fit(group=, + controls=)` -> `unit=`, `covariates=` (`M-033`, `M-034`), `twowayfeweights(group=)` -> `unit=` + (`M-097`), the `*_col` spellings on `HeterogeneousAdoptionDiD.fit`, `RegressionDiscontinuity.fit`, + `RDPlot.fit`, `joint_pretrends_test`, `joint_homogeneity_test`, `did_had_pretest_workflow` + (`M-035`..`M-042`, `M-088`, `M-089`, `M-098`..`M-112`) and `trim_weights(weight_col=)` -> + `weights=` (`M-113`), `StackedDiD(clean_control=)` -> `control_group=` (`M-043`), + `WooldridgeDiDResults.to_dataframe(aggregation=)` -> `level=` (`M-044`) and + `summary(aggregation)` (`M-087`; `summary(alpha=None)` is now the positional signature), + `ContinuousDiD(covariates=)` -> `fit(covariates=)` (`M-084`). The `"event"` value spelling is + rejected with `ValueError` on `WooldridgeDiDResults.aggregate(type=)`, `.to_dataframe(level=)` and + `LPDiDResults.to_dataframe(level=)` - use `"event_study"` (`M-086`, `M-150`, `M-136`). +- **`robust=` removed** from `DifferenceInDifferences` (and so `TwoWayFixedEffects`, + `MultiPeriodDiD`), `TripleDifference`, `HeterogeneousAdoptionDiD` and `LinearRegression` + (`M-045`..`M-047`, `M-115`); `vcov_type=` is the single selector. The legacy-CR1 remap that + turned `robust=False` + clustering into `hc1` went with it. Positional constructor slots after + the removed parameter shift left by one (DiD slot 1, LinearRegression/TripleDifference slot 2, + HeterogeneousAdoptionDiD slot 6); `SyntheticDiD`'s inert `lambda_reg`/`zeta` (`M-001`, `M-002`, + constructor slots 7-8) are removed too, shifting `vcov_type` and the `conley_*` parameters two + slots left. Pass these by keyword. +- **Results-field aliases removed**: `SyntheticDiDResults.placebo_effects` (`M-003`; use + `variance_effects`), `RegressionDiscontinuityResults.treatment_col` (`M-094`; `takeup`), + `StackedDiDResults.clean_control` and `StackedDiD.clean_control` (`M-095`, `M-043`; + `control_group`), `ChaisemartinDHaultfoeuilleResults.groups` (`M-114`; `units`), + `ChangesInChangesResults.estimator` (`M-143`; `method`). Their serialized dual keys are gone from + `to_dict()` (`treatment_col`, `clean_control`, `estimator`, Wooldridge's + `aggregation_weights["event"]`); 3.x pickles still load (`__setstate__` key migrations kept). +- **The eight module-level wrapper functions are removed** (`M-070`..`M-077`): `imputation_did`, + `two_stage_did`, `stacked_did`, `trop`, `synthetic_control`, `triple_difference`, + `bacon_decompose`, `chaisemartin_dhaultfoeuille`. Call the estimator classes. `diff_diff.trop`, + `diff_diff.synthetic_control`, `diff_diff.stacked_did` and `diff_diff.chaisemartin_dhaultfoeuille` + now name the submodules (as `diff_diff.bacon` always did). +- **The dieted aliases are removed** (`M-132`..`M-134`): `CDiD` -> `ContinuousDiD`, `Stacked` -> + `StackedDiD`, `Gardner` -> `TwoStageDiD`; the module `__getattr__` warning shim (`M-135`) is gone. + +### Changed +- **`WooldridgeDiDResults.summary(alpha=None)`** renders the headline (simple) row only; the + group / calendar / event-study tables it used to render on request - and their `[pre]`/`[antic]` + relative-period labels - are no longer part of `summary()`. Use `aggregate(...)` / + `to_dataframe(level=...)`; `anticipation` is still reported on the results object. + `to_dataframe(level=)` now rejects an unknown level with `ValueError` instead of returning an + empty frame. +- `practitioner_next_steps` no longer recognises duck-typed results that carry only the legacy + `estimator` attribute; `ChangesInChangesResults.method` is the routing field. + +### Breaking Changes +- **`BusinessReport.to_dict()` schema `2.0` -> `3.0`**: the StackedDiD `assumption` block no longer + carries the legacy `clean_control` key (read `control_group`). `DiagnosticReport`'s schema stays + `2.0`. + +### Internal +- `tests/test_v4_matrix.py` removed-status probes: a function row that resolves to a submodule + counts as absent; a param row absent from the explicit signature counts as removed despite + `**kwargs`. Naming-guard allowlist extended for the kept body-local aliases, `__setstate__` keys + and shared vocabulary. The `diff_diff._deprecation` helpers survive for the remaining + M-031/M-082, PR-3 and 5.0-window shims. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index f3d6264a9..8136d2a46 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -20,9 +20,6 @@ the difference-in-differences methodology. """ -import warnings as _warnings -from typing import Any as _Any - # Import backend detection from dedicated module (avoids circular imports) from diff_diff._backend import ( HAS_RUST_BACKEND, @@ -41,7 +38,6 @@ BaconDecomposition, BaconDecompositionResults, Comparison2x2, - bacon_decompose, ) from diff_diff.business_report import ( BUSINESS_REPORT_SCHEMA_VERSION, @@ -51,7 +47,6 @@ from diff_diff.chaisemartin_dhaultfoeuille import ( ChaisemartinDHaultfoeuille, TWFEWeightsResult, - chaisemartin_dhaultfoeuille, twowayfeweights, ) from diff_diff.chaisemartin_dhaultfoeuille_results import ( @@ -144,7 +139,6 @@ ImputationBootstrapResults, ImputationDiD, ImputationDiDResults, - imputation_did, ) from diff_diff.linalg import ( InferenceResult, @@ -257,7 +251,6 @@ from diff_diff.stacked_did import ( StackedDiD, StackedDiDResults, - stacked_did, ) from diff_diff.staggered import ( CallawaySantAnna, @@ -283,20 +276,15 @@ compute_deff_diagnostics, make_pweight_design, ) -from diff_diff.synthetic_control import ( - SyntheticControl, - synthetic_control, -) +from diff_diff.synthetic_control import SyntheticControl from diff_diff.synthetic_control_results import SyntheticControlResults from diff_diff.triple_diff import ( TripleDifference, TripleDifferenceResults, - triple_difference, ) from diff_diff.trop import ( TROP, TROPResults, - trop, ) from diff_diff.twfe_weights import ( attgt_weights, @@ -310,7 +298,6 @@ TwoStageBootstrapResults, TwoStageDiD, TwoStageDiDResults, - two_stage_did, ) from diff_diff.utils import ( WildBootstrapResults, @@ -355,33 +342,6 @@ RDD = RegressionDiscontinuity SCM = SyntheticControl -# Alias diet (rows M-132..M-134, mechanism M-135): CDiD / Gardner / -# Stacked are deprecated in 3.9 and removed in 4.0. They deliberately -# do NOT live in module globals — dir()/vars() no longer list them — -# but stay importable (and in __all__) through 3.9, served by the PEP -# 562 module __getattr__ below, which emits the FutureWarning naming -# the surviving class. -_DEPRECATED_ALIASES = { - "CDiD": "ContinuousDiD", - "Gardner": "TwoStageDiD", - "Stacked": "StackedDiD", -} - - -def __getattr__(name: str) -> _Any: - """PEP 562 warning shim for the dieted aliases (row M-135).""" - target = _DEPRECATED_ALIASES.get(name) - if target is not None: - _warnings.warn( - f"diff_diff.{name} is deprecated and will be removed in 4.0; " - f"use diff_diff.{target}.", - FutureWarning, - stacklevel=2, - ) - return globals()[target] - raise AttributeError(f"module 'diff_diff' has no attribute {name!r}") - - __version__ = "3.13.0" __all__ = [ # Estimators @@ -402,30 +362,24 @@ def __getattr__(name: str) -> _Any: "TROP", "SyntheticControl", "StackedDiD", - # Estimator aliases (short names). CDiD / Gardner / Stacked are - # deprecated (M-132..M-134): still importable through 3.9 via the - # module __getattr__ (M-135), gone from module globals/dir(). + # Estimator aliases (short names). "DiD", "TWFE", "EventStudy", "SDiD", "CS", - "CDiD", "DCDH", "CiC", "SA", "BJS", - "Gardner", "DDD", "SDDD", "SCM", - "Stacked", "Bacon", # Bacon Decomposition "BaconDecomposition", "BaconDecompositionResults", "Comparison2x2", - "bacon_decompose", # Results "DiDResults", "MultiPeriodDiDResults", @@ -442,21 +396,15 @@ def __getattr__(name: str) -> _Any: "SABootstrapResults", "ImputationDiDResults", "ImputationBootstrapResults", - "imputation_did", "TwoStageDiDResults", "TwoStageBootstrapResults", - "two_stage_did", "SpilloverDiDResults", "TripleDifferenceResults", - "triple_difference", "StaggeredTripleDifference", "StaggeredTripleDiffResults", "TROPResults", - "trop", "SyntheticControlResults", - "synthetic_control", "StackedDiDResults", - "stacked_did", # EfficientDiD "EfficientDiD", "EfficientDiDResults", @@ -466,7 +414,6 @@ def __getattr__(name: str) -> _Any: "ChaisemartinDHaultfoeuilleResults", "DCDHBootstrapResults", "TWFEWeightsResult", - "chaisemartin_dhaultfoeuille", "twowayfeweights", # TWFE weight diagnostics (Callaway `twfeweights` port) - distinct from # the dCDH `twowayfeweights` surface above: these weight ATT(g,t) diff --git a/diff_diff/_base.py b/diff_diff/_base.py index 3289f4f45..1a3152089 100644 --- a/diff_diff/_base.py +++ b/diff_diff/_base.py @@ -45,8 +45,8 @@ ``_vcov_type_explicit``). ``_normalize_set_params`` Classmethod rewriting the user-supplied ``params`` dict before the - merge (e.g. the ``robust``-alone alias re-derivation on - ``DifferenceInDifferences``). + merge (default no-op; a hook for subclasses whose parameters are + inter-derived and must be re-resolved together). """ import inspect diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 26c641e69..1894feed0 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -16,9 +16,9 @@ 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. +- Dropped parameter (no successor name, e.g. the fit-time ``aggregate=`` + override): the parameter keeps its position with a sentinel default; the + owner 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 @@ -32,8 +32,9 @@ 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``. +section 2 category rule: new shims warn with ``FutureWarning`` (the +pre-existing M-001..M-003 ``DeprecationWarning`` sites were removed at the +4.0 cut). """ import warnings @@ -158,9 +159,9 @@ def deprecated_field_property( 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. ``removed_in`` names the + follows the 3.x ``SyntheticDiDResults.placebo_effects`` renamed-field + alias precedent (M-003, removed at 4.0), with the FutureWarning category + the ledger declares for renamed-field rows. ``removed_in`` names the release the ledger row schedules the removal for (``"4.0"`` for the 3.9 rename sweep; ``"5.0"`` for aliases deprecated AT 4.0, e.g. the LWDiD ``pvalue``/``ci`` rows M-151/M-152) so the warning and the generated diff --git a/diff_diff/agent_workflow.py b/diff_diff/agent_workflow.py index 45ca01678..aec2c6812 100644 --- a/diff_diff/agent_workflow.py +++ b/diff_diff/agent_workflow.py @@ -184,9 +184,7 @@ def agent_workflow( " - Binary staggered : CallawaySantAnna (shown) / SunAbraham / ImputationDiD", " - Continuous dose : ContinuousDiD (also takes first_treat=)", " - Heterogeneous adoption intensity:", - " HeterogeneousAdoptionDiD (event study,", - " takes first_treat=; first_treat_col= is a", - " deprecated alias slated for 4.0 removal)", + " HeterogeneousAdoptionDiD (event study, takes first_treat=)", ] else: fit_example_kwargs = _join_kwargs( diff --git a/diff_diff/bacon.py b/diff_diff/bacon.py index d80949b6c..00fd8694f 100644 --- a/diff_diff/bacon.py +++ b/diff_diff/bacon.py @@ -12,7 +12,7 @@ import warnings from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd @@ -23,9 +23,6 @@ from diff_diff.utils import pre_demean_norms, snap_absorbed_regressors from diff_diff.utils import within_transform as _within_transform_util -if TYPE_CHECKING: - from diff_diff.survey import SurveyDesign - @dataclass class Comparison2x2: @@ -1267,141 +1264,3 @@ def summary(self) -> str: def print_summary(self) -> None: """Print summary to stdout.""" print(self.summary()) - - -def bacon_decompose( - data: pd.DataFrame, - outcome: str, - unit: str, - time: str, - first_treat: str, - weights: str = "exact", - survey_design: Optional["SurveyDesign"] = None, -) -> BaconDecompositionResults: - """ - Convenience function for Goodman-Bacon decomposition. - - .. deprecated:: 3.9 - ``bacon_decompose()`` is deprecated and will be removed in 4.0 - (row M-076). Construct the diagnostic instead: - ``BaconDecomposition(...).fit(data, ...)``. - - Decomposes a TWFE estimate into weighted 2x2 DiD comparisons, - showing which comparisons drive the estimate and whether - problematic "forbidden comparisons" are involved. - - Parameters - ---------- - data : pd.DataFrame - Panel data with unit and time identifiers. - outcome : str - Name of outcome variable column. - unit : str - Name of unit identifier column. - time : str - Name of time period column. - first_treat : str - Name of column indicating when unit was first treated. The - values ``0`` and ``np.inf`` are **reserved as never-treated - sentinels**; a real treatment cohort with ``first_treat == 0`` - would be folded into ``U`` and should be re-labeled to a - non-sentinel value before fitting. Units whose ``first_treat`` - is at or before the first observable period - (``first_treat <= min(time)``, excluding the sentinels) are - automatically remapped to the ``U`` (untreated) bucket per - Goodman-Bacon (2021) footnote 11, with a ``UserWarning``. See - ``BaconDecomposition.fit()`` for the full contract and - ``BaconDecompositionResults.n_always_treated_remapped`` for the - count. The user's original ``first_treat`` column is preserved - unchanged. - weights : str, default="exact" - Weight calculation method: - - - "exact" (default): Variance-based weights from Goodman-Bacon - (2021) Theorem 1, Eqs. 7-9 and 10e-g. Paper-faithful. - - "approximate": Fast simplified formula. Opt in for - speed-sensitive diagnostic loops; numerical output may differ - from R ``bacondecomp::bacon()``. - survey_design : SurveyDesign, optional - Survey design specification for weighted estimation. When provided, - cell means, group shares, and within-transform use survey weights. - The decomposition remains diagnostic (no survey vcov needed). - - **Default-flip caveat (PR-B, 2026-05-16):** the new - ``weights="exact"`` default routes through - ``_validate_unit_constant_survey``, which **rejects survey - designs whose weights / strata / PSU / FPC columns vary within - a unit across periods** (the exact path collapses to per-unit - aggregation via ``groupby().first()``). Users whose survey - design has time-varying within-unit columns must either (a) - collapse the columns to be unit-constant or (b) pass explicit - ``weights="approximate"`` to retain the legacy observation-level - weighted-means path. - - Returns - ------- - BaconDecompositionResults - Object containing decomposition results with: - - - twfe_estimate: The overall TWFE coefficient - - comparisons: List of all 2x2 comparisons with estimates and weights - - Weight totals by comparison type - - Methods for visualization and export - - Examples - -------- - >>> from diff_diff import bacon_decompose - >>> - >>> # Default: paper-faithful Goodman-Bacon (2021) Theorem 1 weights - >>> # (weights="exact"); matches R bacondecomp::bacon() at atol=1e-6 on - >>> # the aggregate (TWFE coefficient + weights-sum) across all panels, - >>> # and on the per-component breakdown when there are no - >>> # always-treated / first-period-treated cohorts (i.e. all - >>> # non-sentinel first_treat values are strictly greater than - >>> # min(time)). For panels with always-treated units, the - >>> # per-component breakdown diverges by convention (Python remaps - >>> # to U per paper footnote 11; R emits `Later vs Always Treated`); - >>> # see REGISTRY note on R parity convention divergence. Validated - >>> # via tests/test_methodology_bacon.py::TestBaconParityR. - >>> results = bacon_decompose( - ... data=panel_df, - ... outcome='earnings', - ... unit='state', - ... time='year', - ... first_treat='treatment_year' - ... ) - >>> - >>> # Opt-in: simplified-variance fast path for diagnostic loops - >>> # (numerical output may differ from R; sum-to-1 still holds). - >>> results_approx = bacon_decompose( - ... data=panel_df, - ... outcome='earnings', - ... unit='state', - ... time='year', - ... first_treat='treatment_year', - ... weights='approximate' - ... ) - >>> - >>> # View summary - >>> results.print_summary() - >>> - >>> # Check weight on forbidden comparisons - >>> print(f"Forbidden weight: {results.total_weight_later_vs_earlier:.1%}") - >>> - >>> # Convert to DataFrame for analysis - >>> df = results.to_dataframe() - - See Also - -------- - BaconDecomposition : Class-based interface with more options - plot_bacon : Visualize the decomposition - CallawaySantAnna : Robust estimator that avoids forbidden comparisons - """ - warnings.warn( - "bacon_decompose() is deprecated and will be removed in 4.0; " - "construct the diagnostic instead: BaconDecomposition(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - decomp = BaconDecomposition(weights=weights) - return decomp.fit(data, outcome, unit, time, first_treat, survey_design=survey_design) diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 1c29a5657..51f3a1386 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -53,7 +53,7 @@ from diff_diff.diagnostic_report import DiagnosticReport, DiagnosticReportResults from diff_diff.results_base import Diagnostic, _coverage_level, _coverage_pct -BUSINESS_REPORT_SCHEMA_VERSION = "2.0" +BUSINESS_REPORT_SCHEMA_VERSION = "3.0" __all__ = [ "BusinessReport", @@ -794,7 +794,7 @@ def _extract_sample(self) -> Dict[str, Any]: # path even in dynamic mode. # * Stacked — ``n_control_units`` is "distinct control units # across the trimmed set" (stacked_did_results.py L59-62). - # Under ``clean_control="not_yet_treated"``, the trimmed + # Under ``control_group="not_yet_treated"``, the trimmed # set uses the rule ``A_s > a + kappa_post`` which admits # future-treated controls; it is NOT a never-treated tally # and cannot be relabeled as ``n_never_treated``. Keep @@ -1143,10 +1143,9 @@ def _control_group_choice(results: Any) -> Optional[str]: across estimator-specific attribute names. Every anticipation-capable estimator now exposes the control-group - choice as ``results.control_group`` (``StackedDiDResults`` joined via - row M-095; its ``__setstate__`` migrates pre-rename pickles, so no - ``clean_control`` fallback is needed - reading the deprecated alias - here would fire its FutureWarning on every report build). + choice as ``results.control_group`` (``StackedDiDResults`` included; + its ``__setstate__`` migrates pre-rename pickles, so no legacy-name + fallback is needed here). """ cg = getattr(results, "control_group", None) if isinstance(cg, str): @@ -1480,16 +1479,16 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, # event) inclusion conditions, NOT the generic "group-time ATT # parallel trends" clause used for CS / SA / etc. (round-22 P1 # CI review on PR #318). The active ``control_group`` rule - # (pre-M-095 name: ``clean_control``) determines which units - # qualify as valid controls for each adoption event. REGISTRY.md - # §StackedDiD (identification / clean-control rules). - clean_control = getattr(results, "control_group", None) - if clean_control == "never_treated": + # determines which units qualify as valid controls for each + # adoption event. REGISTRY.md §StackedDiD (identification / + # clean-control rules). + control_group = getattr(results, "control_group", None) + if control_group == "never_treated": control_clause = ( "controls are restricted to units that are never treated " "over the panel (``A_s = infinity``)" ) - elif clean_control == "strict": + elif control_group == "strict": control_clause = ( "controls for event ``a`` are units satisfying the strict " "rule ``A_s > a + kappa_post + kappa_pre`` (strictly " @@ -1522,11 +1521,8 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, "anticipation." ), } - if isinstance(clean_control, str): - block["control_group"] = clean_control - # Deprecated reporting key kept through the 3.9 shim window; - # retired at 4.0 (row M-095, section 5 policy). - block["clean_control"] = clean_control + if isinstance(control_group, str): + block["control_group"] = control_group return block if estimator_name == "ImputationDiDResults": # Borusyak, Jaravel & Spiess (2024) — identification is through @@ -2773,7 +2769,7 @@ def _render_full_report(schema: Dict[str, Any]) -> str: # ``n_control`` is only populated for estimators whose control set # is a fixed tally. For dynamic modes (CS / ContinuousDiD / # StaggeredTripleDiff / EfficientDiD / StackedDiD under - # ``clean_control in {"not_yet_treated", "strict"}``) the comparison + # ``control_group in {"not_yet_treated", "strict"}``) the comparison # group is dynamic per cohort/sub-experiment; report the estimator- # specific fixed subset (``n_never_enabled`` for triple-difference; # ``n_never_treated`` elsewhere; ``n_distinct_controls_trimmed`` for diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index c6b08bd4a..dd57fee23 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -37,12 +37,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import ( - NOT_SUPPLIED, - require_arg, - resolve_renamed_kwarg, - warn_deprecated_kwarg, -) +from diff_diff._deprecation import NOT_SUPPLIED, warn_deprecated_kwarg from diff_diff.chaisemartin_dhaultfoeuille_bootstrap import ( ChaisemartinDHaultfoeuilleBootstrapMixin, ) @@ -55,7 +50,6 @@ __all__ = [ "ChaisemartinDHaultfoeuille", - "chaisemartin_dhaultfoeuille", "twowayfeweights", "TWFEWeightsResult", ] @@ -880,13 +874,13 @@ def fit( self, data: pd.DataFrame, outcome: str, - unit: Any = NOT_SUPPLIED, - time: Any = NOT_SUPPLIED, - treatment: Any = NOT_SUPPLIED, + unit: str, + time: str, + treatment: str, # ---------- forward-compat parameters ---------- aggregate: Any = NOT_SUPPLIED, L_max: Optional[int] = None, - covariates: Any = NOT_SUPPLIED, + covariates: Optional[List[str]] = None, trends_linear: Optional[bool] = None, trends_nonparam: Optional[Any] = None, honest_did: bool = False, @@ -895,9 +889,6 @@ 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. @@ -1043,12 +1034,6 @@ 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 ------- @@ -1063,27 +1048,8 @@ 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). + # Body-local names (the paper's "group"/"controls" vocabulary); the + # public parameters are unit/covariates. group = unit controls = covariates # ------------------------------------------------------------------ @@ -8726,81 +8692,14 @@ def _compute_twfe_diagnostic( # ============================================================================= -def chaisemartin_dhaultfoeuille( +def twowayfeweights( data: pd.DataFrame, outcome: str, - group: str, + unit: str, time: str, treatment: str, - **kwargs: Any, -) -> ChaisemartinDHaultfoeuilleResults: - """ - One-shot convenience wrapper around - :class:`ChaisemartinDHaultfoeuille`. - - .. deprecated:: 3.9 - ``chaisemartin_dhaultfoeuille()`` is deprecated and will be - removed in 4.0 (row M-077). Construct the estimator instead: - ``ChaisemartinDHaultfoeuille(...).fit(data, ...)``. - - Equivalent to:: - - ChaisemartinDHaultfoeuille(**init_kwargs).fit( - data, outcome=..., group=..., time=..., treatment=..., - **fit_kwargs, - ) - - All keyword arguments are split between ``__init__`` and ``fit`` based - on which signature accepts them. Useful for one-line use in scripts. - - Parameters - ---------- - data : pd.DataFrame - outcome, group, time, treatment : str - **kwargs : Any - Forwarded to ``ChaisemartinDHaultfoeuille.__init__`` or - ``.fit()`` based on parameter name. - - Returns - ------- - ChaisemartinDHaultfoeuilleResults - """ - import inspect - - warnings.warn( - "chaisemartin_dhaultfoeuille() is deprecated and will be removed in 4.0; " - "construct the estimator instead: " - "ChaisemartinDHaultfoeuille(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - init_keys = { - name - for name, p in inspect.signature(ChaisemartinDHaultfoeuille.__init__).parameters.items() - if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD) and name != "self" - } - init_kwargs = {k: v for k, v in kwargs.items() if k in init_keys} - fit_kwargs = {k: v for k, v in kwargs.items() if k not in init_keys} - est = ChaisemartinDHaultfoeuille(**init_kwargs) - return est.fit( - data, - outcome=outcome, - unit=group, - time=time, - treatment=treatment, - **fit_kwargs, - ) - - -def twowayfeweights( - data: pd.DataFrame, - outcome: 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. @@ -8832,23 +8731,13 @@ 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). + # Body-local name (the R/Stata "group" vocabulary); the public parameter is unit. group = unit # Survey resolution (optional): mirrors the fit() path so that the # standalone helper produces identical numbers to fit(..., survey_design=sd). diff --git a/diff_diff/chaisemartin_dhaultfoeuille_results.py b/diff_diff/chaisemartin_dhaultfoeuille_results.py index bacb688ba..ef9f09b1f 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille_results.py +++ b/diff_diff/chaisemartin_dhaultfoeuille_results.py @@ -31,7 +31,6 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import deprecated_field_property from diff_diff.aggregation import AggregationMixin, AggregationResult from diff_diff.results import _get_significance_stars from diff_diff.results_base import ( @@ -331,9 +330,7 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): filters drop groups — see the ``twfe_weights`` docstring above for the sample contract. 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.) + Unit identifiers in the post-filter sample. time_periods : list Time periods in the panel. n_obs : int @@ -690,17 +687,11 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults, AggregationMixin): # 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. + """Migrate 3.x 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. + Results pickled under the old name stored the list under ``groups``; + rewriting the key on load populates ``units`` from old pickles. """ if "groups" in state and "units" not in state: state = dict(state) diff --git a/diff_diff/changes_in_changes_results.py b/diff_diff/changes_in_changes_results.py index 916aca06a..f40a291c8 100644 --- a/diff_diff/changes_in_changes_results.py +++ b/diff_diff/changes_in_changes_results.py @@ -6,7 +6,6 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import deprecated_field_property from diff_diff.results_base import BaseResults, _coverage_pct _ESTIMATOR_TITLES = { @@ -116,10 +115,6 @@ def to_dict(self) -> Dict[str, Any]: "n_bootstrap_valid": self.n_bootstrap_valid, "panel": self.panel, "method": self.method, - # Dual-key 3.9 window (row M-143, the M-094/rdd.py twin): the old key - # stays until 4.0 so serialized consumers keep working. Both read the - # SAME attribute - they can never disagree. - "estimator": self.method, "alpha": self.alpha, "covariates": list(self.covariates) if self.covariates else None, "inference_method": "bootstrap" if self.n_bootstrap > 0 else "none", @@ -234,21 +229,13 @@ def print_summary(self) -> None: """Print :meth:`summary` to stdout.""" print(self.summary()) - # Row M-143: read-only alias for the pre-3.9 ``estimator`` field name. No - # annotation, so it stays a descriptor and never becomes a - # __dataclass_fields__ entry. Being setter-less is deliberate and load-bearing: - # ``setattr(res, "estimator", ...)`` must fail rather than silently write a - # shadow attribute the renamed field would not see. - estimator = deprecated_field_property("ChangesInChangesResults", "estimator", "method") - def __setstate__(self, state: Dict[str, Any]) -> None: - """Migrate pickles created before the ``estimator`` -> ``method`` rename. + """Migrate 3.x pickles created before the ``estimator`` -> ``method`` rename. - Results pickled before row M-143 stored the tag under ``estimator``; - rewriting the key on load keeps both ``method`` and the deprecated - ``estimator`` alias working on old pickles. Dataclass unpickling routes - through here for CURRENT objects too, so this must stay a pass-through - in the common case. + Results pickled under the old name stored the tag under ``estimator``; + rewriting the key on load populates ``method`` from old pickles. + Dataclass unpickling routes through here for CURRENT objects too, so + this must stay a pass-through in the common case. """ if "estimator" in state and "method" not in state: state = dict(state) diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index d419f26e1..a4e6e3104 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -17,7 +17,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED, warn_deprecated_kwarg +from diff_diff._deprecation import NOT_SUPPLIED from diff_diff._dr_scores import drdid_panel_inf_func from diff_diff.aggregation import AggregationKit from diff_diff.bootstrap_utils import ( @@ -202,16 +202,6 @@ class ContinuousDiD(_ContinuousDiDAggregationMixin, BaseEstimator): Random seed for reproducibility. rank_deficient_action : str, default="warn" Action for rank-deficient B-spline OLS: ``"warn"``, ``"error"``, or ``"silent"``. - covariates : list of str, optional - DEPRECATED constructor home (row M-084; warns with - ``FutureWarning``, removed in 4.0) - pass ``covariates=`` to - ``fit()`` instead (the sklearn hyperparameter/data split). - Column names of covariates for **conditional** parallel trends - (``E[ΔY(0) | D=d, X] = E[ΔY(0) | D=0, X]``). When ``None`` (default) the - estimator uses unconditional parallel trends. Covariates enter through a - covariate-adjusted per-cell control counterfactual (see ``estimation_method``). - Covariates are read from the base period of each ``(g, t)`` comparison. - Not currently composable with ``survey_design=`` (raises ``NotImplementedError``). estimation_method : str, default="dr" Covariate-adjustment method (only used when ``covariates`` is set): ``"reg"`` (outcome regression) or ``"dr"`` (doubly-robust, the default). @@ -281,7 +271,6 @@ def __init__( bootstrap_weights: str = "rademacher", seed: Optional[int] = None, rank_deficient_action: str = "warn", - covariates: Optional[List[str]] = None, estimation_method: str = "dr", pscore_trim: float = 0.01, epv_threshold: float = 10.0, @@ -300,17 +289,6 @@ def __init__( self.bootstrap_weights = bootstrap_weights self.seed = seed self.rank_deficient_action = rank_deficient_action - # M-084: constructor covariates= is deprecated (removed in 4.0); - # the design-matrix column spec moves to fit() per the sklearn - # hyperparameter/data split. Raw-keep storage: the value still - # routes exactly as before, and get_params round-trips it. - if covariates is not None: - warn_deprecated_kwarg( - type(self).__name__, - "covariates", - "pass covariates to fit() instead", - ) - self.covariates = covariates self.estimation_method = estimation_method self.pscore_trim = pscore_trim self.epv_threshold = epv_threshold @@ -358,17 +336,6 @@ def _validate_constrained_params(self) -> None: f"Invalid treatment_type: '{self.treatment_type}'. " f"Must be one of {self._VALID_TREATMENT_TYPES}." ) - if self.control_group == "lowest_dose" and self.covariates is not None: - # The covariate estimand under lowest-dose-as-control shifts to - # conditional PT *relative to d_L* (E[ΔY(0)|D=d,X] = E[ΔY(0)|d_L,X]). - # Deferred (see the TODO.md ContinuousDiD row) rather than silently estimated with the wrong - # identifying assumption. - raise NotImplementedError( - "control_group='lowest_dose' does not yet compose with covariates= " - "(the conditional-parallel-trends estimand relative to the lowest " - "dose d_L is deferred). Use covariates=None for the unconditional " - "lowest-dose fit." - ) # get_params/set_params come from BaseEstimator. @@ -422,10 +389,14 @@ def fit( variance with strata, PSU, and FPC. covariates : list of str, optional - Covariate column names for the conditional-parallel-trends - estimand (the canonical fit-level home - row M-084). The - deprecated constructor ``covariates=`` still routes and warns; - supplying both raises ``ValueError``. + Column names of covariates for **conditional** parallel trends + (``E[ΔY(0) | D=d, X] = E[ΔY(0) | D=0, X]``). When ``None`` + (default) the estimator uses unconditional parallel trends. + Covariates enter through a covariate-adjusted per-cell control + counterfactual (see ``estimation_method``) and are read from the + base period of each ``(g, t)`` comparison. Not currently + composable with ``survey_design=`` (raises + ``NotImplementedError``) or ``control_group="lowest_dose"``. Returns ------- @@ -482,17 +453,14 @@ def fit( # Bootstrap + survey supported via PSU-level multiplier bootstrap. - # M-084: fit-time covariates= is the canonical home; the deprecated - # constructor spec still routes (raw-keep). Supplying both is - # ambiguous and fails loudly. - if covariates is not None and self.covariates is not None: - raise ValueError( - "covariates= was supplied both to the constructor " - "(deprecated, row M-084) and to fit(); pass it to fit() only." - ) - effective_covariates = covariates if covariates is not None else self.covariates + # covariates= is a fit-time (data-side) spec per the sklearn + # hyperparameter/data split. + effective_covariates = covariates if self.control_group == "lowest_dose" and effective_covariates is not None: - # Mirror of the constructor-time guard for the fit-level spec. + # The covariate estimand under lowest-dose-as-control shifts to + # conditional PT *relative to d_L* (E[ΔY(0)|D=d,X] = E[ΔY(0)|d_L,X]). + # Deferred (see the TODO.md ContinuousDiD row) rather than silently + # estimated with the wrong identifying assumption. raise NotImplementedError( "control_group='lowest_dose' does not yet compose with covariates= " "(the conditional-parallel-trends estimand relative to the lowest " diff --git a/diff_diff/diagnostics.py b/diff_diff/diagnostics.py index 7fd329b57..72cc06619 100644 --- a/diff_diff/diagnostics.py +++ b/diff_diff/diagnostics.py @@ -17,7 +17,6 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg from diff_diff.estimators import DifferenceInDifferences from diff_diff.results import _get_significance_stars from diff_diff.results_base import Diagnostic, _coverage_pct @@ -574,12 +573,11 @@ def permutation_test( data: pd.DataFrame, outcome: str, treatment: str, - post: Any = NOT_SUPPLIED, - unit: Any = NOT_SUPPLIED, + post: str, + unit: str, n_permutations: int = 1000, alpha: float = 0.05, seed: Optional[int] = None, - time: Any = NOT_SUPPLIED, **estimator_kwargs, ) -> PlaceboTestResults: """ @@ -602,8 +600,7 @@ def permutation_test( treatment : str Treatment indicator column. post : str - Post-period indicator column (0/1). (``time`` is a deprecated - alias; warns with ``FutureWarning`` and will be removed in 4.0.) + Post-period indicator column (0/1). unit : str Unit identifier column. n_permutations : int, default=1000 @@ -638,11 +635,6 @@ def permutation_test( # First, fit original model did = DifferenceInDifferences(**estimator_kwargs) - post = resolve_renamed_kwarg( - "permutation_test", "time", time, "post", post, default=NOT_SUPPLIED - ) - require_arg("permutation_test", "post", post) - require_arg("permutation_test", "unit", unit) time = post # body-local name; the public parameter is post original_results = did.fit(data, outcome=outcome, treatment=treatment, post=time) original_att = original_results.att @@ -736,10 +728,9 @@ def leave_one_out_test( data: pd.DataFrame, outcome: str, treatment: str, - post: Any = NOT_SUPPLIED, - unit: Any = NOT_SUPPLIED, + post: str, + unit: str, alpha: float = 0.05, - time: Any = NOT_SUPPLIED, **estimator_kwargs, ) -> PlaceboTestResults: """ @@ -757,8 +748,7 @@ def leave_one_out_test( treatment : str Treatment indicator column. post : str - Post-period indicator column (0/1). (``time`` is a deprecated - alias; warns with ``FutureWarning`` and will be removed in 4.0.) + Post-period indicator column (0/1). unit : str Unit identifier column. alpha : float, default=0.05 @@ -773,11 +763,6 @@ def leave_one_out_test( """ # Fit original model did = DifferenceInDifferences(**estimator_kwargs) - post = resolve_renamed_kwarg( - "leave_one_out_test", "time", time, "post", post, default=NOT_SUPPLIED - ) - require_arg("leave_one_out_test", "post", post) - require_arg("leave_one_out_test", "unit", unit) time = post # body-local name; the public parameter is post original_results = did.fit(data, outcome=outcome, treatment=treatment, post=time) original_att = original_results.att diff --git a/diff_diff/estimators.py b/diff_diff/estimators.py index 397cd2b71..c04a74696 100644 --- a/diff_diff/estimators.py +++ b/diff_diff/estimators.py @@ -20,11 +20,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import ( - NOT_SUPPLIED, - resolve_renamed_kwarg, - warn_deprecated_kwarg, -) from diff_diff.linalg import ( LinearRegression, _absorbed_fe_vcov_scale, @@ -89,20 +84,13 @@ class DifferenceInDifferences(BaseEstimator): formula : str, optional R-style formula for the model (e.g., "outcome ~ treated * post"). If provided, overrides column name parameters. - robust : bool, optional - DEPRECATED legacy alias for ``vcov_type`` (row M-045; warns with - ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). - ``robust=True`` maps to ``vcov_type="hc1"``; ``robust=False`` maps - to ``vcov_type="classical"``. Explicit ``vcov_type`` overrides - ``robust`` unless the pair is contradictory (e.g. - ``robust=False, vcov_type="hc2"`` raises). cluster : str, optional Column name for cluster-robust standard errors. Combined with ``vcov_type``: with ``"hc1"`` dispatches to CR1 (Liang-Zeger); with ``"hc2_bm"`` dispatches to CR2 Bell-McCaffrey (Pustejovsky-Tipton 2018 symmetric-sqrt + Satterthwaite DOF). vcov_type : {"classical", "hc1", "hc2", "hc2_bm", "hc3", "conley"}, optional - Variance-covariance family. Defaults to the ``robust`` alias. + Variance-covariance family. Defaults to ``"hc1"``. - ``"classical"``: non-robust OLS SEs, ``sigma_hat^2 * (X'X)^{-1}``. - ``"hc1"``: heteroskedasticity-robust HC1 with ``n/(n-k)`` adjustment @@ -253,7 +241,6 @@ class DifferenceInDifferences(BaseEstimator): def __init__( self, - robust: Optional[bool] = None, cluster: Optional[str] = None, vcov_type: Optional[str] = None, alpha: float = 0.05, @@ -270,8 +257,8 @@ def __init__( conley_lag_cutoff: Optional[int] = None, df_convention: str = "residual", ): - # Resolve vcov_type from the legacy `robust` alias via the shared - # helper so __init__ and set_params use identical validation logic. + # Resolve/validate vcov_type via the shared helper so __init__ and + # set_params use identical validation logic. from diff_diff.linalg import resolve_vcov_type validate_df_convention(df_convention) @@ -283,24 +270,14 @@ def __init__( if not isinstance(inference, str) or inference not in _INFERENCE_METHODS: raise ValueError(f"inference must be one of {_INFERENCE_METHODS}, got {inference!r}") - # `robust` is deprecated (rows M-045..M-047; removed in 4.0). None is - # the not-supplied sentinel: default constructions and get_params - # round-trips stay silent, only an explicit robust= warns. The raw - # arg lives at `_robust_arg` (what get_params returns); the PUBLIC - # `self.robust` keeps the RESOLVED legacy bool so pre-3.9 attribute - # readers keep seeing True/False until the 4.0 removal. - if robust is not None: - warn_deprecated_kwarg(type(self).__name__, "robust", "use vcov_type= instead") - self._robust_arg = robust - self.robust = robust if robust is not None else True self.cluster = cluster - self.vcov_type = resolve_vcov_type(robust, vcov_type) + self.vcov_type = resolve_vcov_type(vcov_type) # Preserve the raw constructor arg (possibly None) alongside the # resolved `vcov_type`. `get_params()` returns the raw arg so # sklearn clones preserve the implicit-vs-explicit distinction - # (and therefore the backward-compat remap). Set only in __init__ - # and updated in ``set_params`` so the flag transitions match the - # user-visible parameter state. + # (read by the TWFE/SA/Wooldridge auto-cluster carve-outs). Set only + # in __init__ and updated in ``set_params`` so the flag transitions + # match the user-visible parameter state. self._vcov_type_arg = vcov_type self._vcov_type_explicit = vcov_type is not None self.alpha = alpha @@ -341,14 +318,13 @@ def fit( data: pd.DataFrame, outcome: Optional[str] = None, treatment: Optional[str] = None, - post: Any = NOT_SUPPLIED, + post: Optional[str] = None, formula: Optional[str] = None, covariates: Optional[List[str]] = None, fixed_effects: Optional[List[str]] = None, absorb: Optional[List[str]] = None, survey_design=None, unit: Optional[str] = None, - time: Any = NOT_SUPPLIED, ) -> DiDResults: """ Fit the Difference-in-Differences model. @@ -419,15 +395,9 @@ def fit( >>> did.fit(data, outcome='sales', treatment='treated', post='post', ... absorb=['firm_id']) - - The keyword-only ``time`` parameter is a deprecated alias for - ``post`` (row M-030); it warns with ``FutureWarning`` and will be - removed in 4.0. """ - post = resolve_renamed_kwarg( - f"{type(self).__name__}.fit", "time", time, "post", post, default=None - ) - # Body-local name; the public parameter is post (M-030). + # Body-local name for the post-period indicator column; the public + # parameter is `post`. time = post # Per-fit bootstrap state: cleared up front so the result builder # labels inference from THIS fit only. Without the reset, a wild fit @@ -725,13 +695,10 @@ def fit( if _uses_replicate and absorbed_vars: _lr_survey = None - # Remap implicit "classical" + cluster to CR1 for legacy-alias - # backward compatibility (see `_resolve_effective_vcov_type`). - _fit_vcov_type = ( - "hc1" - if _replicate_vcov_remap - else self._resolve_effective_vcov_type(effective_cluster_ids) - ) + # Replicate-weight survey designs replace the analytical sandwich, + # so the base fit always uses "hc1" there (see + # `_warn_replicate_vcov_ignored`); otherwise the configured family. + _fit_vcov_type = "hc1" if _replicate_vcov_remap else self.vcov_type # Build Conley coord/time/unit arrays when applicable. CRITICAL: # read from the ORIGINAL `data` frame, NOT `working_data` — `absorb` @@ -802,10 +769,8 @@ def fit( weights=survey_weights, ) - # Don't forward `robust=self.robust` when the vcov_type has been - # remapped; `robust=False + vcov_type="hc1"` would otherwise trip - # the conflict check inside `LinearRegression.__init__`. The - # remapped vcov_type is the single source of truth for this call. + # The fit-time vcov_type (possibly remapped to "hc1" under a + # replicate-weight design) is the single source of truth for this call. reg = LinearRegression( include_intercept=False, # Intercept already in X cluster_ids=effective_cluster_ids if self.inference != "wild_bootstrap" else None, @@ -993,8 +958,8 @@ def _refit_did_absorb(w_r): p_val_type=p_val_type_used, survey_metadata=survey_metadata, # Report the family that actually produced the SE, which may be - # the remapped "hc1" (CR1) under the legacy alias path, not the - # stored `self.vcov_type`. + # the remapped "hc1" under a replicate-weight survey design, not + # the stored `self.vcov_type`. vcov_type=_fit_vcov_type, cluster_name=self.cluster, conley_lag_cutoff=(self.conley_lag_cutoff if _fit_vcov_type == "conley" else None), @@ -1281,29 +1246,19 @@ def predict(self, data: pd.DataFrame) -> np.ndarray: # get_params/set_params come from BaseEstimator. `vcov_type`'s RAW arg # lives at `_vcov_type_arg` (the resolved value at `vcov_type`), and # get_params must return the raw one: a clone of - # `DifferenceInDifferences(robust=False, cluster="unit")` must behave - # the same as the original on a clustered fit, which requires the - # clone's `__init__` to see `vcov_type=None` (flagging - # `_vcov_type_explicit=False`) rather than the alias-resolved - # "classical" (which would mark it explicit and skip the CR1 remap). - _PARAM_ATTR_ALIASES = {"vcov_type": "_vcov_type_arg", "robust": "_robust_arg"} + # `DifferenceInDifferences(cluster="unit")` must behave the same as the + # original, which requires the clone's `__init__` to see + # `vcov_type=None` (flagging `_vcov_type_explicit=False`, read by the + # TWFE/SA/Wooldridge auto-cluster carve-outs) rather than the resolved + # "hc1" (which would mark it explicit). SyntheticDiD's probe re-init + # relies on the same alias. + _PARAM_ATTR_ALIASES = {"vcov_type": "_vcov_type_arg"} _DERIVED_CONFIG_ATTRS = ( "vcov_type", "_vcov_type_arg", "_vcov_type_explicit", - "robust", ) - @classmethod - def _normalize_set_params(cls, params: Dict[str, Any]) -> Dict[str, Any]: - # `robust=` alone re-derives `vcov_type` from the alias: the merged - # probe config must carry vcov_type=None so `resolve_vcov_type` - # re-runs on the new `robust` value instead of seeing a conflict - # with the previously stored raw vcov_type. - if "robust" in params and "vcov_type" not in params: - params["vcov_type"] = None - return params - def _warn_replicate_vcov_ignored(self, stacklevel: int = 3) -> bool: """Warn that an explicit ``vcov_type`` has no effect under a replicate-weight survey design, and tell the caller to remap the @@ -1343,45 +1298,6 @@ def _warn_replicate_vcov_ignored(self, stacklevel: int = 3) -> bool: ) return True - def _resolve_effective_vcov_type(self, effective_cluster_ids, stacklevel: int = 3) -> str: - """Pick the ``vcov_type`` to use for a given fit given cluster context. - - Returns ``self.vcov_type`` unchanged in nearly every case. The one - exception is the legacy-alias path: if the user supplied - ``robust=False`` (or nothing) without an explicit ``vcov_type=``, - ``resolve_vcov_type`` stored ``"classical"`` at ``__init__``. - But ``"classical"`` is one-way only and the linalg validator - rejects it with ``cluster_ids`` set, so calls like - ``DifferenceInDifferences(robust=False, cluster="unit")`` that - previously produced CR1 inference would now fail. To preserve that - contract, when the stored vcov_type is implicit ``"classical"`` - and a cluster structure is present at fit time, remap to ``"hc1"`` - (which dispatches to CR1 cluster-robust). Emit a UserWarning so - the remap is not silent. - - Callers should always route ``vcov_type`` through this method - before passing it into ``solve_ols``/``compute_robust_vcov`` so - subclasses (and survey-PSU-injected cluster ids) get the same - backward-compatible treatment. - """ - if ( - self.vcov_type == "classical" - and not self._vcov_type_explicit - and effective_cluster_ids is not None - ): - warnings.warn( - "robust=False with cluster=... (or an auto-injected " - "cluster from survey/TWFE) now maps to vcov_type='hc1' " - "to preserve the legacy CR1 cluster-robust behavior. " - "Pass vcov_type='classical' explicitly to request " - "non-robust SEs, or vcov_type='hc1' to silence this " - "warning.", - UserWarning, - stacklevel=stacklevel, - ) - return "hc1" - return self.vcov_type - def summary(self) -> str: """ Get summary of estimation results. @@ -1901,14 +1817,10 @@ def _fit_event_study_core( # Determine if survey vcov should be used _use_survey_vcov = resolved_survey is not None and resolved_survey.needs_survey_vcov - # Remap implicit "classical" + cluster to CR1 (legacy backward compat). - _fit_vcov_type = ( - "hc1" - if _replicate_vcov_remap_mp - else self._resolve_effective_vcov_type( - effective_cluster_ids, stacklevel=3 + _frame_offset - ) - ) + # Replicate-weight survey designs replace the analytical sandwich, + # so the base fit always uses "hc1" there; otherwise the configured + # family. + _fit_vcov_type = "hc1" if _replicate_vcov_remap_mp else self.vcov_type # Cluster + CR2 Bell-McCaffrey (non-survey, unweighted) shares the SAME # expensive CR2 precomputes (per-cluster A_g eigendecompositions, S_W, @@ -2231,12 +2143,10 @@ def _refit_mp_absorb(w_r): ) df = None - # Note: the prior homoskedastic-vcov fallback conditioned on - # `not self.robust` has been subsumed by the vcov_type dispatch in - # solve_ols above, which routes vcov_type="classical" through - # compute_robust_vcov's classical branch (identical math). The - # explicit branch is no longer needed; vcov above already matches the - # requested variance family. + # Note: there is no separate homoskedastic-vcov branch here; the + # vcov_type dispatch in solve_ols above routes vcov_type="classical" + # through compute_robust_vcov's classical branch (identical math), so + # vcov above already matches the requested variance family. # For hc2_bm with a non-survey fit, compute per-coefficient and # per-contrast Bell-McCaffrey Satterthwaite DOF so period-specific @@ -2455,7 +2365,7 @@ def _refit_mp_absorb(w_r): interaction_indices=interaction_indices, survey_metadata=survey_metadata, # Report the family that actually produced the SE; may be the - # remapped hc1 under the legacy alias path, not self.vcov_type. + # remapped hc1 under a replicate-weight survey design. vcov_type=_fit_vcov_type, cluster_name=cluster_resolved, n_clusters=( @@ -2493,13 +2403,6 @@ class MultiPeriodDiD(DifferenceInDifferences): Parameters ---------- - robust : bool, optional - DEPRECATED legacy alias for ``vcov_type`` (row M-045; warns with - ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). - ``robust=True`` maps to ``vcov_type="hc1"``; ``robust=False`` maps - to ``vcov_type="classical"``. Explicit ``vcov_type`` overrides - ``robust`` unless the pair is contradictory (e.g. - ``robust=False, vcov_type="hc2"`` raises). cluster : str, optional Column name for cluster-robust standard errors. With ``vcov_type="hc1"`` dispatches to CR1 (Liang-Zeger). With ``vcov_type="hc2_bm"`` dispatches @@ -2510,7 +2413,7 @@ class MultiPeriodDiD(DifferenceInDifferences): ``Wald_test(test="HTZ")$df_denom`` at atol=1e-10). Weighted CR2-BM (``survey_design=``) is a separate, still-gated path. vcov_type : {"classical", "hc1", "hc2", "hc2_bm", "hc3", "conley"}, optional - Variance-covariance family. Defaults to the ``robust`` alias. + Variance-covariance family. Defaults to ``"hc1"``. - ``"classical"``: non-robust OLS SEs, ``sigma_hat^2 * (X'X)^{-1}``. - ``"hc1"``: heteroskedasticity-robust HC1 with ``n/(n-k)`` adjustment diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index bc37e01e7..1be5a55fe 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -1202,17 +1202,17 @@ close to zero support parallel trends. `BusinessReport(results)` and `DiagnosticReport(results)` are experimental in the 3.2 line. Their schema is versioned (`BUSINESS_REPORT_SCHEMA_VERSION` -and `DIAGNOSTIC_REPORT_SCHEMA_VERSION`, both `"2.0"` at time of writing) +and `DIAGNOSTIC_REPORT_SCHEMA_VERSION`, `"3.0"` and `"2.0"` respectively at time of writing) and expected to evolve. Treat `.to_dict()` output as the agent-legible contract; the prose renderers (`summary()`, `full_report()`) are derived from it. -### BusinessReport `to_dict()` schema (v2.0) +### BusinessReport `to_dict()` schema (v3.0) Top-level keys emitted by `BusinessReport.to_dict()` (source: `diff_diff/business_report.py`): -- `schema_version: str` - `BUSINESS_REPORT_SCHEMA_VERSION`, e.g. `"2.0"`. +- `schema_version: str` - `BUSINESS_REPORT_SCHEMA_VERSION`, e.g. `"3.0"`. - `estimator: dict` - `class_name` (the fitted result class) and a human-friendly `display_name`. - `context: dict` - the `BusinessContext` bundle: `outcome_label`, diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 45f807c58..b6e376047 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -29,7 +29,7 @@ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})") - **sklearn-like API**: All estimators use `fit()` method, `get_params()`/`set_params()` for configuration. - **Formula interface**: Supports R-style formulas like `"outcome ~ treated * post"`. - **Results objects**: Rich dataclass containers with `summary()`, `to_dict()`, `to_dataframe()`. -- **Estimator aliases**: Short names available (e.g., `DiD`, `CS`, `SA`, `BJS`, `SDiD`, `TWFE`, `DDD`, `EDiD`, `SCM`, `Bacon`). The former `CDiD`/`Gardner`/`Stacked` aliases are deprecated (3.9, removed in 4.0) - use `ContinuousDiD`/`TwoStageDiD`/`StackedDiD`. +- **Estimator aliases**: Short names available (e.g., `DiD`, `CS`, `SA`, `BJS`, `SDiD`, `TWFE`, `DDD`, `EDiD`, `SCM`, `Bacon`). The former `CDiD`/`Gardner`/`Stacked` aliases were removed in 4.0 - use `ContinuousDiD`/`TwoStageDiD`/`StackedDiD`. ## Practitioner Workflow (based on Baker et al. 2025) @@ -81,7 +81,7 @@ did.fit( ) -> DiDResults ``` -`time=` remains accepted as a deprecated alias for `post=` (FutureWarning; removed in 4.0), and the `robust=` constructor flag as a deprecated alias for `vcov_type=`. +The pre-4.0 `time=` alias for `post=` and the `robust=` constructor flag (alias for `vcov_type=`) were removed in 4.0: `fit()` takes `post=` and the constructor takes `vcov_type=` only. **Usage:** @@ -106,7 +106,6 @@ Two-Way Fixed Effects estimator for panel data. Inherits from DifferenceInDiffer ```python TwoWayFixedEffects( - robust: bool = True, cluster: str | None = None, # Auto-clusters at unit level if None alpha: float = 0.05, df_convention: str = "residual", # Analytical t/p/CI df: "residual" (default), "cluster" (G-1), or "normal" (z); flips at v4 @@ -162,7 +161,6 @@ period-specific treatment effects. Inherits from DifferenceInDifferences. ```python MultiPeriodDiD( - robust: bool = True, cluster: str | None = None, alpha: float = 0.05, df_convention: str = "residual", # Analytical t/p/CI df: "residual" (default), "cluster" (G-1), or "normal" (z); flips at v4 @@ -499,7 +497,7 @@ TwoStageDiD( ) ``` -**Alias:** `Gardner` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `TwoStageDiD`) +**Alias:** none (the pre-4.0 `Gardner` alias was removed in 4.0; use `TwoStageDiD`) **fit() parameters:** @@ -755,7 +753,6 @@ ContinuousDiD( bootstrap_weights: str = "rademacher", seed: int | None = None, rank_deficient_action: str = "warn", - covariates: list[str] | None = None, # DEPRECATED here (M-084; removed in 4.0) — pass covariates= to fit() instead estimation_method: str = "dr", # "reg" or "dr" (used only with covariates); "ipw" not # supported on the dose curve (raises NotImplementedError) pscore_trim: float = 0.01, # dr propensity trimming @@ -779,7 +776,7 @@ is the paper's backward finite difference on `{0, d_1, …, d_J}` (`ACRT(d_1) = covariate / survey) and reduces to the per-level 2×2 DiD SE. Multi-cohort fits need a shared dose support across cohorts (else `NotImplementedError`); an off-support `dvals` value raises `ValueError`. -**Alias:** `CDiD` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `ContinuousDiD`) +**Alias:** none (the pre-4.0 `CDiD` alias was removed in 4.0; use `ContinuousDiD`) **fit() parameters:** @@ -829,7 +826,6 @@ HeterogeneousAdoptionDiD( kernel: str = "epanechnikov", # Local-linear kernel alpha: float = 0.05, vcov_type: str | None = None, # Mass-point only: "classical" (default) or "hc1" - robust: bool | None = None, # DEPRECATED alias for vcov_type= (M-047; FutureWarning, removed in 4.0) cluster: str | None = None, # Cluster column for cluster-robust SEs (all designs; event-study adds a cluster-robust sup-t band). cluster= + survey_design= rejected n_bootstrap: int = 999, # Multiplier-bootstrap iterations for sup-t bands (event-study, when weighted/survey OR clustered) seed: int | None = None, @@ -854,7 +850,6 @@ had.fit( 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 a multi-period panel (the event-study mode); 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 ``` @@ -935,7 +930,7 @@ rd.fit( ) -> RegressionDiscontinuityResults ``` -The 3.8 names `outcome_col=`, `running_col=`, and `treatment_col=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). +The 3.8 names `outcome_col=`, `running_col=`, and `treatment_col=` were removed in 4.0 - use `outcome=`, `running=`, `takeup=`. ```python from diff_diff import RegressionDiscontinuity @@ -1014,7 +1009,7 @@ rp.fit( ) -> RDPlotResult ``` -The 3.8 names `outcome_col=` and `running_col=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). +The 3.8 names `outcome_col=` and `running_col=` were removed in 4.0 - use `outcome=` and `running=`. ```python from diff_diff import RDPlot @@ -1102,7 +1097,6 @@ StackedDiD( kappa_post: int = 1, # Post-treatment event-time periods weighting: str = "aggregate", # "aggregate", "population", or "sample_share" control_group: str = "not_yet_treated", # "not_yet_treated", "strict", or "never_treated" - clean_control: str | None = None, # DEPRECATED alias for control_group= (M-043; FutureWarning, removed in 4.0) cluster: str = "unit", # "unit" or "unit_subexp" alpha: float = 0.05, anticipation: int = 0, # non-negative integer; bool rejected @@ -1113,7 +1107,7 @@ StackedDiD( ) ``` -**Alias:** `Stacked` (deprecated 3.9, removed 4.0 - emits FutureWarning; use `StackedDiD`) +**Alias:** none (the pre-4.0 `Stacked` alias was removed in 4.0; use `StackedDiD`) **fit() parameters:** @@ -1758,7 +1752,7 @@ etwfe.fit( ) -> WooldridgeDiDResults ``` -The 3.8 name `cohort=` remains accepted as a deprecated alias for `first_treat=` (FutureWarning; removed in 4.0). +The 3.8 name `cohort=` was removed in 4.0 - use `first_treat=`. **Aggregation types:** @@ -1791,23 +1785,6 @@ etwfe_pois = WooldridgeDiD(method='poisson') results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', first_treat='first_treat') ``` -### Convenience Functions (deprecated 3.9, removed 4.0) - -All 8 wrapper functions (`imputation_did`, `two_stage_did`, `stacked_did`, `trop`, `synthetic_control`, `triple_difference`, `bacon_decompose`, `chaisemartin_dhaultfoeuille`) emit a `FutureWarning` since 3.9 and are removed in 4.0 (rows M-070..M-077) - construct the class instead: - -```python -# Canonical class form (what the wrappers did internally) -from diff_diff import ImputationDiD, TwoStageDiD, TripleDifference, StackedDiD, TROP, BaconDecomposition - -results = ImputationDiD().fit(data, outcome='y', unit='id', time='t', first_treat='ft') -results = TwoStageDiD().fit(data, outcome='y', unit='id', time='t', first_treat='ft') -results = TripleDifference().fit(data, outcome='y', group='g', partition='p', post='t') -results = StackedDiD(kappa_pre=2, kappa_post=2).fit(data, outcome='y', unit='id', time='t', - first_treat='ft') -results = TROP().fit(data, 'y', 'd', 'id', 't') -results = BaconDecomposition().fit(data, outcome='y', unit='id', time='t', first_treat='ft') -``` - ## Results Objects **Flat-alias compatibility note.** Every staggered result class in this @@ -1990,7 +1967,7 @@ Returned by `SyntheticDiD.fit()`. | `pre_periods` | `list` | Pre-treatment periods | | `post_periods` | `list` | Post-treatment periods | | `variance_method` | `str` | "bootstrap", "jackknife", or "placebo" | -| `variance_effects` | `np.ndarray` | Per-iteration draws (placebo effects, bootstrap ATT draws, or jackknife LOO estimates per `variance_method`); deprecated alias `placebo_effects` (removed v4.0.0) | +| `variance_effects` | `np.ndarray` | Per-iteration draws (placebo effects, bootstrap ATT draws, or jackknife LOO estimates per `variance_method`); the pre-4.0 alias `placebo_effects` was removed in 4.0 | | `noise_level` | `float` | Estimated noise level | | `zeta_omega` | `float` | Unit weight regularization | | `zeta_lambda` | `float` | Time weight regularization | @@ -2061,7 +2038,7 @@ Returned by `TripleDifference.fit()`. ### BaconDecompositionResults -Returned by `BaconDecomposition.fit()` (and the deprecated `bacon_decompose()` wrapper, removed in 4.0). +Returned by `BaconDecomposition.fit()` (the `bacon_decompose()` wrapper was removed in 4.0). | Attribute | Type | Description | |-----------|------|-------------| @@ -2272,7 +2249,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD`'s event ### ChangesInChangesResults -Results container shared by `ChangesInChanges` and `QDiD` (the `method` field is `"cic"` or `"qdid"`; the pre-3.9 name `estimator` still reads it with a FutureWarning until 4.0; `QDiDResults` is an alias of this class). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). +Results container shared by `ChangesInChanges` and `QDiD` (the `method` field is `"cic"` or `"qdid"`; the pre-3.9 name `estimator` was removed in 4.0; `QDiDResults` is an alias of this class). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). ### LWDiDResults @@ -2347,7 +2324,7 @@ permutation_test(data, outcome, treatment, post, unit, leave_one_out_test(data, outcome, treatment, post, unit, alpha=0.05) ``` -`permutation_test` / `leave_one_out_test` accept `time=` as a deprecated alias for `post=` (FutureWarning; removed in 4.0). The `run_placebo_test` / `run_all_placebo_tests` wrappers keep their `time` parameter. +`permutation_test` / `leave_one_out_test` take `post=` (the pre-4.0 `time=` alias was removed in 4.0). The `run_placebo_test` / `run_all_placebo_tests` wrappers keep their `time` parameter. All return `PlaceboTestResults` with attributes: `test_type`, `placebo_effect`, `se`, `t_stat`, `p_value`, `conf_int`, `n_obs`, `is_significant`. diff --git a/diff_diff/had.py b/diff_diff/had.py index 8fb82477c..8e21c467c 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -73,12 +73,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import ( - NOT_SUPPLIED, - require_arg, - resolve_renamed_kwarg, - warn_deprecated_kwarg, -) +from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationMixin, AggregationResult, resolve_inference_df from diff_diff.bootstrap_chunking import ( compute_block_size, @@ -402,7 +397,7 @@ class HeterogeneousAdoptionDiDResults(BaseResults, AggregationMixin): ``"survey_binder_tsl"`` (continuous, Binder 1983 TSL with PSU/strata/FPC under ``survey_design=SurveyDesign(...)``) or ``"survey_binder_tsl_2sls"`` (mass-point, Binder 1983 TSL under ``survey_design=``; requires - ``vcov_type="hc1"`` / ``robust=True`` — the mass-point survey path rejects + ``vcov_type="hc1"`` — the mass-point survey path rejects ``vcov_type="classical"``, and ``cluster=`` + ``survey_design=`` is rejected, so PSU clustering is expressed via ``SurveyDesign(weights='', psu='')``). @@ -542,7 +537,7 @@ def to_dict(self) -> Dict[str, Any]: ``"survey_binder_tsl"`` (continuous, Binder 1983 TSL under ``survey_design=``) or ``"survey_binder_tsl_2sls"`` (mass-point, Binder 1983 TSL under ``survey_design=``; requires - ``vcov_type="hc1"`` / ``robust=True`` — the mass-point survey path + ``vcov_type="hc1"`` — the mass-point survey path rejects ``vcov_type="classical"``). See the field docstring above for the full contract. - ``effective_dose_mean``: weighted denominator used by the @@ -2846,21 +2841,13 @@ class HeterogeneousAdoptionDiD(BaseEstimator): alpha : float CI level (0.05 for 95% CI). vcov_type : {"classical", "hc1"} or None - Mass-point-path only. When ``None``, the effective family falls - back to the ``robust`` flag: ``robust=True`` -> ``"hc1"``, - ``robust=False`` -> ``"classical"`` (the default construction). - Explicit ``"hc2"`` and ``"hc2_bm"`` raise ``NotImplementedError`` - pending a 2SLS-specific leverage derivation. Ignored on the - continuous paths (which use the CCT-2014 robust SE from - Phase 1c); passing a non-default ``vcov_type`` on a continuous - path emits a ``UserWarning`` per fit call. - robust : bool, optional - DEPRECATED backward-compat alias (row M-047; warns with - ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). Used - only when ``vcov_type is None``: ``True`` -> ``"hc1"``, ``False`` - (the historical HAD default) -> ``"classical"``. Explicit - ``vcov_type`` takes precedence. Only the mass-point path consumes - these; continuous paths ignore both with a warning. + Mass-point-path only. When ``None`` (the default construction), + the effective family is ``"classical"``. Explicit ``"hc2"`` and + ``"hc2_bm"`` raise ``NotImplementedError`` pending a 2SLS-specific + leverage derivation. Ignored on the continuous paths (which use + the CCT-2014 robust SE from Phase 1c); passing a non-default + ``vcov_type`` on a continuous path emits a ``UserWarning`` per fit + call. cluster : str or None Column name for cluster-robust SE. On the mass-point path this is the 2SLS CR1 sandwich; on the continuous (``continuous_at_zero`` / @@ -2965,9 +2952,6 @@ class HeterogeneousAdoptionDiD(BaseEstimator): 'continuous_at_zero' """ - _PARAM_ATTR_ALIASES = {"robust": "_robust_arg"} - _DERIVED_CONFIG_ATTRS = ("robust",) - def __init__( self, design: str = "auto", @@ -2975,7 +2959,6 @@ def __init__( kernel: str = "epanechnikov", alpha: float = 0.05, vcov_type: Optional[str] = None, - robust: Optional[bool] = None, cluster: Optional[str] = None, n_bootstrap: int = 999, seed: Optional[int] = None, @@ -2985,13 +2968,6 @@ def __init__( self.kernel = kernel self.alpha = alpha self.vcov_type = vcov_type - # `robust` is deprecated (row M-047; removed in 4.0). HAD's historical - # default is False (the third meaning of the knob) - the None sentinel - # resolves to that legacy default on the public attr. - if robust is not None: - warn_deprecated_kwarg(type(self).__name__, "robust", "use vcov_type= instead") - self._robust_arg = robust - self.robust = robust if robust is not None else False self.cluster = cluster # Event-study sup-t simultaneous-CI support. ``n_bootstrap`` = # number of multiplier-bootstrap replicates for the sup-t band; @@ -3066,22 +3042,17 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome: Any = NOT_SUPPLIED, - dose: Any = NOT_SUPPLIED, - time: Any = NOT_SUPPLIED, - unit: Any = NOT_SUPPLIED, - first_treat: Any = NOT_SUPPLIED, + outcome: str, + dose: str, + time: str, + unit: str, + first_treat: Optional[str] = None, aggregate: Any = NOT_SUPPLIED, *, 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. @@ -3229,21 +3200,13 @@ 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" # M-027 deprecation shim: a plain fit() never warns; supplying # aggregate= with ANY value (None included) warns once, then the # legacy routing below runs unchanged - invalid values still # reach the pre-existing _VALID_AGGREGATES ValueError. The - # SENTINEL resolves to a panel-inferred mode, but only AFTER the - # time/time_col alias reconciliation below (both spellings are - # NOT_SUPPLIED twins until then - rows M-035..M-039), at the - # _VALID_AGGREGATES check site. + # SENTINEL resolves to a panel-inferred mode at the + # _VALID_AGGREGATES check site below. if aggregate is not NOT_SUPPLIED: warnings.warn( "HeterogeneousAdoptionDiD.fit(aggregate=) is deprecated " @@ -3256,47 +3219,16 @@ def fit( FutureWarning, stacklevel=2, ) - 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). + # Body-local ``*_col`` names feed the private helpers; the public + # parameters are the bare names. outcome_col = outcome dose_col = dose time_col = time unit_col = unit first_treat_col = first_treat # ---- aggregate / survey_design validation ---- - # Sentinel resolution runs HERE - after the alias reconciliation - # above (time_col is now the resolved column name) - so both the - # canonical time= and legacy time_col= spellings reach the - # inference helper resolved (M-027). + # Sentinel resolution (M-027): infer the mode from the panel shape + # when aggregate= was not supplied. if aggregate is NOT_SUPPLIED: aggregate = _infer_aggregate_mode(data, time_col) elif aggregate not in _VALID_AGGREGATES: @@ -3407,7 +3339,6 @@ def fit( design_arg = self.design d_lower_arg = self.d_lower vcov_type_arg = self.vcov_type - robust_arg = self.robust cluster_arg = self.cluster # ---- Validate panel contract ---- @@ -3804,16 +3735,6 @@ def fit( UserWarning, stacklevel=2, ) - if robust_arg: - warnings.warn( - f"robust=True is ignored on the '{resolved_design}' " - f"path (the continuous designs use the CCT-2014 " - f"robust SE from Phase 1c unconditionally; the " - f"robust flag is a mass-point-only backward-compat " - f"alias for vcov_type).", - UserWarning, - stacklevel=2, - ) # (cluster= + survey_design= was rejected up front, before cluster # extraction — see the guard in the design-resolution block above.) # Fit on FULL (unfiltered) arrays so the IF aligns with the @@ -3863,16 +3784,15 @@ def fit( f"cluster-robust + survey inference is deferred to a " f"follow-up PR." ) - # Resolve the EFFECTIVE vcov family first (vcov_type_arg, - # with default-mapping from robust= when unset). Reject the + # Resolve the EFFECTIVE vcov family first (vcov_type_arg, with + # HAD's implicit default "classical" when unset). Reject the # classical-on-IF-consumption combination against the # resolved value, NOT the raw kwarg, so the default - # `vcov_type=None, robust=False` case (which maps to - # classical) hits the guard too (review R5 P1 — previous - # fix only fired on explicit vcov_type='classical'). + # `vcov_type=None` case (which maps to classical) hits the + # guard too (review R5 P1 — previous fix only fired on + # explicit vcov_type='classical'). if vcov_type_arg is None: - # Backward-compat: robust=True -> hc1, robust=False -> classical. - vcov_requested = "hc1" if robust_arg else "classical" + vcov_requested = "classical" else: vcov_requested = vcov_type_arg.lower() # Review R3 P1 / R5 P1: the weighted mass-point path returns @@ -3887,14 +3807,13 @@ def fit( if vcov_requested == "classical" and resolved_survey_unit_full is not None: raise NotImplementedError( "vcov_type='classical' (resolved — either explicit or " - "from the default robust=False mapping) + survey_design= on " + "from the default vcov_type=None mapping) + survey_design= on " "design='mass_point' is not yet supported: the " "survey path composes Binder-TSL variance via the " "HC1-scale influence function, which targets V_HC1 " "rather than the classical sandwich " "V_cl = σ² · (Z'WX)^{-1}(Z'W²Z)(X'WZ)^{-1}. Use " - "vcov_type='hc1' (or leave vcov_type unset with " - "robust=True) on the weighted path; a classical-" + "vcov_type='hc1' on the weighted path; a classical-" "aligned IF derivation is queued for a follow-up PR." ) # Phase 4.5 B: accept weights_unit (None on unweighted fits). @@ -4247,7 +4166,6 @@ def _fit_event_study( design_arg = self.design d_lower_arg = self.d_lower vcov_type_arg = self.vcov_type - robust_arg = self.robust cluster_arg = self.cluster n_bootstrap_eff = int(self.n_bootstrap) seed_eff = None if self.seed is None else int(self.seed) @@ -4545,12 +4463,6 @@ def _fit_event_study( UserWarning, stacklevel=3, ) - if robust_arg: - warnings.warn( - f"robust=True is ignored on the '{resolved_design}' " f"path.", - UserWarning, - stacklevel=3, - ) # cluster= is now threaded into the continuous-path CCT SE and the # cluster-robust sup-t band (Phase 2b parity with the static path); # no "cluster ignored" warning. @@ -4559,10 +4471,9 @@ def _fit_event_study( if resolved_design == "mass_point": # Resolve the EFFECTIVE vcov family first (review R5 P1 — # previous fix only fired on explicit vcov_type='classical' - # and missed the default vcov_type=None, robust=False → - # 'classical' mapping). + # and missed the default vcov_type=None → 'classical' mapping). if vcov_type_arg is None: - vcov_requested = "hc1" if robust_arg else "classical" + vcov_requested = "classical" else: vcov_requested = vcov_type_arg.lower() # Review R3/R5 P1 (event-study arm): reject effective @@ -4581,7 +4492,7 @@ def _fit_event_study( if vcov_requested == "classical" and _uses_if_matrix: raise NotImplementedError( "vcov_type='classical' (resolved — either explicit " - "or from the default robust=False mapping) + " + "or from the default vcov_type=None mapping) + " "survey_design= on design='mass_point' event-study " "is not yet supported: the per-horizon IF matrix is " "HC1-scale (targets V_HC1 via " @@ -4590,8 +4501,7 @@ def _fit_event_study( "override would produce an inconsistent variance " "family. cband=False does not avoid this — the survey " "path consumes the HC1-scaled IF for the analytical SE " - "regardless. Use vcov_type='hc1' (or leave vcov_type " - "unset with robust=True) on the survey_design= " + "regardless. Use vcov_type='hc1' on the survey_design= " "event-study path." ) inference_method = "analytical_2sls" diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index 3c606f896..a1c171170 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -77,7 +77,7 @@ import pandas as pd from scipy import stats -from diff_diff._deprecation import NOT_SUPPLIED, require_arg, resolve_renamed_kwarg +from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.bootstrap_utils import ( apply_stratum_centering, generate_survey_multiplier_weights_batch, @@ -3380,24 +3380,19 @@ def _resolve_pretest_unit_weights( def joint_pretrends_test( data: pd.DataFrame, - 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, + outcome: str, + dose: str, + time: str, + unit: str, + pre_periods: Any, + base_period: Any, + first_treat: Optional[str] = None, *, 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). @@ -3467,23 +3462,8 @@ 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. + # Body-local ``*_col`` names feed the private helpers; the public + # parameters are the bare names. outcome_col = outcome dose_col = dose time_col = time @@ -3793,24 +3773,19 @@ def joint_pretrends_test( def joint_homogeneity_test( data: pd.DataFrame, - 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, + outcome: str, + dose: str, + time: str, + unit: str, + post_periods: Any, + base_period: Any, + first_treat: Optional[str] = None, *, 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). @@ -3866,23 +3841,8 @@ 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. + # Body-local ``*_col`` names feed the private helpers; the public + # parameters are the bare names. outcome_col = outcome dose_col = dose time_col = time @@ -4254,11 +4214,11 @@ def _compose_verdict_event_study_survey( def did_had_pretest_workflow( data: pd.DataFrame, - outcome: Any = NOT_SUPPLIED, - dose: Any = NOT_SUPPLIED, - time: Any = NOT_SUPPLIED, - unit: Any = NOT_SUPPLIED, - first_treat: Any = NOT_SUPPLIED, + outcome: str, + dose: str, + time: str, + unit: str, + first_treat: Optional[str] = None, alpha: float = 0.05, n_bootstrap: int = 999, seed: Optional[int] = None, @@ -4266,11 +4226,6 @@ def did_had_pretest_workflow( aggregate: Any = NOT_SUPPLIED, 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). @@ -4450,13 +4405,11 @@ 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" # M-139 deprecation shim (the fit-side twin is row M-027): a plain # workflow call never warns; supplying aggregate= with ANY value # warns once, then the legacy routing runs unchanged. The SENTINEL - # resolves to the same panel-inferred mode fit() uses - AFTER the - # time/time_col alias reconciliation below - at the - # _VALID_AGGREGATES check site. + # resolves to the same panel-inferred mode fit() uses at the + # _VALID_AGGREGATES check site below. if aggregate is not NOT_SUPPLIED: warnings.warn( "did_had_pretest_workflow(aggregate=) is deprecated and " @@ -4468,28 +4421,15 @@ def did_had_pretest_workflow( FutureWarning, stacklevel=2, ) - 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. + # Body-local ``*_col`` names feed the private helpers; the public + # parameters are the bare names. outcome_col = outcome dose_col = dose time_col = time unit_col = unit first_treat_col = first_treat - # Sentinel resolution runs HERE - after the alias reconciliation - # above, so both time= and legacy time_col= spellings reach the - # inference helper resolved (M-139, mirroring fit's M-027 shim). + # Sentinel resolution (M-139, mirroring fit's M-027 shim): infer the + # mode from the panel shape when aggregate= was not supplied. if aggregate is NOT_SUPPLIED: aggregate = _infer_aggregate_mode(data, time_col) elif aggregate not in _VALID_AGGREGATES: diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 7af995732..41b654a4a 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -1407,105 +1407,3 @@ def _build_imputation_aggregation_kit( cband=False, # no simultaneous-band concept on this estimator bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) ) - - -# ============================================================================= -# Convenience function -# ============================================================================= - - -def imputation_did( - data: pd.DataFrame, - outcome: str, - unit: str, - time: str, - first_treat: str, - covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, - survey_design: Optional["SurveyDesign"] = None, - vcov_type: str = "hc1", - **kwargs, -) -> ImputationDiDResults: - """ - Convenience function for imputation DiD estimation. - - .. deprecated:: 3.9 - ``imputation_did()`` is deprecated and will be removed in 4.0 - (row M-070). Construct the estimator instead: - ``ImputationDiD(...).fit(data, ...)``. - - This is a shortcut for creating an ImputationDiD estimator and calling fit(). - - Parameters - ---------- - data : pd.DataFrame - Panel data. - outcome : str - Outcome variable column name. - unit : str - Unit identifier column name. - time : str - Time period column name. - first_treat : str - Column indicating first treatment period (0 for never-treated). - covariates : list of str, optional - Covariate column names. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; row M-021): forwarded to ``fit()``, - which warns — aggregate post-fit via - ``results.aggregate('event_study')`` instead. A plain wrapper call - (kwarg not supplied) never fires the aggregate warning; since 3.9 - every wrapper call fires the M-070 wrapper-deprecation warning. - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0; row M-118): forwarded to ``fit()``, - which warns — moves onto ``results.aggregate('event_study', - balance_e=...)``. - survey_design : SurveyDesign, optional - Survey design specification for design-based inference. Supports - pweight only (aweight/fweight raise ValueError). Supports strata, - PSU, and FPC for design-based variance. Strata enters survey df - for t-distribution inference. - Both analytical (n_bootstrap=0) and bootstrap inference are supported. - vcov_type : str, default="hc1" - Variance estimator family. ImputationDiD permanently accepts - ``{"hc1"}`` only — analytical-sandwich families - ``{classical, hc2, hc2_bm}`` are rejected at ``__init__`` because the - Theorem 3 per-unit IF aggregation has no single design matrix on - which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can - be defined. ``cluster=`` invokes per-cluster IF summation; - ``survey_design=`` invokes TSL on the combined IF. - **kwargs - Additional keyword arguments passed to ImputationDiD constructor. - - Returns - ------- - ImputationDiDResults - Estimation results. - - Examples - -------- - >>> from diff_diff import imputation_did, generate_staggered_data - >>> data = generate_staggered_data(seed=42) - >>> results = imputation_did(data, 'outcome', 'unit', 'time', 'first_treat') - >>> results.print_summary() - >>> results.aggregate('event_study').summary() # post-fit aggregation - """ - warnings.warn( - "imputation_did() is deprecated and will be removed in 4.0; " - "construct the estimator instead: ImputationDiD(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - est = ImputationDiD(vcov_type=vcov_type, **kwargs) - return est.fit( - data, - outcome=outcome, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - aggregate=aggregate, - balance_e=balance_e, - survey_design=survey_design, - ) diff --git a/diff_diff/linalg.py b/diff_diff/linalg.py index 49b6b3324..72177d713 100644 --- a/diff_diff/linalg.py +++ b/diff_diff/linalg.py @@ -2059,45 +2059,26 @@ def effective_cluster_count(cluster_ids: np.ndarray, weights: Optional[np.ndarra return int(np.sum(sums > 0)) -def resolve_vcov_type( - robust: Optional[bool] = None, - vcov_type: Optional[str] = None, - *, - legacy_default: bool = True, -) -> str: - """Resolve the effective ``vcov_type`` from the ``robust``/``vcov_type`` pair. - - Single source of truth for the alias and conflict rules shared by - :class:`LinearRegression` and :class:`~diff_diff.estimators.DifferenceInDifferences` - (and any future caller that needs to validate the pair). Keeping the resolution - in one place prevents ``__init__``/``set_params`` drift. - - Rules (per the Phase 1a plan; ``robust`` is deprecated per rows - M-045..M-047/M-115 and dies in 4.0 with this helper's ``robust`` - handling): - - - ``robust=None`` (the 3.9 not-supplied sentinel) resolves as the - caller's ``legacy_default`` (``True`` everywhere except - ``HeterogeneousAdoptionDiD``, whose historical default was - ``False``). - - If ``vcov_type`` is ``None``: map effective ``robust=True`` to - ``"hc1"`` and ``robust=False`` to ``"classical"``. +def resolve_vcov_type(vcov_type: Optional[str] = None) -> str: + """Resolve and validate the effective ``vcov_type``. + + Single source of truth for the default and the allowed-value check shared + by :class:`LinearRegression` and + :class:`~diff_diff.estimators.DifferenceInDifferences` (and any future + caller). Keeping the resolution in one place prevents + ``__init__``/``set_params`` drift. + + Rules: + + - If ``vcov_type`` is ``None`` (not supplied): resolve to ``"hc1"``. - If ``vcov_type`` is supplied: it must be one of the values in the module-level ``_VALID_VCOV_TYPES`` set, namely ``{"classical", "hc1", "hc2", "hc2_bm", "hc3", "conley"}``. - - If ``robust=False`` is supplied together with a non-``"classical"`` ``vcov_type``, - raise ``ValueError`` - the combination is ambiguous. Parameters ---------- - robust : bool, optional - Deprecated legacy alias (removed in 4.0). ``True`` == HC1; - ``False`` == classical OLS SEs; ``None`` == not supplied. vcov_type : str, optional - Explicit variance family. Overrides ``robust`` unless the pair is contradictory. - legacy_default : bool, keyword-only, default True - What ``robust=None`` resolves to - the calling class's historical - ``robust`` default. + Explicit variance family; ``None`` selects the library default. Returns ------- @@ -2108,23 +2089,14 @@ def resolve_vcov_type( Raises ------ ValueError - If ``vcov_type`` is not one of the allowed values, or if - ``robust=False`` conflicts with an explicit non-classical ``vcov_type``. + If ``vcov_type`` is not one of the allowed values. """ - if robust is None: - robust = legacy_default if vcov_type is None: - return "hc1" if robust else "classical" + return "hc1" if vcov_type not in _VALID_VCOV_TYPES: raise ValueError( f"vcov_type must be one of {sorted(_VALID_VCOV_TYPES)}; " f"got {vcov_type!r}" ) - if robust is False and vcov_type != "classical": - raise ValueError( - f"robust=False conflicts with vcov_type={vcov_type!r}. " - "Pass vcov_type='classical' for non-robust SEs, or drop " - "`robust=` and rely on vcov_type alone." - ) return vcov_type @@ -2157,8 +2129,7 @@ def compute_robust_vcov( matrix determined by the ``vcov_type`` dispatch: - ``"classical"``: non-robust OLS SE. ``vcov = sigma_hat^2 * (X'X)^{-1}`` - with ``sigma_hat^2 = sum(u_i^2) / (n - k)``. Useful as a baseline and for - backward compatibility with ``robust=False``. + with ``sigma_hat^2 = sum(u_i^2) / (n - k)``. Useful as a baseline. - ``"hc1"`` (default): heteroskedasticity-robust HC1, meat ``sum_i (u_i^2) x_i x_i'`` with DOF factor ``n / (n - k)``. With ``cluster_ids``, switches to CR1 (Liang-Zeger) cluster-robust. @@ -4368,14 +4339,9 @@ class LinearRegression: ---------- include_intercept : bool, default True Whether to automatically add an intercept column to the design matrix. - robust : bool, optional - DEPRECATED legacy alias (row M-115; warns with ``FutureWarning``, - removed in 4.0 - use ``vcov_type=``). ``True`` (the historical - default) -> HC1; ``False`` with no ``cluster_ids`` -> classical - OLS standard errors. cluster_ids : array-like, optional - Cluster identifiers for cluster-robust standard errors. - Overrides the `robust` parameter if provided. + Cluster identifiers for cluster-robust standard errors. With + ``vcov_type="hc1"`` dispatches to CR1 (Liang-Zeger). alpha : float, default 0.05 Significance level for confidence intervals. rank_deficient_action : str, default "warn" @@ -4393,11 +4359,8 @@ class LinearRegression: estimation. When provided, weights and weight_type are canonicalized from this object. vcov_type : {"classical", "hc1", "hc2", "hc2_bm", "hc3", "conley"}, optional - Variance-covariance family. Defaults to the ``robust`` alias - (``robust=True`` -> ``"hc1"``, ``robust=False`` -> ``"classical"``). - Passing an explicit ``vcov_type`` overrides ``robust`` unless the - two conflict (e.g. ``robust=False, vcov_type="hc2"``), in which - case ``__init__`` raises. See :func:`solve_ols` for the per-family + Variance-covariance family. Defaults to ``"hc1"``. See + :func:`solve_ols` for the per-family semantics and unsupported combinations. For ``"hc2_bm"``: when ``cluster_ids`` is provided, dispatches to CR2 Bell-McCaffrey; with ``weights`` (one-way or clustered) dispatches to the @@ -4523,7 +4486,6 @@ class LinearRegression: def __init__( self, include_intercept: bool = True, - robust: Optional[bool] = None, cluster_ids: Optional[np.ndarray] = None, alpha: float = 0.05, rank_deficient_action: str = "warn", @@ -4549,14 +4511,6 @@ def __init__( f"got {df_convention!r}" ) self.include_intercept = include_intercept - # `robust` is deprecated (row M-115; removed in 4.0): None sentinel, - # raw arg kept alongside the resolved legacy bool (True). - if robust is not None: - from diff_diff._deprecation import warn_deprecated_kwarg - - warn_deprecated_kwarg(type(self).__name__, "robust", "use vcov_type= instead") - self._robust_arg = robust - self.robust = robust if robust is not None else True self.cluster_ids = cluster_ids self.alpha = alpha self.rank_deficient_action = rank_deficient_action @@ -4582,16 +4536,14 @@ def __init__( # The default flips to "cluster" at v4 (see REGISTRY clustered-CR1 # inference-df deviation note). self.df_convention = df_convention - # Resolve vcov_type from the legacy `robust` alias via the shared helper. - self.vcov_type = resolve_vcov_type(robust, vcov_type) - # Preserve the raw constructor arg (possibly None) so `fit()` can - # distinguish "alias-derived classical" from "explicit classical". - # This is the single source of truth for backward-compat remap - # decisions (robust=False + cluster -> CR1). `fit()` treats the - # configured state as IMMUTABLE and computes all effective fit- - # time values as locals, so repeat fits with different cluster - # or survey context produce the correct result without state - # drift between calls. + # Resolve/validate vcov_type via the shared helper. + self.vcov_type = resolve_vcov_type(vcov_type) + # Preserve the raw constructor arg (possibly None) so callers can + # distinguish the implicit default from an explicit choice. `fit()` + # treats the configured state as IMMUTABLE and computes all + # effective fit-time values as locals, so repeat fits with + # different cluster or survey context produce the correct result + # without state drift between calls. self._vcov_type_arg = vcov_type self._vcov_type_explicit = vcov_type is not None @@ -4669,33 +4621,9 @@ def fit( # Use provided cluster_ids or fall back to instance-level effective_cluster_ids = cluster_ids if cluster_ids is not None else self.cluster_ids - # Resolve the effective fit-time vcov_type WITHOUT mutating self. - # Legacy-alias backward compat: when the user supplied - # ``robust=False`` without an explicit ``vcov_type`` and a cluster - # structure is present at fit time, remap the implicit - # ``"classical"`` to ``"hc1"`` so the call dispatches to CR1 - # instead of raising. Per-fit local only; the configured - # ``self.vcov_type`` is left untouched so a subsequent unclustered - # fit continues to use classical SEs. + # Effective fit-time vcov_type as a local (the configured + # ``self.vcov_type`` is never mutated by fit()). _fit_vcov_type = self.vcov_type - if ( - not self._vcov_type_explicit - and _fit_vcov_type == "classical" - and effective_cluster_ids is not None - ): - warnings.warn( - "LinearRegression(robust=False) with clustered fit " - "(cluster_ids=...) historically produced CR1 cluster-" - "robust SEs. To preserve that behavior, vcov_type has " - "been remapped from 'classical' to 'hc1' for THIS fit " - "only (configured state on `self` is preserved). Pass " - "vcov_type='hc1' explicitly to silence this warning, or " - "vcov_type='classical' (with cluster_ids=None) for " - "non-robust SEs.", - UserWarning, - stacklevel=2, - ) - _fit_vcov_type = "hc1" # Determine if survey vcov should be used _use_survey_vcov = False diff --git a/diff_diff/lpdid_results.py b/diff_diff/lpdid_results.py index 4c75b3a6a..4c95d6fa9 100644 --- a/diff_diff/lpdid_results.py +++ b/diff_diff/lpdid_results.py @@ -4,7 +4,6 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import warn_deprecated_kwarg from diff_diff.results_base import BaseResults, _coverage_pct @@ -141,17 +140,8 @@ def conf_int(self) -> Tuple[float, float]: # serialization # ------------------------------------------------------------------ def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: - # "event_study" is the canonical level spelling library-wide; the - # drifted "event" warns and maps through the 3.9 shim window - # (missed-rename row, section 8 rule 10 - same unification as - # WooldridgeDiDResults.aggregate's M-086). - if level == "event": - warn_deprecated_kwarg( - "LPDiDResults.to_dataframe", - "level='event'", - "use level='event_study' instead", - ) - level = "event_study" + # "event_study" is the canonical level spelling library-wide (the + # 3.x "event" spelling was retired at 4.0 and now raises below). if level == "event_study": if self.event_study is None: raise ValueError("event_study dataframe was not computed") diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index e91635857..e34d8e3d0 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -54,21 +54,12 @@ def _distributional_kind(results: Any) -> Any: - """Read a ChangesInChangesResults' method tag, old field name included. - - Row M-143 renamed the field ``estimator`` -> ``method``. The fallback is - NOT ``getattr(results, "estimator", None)``: on a real results object that - name is now a deprecation property, so reading it emits a FutureWarning - even when it returns the right value - which turns into an error under the - warning-as-error assertions this module's own tests use. Reading the - instance ``__dict__`` finds a duck-typed hand-built attribute without ever - touching the descriptor. Real results resolve ``method`` first, so the - fallback only ever fires for mocks built before the rename. + """Read a ChangesInChangesResults' ``method`` tag (``None`` when absent). + + ``method`` is the canonical field name (the pre-3.9 ``estimator`` spelling + was retired at 4.0 along with its duck-typed fallback here). """ - kind = getattr(results, "method", None) - if kind is not None: - return kind - return getattr(results, "__dict__", {}).get("estimator") + return getattr(results, "method", None) def _estimator_display(type_name: str, results: Any) -> str: @@ -788,7 +779,7 @@ def _handle_stacked(results: Any): ), priority="medium", # See note on SA handler: DR completes ``sensitivity`` when - # HonestDiD runs, which does not replay ``clean_control`` + # HonestDiD runs, which does not replay ``control_group`` # variation. Tag separately. step_name="specification_comparison", ), diff --git a/diff_diff/prep.py b/diff_diff/prep.py index e8ec65393..f50b30665 100644 --- a/diff_diff/prep.py +++ b/diff_diff/prep.py @@ -16,7 +16,6 @@ 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, @@ -1336,11 +1335,10 @@ def _suggest_treatment_candidates( def trim_weights( data: pd.DataFrame, - weights: Any = NOT_SUPPLIED, + weights: str, 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. @@ -1363,9 +1361,6 @@ 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 ------- @@ -1378,16 +1373,7 @@ def trim_weights( 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). + # Body-local column name; the public parameter is weights. weight_col = weights if upper is not None and quantile is not None: raise ValueError("Specify either 'upper' or 'quantile', not both.") diff --git a/diff_diff/rdd.py b/diff_diff/rdd.py index f3b35399c..5ee32504e 100644 --- a/diff_diff/rdd.py +++ b/diff_diff/rdd.py @@ -112,12 +112,6 @@ 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, @@ -237,8 +231,6 @@ class RegressionDiscontinuityResults(BaseResults): # 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 takeup: Optional[str] @@ -289,17 +281,10 @@ 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.""" + """Migrate 3.x pickles created before the ``treatment_col`` -> + ``takeup`` rename: rewrite the legacy key on load so the field + populates under its canonical name.""" if "treatment_col" in state and "takeup" not in state: state = dict(state) state["takeup"] = state.pop("treatment_col") @@ -481,9 +466,6 @@ def to_dict(self) -> Dict[str, Any]: "estimand": self.estimand, "sharpbw": self.sharpbw, "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). @@ -729,13 +711,10 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome: Any = NOT_SUPPLIED, - running: Any = NOT_SUPPLIED, - takeup: Any = NOT_SUPPLIED, + outcome: str, + running: str, + takeup: Optional[str] = None, 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). @@ -775,40 +754,9 @@ 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). + # Body-local column names feeding the private helpers; the public + # parameters are outcome/running/takeup. outcome_col = outcome running_col = running treatment_col = takeup diff --git a/diff_diff/rdplot.py b/diff_diff/rdplot.py index 1cc63c5c6..40adef02a 100644 --- a/diff_diff/rdplot.py +++ b/diff_diff/rdplot.py @@ -37,7 +37,6 @@ 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, @@ -476,11 +475,9 @@ def _validate_constructor_args(self) -> None: def fit( self, data: pd.DataFrame, - outcome: Any = NOT_SUPPLIED, - running: Any = NOT_SUPPLIED, + outcome: str, + running: str, covariates: Optional[List[str]] = None, - outcome_col: Any = NOT_SUPPLIED, - running_col: Any = NOT_SUPPLIED, ) -> RDPlotResult: """Build the RD plot quantities. @@ -506,36 +503,12 @@ 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). + # Body-local column names feeding the private helpers; the public + # parameters are outcome/running. outcome_col = outcome running_col = running if covariates is not None: diff --git a/diff_diff/results.py b/diff_diff/results.py index bd504644a..fb5efed46 100644 --- a/diff_diff/results.py +++ b/diff_diff/results.py @@ -4,7 +4,6 @@ Provides statsmodels-style output with a more Pythonic interface. """ -import warnings from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple @@ -1147,8 +1146,7 @@ class SyntheticDiDResults(BaseResults): (for ``"placebo"``), bootstrap ATT estimates with re-estimated weights per draw (for ``"bootstrap"``), or leave-one-out estimates (for ``"jackknife"``). The ``variance_method`` field disambiguates - the contents. (The deprecated read-only alias ``placebo_effects`` - returns this array and is removed in v4.0.0.) + the contents. pre_treatment_fit : float, optional Shape-only pre-treatment fit: the RMSE of the pre-period residual (treated mean minus synthetic control) after removing its mean. The @@ -1277,9 +1275,9 @@ def __setstate__(self, state: Dict[str, Any]) -> None: Results pickled before the ``placebo_effects`` → ``variance_effects`` rename (<= 3.5.x) carry the old key in their state; map it so the - stored variance draws survive and remain reachable through both - ``variance_effects`` and the deprecated ``placebo_effects`` alias. - Remove together with the alias in v4.0.0. + stored variance draws survive and remain reachable through + ``variance_effects`` (the ``placebo_effects`` alias itself was + removed at 4.0; this migration keeps 3.x pickles loadable). """ if "placebo_effects" in state and "variance_effects" not in state: state = dict(state) @@ -1322,25 +1320,6 @@ def coef_var(self) -> float: return np.nan return self.se / abs(self.att) - @property - def placebo_effects(self) -> Optional[np.ndarray]: - """Deprecated alias for :attr:`variance_effects` (removed in v4.0.0). - - .. deprecated:: 3.5.2 - Renamed to ``variance_effects`` because the array's contents are - method-specific (placebo effects, bootstrap ATT draws, or - leave-one-out estimates depending on ``variance_method``). - """ - warnings.warn( - "SyntheticDiDResults.placebo_effects is deprecated; use " - "variance_effects instead. The array holds placebo effects, " - "bootstrap ATT draws, or leave-one-out estimates depending on " - "variance_method. Will be removed in v4.0.0.", - DeprecationWarning, - stacklevel=2, - ) - return self.variance_effects - def summary(self, alpha: Optional[float] = None) -> str: """ Generate a formatted summary of the estimation results. diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index 4a9f51aa5..a7072123e 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -25,11 +25,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import ( - NOT_SUPPLIED, - deprecated_field_property, - resolve_renamed_kwarg, -) +from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.balancing import BalanceError, entropy_balance from diff_diff.linalg import effective_cluster_count, solve_ols from diff_diff.stacked_did_results import StackedDiDResults # noqa: F401 (re-export) @@ -43,7 +39,6 @@ __all__ = [ "StackedDiD", "StackedDiDResults", - "stacked_did", ] @@ -73,8 +68,6 @@ class StackedDiD(BaseEstimator): - "not_yet_treated": Units with A_s > a + kappa_post - "strict": Units with A_s > a + kappa_post + kappa_pre - "never_treated": Only units with A_s = infinity - (``clean_control=`` remains accepted as a deprecated alias, row - M-043; FutureWarning, removed in 4.0.) cluster : str, default="unit" Clustering level for standard errors: - "unit": Cluster on original unit identifier @@ -200,38 +193,12 @@ class StackedDiD(BaseEstimator): Difference-in-Differences. NBER Working Paper 32054. """ - _PARAM_ATTR_ALIASES = { - "control_group": "_control_group_arg", - "clean_control": "_clean_control_arg", - } - _DERIVED_CONFIG_ATTRS = ("control_group",) - - # Deprecated read-only alias for the resolved ``control_group`` config - # (row M-043; removed in 4.0). External attribute readers get - # warn+value instead of a silent AttributeError. - clean_control = deprecated_field_property("StackedDiD", "clean_control", "control_group") - - @classmethod - def _normalize_set_params(cls, params: Dict[str, Any]) -> Dict[str, Any]: - # During the M-043 shim window `control_group`/`clean_control` are a - # renamed pair resolved at __init__. get_params() returns the RAW - # sentinel-era args, so a user migrating in place - - # StackedDiD(clean_control="strict").set_params(control_group=...) - - # would merge two supplied values and trip the both-supplied gate. - # Whichever of the pair the user passes wins; the other resets to - # its not-supplied sentinel in the merge. - if "control_group" in params and "clean_control" not in params: - params["clean_control"] = NOT_SUPPLIED - elif "clean_control" in params and "control_group" not in params: - params["control_group"] = NOT_SUPPLIED - return params - def __init__( self, kappa_pre: int = 1, kappa_post: int = 1, weighting: str = "aggregate", - control_group: Any = NOT_SUPPLIED, + control_group: str = "not_yet_treated", cluster: str = "unit", alpha: float = 0.05, anticipation: int = 0, @@ -239,26 +206,12 @@ def __init__( vcov_type: str = "hc1", balance: str = "none", df_convention: str = "residual", - clean_control: Any = NOT_SUPPLIED, ): if weighting not in ("aggregate", "population", "sample_share"): raise ValueError( f"weighting must be 'aggregate', 'population', or 'sample_share', " f"got '{weighting}'" ) - # M-043: clean_control= is the deprecated alias for control_group=. - # Raw args are stored for get_params (aliases above); the resolved - # value lives on self.control_group. - _control_group_arg = control_group - _clean_control_arg = clean_control - control_group = resolve_renamed_kwarg( - type(self).__name__, - "clean_control", - clean_control, - "control_group", - control_group, - default="not_yet_treated", - ) if control_group not in ("not_yet_treated", "strict", "never_treated"): raise ValueError( f"control_group must be 'not_yet_treated', 'strict', or " @@ -280,8 +233,6 @@ def __init__( self.kappa_pre = kappa_pre self.kappa_post = kappa_post self.weighting = weighting - self._control_group_arg = _control_group_arg - self._clean_control_arg = _clean_control_arg self.control_group = control_group self.cluster = cluster self.alpha = alpha @@ -1698,99 +1649,3 @@ def summary(self) -> str: def print_summary(self) -> None: """Print summary to stdout.""" print(self.summary()) - - -# ============================================================================= -# Convenience function -# ============================================================================= - - -def stacked_did( - data: pd.DataFrame, - outcome: str, - unit: str, - time: str, - first_treat: str, - kappa_pre: int = 1, - kappa_post: int = 1, - aggregate: Any = NOT_SUPPLIED, - population: Optional[str] = None, - survey_design=None, - covariates: Optional[List[str]] = None, - **kwargs: Any, -) -> StackedDiDResults: - """ - Convenience function for stacked DiD estimation. - - .. deprecated:: 3.9 - ``stacked_did()`` is deprecated and will be removed in 4.0 - (row M-072). Construct the estimator instead: - ``StackedDiD(...).fit(data, ...)``. - - This is a shortcut for creating a StackedDiD estimator and calling fit(). - - Parameters - ---------- - data : pd.DataFrame - Panel data. - outcome : str - Outcome variable column name. - unit : str - Unit identifier column name. - time : str - Time period column name. - first_treat : str - Column indicating first treatment period (0 or inf for never-treated). - kappa_pre : int, default=1 - Pre-treatment event-time periods. - kappa_post : int, default=1 - Post-treatment event-time periods. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; row M-024) - the event-study - surface is always computed; passing this warns and changes - nothing. Use ``results.aggregate('event_study')`` / - ``results.aggregate('simple')`` post-fit. - population : str, optional - Population column for weighting="population". - survey_design : SurveyDesign, optional - Survey design specification for design-based inference. - covariates : list of str, optional - Covariate columns to balance the clean controls toward the treated - cohort (pass ``balance="entropy"`` via ``**kwargs`` to enable). See - ``StackedDiD.fit``. - **kwargs - Additional keyword arguments passed to StackedDiD constructor - (e.g. ``balance="entropy"``, ``weighting``, ``cluster``, ``vcov_type``). - - Returns - ------- - StackedDiDResults - Estimation results. - - Examples - -------- - >>> from diff_diff import stacked_did, generate_staggered_data - >>> data = generate_staggered_data(seed=42) - >>> results = stacked_did(data, 'outcome', 'unit', 'period', - ... 'first_treat', kappa_pre=2, kappa_post=2) - >>> results.print_summary() - >>> es = results.aggregate('event_study') # post-fit view (M-024) - """ - warnings.warn( - "stacked_did() is deprecated and will be removed in 4.0; " - "construct the estimator instead: StackedDiD(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - est = StackedDiD(kappa_pre=kappa_pre, kappa_post=kappa_post, **kwargs) - return est.fit( - data, - outcome=outcome, - unit=unit, - time=time, - first_treat=first_treat, - aggregate=aggregate, - population=population, - survey_design=survey_design, - covariates=covariates, - ) diff --git a/diff_diff/stacked_did_results.py b/diff_diff/stacked_did_results.py index 2a1880dbd..2d1188ac2 100644 --- a/diff_diff/stacked_did_results.py +++ b/diff_diff/stacked_did_results.py @@ -11,7 +11,6 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import deprecated_field_property from diff_diff.aggregation import AggregationMixin, AggregationResult from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import ( @@ -79,9 +78,7 @@ class StackedDiDResults(BaseResults, AggregationMixin): weighting : str Weighting scheme used. control_group : str - Control-group (clean-control) definition used. (The deprecated - read-only alias ``clean_control`` warns and returns this value; - removed in 4.0 - row M-095.) + Control-group (clean-control) definition used. alpha : float Significance level used. event_study_vcov : np.ndarray, optional @@ -176,15 +173,10 @@ class StackedDiDResults(BaseResults, AggregationMixin): df_convention: Optional[str] = None inference_df: Optional[float] = None - # Deprecated read-only alias for ``control_group`` (row M-095; removed - # in 4.0). No annotation, so it stays a descriptor and never becomes a - # __dataclass_fields__ entry. - clean_control = deprecated_field_property("StackedDiDResults", "clean_control", "control_group") - def __setstate__(self, state: Dict[str, Any]) -> None: - """Migrate pickles created before the ``clean_control`` -> - ``control_group`` rename (row M-095): rewrite the key on load so - both the new field and the deprecated alias work on old pickles.""" + """Migrate 3.x pickles created before the ``clean_control`` -> + ``control_group`` rename: rewrite the legacy key on load so the + field populates under its canonical name.""" if "clean_control" in state and "control_group" not in state: state = dict(state) state["control_group"] = state.pop("clean_control") @@ -527,9 +519,6 @@ def to_dict(self) -> Dict[str, Any]: "kappa_post": self.kappa_post, "weighting": self.weighting, "control_group": self.control_group, - # Deprecated key mirroring ``control_group`` through the 3.9 - # shim window; dropped in 4.0 (row M-095, section 5 policy). - "clean_control": self.control_group, "anticipation": self.anticipation, "alpha": self.alpha, "vcov_type": self.vcov_type, diff --git a/diff_diff/synthetic_control.py b/diff_diff/synthetic_control.py index 33ce2d049..fdc2b822b 100644 --- a/diff_diff/synthetic_control.py +++ b/diff_diff/synthetic_control.py @@ -47,7 +47,7 @@ ) from diff_diff.utils import _sc_weight_nnls, safe_inference -__all__ = ["SyntheticControl", "synthetic_control"] +__all__ = ["SyntheticControl"] # Aggregation operators allowed for predictor / special-predictor rows. Restricted to # LINEAR combinations of pre-period values, matching ADH (2010) §2.3 @@ -730,49 +730,6 @@ def _prepare_custom_v(self, k: int) -> np.ndarray: return v / v.sum() -def synthetic_control( - data: pd.DataFrame, - outcome: str, - treatment: str, - unit: str, - time: str, - **kwargs, -) -> SyntheticControlResults: - """ - Convenience function for classic synthetic control estimation. - - .. deprecated:: 3.9 - ``synthetic_control()`` is deprecated and will be removed in 4.0 - (row M-074). Construct the estimator instead: - ``SyntheticControl(...).fit(data, ...)``. - - Constructor-only keyword arguments (``v_method`` — ``"nested"`` / ``"custom"`` / - ``"cv"`` / ``"inverse_variance"`` — ``custom_v``, ``v_cv_t0``, ``n_starts``, - ``standardize``, ``alpha``, ``seed``, ``optimizer_options``) and ``fit`` - keyword arguments - (``post_periods``, ``treated_unit``, ``predictors``, ``special_predictors``, - ...) may both be passed via ``**kwargs``. - - Examples - -------- - >>> from diff_diff import synthetic_control - >>> res = synthetic_control(data, "y", "treated", "unit", "year", - ... predictors=["x1", "x2"]) - >>> print(f"ATT: {res.att:.3f}, pre-RMSPE: {res.pre_rmspe:.3f}") - """ - warnings.warn( - "synthetic_control() is deprecated and will be removed in 4.0; " - "construct the estimator instead: SyntheticControl(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - ctor_keys = set(SyntheticControl().get_params().keys()) - ctor_kwargs = {k: v for k, v in kwargs.items() if k in ctor_keys} - fit_kwargs = {k: v for k, v in kwargs.items() if k not in ctor_keys} - estimator = SyntheticControl(**ctor_kwargs) - return estimator.fit(data, outcome, treatment, unit, time, **fit_kwargs) - - # ============================================================================= # module-private helpers # ============================================================================= diff --git a/diff_diff/synthetic_did.py b/diff_diff/synthetic_did.py index a04c01f56..cedd6c942 100644 --- a/diff_diff/synthetic_did.py +++ b/diff_diff/synthetic_did.py @@ -196,9 +196,6 @@ def __init__( variance_method: str = "placebo", n_bootstrap: int = 200, seed: Optional[int] = None, - # Deprecated — accepted for backward compat, ignored with warning - lambda_reg: Optional[float] = None, - zeta: Optional[float] = None, # Defensive guard against silently-ignored Conley kwargs. SyntheticDiD # inherits __init__ from DifferenceInDifferences but overrides with # literal `super().__init__(cluster=None, alpha=alpha)`, @@ -236,22 +233,6 @@ def __init__( f"SyntheticDiD's variance is bootstrap/jackknife/placebo " f"based; configure via variance_method=..." ) - if lambda_reg is not None: - warnings.warn( - "lambda_reg is deprecated and ignored. Regularization is now " - "auto-computed from data. Use zeta_omega to override unit weight " - "regularization. Will be removed in v4.0.0.", - DeprecationWarning, - stacklevel=2, - ) - if zeta is not None: - warnings.warn( - "zeta is deprecated and ignored. Use zeta_lambda to override " - "time weight regularization. Will be removed in v4.0.0.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(cluster=None, alpha=alpha) self.zeta_omega = zeta_omega self.zeta_lambda = zeta_lambda @@ -262,14 +243,9 @@ def __init__( # conley_metric/conley_kernel would otherwise read back the DiD # defaults stored by super().__init__ ("haversine"/"bartlett"), # poisoning every set_params probe against the non-None TypeError - # guard above; both are None in any legal construction. The - # deprecated lambda_reg/zeta are warn-and-discard: stored normalized - # to None so a probe re-init warns exactly once for the call that - # passes them and never again on unrelated set_params/clone calls. + # guard above; both are None in any legal construction. self.conley_metric = conley_metric self.conley_kernel = conley_kernel - self.lambda_reg = None - self.zeta = None self._validate_config() @@ -2927,9 +2903,6 @@ def _jackknife_se_survey( # get_params/set_params come from BaseEstimator (via # DifferenceInDifferences). The probe re-init reproduces every guard in - # __init__: the conley/vcov_type non-None TypeError, the lambda_reg/zeta - # DeprecationWarnings (emitted exactly once per call that passes them - - # the raw-arg storage in __init__ normalizes both to None, so unrelated - # set_params/clone calls stay silent), and _validate_config. The - # inherited vcov_type -> _vcov_type_arg alias makes get_params report + # __init__: the conley/vcov_type non-None TypeError and _validate_config. + # The inherited vcov_type -> _vcov_type_arg alias makes get_params report # the raw (always-None) vcov_type, not the resolved "hc1". diff --git a/diff_diff/triple_diff.py b/diff_diff/triple_diff.py index 64dcfd383..4d72a3051 100644 --- a/diff_diff/triple_diff.py +++ b/diff_diff/triple_diff.py @@ -29,7 +29,7 @@ import warnings from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd @@ -39,7 +39,6 @@ NOT_SUPPLIED, require_arg, resolve_renamed_kwarg, - warn_deprecated_kwarg, ) from diff_diff._staggered_triple_diff_engine import _StaggeredTripleDiffEngineMixin from diff_diff.linalg import _rank_guarded_inv, solve_logit, solve_ols @@ -61,10 +60,6 @@ validate_pscore_trim, ) -if TYPE_CHECKING: - from diff_diff.survey import SurveyDesign - - _MIN_CELL_SIZE = 10 # ============================================================================= @@ -429,11 +424,6 @@ class TripleDifference( model or propensity score model is correctly specified. - "reg": Regression adjustment (outcome regression). - "ipw": Inverse probability weighting. - robust : bool, optional - DEPRECATED (row M-046; warns with ``FutureWarning``, removed in - 4.0 - use ``vcov_type=``). Influence-function SEs are inherently - robust to heteroskedasticity, so the flag never had an effect - here; it was retained only for API compatibility. cluster : str, optional Column name for cluster-robust standard errors. When provided, SEs are computed using the Liang-Zeger cluster-robust variance @@ -605,13 +595,9 @@ class TripleDifference( # literal there would misname whichever surface was actually fit. _BOOTSTRAP_LABEL: str = "TripleDifference" - _PARAM_ATTR_ALIASES = {"robust": "_robust_arg"} - _DERIVED_CONFIG_ATTRS = ("robust",) - def __init__( self, estimation_method: str = "dr", - robust: Optional[bool] = None, cluster: Optional[str] = None, vcov_type: str = "hc1", alpha: float = 0.05, @@ -686,12 +672,6 @@ def __init__( self._validate_vcov_type(vcov_type) self.estimation_method = estimation_method - # `robust` is deprecated (row M-046; removed in 4.0): None sentinel, - # raw arg at `_robust_arg`, resolved legacy bool on the public attr. - if robust is not None: - warn_deprecated_kwarg(type(self).__name__, "robust", "use vcov_type= instead") - self._robust_arg = robust - self.robust = robust if robust is not None else True self.cluster = cluster self.vcov_type = vcov_type self.alpha = alpha @@ -1140,20 +1120,14 @@ def _fit_staggered( ) # Step 2: the reverse constructor guard, the mirror of the 2x2x2 one. - # The two arms have DIFFERENT reachability. `robust=` is constructible - # (it only warns), so that arm is reached normally. `vcov_type` is - # validated eagerly in __init__ and by set_params' probe re-init, so a - # bad value never survives construction - its only live route is direct - # attribute mutation, the same bypass __init__'s comment documents. That - # makes this arm the staggered mode's direct-mutation guard, not dead - # code, and it is why no separate _validate_vcov_type call is needed. + # `vcov_type` is validated eagerly in __init__ and by set_params' probe + # re-init, so a bad value never survives construction - its only live + # route is direct attribute mutation, the same bypass __init__'s + # comment documents. That makes this arm the staggered mode's + # direct-mutation guard, not dead code, and it is why no separate + # _validate_vcov_type call is needed. _2x2x2_ctor = [ - name - for name, non_default in ( - ("robust", self._robust_arg is not None), - ("vcov_type", self.vcov_type != "hc1"), - ) - if non_default + name for name, non_default in (("vcov_type", self.vcov_type != "hc1"),) if non_default ] if _2x2x2_ctor: raise ValueError( @@ -2447,127 +2421,3 @@ def summary(self) -> str: def print_summary(self) -> None: """Print summary to stdout.""" print(self.summary()) - - -# ============================================================================= -# Convenience function -# ============================================================================= - - -def triple_difference( - data: pd.DataFrame, - outcome: str, - group: str, - partition: str, - time: str, - covariates: Optional[List[str]] = None, - estimation_method: str = "dr", - robust: Optional[bool] = None, - cluster: Optional[str] = None, - vcov_type: str = "hc1", - alpha: float = 0.05, - rank_deficient_action: str = "warn", - epv_threshold: float = 10, - pscore_fallback: str = "error", - survey_design: Optional["SurveyDesign"] = None, -) -> TripleDifferenceResults: - """ - Estimate Triple Difference (DDD) treatment effect. - - .. deprecated:: 3.9 - ``triple_difference()`` is deprecated and will be removed in 4.0 - (row M-075). Construct the estimator instead: - ``TripleDifference(...).fit(data, ...)``. - - Convenience function that creates a TripleDifference estimator and - fits it to the data in one step. - - Parameters - ---------- - data : pd.DataFrame - DataFrame containing all variables. - outcome : str - Name of the outcome variable column. - group : str - Name of the group indicator column (0/1). - 1 = treated group (e.g., states that enacted policy). - partition : str - Name of the partition/eligibility indicator column (0/1). - 1 = eligible partition (e.g., women, targeted demographic). - time : str - Name of the time period indicator column (0/1). - 1 = post-treatment period. - covariates : list of str, optional - List of covariate column names to adjust for. - estimation_method : str, default="dr" - Estimation method: "dr" (doubly robust), "reg" (regression), - or "ipw" (inverse probability weighting). - robust : bool, optional - DEPRECATED (row M-046; warns with ``FutureWarning``, removed in - 4.0 - use ``vcov_type=``). Influence-function SEs are inherently - robust to heteroskedasticity, so the flag never had an effect - here; it was retained only for API compatibility. - cluster : str, optional - Column name for cluster-robust standard errors. - vcov_type : str, default="hc1" - Variance estimator. Permanently narrow to ``{"hc1"}`` per the - IF-based variance decomposition; ``classical``/``hc2``/``hc2_bm`` - are rejected at ``__init__`` and ``conley`` is deferred. See the - ``TripleDifference`` class docstring for the structural taxonomy. - alpha : float, default=0.05 - Significance level for confidence intervals. - rank_deficient_action : str, default="warn" - Action when design matrix is rank-deficient: - - "warn": Issue warning and drop linearly dependent columns (default) - - "error": Raise ValueError - - "silent": Drop columns silently without warning - epv_threshold : float, default=10 - Events Per Variable threshold for propensity score logit. - pscore_fallback : str, default="error" - Action when propensity score estimation fails: - - "error": Raise (default) - - "unconditional": Fall back to unconditional propensity - - Returns - ------- - TripleDifferenceResults - Object containing estimation results. - - Examples - -------- - >>> from diff_diff import triple_difference - >>> results = triple_difference( - ... data, - ... outcome='earnings', - ... group='policy_state', - ... partition='female', - ... time='post_policy', - ... covariates=['age', 'education'] - ... ) - >>> print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})") - """ - warnings.warn( - "triple_difference() is deprecated and will be removed in 4.0; " - "construct the estimator instead: TripleDifference(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - estimator = TripleDifference( - estimation_method=estimation_method, - robust=robust, - cluster=cluster, - vcov_type=vcov_type, - alpha=alpha, - rank_deficient_action=rank_deficient_action, - epv_threshold=epv_threshold, - pscore_fallback=pscore_fallback, - ) - return estimator.fit( - data=data, - outcome=outcome, - group=group, - partition=partition, - post=time, - covariates=covariates, - survey_design=survey_design, - ) diff --git a/diff_diff/trop.py b/diff_diff/trop.py index 0eda85243..6f962c9e3 100644 --- a/diff_diff/trop.py +++ b/diff_diff/trop.py @@ -951,64 +951,3 @@ def fit( # ========================================================================= # get_params/set_params come from BaseEstimator. - - -def trop( - data: pd.DataFrame, - outcome: str, - treatment: str, - unit: str, - time: str, - survey_design=None, - **kwargs, -) -> TROPResults: - """ - Convenience function for TROP estimation. - - .. deprecated:: 3.9 - ``trop()`` is deprecated and will be removed in 4.0 (row M-073). - Construct the estimator instead: ``TROP(...).fit(data, ...)``. - - Parameters - ---------- - data : pd.DataFrame - Panel data. - outcome : str - Outcome variable column name. - treatment : str - Treatment indicator column name (0/1). - - By default (``non_absorbing=False``) this must be an ABSORBING STATE - indicator, not a treatment timing indicator: for each unit, D=1 for ALL - periods during and after treatment (D[t,i]=0 for t < g_i, D[t,i]=1 for - t >= g_i where g_i is the treatment start time for unit i). Pass - ``non_absorbing=True`` (via ``**kwargs``) to accept general on/off - assignment patterns (``method='local'`` only); see ``TROP``. - unit : str - Unit identifier column name. - time : str - Time period column name. - survey_design : SurveyDesign, optional - Survey design specification. Supports pweight, strata, PSU, and FPC. - **kwargs - Additional arguments passed to TROP constructor (e.g. ``non_absorbing``). - - Returns - ------- - TROPResults - Estimation results. - - Examples - -------- - >>> from diff_diff import trop - >>> results = trop(data, 'y', 'treated', 'unit', 'time') - >>> print(f"ATT: {results.att:.3f}") - """ - warnings.warn( - "trop() is deprecated and will be removed in 4.0; " - "construct the estimator instead: TROP(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - estimator = TROP(**kwargs) - return estimator.fit(data, outcome, treatment, unit, time, survey_design=survey_design) diff --git a/diff_diff/twfe.py b/diff_diff/twfe.py index 39891450b..ea037c3f0 100644 --- a/diff_diff/twfe.py +++ b/diff_diff/twfe.py @@ -43,10 +43,6 @@ class TwoWayFixedEffects(DifferenceInDifferences): Parameters ---------- - robust : bool, optional - DEPRECATED legacy alias inherited from - ``DifferenceInDifferences`` (row M-045; warns with - ``FutureWarning``, removed in 4.0 - use ``vcov_type=``). cluster : str, optional Column name for cluster-robust standard errors. If None, automatically clusters at the unit level (the `unit` @@ -430,16 +426,14 @@ def fit( # type: ignore[override] # ``cluster_ids + vcov_type in ("classical","hc2")``. When the user # EXPLICITLY asks for one of these analytical-one-way families AND # does NOT set ``cluster=``, honor that choice by disabling the - # auto-cluster. + # auto-cluster. The ``_vcov_type_explicit`` flag distinguishes a + # user-chosen family from the implicit ``"hc1"`` default (which keeps + # the unit auto-cluster and dispatches to CR1 at unit). # - # When ``"classical"`` is IMPLICIT (from the legacy alias - # ``robust=False``), keep the unit auto-cluster so - # ``_resolve_effective_vcov_type`` below can remap it to ``"hc1"`` - # and preserve the historical CR1-at-unit behavior. Wild-bootstrap - # inference also keeps the unit auto-cluster regardless of - # ``vcov_type`` (bootstrap consumes cluster structure for - # resampling, independent of the analytical sandwich). ``hc2_bm`` - # also keeps the auto-cluster (routes to CR2-BM at unit). + # Wild-bootstrap inference also keeps the unit auto-cluster + # regardless of ``vcov_type`` (bootstrap consumes cluster structure + # for resampling, independent of the analytical sandwich). + # ``hc2_bm`` also keeps the auto-cluster (routes to CR2-BM at unit). if self.cluster is not None: cluster_var: Optional[str] = self.cluster elif ( @@ -635,18 +629,12 @@ def fit( # type: ignore[override] # from computing replicate vcov on already-demeaned data (demeaning depends # on weights, so replicate refits must re-demean at the estimator level). _lr_survey_twfe = None if _uses_replicate_twfe else resolved_survey - # Remap implicit "classical" + cluster to CR1 for legacy-alias - # backward compatibility. TWFE auto-clusters at unit when the user - # doesn't set cluster, so `robust=False` without an explicit - # vcov_type historically produced CR1 at unit; we preserve that. - # Don't forward `robust=self.robust` to LinearRegression when the - # remapped vcov_type disagrees; the remapped `vcov_type` is the - # single source of truth. - _fit_vcov_type = ( - "hc1" - if _replicate_vcov_remap_twfe - else self._resolve_effective_vcov_type(survey_cluster_ids) - ) + # Replicate-weight survey designs replace the analytical sandwich, + # so the base fit always uses "hc1" there (see + # `_warn_replicate_vcov_ignored`); otherwise the configured family. + # The fit-time `_fit_vcov_type` is the single source of truth for + # the LinearRegression call below. + _fit_vcov_type = "hc1" if _replicate_vcov_remap_twfe else self.vcov_type # Phase 2 panel-Conley: build coord array + row-aligned time/unit # vectors from the original (un-demeaned) data. FWL composability: diff --git a/diff_diff/two_stage.py b/diff_diff/two_stage.py index 76fd56bc2..d38a7328a 100644 --- a/diff_diff/two_stage.py +++ b/diff_diff/two_stage.py @@ -2331,103 +2331,3 @@ def _build_twostage_aggregation_kit( cband=False, # no simultaneous-band concept on this estimator bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) ) - - -# ============================================================================= -# Convenience function -# ============================================================================= - - -def two_stage_did( - data: pd.DataFrame, - outcome: str, - unit: str, - time: str, - first_treat: str, - covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, - survey_design: Optional["SurveyDesign"] = None, - vcov_type: str = "hc1", - **kwargs, -) -> TwoStageDiDResults: - """ - Convenience function for two-stage DiD estimation. - - .. deprecated:: 3.9 - ``two_stage_did()`` is deprecated and will be removed in 4.0 - (row M-071). Construct the estimator instead: - ``TwoStageDiD(...).fit(data, ...)``. - - This is a shortcut for creating a TwoStageDiD estimator and calling fit(). - - Parameters - ---------- - data : pd.DataFrame - Panel data. - outcome : str - Outcome variable column name. - unit : str - Unit identifier column name. - time : str - Time period column name. - first_treat : str - Column indicating first treatment period (0 for never-treated). - covariates : list of str, optional - Covariate column names. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; row M-022): forwarded to ``fit()``, - which warns — aggregate post-fit via - ``results.aggregate('event_study')`` instead. A plain wrapper call - (kwarg not supplied) never fires the aggregate warning; since 3.9 - every wrapper call fires the M-071 wrapper-deprecation warning. - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0; row M-119): forwarded to ``fit()``, - which warns — moves onto ``results.aggregate('event_study', - balance_e=...)``. - survey_design : SurveyDesign, optional - Survey design specification for design-based inference. Supports - pweight only (aweight/fweight raise ValueError). Supports strata, - PSU, and FPC for design-based GMM sandwich variance. Strata enters - survey df for t-distribution inference. - Both analytical (n_bootstrap=0) and bootstrap inference are supported. - vcov_type : str, default="hc1" - Variance estimator family; permanently narrow to ``{"hc1"}`` (the Gardner - 2022 two-stage GMM cluster-sandwich). Analytical-sandwich families - ``{"classical", "hc2", "hc2_bm"}`` and ``"conley"`` are rejected. See - :class:`TwoStageDiD`. - **kwargs - Additional keyword arguments passed to TwoStageDiD constructor. - - Returns - ------- - TwoStageDiDResults - Estimation results. - - Examples - -------- - >>> from diff_diff import two_stage_did, generate_staggered_data - >>> data = generate_staggered_data(seed=42) - >>> results = two_stage_did(data, 'outcome', 'unit', 'period', - ... 'first_treat') - >>> results.print_summary() - >>> results.aggregate('event_study').summary() # post-fit aggregation - """ - warnings.warn( - "two_stage_did() is deprecated and will be removed in 4.0; " - "construct the estimator instead: TwoStageDiD(...).fit(data, ...).", - FutureWarning, - stacklevel=2, - ) - est = TwoStageDiD(vcov_type=vcov_type, **kwargs) - return est.fit( - data, - outcome=outcome, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - aggregate=aggregate, - balance_e=balance_e, - survey_design=survey_design, - ) diff --git a/diff_diff/visualization/_diagnostic.py b/diff_diff/visualization/_diagnostic.py index da496f518..91c3f65f2 100644 --- a/diff_diff/visualization/_diagnostic.py +++ b/diff_diff/visualization/_diagnostic.py @@ -363,8 +363,7 @@ def plot_bacon( Parameters ---------- results : BaconDecompositionResults - Results from BaconDecomposition.fit() (or the deprecated - bacon_decompose() wrapper, removed in 4.0). + Results from BaconDecomposition.fit(). plot_type : str, default="scatter" Type of plot to create: - "scatter": Scatter plot with estimates on x-axis, weights on y-axis diff --git a/diff_diff/wooldridge.py b/diff_diff/wooldridge.py index 8a5d452ee..3d2b0c7e8 100644 --- a/diff_diff/wooldridge.py +++ b/diff_diff/wooldridge.py @@ -21,7 +21,6 @@ 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, @@ -1127,12 +1126,11 @@ def fit( outcome: str, unit: str, time: str, - first_treat: Any = NOT_SUPPLIED, + first_treat: str, 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. @@ -1152,29 +1150,14 @@ 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). + # Body-local name; the public parameter is first_treat. cohort = first_treat # Fit-time re-check: __init__ and set_params validate eagerly, so # this only catches DIRECT attribute mutation (est.anticipation = ...) # — an out-of-domain value silently changes the ESTIMAND. The - # assignment also re-normalizes a mutated numpy scalar to int. Placed - # AFTER the cohort→first_treat rename shim so a caller who both - # mutated and passed the deprecated kwarg still sees the - # FutureWarning before the raise. + # assignment also re-normalizes a mutated numpy scalar to int. self.anticipation = validate_anticipation(self.anticipation) self._validate_unsupported_period_action(self.unsupported_period_action) diff --git a/diff_diff/wooldridge_results.py b/diff_diff/wooldridge_results.py index 5f6374e23..538659737 100644 --- a/diff_diff/wooldridge_results.py +++ b/diff_diff/wooldridge_results.py @@ -9,13 +9,12 @@ import numpy as np import pandas as pd -from diff_diff._deprecation import ( - NOT_SUPPLIED, - _NotSupplied, - resolve_renamed_kwarg, - warn_deprecated_kwarg, +from diff_diff.results_base import ( + _SUMMARY_ALPHA_MESSAGE, + BaseResults, + _coverage_pct, + _require_fit_alpha, ) -from diff_diff.results_base import BaseResults, _coverage_pct from diff_diff.utils import safe_inference @@ -30,7 +29,7 @@ class WooldridgeDiDResults(BaseResults): cell-count weighting (``weights="cell"``, matches Stata ``jwdid_estat``) or the paper W2025 opt-in cohort-share weighting (``weights="cohort_share"``, Eqs. 7.4 / 7.6; restricted to - ``type ∈ {"simple", "event"}``). ``cohort_trend_coefs`` carries + ``type ∈ {"simple", "event_study"}``). ``cohort_trend_coefs`` carries Section 8 / Eq. 8.1 estimated ``δ_g`` slopes when the fit was produced under ``WooldridgeDiD(cohort_trends=True)``. ``aggregation_weights`` is keyed by aggregation type and records @@ -142,7 +141,7 @@ class WooldridgeDiDResults(BaseResults): # aggregation surface so ``summary()`` / ``to_dataframe()`` / # ``__repr__()`` can label each surface correctly under mixed-order # ``aggregate(weights=...)`` calls. Keys: ``"simple"`` (matches the - # ``overall_*`` fields), ``"group"``, ``"calendar"``, ``"event"``. + # ``overall_*`` fields), ``"group"``, ``"calendar"``, ``"event_study"``. # The fit-time ``overall_*`` is cell-weighted, so ``"simple"`` is # initialized to ``"cell"`` and only flips after a successful # ``aggregate(type="simple", weights="cohort_share")`` call. The @@ -251,12 +250,16 @@ def __setstate__(self, state: Dict[str, Any]) -> None: if "df_convention" not in self.__dict__: self.__dict__["df_convention"] = "residual" self.__dict__.setdefault("unsupported_period_action", "drop") - # M-086: pickles written before the "event" -> "event_study" value - # unification carry only the old aggregation_weights key; mirror it - # so both spellings resolve during the 3.9 window. + # 3.x pickles written before the "event" -> "event_study" value + # unification carry the legacy aggregation_weights key - 3.9-3.13 + # pickles carry BOTH (the old mirror). Drop the legacy key + # unconditionally and seed the canonical one from it only when the + # canonical key is absent (an existing canonical value wins). aw = self.__dict__.get("aggregation_weights") - if isinstance(aw, dict) and "event" in aw and "event_study" not in aw: - aw["event_study"] = aw["event"] + if isinstance(aw, dict): + legacy = aw.pop("event", None) + if legacy is not None: + aw.setdefault("event_study", legacy) def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults": # noqa: A002 """Compute and store one of the four jwdid_estat aggregation types. @@ -264,8 +267,6 @@ def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults": Parameters ---------- type : "simple" | "group" | "calendar" | "event_study" - ("event" is a deprecated spelling of "event_study"; warns with - ``FutureWarning`` and is rejected in 4.0 - row M-086.) weights : "cell" | "cohort_share", default "cell" Aggregation weighting scheme. ``"cell"`` (default) uses cell- count ``n_{g,t}`` observation counts and matches Stata @@ -294,15 +295,8 @@ def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults": is rebuilt under the active ``weights`` scheme so the BM DOF reflects the actual weighting used by ATT + SE. """ - # M-086: "event_study" is the canonical spelling; the drifted - # "event" warns and maps through the 3.9 shim window. - if type == "event": - warn_deprecated_kwarg( - "WooldridgeDiDResults.aggregate", - "type='event'", - "use type='event_study' instead", - ) - type = "event_study" # noqa: A001 + # "event_study" is the canonical spelling library-wide; the 3.x + # "event" spelling was retired at 4.0 and is rejected below. valid = ("simple", "group", "calendar", "event_study") if type not in valid: raise ValueError(f"type must be one of {valid}, got {type!r}") @@ -683,58 +677,25 @@ def _build_effect( result[k] = _build_effect(att, se, dofs.get(k)) self.event_study_effects = result self.aggregation_weights["event_study"] = weights - # Deprecated key mirroring "event_study" through the 3.9 shim - # window; dropped in 4.0 (row M-086, section 5 policy). - self.aggregation_weights["event"] = weights return self - def summary(self, aggregation: Any = NOT_SUPPLIED, *, alpha: Optional[float] = None) -> str: - """Print formatted summary table. + def summary(self, alpha: Optional[float] = None) -> str: + """Print the formatted headline summary (the simple aggregation row). + + Other aggregations are not rendered here; select them via + ``aggregate(...)`` / ``to_dataframe(level=)``. Parameters ---------- - aggregation : str, optional - DEPRECATED (row M-087; retired in 4.0): which aggregation to - display ("simple", "group", "calendar", "event_study"). From - 4.0 ``summary()`` takes the library-uniform ``alpha``-only - signature and renders the headline simple row; select other - aggregations via ``aggregate(...)`` / ``to_dataframe(level=)``. - alpha : float, keyword-only, optional - Accepted for signature uniformity (spec section 5). The stored - confidence columns were computed at fit time; passing a value - different from the stored ``alpha`` raises rather than - silently recomputing or mislabeling - refit at the desired - level instead. Keyword-only during the 3.9 shim window because - ``aggregation`` still holds position 1; it becomes the sole - positional parameter in 4.0, matching every other results - class. + alpha : float, optional + Accepted for signature uniformity. The stored intervals were + computed at fit time; a value different from the stored + ``alpha`` raises ValueError rather than silently recomputing + or relabeling. Re-fit at the desired alpha instead. """ - if alpha is not None and alpha != self.alpha: - raise ValueError( - f"This results object stores intervals computed at " - f"alpha={self.alpha}; refit at the desired level to obtain " - f"alpha={alpha} intervals (summary() never recomputes " - "stored inference)." - ) - if isinstance(aggregation, _NotSupplied): - aggregation = "simple" - else: - if aggregation is None or isinstance(aggregation, float): - raise TypeError( - "summary() takes the aggregation selector positionally " - "only through 3.9 (deprecated); alpha= is KEYWORD-ONLY " - "- call summary(alpha=...) instead." - ) - warn_deprecated_kwarg( - "WooldridgeDiDResults.summary", - "aggregation", - "summary() renders the simple row from 4.0; select other " - "aggregations via aggregate(...) or to_dataframe(level=)", - ) - if aggregation == "event": - aggregation = "event_study" - _alpha = self.alpha + _alpha = _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) + aggregation = "simple" lines = [ "=" * 70, " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results", @@ -791,62 +752,16 @@ def _fmt_row(label: str, att: float, se: float, t: float, p: float, ci: Tuple) - lines.append(header) lines.append("-" * 70) - if aggregation == "simple": - lines.append( - _fmt_row( - "ATT (simple)", - self.overall_att, - self.overall_se, - self.overall_t_stat, - self.overall_p_value, - self.overall_conf_int, - ) + lines.append( + _fmt_row( + "ATT (simple)", + self.overall_att, + self.overall_se, + self.overall_t_stat, + self.overall_p_value, + self.overall_conf_int, ) - elif aggregation == "group" and self.group_effects: - for g, eff in sorted(self.group_effects.items()): - lines.append( - _fmt_row( - f"ATT(g={g})", - eff["att"], - eff["se"], - eff["t_stat"], - eff["p_value"], - eff["conf_int"], - ) - ) - elif aggregation == "calendar" and self.calendar_effects: - for t, eff in sorted(self.calendar_effects.items()): - lines.append( - _fmt_row( - f"ATT(t={t})", - eff["att"], - eff["se"], - eff["t_stat"], - eff["p_value"], - eff["conf_int"], - ) - ) - elif aggregation == "event_study" and self.event_study_effects: - for k, eff in sorted(self.event_study_effects.items()): - if k < -self.anticipation: - suffix = " [pre]" - elif k < 0: - suffix = " [antic]" - else: - suffix = "" - label = f"ATT(k={k})" + suffix - lines.append( - _fmt_row( - label, - eff["att"], - eff["se"], - eff["t_stat"], - eff["p_value"], - eff["conf_int"], - ) - ) - else: - lines.append(f" (call .aggregate({aggregation!r}) first)") + ) lines.append("=" * 70) return "\n".join(lines) @@ -889,37 +804,19 @@ def to_dict(self) -> Dict[str, Any]: result["df_convention"] = self.df_convention return result - def to_dataframe( - self, level: Any = NOT_SUPPLIED, aggregation: Any = NOT_SUPPLIED - ) -> pd.DataFrame: + def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: """Export aggregated effects to a DataFrame. Parameters ---------- level : "simple" | "group" | "calendar" | "event_study" | "gt" - Use "gt" to export raw group-time effects. ("event" is a - deprecated spelling of "event_study"; warns and is rejected - in 4.0 - row M-150, the to_dataframe twin of M-086.) - aggregation : str, optional - Deprecated alias for ``level`` (row M-044); warns with - ``FutureWarning`` and will be removed in 4.0. + Use "gt" to export raw group-time effects. Any other value + raises ``ValueError``. """ - level = resolve_renamed_kwarg( - "WooldridgeDiDResults.to_dataframe", - "aggregation", - aggregation, - "level", - level, - default="event_study", - ) - if level == "event": - warn_deprecated_kwarg( - "WooldridgeDiDResults.to_dataframe", - "level='event'", - "use level='event_study' instead", - ) - level = "event_study" - # Body-local name; the public parameter is level (M-044). + valid_levels = ("simple", "group", "calendar", "event_study", "gt") + if level not in valid_levels: + raise ValueError(f"level must be one of {valid_levels}, got {level!r}") + # Body-local name; the public parameter is level. aggregation = level if aggregation == "gt": rows = [] diff --git a/docs/api/_autosummary/diff_diff.ATTGTWeightsResult.rst b/docs/api/_autosummary/diff_diff.ATTGTWeightsResult.rst new file mode 100644 index 000000000..0a445b3fd --- /dev/null +++ b/docs/api/_autosummary/diff_diff.ATTGTWeightsResult.rst @@ -0,0 +1,40 @@ +diff\_diff.ATTGTWeightsResult +============================= + +.. currentmodule:: diff_diff + +.. autoclass:: ATTGTWeightsResult + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~ATTGTWeightsResult.__init__ + ~ATTGTWeightsResult.print_summary + ~ATTGTWeightsResult.summary + ~ATTGTWeightsResult.to_dataframe + ~ATTGTWeightsResult.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~ATTGTWeightsResult.LEVELS + ~ATTGTWeightsResult.base_period + ~ATTGTWeightsResult.control_group + ~ATTGTWeightsResult.n_dropped_cells + ~ATTGTWeightsResult.source + ~ATTGTWeightsResult.weights + ~ATTGTWeightsResult.level + ~ATTGTWeightsResult.implied_att + ~ATTGTWeightsResult.n_negative + ~ATTGTWeightsResult.negative_weight_share + ~ATTGTWeightsResult.n_negative_post + ~ATTGTWeightsResult.negative_post_weight_share + ~ATTGTWeightsResult.n_cells + diff --git a/docs/api/_autosummary/diff_diff.CSBootstrapResults.rst b/docs/api/_autosummary/diff_diff.CSBootstrapResults.rst index 01cbfe5b5..24a1aa586 100644 --- a/docs/api/_autosummary/diff_diff.CSBootstrapResults.rst +++ b/docs/api/_autosummary/diff_diff.CSBootstrapResults.rst @@ -25,6 +25,8 @@ ~CSBootstrapResults.event_study_cis ~CSBootstrapResults.event_study_p_values ~CSBootstrapResults.event_study_ses + ~CSBootstrapResults.event_study_vcov + ~CSBootstrapResults.event_study_vcov_index ~CSBootstrapResults.group_effect_cis ~CSBootstrapResults.group_effect_p_values ~CSBootstrapResults.group_effect_ses diff --git a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst index 265e7bfb2..9c89a435c 100644 --- a/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst +++ b/docs/api/_autosummary/diff_diff.ChaisemartinDHaultfoeuilleResults.rst @@ -36,7 +36,6 @@ ~ChaisemartinDHaultfoeuilleResults.design2_effects ~ChaisemartinDHaultfoeuilleResults.event_study_df ~ChaisemartinDHaultfoeuilleResults.event_study_effects - ~ChaisemartinDHaultfoeuilleResults.groups ~ChaisemartinDHaultfoeuilleResults.heterogeneity_effects ~ChaisemartinDHaultfoeuilleResults.honest_did_results ~ChaisemartinDHaultfoeuilleResults.is_significant diff --git a/docs/api/_autosummary/diff_diff.DMLDiD.rst b/docs/api/_autosummary/diff_diff.DMLDiD.rst index 43bfebc2d..3079934ff 100644 --- a/docs/api/_autosummary/diff_diff.DMLDiD.rst +++ b/docs/api/_autosummary/diff_diff.DMLDiD.rst @@ -29,3 +29,4 @@ ~DMLDiD.seed ~DMLDiD.anticipation ~DMLDiD.base_period + diff --git a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst index 9f9e63671..6c431f6fe 100644 --- a/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst +++ b/docs/api/_autosummary/diff_diff.HeterogeneousAdoptionDiDEventStudyResults.rst @@ -31,8 +31,8 @@ ~HeterogeneousAdoptionDiDEventStudyResults.cband_method ~HeterogeneousAdoptionDiDEventStudyResults.cband_n_bootstrap ~HeterogeneousAdoptionDiDEventStudyResults.effective_dose_mean - ~HeterogeneousAdoptionDiDEventStudyResults.variance_formula ~HeterogeneousAdoptionDiDEventStudyResults.event_study_df + ~HeterogeneousAdoptionDiDEventStudyResults.variance_formula ~HeterogeneousAdoptionDiDEventStudyResults.event_times ~HeterogeneousAdoptionDiDEventStudyResults.att ~HeterogeneousAdoptionDiDEventStudyResults.se diff --git a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst index 503c13700..c0a88fd15 100644 --- a/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.ImputationDiDResults.rst @@ -34,6 +34,7 @@ ~ImputationDiDResults.coef_var ~ImputationDiDResults.conf_int ~ImputationDiDResults.df_convention + ~ImputationDiDResults.event_study_df ~ImputationDiDResults.is_significant ~ImputationDiDResults.leave_one_out ~ImputationDiDResults.n_clusters @@ -50,7 +51,6 @@ ~ImputationDiDResults.overall_t_stat ~ImputationDiDResults.overall_p_value ~ImputationDiDResults.overall_conf_int - ~ImputationDiDResults.event_study_df ~ImputationDiDResults.event_study_effects ~ImputationDiDResults.group_effects ~ImputationDiDResults.groups diff --git a/docs/api/_autosummary/diff_diff.RDDensityTestResult.rst b/docs/api/_autosummary/diff_diff.RDDensityTestResult.rst index 77bd2b395..ebd9b7ca9 100644 --- a/docs/api/_autosummary/diff_diff.RDDensityTestResult.rst +++ b/docs/api/_autosummary/diff_diff.RDDensityTestResult.rst @@ -14,6 +14,7 @@ ~RDDensityTestResult.__init__ ~RDDensityTestResult.summary ~RDDensityTestResult.to_dataframe + ~RDDensityTestResult.to_dict diff --git a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst index d0414a3d8..66dfdf39c 100644 --- a/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst +++ b/docs/api/_autosummary/diff_diff.RegressionDiscontinuityResults.rst @@ -45,7 +45,6 @@ ~RegressionDiscontinuityResults.first_stage_t_stat ~RegressionDiscontinuityResults.first_stage_t_stat_bias_corrected ~RegressionDiscontinuityResults.first_stage_t_stat_conventional - ~RegressionDiscontinuityResults.treatment_col ~RegressionDiscontinuityResults.att ~RegressionDiscontinuityResults.se ~RegressionDiscontinuityResults.t_stat diff --git a/docs/api/_autosummary/diff_diff.SieveLearner.rst b/docs/api/_autosummary/diff_diff.SieveLearner.rst index a33de4118..7edd75a5a 100644 --- a/docs/api/_autosummary/diff_diff.SieveLearner.rst +++ b/docs/api/_autosummary/diff_diff.SieveLearner.rst @@ -14,3 +14,7 @@ ~SieveLearner.__init__ ~SieveLearner.fit ~SieveLearner.predict + + + + diff --git a/docs/api/_autosummary/diff_diff.StackedDiD.rst b/docs/api/_autosummary/diff_diff.StackedDiD.rst index a0ac5abb3..681a8bb34 100644 --- a/docs/api/_autosummary/diff_diff.StackedDiD.rst +++ b/docs/api/_autosummary/diff_diff.StackedDiD.rst @@ -21,9 +21,3 @@ - .. rubric:: Attributes - - .. autosummary:: - - ~StackedDiD.clean_control - diff --git a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst index 1ae35dd12..956982b57 100644 --- a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst @@ -31,7 +31,6 @@ ~StackedDiDResults.balance ~StackedDiDResults.balance_diagnostics ~StackedDiDResults.base_period - ~StackedDiDResults.clean_control ~StackedDiDResults.cluster_name ~StackedDiDResults.coef_var ~StackedDiDResults.conf_int diff --git a/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst b/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst index 00893a63c..ba4ccdc6d 100644 --- a/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst +++ b/docs/api/_autosummary/diff_diff.StaggeredTripleDifference.rst @@ -23,10 +23,19 @@ .. autosummary:: - ~StaggeredTripleDifference.n_bootstrap - ~StaggeredTripleDifference.bootstrap_weights + ~StaggeredTripleDifference.estimation_method + ~StaggeredTripleDifference.control_group ~StaggeredTripleDifference.alpha - ~StaggeredTripleDifference.seed ~StaggeredTripleDifference.anticipation ~StaggeredTripleDifference.base_period + ~StaggeredTripleDifference.n_bootstrap + ~StaggeredTripleDifference.cband + ~StaggeredTripleDifference.pscore_trim + ~StaggeredTripleDifference.cluster + ~StaggeredTripleDifference.rank_deficient_action + ~StaggeredTripleDifference.epv_threshold + ~StaggeredTripleDifference.pscore_fallback + ~StaggeredTripleDifference.is_fitted_ + ~StaggeredTripleDifference.bootstrap_weights + ~StaggeredTripleDifference.seed diff --git a/docs/api/_autosummary/diff_diff.SyntheticDiDResults.rst b/docs/api/_autosummary/diff_diff.SyntheticDiDResults.rst index 1e6aa81b4..e5e1f81f9 100644 --- a/docs/api/_autosummary/diff_diff.SyntheticDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.SyntheticDiDResults.rst @@ -35,7 +35,6 @@ ~SyntheticDiDResults.is_significant ~SyntheticDiDResults.n_bootstrap ~SyntheticDiDResults.noise_level - ~SyntheticDiDResults.placebo_effects ~SyntheticDiDResults.pre_fit_placebo_pvalue ~SyntheticDiDResults.pre_fit_placebo_rmse ~SyntheticDiDResults.pre_treatment_fit diff --git a/docs/api/_autosummary/diff_diff.TWFEDecompositionResult.rst b/docs/api/_autosummary/diff_diff.TWFEDecompositionResult.rst new file mode 100644 index 000000000..3e2973d92 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.TWFEDecompositionResult.rst @@ -0,0 +1,41 @@ +diff\_diff.TWFEDecompositionResult +================================== + +.. currentmodule:: diff_diff + +.. autoclass:: TWFEDecompositionResult + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~TWFEDecompositionResult.__init__ + ~TWFEDecompositionResult.covariate_balance + ~TWFEDecompositionResult.print_summary + ~TWFEDecompositionResult.summary + ~TWFEDecompositionResult.to_dataframe + ~TWFEDecompositionResult.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~TWFEDecompositionResult.balance + ~TWFEDecompositionResult.cells + ~TWFEDecompositionResult.method + ~TWFEDecompositionResult.estimate + ~TWFEDecompositionResult.decomposition + ~TWFEDecompositionResult.remainder + ~TWFEDecompositionResult.pre_period_contribution + ~TWFEDecompositionResult.post_only + ~TWFEDecompositionResult.base_period + ~TWFEDecompositionResult.covariates + ~TWFEDecompositionResult.effective_sample_size + ~TWFEDecompositionResult.n_units + ~TWFEDecompositionResult.n_periods + diff --git a/docs/api/_autosummary/diff_diff.TripleDifference.rst b/docs/api/_autosummary/diff_diff.TripleDifference.rst index 2f80893c8..a2368d76f 100644 --- a/docs/api/_autosummary/diff_diff.TripleDifference.rst +++ b/docs/api/_autosummary/diff_diff.TripleDifference.rst @@ -21,3 +21,23 @@ + .. rubric:: Attributes + + .. autosummary:: + + ~TripleDifference.estimation_method + ~TripleDifference.control_group + ~TripleDifference.alpha + ~TripleDifference.anticipation + ~TripleDifference.base_period + ~TripleDifference.n_bootstrap + ~TripleDifference.cband + ~TripleDifference.pscore_trim + ~TripleDifference.cluster + ~TripleDifference.rank_deficient_action + ~TripleDifference.epv_threshold + ~TripleDifference.pscore_fallback + ~TripleDifference.is_fitted_ + ~TripleDifference.bootstrap_weights + ~TripleDifference.seed + diff --git a/docs/api/_autosummary/diff_diff.attgt_weights.rst b/docs/api/_autosummary/diff_diff.attgt_weights.rst new file mode 100644 index 000000000..ab5de8eea --- /dev/null +++ b/docs/api/_autosummary/diff_diff.attgt_weights.rst @@ -0,0 +1,7 @@ +diff\_diff.attgt\_weights +========================= + +.. currentmodule:: diff_diff + +.. autofunction:: attgt_weights + :no-index: \ No newline at end of file diff --git a/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst b/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst index 81f1e7d02..61d87dfc3 100644 --- a/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst +++ b/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst @@ -27,7 +27,6 @@ ~ChangesInChangesResults.alpha ~ChangesInChangesResults.covariates - ~ChangesInChangesResults.estimator ~ChangesInChangesResults.is_significant ~ChangesInChangesResults.significance_stars ~ChangesInChangesResults.att diff --git a/docs/api/_autosummary/diff_diff.decompose_twfe_weights.rst b/docs/api/_autosummary/diff_diff.decompose_twfe_weights.rst new file mode 100644 index 000000000..13de65301 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.decompose_twfe_weights.rst @@ -0,0 +1,7 @@ +diff\_diff.decompose\_twfe\_weights +=================================== + +.. currentmodule:: diff_diff + +.. autofunction:: decompose_twfe_weights + :no-index: \ No newline at end of file diff --git a/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst b/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst index 6a6697c7f..434ec0025 100644 --- a/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.dml_did_results.DMLDiDResults.rst @@ -45,6 +45,7 @@ ~DMLDiDResults.control_group ~DMLDiDResults.cross_fit_diagnostics ~DMLDiDResults.df_inference + ~DMLDiDResults.effective_n_folds ~DMLDiDResults.epv_diagnostics ~DMLDiDResults.epv_threshold ~DMLDiDResults.event_study_df @@ -82,3 +83,4 @@ ~DMLDiDResults.n_obs ~DMLDiDResults.n_treated_units ~DMLDiDResults.n_control_units + diff --git a/docs/api/_autosummary/diff_diff.lwdid_results.LWDiDResults.rst b/docs/api/_autosummary/diff_diff.lwdid_results.LWDiDResults.rst index 1cf221ea3..8ad5e8d66 100644 --- a/docs/api/_autosummary/diff_diff.lwdid_results.LWDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.lwdid_results.LWDiDResults.rst @@ -28,12 +28,12 @@ .. autosummary:: + ~LWDiDResults.att_tau_omega_complete_case ~LWDiDResults.bootstrap_pvalue ~LWDiDResults.bse ~LWDiDResults.cband_crit_value ~LWDiDResults.cband_method ~LWDiDResults.cband_n_bootstrap - ~LWDiDResults.att_tau_omega_complete_case ~LWDiDResults.ci ~LWDiDResults.cluster_name ~LWDiDResults.cohort_effects @@ -54,8 +54,8 @@ ~LWDiDResults.pscore_trim ~LWDiDResults.psm_config ~LWDiDResults.pvalue - ~LWDiDResults.seed ~LWDiDResults.ri_pvalue + ~LWDiDResults.seed ~LWDiDResults.vcov ~LWDiDResults.att ~LWDiDResults.se diff --git a/docs/api/_autosummary/diff_diff.meridian_calibration_mask.rst b/docs/api/_autosummary/diff_diff.meridian_calibration_mask.rst index 22e0bf9d6..5d8cb98ce 100644 --- a/docs/api/_autosummary/diff_diff.meridian_calibration_mask.rst +++ b/docs/api/_autosummary/diff_diff.meridian_calibration_mask.rst @@ -1,7 +1,7 @@ diff\_diff.meridian\_calibration\_mask -======================================= +====================================== .. currentmodule:: diff_diff .. autofunction:: meridian_calibration_mask - :no-index: + :no-index: \ No newline at end of file diff --git a/docs/api/_autosummary/diff_diff.plot_twfe_weights.rst b/docs/api/_autosummary/diff_diff.plot_twfe_weights.rst new file mode 100644 index 000000000..cf4567131 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.plot_twfe_weights.rst @@ -0,0 +1,7 @@ +diff\_diff.plot\_twfe\_weights +============================== + +.. currentmodule:: diff_diff + +.. autofunction:: plot_twfe_weights + :no-index: \ No newline at end of file diff --git a/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst b/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst index 4cc51a8af..246f38ac3 100644 --- a/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.wooldridge_results.WooldridgeDiDResults.rst @@ -46,6 +46,7 @@ ~WooldridgeDiDResults.se ~WooldridgeDiDResults.survey_metadata ~WooldridgeDiDResults.t_stat + ~WooldridgeDiDResults.unsupported_period_action ~WooldridgeDiDResults.vcov_type ~WooldridgeDiDResults.group_time_effects ~WooldridgeDiDResults.overall_att diff --git a/docs/api/bacon.rst b/docs/api/bacon.rst index 10a0a5f80..d80df9c12 100644 --- a/docs/api/bacon.rst +++ b/docs/api/bacon.rst @@ -79,11 +79,6 @@ Container for an individual 2x2 DiD comparison within the decomposition. :show-inheritance: :no-index: -Convenience Function --------------------- - -.. autofunction:: diff_diff.bacon_decompose - Example Usage ------------- diff --git a/docs/api/chaisemartin_dhaultfoeuille.rst b/docs/api/chaisemartin_dhaultfoeuille.rst index a88c5d102..dc4025cd4 100644 --- a/docs/api/chaisemartin_dhaultfoeuille.rst +++ b/docs/api/chaisemartin_dhaultfoeuille.rst @@ -13,7 +13,7 @@ This module implements the methodology from de Chaisemartin & D'Haultfœuille ``DID_l`` for ``l = 1..L_max`` via the ``L_max`` parameter, with normalized estimator ``DID^n_l``, cost-benefit aggregate ``delta``, dynamic placebos ``DID^{pl}_l``, and sup-t simultaneous confidence bands; residualization-style -covariate adjustment (``controls``); group-specific linear trends +covariate adjustment (``covariates``); group-specific linear trends (``trends_linear``); state-set-specific trends (``trends_nonparam``); heterogeneity testing; non-binary treatment; HonestDiD sensitivity integration on placebos; survey support via Taylor-series linearization @@ -173,11 +173,6 @@ Multiplier-bootstrap inference results, populated when ``n_bootstrap > 0``. :undoc-members: :show-inheritance: -Convenience Function --------------------- - -.. autofunction:: diff_diff.chaisemartin_dhaultfoeuille - Standalone TWFE Decomposition Diagnostic ---------------------------------------- diff --git a/docs/api/changes_in_changes.rst b/docs/api/changes_in_changes.rst index 1d8132bdf..eb33fbd1d 100644 --- a/docs/api/changes_in_changes.rst +++ b/docs/api/changes_in_changes.rst @@ -115,8 +115,8 @@ ChangesInChangesResults ----------------------- Results container shared by both estimators (the ``method`` field records -which produced it - the pre-3.9 name ``estimator`` still reads it with a -``FutureWarning`` until 4.0; ``QDiDResults`` is an alias of this class). +which produced it; the pre-3.9 name ``estimator`` was removed in 4.0; +``QDiDResults`` is an alias of this class). .. autoclass:: diff_diff.changes_in_changes_results.ChangesInChangesResults :no-index: diff --git a/docs/api/estimators.rst b/docs/api/estimators.rst index c8ee98d3c..752979f10 100644 --- a/docs/api/estimators.rst +++ b/docs/api/estimators.rst @@ -23,10 +23,6 @@ Most estimators have short aliases (``TROP`` already uses its short canonical na from diff_diff import DiD, TWFE, EventStudy, SDiD, CS, SA, BJS, DDD, SCM, Bacon -``CDiD``, ``Gardner`` and ``Stacked`` are deprecated since 3.9 (they emit a -``FutureWarning`` and are removed in 4.0) — use ``ContinuousDiD``, -``TwoStageDiD`` and ``StackedDiD``. - .. module:: diff_diff.estimators DifferenceInDifferences (alias: ``DiD``) diff --git a/docs/api/imputation.rst b/docs/api/imputation.rst index a26cbd9cf..d8bad6259 100644 --- a/docs/api/imputation.rst +++ b/docs/api/imputation.rst @@ -82,11 +82,6 @@ Bootstrap inference results. :undoc-members: :show-inheritance: -Convenience Function --------------------- - -.. autofunction:: diff_diff.imputation_did - Example Usage ------------- diff --git a/docs/api/stacked_did.rst b/docs/api/stacked_did.rst index e7fa4c2a1..0009782cb 100644 --- a/docs/api/stacked_did.rst +++ b/docs/api/stacked_did.rst @@ -63,11 +63,6 @@ Results container for Stacked DiD estimation. ~StackedDiDResults.print_summary ~StackedDiDResults.to_dataframe -Convenience Function --------------------- - -.. autofunction:: diff_diff.stacked_did - Example Usage ------------- diff --git a/docs/api/synthetic_control.rst b/docs/api/synthetic_control.rst index 4d17f14a4..77ed397dc 100644 --- a/docs/api/synthetic_control.rst +++ b/docs/api/synthetic_control.rst @@ -146,11 +146,6 @@ Results container for synthetic control estimation. ~SyntheticControlResults.get_gap_df ~SyntheticControlResults.get_weights_df -Convenience Function --------------------- - -.. autofunction:: diff_diff.synthetic_control - Predictors and V selection -------------------------- @@ -229,8 +224,7 @@ Basic usage with covariate and special predictors:: gap_df = results.get_gap_df() # period, gap, phase weights_df = results.get_weights_df() # unit, weight (descending) -Quick one-call estimation (the ``synthetic_control()`` wrapper is -deprecated since 3.9 and removed in 4.0):: +Quick one-call estimation:: from diff_diff import SyntheticControl diff --git a/docs/api/triple_diff.rst b/docs/api/triple_diff.rst index 07b8d4cb4..06d4a6a51 100644 --- a/docs/api/triple_diff.rst +++ b/docs/api/triple_diff.rst @@ -99,11 +99,6 @@ Results container for Triple Difference estimation. ~TripleDifferenceResults.to_dict ~TripleDifferenceResults.to_dataframe -Convenience Function --------------------- - -.. autofunction:: diff_diff.triple_difference - Estimation Methods ------------------ @@ -166,8 +161,7 @@ With covariates:: covariates=['age', 'education'] ) -Quick one-call estimation (the ``triple_difference()`` wrapper is -deprecated since 3.9 and removed in 4.0):: +Quick one-call estimation:: from diff_diff import TripleDifference, generate_ddd_data diff --git a/docs/api/trop.rst b/docs/api/trop.rst index 1015fc7b5..b4bcc05ef 100644 --- a/docs/api/trop.rst +++ b/docs/api/trop.rst @@ -68,11 +68,6 @@ Results container for TROP estimation. ~TROPResults.get_unit_effects_df ~TROPResults.get_time_effects_df -Convenience Function --------------------- - -.. autofunction:: diff_diff.trop - Tuning Parameters ----------------- @@ -228,8 +223,7 @@ Basic usage:: ) results.print_summary() -Quick one-call estimation (the ``trop()`` wrapper is deprecated since -3.9 and removed in 4.0):: +Quick one-call estimation:: from diff_diff import TROP diff --git a/docs/api/two_stage.rst b/docs/api/two_stage.rst index a6a2072d2..fe58f1e70 100644 --- a/docs/api/two_stage.rst +++ b/docs/api/two_stage.rst @@ -85,11 +85,6 @@ Bootstrap inference results. :undoc-members: :show-inheritance: -Convenience Function --------------------- - -.. autofunction:: diff_diff.two_stage_did - Example Usage ------------- diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 1797eabe9..faedec817 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2225,7 +2225,7 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - [x] Supports balanced and unbalanced panels (iterative Gauss-Seidel demeaning for exact FE) - [x] Event study and group aggregation -- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did` wrapper forwards the shared sentinel so plain wrapper calls never fire the aggregate warning; since 3.9 the wrapper itself warns per M-070) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run without the deprecated fit-time kwarg; the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (bootstrap, missing kit, the replicate gate above) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did()` wrapper that used to forward the shared sentinel was removed at 4.0 per M-070 - PR 2 of the cut) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run without the deprecated fit-time kwarg; the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (bootstrap, missing kit, the replicate gate above) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. --- @@ -2319,7 +2319,7 @@ Our implementation uses multiplier bootstrap on the GMM influence function: clus - [x] Multiplier bootstrap on GMM influence function - [x] Event study and overall ATT aggregation -- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did` wrapper forwards the shared sentinel so plain wrapper calls never fire the aggregate warning; since 3.9 the wrapper itself warns per M-071) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class; the pre-filter |Omega_1| count, while the ATT's Stage-2 support excludes rows whose `y_tilde` is non-finite - on such degenerate fits `n` exceeds the averaged support) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits; the recomputed joint vcov drives the joint-Wald path) and `heterogeneity` checks run without the deprecated fit-time kwarg; re-emitted recompute warnings are captured and re-published on the consuming report section, and derivation failures surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the POST-FILTER treatment-indicator support the second stage actually regressed on (`omega_1_mask` minus non-finite `_y_tilde` rows, computed inline from the kit's private column-subset frame COPY - snapshot-grade, no fit-time stash needed, and the fit-time masker is never re-called so its warnings are not re-emitted; `score_pad_mask` is irrelevant here - it pads scores/variance only). This is the post-filter count 'simple''s raw pre-filter `n` documents as exceeding on degenerate fits. `D.sum() == 0` -> NaN mass -> all-NaN row (mirrors the fit's `(nan, nan)`); finite `C` relays verbatim. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did()` wrapper that used to forward the shared sentinel was removed at 4.0 per M-071 - PR 2 of the cut) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class; the pre-filter |Omega_1| count, while the ATT's Stage-2 support excludes rows whose `y_tilde` is non-finite - on such degenerate fits `n` exceeds the averaged support) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits; the recomputed joint vcov drives the joint-Wald path) and `heterogeneity` checks run without the deprecated fit-time kwarg; re-emitted recompute warnings are captured and re-published on the consuming report section, and derivation failures surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the POST-FILTER treatment-indicator support the second stage actually regressed on (`omega_1_mask` minus non-finite `_y_tilde` rows, computed inline from the kit's private column-subset frame COPY - snapshot-grade, no fit-time stash needed, and the fit-time masker is never re-called so its warnings are not re-emitted; `score_pad_mask` is irrelevant here - it pads scores/variance only). This is the post-filter count 'simple''s raw pre-filter `n` documents as exceeding on degenerate fits. `D.sum() == 0` -> NaN mass -> all-NaN row (mirrors the fit's `(nan, nan)`); finite `C` relays verbatim. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. --- @@ -3976,8 +3976,9 @@ where `G` is the number of clusters, `ψ_c = Σ_{i∈c} IF_i` is the sum of the influence function within cluster `c`, and the `G/(G-1)` factor is the Liang-Zeger finite-sample adjustment. -Note: IF-based SEs are inherently heteroskedasticity-robust; the `robust` parameter -has no additional effect. +Note: IF-based SEs are inherently heteroskedasticity-robust; the pre-4.0 `robust` +constructor parameter had no additional effect and was removed at 4.0 (PR 2 of the +cut; rows M-045..M-047). **Note (panel-shaped input, `generate_ddd_panel_data`):** `TripleDifference` is the repeated-cross-section `panel=FALSE` implementation: the individual-level default SE @@ -4109,18 +4110,14 @@ shared verbatim. the shared `utils.validate_pscore_trim` (`0 < x < 0.5`, type guard, `float` coercion), with the deprecated `StaggeredTripleDifference` deliberately keeping its permissive construction shape (M-013/M-144 posture). -- **Note (the `triple_difference()` wrapper stays 2x2x2-only):** the deprecated - functional wrapper is deliberately NOT extended to staggered mode. It reaches - only the 2x2x2 design and forwards its own `time=` as `post=`. Three reasons, - all program-level rather than local: the wrapper is itself deprecated in 3.9 - and removed at 4.0 (row M-075), so widening it would mint new removal - obligations for params introduced solely to be deleted; phase 3(a) set the - precedent by extending no wrapper with its new event-study mode; and the - wrapper's own deprecation message already steers to the class, which is where - staggered mode lives. A caller who needs staggered DDD migrates to - `TripleDifference(...).fit(..., first_treat=...)` - the migration the row - exists to produce. Pinned by - `tests/test_v4_merge_ddd.py::TestGateHConsumers::test_deprecated_wrapper_stays_2x2x2_only`. +- **Note (the `triple_difference()` wrapper was removed at 4.0, PR 2 of the cut):** + the functional wrapper (deprecated 3.9, row M-075) reached only the 2x2x2 + design and forwarded its own `time=` as `post=`; it was deliberately never + extended to staggered mode, so that widening a dying surface would not mint + new removal obligations (phase 3(a) set the precedent by extending no wrapper + with its new event-study mode). Staggered DDD lives on the class only: + `TripleDifference(...).fit(..., first_treat=...)`. The 4.0 removal is pinned + by `tests/test_v4_wrapper_shims.py`. - **Note (bootstrap `seed` is estimator state, not results provenance):** the staggered container does NOT carry the `seed` that generated its bootstrap SEs, p-values and sup-t bands; it is readable from the estimator @@ -5040,7 +5037,7 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **`TwoWayFixedEffects(vcov_type in {"hc2","hc2_bm"})` — SUPPORTED (inline full-dummy build).** TWFE has no `absorb=` / `fixed_effects=` parameter (the unit + time FE are baked into the estimator's identity), so the same parameter-swap auto-route used for DiD-absorb / MPD-absorb is not directly applicable. Instead, `TwoWayFixedEffects.fit()` bypasses the within-transform when `vcov_type in {"hc2","hc2_bm"}` and builds the full-dummy design `[intercept, treated×post, covariates, factor(unit), factor(time)]` explicitly, then runs OLS through the standard `solve_ols` path so the leverage correction and BM DOF compute on the full FE projection. Verified at atol=1e-10 vs `lm(y ~ treat_post + factor(unit) + factor(post)) + sandwich::vcovHC(type="HC2")` for HC2 and vs `clubSandwich::vcovCR(cluster=seq_len(n), type="CR2")` for the singleton-cluster one-way HC2-BM Satterthwaite DOF; vs `vcovCR(cluster=unit, type="CR2")` for the auto-cluster CR2-BM path. **Auto-cluster default (non-survey analytical path):** TWFE's unit auto-cluster is preserved on `hc2_bm` (routes to CR2-BM at unit) and on `hc2 + wild_bootstrap` (the bootstrap consumes the cluster structure for resampling regardless of the analytical sandwich choice); dropped on explicit `hc2 + analytical` to match the one-way contract (the linalg validator rejects `hc2 + cluster_ids`). `hc2_bm + analytical` with no explicit cluster yields the auto-cluster CR2-BM path. **Survey-design exception:** under `survey_design=` with no explicit `cluster=`, TWFE intentionally keeps the documented implicit-PSU path (the auto-cluster is NOT injected into the survey PSU structure); users who want unit-level PSU injection under a survey design must pass explicit `cluster="unit"` or set `survey_design.psu` directly. **User-visible surface change** (matches the DiD-absorb / MPD-absorb disclosure above): under HC2 / HC2-BM, `result.coefficients`, `result.vcov`, `result.residuals`, `result.fitted_values`, and `result.r_squared` reflect the full-dummy fit rather than the within-transformed reduced fit (FE-dummy entries are included, `r_squared` is computed on the un-demeaned outcome, residuals/fitted are on the original scale). `result.att`, its SE, and analytical inference are unchanged (FWL-equivalent). HC1 / CR1 / Conley / classical paths remain on the within-transform (no leverage term in those vcov families). **Survey-design scope** (mirrors the DiD-absorb auto-route contract above): when `survey_design=` is supplied, the existing survey variance path (Taylor-series linearization for analytical-weight designs, or replicate-weight variance for BRR/Fay/JK1/JKn/SDR) takes precedence over the analytical HC2/HC2-BM sandwich; the full-dummy build only changes the FE handling (removing the prior reject) and does not redirect to the analytical small-sample sandwich on survey fits. **Replicate-weight survey designs** are blocked at the estimator level: `vcov_type in {"hc2","hc2_bm"}` + replicate weights raises `NotImplementedError` because the replicate refit path re-demeans per replicate, which doesn't compose with the full-dummy build (would require per-replicate full-dummy refit); workaround: use `vcov_type="hc1"` for replicate-weight CR1. `hc2_bm + weights` (Gates 4-5) is now lifted via the clubSandwich WLS-CR2 port; the survey-design rejection here is a separate estimator-level gate (analytical sandwich vs survey TSL precedence), independent of the linalg validator. - **`MultiPeriodDiD(absorb=..., vcov_type in {"hc2","hc2_bm"})` — SUPPORTED (auto-route).** Same auto-route pattern as `DifferenceInDifferences`: `MultiPeriodDiD.fit()` internally promotes the absorb columns to `fixed_effects=` for HC2 / HC2-BM callers, so the existing full-dummy code path computes the algebraically correct vcov from the full FE projection on the event-study design (`treated + period_X dummies + treated:period_X interactions + factor(unit)`). Verified at ~1e-10 vs `lm() + sandwich::vcovHC(type="HC2")` and `lm() + clubSandwich::vcovCR(cluster=1:n, type="CR2")` on a 5-cohort × 5-period event-study fixture; the parity target is a per-period interaction `treated:period_X` because MPD requires the `treated` column to be a time-invariant ever-treated indicator, which lies in the span of the intercept and the post-auto-route unit FE dummies (under `pd.get_dummies(..., drop_first=True)` the dropped reference unit is implicit in the intercept, so the exact alias relation depends on the omitted FE category — it is NOT simply "the sum of treated-cohort unit dummies"). `solve_ols` drops one column from the collinear set under R-style rank-deficiency handling; in the shipped parity fixture (4 ever-treated cohorts of 5 units + 1 never-treated cohort of 5 units) it drops a unit dummy from the never-treated cohort (`unit_25`) and the `treated` main effect remains finite, but the specific column that gets NaN'd is pivot-order and dummy-coding dependent. Either way, the slope coefficients (`treated:period_X`) and the post-period-average `avg_att` are identified and invariant to which column was dropped. Same `MultiPeriodDiDResults` surface change as DiD: `vcov`, `residuals`, `fitted_values`, `r_squared`, and `coefficients` reflect the full-dummy fit, with `period_effects[t].effect` / `.se` / `.p_value` / `.conf_int` invariant by FWL. HC1/CR1 paths on `absorb=` are unchanged (no leverage term). Same survey-design scope as DiD: replicate-weight variance routes through the standard `compute_replicate_vcov` path on the fixed full-dummy design rather than the per-replicate refit branch (which targets the demeaning path); since the auto-routed design does not depend on replicate weights, no refit is needed. **Redundant time-FE skip:** when the routed (or directly-supplied) `fixed_effects` list contains the `time` column, MPD silently skips emitting `