diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec596ba..fa8b9787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Deprecated +- **Semantic rename wave** (v4 program 2(c)-ii PR-B, ledger rows [M-030] + [M-031] [M-043] [M-044] [M-045]..[M-047] [M-084] [M-086] [M-087] [M-095] + [M-115] plus the missed-rename amendments [M-136]..[M-138]): the + judgment-heavy renames land with 3.9 `FutureWarning` shims (removal in + 4.0; positional calls unaffected; deprecated paths route to + bit-identical numbers; both-spellings calls raise `ValueError`). + - `DifferenceInDifferences.fit`: `time=` -> `post=` [M-030] (the 2x2 + 0/1 post-period indicator - the calendar `time=` vocabulary on every + other estimator is unchanged; `TwoWayFixedEffects.fit(time=)` moves + in a later phase with [M-082]). `TripleDifference.fit`: `time=` -> + `post=` [M-031]; its warning notes that from 4.0 `time=` means the + calendar column only ([M-085]). The `permutation_test` / + `leave_one_out_test` diagnostics follow with `time=` -> `post=` + [M-137] [M-138]; the `run_placebo_test`/`run_all_placebo_tests` + wrappers keep their overloaded `time` (redesign tracked in TODO.md). + - `robust=` constructor param deprecated everywhere it exists [M-045] + [M-046] [M-047] [M-115] (`DifferenceInDifferences` incl. inherited + `TwoWayFixedEffects`/`MultiPeriodDiD`, `TripleDifference`, + `HeterogeneousAdoptionDiD`, `LinearRegression`): use `vcov_type=`. + Through 3.9 the public `.robust` attribute keeps the RESOLVED legacy + bool; `get_params()` returns the raw sentinel-era arg (None when not + supplied) so clone round-trips stay silent. An instance CONFIGURED + via `robust=` re-warns on `set_params` probe re-init. + - `StackedDiD(clean_control=)` -> `control_group=` [M-043] with a + deprecated warning-property alias on the estimator, and the results + field `StackedDiDResults.clean_control` -> `.control_group` [M-095] + (alias property + `__setstate__` pickle migration; `to_dict()` and + the business-report block emit BOTH keys through 3.9; + `summary()` prints "Control group:"). + - `WooldridgeDiDResults`: `aggregate(type="event")` -> + `type="event_study"` [M-086] (`aggregation_weights` carries both + keys through 3.9, old pickles are mirrored on load); + `to_dataframe(aggregation=)` -> `to_dataframe(level=)` [M-044] with + the default resolving to `"event_study"`; `summary(aggregation=)` is + deprecated [M-087] - `summary()` renders the simple row and the new + KEYWORD-ONLY `summary(alpha=)` controls the CI label (uniform + positional `summary(alpha=None)` arrives at 4.0). + `LPDiDResults.to_dataframe(level="event")` -> `"event_study"` + [M-136] (default flips to the canonical spelling; identical frame). + - `ContinuousDiD(covariates=)` constructor -> `fit(..., covariates=)` + [M-084] (the sklearn hyperparameter/data split; supplying both + raises). + - Note: sentinel-defaulted shim parameters are transitionally annotated + `Any`; the 4.0 removal restores the narrow annotations. + - **Mechanical rename wave** (v4 program 2(c)-ii, ledger rows [M-032] [M-033] [M-034] [M-035]..[M-042] [M-088] [M-089] [M-094] [M-097]..[M-113] [M-114]): the following parameters/fields gain their canonical names now, diff --git a/README.md b/README.md index 8c203bf3..2aa3e054 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ data = pd.DataFrame({ }) did = DifferenceInDifferences() -results = did.fit(data, outcome='outcome', treatment='treated', time='post') +results = did.fit(data, outcome='outcome', treatment='treated', post='post') print(results) # DiDResults(ATT=3.0000, SE=1.7321, p=0.1583) results.print_summary() # full statsmodels-style table ``` diff --git a/TODO.md b/TODO.md index 63b68b44..01436c8e 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,7 @@ Related tracking surfaces: | Issue | Location | Origin | Effort | Priority | |-------|----------|--------|--------|----------| +| `run_placebo_test`/`run_all_placebo_tests` expose ONE overloaded `time` param (calendar for timing/group tests, 0/1 post dummy forwarded into the M-137/M-138 callees - works only because a two-period 0/1 calendar column is both); redesign the wrapper signatures (dual `time=`/`post=` params or a split) so each callee gets an honestly-named argument | `diff_diff/diagnostics.py` | 2(c)-ii PR-B | Mid | Medium | | `absorbed_fe_rank` N>=3 general rank: the helper keeps `sum(levels-1)` for 3+ absorbed dims, exact for independent connected dims but an over-count for duplicated/nested triples (measured `a(5),b(4),c==b(4)`: true 7 vs formula 10) and for disconnected N-way graphs. Two-way is component-exact. Deriving general N-way FE rank is a hypergraph problem; do it with a reference (fixest's `fixef.rm`/reghdfe df_a) rather than a guess. See `docs/methodology/variance-conventions.md` D3. | `diff_diff/utils.py` | #variance-inventory | Mid | Low | | `SyntheticControl` conformal (CWZ 2021) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies — the residual-permutation shortcut is only valid for time-permutation-invariant proxies (SC/Lasso/DiD); an AR proxy needs innovation permutation. | `diff_diff/conformal.py`, `diff_diff/synthetic_control_results.py` | CWZ-2021 | Heavy | Low | | Make the post-fit `results.aggregate("event_study")` container consumable downstream. `EventStudyResults` is rejected by all THREE consumers that read a CS event study — `compute_honest_did` (`honest_did.py`, dispatches on `CallawaySantAnnaResults` and raises `TypeError`), `compute_pretrends_power` (`pretrends.py`, same), and `plot_event_study` (`visualization`, same) — so `fit(aggregate="event_study")` is still the only route for them and their error messages say so explicitly. Needs an `EventStudyResults` branch in each extraction path (consuming `event_time` / `is_reference` / `vcov` / `vcov_index` / per-row `df`) PLUS `base_period` and `anticipation` provenance, which the unified container does not carry and HonestDiD needs for its universal-base-period warning and pre-period classification. Gate with end-to-end tests: `compute_honest_did(res.aggregate("event_study"))` at `base_period="universal"`, and `compute_pretrends_power(...)` at `anticipation=1`. | `diff_diff/honest_did.py`, `diff_diff/pretrends.py`, `diff_diff/results_base.py` | #726 | Mid | Medium | diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index 7ce1422c..9e95788d 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -71,7 +71,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: ETWFE (``method="logit"`` / ``"poisson"``) it is the average-structural-function (ASF) contrast across cohort x time cells. Both paths preserve ``overall_att`` across - ``.aggregate("event")`` calls (which only populate additional + ``.aggregate("event_study")`` calls (which only populate additional event-study tables). """ name = type(results).__name__ @@ -197,7 +197,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: } if name == "StackedDiDResults": - clean_control = getattr(results, "clean_control", None) + # M-095: the field is control_group; __setstate__ migration + # guarantees it exists even on pre-rename pickles, so no + # clean_control fallback (a getattr default would silently + # misreport the comparison group after the 4.0 removal). + clean_control = getattr(results, "control_group", None) if clean_control == "never_treated": control_clause = "Controls are the never-treated units (``A_s = infinity``)." elif clean_control == "strict": @@ -253,7 +257,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "saturated regression fits cohort x time ATT(g, t) " "coefficients, and ``overall_att`` is their " "observation-count-weighted average across post-" - 'treatment cells. Calling ``.aggregate("event")`` ' + 'treatment cells. Calling ``.aggregate("event_study")`` ' "populates additional event-study tables but does NOT " "change the ``overall_att`` scalar." ), @@ -273,7 +277,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: f"outcomes averaged across cohort x time cells with " f"observation-count weights. The ASF handles the " f"nonlinearity; OLS ETWFE uses the saturated-regression " - f'coefficient path instead. Calling ``.aggregate("event")`` ' + f'coefficient path instead. Calling ``.aggregate("event_study")`` ' f"populates additional event-study tables but does NOT " f"change the ``overall_att`` scalar." ), diff --git a/diff_diff/agent_workflow.py b/diff_diff/agent_workflow.py index 7fcb2e9b..45ca0167 100644 --- a/diff_diff/agent_workflow.py +++ b/diff_diff/agent_workflow.py @@ -190,7 +190,7 @@ def agent_workflow( ] else: fit_example_kwargs = _join_kwargs( - outcome=outcome, unit=unit, time=time, treatment=treatment + outcome=outcome, unit=unit, post=time, treatment=treatment ) fit_example_call = ( f"diff_diff.DifferenceInDifferences().fit({df_name}, {fit_example_kwargs})" diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 40fdbf58..8f11cdfd 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -1075,21 +1075,15 @@ def _control_group_choice(results: Any) -> Optional[str]: """Return the control-group choice string for a fitted result, normalized across estimator-specific attribute names. - Most anticipation-capable estimators expose the control-group choice as - ``results.control_group``. ``StackedDiDResults`` exposes the same choice - as ``clean_control`` (the public Wing-Freedman-Hollingsworth-2024 kwarg - name). Without this alias, a StackedDiD fit with - ``clean_control="not_yet_treated"`` would surface as ``control_group=None`` - in the business-report schema, and the dynamic-control branch in - ``_extract_sample`` would never fire. + 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). """ cg = getattr(results, "control_group", None) if isinstance(cg, str): return cg - if type(results).__name__ == "StackedDiDResults": - clean = getattr(results, "clean_control", None) - if isinstance(clean, str): - return clean return None @@ -1418,11 +1412,11 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, # within the data range) and IC2 (clean controls exist for the # 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 ``clean_control`` rule - # determines which units qualify as valid controls for each - # adoption event. REGISTRY.md §StackedDiD lines 1189-1193 - # (identification) and 1234-1256 (clean-control rules). - clean_control = getattr(results, "clean_control", None) + # 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": control_clause = ( "controls are restricted to units that are never treated " @@ -1463,6 +1457,8 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, } 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 return block if estimator_name == "ImputationDiDResults": @@ -2358,7 +2354,7 @@ def _render_summary(schema: Dict[str, Any]) -> str: # Sample sentence. For fits with a dynamic comparison set (CS / # ContinuousDiD / StaggeredTripleDiff / EfficientDiD / - # StackedDiD under ``clean_control in {"not_yet_treated", + # StackedDiD under ``control_group in {"not_yet_treated", # "strict"}``) the fixed control count is suppressed because the # comparison group varies by cohort/sub-experiment; narrate the # mode explicitly rather than misreporting a fixed-subset tally as @@ -2408,7 +2404,7 @@ def _render_summary(schema: Dict[str, Any]) -> str: sentences.append( f"Sample: {n_obs:,} observations ({n_t:,} treated) with a " f"sub-experiment-specific clean-control comparison " - f"(``clean_control='{cc_label}'``): each adoption event is " + f"(``control_group='{cc_label}'``): each adoption event is " f"compared against the units satisfying the rule relative " f"to that event's window, not a single fixed control " f"group{distinct_clause}{subset_clause}." @@ -2668,7 +2664,7 @@ def _render_full_report(schema: Dict[str, Any]) -> str: cc_label = cg if isinstance(cg, str) else "clean_control" lines.append( f"- Comparison group: sub-experiment-specific clean controls " - f"(``clean_control='{cc_label}'``; each adoption event is " + f"(``control_group='{cc_label}'``; each adoption event is " "compared against units satisfying the rule relative to that " "event's window, not a single fixed control group)" ) diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index 5dca5247..d361f80d 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -16,6 +16,7 @@ import pandas as pd from diff_diff._base import BaseEstimator +from diff_diff._deprecation import warn_deprecated_kwarg from diff_diff.bootstrap_utils import ( compute_effect_bootstrap_stats, generate_bootstrap_weights_batch, @@ -88,6 +89,9 @@ class ContinuousDiD(BaseEstimator): 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 @@ -178,6 +182,16 @@ 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 @@ -249,6 +263,7 @@ def fit( dose: str, aggregate: Optional[str] = None, survey_design: Optional["SurveyDesign"] = None, + covariates: Optional[List[str]] = None, ) -> ContinuousDiDResults: """ Fit the continuous DiD estimator. @@ -275,6 +290,12 @@ def fit( Supports weighted estimation and Taylor series linearization 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``. + Returns ------- ContinuousDiDResults @@ -297,8 +318,26 @@ 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 + if self.control_group == "lowest_dose" and effective_covariates is not None: + # Mirror of the constructor-time guard for the fit-level spec. + 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." + ) + df = data.copy() - cov_cols = list(self.covariates) if self.covariates else [] + cov_cols = list(effective_covariates) if effective_covariates else [] for col in [outcome, unit, time, first_treat, dose, *cov_cols]: if col not in df.columns: raise ValueError(f"Column '{col}' not found in data.") @@ -584,7 +623,7 @@ def fit( dose, time_periods, survey_weights=survey_weights, - covariates=self.covariates, + covariates=effective_covariates, ) # Thread the lowest-dose reference d_L (Remark 3.1) to the per-cell # dose-response so it swaps the control group and shifts the discrete @@ -1107,7 +1146,7 @@ def fit( reference_dose=reference_dose_out, alpha=self.alpha, control_group=self.control_group, - covariates=self.covariates, + covariates=effective_covariates, estimation_method=self.estimation_method, pscore_trim=self.pscore_trim, epv_threshold=self.epv_threshold, diff --git a/diff_diff/datasets.py b/diff_diff/datasets.py index 26f35069..99a0a37e 100644 --- a/diff_diff/datasets.py +++ b/diff_diff/datasets.py @@ -844,7 +844,7 @@ def load_card_krueger(force_download: bool = False) -> pd.DataFrame: >>> >>> # Estimate DiD >>> did = DifferenceInDifferences() - >>> results = did.fit(ck_long, outcome='employment', treatment='treated', time='post') + >>> results = did.fit(ck_long, outcome='employment', treatment='treated', post='post') """ return _load_verified_dataset( cache_name="card_krueger", @@ -1506,7 +1506,7 @@ def load_prop99(force_download: bool = False) -> pd.DataFrame: >>> >>> did = DifferenceInDifferences() >>> results = did.fit( - ... prop99, outcome="lcigsale", treatment="treated_state", time="post" + ... prop99, outcome="lcigsale", treatment="treated_state", post="post" ... ) """ url = "http://fmwww.bc.edu/repec/bocode/l/lw_smoking.dta" diff --git a/diff_diff/diagnostics.py b/diff_diff/diagnostics.py index a193d769..5cfcb4f6 100644 --- a/diff_diff/diagnostics.py +++ b/diff_diff/diagnostics.py @@ -17,6 +17,7 @@ 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 @@ -325,7 +326,7 @@ def run_placebo_test( data=data, outcome=outcome, treatment=treatment, - time=time, + post=time, unit=unit, n_permutations=n_permutations, alpha=alpha, @@ -340,7 +341,7 @@ def run_placebo_test( data=data, outcome=outcome, treatment=treatment, - time=time, + post=time, unit=unit, alpha=alpha, **estimator_kwargs, @@ -415,13 +416,13 @@ def placebo_timing_test( # Fit DiD on pre-treatment data with fake post did = DifferenceInDifferences(**estimator_kwargs) - results = did.fit(pre_data, outcome=outcome, treatment=treatment, time="_fake_post") + results = did.fit(pre_data, outcome=outcome, treatment=treatment, post="_fake_post") # Also fit on full data for comparison data_with_post = data.copy() data_with_post["_post"] = data_with_post[time].isin(post_periods).astype(int) did_full = DifferenceInDifferences(**estimator_kwargs) - results_full = did_full.fit(data_with_post, outcome=outcome, treatment=treatment, time="_post") + results_full = did_full.fit(data_with_post, outcome=outcome, treatment=treatment, post="_post") return PlaceboTestResults( test_type="fake_timing", @@ -547,7 +548,7 @@ def placebo_group_test( # Fit DiD did = DifferenceInDifferences(**estimator_kwargs) - results = did.fit(fake_data, outcome=outcome, treatment="_fake_treated", time="_post") + results = did.fit(fake_data, outcome=outcome, treatment="_fake_treated", post="_post") # Record the fake-treated units actually used (after any never-treated # filtering), not just the originally requested list, to avoid metadata drift. @@ -573,11 +574,12 @@ def permutation_test( data: pd.DataFrame, outcome: str, treatment: str, - time: str, - unit: str, + post: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, n_permutations: int = 1000, alpha: float = 0.05, seed: Optional[int] = None, + time: Any = NOT_SUPPLIED, **estimator_kwargs, ) -> PlaceboTestResults: """ @@ -599,8 +601,9 @@ def permutation_test( Outcome variable column. treatment : str Treatment indicator column. - time : str - Time period column. + post : str + Post-period indicator column (0/1). (``time`` is a deprecated + alias; warns with ``FutureWarning`` and will be removed in 4.0.) unit : str Unit identifier column. n_permutations : int, default=1000 @@ -635,7 +638,13 @@ def permutation_test( # First, fit original model did = DifferenceInDifferences(**estimator_kwargs) - original_results = did.fit(data, outcome=outcome, treatment=treatment, time=time) + 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 # Get unit-level treatment assignment @@ -658,7 +667,7 @@ def permutation_test( try: perm_did = DifferenceInDifferences(**estimator_kwargs) perm_results = perm_did.fit( - perm_data, outcome=outcome, treatment="_perm_treatment", time=time + perm_data, outcome=outcome, treatment="_perm_treatment", post=time ) permuted_effects[i] = perm_results.att except (ValueError, KeyError, np.linalg.LinAlgError): @@ -727,9 +736,10 @@ def leave_one_out_test( data: pd.DataFrame, outcome: str, treatment: str, - time: str, - unit: str, + post: Any = NOT_SUPPLIED, + unit: Any = NOT_SUPPLIED, alpha: float = 0.05, + time: Any = NOT_SUPPLIED, **estimator_kwargs, ) -> PlaceboTestResults: """ @@ -746,8 +756,9 @@ def leave_one_out_test( Outcome variable column. treatment : str Treatment indicator column. - time : str - Time period column. + post : str + Post-period indicator column (0/1). (``time`` is a deprecated + alias; warns with ``FutureWarning`` and will be removed in 4.0.) unit : str Unit identifier column. alpha : float, default=0.05 @@ -762,7 +773,13 @@ def leave_one_out_test( """ # Fit original model did = DifferenceInDifferences(**estimator_kwargs) - original_results = did.fit(data, outcome=outcome, treatment=treatment, time=time) + 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 # Get treated units @@ -781,7 +798,7 @@ def leave_one_out_test( try: loo_did = DifferenceInDifferences(**estimator_kwargs) - loo_results = loo_did.fit(loo_data, outcome=outcome, treatment=treatment, time=time) + loo_results = loo_did.fit(loo_data, outcome=outcome, treatment=treatment, post=time) loo_effects[u] = loo_results.att except (ValueError, KeyError, np.linalg.LinAlgError): # Skip units that cause fitting issues @@ -917,7 +934,7 @@ def run_all_placebo_tests( data=data, outcome=outcome, treatment=treatment, - time=time, + post=time, unit=unit, n_permutations=n_permutations, alpha=alpha, @@ -938,7 +955,7 @@ def run_all_placebo_tests( data=data, outcome=outcome, treatment=treatment, - time=time, + post=time, unit=unit, alpha=alpha, **estimator_kwargs, diff --git a/diff_diff/estimators.py b/diff_diff/estimators.py index 77b35178..6ed8f1ee 100644 --- a/diff_diff/estimators.py +++ b/diff_diff/estimators.py @@ -20,6 +20,11 @@ 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, @@ -59,11 +64,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, default=True - Legacy alias for ``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). + 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 @@ -178,7 +185,7 @@ class DifferenceInDifferences(BaseEstimator): >>> >>> # Fit the model >>> did = DifferenceInDifferences() - >>> results = did.fit(data, outcome='outcome', treatment='treated', time='post') + >>> results = did.fit(data, outcome='outcome', treatment='treated', post='post') >>> >>> # View results >>> print(results.att) # ATT estimate @@ -204,7 +211,7 @@ class DifferenceInDifferences(BaseEstimator): def __init__( self, - robust: bool = True, + robust: Optional[bool] = None, cluster: Optional[str] = None, vcov_type: Optional[str] = None, alpha: float = 0.05, @@ -227,7 +234,16 @@ def __init__( validate_df_convention(df_convention) - self.robust = robust + # `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) # Preserve the raw constructor arg (possibly None) alongside the @@ -276,13 +292,14 @@ def fit( data: pd.DataFrame, outcome: Optional[str] = None, treatment: Optional[str] = None, - time: Optional[str] = None, + post: Any = NOT_SUPPLIED, 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. @@ -295,11 +312,11 @@ def fit( Name of the outcome variable column. treatment : str Name of the treatment group indicator column (0/1). - time : str + post : str Name of the post-treatment period indicator column (0/1). formula : str, optional R-style formula (e.g., "outcome ~ treated * post"). - If provided, overrides outcome, treatment, and time parameters. + If provided, overrides outcome, treatment, and post parameters. covariates : list, optional List of covariate column names to include as linear controls. Names must not collide with reserved structural terms (``const``, @@ -346,20 +363,29 @@ def fit( -------- Using fixed effects (dummy variables): - >>> did.fit(data, outcome='sales', treatment='treated', time='post', + >>> did.fit(data, outcome='sales', treatment='treated', post='post', ... fixed_effects=['state', 'industry']) Using absorbed fixed effects (within-transformation): - >>> did.fit(data, outcome='sales', treatment='treated', time='post', + >>> 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). + time = post # Parse formula if provided if formula is not None: outcome, treatment, time, covariates = self._parse_formula(formula, data) elif outcome is None or treatment is None or time is None: raise ValueError( - "Must provide either 'formula' or all of 'outcome', 'treatment', and 'time'" + "Must provide either 'formula' or all of 'outcome', 'treatment', and 'post'" ) # Validate inputs @@ -1186,8 +1212,13 @@ def predict(self, data: pd.DataFrame) -> np.ndarray: # 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"} - _DERIVED_CONFIG_ATTRS = ("vcov_type", "_vcov_type_arg", "_vcov_type_explicit") + _PARAM_ATTR_ALIASES = {"vcov_type": "_vcov_type_arg", "robust": "_robust_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]: @@ -1306,11 +1337,13 @@ class MultiPeriodDiD(DifferenceInDifferences): Parameters ---------- - robust : bool, default=True - Legacy alias for ``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). + 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 diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 0339c374..f89639d3 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -19,7 +19,7 @@ data = generate_did_data(n_units=200, treatment_effect=5.0, seed=42) # Fit basic 2x2 DiD did = DifferenceInDifferences() -results = did.fit(data, outcome='outcome', treatment='treated', time='post') +results = did.fit(data, outcome='outcome', treatment='treated', post='post') print(results.summary()) print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})") ``` @@ -52,7 +52,7 @@ Basic 2x2 Difference-in-Differences estimator. ```python DifferenceInDifferences( - robust: bool = True, # HC1 robust standard errors + vcov_type: str | None = None, # Variance family: "hc1" (default), "classical", "hc2", "hc2_bm", "conley" cluster: str | None = None, # Column for cluster-robust SEs alpha: float = 0.05, # Significance level inference: str = "analytical", # "analytical" or "wild_bootstrap" @@ -73,7 +73,7 @@ did.fit( data: pd.DataFrame, outcome: str = None, # Outcome variable column treatment: str = None, # Treatment indicator column (0/1) - time: str = None, # Post-treatment indicator column (0/1) + post: str = None, # Post-treatment indicator column (0/1) formula: str = None, # R-style formula (e.g., "y ~ treated * post") covariates: list[str] = None, # Linear control variables fixed_effects: list[str] = None, # Low-dimensional FE (dummy variables) @@ -81,20 +81,22 @@ 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=`. + **Usage:** ```python from diff_diff import DifferenceInDifferences -did = DifferenceInDifferences(robust=True) -results = did.fit(data, outcome='y', treatment='treated', time='post') +did = DifferenceInDifferences() +results = did.fit(data, outcome='y', treatment='treated', post='post') results.print_summary() # Formula interface results = did.fit(data, formula='y ~ treated * post') # With covariates and fixed effects -results = did.fit(data, outcome='y', treatment='treated', time='post', +results = did.fit(data, outcome='y', treatment='treated', post='post', covariates=['age', 'income'], absorb=['firm_id']) ``` @@ -673,7 +675,6 @@ Triple Difference (DDD) estimator following Ortiz-Villavicencio & Sant'Anna (202 ```python TripleDifference( estimation_method: str = "dr", # "dr", "reg", or "ipw" - robust: bool = True, cluster: str | None = None, vcov_type: str = "hc1", # {"hc1"} only — IF-based variance per Ortiz-Villavicencio & Sant'Anna (2025). Analytical-sandwich {classical, hc2, hc2_bm} and conley REJECTED at __init__ (see REGISTRY.md IF-vs-sandwich subsection). alpha: float = 0.05, @@ -692,7 +693,7 @@ ddd.fit( outcome: str, group: str, # Treated group indicator (0/1) partition: str, # Eligible partition indicator (0/1) - time: str, # Post-treatment indicator (0/1) + post: str, # Post-treatment indicator (0/1) covariates: list[str] = None, ) -> TripleDifferenceResults ``` @@ -704,7 +705,7 @@ from diff_diff import TripleDifference ddd = TripleDifference(estimation_method="dr") results = ddd.fit(data, outcome='outcome', group='group', - partition='partition', time='post') + partition='partition', post='post') results.print_summary() ``` @@ -725,7 +726,7 @@ ContinuousDiD( bootstrap_weights: str = "rademacher", seed: int | None = None, rank_deficient_action: str = "warn", - covariates: list[str] | None = None, # Conditional parallel trends (X). None = unconditional + 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 @@ -762,6 +763,7 @@ cdid.fit( first_treat: str, dose: str, # Column with continuous treatment dose aggregate: str = None, # None, "dose", "eventstudy" + covariates: list[str] = None, # Conditional parallel trends (X). None = unconditional ) -> ContinuousDiDResults ``` @@ -787,7 +789,7 @@ 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 = False, # Mass-point only: HC1 robust SE shorthand + 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, @@ -848,7 +850,7 @@ es = est.fit(data_mp, outcome='y', unit='unit', **Staggered panels.** On multi-cohort panels with `aggregate="event_study"`, `fit()` auto-filters to the last treatment cohort plus never-treated units (paper Appendix B.2) and emits a `UserWarning` naming kept/dropped counts. The estimand is then a **last-cohort-only WAS**, not a multi-cohort average. For full multi-cohort staggered support, see `ChaisemartinDHaultfoeuille`. -**Mass-point + survey constraint.** When fitting `design="mass_point"` with `survey_design=SurveyDesign(...)`, `vcov_type="hc1"` (or `robust=True`) is required: the survey path composes the standard error via Binder-TSL on the HC1-scale influence function, so the default classical sandwich path raises `NotImplementedError` — on both the static path and the event-study path (the event-study rejection fires regardless of `cband`, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way). The one exception is `cluster=`: a clustered mass-point fit resolves to the CR1 sandwich regardless of `vcov_type` (and uses the clustered sup-t band), so there is no classical-vs-HC1 mismatch. Passing `vcov_type="hc1"` is a safe default on weighted survey + sup-t examples since `vcov_type` is unused on the continuous designs (CCT-2014 robust SE is the only formula there). +**Mass-point + survey constraint.** When fitting `design="mass_point"` with `survey_design=SurveyDesign(...)`, `vcov_type="hc1"` is required: the survey path composes the standard error via Binder-TSL on the HC1-scale influence function, so the default classical sandwich path raises `NotImplementedError` — on both the static path and the event-study path (the event-study rejection fires regardless of `cband`, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way). The one exception is `cluster=`: a clustered mass-point fit resolves to the CR1 sandwich regardless of `vcov_type` (and uses the clustered sup-t band), so there is no classical-vs-HC1 mismatch. Passing `vcov_type="hc1"` is a safe default on weighted survey + sup-t examples since `vcov_type` is unused on the continuous designs (CCT-2014 robust SE is the only formula there). ### RegressionDiscontinuity @@ -1003,7 +1005,8 @@ StackedDiD( kappa_pre: int = 1, # Pre-treatment event-time periods kappa_post: int = 1, # Post-treatment event-time periods weighting: str = "aggregate", # "aggregate", "population", or "sample_share" - clean_control: str = "not_yet_treated", # "not_yet_treated", "strict", or "never_treated" + 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, @@ -1428,7 +1431,7 @@ The 3.8 name `cohort=` remains accepted as a deprecated alias for `first_treat=` results.aggregate("simple") # Overall ATT results.aggregate("group") # ATT by cohort results.aggregate("calendar") # ATT by calendar period -results.aggregate("event") # ATT by event time (relative to treatment) +results.aggregate("event_study") # ATT by event time (relative to treatment) ``` **Usage:** @@ -1839,7 +1842,7 @@ Single-period results container for `HeterogeneousAdoptionDiD`. The table below | `survey_metadata` | `SurveyMetadata | None` | Repo-standard survey metadata when `survey_design=` is supplied | | `bandwidth_diagnostics` | `BandwidthResult | None` | MSE-DPI selector output (continuous designs); `None` on `mass_point` | | `bias_corrected_fit` | `BiasCorrectedFit | None` | Phase 1c bias-corrected local-linear fit object (continuous designs); `None` on `mass_point` | -| `variance_formula` | `str | None` | HAD-specific SE label on weighted fits, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `vcov_type="hc1"` / `robust=True` — the mass-point survey path rejects `vcov_type="classical"`, and `cluster=` + `survey_design=` is rejected, so PSU clustering is expressed via `SurveyDesign(weights='', psu='')`). `None` on unweighted fits | +| `variance_formula` | `str | None` | HAD-specific SE label on weighted fits, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `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='')`). `None` on unweighted fits | | `effective_dose_mean` | `float | None` | Weighted denominator used by the β̂-scale rescaling, populated on weighted fits across all designs: weighted `mean(d)` (`continuous_at_zero`), weighted `mean(d − d_lower)` (`continuous_near_d_lower`), or weighted Wald-IV dose gap `mean(d | Z=1, w) − mean(d | Z=0, w)` (`mass_point`). `None` on unweighted fits | **Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()` @@ -1872,7 +1875,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD` with `a | `bandwidth_diagnostics` | `list[BandwidthResult | None] | None` | Per-horizon MSE-DPI selector output (continuous designs); `None` on `mass_point`; entries can be `None` on degenerate horizons | | `bias_corrected_fit` | `list[BiasCorrectedFit | None] | None` | Per-horizon Phase 1c bias-corrected local-linear fit objects; `None` on `mass_point`; entries can be `None` on degenerate horizons | | `filter_info` | `dict | None` | Staggered last-cohort auto-filter metadata (`F_last`, `n_kept`, `n_dropped`, `dropped_cohorts`); `None` when no filter applied | -| `variance_formula` | `str | None` | HAD-specific SE label applied UNIFORMLY across all horizons, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `vcov_type="hc1"` / `robust=True` — the mass-point survey path rejects `vcov_type="classical"`, and `cluster=` + `survey_design=` is rejected, so PSU clustering is expressed via `SurveyDesign(weights='', psu='')`). `None` on unweighted fits | +| `variance_formula` | `str | None` | HAD-specific SE label applied UNIFORMLY across all horizons, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `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='')`). `None` on unweighted fits | | `effective_dose_mean` | `float | None` | Weighted denominator used by the β̂-scale rescaling, populated on weighted fits across all designs: weighted `sum(w·d)/sum(w)` (`continuous_at_zero`), weighted `sum(w·(d − d_lower))/sum(w)` (`continuous_near_d_lower`), or weighted Wald-IV dose gap (`mass_point`). Scalar (not per-horizon) because the β̂-scale denominator is computed once on the fit sample. `None` on unweighted fits | | `cband_low` | `np.ndarray | None` | Simultaneous (sup-t) band lower bounds; `None` when `cband=False` or on unweighted, unclustered fits (a clustered fit produces the band even when unweighted) | | `cband_high` | `np.ndarray | None` | Simultaneous (sup-t) band upper bounds | @@ -1948,13 +1951,15 @@ placebo_group_test(data, outcome, time, unit, fake_treated_units, post_periods=None, alpha=0.05) # Permutation test -permutation_test(data, outcome, treatment, time, unit, +permutation_test(data, outcome, treatment, post, unit, n_permutations=1000, alpha=0.05, seed=None) # Leave-one-out test -leave_one_out_test(data, outcome, treatment, time, unit, alpha=0.05) +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. + All return `PlaceboTestResults` with attributes: `test_type`, `placebo_effect`, `se`, `t_stat`, `p_value`, `conf_int`, `n_obs`, `is_significant`. ### Parallel Trends Testing @@ -1991,7 +1996,7 @@ from diff_diff import wild_bootstrap_se, WildBootstrapResults # Directly via estimator did = DifferenceInDifferences(inference="wild_bootstrap", n_bootstrap=999, bootstrap_weights="webb", cluster="state") -results = did.fit(data, outcome='y', treatment='treated', time='post') +results = did.fit(data, outcome='y', treatment='treated', post='post') ``` ## HAD Pretests @@ -2463,7 +2468,6 @@ from diff_diff import LinearRegression, InferenceResult # Low-level regression helper reg = LinearRegression( include_intercept=True, - robust=True, cluster_ids=cluster_array, ) reg.fit(X, y) @@ -2551,7 +2555,7 @@ did_res = DifferenceInDifferences( conley_coords=("lat", "lon"), conley_cutoff_km=200.0, conley_lag_cutoff=1, -).fit(data, outcome="y", treatment="treated", time="post", unit="unit_id") +).fit(data, outcome="y", treatment="treated", post="post", unit="unit_id") # Combined spatial + cluster product kernel: pass cluster= alongside # Conley to apply K_total[i,j] = K_space(d_ij/h) · 1{c_i = c_j}. On the diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index afe6c551..e158f468 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -297,8 +297,8 @@ print(results.summary()) ```python from diff_diff import DifferenceInDifferences -did = DifferenceInDifferences(robust=True, cluster='state_id') -results = did.fit(data, outcome='y', treatment='treated', time='post') +did = DifferenceInDifferences(cluster='state_id') +results = did.fit(data, outcome='y', treatment='treated', post='post') print(results.summary()) ``` @@ -380,7 +380,7 @@ designs but NOT for staggered, synthetic control, or other advanced estimators. **For non-2x2 estimators**, use specification-based falsification appropriate to your estimator's API. Examples: - **CS/SA**: compare `control_group='never_treated'` vs `'not_yet_treated'` -- **StackedDiD**: vary `clean_control` definition +- **StackedDiD**: vary `control_group` definition - **EfficientDiD**: compare `control_group='never_treated'` vs `'last_cohort'` - **ImputationDiD/TwoStageDiD**: leave-one-cohort-out, cross-estimator comparison - **SyntheticDiD**: built-in diagnostics on the results object - `results.in_time_placebo()`, `results.get_loo_effects_df()` (requires `variance_method="jackknife"` at fit time), `results.sensitivity_to_zeta_omega()`, and `results.get_weight_concentration()` diff --git a/diff_diff/had.py b/diff_diff/had.py index ef278728..e3ae0a5e 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -72,7 +72,12 @@ 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._deprecation import ( + NOT_SUPPLIED, + require_arg, + resolve_renamed_kwarg, + warn_deprecated_kwarg, +) from diff_diff.bootstrap_chunking import ( compute_block_size, iter_survey_multiplier_weight_blocks, @@ -2717,13 +2722,13 @@ class HeterogeneousAdoptionDiD(BaseEstimator): 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 - Backward-compat alias used only when ``vcov_type is None``: - ``True`` -> ``"hc1"``, ``False`` -> ``"classical"``. Explicit - ``vcov_type`` takes precedence (e.g., - ``vcov_type="classical", robust=True`` runs classical). Only - the mass-point path consumes these; continuous paths ignore - both with a warning. + 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. 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`` / @@ -2828,6 +2833,9 @@ class HeterogeneousAdoptionDiD(BaseEstimator): 'continuous_at_zero' """ + _PARAM_ATTR_ALIASES = {"robust": "_robust_arg"} + _DERIVED_CONFIG_ATTRS = ("robust",) + def __init__( self, design: str = "auto", @@ -2835,7 +2843,7 @@ def __init__( kernel: str = "epanechnikov", alpha: float = 0.05, vcov_type: Optional[str] = None, - robust: bool = False, + robust: Optional[bool] = None, cluster: Optional[str] = None, n_bootstrap: int = 999, seed: Optional[int] = None, @@ -2845,7 +2853,13 @@ def __init__( self.kernel = kernel self.alpha = alpha self.vcov_type = vcov_type - self.robust = robust + # `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; diff --git a/diff_diff/linalg.py b/diff_diff/linalg.py index c6d3f7d2..a0fd18a4 100644 --- a/diff_diff/linalg.py +++ b/diff_diff/linalg.py @@ -2044,8 +2044,10 @@ def effective_cluster_count(cluster_ids: np.ndarray, weights: Optional[np.ndarra def resolve_vcov_type( - robust: bool = True, + 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. @@ -2054,10 +2056,16 @@ def resolve_vcov_type( (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): + 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): - - If ``vcov_type`` is ``None``: map ``robust=True`` to ``"hc1"`` and - ``robust=False`` to ``"classical"``. + - ``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"``. - 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", "conley"}``. @@ -2066,10 +2074,14 @@ def resolve_vcov_type( Parameters ---------- - robust : bool, default True - Legacy alias. ``True`` == HC1; ``False`` == classical OLS SEs. + 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. Returns ------- @@ -2082,6 +2094,8 @@ def resolve_vcov_type( If ``vcov_type`` is not one of the allowed values, or if ``robust=False`` conflicts with an explicit non-classical ``vcov_type``. """ + if robust is None: + robust = legacy_default if vcov_type is None: return "hc1" if robust else "classical" if vcov_type not in _VALID_VCOV_TYPES: @@ -4286,9 +4300,11 @@ class LinearRegression: ---------- include_intercept : bool, default True Whether to automatically add an intercept column to the design matrix. - robust : bool, default True - Whether to use heteroskedasticity-robust (HC1) standard errors. - If False and cluster_ids is None, uses classical OLS standard errors. + 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. @@ -4434,7 +4450,7 @@ class LinearRegression: def __init__( self, include_intercept: bool = True, - robust: bool = True, + robust: Optional[bool] = None, cluster_ids: Optional[np.ndarray] = None, alpha: float = 0.05, rank_deficient_action: str = "warn", @@ -4460,7 +4476,14 @@ def __init__( f"got {df_convention!r}" ) self.include_intercept = include_intercept - self.robust = robust + # `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 diff --git a/diff_diff/lpdid_results.py b/diff_diff/lpdid_results.py index a54d2c74..15dc40e7 100644 --- a/diff_diff/lpdid_results.py +++ b/diff_diff/lpdid_results.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import warn_deprecated_kwarg from diff_diff.results_base import BaseResults @@ -139,8 +140,19 @@ def conf_int(self) -> Tuple[float, float]: # ------------------------------------------------------------------ # serialization # ------------------------------------------------------------------ - def to_dataframe(self, level: str = "event") -> pd.DataFrame: + 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" + if level == "event_study": if self.event_study is None: raise ValueError("event_study dataframe was not computed") return self.event_study.copy() @@ -148,7 +160,7 @@ def to_dataframe(self, level: str = "event") -> pd.DataFrame: if self.pooled is None: raise ValueError("pooled dataframe was not computed") return self.pooled.copy() - raise ValueError("level must be 'event' or 'pooled'") + raise ValueError("level must be 'event_study' or 'pooled'") def to_dict(self) -> Dict[str, Any]: pre = self._pooled_row("pre") diff --git a/diff_diff/power.py b/diff_diff/power.py index e644eefd..efd01ab9 100644 --- a/diff_diff/power.py +++ b/diff_diff/power.py @@ -299,7 +299,7 @@ def _basic_fit_kwargs( n_periods: int, treatment_period: int, ) -> Dict[str, Any]: - return dict(outcome="outcome", treatment="treated", time="post") + return dict(outcome="outcome", treatment="treated", post="post") def _twfe_fit_kwargs( @@ -340,7 +340,7 @@ def _ddd_fit_kwargs( n_periods: int, treatment_period: int, ) -> Dict[str, Any]: - return dict(outcome="outcome", group="group", partition="partition", time="time") + return dict(outcome="outcome", group="group", partition="partition", post="time") def _ddd_panel_fit_kwargs( @@ -353,7 +353,7 @@ def _ddd_panel_fit_kwargs( # pre/post indicator (vs the cross-sectional "time"). Clustering is NOT a # fit kwarg — it resolves from the estimator's cluster="unit" attribute # against the DGP's "unit" column. - return dict(outcome="outcome", group="group", partition="partition", time="post") + return dict(outcome="outcome", group="group", partition="partition", post="post") def _trop_fit_kwargs( @@ -433,13 +433,13 @@ def _survey_basic_fit_kwargs( Uses ``ever_treated`` (time-invariant group indicator) rather than the survey DGP's ``treated`` column (which is post-only: 1{g>0, t>=g}). - DifferenceInDifferences internally constructs ``treatment * time``, + DifferenceInDifferences internally constructs ``treatment * post``, so passing the post-only flag would make that interaction rank-deficient. """ return dict( outcome="outcome", treatment="ever_treated", - time="post", + post="post", survey_design=survey_config._build_survey_design(), ) @@ -638,12 +638,12 @@ def _check_staggered_dgp_compat( f"effect onset." ) - # Check clean_control on StackedDiD + # Check control_group (pre-M-095: clean_control) on StackedDiD if name == "StackedDiD": - cc = getattr(estimator, "clean_control", "not_yet_treated") + cc = getattr(estimator, "control_group", "not_yet_treated") if cc == "strict" and not has_multi_cohort: issues.append( - ' - StackedDiD has clean_control="strict" but the default ' + ' - StackedDiD has control_group="strict" but the default ' "single-cohort DGP makes strict controls equivalent to " "never-treated controls.\n" " Fix: pass data_generator_kwargs=" @@ -2261,21 +2261,28 @@ def simulate_power( ) # Reject estimator settings that require a multi-cohort DGP. # survey_config hard-codes a single-cohort DGP and blocks - # cohort_periods/never_treated_frac overrides. - control_group = getattr(estimator, "control_group", "never_treated") - clean_control = getattr(estimator, "clean_control", None) - if control_group in ("not_yet_treated", "last_cohort"): - raise ValueError( - f"survey_config does not support control_group='{control_group}' " - "(requires multi-cohort DGP). Use the custom data_generator " - "path for survey power with this control-group design." - ) - if clean_control == "strict": - raise ValueError( - "survey_config does not support clean_control='strict' " - "(requires multi-cohort DGP). Use the custom data_generator " - "path for survey power with strict clean controls." - ) + # cohort_periods/never_treated_frac overrides. StackedDiD is gated + # separately: post-M-095 it exposes `control_group`, but its + # vocabulary {"not_yet_treated","strict","never_treated"} maps onto + # the single-cohort DGP for everything except "strict" - matching + # the pre-rename gate bit-for-bit (which rejected only + # clean_control="strict" and allowed the not_yet_treated default). + if type(estimator).__name__ == "StackedDiD": + if getattr(estimator, "control_group", None) == "strict": + raise ValueError( + "survey_config does not support control_group='strict' " + "(requires multi-cohort DGP). Use the custom " + "data_generator path for survey power with strict " + "clean controls." + ) + else: + control_group = getattr(estimator, "control_group", "never_treated") + if control_group in ("not_yet_treated", "last_cohort"): + raise ValueError( + f"survey_config does not support control_group='{control_group}' " + "(requires multi-cohort DGP). Use the custom data_generator " + "path for survey power with this control-group design." + ) # SyntheticDiD placebo variance requires n_control > n_treated. # Check after merging data_generator_kwargs so overrides of n_treated diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index f48b76e7..9ca7a7d6 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -532,13 +532,15 @@ def _handle_stacked(results: Any): baker_step=6, label="Vary clean control definition", why=( - "StackedDiD uses clean_control parameter (not control_group). " - "Compare results with different clean control definitions " - "and event window widths as falsification." + "StackedDiD's control_group parameter selects the " + "clean-control rule (not_yet_treated / strict / " + "never_treated). Compare results with different clean " + "control definitions and event window widths as " + "falsification." ), code=( - "# Re-estimate with different clean_control settings:\n" - "# stacked_alt = StackedDiD(clean_control='not_yet_treated')" + "# Re-estimate with different control_group settings:\n" + "# stacked_alt = StackedDiD(control_group='not_yet_treated')" ), priority="medium", # See note on SA handler: DR completes ``sensitivity`` when @@ -1508,7 +1510,7 @@ def _did_anchor_snippet(results: Any) -> str: two specification changes at once. """ covs = list(getattr(results, "covariates", None) or []) - args = " data, outcome='y', treatment='treated', time='post'" + args = " data, outcome='y', treatment='treated', post='post'" if covs: args += f",\n covariates={covs!r}" return "did_results = DifferenceInDifferences().fit(\n" + args + ")" diff --git a/diff_diff/prep_dgp.py b/diff_diff/prep_dgp.py index 812c7d35..7dd199f7 100644 --- a/diff_diff/prep_dgp.py +++ b/diff_diff/prep_dgp.py @@ -75,7 +75,7 @@ def generate_did_data( >>> from diff_diff import DifferenceInDifferences >>> did = DifferenceInDifferences() - >>> results = did.fit(data, outcome='outcome', treatment='treated', time='post') + >>> results = did.fit(data, outcome='outcome', treatment='treated', post='post') >>> abs(results.att - 3.0) < 1.0 # Close to true effect True """ @@ -758,7 +758,7 @@ def generate_ddd_data( >>> from diff_diff import TripleDifference >>> ddd = TripleDifference() >>> results = ddd.fit(data, outcome='outcome', group='group', - ... partition='partition', time='time') + ... partition='partition', post='time') >>> abs(results.att - 3.0) < 1.0 True """ @@ -1482,14 +1482,14 @@ def generate_ddd_panel_data( >>> data.groupby('unit')['period'].count().eq(8).all() True - Fit with TripleDifference. Note ``time="post"`` (the derived binary + Fit with TripleDifference. Note ``post="post"`` (the derived binary indicator) and ``cluster="unit"`` (required for valid inference on panel-generated data; see the warning above): >>> from diff_diff import TripleDifference >>> result = TripleDifference(cluster="unit").fit( ... data, outcome='outcome', group='group', - ... partition='partition', time='post', + ... partition='partition', post='post', ... ) """ if not (1 <= treatment_period < n_periods): diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index 2ad7586e..3935fb88 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -550,7 +550,7 @@ def summary(self, alpha: Optional[float] = None) -> str: "StaggeredTripleDiffResults": "refit with aggregate='event_study' (or 'all')", "EfficientDiDResults": "refit with aggregate='event_study' (or 'all')", "ContinuousDiDResults": "refit with aggregate='eventstudy' (or 'all')", - "WooldridgeDiDResults": "call results.aggregate('event') first", + "WooldridgeDiDResults": "call results.aggregate('event_study') first", "SpilloverDiDResults": "refit with event_study=True", "ChaisemartinDHaultfoeuilleResults": "refit with L_max >= 1", "LPDiDResults": "refit with only_pooled=False", diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index de9f9631..e7efa79e 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -25,6 +25,11 @@ 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.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) @@ -58,11 +63,13 @@ class StackedDiD(BaseEstimator): - "aggregate": Equal weight per adoption event (trimmed aggregate ATT) - "population": Weight by population size of treated cohort - "sample_share": Weight by sample share of each sub-experiment - clean_control : str, default="not_yet_treated" + control_group : str, default="not_yet_treated" How to define clean controls per Appendix A of the paper: - "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 @@ -188,12 +195,38 @@ 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", - clean_control: str = "not_yet_treated", + control_group: Any = NOT_SUPPLIED, cluster: str = "unit", alpha: float = 0.05, anticipation: int = 0, @@ -201,16 +234,30 @@ 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}'" ) - if clean_control not in ("not_yet_treated", "strict", "never_treated"): + # 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"clean_control must be 'not_yet_treated', 'strict', or " - f"'never_treated', got '{clean_control}'" + f"control_group must be 'not_yet_treated', 'strict', or " + f"'never_treated', got '{control_group}'" ) if cluster not in ("unit", "unit_subexp"): raise ValueError(f"cluster must be 'unit' or 'unit_subexp', got '{cluster}'") @@ -228,7 +275,9 @@ def __init__( self.kappa_pre = kappa_pre self.kappa_post = kappa_post self.weighting = weighting - self.clean_control = clean_control + 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 self.anticipation = anticipation @@ -1018,7 +1067,7 @@ def _refit_stacked(w_r): kappa_pre=self.kappa_pre, kappa_post=self.kappa_post, weighting=self.weighting, - clean_control=self.clean_control, + control_group=self.control_group, alpha=self.alpha, anticipation=self.anticipation, vcov_type=self.vcov_type, @@ -1122,9 +1171,9 @@ def _trim_adoption_events( def _check_clean_controls_exist(self, a: int, unit_info: pd.DataFrame) -> bool: """Check IC2: whether clean control units exist for adoption event a.""" ft = unit_info["_first_treat"].values - if self.clean_control == "not_yet_treated": + if self.control_group == "not_yet_treated": return bool(np.any(ft > a + self.kappa_post)) - elif self.clean_control == "strict": + elif self.control_group == "strict": return bool(np.any(ft > a + self.kappa_post + self.kappa_pre)) else: # never_treated return bool(np.any(np.isinf(ft))) @@ -1176,9 +1225,9 @@ def _build_sub_experiment( treated_units = set(unit_ids[treated_mask]) # Clean control units - if self.clean_control == "not_yet_treated": + if self.control_group == "not_yet_treated": control_mask = ft > a_int + self.kappa_post - elif self.clean_control == "strict": + elif self.control_group == "strict": control_mask = ft > a_int + self.kappa_post + self.kappa_pre else: # never_treated control_mask = np.isinf(ft) @@ -1434,9 +1483,9 @@ def _compute_balancing_weights( def _expected_units(a_val: Any) -> set: treated = set(ft_by_unit[ft_by_unit == a_val].index) - if self.clean_control == "not_yet_treated": + if self.control_group == "not_yet_treated": controls = set(ft_by_unit[ft_by_unit > a_val + self.kappa_post].index) - elif self.clean_control == "strict": + elif self.control_group == "strict": controls = set( ft_by_unit[ft_by_unit > a_val + self.kappa_post + self.kappa_pre].index ) diff --git a/diff_diff/stacked_did_results.py b/diff_diff/stacked_did_results.py index e21e6b09..5e9dc8dd 100644 --- a/diff_diff/stacked_did_results.py +++ b/diff_diff/stacked_did_results.py @@ -11,6 +11,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import BaseResults @@ -67,8 +68,10 @@ class StackedDiDResults(BaseResults): Post-treatment event-time window size. weighting : str Weighting scheme used. - clean_control : str - Clean control definition 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.) alpha : float Significance level used. event_study_vcov : np.ndarray, optional @@ -124,7 +127,7 @@ class StackedDiDResults(BaseResults): kappa_pre: int = 1 kappa_post: int = 1 weighting: str = "aggregate" - clean_control: str = "not_yet_treated" + control_group: str = "not_yet_treated" alpha: float = 0.05 anticipation: int = 0 # Analytical variance family configured at fit time (Phase 1b 2/8). When @@ -160,6 +163,20 @@ class StackedDiDResults(BaseResults): 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.""" + if "clean_control" in state and "control_group" not in state: + state = dict(state) + state["control_group"] = state.pop("clean_control") + self.__dict__.update(state) + # --- Inference-field aliases (balance/external-adapter compatibility) --- @property def att(self) -> float: @@ -231,7 +248,7 @@ def summary(self, alpha: Optional[float] = None) -> str: f"{'Trimmed cohorts:':<30} {len(self.trimmed_groups):>10}", f"{'Event window:':<30} {'[' + str(-self.kappa_pre) + ', ' + str(self.kappa_post) + ']':>10}", f"{'Weighting:':<30} {self.weighting:>10}", - f"{'Clean control:':<30} {self.clean_control:>10}", + f"{'Control group:':<30} {self.control_group:>10}", "", ] @@ -409,7 +426,10 @@ def to_dict(self) -> Dict[str, Any]: "kappa_pre": self.kappa_pre, "kappa_post": self.kappa_post, "weighting": self.weighting, - "clean_control": self.clean_control, + "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/sun_abraham.py b/diff_diff/sun_abraham.py index 19cac581..cf1acf4c 100644 --- a/diff_diff/sun_abraham.py +++ b/diff_diff/sun_abraham.py @@ -1798,7 +1798,6 @@ def _fit_saturated_regression( reg = LinearRegression( include_intercept=False, # Full design already built (with or without intercept) - robust=True, # legacy alias; vcov_type below overrides cluster_ids=cluster_ids, rank_deficient_action=self.rank_deficient_action, weights=survey_weights, diff --git a/diff_diff/synthetic_did.py b/diff_diff/synthetic_did.py index 7f7978cb..3f2df374 100644 --- a/diff_diff/synthetic_did.py +++ b/diff_diff/synthetic_did.py @@ -178,7 +178,7 @@ def __init__( zeta: Optional[float] = None, # Defensive guard against silently-ignored Conley kwargs. SyntheticDiD # inherits __init__ from DifferenceInDifferences but overrides with - # literal `super().__init__(robust=True, cluster=None, alpha=alpha)`, + # literal `super().__init__(cluster=None, alpha=alpha)`, # so any user-passed `vcov_type=` or `conley_*=` would be silently # dropped. Per `feedback_no_silent_failures`, raise loudly. Tracked # in DEFERRED.md for a follow-up that wires Conley to a non-bootstrap @@ -229,7 +229,7 @@ def __init__( stacklevel=2, ) - super().__init__(robust=True, cluster=None, alpha=alpha) + super().__init__(cluster=None, alpha=alpha) self.zeta_omega = zeta_omega self.zeta_lambda = zeta_lambda self.variance_method = variance_method diff --git a/diff_diff/triple_diff.py b/diff_diff/triple_diff.py index e35be170..37404a4b 100644 --- a/diff_diff/triple_diff.py +++ b/diff_diff/triple_diff.py @@ -35,6 +35,12 @@ 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.linalg import _rank_guarded_inv, solve_logit, solve_ols from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import BaseResults @@ -401,11 +407,11 @@ class TripleDifference(BaseEstimator): model or propensity score model is correctly specified. - "reg": Regression adjustment (outcome regression). - "ipw": Inverse probability weighting. - robust : bool, default=True - Whether to use heteroskedasticity-robust standard errors. - Note: influence function-based SEs are inherently robust to - heteroskedasticity, so this parameter has no effect. Retained - for API compatibility. + 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 @@ -479,7 +485,7 @@ class TripleDifference(BaseEstimator): ... outcome='outcome', ... group='group', ... partition='partition', - ... time='post' + ... post='post' ... ) >>> print(results.att) # ATT estimate @@ -490,7 +496,7 @@ class TripleDifference(BaseEstimator): ... outcome='outcome', ... group='group', ... partition='partition', - ... time='post', + ... post='post', ... covariates=['age', 'income'] ... ) @@ -519,10 +525,13 @@ class TripleDifference(BaseEstimator): American Economic Review, 84(3), 622-641. """ + _PARAM_ATTR_ALIASES = {"robust": "_robust_arg"} + _DERIVED_CONFIG_ATTRS = ("robust",) + def __init__( self, estimation_method: str = "dr", - robust: bool = True, + robust: Optional[bool] = None, cluster: Optional[str] = None, vcov_type: str = "hc1", alpha: float = 0.05, @@ -558,7 +567,12 @@ def __init__( self._validate_vcov_type(vcov_type) self.estimation_method = estimation_method - self.robust = robust + # `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 @@ -576,9 +590,10 @@ def fit( outcome: str, group: str, partition: str, - time: str, + post: Any = NOT_SUPPLIED, covariates: Optional[List[str]] = None, survey_design=None, + time: Any = NOT_SUPPLIED, ) -> TripleDifferenceResults: """ Fit the Triple Difference model. @@ -597,8 +612,8 @@ def fit( Name of the partition/eligibility indicator column (0/1). 1 = eligible partition (e.g., women, targeted demographic). 0 = ineligible partition. - time : str - Name of the time period indicator column (0/1). + post : str + Name of the post-period indicator column (0/1). 1 = post-treatment period. 0 = pre-treatment period. covariates : list of str, optional @@ -622,7 +637,24 @@ def fit( If required columns are missing or data validation fails. NotImplementedError If survey_design is used with wild_bootstrap inference. + + The keyword-only ``time`` parameter is a deprecated alias for + ``post`` (row M-031); it warns with ``FutureWarning``. From 4.0, + ``time=`` on the merged staggered interface means the CALENDAR + column only (row M-085) - the 2x2x2 post dummy is ``post=``. """ + post = resolve_renamed_kwarg( + "TripleDifference.fit", + "time", + time, + "post", + post, + default=NOT_SUPPLIED, + extra="From 4.0, time= means the calendar column only.", + ) + require_arg("TripleDifference.fit", "post", post) + # Body-local name; the public parameter is post (M-031). + time = post # Re-validate vcov_type at fit-time: __init__ and set_params (via # the BaseEstimator probe re-init) both validate eagerly, so this # second layer only catches DIRECT attribute mutation @@ -2095,7 +2127,7 @@ def triple_difference( time: str, covariates: Optional[List[str]] = None, estimation_method: str = "dr", - robust: bool = True, + robust: Optional[bool] = None, cluster: Optional[str] = None, vcov_type: str = "hc1", alpha: float = 0.05, @@ -2130,11 +2162,11 @@ def triple_difference( estimation_method : str, default="dr" Estimation method: "dr" (doubly robust), "reg" (regression), or "ipw" (inverse probability weighting). - robust : bool, default=True - Whether to use heteroskedasticity-robust standard errors. - Note: influence function-based SEs are inherently robust to - heteroskedasticity, so this parameter has no effect. Retained - for API compatibility. + 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" @@ -2189,7 +2221,7 @@ def triple_difference( outcome=outcome, group=group, partition=partition, - time=time, + post=time, covariates=covariates, survey_design=survey_design, ) diff --git a/diff_diff/twfe.py b/diff_diff/twfe.py index 2b596ff7..8a6eb5f0 100644 --- a/diff_diff/twfe.py +++ b/diff_diff/twfe.py @@ -40,8 +40,10 @@ class TwoWayFixedEffects(DifferenceInDifferences): Parameters ---------- - robust : bool, default=True - Whether to use heteroskedasticity-robust standard errors. + 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` diff --git a/diff_diff/wooldridge_results.py b/diff_diff/wooldridge_results.py index 29efc302..8da44568 100644 --- a/diff_diff/wooldridge_results.py +++ b/diff_diff/wooldridge_results.py @@ -9,6 +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 BaseResults from diff_diff.utils import safe_inference @@ -237,24 +243,32 @@ def __setstate__(self, state: Dict[str, Any]) -> None: self.__dict__.pop("_df_one_way", None) if "df_convention" not in self.__dict__: self.__dict__["df_convention"] = "residual" + # 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. + 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"] def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults": # noqa: A002 """Compute and store one of the four jwdid_estat aggregation types. Parameters ---------- - type : "simple" | "group" | "calendar" | "event" + 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 ``jwdid_estat``. ``"cohort_share"`` uses paper W2025 Eq. 7.4 ``ω̂_g = N_g / Σ_{g'} N_{g'} M_{g'}`` for ``type="simple"`` and Eq. 7.6 ``ω̂_{ge} = N_g / Σ_{g': g'+e ≤ T} N_{g'}`` for - ``type="event"``. Both formulas reduce to ``N_g``-proportional + ``type="event_study"``. Both formulas reduce to ``N_g``-proportional per-cell weights with the appropriate normalization. The two schemes coincide on balanced panels with uniform within-cohort cell counts (paper Section 7.5). The cohort-share scheme is - supported only for ``type="simple"`` and ``type="event"``; the + supported only for ``type="simple"`` and ``type="event_study"``; the paper provides no explicit cohort-share formula for ``"group"`` or ``"calendar"`` aggregations and the library raises ``ValueError`` to preserve a fail-closed contract. @@ -272,7 +286,16 @@ 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. """ - valid = ("simple", "group", "calendar", "event") + # 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 + valid = ("simple", "group", "calendar", "event_study") if type not in valid: raise ValueError(f"type must be one of {valid}, got {type!r}") @@ -282,7 +305,7 @@ def aggregate(self, type: str, weights: str = "cell") -> "WooldridgeDiDResults": if weights == "cohort_share" and type in ("group", "calendar"): raise ValueError( f"weights='cohort_share' is only supported for type='simple' " - f"(paper W2025 Eq. 7.4) and type='event' (paper W2025 Eq. 7.6). " + f"(paper W2025 Eq. 7.4) and type='event_study' (paper W2025 Eq. 7.6). " f"type={type!r} has no explicit paper closed-form cohort-share " f"weighting; use weights='cell' (default) for " f"jwdid_estat-style cell-count weighting." @@ -617,7 +640,7 @@ def _build_effect( self.calendar_effects = result self.aggregation_weights["calendar"] = weights - elif type == "event": + elif type == "event_study": # Paper W2025 Eq. 7.6 cohort-share-by-exposure weighting is # defined for post-treatment exposure times (k >= 0) only; # pre-treatment lead effects use a separate Eq. 7.7 @@ -651,17 +674,59 @@ def _build_effect( se = _agg_se(w_vec) 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: str = "simple") -> str: + def summary(self, aggregation: Any = NOT_SUPPLIED, *, alpha: Optional[float] = None) -> str: """Print formatted summary table. Parameters ---------- - aggregation : which aggregation to display ("simple", "group", "calendar", "event") + 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. """ + 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 lines = [ "=" * 70, " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results", @@ -709,7 +774,7 @@ def _fmt_row(label: str, att: float, se: float, t: float, p: float, ci: Tuple) - f"{p:>8.4f}{stars} [{ci_lo}, {ci_hi}]" ) - ci_pct = f"{(1 - self.alpha) * 100:.0f}%" + ci_pct = f"{(1 - _alpha) * 100:.0f}%" header = ( f"{'Parameter':<22} {'Estimate':>10} {'Std. Err.':>10} " f"{'t-stat':>8} {'P>|t|':>8} [{ci_pct} CI]" @@ -752,7 +817,7 @@ def _fmt_row(label: str, att: float, se: float, t: float, p: float, ci: Tuple) - eff["conf_int"], ) ) - elif aggregation == "event" and self.event_study_effects: + 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]" @@ -786,7 +851,7 @@ def to_dict(self) -> Dict[str, Any]: Dict[str, Any] Canonical inference row plus scalar metadata. Detailed group-time / aggregated tables are available via - ``to_dataframe(aggregation=...)``. + ``to_dataframe(level=...)``. """ result = { "att": self.att, @@ -814,14 +879,38 @@ def to_dict(self) -> Dict[str, Any]: result["df_convention"] = self.df_convention return result - def to_dataframe(self, aggregation: str = "event") -> pd.DataFrame: + def to_dataframe( + self, level: Any = NOT_SUPPLIED, aggregation: Any = NOT_SUPPLIED + ) -> pd.DataFrame: """Export aggregated effects to a DataFrame. Parameters ---------- - aggregation : "simple" | "group" | "calendar" | "event" | "gt" - Use "gt" to export raw group-time effects. + 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-086.) + aggregation : str, optional + Deprecated alias for ``level`` (row M-044); warns with + ``FutureWarning`` and will be removed in 4.0. """ + 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). + aggregation = level if aggregation == "gt": rows = [] for (g, t), eff in sorted(self.group_time_effects.items()): @@ -873,7 +962,7 @@ def to_dataframe(self, aggregation: str = "event") -> pd.DataFrame: } for t, eff in sorted((self.calendar_effects or {}).items()) ], - "event": [ + "event_study": [ { "relative_period": k, **{kk: vv for kk, vv in eff.items() if kk != "conf_int"}, @@ -889,13 +978,13 @@ def to_dataframe(self, aggregation: str = "event") -> pd.DataFrame: return pd.DataFrame(rows) def plot_event_study(self, weights: str = "cell", **kwargs) -> None: - """Event study plot. Always calls ``aggregate('event', weights=weights)``. + """Event study plot. Always calls ``aggregate('event_study', weights=weights)``. Parameters ---------- weights : "cell" | "cohort_share", default "cell" Aggregation weighting scheme threaded into the underlying - ``aggregate("event", ...)`` call. ``"cohort_share"`` produces + ``aggregate("event_study", ...)`` call. ``"cohort_share"`` produces paper W2025 Eq. 7.6 cohort-share-by-exposure weights (post-treatment ``k >= 0`` only); inference fields are fail-closed to NaN per the Section 7.5 conditional-on-shares @@ -921,7 +1010,7 @@ def plot_event_study(self, weights: str = "cell", **kwargs) -> None: # aggregate() method replaces ``event_study_effects`` in place # per the existing contract, so this is cheap and avoids # cohort_share→cell (or any cross-scheme) stale-cache bugs. - self.aggregate("event", weights=weights) + self.aggregate("event_study", weights=weights) from diff_diff.visualization import plot_event_study diff --git a/docs/api/_autosummary/diff_diff.AggregationResult.rst b/docs/api/_autosummary/diff_diff.AggregationResult.rst index eea2db70..5cf24414 100644 --- a/docs/api/_autosummary/diff_diff.AggregationResult.rst +++ b/docs/api/_autosummary/diff_diff.AggregationResult.rst @@ -38,4 +38,3 @@ ~AggregationResult.conf_int_upper ~AggregationResult.n ~AggregationResult.df - diff --git a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst index cece61be..55e201e4 100644 --- a/docs/api/_autosummary/diff_diff.StackedDiDResults.rst +++ b/docs/api/_autosummary/diff_diff.StackedDiDResults.rst @@ -29,10 +29,10 @@ ~StackedDiDResults.att ~StackedDiDResults.balance ~StackedDiDResults.balance_diagnostics - ~StackedDiDResults.clean_control ~StackedDiDResults.cluster_name ~StackedDiDResults.coef_var ~StackedDiDResults.conf_int + ~StackedDiDResults.control_group ~StackedDiDResults.covariates ~StackedDiDResults.df_convention ~StackedDiDResults.event_study_df diff --git a/docs/api/datasets.rst b/docs/api/datasets.rst index ace237e0..c3eff26c 100644 --- a/docs/api/datasets.rst +++ b/docs/api/datasets.rst @@ -60,7 +60,7 @@ Example ck_long = ck_long.dropna(subset=['employment']) did = DifferenceInDifferences() - results = did.fit(ck_long, outcome='employment', treatment='treated', time='post') + results = did.fit(ck_long, outcome='employment', treatment='treated', post='post') load_castle_doctrine ~~~~~~~~~~~~~~~~~~~~ @@ -169,7 +169,7 @@ Example did = DifferenceInDifferences() results = did.fit( - prop99, outcome="lcigsale", treatment="treated_state", time="post" + prop99, outcome="lcigsale", treatment="treated_state", post="post" ) load_walmart diff --git a/docs/api/diagnostics.rst b/docs/api/diagnostics.rst index ec70a1f5..b2f22d2b 100644 --- a/docs/api/diagnostics.rst +++ b/docs/api/diagnostics.rst @@ -83,7 +83,7 @@ Example panel, outcome='outcome', treatment='treated', - time='post', + post='post', unit='unit', n_permutations=1000 ) @@ -109,7 +109,7 @@ Example panel, outcome='outcome', treatment='treated', - time='post', + post='post', unit='unit' ) diff --git a/docs/api/had.rst b/docs/api/had.rst index 6e42be85..831b45f4 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -92,9 +92,9 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: ``compute_survey_if_variance`` and the sup-t bootstrap target ``V_HC1`` consistently; mixing it with a classical analytical SE would silently report a ``V_HC1``-targeted variance under a - ``classical`` label. Use ``vcov_type="hc1"`` or set ``robust=True`` - explicitly (the constructor default ``robust=False`` maps to - ``vcov_type="classical"``, which triggers the guard); a + ``classical`` label. Use ``vcov_type="hc1"`` + explicitly (the constructor default ``vcov_type="classical"`` + triggers the guard); a classical-aligned IF derivation is queued for a follow-up PR. **``cluster=`` + ``survey_design=`` deviation.** On both designs, diff --git a/docs/api/mmm.rst b/docs/api/mmm.rst index 615f856a..a7caa384 100644 --- a/docs/api/mmm.rst +++ b/docs/api/mmm.rst @@ -82,7 +82,7 @@ Example from diff_diff import DifferenceInDifferences, to_meridian_roi_prior result = DifferenceInDifferences().fit(panel, outcome='revenue', - treatment='treated', time='post') + treatment='treated', post='post') # Caller aggregates the ATT to a total incremental outcome over the treated # population and window (e.g. att x treated units x post periods) and supplies diff --git a/docs/api/prep.rst b/docs/api/prep.rst index c9fd2d14..8456e258 100644 --- a/docs/api/prep.rst +++ b/docs/api/prep.rst @@ -363,7 +363,7 @@ Example # panel["post"] = (panel["year"] >= treatment_year).astype(int) # result = DifferenceInDifferences().fit( # panel, outcome="smoking_rate_mean", - # treatment="treated", time="post", survey_design=stage2, + # treatment="treated", post="post", survey_design=stage2, # ) Weight calibration with balance diff --git a/docs/api/triple_diff.rst b/docs/api/triple_diff.rst index cb73a72b..9618941a 100644 --- a/docs/api/triple_diff.rst +++ b/docs/api/triple_diff.rst @@ -104,7 +104,7 @@ Basic usage:: outcome='wages', group='policy_state', # 1=state enacted policy, 0=control state partition='female', # 1=women (affected by policy), 0=men - time='post' # 1=post-policy, 0=pre-policy + post='post' # 1=post-policy, 0=pre-policy ) results.print_summary() @@ -115,7 +115,7 @@ With covariates:: outcome='wages', group='policy_state', partition='female', - time='post', + post='post', covariates=['age', 'education', 'experience'] ) diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 4cd52faf..2f374d66 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -95,7 +95,7 @@ Example did = DifferenceInDifferences(inference='wild_bootstrap', n_bootstrap=999, cluster='unit') results = did.fit(panel, outcome='outcome', treatment='treated', - time='post') + post='post') print(f"Bootstrap SE: {results.se:.3f}") print(f"Bootstrap 95% CI: [{results.conf_int[0]:.3f}, {results.conf_int[1]:.3f}]") diff --git a/docs/api/wooldridge_etwfe.rst b/docs/api/wooldridge_etwfe.rst index 26b50a66..dbcb911f 100644 --- a/docs/api/wooldridge_etwfe.rst +++ b/docs/api/wooldridge_etwfe.rst @@ -10,7 +10,7 @@ This module implements ETWFE via a single saturated regression that: 1. **Estimates ATT(g,t)** for each cohort×time treatment cell simultaneously 2. **Supports linear (OLS), Poisson QMLE, and logit** link functions 3. **Uses ASF-based ATT** for nonlinear models: E[f(η₁)] − E[f(η₀)] -4. **Computes delta-method SEs** for all aggregations (event, group, calendar, simple) +4. **Computes delta-method SEs** for all aggregations (event_study, group, calendar, simple) 5. **Supports paper W2025 cohort-share aggregation** via ``aggregate(weights="cohort_share")`` (Eqs. 7.4 + 7.6; default is cell-count matching Stata ``jwdid_estat``) 6. **Supports paper W2025 Section 8 heterogeneous cohort trends** via ``cohort_trends=True`` (OLS path only; auto-routes to full-dummy mode; requires ``control_group="not_yet_treated"`` — the default — and ``survey_design=None``; the ``never_treated`` and survey paths are fail-closed with ``NotImplementedError`` because the placebo-cell basis remaining collinear with the trend columns through the unit fixed effects / unvalidated survey-TSL composition would make the trend specification unidentified or unverified — see Methodology Registry for the full contract) 7. **Follows the Stata jwdid specification** for OLS defaults and nonlinear paths (see Methodology Registry for documented SE/aggregation deviations) @@ -113,10 +113,10 @@ Basic OLS (follows Stata ``jwdid y, ivar(unit) tvar(time) gvar(cohort)``):: m = WooldridgeDiD() r = m.fit(df, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat') - r.aggregate('event').aggregate('group').aggregate('simple') - print(r.summary('event')) - print(r.summary('group')) - print(r.summary('simple')) + r.aggregate('event_study').aggregate('group').aggregate('simple') + print(r.to_dataframe(level='event_study')) + print(r.to_dataframe(level='group')) + print(r.summary()) .. note:: @@ -149,8 +149,8 @@ Poisson QMLE for non-negative outcomes m_pois = WooldridgeDiD(method='poisson') r_pois = m_pois.fit(df, outcome='emp', unit='countyreal', time='year', first_treat='first_treat') - r_pois.aggregate('event').aggregate('group').aggregate('simple') - print(r_pois.summary('simple')) + r_pois.aggregate('event_study').aggregate('group').aggregate('simple') + print(r_pois.summary()) Logit for binary outcomes (follows Stata ``jwdid y, method(logit)``):: @@ -159,12 +159,14 @@ Logit for binary outcomes r_logit = m_logit.fit(df, outcome='hi_emp', unit='countyreal', time='year', first_treat='first_treat') r_logit.aggregate('group').aggregate('simple') - print(r_logit.summary('group')) + print(r_logit.to_dataframe(level='group')) Aggregation Methods ------------------- -Call ``.aggregate(type, weights=...)`` before ``.summary(type)``: +Call ``.aggregate(type, weights=...)``, then export with +``.to_dataframe(level=...)`` (``summary()`` prints the headline simple +aggregation): .. list-table:: :header-rows: 1 @@ -173,7 +175,7 @@ Call ``.aggregate(type, weights=...)`` before ``.summary(type)``: * - Type - Description - Stata equivalent - * - ``'event'`` + * - ``'event_study'`` - ATT by relative time k = t − g - ``estat event`` * - ``'group'`` @@ -193,7 +195,7 @@ opt-in): Stata ``jwdid_estat``. Supported for all four aggregation types. - ``weights="cohort_share"`` — paper W2025 Eq. 7.4 (simple) and Eq. 7.6 (event, restricted to ``k >= 0``) cohort-share weighting. Supported - only for ``type="simple"`` and ``type="event"``; raises on + only for ``type="simple"`` and ``type="event_study"``; raises on ``type ∈ {"group","calendar"}`` (no paper closed-form). Inference fields (t-stat / p-value / conf-int) are fail-closed to ``NaN`` with a ``UserWarning`` documenting the conditional-on-shares diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 9d714ff3..6d2b0109 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -164,7 +164,7 @@ Use :class:`~diff_diff.DifferenceInDifferences` when: from diff_diff import DifferenceInDifferences did = DifferenceInDifferences() - results = did.fit(data, outcome='y', treatment='treated', time='post') + results = did.fit(data, outcome='y', treatment='treated', post='post') Two-Way Fixed Effects ~~~~~~~~~~~~~~~~~~~~~ @@ -703,7 +703,7 @@ differences helps interpret results and choose appropriate inference. - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. * - ``HeterogeneousAdoptionDiD`` - Path-dependent (CCT-2014 / 2SLS / Binder TSL) - - Two SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=SurveyDesign(weights="col", ...)``** (the sole weighting entry as of the 3.7.0 ``survey=`` / ``weights=`` removal): both paths compose Binder (1983) Taylor-series linearization (``variance_formula="survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``); the mass-point survey path rejects ``vcov_type="classical"`` (requires ``hc1`` / ``robust=True``), and ``survey_design=`` + ``cluster=`` is rejected outright (route weighted clustering via ``SurveyDesign(weights=, psu=)``; a bare ``cluster=`` gives unweighted CR1). Per-horizon CIs are pointwise; sup-t bands available on the event-study path via ``cband=True`` whenever ``survey_design=`` or ``cluster=`` is supplied. + - Two SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=SurveyDesign(weights="col", ...)``** (the sole weighting entry as of the 3.7.0 ``survey=`` / ``weights=`` removal): both paths compose Binder (1983) Taylor-series linearization (``variance_formula="survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``); the mass-point survey path rejects ``vcov_type="classical"`` (requires ``vcov_type="hc1"``), and ``survey_design=`` + ``cluster=`` is rejected outright (route weighted clustering via ``SurveyDesign(weights=, psu=)``; a bare ``cluster=`` gives unweighted CR1). Per-horizon CIs are pointwise; sup-t bands available on the event-study path via ``cband=True`` whenever ``survey_design=`` or ``cluster=`` is supplied. * - ``RegressionDiscontinuity`` - Robust bias-corrected (CCT 2014, NN variance) - Sharp, fuzzy, and covariate-adjusted RD with rdrobust-4.0.0-parity inference (fuzzy via ``fit(..., takeup=...)``: local Wald ratio with a linearized bias correction, first-stage block, and a weak-first-stage warning; covariates via ``fit(..., covariates=[...])``: same estimand, precision only, covariate-aware bandwidths). Canonical ``att``/``se``/``conf_int`` are the ROBUST bias-corrected row (``att`` = bias-corrected estimate, CI centered on it); the conventional estimate rdrobust prints as its headline is ``att_conventional`` with its own inference row. Only ``vcov_type="nn"`` in this release; cluster-robust RD variance is a documented follow-up. @@ -751,12 +751,12 @@ For panel data, always cluster at the unit level unless you have a strong reason # Good: Cluster at unit level for panel data did = DifferenceInDifferences(cluster='unit') results = did.fit(panel, outcome='outcome', treatment='treated', - time='post') + post='post') # Better for few clusters: Wild bootstrap did = DifferenceInDifferences(inference='wild_bootstrap', cluster='unit') results = did.fit(panel, outcome='outcome', treatment='treated', - time='post') + post='post') When in Doubt ------------- diff --git a/docs/index.rst b/docs/index.rst index 17b414d8..2b19046c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,7 +14,7 @@ It provides sklearn-like estimators with statsmodels-style output for econometri # Fit a basic DiD model did = DifferenceInDifferences() - results = did.fit(data, outcome='y', treatment='treated', time='post') + results = did.fit(data, outcome='y', treatment='treated', post='post') print(results.summary()) Key Features diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index c064f959..cc15d77f 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -2155,7 +2155,7 @@ where `g(·)` is the link inverse (logistic or exp), `η_i` is the individual li *Variance families (`vcov_type`, OLS path only):* - `hc1` (default) — CR1 Liang-Zeger cluster-robust on the within-transformed design. Point estimates, residuals, and the unscaled cluster-score meat are unchanged by the within-transform (FWL preserves the score); the CR1 finite-sample scalar uses `K_reference` (3.9). The natural R anchor is `fixest::feols(y ~ | unit + time, cluster=~unit)` or Stata `jwdid` (both within-transform). **Parity with Stata `jwdid` (K_reference convergence, 3.9; `tests/test_etwfe_cs_stata_parity.py`):** the ATT(g,t) POINT estimates match `jwdid` exactly (~3e-8 on the `mpdta` panel, i.e. Stata's log-output rounding), and the `hc1` SEs now match `jwdid`/reghdfe at machine precision (ratio 1.0, spreads ~1e-15..1e-14) on all three committed arms AND at every rung of the committed G≈20..500 subsample ladder (`etwfe_cs_stata_golden.json` `ladder` block). Historically the SEs were uniformly SMALLER by a cluster-count-dependent factor (1.0280 at G=20 down to 1.0010 at G=500) — defect D2 (`docs/methodology/variance-conventions.md`): the clustered CR1 factor's `k` counted only the visible treatment-cell columns, omitting the absorbed FE not nested in the cluster. The applied correction is the closed form `K_reference = explicit columns + (1 if no intercept column) + rank(non-nested FE | nested)` (here `cells + T`), threaded as `cluster_k_adjustment`; the ladder doubles as the K-accounting probe (reghdfe `df_a == absorbed_fe_cr1_k_increment − 1` per rung). This is distinct from the `lm + clubSandwich` deviation below, whose factor is `k`-based. `CallawaySantAnna` shows no such gap - its SEs match Stata `csdid` outright - which localizes this to the ETWFE path rather than a library-wide convention. **Deviation from R `lm + clubSandwich::vcovCR(type="CR1S")`:** the full-dummy `lm` SE differs by a factor of `sqrt((n - K_reference) / (n - k_total))` because clubSandwich's `(n-1)/(n-p)` finite-sample correction counts ALL columns (intercept + treatment + unit dummies + time dummies = `k_total`) while WooldridgeDiD's within-transform CR1 counts `K_reference` (cells + constant + the non-nested time-FE rank; the cluster-NESTED unit dummies are the remaining difference). On the 240-obs / 51-column R-parity fixture (`k_total = 51`, `K_reference = 12`) this is `sqrt(228/189)` ≈ 10% (was ~11% under the historical cells-only count); on typical larger panels (n >> k_total) the gap shrinks to <2%. No public WooldridgeDiD code path exposes the `lm + CR1S` (CR1 cluster-robust on the full-dummy design) finite-sample correction — `vcov_type="hc2_bm"` routes to the CR2 Bell-McCaffrey sandwich on the full-dummy design (different variance estimator entirely), not CR1S. Users who need exact `lm + clubSandwich::vcovCR(type="CR1S")` parity must call `solve_ols` directly on a full-dummy design or fit via R. Same deviation pattern as SunAbraham PR #472 (`fixest::sunab` vs `lm + clubSandwich`). -- `hc2_bm` — CR2 Bell-McCaffrey via auto-route to full-dummy design (`[intercept, X_design, unit_dummies, time_dummies]`), then `solve_ols(..., vcov_type="hc2_bm")` through the clubSandwich port (PR #475). FWL does NOT preserve the hat matrix; HC2 leverage + BM DOF require the full-projection design. Per-coefficient SE matches `clubSandwich::vcovCR(lm(...), cluster=~unit, type="CR2")` at atol=1e-10. Per-cell `(g, t)` inference fields use `coef_test()$df_Satt` Bell-McCaffrey DOF (pinned at atol=1e-6 from CI half-width inversion). Aggregated inference (overall ATT + `.aggregate("group" | "calendar" | "event")`) uses contrast-specific BM DOFs from `_compute_cr2_bm_contrast_dof` (matches R `Wald_test(constraints=matrix(w, 1), vcov=vcov_CR2, test="HTZ")$df_denom`); the overall ATT contrast DOF is computed at fit time, the other three aggregations lazily on each `.aggregate(...)` call from BM artifacts (the REDUCED kept-column `X` / `cluster_ids` / bread matrix + the reduced-space coef-index map) stored on the Results object — using the reduced design after rank-deficient drops keeps the bread non-singular and matches the subspace `solve_ols` actually estimated in. Fail-closed across all surfaces: when BM DOF is unavailable (helper raises or returns non-finite), the affected inference fields are NaN — not normal-theory fallback (per `feedback_bm_contrast_dof_fail_closed`). +- `hc2_bm` — CR2 Bell-McCaffrey via auto-route to full-dummy design (`[intercept, X_design, unit_dummies, time_dummies]`), then `solve_ols(..., vcov_type="hc2_bm")` through the clubSandwich port (PR #475). FWL does NOT preserve the hat matrix; HC2 leverage + BM DOF require the full-projection design. Per-coefficient SE matches `clubSandwich::vcovCR(lm(...), cluster=~unit, type="CR2")` at atol=1e-10. Per-cell `(g, t)` inference fields use `coef_test()$df_Satt` Bell-McCaffrey DOF (pinned at atol=1e-6 from CI half-width inversion). Aggregated inference (overall ATT + `.aggregate("group" | "calendar" | "event_study")`) uses contrast-specific BM DOFs from `_compute_cr2_bm_contrast_dof` (matches R `Wald_test(constraints=matrix(w, 1), vcov=vcov_CR2, test="HTZ")$df_denom`); the overall ATT contrast DOF is computed at fit time, the other three aggregations lazily on each `.aggregate(...)` call from BM artifacts (the REDUCED kept-column `X` / `cluster_ids` / bread matrix + the reduced-space coef-index map) stored on the Results object — using the reduced design after rank-deficient drops keeps the bread non-singular and matches the subspace `solve_ols` actually estimated in. Fail-closed across all surfaces: when BM DOF is unavailable (helper raises or returns non-finite), the affected inference fields are NaN — not normal-theory fallback (per `feedback_bm_contrast_dof_fail_closed`). - `classical`, `hc2` — supported via auto-route to full-dummy AND auto-drop of the unit auto-cluster (one-way families don't compose with `cluster_ids` per the linalg validator). Set `self.cluster=None` (default) for these; explicit `cluster="state"` + one-way family raises at the linalg validator. SE matches `summary(lm(...))$coefficients` (classical) and `sandwich::vcovHC(type="HC2")` respectively. Per-cell + aggregate p-values/CIs use the residual DOF `n - rank(X)` (matches R `lm()` / `coef_test()` t-distribution under both classical OLS SE and `sandwich::vcovHC` defaults) — not normal-theory, so inference is correct under small samples. - `conley` (spatial-HAC, Conley 1999) — supported on the **OLS path** via the within-transform design (or the full-dummy design when `cohort_trends=True`, like the other full-dummy families — see the cohort-trends row below), threading the `conley_*` params through `solve_ols` / `conley.py` (`conley_lag_cutoff=0` = within-period spatial only; `>0` adds within-unit Bartlett serial — the panel-aware path, since `conley_time`/`conley_unit` are always supplied, not pooled cross-sectional). Reuses the already-`conleyreg`-validated machinery (no new variance code). The unit auto-cluster is dropped on the conley path (an explicit `cluster=` enables the spatial+cluster product kernel); `survey_design=` / `weights` / `n_bootstrap>0` are rejected, and `method ∈ {logit, poisson}` + conley remains rejected (the `method != "ols"` guard — a QMLE-on-pseudo-residuals Conley sandwich is a separate derivation). FWL-composability (the within-transform conley SE equals the full-dummy conley SE) is pinned in `tests/test_conley_vcov.py::TestConleyWooldridge::test_fwl_composability_vs_full_dummy`. - `method ∈ {"logit","poisson"}` + `vcov_type != "hc1"` — REJECTED at `__init__`. GLM QMLE sandwich with HC2 leverage on canonical-link pseudo-residuals (`w = p(1-p)` for logit, `w = μ_i` for Poisson) needs CR2-BM-on-GLM derivation + R parity against `clubSandwich::vcovCR(glm(...))`. Tracked in DEFERRED.md (WooldridgeDiD logit/poisson follow-up row). @@ -2195,7 +2195,7 @@ where `g(·)` is the link inverse (logistic or exp), `η_i` is the individual li - `group`: Weighted average across t for each cohort g (cell-count weights only — paper W2025 has no closed-form cohort-share weights for this aggregation type) - `calendar`: Weighted average across g for each calendar time t (cell-count weights only) -- `event`: Weighted average across (g, t) cells by relative period k = t - g. Default `weights="cell"`; opt-in `weights="cohort_share"` exposes paper Eq. 7.6 cohort-share-by-exposure form `ω̂_{ge} ∝ N_g` with per-event-time normalization across cohorts present at event-time `e`. The cohort-share event path is **restricted to `k >= 0`** (post-treatment exposure times only); paper Eq. 7.6 is defined for post-treatment exposure, and pre-treatment leads use a separate Eq. 7.7 `nw_it`-based construction not yet exposed in the library. Under the default `weights="cell"`, negative-`k` placebo cells (e.g., from OLS + `control_group="never_treated"` or `anticipation > 0`) remain in the event aggregation for the placebo-test use case. +- `event_study`: Weighted average across (g, t) cells by relative period k = t - g. Default `weights="cell"`; opt-in `weights="cohort_share"` exposes paper Eq. 7.6 cohort-share-by-exposure form `ω̂_{ge} ∝ N_g` with per-event-time normalization across cohorts present at event-time `e`. The cohort-share event path is **restricted to `k >= 0`** (post-treatment exposure times only); paper Eq. 7.6 is defined for post-treatment exposure, and pre-treatment leads use a separate Eq. 7.7 `nw_it`-based construction not yet exposed in the library. Under the default `weights="cell"`, negative-`k` placebo cells (e.g., from OLS + `control_group="never_treated"` or `anticipation > 0`) remain in the event aggregation for the placebo-test use case. *Covariates:* - `exovar`: Time-invariant covariates, added without demeaning (corresponds to W2025 Eq. 5.2 `x_i`) @@ -2235,7 +2235,7 @@ where `g(·)` is the link inverse (logistic or exp), `η_i` is the individual li - [x] Cluster-robust SEs at unit level for all methods - [x] ASF-based ATT for nonlinear methods with delta-method SEs - [x] Joint delta-method SE for aggregate ATT in nonlinear models -- [x] Four aggregation types: simple, group, calendar, event +- [x] Four aggregation types: simple, group, calendar, event_study - [x] Both control groups: not_yet_treated, never_treated - [x] Anticipation parameter support - [x] Multiplier bootstrap (Rademacher/Webb/Mammen) for OLS overall SE @@ -2264,7 +2264,7 @@ where `g(·)` is the link inverse (logistic or exp), `η_i` is the individual li Consolidated list of substantive deviations from the W2025 paper and from R `etwfe`. Each is documented in the relevant section above with a labeled `**Note:**` or `**Deviation from R:**` line. AI PR reviewer recognizes these as documented (P3 informational) per the project's documented-deviation convention. -1. **Cell-count default for aggregation** (vs paper Eq. 7.4 / 7.6 cohort-share). `aggregate(weights="cell")` (default) matches Stata `jwdid_estat`. The opt-in `weights="cohort_share"` exposes the paper-Eq. 7.4 / 7.6 forms. Cohort-share is supported only for `type="simple"` and `type="event"`. See § Aggregations Note. +1. **Cell-count default for aggregation** (vs paper Eq. 7.4 / 7.6 cohort-share). `aggregate(weights="cell")` (default) matches Stata `jwdid_estat`. The opt-in `weights="cohort_share"` exposes the paper-Eq. 7.4 / 7.6 forms. Cohort-share is supported only for `type="simple"` and `type="event_study"`. See § Aggregations Note. 2. **HC1 finite-sample correction `(n-1)/(n-K_reference)`** (vs R `lm + clubSandwich::vcovCR(type="CR1S")` which uses `(n-1)/(n-k_total)`, nested dummies included). K_reference counts the non-nested absorbed FE + constant (matches Stata jwdid/reghdfe at machine precision); the remaining CR1S gap is the cluster-nested unit-dummy count. See § Variance families. 3. **QMLE sandwich `(G/(G-1)) · ((n-1)/(n-k))`** (vs Stata `jwdid` `G/(G-1)` only). Conservative; for typical panels n >> k the difference is negligible. Tracked in DEFERRED.md (WooldridgeDiD follow-up cluster row). See § Method Note. 4. **Nonlinear methods via direct QMLE** (vs R `etwfe` fixest backend). Avoids statsmodels/fixest dependency. See § Method Deviation from R. @@ -4832,13 +4832,13 @@ where δ is the target effect. - Unequal allocation: optimal is often 50-50 but depends on costs - **Note:** `data_generator_kwargs` keys that overlap with registry-managed simulation inputs (`treatment_effect`, `noise_sd`, `n_units`, `n_periods`, `treatment_fraction`, `treatment_period`, `n_pre`, `n_post`) are rejected with `ValueError` to prevent silent desync between the DGP and result metadata. `n_pre` and `n_post` are derived from `treatment_period` and `n_periods` in factor-model DGPs (SyntheticDiD, TROP); the 3-way intersection check naturally scopes the rejection to those estimators only. Use the corresponding `simulate_power()` parameters directly, or pass a custom `data_generator` to override the DGP entirely. - **Note:** For the **cross-sectional** `TripleDifference` path (`n_periods ≤ 2`), `simulate_sample_size()` rejects `n_per_cell` in `data_generator_kwargs` because `n_per_cell` is derived from `n_units` (the search variable). A fixed override would freeze the effective sample size across bisection iterations, making the search degenerate. Use `simulate_power()` with a fixed `n_per_cell` override instead, or pass a custom `data_generator`. (On the panel path — `n_periods > 2` — `n_per_cell` is rejected upstream in `simulate_power` because the panel DGP does not accept it; see the panel routing Note below.) -- **Note:** The simulation-based power registry (`simulate_power`, `simulate_mde`, `simulate_sample_size`) uses a single-cohort staggered DGP by default. Estimators configured with `control_group="not_yet_treated"`, `clean_control="strict"`, or `anticipation>0` will receive a `UserWarning` because the default DGP does not match their identification strategy. Users must supply `data_generator_kwargs` (e.g., `cohort_periods=[2, 4]`, `never_treated_frac=0.0`) or a custom `data_generator` to match the estimator design. +- **Note:** The simulation-based power registry (`simulate_power`, `simulate_mde`, `simulate_sample_size`) uses a single-cohort staggered DGP by default. Estimators configured with `control_group="not_yet_treated"`, StackedDiD `control_group="strict"`, or `anticipation>0` will receive a `UserWarning` because the default DGP does not match their identification strategy. Users must supply `data_generator_kwargs` (e.g., `cohort_periods=[2, 4]`, `never_treated_frac=0.0`) or a custom `data_generator` to match the estimator design. - **Note:** The `TripleDifference` registry adapter routes by `n_periods`. For `n_periods ≤ 2` it uses `generate_ddd_data`, a fixed 2×2×2 factorial DGP (group × partition × time): the `n_periods`, `treatment_period`, and `treatment_fraction` parameters are ignored — it always simulates 2 periods with balanced groups. `n_units` is mapped to `n_per_cell = max(2, n_units // 8)` (effective total N = `n_per_cell × 8`), so non-multiples of 8 are rounded down and values below 16 are clamped to 16. A `UserWarning` is emitted when simulation inputs differ from the effective DDD design. When rounding occurs, all result objects (`SimulationPowerResults`, `SimulationMDEResults`, `SimulationSampleSizeResults`) set `effective_n_units` to the actual sample size used; it is `None` when no rounding occurred. `simulate_sample_size()` snaps bisection candidates to multiples of 8 so that `required_n` is always a realizable DDD sample size. Passing `n_per_cell` in `data_generator_kwargs` suppresses the effective-N rounding warning but not warnings for ignored parameters (`treatment_period`, `treatment_fraction`). - **Note:** For `n_periods > 2`, `TripleDifference` power routes to `generate_ddd_panel_data` (a balanced panel with time-invariant `group`/`partition` and a derived `post` indicator), which **honors** `n_periods` and `treatment_period`. `n_units` maps directly to panel units (no `// 8` rounding; `effective_n_units` is `None`), and `simulate_sample_size()` uses a continuous (step-1) search. `treatment_fraction` is still ignored (the design is a balanced 2×2×2 with `group_frac = partition_frac = 0.5`); pass `group_frac`/`partition_frac` via `data_generator_kwargs` to vary the split, and a `UserWarning` fires if `treatment_fraction ≠ 0.5`. `n_per_cell` (a cross-sectional-only key) raises `ValueError`. **Clustering caveat:** the panel DGP has within-unit serial correlation, so unclustered standard errors are anti-conservative and overstate power — construct the estimator as `TripleDifference(cluster="unit")` (Liang-Zeger CR1; the panel DGP's unit column is named `"unit"`). A `UserWarning` is emitted when the estimator lacks `cluster="unit"`. The point estimate is invariant to clustering; the inference contract is not. - **Note:** The analytical power methods (`PowerAnalysis.power/mde/sample_size` and the `compute_power/compute_mde/compute_sample_size` convenience functions) accept a `deff` parameter (survey design effect, default 1.0). This inflates variance multiplicatively: `Var(ATT) *= deff`, and inflates required sample size: `n_total *= deff`. The `deff` parameter is **not redundant** with `rho` (intra-cluster correlation): `rho` models within-unit (serial) equicorrelation in panel data via the Burlig (2020) Eq. 2 equicorrelated factor `(1/m + 1/r)(1 - rho)`, while `deff` models the survey design effect from stratified multi-stage sampling (clustering + unequal weighting). A survey panel study may need both. Values `deff > 0` are accepted; `deff < 1.0` (net variance reduction, e.g., from stratification gain) emits a warning. - **Note:** `simulate_power()` catches a narrow set of exception types — `ValueError`, `numpy.linalg.LinAlgError`, `KeyError`, `RuntimeError`, `ZeroDivisionError` — raised inside the per-simulation fit and result-extraction block, increments a per-effect failure counter, and skips the replicate. Programming errors (`TypeError`, `AttributeError`, `NameError`, `IndexError`, etc.) are allowed to propagate so that bugs in the estimator or custom result extractor surface loudly instead of being absorbed as simulation failures. The primary-effect failure count is surfaced on the result object as `SimulationPowerResults.n_simulation_failures`; a `UserWarning` still fires when the failure rate exceeds 10% for any effect size, and all-failed runs raise `RuntimeError`. This replaces the prior bare `except Exception` that swallowed root causes and kept the counter internal to the function (axis C — silent fallback — under the Phase 2 audit). - **Note:** `SurveyPowerConfig._build_survey_design()` no longer caches its return value in `self._cached_survey_design`. Reassigning `config.survey_design` (either replacing a user-supplied `SurveyDesign` with another, or toggling between `None` and a user-supplied design) after the first call used to silently return the stale cached design; the method now returns the live `self.survey_design` (or the default construction when `None`) every call. Other config fields (`n_strata`, `icc`, `weight_variation`, etc.) never influenced the returned design, so the staleness surface was specifically `survey_design` reassignment. Construction is microseconds — the cache never earned its complexity. Axis-J finding #28 in the Phase 2 silent-failures audit. -- **Note:** The simulation-based power functions (`simulate_power/simulate_mde/simulate_sample_size`) accept a `survey_config` parameter (`SurveyPowerConfig` dataclass). When set, the simulation loop uses `generate_survey_did_data` instead of the default registry DGP, and automatically injects `SurveyDesign(weights="weight", strata="stratum", psu="psu", fpc="fpc")` into the estimator's `fit()` call. Supported estimators: DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, EfficientDiD. Unsupported (raises `ValueError`): TROP, SyntheticDiD, TripleDifference (generate_survey_did_data produces staggered cohort data incompatible with factor-model/DDD DGPs). `survey_config` and `data_generator` are mutually exclusive. `data_generator_kwargs` may not contain keys managed by `SurveyPowerConfig` (n_strata, psu_per_stratum, etc.) but may contain passthrough DGP params (unit_fe_sd, add_covariates, strata_sizes). Repeated cross-section survey power (`panel=False`) is only supported for `CallawaySantAnna(panel=False)` with a matching `data_generator_kwargs={"panel": False}`; both mismatch directions are rejected. `estimator_kwargs` may not contain `survey_design` when `survey_config` is set (use `SurveyPowerConfig(survey_design=...)` instead). Estimator settings that require a multi-cohort DGP (`control_group="not_yet_treated"`, `control_group="last_cohort"`, `clean_control="strict"`) are rejected because the survey DGP uses a single cohort; use the custom `data_generator` path for these configurations. `simulate_sample_size` raises the bisection floor to `n_strata * psu_per_stratum * 2` to ensure viable survey structure and rejects `strata_sizes` in `data_generator_kwargs` (it depends on `n_units` which varies during bisection). +- **Note:** The simulation-based power functions (`simulate_power/simulate_mde/simulate_sample_size`) accept a `survey_config` parameter (`SurveyPowerConfig` dataclass). When set, the simulation loop uses `generate_survey_did_data` instead of the default registry DGP, and automatically injects `SurveyDesign(weights="weight", strata="stratum", psu="psu", fpc="fpc")` into the estimator's `fit()` call. Supported estimators: DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD, CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, EfficientDiD. Unsupported (raises `ValueError`): TROP, SyntheticDiD, TripleDifference (generate_survey_did_data produces staggered cohort data incompatible with factor-model/DDD DGPs). `survey_config` and `data_generator` are mutually exclusive. `data_generator_kwargs` may not contain keys managed by `SurveyPowerConfig` (n_strata, psu_per_stratum, etc.) but may contain passthrough DGP params (unit_fe_sd, add_covariates, strata_sizes). Repeated cross-section survey power (`panel=False`) is only supported for `CallawaySantAnna(panel=False)` with a matching `data_generator_kwargs={"panel": False}`; both mismatch directions are rejected. `estimator_kwargs` may not contain `survey_design` when `survey_config` is set (use `SurveyPowerConfig(survey_design=...)` instead). Estimator settings that require a multi-cohort DGP (`control_group="not_yet_treated"`, `control_group="last_cohort"`, StackedDiD `control_group="strict"`) are rejected because the survey DGP uses a single cohort; use the custom `data_generator` path for these configurations. `simulate_sample_size` raises the bisection floor to `n_strata * psu_per_stratum * 2` to ensure viable survey structure and rejects `strata_sizes` in `data_generator_kwargs` (it depends on `n_units` which varies during bisection). **Reference implementation(s):** - R: `pwr::pwr.norm.test` (analytical path — normal-based, matching D1; **not** `pwr.t.test`, which is noncentral-t), `DeclareDesign` (simulation-based) diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index ead23d7d..a8ada36b 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -151,7 +151,7 @@ A few branches read fit-time config from the result object: combined) vs `"post"` (just-identified single-baseline) branches `aggregation` between `"pt_all_combined"` and `"pt_post_single_baseline"`. -- `StackedDiDResults.clean_control`: `"never_treated"` / +- `StackedDiDResults.control_group`: `"never_treated"` / `"strict"` / `"not_yet_treated"` varies the `definition` clause describing which units qualify as controls. - `ChaisemartinDHaultfoeuilleResults.L_max` + diff --git a/docs/performance-scenarios.md b/docs/performance-scenarios.md index f748b935..938b564a 100644 --- a/docs/performance-scenarios.md +++ b/docs/performance-scenarios.md @@ -148,8 +148,8 @@ serves a different purpose: R-parity accuracy). They complement it. - **Estimator + params.** Two variants in the same script: ```python # (a) Analytical TSL path - DifferenceInDifferences(robust=True).fit( - data, outcome="awareness", treatment="treated", time="post", + DifferenceInDifferences().fit( + data, outcome="awareness", treatment="treated", post="post", survey_design=SurveyDesign(weights="w", strata="stratum", psu="cluster", fpc="fpc"), ) diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 7ccf529e..767fba30 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -81,7 +81,7 @@ change in your test markets to the before/after change in your control markets. ) did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") print(f"Campaign lift: {results.att:.1f} (p = {results.p_value:.4f})") .. note:: @@ -427,7 +427,7 @@ See :doc:`practitioner_getting_started` for an end-to-end example. did = DifferenceInDifferences() results = did.fit( data, outcome="outcome", treatment="treated", - time="post", survey_design=survey, + post="post", survey_design=survey, ) .. tip:: diff --git a/docs/practitioner_getting_started.rst b/docs/practitioner_getting_started.rst index cb16679d..8acc83ef 100644 --- a/docs/practitioner_getting_started.rst +++ b/docs/practitioner_getting_started.rst @@ -114,7 +114,7 @@ Step 3: Measure the Campaign Lift data, outcome="outcome", treatment="treated", - time="post", + post="post", ) print(results.summary()) @@ -194,7 +194,7 @@ or data. pre_data, outcome="outcome", treatment="treated", - time="placebo_post", + post="placebo_post", ) print(f"Placebo lift: {placebo_results.att:.2f} (p = {placebo_results.p_value:.3f})") diff --git a/docs/python_comparison.rst b/docs/python_comparison.rst index 9d187621..05609aa8 100644 --- a/docs/python_comparison.rst +++ b/docs/python_comparison.rst @@ -375,7 +375,7 @@ Basic DiD from diff_diff import DifferenceInDifferences did = DifferenceInDifferences() - results = did.fit(data, outcome='y', treatment='treated', time='post') + results = did.fit(data, outcome='y', treatment='treated', post='post') print(results.summary()) .. code-block:: python diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 7a477043..0ce95089 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -51,7 +51,7 @@ The simplest DiD design has two groups (treated/control) and two periods (pre/po data, outcome='outcome', treatment='treated', - time='post' + post='post' ) # View results @@ -90,7 +90,7 @@ Control for confounders with the ``covariates`` parameter: data, outcome='outcome', treatment='treated', - time='post', + post='post', covariates=['age', 'income'] ) @@ -102,7 +102,7 @@ For panel data, cluster standard errors at the unit level: .. code-block:: python did = DifferenceInDifferences(cluster='unit_id') - results = did.fit(data, outcome='y', treatment='treated', time='post') + results = did.fit(data, outcome='y', treatment='treated', post='post') Two-Way Fixed Effects --------------------- diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 41105e42..2f667b21 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -133,16 +133,16 @@ Standard Error Issues # For panel data, always cluster at unit level did = DifferenceInDifferences(cluster='unit_id') - results = did.fit(data, outcome='y', treatment='treated', time='post') + results = did.fit(data, outcome='y', treatment='treated', post='post') # Compare SE methods did_robust = DifferenceInDifferences() did_cluster = DifferenceInDifferences(cluster='unit_id') did_wild = DifferenceInDifferences(inference='wild_bootstrap', cluster='unit_id') - r1 = did_robust.fit(data, outcome='y', treatment='treated', time='post') - r2 = did_cluster.fit(data, outcome='y', treatment='treated', time='post') - r3 = did_wild.fit(data, outcome='y', treatment='treated', time='post') + r1 = did_robust.fit(data, outcome='y', treatment='treated', post='post') + r2 = did_cluster.fit(data, outcome='y', treatment='treated', post='post') + r3 = did_wild.fit(data, outcome='y', treatment='treated', post='post') print(f"Robust SE: {r1.se:.4f}") print(f"Cluster SE: {r2.se:.4f}") @@ -444,7 +444,7 @@ only integer or discrete values. # If treatment is truly binary, use standard DiD instead from diff_diff import DifferenceInDifferences did = DifferenceInDifferences() - results = did.fit(data, outcome='y', treatment='treatment', time='post') + results = did.fit(data, outcome='y', treatment='treatment', post='post') # If dose is continuous but stored as int, convert data['dose'] = data['dose'].astype(float) @@ -621,15 +621,13 @@ silently report a ``V_HC1``-targeted variance under a ``classical`` label. .. code-block:: python - # The constructor default `robust=False` maps to `vcov_type='classical'` - # and triggers the guard on the mass-point survey path - so plain - # `HeterogeneousAdoptionDiD()` is NOT a workaround. Pick one of: + # The constructor default (`vcov_type='classical'`) triggers the guard + # on the mass-point survey path - so plain + # `HeterogeneousAdoptionDiD()` is NOT a workaround. Use: est = HeterogeneousAdoptionDiD(vcov_type='hc1') - # Or equivalently: - est = HeterogeneousAdoptionDiD(robust=True) # maps to vcov_type='hc1' A classical-aligned IF derivation is queued for a follow-up release; until -then, ``vcov_type='hc1'`` (or the equivalent ``robust=True``) is the +then, ``vcov_type='hc1'`` is the recommended path for survey + mass-point fits. See :doc:`api/had` for the full SE-regime contract. @@ -818,7 +816,7 @@ If you encounter issues not covered here: data = generate_did_data(n_units=100, n_periods=10, treatment_effect=2.0) did = DifferenceInDifferences() - results = did.fit(data, outcome='outcome', treatment='treated', time='post') + results = did.fit(data, outcome='outcome', treatment='treated', post='post') print(f"True effect: 2.0, Estimated: {results.att:.3f}") For bugs or feature requests, please open an issue on diff --git a/docs/tutorials/01_basic_did.ipynb b/docs/tutorials/01_basic_did.ipynb index 510d232e..51eeeba9 100644 --- a/docs/tutorials/01_basic_did.ipynb +++ b/docs/tutorials/01_basic_did.ipynb @@ -92,7 +92,7 @@ " data,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\"\n", + " post=\"post\"\n", ")\n", "\n", "# Print the summary\n", @@ -192,7 +192,7 @@ " data,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\",\n", + " post=\"post\",\n", " covariates=[\"size\", \"age\"]\n", ")\n", "\n", @@ -273,7 +273,7 @@ " panel_df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\",\n", + " post=\"post\",\n", " fixed_effects=[\"state\"]\n", ")\n", "\n", @@ -293,7 +293,7 @@ " panel_df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\",\n", + " post=\"post\",\n", " absorb=[\"unit\"] # Absorb unit fixed effects\n", ")\n", "\n", @@ -384,21 +384,21 @@ "outputs": [], "source": [ "# Compare standard errors: robust vs cluster-robust\n", - "did_robust = DifferenceInDifferences(robust=True)\n", + "did_robust = DifferenceInDifferences(vcov_type=\"hc1\")\n", "did_cluster = DifferenceInDifferences(cluster=\"cluster\")\n", "\n", "results_robust = did_robust.fit(\n", " clustered_df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\"\n", + " post=\"post\"\n", ")\n", "\n", "results_cluster = did_cluster.fit(\n", " clustered_df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\"\n", + " post=\"post\"\n", ")\n", "\n", "print(f\"ATT (both methods): {results_robust.att:.4f}\")\n", @@ -431,7 +431,7 @@ " clustered_df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\"\n", + " post=\"post\"\n", ")\n", "\n", "print(results_bootstrap.summary())" diff --git a/docs/tutorials/03_synthetic_did.ipynb b/docs/tutorials/03_synthetic_did.ipynb index 2f68234a..9c456c9f 100644 --- a/docs/tutorials/03_synthetic_did.ipynb +++ b/docs/tutorials/03_synthetic_did.ipynb @@ -217,7 +217,7 @@ " df,\n", " outcome=\"outcome\",\n", " treatment=\"treated\",\n", - " time=\"post\"\n", + " post=\"post\"\n", ")\n", "\n", "print(\"Comparison of Estimators\")\n", diff --git a/docs/tutorials/04_parallel_trends.ipynb b/docs/tutorials/04_parallel_trends.ipynb index 0ab85d2f..8ef1d6b9 100644 --- a/docs/tutorials/04_parallel_trends.ipynb +++ b/docs/tutorials/04_parallel_trends.ipynb @@ -383,7 +383,7 @@ " df_parallel,\n", " outcome='outcome',\n", " treatment='treated',\n", - " time='post'\n", + " post='post'\n", ")\n", "\n", "print(\"Main DiD Results:\")\n", @@ -459,7 +459,7 @@ " df_parallel,\n", " outcome='outcome',\n", " treatment='treated',\n", - " time='post',\n", + " post='post',\n", " unit='unit',\n", " n_permutations=999,\n", " seed=42\n", @@ -616,7 +616,7 @@ " df_nonparallel,\n", " outcome='outcome',\n", " treatment='treated',\n", - " time='post'\n", + " post='post'\n", ")\n", "\n", "# Synthetic DiD (may be less biased)\n", diff --git a/docs/tutorials/08_triple_diff.ipynb b/docs/tutorials/08_triple_diff.ipynb index 20f71808..841cee33 100644 --- a/docs/tutorials/08_triple_diff.ipynb +++ b/docs/tutorials/08_triple_diff.ipynb @@ -105,7 +105,7 @@ " outcome='outcome',\n", " group='group',\n", " partition='partition',\n", - " time='time'\n", + " post='time'\n", ")\n", "\n", "# Print results\n", @@ -189,7 +189,7 @@ " outcome='outcome',\n", " group='group',\n", " partition='partition',\n", - " time='time'\n", + " post='time'\n", " )\n", " results_comparison[method] = res\n", " print(f\"{method.upper():4s}: ATT = {res.att:7.4f} (SE = {res.se:.4f}, p = {res.p_value:.4f})\")" @@ -218,7 +218,7 @@ " outcome='outcome',\n", " group='group',\n", " partition='partition',\n", - " time='time',\n", + " post='time',\n", " covariates=['age', 'education']\n", ")\n", "\n", diff --git a/docs/tutorials/09_real_world_examples.ipynb b/docs/tutorials/09_real_world_examples.ipynb index 3fe74f9a..3ac5e878 100644 --- a/docs/tutorials/09_real_world_examples.ipynb +++ b/docs/tutorials/09_real_world_examples.ipynb @@ -160,13 +160,13 @@ "outputs": [], "source": [ "# Basic DiD estimation\n", - "did = DifferenceInDifferences(robust=True)\n", + "did = DifferenceInDifferences()\n", "\n", "results = did.fit(\n", " ck_long,\n", " outcome='employment',\n", " treatment='treated',\n", - " time='post'\n", + " post='post'\n", ")\n", "\n", "print(\"Card & Krueger DiD Results\")\n", @@ -209,13 +209,13 @@ "outputs": [], "source": [ "# With chain fixed effects for better precision\n", - "did_fe = DifferenceInDifferences(robust=True)\n", + "did_fe = DifferenceInDifferences()\n", "\n", "results_fe = did_fe.fit(\n", " ck_long,\n", " outcome='employment',\n", " treatment='treated',\n", - " time='post',\n", + " post='post',\n", " fixed_effects=['chain']\n", ")\n", "\n", diff --git a/docs/tutorials/13_stacked_did.ipynb b/docs/tutorials/13_stacked_did.ipynb index 8b0e4765..6eef48f9 100644 --- a/docs/tutorials/13_stacked_did.ipynb +++ b/docs/tutorials/13_stacked_did.ipynb @@ -294,7 +294,7 @@ "source": [ "## Clean Control Definitions\n", "\n", - "The `clean_control` parameter determines which units serve as controls in each sub-experiment:\n", + "The `control_group` parameter determines which units serve as controls in each sub-experiment:\n", "\n", "- **`\"not_yet_treated\"`** (default): Units adopted after `a + kappa_post` — most inclusive, maximizes statistical power\n", "- **`\"strict\"`**: Units adopted after `a + kappa_post + kappa_pre` — more conservative, excludes units treated during the window\n", @@ -316,7 +316,7 @@ "print(\"-\" * 70)\n", "\n", "for cc in ['not_yet_treated', 'strict', 'never_treated']:\n", - " r = StackedDiD(kappa_pre=2, kappa_post=2, clean_control=cc).fit(\n", + " r = StackedDiD(kappa_pre=2, kappa_post=2, control_group=cc).fit(\n", " data, outcome='outcome', unit='unit', time='period', first_treat='first_treat'\n", " )\n", " print(f\"{cc:<18} {r.overall_att:>8.3f} {r.overall_se:>8.3f} {r.n_control_units:>12} {len(r.groups):>10}\")" diff --git a/docs/tutorials/16_survey_did.ipynb b/docs/tutorials/16_survey_did.ipynb index 216dd927..b61276e9 100644 --- a/docs/tutorials/16_survey_did.ipynb +++ b/docs/tutorials/16_survey_did.ipynb @@ -523,7 +523,7 @@ "# Fit without survey design (naive)\n", "did_naive = DifferenceInDifferences()\n", "results_no_survey = did_naive.fit(\n", - " cohort3, outcome='outcome', treatment='treat', time='post'\n", + " cohort3, outcome='outcome', treatment='treat', post='post'\n", ")\n", "print(\"=== Naive DiD (no survey design) ===\")\n", "print(results_no_survey.summary())" @@ -545,7 +545,7 @@ "source": [ "# Fit with survey design\n", "results_with_survey = did_naive.fit(\n", - " cohort3, outcome='outcome', treatment='treat', time='post',\n", + " cohort3, outcome='outcome', treatment='treat', post='post',\n", " survey_design=sd,\n", ")\n", "print(\"=== DiD with Survey Design ===\")\n", diff --git a/docs/tutorials/16_wooldridge_etwfe.ipynb b/docs/tutorials/16_wooldridge_etwfe.ipynb index 6d8ffa52..b749a099 100644 --- a/docs/tutorials/16_wooldridge_etwfe.ipynb +++ b/docs/tutorials/16_wooldridge_etwfe.ipynb @@ -4,13 +4,47 @@ "cell_type": "markdown", "id": "a1b2c3d4", "metadata": {}, - "source": "# Wooldridge Extended Two-Way Fixed Effects (ETWFE)\n\nThis tutorial demonstrates the `WooldridgeDiD` estimator (alias: `ETWFE`), which implements Wooldridge's Extended Two-Way Fixed Effects approach — the linear design from Wooldridge (2025) and the nonlinear paths from Wooldridge (2023) — the basis of the Stata `jwdid` package.\n\n**What ETWFE does:** Estimates cohort×time Average Treatment Effects (ATT(g,t)) via a single saturated regression that interacts treatment indicators with cohort×time cells. Unlike standard TWFE, it correctly handles heterogeneous treatment effects across cohorts and time periods. The key insight is to include all cohort×time interaction terms simultaneously, with unit and time fixed effects absorbed via within-transformation.\n\n**Key features:**\n- Follows the Stata `jwdid` specification (OLS and nonlinear paths; see Methodology Registry for documented SE/aggregation deviations)\n- Supports **linear (OLS)**, **Poisson**, and **logit** link functions\n- Nonlinear ATTs use the Average Structural Function (ASF): E[f(η₁)] − E[f(η₀)]\n- Delta-method standard errors for all aggregations\n- Cluster-robust sandwich variance\n\n**Topics covered:**\n1. Basic OLS estimation\n2. Cohort×time cell estimates ATT(g,t)\n3. Aggregation: event-study, group, simple\n4. Poisson QMLE for count / non-negative outcomes\n5. Logit for binary outcomes\n6. Comparison with Callaway-Sant'Anna\n7. All-eventually-treated panels (no never-treated group)\n8. Parameter reference and guidance\n\n*Prerequisites: [Tutorial 02](02_staggered_did.ipynb) (Staggered DiD).*\n\n*See also: [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD, [Tutorial 11](11_imputation_did.ipynb) for Imputation DiD.*" + "source": [ + "# Wooldridge Extended Two-Way Fixed Effects (ETWFE)\n", + "\n", + "This tutorial demonstrates the `WooldridgeDiD` estimator (alias: `ETWFE`), which implements Wooldridge's Extended Two-Way Fixed Effects approach — the linear design from Wooldridge (2025) and the nonlinear paths from Wooldridge (2023) — the basis of the Stata `jwdid` package.\n", + "\n", + "**What ETWFE does:** Estimates cohort×time Average Treatment Effects (ATT(g,t)) via a single saturated regression that interacts treatment indicators with cohort×time cells. Unlike standard TWFE, it correctly handles heterogeneous treatment effects across cohorts and time periods. The key insight is to include all cohort×time interaction terms simultaneously, with unit and time fixed effects absorbed via within-transformation.\n", + "\n", + "**Key features:**\n", + "- Follows the Stata `jwdid` specification (OLS and nonlinear paths; see Methodology Registry for documented SE/aggregation deviations)\n", + "- Supports **linear (OLS)**, **Poisson**, and **logit** link functions\n", + "- Nonlinear ATTs use the Average Structural Function (ASF): E[f(η₁)] − E[f(η₀)]\n", + "- Delta-method standard errors for all aggregations\n", + "- Cluster-robust sandwich variance\n", + "\n", + "**Topics covered:**\n", + "1. Basic OLS estimation\n", + "2. Cohort×time cell estimates ATT(g,t)\n", + "3. Aggregation: event-study, group, simple\n", + "4. Poisson QMLE for count / non-negative outcomes\n", + "5. Logit for binary outcomes\n", + "6. Comparison with Callaway-Sant'Anna\n", + "7. All-eventually-treated panels (no never-treated group)\n", + "8. Parameter reference and guidance\n", + "\n", + "*Prerequisites: [Tutorial 02](02_staggered_did.ipynb) (Staggered DiD).*\n", + "\n", + "*See also: [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD, [Tutorial 11](11_imputation_did.ipynb) for Imputation DiD.*" + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "b2c3d4e5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:31.564032Z", + "iopub.status.busy": "2026-08-02T14:12:31.563744Z", + "iopub.status.idle": "2026-08-02T14:12:32.304914Z", + "shell.execute_reply": "2026-08-02T14:12:32.304424Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -41,10 +75,126 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "d4e5f6a7", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.306186Z", + "iopub.status.busy": "2026-08-02T14:12:32.306092Z", + "iopub.status.idle": "2026-08-02T14:12:32.315452Z", + "shell.execute_reply": "2026-08-02T14:12:32.315150Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Shape: (3000, 7)\n", + "Cohorts: [np.int64(0), np.int64(3), np.int64(5), np.int64(7)]\n", + "Periods: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9)]\n", + "\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
unitperiodoutcomefirst_treattreatedtreattrue_effect
00011.2781610000.0
10111.8356150000.0
20211.5421120000.0
30311.7162600000.0
40412.2897910000.0
\n", + "
" + ], + "text/plain": [ + " unit period outcome first_treat treated treat true_effect\n", + "0 0 0 11.278161 0 0 0 0.0\n", + "1 0 1 11.835615 0 0 0 0.0\n", + "2 0 2 11.542112 0 0 0 0.0\n", + "3 0 3 11.716260 0 0 0 0.0\n", + "4 0 4 12.289791 0 0 0 0.0" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# Simulated data\n", "data = generate_staggered_data(\n", @@ -63,36 +213,116 @@ "cell_type": "markdown", "id": "e5f6a7b8", "metadata": {}, - "source": "## Basic OLS Estimation\n\nThe default `method='ols'` fits a single regression with:\n- Treatment interaction dummies (one per treatment cohort x post-treatment period cell)\n- Unit fixed effects (absorbed via within-transformation)\n- Time fixed effects (absorbed via within-transformation)\n\n**The two control groups produce different designs, and the distinction matters below.**\n\nWith `control_group='not_yet_treated'` (the default, used in this section), only cells at `t >= g - anticipation` are emitted. Pre-treatment observations from treated units sit in the regression baseline alongside not-yet-treated controls, so there are **no placebo cells and no omitted reference cell** — the lag-only specification.\n\nWith `control_group='never_treated'`, pre-treatment interaction indicators are added so only never-treated units define the counterfactual baseline, and pre-treatment coefficients serve as placebo checks. That is the lead-and-lag specification of Wooldridge (2025) Eq. 6.1/6.4, and it is the one that **deliberately omits one cell per cohort — the reference period.** Eq. 6.1 excludes the `g − 1` indicator so that `s = g − 1` is the reference, which is what makes each coefficient a 2×2 DiD against that baseline. The cohort's full block of cell dummies would otherwise sum to its cohort indicator, which the unit fixed effects already absorb, so the design would be rank-deficient and an arbitrary cell — possibly a real post-treatment one — would be dropped instead. Stata `jwdid ... never` omits the same cell. On an unbalanced panel the omitted cell is that cohort's latest *observed* pre-period rather than `g − 1`." + "source": [ + "## Basic OLS Estimation\n", + "\n", + "The default `method='ols'` fits a single regression with:\n", + "- Treatment interaction dummies (one per treatment cohort x post-treatment period cell)\n", + "- Unit fixed effects (absorbed via within-transformation)\n", + "- Time fixed effects (absorbed via within-transformation)\n", + "\n", + "**The two control groups produce different designs, and the distinction matters below.**\n", + "\n", + "With `control_group='not_yet_treated'` (the default, used in this section), only cells at `t >= g - anticipation` are emitted. Pre-treatment observations from treated units sit in the regression baseline alongside not-yet-treated controls, so there are **no placebo cells and no omitted reference cell** — the lag-only specification.\n", + "\n", + "With `control_group='never_treated'`, pre-treatment interaction indicators are added so only never-treated units define the counterfactual baseline, and pre-treatment coefficients serve as placebo checks. That is the lead-and-lag specification of Wooldridge (2025) Eq. 6.1/6.4, and it is the one that **deliberately omits one cell per cohort — the reference period.** Eq. 6.1 excludes the `g − 1` indicator so that `s = g − 1` is the reference, which is what makes each coefficient a 2×2 DiD against that baseline. The cohort's full block of cell dummies would otherwise sum to its cohort indicator, which the unit fixed effects already absorb, so the design would be rank-deficient and an arbitrary cell — possibly a real post-treatment one — would be dropped instead. Stata `jwdid ... never` omits the same cell. On an unbalanced panel the omitted cell is that cohort's latest *observed* pre-period rather than `g − 1`." + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "f6a7b8c9", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.316550Z", + "iopub.status.busy": "2026-08-02T14:12:32.316493Z", + "iopub.status.idle": "2026-08-02T14:12:32.327419Z", + "shell.execute_reply": "2026-08-02T14:12:32.327071Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "======================================================================\n", + " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results\n", + "======================================================================\n", + "Method: ols\n", + "Control group: not_yet_treated\n", + "Observations: 3000\n", + "Treated units: 210\n", + "Control units: 300\n", + "Cohort trends: False\n", + "Aggregation w: cell\n", + "----------------------------------------------------------------------\n", + "Parameter Estimate Std. Err. t-stat P>|t| [95% CI]\n", + "----------------------------------------------------------------------\n", + "ATT (simple) 1.9817 0.0371 53.422 0.0000*** [1.9090, 2.0544]\n", + "======================================================================\n" + ] + } + ], "source": [ "m = WooldridgeDiD() # default: method='ols'\n", "r = m.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", "\n", "# Compute aggregations\n", - "r.aggregate('event').aggregate('group').aggregate('simple')\n", + "r.aggregate('event_study').aggregate('group').aggregate('simple')\n", "\n", - "print(r.summary('simple'))" + "print(r.summary())" ] }, { "cell_type": "markdown", "id": "a7b8c9d0", "metadata": {}, - "source": "## Cohort×Time Cell Estimates ATT(g,t)\n\nThe raw building blocks are ATT(g,t) — the treatment effect for cohort `g` at calendar time `t`. These are stored in `r.group_time_effects` and correspond to Stata's regression output table (`first_treat#year#c.__tr__`).\n\n`r` above is the **default** `not_yet_treated` fit, so it contains post-treatment cells (`t >= g`) only. Pre-treatment placebo cells belong to the `never_treated` specification, which the next cell fits explicitly so the two can be compared." + "source": [ + "## Cohort×Time Cell Estimates ATT(g,t)\n", + "\n", + "The raw building blocks are ATT(g,t) — the treatment effect for cohort `g` at calendar time `t`. These are stored in `r.group_time_effects` and correspond to Stata's regression output table (`first_treat#year#c.__tr__`).\n", + "\n", + "`r` above is the **default** `not_yet_treated` fit, so it contains post-treatment cells (`t >= g`) only. Pre-treatment placebo cells belong to the `never_treated` specification, which the next cell fits explicitly so the two can be compared." + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "b8c9d0e1", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.328428Z", + "iopub.status.busy": "2026-08-02T14:12:32.328367Z", + "iopub.status.idle": "2026-08-02T14:12:32.330472Z", + "shell.execute_reply": "2026-08-02T14:12:32.330183Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Post-treatment ATT(g,t) cells\n", + " cohort year | Coef. Std.Err. t P>|t|\n", + "------------------------------------------------------------\n", + " 3 3 | 2.0725 0.0941 22.02 0.000\n", + " 3 4 | 2.0134 0.0852 23.64 0.000\n", + " 3 5 | 2.0190 0.0934 21.63 0.000\n", + " 3 6 | 1.9355 0.0804 24.07 0.000\n", + " 3 7 | 2.0329 0.1005 20.23 0.000\n", + " 3 8 | 1.9614 0.0994 19.73 0.000\n", + " 3 9 | 2.0018 0.0839 23.85 0.000\n", + " 5 5 | 2.1152 0.0788 26.86 0.000\n", + " 5 6 | 1.9647 0.0805 24.42 0.000\n", + " 5 7 | 1.8765 0.0824 22.77 0.000\n", + " 5 8 | 2.1117 0.0828 25.50 0.000\n", + " 5 9 | 1.9286 0.0822 23.45 0.000\n", + " 7 7 | 1.8762 0.0801 23.43 0.000\n", + " 7 8 | 1.9168 0.0889 21.57 0.000\n", + " 7 9 | 1.9321 0.0801 24.11 0.000\n" + ] + } + ], "source": [ "print(\"Post-treatment ATT(g,t) cells\")\n", "print(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n", @@ -110,11 +340,71 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "c9d0e1f2", - "metadata": {}, - "outputs": [], - "source": "# Placebo cells exist only under control_group='never_treated' (the lead-and-lag\n# specification). The default not_yet_treated fit `r` above has none.\nprint(f\"default (not_yet_treated) pre-treatment cells: \"\n f\"{sorted(k for k in r.group_time_effects if k[1] < k[0])}\")\n\nr_never = WooldridgeDiD(control_group='never_treated').fit(\n data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nprint(\"\\nPre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\")\nprint(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\nprint(\"-\" * 60)\n\nfor (g, t), v in sorted(r_never.group_time_effects.items()):\n if t >= g:\n continue\n print(\"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']))\n\n# One cell per cohort is absent: the omitted reference period (g-1 here, since\n# this panel is balanced). Every coefficient above is measured against it.\nfor g in sorted(c for c in data['first_treat'].unique() if c > 0):\n present = {t for (gg, t) in r_never.group_time_effects if gg == g}\n missing = sorted(set(data['period'].unique()) - present)\n print(f\"cohort {g}: omitted reference cell -> {[(g, t) for t in missing]}\")" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.331446Z", + "iopub.status.busy": "2026-08-02T14:12:32.331391Z", + "iopub.status.idle": "2026-08-02T14:12:32.344223Z", + "shell.execute_reply": "2026-08-02T14:12:32.343925Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "default (not_yet_treated) pre-treatment cells: []\n", + "\n", + "Pre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\n", + " cohort year | Coef. Std.Err. t P>|t|\n", + "------------------------------------------------------------\n", + " 3 0 | 0.0568 0.1181 0.48 0.630\n", + " 3 1 | 0.1425 0.0996 1.43 0.152\n", + " 5 0 | -0.0206 0.1175 -0.18 0.861\n", + " 5 1 | 0.0423 0.1086 0.39 0.697\n", + " 5 2 | -0.0191 0.1044 -0.18 0.855\n", + " 5 3 | -0.1359 0.1089 -1.25 0.212\n", + " 7 0 | -0.1344 0.1180 -1.14 0.255\n", + " 7 1 | -0.0188 0.1135 -0.17 0.868\n", + " 7 2 | -0.2664 0.1118 -2.38 0.017\n", + " 7 3 | -0.0605 0.1092 -0.55 0.580\n", + " 7 4 | -0.2214 0.1177 -1.88 0.060\n", + " 7 5 | -0.1768 0.1122 -1.57 0.115\n", + "cohort 3: omitted reference cell -> [(np.int64(3), np.int64(2))]\n", + "cohort 5: omitted reference cell -> [(np.int64(5), np.int64(4))]\n", + "cohort 7: omitted reference cell -> [(np.int64(7), np.int64(6))]\n" + ] + } + ], + "source": [ + "# Placebo cells exist only under control_group='never_treated' (the lead-and-lag\n", + "# specification). The default not_yet_treated fit `r` above has none.\n", + "print(f\"default (not_yet_treated) pre-treatment cells: \"\n", + " f\"{sorted(k for k in r.group_time_effects if k[1] < k[0])}\")\n", + "\n", + "r_never = WooldridgeDiD(control_group='never_treated').fit(\n", + " data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", + "\n", + "print(\"\\nPre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\")\n", + "print(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n", + " \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\n", + "print(\"-\" * 60)\n", + "\n", + "for (g, t), v in sorted(r_never.group_time_effects.items()):\n", + " if t >= g:\n", + " continue\n", + " print(\"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n", + " int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']))\n", + "\n", + "# One cell per cohort is absent: the omitted reference period (g-1 here, since\n", + "# this panel is balanced). Every coefficient above is measured against it.\n", + "for g in sorted(c for c in data['first_treat'].unique() if c > 0):\n", + " present = {t for (gg, t) in r_never.group_time_effects if gg == g}\n", + " missing = sorted(set(data['period'].unique()) - present)\n", + " print(f\"cohort {g}: omitted reference cell -> {[(g, t) for t in missing]}\")" + ] }, { "cell_type": "markdown", @@ -127,7 +417,7 @@ "\n", "| Python | Stata | Description |\n", "|--------|-------|-------------|\n", - "| `aggregate('event')` | `estat event` | By relative time k = t − g |\n", + "| `aggregate('event_study')` | `estat event` | By relative time k = t − g |\n", "| `aggregate('group')` | `estat group` | By treatment cohort g |\n", "| `aggregate('calendar')` | `estat calendar` | By calendar time t |\n", "| `aggregate('simple')` | `estat simple` | Overall weighted average ATT |\n", @@ -137,43 +427,144 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "e1f2a3b4", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.345268Z", + "iopub.status.busy": "2026-08-02T14:12:32.345212Z", + "iopub.status.idle": "2026-08-02T14:12:32.347803Z", + "shell.execute_reply": "2026-08-02T14:12:32.347524Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " relative_period att se t_stat p_value conf_int_lo \\\n", + "0 0 2.017643 0.048198 41.861506 4.889180e-295 1.923134 \n", + "1 1 1.961511 0.048986 40.041886 3.922971e-275 1.865456 \n", + "2 2 1.937067 0.050790 38.138931 1.473713e-254 1.837476 \n", + "3 3 2.033384 0.059205 34.344547 1.730538e-214 1.917291 \n", + "4 4 1.974940 0.064935 30.414086 9.171112e-175 1.847612 \n", + "5 5 1.961401 0.099388 19.734829 4.782277e-81 1.766516 \n", + "6 6 2.001811 0.083933 23.850075 3.696498e-114 1.837230 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 2.112152 False cell \n", + "1 2.057567 False cell \n", + "2 2.036658 False cell \n", + "3 2.149477 False cell \n", + "4 2.102268 False cell \n", + "5 2.156285 False cell \n", + "6 2.166391 False cell \n" + ] + } + ], "source": [ "# Event-study aggregation: ATT by relative time k = t - g\n", - "print(r.summary('event'))" + "print(r.to_dataframe(level='event_study'))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "f2a3b4c5", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.348827Z", + "iopub.status.busy": "2026-08-02T14:12:32.348773Z", + "iopub.status.idle": "2026-08-02T14:12:32.351115Z", + "shell.execute_reply": "2026-08-02T14:12:32.350802Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cohort att se t_stat p_value conf_int_lo \\\n", + "0 3 2.005216 0.050244 39.909748 1.073586e-273 1.906696 \n", + "1 5 1.999333 0.049288 40.564523 7.899715e-281 1.902687 \n", + "2 7 1.908365 0.055531 34.365894 1.039854e-214 1.799477 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 2.103737 False cell \n", + "1 2.095979 False cell \n", + "2 2.017253 False cell \n" + ] + } + ], "source": [ "# Group aggregation: ATT averaged across post-treatment periods for each cohort\n", - "print(r.summary('group'))" + "print(r.to_dataframe(level='group'))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "a3b4c5d6", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.352009Z", + "iopub.status.busy": "2026-08-02T14:12:32.351956Z", + "iopub.status.idle": "2026-08-02T14:12:32.353524Z", + "shell.execute_reply": "2026-08-02T14:12:32.353222Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "======================================================================\n", + " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results\n", + "======================================================================\n", + "Method: ols\n", + "Control group: not_yet_treated\n", + "Observations: 3000\n", + "Treated units: 210\n", + "Control units: 300\n", + "Cohort trends: False\n", + "Aggregation w: cell\n", + "----------------------------------------------------------------------\n", + "Parameter Estimate Std. Err. t-stat P>|t| [95% CI]\n", + "----------------------------------------------------------------------\n", + "ATT (simple) 1.9817 0.0371 53.422 0.0000*** [1.9090, 2.0544]\n", + "======================================================================\n" + ] + } + ], "source": [ "# Simple ATT: overall weighted average\n", - "print(r.summary('simple'))" + "print(r.summary())" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "b4c5d6e7", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.354356Z", + "iopub.status.busy": "2026-08-02T14:12:32.354308Z", + "iopub.status.idle": "2026-08-02T14:12:32.420978Z", + "shell.execute_reply": "2026-08-02T14:12:32.420602Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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INDz4QEDYt/RRx9tmrHokkT6u6nrXWrp34UXXhjtXcNW0L+bWhavHar17+hTTz0lS5YsicvfTdquYZtphuDhhx826+dpO9quXbua9UY0g4D4ph889IT49Morr5jlFZ599llzCvbJJ5+QCYozOpfrjjvuMJnZ3377TXr27Gnee/fZZ59o7xq2kWYKeK+NXzvuuKMJDP7+97/LI488YsrktSz3+OOPj/auYSscfvjhUlpaaj7Lrly5Unr16mWOa48ePSTeJNRRDA4AAAAAnkLpJgAAAAB4DIEeAAAAAHgMgR4AAAAAeAyBHgAAAAB4DIEeAAAAAHgMgR4AAAAAeAyBHgAAAAB4DIEeACBqFi1aZBamRXRxHADAewj0AACycOFC6du3b8hpwIABcvDBB8sdd9whPp8volepsLBQjjzyyIiuu2rVKhk9erSUlZWZy2+99ZYcdthhcXE0tnVfzz77bJk8eXKT13n33XdlwoQJrfJ4LTkObluyZImceeaZUltba+XxAKCtSo72DgAAYsd7770nO+20kzlfWVkpn376qVx++eXStWtXOeecc1r1saqrq0MCyKOPPtqc4oHb+7px40a5++675cUXXxS3hR8Ht/Xu3Vt22WUXefbZZ03ABwBwBxk9AECD0tLS5NBDD5VRo0bJ+++/H9g+d+5c+cMf/iC77767+dmrr77a4O01Y/Pggw/KscceK3vssYcMGTJExo0bJ+Xl5SaIHD58uLne0KFDTfbKn7WqqamR/fffX15//fWQ+9Pra8awJfug9H5OPfVUuf3222WvvfYyWcqnnnoq5DpN3d8rr7wip5xyipx33nkyePBgee2117bIsM2ePVsKCgrM8xwxYkRgP/3efvttOeKII8zPr732WvP8m6L7t99++0mnTp0afF01+D7qqKNk3bp1si0aOg6tYerUqTJy5EjZe++95bLLLpOrrrpKbrvttsDPx4wZIw899JBUVVW1yuMBALZEoAcAaFJdXZ0kJSUFyvzOP/98EzhpcKOlhXfddZdMmzZti9u98cYb8sILL8i9994rc+bMMUHfjBkzTOCkQeT06dPN9T7++OOQcs/k5GQTFGl20e+7774zj63bW7IPft9884389ttv5jE14Js0aZK88847ET+nr7/+WoYNG2Zur8FvsO+//97c/sQTT5RPPvnEBHITJ06UDz74ILDvGuj85S9/kVmzZsl2220n8+bNa/L1fu6550wg1xDdP30+jz32mHTs2HGLn2tJaHgZrv+0cuXKkOs2dRy21oIFC+SKK66QP//5z+Z49+nTx/xfCKbbsrOzQ44xAKB1EegBABpUUVFhgh3NRvkDgGeeeUb23Xdfk73Kysoy5zU7E54hUyeccIJ8+OGH0qtXL0lNTZV99tlHBg0aJGvWrGn2FT/mmGNM4OGfN6aZJs045ebmtmgf/PTxb775ZhMYHXjggSZDp0FopM9JA93TTjtNOnToYPYhmJYg5ufny+mnn26CF8306XWnTJlifv7SSy+ZLKKWeubk5JgM14477tjkfEnN1GkGNNzf//53E5hpkLf99ts3ePtLL71UfvjhhwZPjd2mNWmQqq+BZnL1+WrAp+Wa4fR11sAXAOAO5ugBAAI0YxasR48e8te//tUEMerHH380wZtmh4LtsMMODb6Kv/zyiynj+9///mcCDQ1itHyyORoUakA1c+ZME2TqfWiAtDX7oHbeeWdp37594HK/fv3kzTffjPj+unTpYjKNDfn5559NoBds4MCB8vLLL5vzP/30kylh9EtMTDSP3xjdH836paenh2zXjKQGjxqs6f7YohnC+++/v8GfffTRR1sEj3qcDznkkJBtu+222xa31bmg/qwqAKD1EegBALZoxrJixQq5+uqrTUChDTMSEhLMz7Vph16+/vrrm33VNFtz8cUXm+yOzm076aST5L777ovo1dZgSEsXdX/y8vJk9erVgblkLdmHwB+7sCBN57n5t0Vyf5oRbExKSkqD5ZdNdZVsLGhUmzZtkoyMjAZv88gjj8h1111nAq+//e1vrRKYNUczhHqKlGY/I+momZmZaZ4rAMAdlG4CALbQrVs3efjhh012SYM1f9OMnj17mvl2kdCSSG1sosGdLiegmbyioqLAz/3BY2O01FGDRc3m6fw4Lats6T4EZxa1FDV4Hpk/q7Y19xds1113NfP0gull7SyptGxRHy98iYHGaHloSUnJFts16NbM4f/93//Jk08+2eh9tLR0s7nj0FL6fBcvXhyyTR87XHFxsXmuAAB3EOgBABrNuPzjH/+QL7/8Uu655x6z7YwzzpClS5fKP//5T/NBXYMNbWKi2b9wOj9LA0XNxumcM20iooGedt1U/qyVNghpaA037YCp5ZZPP/10yFIGLdkHP80c6XqAumyBZrW0rNLf2n9r7i/YWWedZZq46Fw9DdC0Ccvzzz8fuH9/kxdtQqP7oVk5LfdsjAaIa9eubTDY85fXamnrTTfdJK2huePQUlrmq6+BdibV5/Cf//zHLMgeHlAuW7bMlNQCANxBoAcAaJRmu8aPHy9PPPGEmcemZZSa6dNg6YADDjCZOp3b5l/YO5guAaDB3uGHH27KNnXOma7FN3/+fPNzbYyiDTu0ccl///vfBh9fM4IaIATP+WrJPvjpOoBaUqhB0i233GKuq81dtvb+wl8jLZXUJiS6LIR27NQy0OOOO878XO/rgQceMPPrtBHMV199tUXnzmD9+/c32bumOnNq+abejy71sK0iOQ4toZlS7TqqXw5oJlaDfc1Ehpe46pIW4XP5AACtJ6FOJxIAAOBRuo6eZuv8yx3EA51nt3z5chM0esHJJ59sgnYNov2lrRdddJHp6hredAYA0DrI6AEAEGPGjh0rn3/+eURLUcQazY5qqa0Gqrogu2YddY6ev5mOf/6mLkBPkAcA7iHQAwAgxmjJq3bVjLRLaSzRNQj32GMPk8XTuYTaOObBBx+U7t27m59roxZdbkOvBwBwD6WbAAAAAOAxZPQAAAAAwGMI9AAAAADAYwj0AAAAAMBjCPQAAAAAwGMI9AAAAADAYwj0AAAAAMBjCPQAAAAAwGMI9AAAAADAYwj0AAAAAEC85f8B7FGT7jTSZS0AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Event study plot\n", "if HAS_MATPLOTLIB:\n", @@ -218,10 +609,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "d6e7f8a9", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.422063Z", + "iopub.status.busy": "2026-08-02T14:12:32.422003Z", + "iopub.status.idle": "2026-08-02T14:12:32.431077Z", + "shell.execute_reply": "2026-08-02T14:12:32.430739Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "======================================================================\n", + " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results\n", + "======================================================================\n", + "Method: poisson\n", + "Control group: not_yet_treated\n", + "Observations: 3000\n", + "Treated units: 210\n", + "Control units: 300\n", + "Cohort trends: False\n", + "Aggregation w: cell\n", + "----------------------------------------------------------------------\n", + "Parameter Estimate Std. Err. t-stat P>|t| [95% CI]\n", + "----------------------------------------------------------------------\n", + "ATT (simple) 222.6119 9.7423 22.850 0.0000*** [203.5172, 241.7065]\n", + "======================================================================\n" + ] + } + ], "source": [ "# Simulate a non-negative outcome (e.g., employment level)\n", "data_pois = data.copy()\n", @@ -229,17 +649,49 @@ "\n", "m_pois = WooldridgeDiD(method='poisson')\n", "r_pois = m_pois.fit(data_pois, outcome='emp', unit='unit', time='period', first_treat='first_treat')\n", - "r_pois.aggregate('event').aggregate('group').aggregate('simple')\n", + "r_pois.aggregate('event_study').aggregate('group').aggregate('simple')\n", "\n", - "print(r_pois.summary('simple'))" + "print(r_pois.summary())" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "e7f8a9b0", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.432052Z", + "iopub.status.busy": "2026-08-02T14:12:32.431994Z", + "iopub.status.idle": "2026-08-02T14:12:32.433843Z", + "shell.execute_reply": "2026-08-02T14:12:32.433562Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Poisson ATT(g,t) — post-treatment cells\n", + " cohort year | ATT Std.Err. t P>|t|\n", + "------------------------------------------------------------\n", + " 3 3 | 232.1684 20.1192 11.54 0.000\n", + " 3 4 | 237.9872 23.0383 10.33 0.000\n", + " 3 5 | 231.4901 19.8361 11.67 0.000\n", + " 3 6 | 223.6876 17.1670 13.03 0.000\n", + " 3 7 | 237.2743 19.9434 11.90 0.000\n", + " 3 8 | 238.1305 19.9106 11.96 0.000\n", + " 3 9 | 265.6228 26.4917 10.03 0.000\n", + " 5 5 | 219.3397 14.5190 15.11 0.000\n", + " 5 6 | 214.5765 17.5795 12.21 0.000\n", + " 5 7 | 198.8336 14.8757 13.37 0.000\n", + " 5 8 | 242.4799 17.3696 13.96 0.000\n", + " 5 9 | 226.3546 17.2521 13.12 0.000\n", + " 7 7 | 182.5155 14.1802 12.87 0.000\n", + " 7 8 | 202.8975 18.3660 11.05 0.000\n", + " 7 9 | 207.4356 17.4584 11.88 0.000\n" + ] + } + ], "source": [ "# Cohort×time cells (post-treatment, Poisson)\n", "print(\"Poisson ATT(g,t) — post-treatment cells\")\n", @@ -257,13 +709,53 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "f8a9b0c1", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.434693Z", + "iopub.status.busy": "2026-08-02T14:12:32.434638Z", + "iopub.status.idle": "2026-08-02T14:12:32.438023Z", + "shell.execute_reply": "2026-08-02T14:12:32.437644Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " relative_period att se t_stat p_value \\\n", + "0 0 209.853525 9.262249 22.656865 1.193811e-113 \n", + "1 1 217.094215 11.301395 19.209506 3.081876e-82 \n", + "2 2 211.236155 10.015702 21.090498 9.723169e-99 \n", + "3 3 234.127753 12.505668 18.721732 3.292711e-78 \n", + "4 4 231.207818 12.990070 17.798813 7.218311e-71 \n", + "5 5 238.130506 19.910574 11.960002 5.756036e-33 \n", + "6 6 265.622807 26.491724 10.026634 1.164183e-23 \n", + "\n", + " conf_int_lo conf_int_hi cohort_trends aggregation_weights \n", + "0 191.699851 228.007200 False cell \n", + "1 194.943888 239.244543 False cell \n", + "2 191.605739 230.866571 False cell \n", + "3 209.617095 258.638412 False cell \n", + "4 205.747749 256.667887 False cell \n", + "5 199.106499 277.154513 False cell \n", + "6 213.699982 317.545631 False cell \n", + " cohort att se t_stat p_value conf_int_lo \\\n", + "0 3 238.051549 17.463438 13.631425 2.604447e-42 203.823839 \n", + "1 5 220.316861 13.840221 15.918594 4.708403e-57 193.190525 \n", + "2 7 197.616203 14.969396 13.201348 8.617899e-40 168.276726 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 272.279259 False cell \n", + "1 247.443196 False cell \n", + "2 226.955680 False cell \n" + ] + } + ], "source": [ - "print(r_pois.summary('event'))\n", - "print(r_pois.summary('group'))" + "print(r_pois.to_dataframe(level='event_study'))\n", + "print(r_pois.to_dataframe(level='group'))" ] }, { @@ -284,10 +776,40 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "b0c1d2e3", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.438998Z", + "iopub.status.busy": "2026-08-02T14:12:32.438929Z", + "iopub.status.idle": "2026-08-02T14:12:32.448811Z", + "shell.execute_reply": "2026-08-02T14:12:32.448453Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Binary outcome mean: 0.668\n", + "======================================================================\n", + " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results\n", + "======================================================================\n", + "Method: logit\n", + "Control group: not_yet_treated\n", + "Observations: 3000\n", + "Treated units: 210\n", + "Control units: 300\n", + "Cohort trends: False\n", + "Aggregation w: cell\n", + "----------------------------------------------------------------------\n", + "Parameter Estimate Std. Err. t-stat P>|t| [95% CI]\n", + "----------------------------------------------------------------------\n", + "ATT (simple) 0.2460 0.0311 7.919 0.0000*** [0.1851, 0.3069]\n", + "======================================================================\n" + ] + } + ], "source": [ "# Create a binary outcome\n", "data_logit = data.copy()\n", @@ -298,58 +820,236 @@ "\n", "m_logit = WooldridgeDiD(method='logit')\n", "r_logit = m_logit.fit(data_logit, outcome='hi_outcome', unit='unit', time='period', first_treat='first_treat')\n", - "r_logit.aggregate('event').aggregate('group').aggregate('simple')\n", + "r_logit.aggregate('event_study').aggregate('group').aggregate('simple')\n", "\n", - "print(r_logit.summary('simple'))" + "print(r_logit.summary())" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "c1d2e3f4", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.449770Z", + "iopub.status.busy": "2026-08-02T14:12:32.449715Z", + "iopub.status.idle": "2026-08-02T14:12:32.452250Z", + "shell.execute_reply": "2026-08-02T14:12:32.451881Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " cohort att se t_stat p_value conf_int_lo \\\n", + "0 3 0.238387 0.048375 4.927931 8.310506e-07 0.143574 \n", + "1 5 0.229646 0.045347 5.064162 4.101997e-07 0.140767 \n", + "2 7 0.287403 0.053016 5.421110 5.923009e-08 0.183495 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 0.333200 False cell \n", + "1 0.318525 False cell \n", + "2 0.391312 False cell \n" + ] + } + ], "source": [ - "print(r_logit.summary('group'))" + "print(r_logit.to_dataframe(level='group'))" ] }, { "cell_type": "markdown", "id": "d2e3f4a5", "metadata": {}, - "source": "## mpdta: Real-World Example\n\nThe **mpdta** dataset (Callaway & Sant'Anna 2021) contains county-level log employment (`lemp`) data with staggered minimum-wage adoption (`first_treat` = year of treatment, 0 = never treated). It is the canonical benchmark for staggered DiD methods.\n\nThis follows Stata's `jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)` specification. See the Methodology Registry for documented SE/aggregation deviations." + "source": [ + "## mpdta: Real-World Example\n", + "\n", + "The **mpdta** dataset (Callaway & Sant'Anna 2021) contains county-level log employment (`lemp`) data with staggered minimum-wage adoption (`first_treat` = year of treatment, 0 = never treated). It is the canonical benchmark for staggered DiD methods.\n", + "\n", + "This follows Stata's `jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)` specification. See the Methodology Registry for documented SE/aggregation deviations." + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "e3f4a5b6", - "metadata": {}, - "outputs": [], - "source": "from diff_diff import load_mpdta\n\nmpdta = load_mpdta()\nprint(f\"mpdta loaded: {mpdta.shape}\")\nprint(f\"Cohorts: {sorted(mpdta['first_treat'].unique())}\")" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.453177Z", + "iopub.status.busy": "2026-08-02T14:12:32.453125Z", + "iopub.status.idle": "2026-08-02T14:12:32.457318Z", + "shell.execute_reply": "2026-08-02T14:12:32.456928Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mpdta loaded: (2500, 7)\n", + "Cohorts: [np.int64(0), np.int64(2004), np.int64(2006), np.int64(2007)]\n" + ] + } + ], + "source": [ + "from diff_diff import load_mpdta\n", + "\n", + "mpdta = load_mpdta()\n", + "print(f\"mpdta loaded: {mpdta.shape}\")\n", + "print(f\"Cohorts: {sorted(mpdta['first_treat'].unique())}\")" + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "f4a5b6c7", - "metadata": {}, - "outputs": [], - "source": "# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)\nm_ols = WooldridgeDiD(method='ols')\nr_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat')\nr_ols.aggregate('event').aggregate('group').aggregate('simple')\nprint(r_ols.summary('event'))" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.458209Z", + "iopub.status.busy": "2026-08-02T14:12:32.458155Z", + "iopub.status.idle": "2026-08-02T14:12:32.466865Z", + "shell.execute_reply": "2026-08-02T14:12:32.466501Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " relative_period att se t_stat p_value conf_int_lo \\\n", + "0 0 -0.031067 0.013621 -2.280835 0.022664 -0.057780 \n", + "1 1 -0.052235 0.018873 -2.767725 0.005697 -0.089247 \n", + "2 2 -0.136078 0.035455 -3.837999 0.000128 -0.205612 \n", + "3 3 -0.104707 0.033874 -3.091059 0.002022 -0.171140 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 -0.004354 False cell \n", + "1 -0.015222 False cell \n", + "2 -0.066544 False cell \n", + "3 -0.038275 False cell \n" + ] + } + ], + "source": [ + "# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)\n", + "m_ols = WooldridgeDiD(method='ols')\n", + "r_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat')\n", + "r_ols.aggregate('event_study').aggregate('group').aggregate('simple')\n", + "print(r_ols.to_dataframe(level='event_study'))" + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "a5b6c7d8", - "metadata": {}, - "outputs": [], - "source": "# cohort x time ATT cells (post-treatment)\n# Matches Stata: first_treat#year#c.__tr__ output table\nprint(\"ATT(g,t) — post-treatment cells (matches Stata jwdid output)\")\nprint(\"{:>6} {:>6} | {:>9} {:>9} {:>7} {:>7}\".format(\n \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\nprint(\"-\" * 55)\nfor (g, t), v in sorted(r_ols.group_time_effects.items()):\n if t < g:\n continue\n print(\"{:>6} {:>6} | {:>9.4f} {:>9.4f} {:>7.2f} {:>7.3f}\".format(\n g, t, v['att'], v['se'], v['t_stat'], v['p_value']))" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.467850Z", + "iopub.status.busy": "2026-08-02T14:12:32.467790Z", + "iopub.status.idle": "2026-08-02T14:12:32.469625Z", + "shell.execute_reply": "2026-08-02T14:12:32.469333Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ATT(g,t) — post-treatment cells (matches Stata jwdid output)\n", + "cohort year | Coef. Std.Err. t P>|t|\n", + "-------------------------------------------------------\n", + " 2004 2004 | -0.0194 0.0224 -0.87 0.387\n", + " 2004 2005 | -0.0783 0.0305 -2.57 0.010\n", + " 2004 2006 | -0.1361 0.0355 -3.84 0.000\n", + " 2004 2007 | -0.1047 0.0339 -3.09 0.002\n", + " 2006 2006 | 0.0025 0.0199 0.13 0.900\n", + " 2006 2007 | -0.0392 0.0240 -1.63 0.103\n", + " 2007 2007 | -0.0431 0.0184 -2.34 0.019\n" + ] + } + ], + "source": [ + "# cohort x time ATT cells (post-treatment)\n", + "# Matches Stata: first_treat#year#c.__tr__ output table\n", + "print(\"ATT(g,t) — post-treatment cells (matches Stata jwdid output)\")\n", + "print(\"{:>6} {:>6} | {:>9} {:>9} {:>7} {:>7}\".format(\n", + " \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\n", + "print(\"-\" * 55)\n", + "for (g, t), v in sorted(r_ols.group_time_effects.items()):\n", + " if t < g:\n", + " continue\n", + " print(\"{:>6} {:>6} | {:>9.4f} {:>9.4f} {:>7.2f} {:>7.3f}\".format(\n", + " g, t, v['att'], v['se'], v['t_stat'], v['p_value']))" + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "b6c7d8e9", - "metadata": {}, - "outputs": [], - "source": "# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)\nmpdta['emp'] = np.exp(mpdta['lemp'])\n\nm_pois2 = WooldridgeDiD(method='poisson')\nr_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', first_treat='first_treat')\nr_pois2.aggregate('event').aggregate('group').aggregate('simple')\n\nprint(r_pois2.summary('event'))\nprint(r_pois2.summary('group'))\nprint(r_pois2.summary('simple'))" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.470449Z", + "iopub.status.busy": "2026-08-02T14:12:32.470395Z", + "iopub.status.idle": "2026-08-02T14:12:32.478580Z", + "shell.execute_reply": "2026-08-02T14:12:32.478250Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " relative_period att se t_stat p_value conf_int_lo \\\n", + "0 0 -25.572049 21.809918 -1.172496 0.240998 -68.318703 \n", + "1 1 0.873630 53.217084 0.016416 0.986902 -103.429938 \n", + "2 2 -76.746573 28.036277 -2.737402 0.006193 -131.696667 \n", + "3 3 -102.357517 41.590684 -2.461068 0.013852 -183.873759 \n", + "\n", + " conf_int_hi cohort_trends aggregation_weights \n", + "0 17.174605 False cell \n", + "1 105.177198 False cell \n", + "2 -21.796478 False cell \n", + "3 -20.841275 False cell \n", + " cohort att se t_stat p_value conf_int_lo conf_int_hi \\\n", + "0 2004 -56.868740 24.371745 -2.333388 0.019628 -104.636481 -9.100998 \n", + "1 2006 58.439881 72.503696 0.806026 0.420228 -83.664751 200.544514 \n", + "2 2007 -65.187913 23.560932 -2.766780 0.005661 -111.366491 -19.009336 \n", + "\n", + " cohort_trends aggregation_weights \n", + "0 False cell \n", + "1 False cell \n", + "2 False cell \n", + "======================================================================\n", + " Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results\n", + "======================================================================\n", + "Method: poisson\n", + "Control group: not_yet_treated\n", + "Observations: 2500\n", + "Treated units: 191\n", + "Control units: 500\n", + "Cohort trends: False\n", + "Aggregation w: cell\n", + "----------------------------------------------------------------------\n", + "Parameter Estimate Std. Err. t-stat P>|t| [95% CI]\n", + "----------------------------------------------------------------------\n", + "ATT (simple) -28.9138 23.7821 -1.216 0.2241 [-75.5258, 17.6981]\n", + "======================================================================\n" + ] + } + ], + "source": [ + "# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)\n", + "mpdta['emp'] = np.exp(mpdta['lemp'])\n", + "\n", + "m_pois2 = WooldridgeDiD(method='poisson')\n", + "r_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', first_treat='first_treat')\n", + "r_pois2.aggregate('event_study').aggregate('group').aggregate('simple')\n", + "\n", + "print(r_pois2.to_dataframe(level='event_study'))\n", + "print(r_pois2.to_dataframe(level='group'))\n", + "print(r_pois2.summary())" + ] }, { "cell_type": "markdown", @@ -371,18 +1071,83 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "d8e9f0a1", - "metadata": {}, - "outputs": [], - "source": "# Compare overall ATT: ETWFE vs Callaway-Sant'Anna\ncs = CallawaySantAnna()\nr_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nm_etwfe = WooldridgeDiD(method='ols')\nr_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\nr_etwfe.aggregate('event').aggregate('simple')\n\nprint(\"Overall ATT Comparison (true effect = 2.0)\")\nprint(\"=\" * 60)\nprint(\"{:<25} {:>10} {:>10} {:>12}\".format(\"Estimator\", \"ATT\", \"SE\", \"95% CI\"))\nprint(\"-\" * 60)\n\nfor name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n ci = est_r.overall_conf_int\n print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n ))" + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.479550Z", + "iopub.status.busy": "2026-08-02T14:12:32.479491Z", + "iopub.status.idle": "2026-08-02T14:12:32.492602Z", + "shell.execute_reply": "2026-08-02T14:12:32.492203Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Overall ATT Comparison (true effect = 2.0)\n", + "============================================================\n", + "Estimator ATT SE 95% CI\n", + "------------------------------------------------------------\n", + "WooldridgeDiD (ETWFE) 1.9817 0.0371 [1.909, 2.054]\n", + "CallawaySantAnna 1.9754 0.0528 [1.872, 2.079]\n" + ] + } + ], + "source": [ + "# Compare overall ATT: ETWFE vs Callaway-Sant'Anna\n", + "cs = CallawaySantAnna()\n", + "r_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", + "\n", + "m_etwfe = WooldridgeDiD(method='ols')\n", + "r_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", + "r_etwfe.aggregate('event_study').aggregate('simple')\n", + "\n", + "print(\"Overall ATT Comparison (true effect = 2.0)\")\n", + "print(\"=\" * 60)\n", + "print(\"{:<25} {:>10} {:>10} {:>12}\".format(\"Estimator\", \"ATT\", \"SE\", \"95% CI\"))\n", + "print(\"-\" * 60)\n", + "\n", + "for name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n", + " ci = est_r.overall_conf_int\n", + " print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n", + " name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n", + " ))" + ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "e9f0a1b2", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.493513Z", + "iopub.status.busy": "2026-08-02T14:12:32.493455Z", + "iopub.status.idle": "2026-08-02T14:12:32.562888Z", + "shell.execute_reply": "2026-08-02T14:12:32.562582Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/bh/mzf05nq92hs6t7vn2ssvfhpr0000gn/T/ipykernel_96398/1757607880.py:2: FutureWarning: CallawaySantAnna.fit(aggregate=) is deprecated and will be removed in 4.0. Fit once, then aggregate as a post-fit step: results = CallawaySantAnna().fit(...); results.aggregate('event_study') / .aggregate('group') / .aggregate('simple'). balance_e moves onto aggregate() alongside it: results.aggregate('event_study', balance_e=2).\n", + " r_cs_es = CallawaySantAnna().fit(\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAHqCAYAAADyGZa5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAkJFJREFUeJzt3QeYVPXVx/Hf9r5LXdoCUgURBFSKvWDDxqsSe0lsUaNGTDS2BEs0iQkmlliCBbti7yj2gogigkgRlCq9be/zPufend3ZPgvTduf7eZ7hzs7cnblz5zI7557zP/8Yj8fjEQAAAAAACIrY4DwsAAAAAAAg8AYAAAAAIMjIeAMAAAAAEEQE3gAAAAAABBGBNwAAAAAAQUTgDQAAAABAEBF4AwAAAAAQRATeAAAAAAAEEYE3AAAAAABBFB/MBwcANO///u//lJ+f3+j93bt317Rp00K+Kz/99FP9+OOP+s1vftPsuosXL9bTTz+thQsXqri4WB06dNC+++6r008/XZ07d96px2zOQw89pLffflsvv/yywvWenH322Vq/fn2TjzVkyBD9/PPPGjlypP7yl7/Uuu+LL75wbuvataueeOKJWvetXr3a2U8XXXSRJk6cqDPOOEObNm1q9HluuukmHXTQQRF7PPmy4+TFF1/UokWLnG3t1q2bDjnkEJ100klKTk72+3GWL1+u3/72t7r11ls1ZsyYej+3Zv7+nwqUuv83H374YT377LP63//+p912263Wur/73e+0ZMkS/fWvf9WoUaMCvi0A0BYReANAmFmANXz4cCdwakhCQoLC4YYbbnCCoebMmDFDkyZN0jnnnOP8Tnp6utasWaOpU6fqmWee0VNPPaW+ffu26DH9sW3bNmffhfM9+dvf/qby8vLq2y0Qt8D273//e/VtFkjeeOONmjlzZr3A+7333tOWLVu0atUqrVy5Ur17966+78svv3Ru32OPPZyfbZ/27NlTt99+e4Pb5A3GIvV48vrXv/6lxx9/3DmRcNVVVykzM9MJLu+55x7neHn00UfVqVMnvx6rtLTU2UdFRUUN/txateT/VKDU/b9p/79sX9o+9WUnN+xY3nPPPfXkk08SeAOAnwi8ASACpKam1gq6WhMLMo855hhde+211bcNHDhQ++23nw4++GDdd999TrDVFt+THj161Po5Pj7eCbTr/t7++++vTz75xAmKLXj2stvOPfdcJ9j86KOPnOtec+bMcbKc3sDbNPTYO7vt4WCBmmVQH3zwQefY8Bo0aJATwJ1wwgnOyQk7ZqJZJPyf6tixo7OsexLkhRde0IABA3TmmWc6J5Q2bNigLl26BHVbAKAtYIw3ALQCHo9Hp512mm677bZ691lZ7XnnnVdr3enTpztB3LHHHqsLL7xQ77//fvX9FvwdccQR+vrrr51S0RNPPNEp8f3Pf/7jlLSao48+Wps3b9abb77prNtYiXNFRYV++eUXJ+Csy4LE1157TX/84x8bfUzLntl1y+7WLSO3IMzLtssyolZybaXU9957r8rKyqrvP//8853saV22nu032yfm6quvrvW4oWIBk/nmm2+qb7PXblnMww8/3Ak6LQj3ZYG3BewxMTERd4zNnTtXV155pY4//njn2LHsur2eptj7Ze/HoYceWivo9rITEpbltdJ8O65MSUmJU/JslQTjx4/Xqaeeqrvvvrv6OPVHU49hx+ORRx6pV155pdb6FvT+4Q9/qPU4119/va677jq/tsvf43FX/0/5sy07+//dAn0bAmEnf3zfQ9tXBx54oLNeXFycnn/++Vrb6M/z+bOOP68NAFoTAm8AaAUs+Bo2bJgzLta3jLawsFAvvfSSRowYUX2bBUEWPFlAZ1mxAw44wAmSvON6veW4dptltaxc+te//rUee+wx3XnnndWBr33httJTK2/1/fLty75427jTV1991QnO5s+fr8rKyur7LRNmX94be8zGSoN9y8gtSLn44oudTJst//znPzulyVZu62Xjp9955x2tW7eu+jbbjueee05Dhw6tDl6vueaasGRTLYixUnDfwPvjjz929oEFmvYeffXVV877aey1W/BlAU6kHWM//PCDc1LHSp3teLEMtQVqVjre1NhyC7LsfT3ssMMaXccCrEsvvdQ5rsxll13mPLeNc//3v//tPIe973bM+qupx7BsbmJioj744IPq9e09+umnn5yTVd6TO3acWj+B3Xff3a/t8vd43NX/U/5sy87+f7dtqBtU2z7ZunWrc/LKyt/HjRvnrOM73MKf5/NnnUC9/wAQKQi8ASACWGMjywA1dHnjjTecdSzTa0HQhx9+WP17NtbSbpswYUJ1cGPZbsu22fhQK+G1IMkycBaE25dmL8v0WSOqwYMHO5lLu7z++uvOfb16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MuMNAECbVVwsTZrkLgEAQFQg8AYAAAAAIBpLzQEAaJOSk6UpU8K9FQAAIISiLvD2zu1tU5SFm0175u3SznRi4FgBny1RwkrM//EP6Zpr3CA8jPg7BI4V8LkC/gbtPG9M6Y0xmxLj8Xg8iiI2BdmKFSvCvRkAAAAAgDZgt912U8eOHZtcJ+oCb5vfe8eOHUpKSlJsLEPcAQAAAAAtZ5nukpISZWVlKT6+6WLyqAu8AQAAAAAIJVK+AAAAAAAEEYE30Ip5GyMBAABEEr6jALUReEeIadOm6eCDD9aee+6p448/Xp988km4NwkRbvHixdprr720dOnScG8KItT06dN11FFHOZ8rRx99tF577bVwbxIi6PPjV7/6lXNsjBs3TjNmzAj3JiGCj5Wzzz5bI0aM0H777adbbrnFmY0FaMr999/v/N0BGvPss8/qsMMO05AhQzR+/HjNnDmzze8sAu8I8MILL+iuu+7SHXfcoblz5+qMM87QlVdeqdzc3HBvGiK4SeD111+vsrKycG8KItTHH3/sfKbcfPPNzufKpEmTnGPmyy+/DPemIQKmPrngggt00EEHafbs2frDH/6ga6+9VkuWLAn3piHCFBQU6MILL9Tee++tzz//XE899ZTmzJmjv/3tb+HeNESw5cuX67///W+4NwMR7K233tJf//pX3XTTTfr222910UUX6eqrr9amTZvUlhF4R4CpU6fq/PPPd84kJyYm6vTTT3cOwszMzHBvGiLUww8/3GznRES3d955RyeccILGjBnjfK4ceeSR2n///fXhhx+Ge9MQZu+9954zq8dll12mtLQ0JytlWQerkAB8ff311yotLXWSAampqerTp49z0uaDDz5gR6HRDs833HCDBg8ezB5Ck7HPWWedpUMPPdT5jjJhwgR999136ty5c5veawTeYbZ161b9/PPPTqkf4O+Z5EceecQp9wMaY9nuyZMn17pt48aNat++PTstyi1cuFB77LGHYmJiqm+zn7///vuwbhcijw2Bs6oI32Nlw4YNfI6gUY8//riztOpNoLGqq0WLFunwww+Puh1E4B2i5hJWGlz3YmcF7YuwWbt2rY499lgNHTpUp556qhYsWBCKTUMrOVa8DUq8Z5IvueQS9ezZM9ybizCyY6GpY6WuN9980znJZ58ziG7bt2+vFzjZ/KPbtm0L2zahddi8ebMee+wxTZw4Mdybggi0evVqZ2y3lRD7nqwBfG3atMn5DmNLq8yzXiMnnXSSMyyuraNWNQT22WcfZ2L1uqyJmpVZeJur2XgYK7GwpY11ePvtt9WuXbtQbCIihA0zaCjrNGjQIL300kvVZ5LPOecc54whoteNN96oV155pd7tcXFx9U7czZo1yxlH9c9//pMTNpDH43EuvuxnviijuRM2VmZ+wAEHOM3WgLqfIZYYsKGT/fr1o4IGjSqr6k9ksY/1uOrevbtTyXnxxRc7SYLs7Ow2u/cIvEPAxms3dXbQXHrpperdu7dz3ZoLPPfcc87YKkrQo8vzzz/f5LFiJ2WefPJJ50yhXYz3uo3ZRPS4/fbbnYs/43ktSLc/blY2Cli2e9WqVbV2xI4dOzjRi0atX7/eCahGjx7tfJ5wkgZ12fdWawp87rnnOtVX3pN7dp2eNPDVriqpaCfy7CSNsZ4jdgxZA1jLgrdVBN5hlpOT43wJ8p2aw4IoKxflDxt8ffHFF8rPz3caUPiy8hzrOnvVVVexw1CLNcv6z3/+o//9738aNmwYewcOK+uz5nu+J+ys0sZuB+qyISoWdJ922mlONR7QEGu4Z9Ob2rRzxgJv+4yxvz3PPPOMM/0pYDp27OhkuX0rN+14sZM0bT32ifHUrTdDyFkW06YUu/fee9WtWzfdd999TpbKvhilpKTwjqDRaV5Gjhyp119/XQMHDmQvoRYr17LMlH3h6d+/f/XtFmhRHRHd7LPD5ne34U520u6zzz7Tn//8Z+dEzYABA8K9eYgg1ofm5JNP1plnnulkp7zsy7ENawEa8+qrrzrfa+37LFDXY4895szQY8fIbrvtpoceesgZUvnuu+8qIyOjze4wMt4RwBpl2R8xKze3cj87W2hjHQi60RTvF5+2fnYQO8dKy623hFVE+LJg6+9//zu7NYrZFGL2JcdmRrBpxKxR47///W+CbtRjQ5u2bNmiu+++27k01UsC8GUneDk5g8acd955TkWEVWtaY0+ruHr00UfbdNBtyHgDAAAAABBEdGMCAAAAACCICLwBAAAAAAgiAm8AAAAAAIKIwBsAAAAAgCAi8AYAAAAAIIgIvAEAAAAACCICbwAAAAAAgojAGwCAAFu8eLEKCgrYr2HG+wAAiBQE3gCANmvRokXafffda12GDBmigw46SH/7299UUVHh1+NMnz5dRx99tF/rbtiwQSeddJIKCwudn998800ddthhag12dVvPO+883XPPPU2u884772jy5MkBeb6WvA/B9uOPP+qss85SZWVlSJ4PANC6xId7AwAACLZ3331XvXv3dq6XlJToiy++0FVXXaUuXbro17/+dUCfq6ysrFZAf+yxxzqX1iDY27pjxw7deeedev755xVsdd+HYBswYID69Omjp59+2gnAAQDwRcYbABBVkpKSdOihh2r8+PF67733qm//+uuvdfLJJ2vYsGHOfS+//HKDv28Zzfvvv1/HH3+89tprL+27776aNGmSioqKnKD+8MMPd9Y74IADnOyuN6tbXl6uMWPG6NVXX631eLa+ZdRbsg3GHue0007THXfcoREjRjhZ/CeeeKLWOk093ksvvaRTTz1VF1xwgUaOHKlXXnmlXgZ61qxZmjhxovM6x40bV72dXm+99ZaOPPJI5/7rr7/eef1Nse0bPXq0Onbs2OB+tZMhxxxzjLZu3apd0dD7EAgzZszQUUcdpb333ltXXHGF/vjHP+qvf/1r9f1nn322HnjgAZWWlgbk+QAAbQeBNwAgKnk8HsXFxVWXJV944YVOIGvBppVC/+Mf/9DMmTPr/d5rr72m5557TnfddZfmzJnjBOEffvihE8haUP/+++8763322We1ytPj4+OdINWy714LFixwnttub8k2eH333Xdat26d85wWgE+ZMkVvv/22369p3rx5Ovjgg53ft5MRvr7//nvn9//v//5Pn3/+uRNY33777frggw+qt90CzyuvvFKffvqpsrOzNXfu3Cb39zPPPOME1g2x7bPX8+ijj6pDhw717rcS9rrDBryX9evX11q3qfdhZ/3www+6+uqr9bvf/c55vwcOHOgcC77stvT09FrvMQAAhsAbABBViouLneDTsrXegOypp57SqFGjnOxuWlqac92yl3UzyGbChAn66KOP1L9/fyUmJmqfffbR0KFDtXnz5maf+7jjjnMCQe+4Y8vEWkY2KyurRdvgZc9/yy23OIHq/vvv72Sw7aSAv6/JTjycfvrpat++vbMNvqxkeuzYsTrjjDOcYNIy4bbu1KlTnftfeOEFJ8tupemZmZlOBrhHjx5Njre3TLZVCNT1z3/+0wmULeju2rVrg79/+eWXa8mSJQ1eGvudQLKTBrYPrNLBXq8F4FZeXpftZzsRAQCAL8Z4AwDaPMso++rVq5d+//vfO0GlWbZsmRNMW/bUV7du3Rp8vJUrVzplxz///LMT+FlQaeXezbEg3QLcTz75xAn67TEsYN2ZbTC77bab2rVrV/3zoEGD9MYbb/j9eJ07d3Yy8Q356aefnMDb15577qkXX3zRub58+XKn5NorNjbWef7G2PZYVjw5ObnW7Zaxt2DegmfbnlCxDPq9997b4H0ff/xxvWDe3udDDjmk1m177LFHvd+1XgLeqgMAALwIvAEAUdNc7ZdfftG1117rBHjWACsmJsa535pw2c833XRTs49l2cxLLrnEyX7a2OhTTjlFd999t1/bYcGplVrb9uTk5GjTpk3VY5Fbsg1edYNmGyftvc2fx7OMeWMSEhIaLBdvqmt3Y0G8ycvLU0pKSoO/89BDD+mGG25wAuFrrrkmIIFycyyDbhd/WXWAPx3LU1NTndcKAIAvSs0BAFGje/fuevDBB53sqwXP3iZYffv2dcZr+8NKuK1RmQXbNn2WZbrXrFlTfb83mG+MlWZb8G7ZbhtfbWXgLd0G38y7lc77jkP2Zp135vF89evXzxnn7ct+ts7dxsqs7fnqTqnVGCtnz8/Pr3e7nQSxzPqf/vQnPf74440+RktLzZt7H1rKXu/SpUtr3WbPXVdubq7zWgEA8EXgDQCIKpaR/Pe//61vvvlG//rXv5zbzjzzTK1YsUL/+c9/nMDJgj9rSmbZ8bpsfK8F7pattjHL1hTMAm/ram68WV1r+NXQHNLWYdzKw5988slaU3e1ZBu8LLNq85HbNF2W9bUycO9UVjvzeL7OOeccpymbjfW2gNmaqj377LPVj+9t2mZN5Ww7LGtt5emNsYB9y5YtDQbf3uEAVop/8803KxCaex9ayoYl2D6wzu/2Gh5++GEtXry4XoC/atUqZwgAAAC+CLwBAFHHssE33nijpk2b5oyDtrJvy4Rb8Lrffvs5mWwbG21BdV025ZUF30cccYRTZm5jlm0u8IULFzr3W6Mza8Bljchef/31Bp/fMuYWsPmOGW7JNnjZPORWAm1B66233uqsa83advbx6u4jK+22pmI2DZp1RLey9RNOOMG53x7rvvvuc8ZnW2O3b7/9tl5ndF+DBw92sttNdT63cnN7HJvabFf58z60hFUSWFd3O1ljlQp28sUy9XVL8m0Kt7pjwQEAiPHYgC0AANCq2Dzels32Tu/VGtg47dWrVztBfFvwq1/9yjmJYic1vKX4v/3tb52u+XWbyAEAohsZbwAAEBLnnnuuZs+e7dfUa5HGqgdsaICdOCgpKXGy8jbG29sczzv+/4ILLiDoBgDUQ+ANAABCwkr0rWu5v13gI4nNgb7XXns5WW4bi26N4O6//3717NnTud8ar9n0crYeAAB1UWoOAAAAAEAQkfEGAAAAACCICLwBAAAAAAgiAm8AAAAAAIKIwBsAAAAAgCAi8AYAAAAAIIgIvAEAAAAACCICbwAAAAAAgojAGwAAAACAICLwBgAAAABAwfP/SivYyG+JOlYAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# Event-study comparison\n", "r_cs_es = CallawaySantAnna().fit(\n", @@ -429,6 +1194,7 @@ }, { "cell_type": "markdown", + "id": "75a307be", "metadata": {}, "source": [ "## All-Eventually-Treated Panels (no never-treated group)\n", @@ -449,9 +1215,31 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 21, + "id": "94d596a0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-02T14:12:32.564131Z", + "iopub.status.busy": "2026-08-02T14:12:32.564054Z", + "iopub.status.idle": "2026-08-02T14:12:32.574159Z", + "shell.execute_reply": "2026-08-02T14:12:32.573818Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: Dropped 400 of 1800 observations (2 of 9 periods: 8, 9) from the estimation sample: no eligible comparison group exists at those periods -- every unit is already treated (accounting for `anticipation=0`), so ATT(g, t) there is not identified against any untreated outcome. To estimate those periods, add never-treated units or restrict the panel.\n", + "\n", + "WARNING: Cohort(s) 8 have NO estimated cells and are excluded from `results.groups`. Their ATT(g, t) are not estimated. Cohort 8 is the W2025 Section 5.4 reference: with no never-treated group the last cohort serves as the comparison and receives no cells of its own.\n", + "\n", + "overall ATT = 1.5502 (true effect: 1.5)\n", + "estimated cohorts = [3, 5]\n", + "cells = [(3, 3), (3, 4), (3, 5), (3, 6), (3, 7), (5, 5), (5, 6), (5, 7)]\n" + ] + } + ], "source": [ "import warnings\n", "\n", @@ -481,14 +1269,68 @@ }, { "cell_type": "markdown", + "id": "fbd2de27", "metadata": {}, - "source": "Two things to read from that output.\n\n**The estimation sample shrank, and you were told.** Periods 8 and 9 have no\nuntreated unit, so their rows are dropped before the solve. Stata's `jwdid`\nperforms the same reduction but reports only a smaller `N` — here the periods,\nthe row count and the reason are all stated. If you are wrapping this in a\npipeline, surface these warnings.\n\n**Cohort 8 has no estimates.** It is the reference — the role a never-treated\ngroup would otherwise play — so it receives no cells and is excluded from\n`results.groups`. That is the Section 5.4 normalization, not a failure.\n\nThe retained ATTs are the **absolute** effects (≈ 1.5 here), not effects\nrelative to the last cohort, because cohort 8 is untreated throughout every\nperiod that survives.\n\n> **Covariates on these panels.** `exovar` / `xgvar`, and `xtvar` with\n> `demean_covariates=False`, are still rank-deficient here: `D_g × X` is built\n> for the last cohort too, which the paper's rule would drop. Coefficients are\n> unaffected, but `rank_deficient_action=\"error\"` will raise. **Default `xtvar`\n> (`demean_covariates=True`) is full rank** and fits normally. Tracked as\n> follow-up work.\n" + "source": [ + "Two things to read from that output.\n", + "\n", + "**The estimation sample shrank, and you were told.** Periods 8 and 9 have no\n", + "untreated unit, so their rows are dropped before the solve. Stata's `jwdid`\n", + "performs the same reduction but reports only a smaller `N` — here the periods,\n", + "the row count and the reason are all stated. If you are wrapping this in a\n", + "pipeline, surface these warnings.\n", + "\n", + "**Cohort 8 has no estimates.** It is the reference — the role a never-treated\n", + "group would otherwise play — so it receives no cells and is excluded from\n", + "`results.groups`. That is the Section 5.4 normalization, not a failure.\n", + "\n", + "The retained ATTs are the **absolute** effects (≈ 1.5 here), not effects\n", + "relative to the last cohort, because cohort 8 is untreated throughout every\n", + "period that survives.\n", + "\n", + "> **Covariates on these panels.** `exovar` / `xgvar`, and `xtvar` with\n", + "> `demean_covariates=False`, are still rank-deficient here: `D_g × X` is built\n", + "> for the last cohort too, which the paper's rule would drop. Coefficients are\n", + "> unaffected, but `rank_deficient_action=\"error\"` will raise. **Default `xtvar`\n", + "> (`demean_covariates=True`) is full rank** and fits normally. Tracked as\n", + "> follow-up work.\n" + ] }, { "cell_type": "markdown", "id": "f0a1b2c3", "metadata": {}, - "source": "## Summary\n\n**Key takeaways:**\n\n1. **ETWFE via a single regression**: all ATT(g,t) cells estimated jointly, not separately — computationally efficient and internally consistent\n2. **OLS path** follows the Stata `jwdid` specification: unit + time FEs (absorbed via within-transformation), treatment interaction dummies\n3. **Nonlinear paths** (Poisson, Logit) use the ASF formula: E[f(η₁)] − E[f(η₀)] — the only valid ATT definition for nonlinear models\n4. **Four aggregations** mirror Stata's `estat` commands: event, group, calendar, simple\n5. **Delta-method SEs** for all aggregations, including nonlinear paths\n6. **All-eventually-treated panels** estimate via the Section 5.4 normalization: the last cohort is the reference, unsupported periods are dropped, and the reduction is always reported\n7. **When to prefer ETWFE**: nonlinear outcomes, or when a single-regression framework is preferred\n8. **When to prefer CS/ImputationDiD**: covariate adjustment via IPW/DR, or multiplier bootstrap inference\n\n**Parameter reference:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `method` | `'ols'` | `'ols'`, `'poisson'`, or `'logit'` |\n| `control_group` | `'not_yet_treated'` | `'not_yet_treated'` or `'never_treated'` |\n| `anticipation` | `0` | Anticipation periods before treatment |\n| `alpha` | `0.05` | Significance level |\n| `cluster` | `None` | Column for clustering (default: unit variable) |\n\n**References:**\n- Wooldridge, J. M. (2025). Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators. *Empirical Economics*, 69(5), 2545–2587. Published version of SSRN 3906345 / NBER Working Paper 29154; cited as Wooldridge (2025) throughout this tutorial, including Eq. 6.1/6.4 (reference period) and Section 5.4 (all-eventually-treated panels).\n- Wooldridge, J. M. (2023). Simple approaches to nonlinear difference-in-differences with panel data. *The Econometrics Journal*, 26(3), C31–C66.\n- Friosavila, F. (2021). `jwdid`: Stata module for ETWFE. SSC s459114.\n\n*See also: [Tutorial 02](02_staggered_did.ipynb) for Callaway-Sant'Anna, [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD.*" + "source": [ + "## Summary\n", + "\n", + "**Key takeaways:**\n", + "\n", + "1. **ETWFE via a single regression**: all ATT(g,t) cells estimated jointly, not separately — computationally efficient and internally consistent\n", + "2. **OLS path** follows the Stata `jwdid` specification: unit + time FEs (absorbed via within-transformation), treatment interaction dummies\n", + "3. **Nonlinear paths** (Poisson, Logit) use the ASF formula: E[f(η₁)] − E[f(η₀)] — the only valid ATT definition for nonlinear models\n", + "4. **Four aggregations** mirror Stata's `estat` commands: event, group, calendar, simple\n", + "5. **Delta-method SEs** for all aggregations, including nonlinear paths\n", + "6. **All-eventually-treated panels** estimate via the Section 5.4 normalization: the last cohort is the reference, unsupported periods are dropped, and the reduction is always reported\n", + "7. **When to prefer ETWFE**: nonlinear outcomes, or when a single-regression framework is preferred\n", + "8. **When to prefer CS/ImputationDiD**: covariate adjustment via IPW/DR, or multiplier bootstrap inference\n", + "\n", + "**Parameter reference:**\n", + "\n", + "| Parameter | Default | Description |\n", + "|-----------|---------|-------------|\n", + "| `method` | `'ols'` | `'ols'`, `'poisson'`, or `'logit'` |\n", + "| `control_group` | `'not_yet_treated'` | `'not_yet_treated'` or `'never_treated'` |\n", + "| `anticipation` | `0` | Anticipation periods before treatment |\n", + "| `alpha` | `0.05` | Significance level |\n", + "| `cluster` | `None` | Column for clustering (default: unit variable) |\n", + "\n", + "**References:**\n", + "- Wooldridge, J. M. (2025). Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators. *Empirical Economics*, 69(5), 2545–2587. Published version of SSRN 3906345 / NBER Working Paper 29154; cited as Wooldridge (2025) throughout this tutorial, including Eq. 6.1/6.4 (reference period) and Section 5.4 (all-eventually-treated panels).\n", + "- Wooldridge, J. M. (2023). Simple approaches to nonlinear difference-in-differences with panel data. *The Econometrics Journal*, 26(3), C31–C66.\n", + "- Friosavila, F. (2021). `jwdid`: Stata module for ETWFE. SSC s459114.\n", + "\n", + "*See also: [Tutorial 02](02_staggered_did.ipynb) for Callaway-Sant'Anna, [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD.*" + ] } ], "metadata": { @@ -498,10 +1340,18 @@ "name": "python3" }, "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", "name": "python", - "version": "3.10.0" + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/tutorials/17_brand_awareness_survey.ipynb b/docs/tutorials/17_brand_awareness_survey.ipynb index adf3dc1a..35327cbe 100644 --- a/docs/tutorials/17_brand_awareness_survey.ipynb +++ b/docs/tutorials/17_brand_awareness_survey.ipynb @@ -88,7 +88,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "did_naive = DifferenceInDifferences()\nresults_naive = did_naive.fit(\n data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n time=\"post_campaign\",\n)\nprint(results_naive)\nprint(f\"\\nThe campaign increased awareness by {results_naive.att:.1f} percentage points\")\nprint(f\"95% CI: ({results_naive.conf_int[0]:.1f}, {results_naive.conf_int[1]:.1f})\")", + "source": "did_naive = DifferenceInDifferences()\nresults_naive = did_naive.fit(\n data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n post=\"post_campaign\",\n)\nprint(results_naive)\nprint(f\"\\nThe campaign increased awareness by {results_naive.att:.1f} percentage points\")\nprint(f\"95% CI: ({results_naive.conf_int[0]:.1f}, {results_naive.conf_int[1]:.1f})\")", "id": "e5db9120" }, { @@ -118,7 +118,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "sd = SurveyDesign(\n weights=\"survey_weight\", # Accounts for demographic oversampling\n strata=\"region\", # Sample was drawn separately within each region\n psu=\"cluster\", # Respondents sampled in geographic clusters\n fpc=\"fpc\", # Finite population correction\n)\n\ndid_survey = DifferenceInDifferences()\nresults_survey = did_survey.fit(\n data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n time=\"post_campaign\",\n survey_design=sd,\n)\n\nprint(results_survey)\nprint(f\"\\nThe campaign increased awareness by {results_survey.att:.1f} percentage points\")\nprint(f\"95% CI: ({results_survey.conf_int[0]:.1f}, {results_survey.conf_int[1]:.1f})\")", + "source": "sd = SurveyDesign(\n weights=\"survey_weight\", # Accounts for demographic oversampling\n strata=\"region\", # Sample was drawn separately within each region\n psu=\"cluster\", # Respondents sampled in geographic clusters\n fpc=\"fpc\", # Finite population correction\n)\n\ndid_survey = DifferenceInDifferences()\nresults_survey = did_survey.fit(\n data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n post=\"post_campaign\",\n survey_design=sd,\n)\n\nprint(results_survey)\nprint(f\"\\nThe campaign increased awareness by {results_survey.att:.1f} percentage points\")\nprint(f\"95% CI: ({results_survey.conf_int[0]:.1f}, {results_survey.conf_int[1]:.1f})\")", "id": "efbf20d6" }, { @@ -169,7 +169,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "outcomes = [\"awareness\", \"consideration\", \"purchase_intent\"]\nfunnel_results = {}\n\nfor outcome in outcomes:\n did = DifferenceInDifferences()\n r = did.fit(\n data,\n outcome=outcome,\n treatment=\"campaign_respondent\",\n time=\"post_campaign\",\n survey_design=sd,\n )\n funnel_results[outcome] = r\n\n# Results table\nfunnel_df = pd.DataFrame({\n \"Metric\": [\"Awareness\", \"Consideration\", \"Purchase Intent\"],\n \"Lift (pp)\": [funnel_results[o].att for o in outcomes],\n \"SE\": [funnel_results[o].se for o in outcomes],\n \"95% CI Lower\": [funnel_results[o].conf_int[0] for o in outcomes],\n \"95% CI Upper\": [funnel_results[o].conf_int[1] for o in outcomes],\n \"p-value\": [funnel_results[o].p_value for o in outcomes],\n}).round(2)\n\nprint(funnel_df.to_string(index=False))", + "source": "outcomes = [\"awareness\", \"consideration\", \"purchase_intent\"]\nfunnel_results = {}\n\nfor outcome in outcomes:\n did = DifferenceInDifferences()\n r = did.fit(\n data,\n outcome=outcome,\n treatment=\"campaign_respondent\",\n post=\"post_campaign\",\n survey_design=sd,\n )\n funnel_results[outcome] = r\n\n# Results table\nfunnel_df = pd.DataFrame({\n \"Metric\": [\"Awareness\", \"Consideration\", \"Purchase Intent\"],\n \"Lift (pp)\": [funnel_results[o].att for o in outcomes],\n \"SE\": [funnel_results[o].se for o in outcomes],\n \"95% CI Lower\": [funnel_results[o].conf_int[0] for o in outcomes],\n \"95% CI Upper\": [funnel_results[o].conf_int[1] for o in outcomes],\n \"p-value\": [funnel_results[o].p_value for o in outcomes],\n}).round(2)\n\nprint(funnel_df.to_string(index=False))", "id": "f891e2f1" }, { @@ -252,7 +252,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# Use waves 1-4 only; split at wave 3 as a \"placebo\" campaign launch\npre_data = data[data[\"wave\"] <= 4].copy()\npre_data[\"placebo_post\"] = (pre_data[\"wave\"] >= 3).astype(int)\n\n# Use survey_design here too — consistent with the main analysis\ndid_placebo = DifferenceInDifferences()\nr_placebo = did_placebo.fit(\n pre_data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n time=\"placebo_post\",\n survey_design=sd,\n)\n\nprint(f\"Placebo lift: {r_placebo.att:.2f} pp (p = {r_placebo.p_value:.3f})\")\nif r_placebo.p_value > 0.05:\n print(\"No significant effect in the pre-campaign period — the method isn't picking up spurious patterns.\")\nelse:\n print(\"WARNING: Significant placebo effect detected — investigate further.\")", + "source": "# Use waves 1-4 only; split at wave 3 as a \"placebo\" campaign launch\npre_data = data[data[\"wave\"] <= 4].copy()\npre_data[\"placebo_post\"] = (pre_data[\"wave\"] >= 3).astype(int)\n\n# Use survey_design here too — consistent with the main analysis\ndid_placebo = DifferenceInDifferences()\nr_placebo = did_placebo.fit(\n pre_data,\n outcome=\"awareness\",\n treatment=\"campaign_respondent\",\n post=\"placebo_post\",\n survey_design=sd,\n)\n\nprint(f\"Placebo lift: {r_placebo.att:.2f} pp (p = {r_placebo.p_value:.3f})\")\nif r_placebo.p_value > 0.05:\n print(\"No significant effect in the pre-campaign period — the method isn't picking up spurious patterns.\")\nelse:\n print(\"WARNING: Significant placebo effect detected — investigate further.\")", "id": "ef7db9b1" }, { diff --git a/docs/tutorials/27_cic_distributional_effects.ipynb b/docs/tutorials/27_cic_distributional_effects.ipynb index a530e8d4..6c0d70a5 100644 --- a/docs/tutorials/27_cic_distributional_effects.ipynb +++ b/docs/tutorials/27_cic_distributional_effects.ipynb @@ -269,7 +269,7 @@ } ], "source": [ - "did = DifferenceInDifferences().fit(df, outcome=\"spend\", treatment=\"treated\", time=\"post\")\n", + "did = DifferenceInDifferences().fit(df, outcome=\"spend\", treatment=\"treated\", post=\"post\")\n", "print(did.summary())\n" ] }, @@ -653,7 +653,7 @@ "source": [ "df_log = df.assign(log_spend=np.log(df[\"spend\"]))\n", "did_log = DifferenceInDifferences().fit(\n", - " df_log, outcome=\"log_spend\", treatment=\"treated\", time=\"post\"\n", + " df_log, outcome=\"log_spend\", treatment=\"treated\", post=\"post\"\n", ")\n", "print(f\"DiD on spend ($): ATT = {did.att:+.2f} (p = {did.p_value:.3f}) -> 'no effect'\")\n", "print(\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index ab1ab81d..d47d8a9a 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -328,10 +328,11 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning - code_refs: [diff_diff/estimators.py, diff_diff/diagnostics.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, diff_diff/power.py] + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/estimators.py, diff_diff/diagnostics.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md, diff_diff/power.py, diff_diff/datasets.py, diff_diff/prep_dgp.py, diff_diff/practitioner.py, diff_diff/agent_workflow.py, README.md, docs/index.rst, docs/quickstart.rst, docs/python_comparison.rst, docs/practitioner_decision_tree.rst, docs/practitioner_getting_started.rst] notes: "2x2 'time' is a 0/1 post dummy - same name as the calendar column everywhere else with a different meaning (the library's worst overload)." - id: M-031 kind: param @@ -341,10 +342,11 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: null - status: planned + status: shimmed phase: 2 warning: FutureWarning - code_refs: [diff_diff/triple_diff.py] + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/triple_diff.py, diff_diff/power.py, diff_diff/prep_dgp.py] notes: "Same post-dummy overload as [M-030], but the NAME 'time' persists as the merged class's staggered calendar column, so removed_in is null - the 4.0 semantic enforcement is [M-085] (mirrors the TWFE pair [M-082]/[M-083])." - id: M-032 kind: param @@ -522,9 +524,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/stacked_did.py, diff_diff/power.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, docs/methodology/REGISTRY.md] notes: "Same concept as control_group elsewhere; underscored value spellings win library-wide (spec section 8). CONSUMER MIGRATION: two sites read the ESTIMATOR attribute via getattr(estimator, 'clean_control', ...) and gate on it - power.py (a 'strict' survey_config rejection and a cross-check against control_group) and practitioner.py (guidance text naming the param). Same getattr-returns-None-after-removal failure mode as [M-095]'s results-side consumers, so both rows migrate their consumers in the shared Phase 2c diff; [M-095] covers results.clean_control readers, this row covers estimator.clean_control readers." - id: M-095 @@ -535,9 +538,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/stacked_did_results.py, diff_diff/_reporting_helpers.py, diff_diff/business_report.py, docs/methodology/REPORTING.md] notes: "Results-side mirror of [M-043]: a public dataclass field carrying the constructor param's name, emitted by to_dict() and rendered by summary() ('Clean control:'). Renaming the param alone would leave the results metadata contradicting the param it reports. The VALUE spellings are already canonical ('not_yet_treated'); only the field name moves. Flips WITH [M-043] in the same Phase 2c diff; the summary() label follows. CONSUMER MIGRATION IS PART OF THIS ROW - two reporting sites read results.clean_control via getattr(..., None) and branch on 'never_treated'/'strict' (_reporting_helpers.py, business_report.py). A getattr default silently returns None after removal, so BOTH would fall through to the default 'not_yet_treated' description and misreport the identifying comparison group - a wrong-answer regression, not a crash. business_report.py also EMITS block['clean_control'] as a serialized reporting key, which section 5's canonical-names-only policy retires at 4.0 (the block keeps 'control_group'). REPORTING.md documents the field and updates with it. Tests must cover a results object exposing ONLY control_group, across all three values." - id: M-044 @@ -548,9 +552,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/wooldridge_results.py, docs/methodology/REPORTING.md, diff_diff/guides/llms-autonomous.txt, diff_diff/guides/llms-full.txt] notes: "Every other to_dataframe selector is level=. Covers ONLY the to_dataframe param rename; the 'event' value vocabulary across Wooldridge's aggregation surfaces is [M-086], and the summary(aggregation=) surface is [M-087]." - id: M-045 @@ -560,9 +565,10 @@ rows: new: null deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/estimators.py, docs/methodology/REGISTRY.md, diff_diff/guides/llms-full.txt, diff_diff/guides/llms-practitioner.txt, diff_diff/synthetic_did.py] notes: "Redundant with vcov_type; inherited by TwoWayFixedEffects (and MultiPeriodDiD until [M-010])." - id: M-046 @@ -572,9 +578,10 @@ rows: new: null deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/triple_diff.py] notes: "" - id: M-047 @@ -584,9 +591,10 @@ rows: new: null deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/had.py] notes: "Only estimator where robust defaults False - a third meaning for the same knob." @@ -890,9 +898,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/wooldridge_results.py, docs/methodology/REGISTRY.md, docs/methodology/papers/wooldridge-2025-review.md] notes: "Wooldridge's EXISTING post-fit aggregate() (emfx-style prior art for spec section 6) accepts the drifted 'event' spelling; unifies to 'event_study' across aggregate/summary/to_dataframe value vocabularies. 'gt' stays as a documented estimator extra (group-time table), like ContinuousDiD's 'dose'." - id: M-087 @@ -903,9 +912,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/wooldridge_results.py] notes: "summary() unifies to the library-wide summary(alpha=None) signature (spec section 5); aggregation selection lives on aggregate(). The alpha param arrives additively in the same PR." @@ -1065,9 +1075,10 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/continuous_did.py] notes: "The one estimator with a design-matrix column in the constructor; moves to fit() per the sklearn split (spec section 7)." @@ -1336,9 +1347,10 @@ rows: new: null deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 2 + status: shimmed + phase: 5 warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py code_refs: [diff_diff/linalg.py, diff_diff/sun_abraham.py] notes: "Fourth site of the 'robust' drop - spec section 7 says the flag dies 'everywhere it exists' but [M-045]..[M-047] enumerated only three. LinearRegression is a top-level export with its OWN __init__ (the TWFE/MultiPeriodDiD hits inherit DifferenceInDifferences.__init__ and ride [M-045]). Redundant with vcov_type, same as its siblings." @@ -1543,3 +1555,45 @@ rows: phase: 2 code_refs: [diff_diff/__init__.py] notes: "Alias-diet warning mechanism: CDiD/Stacked/Gardner served via a PEP 562 module __getattr__ emitting FutureWarning naming the surviving class. Names stay in __all__ through 3.9 but leave module globals: dir()/vars() shrink symmetrically (test_dir_returns_full_module_namespace unaffected) and star-import fires the three warnings - accepted diet behavior, documented in v4-design section 3.2. Ships in phase 2d; test_due_rows_are_terminal forces it terminal before any 3.9 bump. Removals are [M-132]..[M-134]." + - id: M-136 + kind: param-value + group: renames-level + old: "diff_diff:LPDiDResults.to_dataframe[level]=event" + new: "diff_diff:LPDiDResults.to_dataframe[level]=event_study" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/lpdid_results.py] + notes: "Missed-rename amendment (section 8 rule 10, 2026-08-02): LPDiD shipped the drifted 'event' level spelling after the ledger froze; every sibling to_dataframe(level=) uses 'event_study' (two_stage/stacked/staggered/imputation). Same value unification as [M-086]; the default flips to the canonical spelling (identical frame), only an explicit 'event' warns." + - id: M-137 + kind: param + group: renames-post + old: "diff_diff:permutation_test[time]" + new: "diff_diff:permutation_test[post]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/diagnostics.py, docs/api/diagnostics.rst, docs/tutorials/04_parallel_trends.ipynb] + notes: "Missed-rename amendment (section 8 rules 1+10, 2026-08-02): the public time param forwards verbatim into DifferenceInDifferences.fit's 0/1 post dummy (validate_binary at the callee) - a rule-1 overload the phase-1 inventory missed because the surface is a diagnostic function. Companion to [M-030]; [M-138] is the leave_one_out_test twin. The run_placebo_test/run_all_placebo_tests WRAPPERS keep their overloaded time (calendar for timing/group tests, post dummy for these two callees) - a rename cannot fix that; redesign tracked in TODO.md." + - id: M-138 + kind: param + group: renames-post + old: "diff_diff:leave_one_out_test[time]" + new: "diff_diff:leave_one_out_test[post]" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_v4_rename_semantic.py + code_refs: [diff_diff/diagnostics.py, docs/api/diagnostics.rst, docs/tutorials/04_parallel_trends.ipynb] + notes: "Twin of [M-137] - see its notes for the missed-rename rationale and the wrapper-overload deferral." diff --git a/docs/v4-design.md b/docs/v4-design.md index a6d618a7..d96d5c8d 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -160,7 +160,12 @@ signatures. Lifecycle facts live in the cited rows. - `WooldridgeDiDResults.to_dataframe(level=...)` - `aggregation` -> `level` [M-044]; the `"event"` value spelling unifies across its existing `aggregate(type=)` surface [M-086] and `summary(aggregation=)` is retired - for the uniform `summary(alpha=None)` [M-087]. + for the uniform `summary(alpha=None)` [M-087]. Transitional 3.9 shape + (shipped by the 2(c)-ii PR-B): `summary(aggregation=SENTINEL, *, + alpha=None)` - `alpha` is KEYWORD-ONLY while the deprecated `aggregation` + still holds position 1 (a positional float raises a pointed TypeError); + the uniform positional `summary(alpha=None)` arrives with the 4.0 + removal. - `robust` constructor param dropped everywhere it exists [M-045]..[M-047] [M-115] - fully redundant with `vcov_type`, and its default even differed across estimators (True/True/False). Four sites, not three: @@ -592,6 +597,18 @@ missed.** `code_refs` UNION, and the pre-terminal repo-wide grep above remains the per-row safety net (decided 2026-08-01 over per-row duplication). +**Missed-rename amendments (2026-08-02, the 2(c)-ii PR-B sweep):** [M-136] +(LPDiD `to_dataframe(level="event")` - the drifted value spelling every +sibling already writes as `"event_study"`; same unification as [M-086]) and +[M-137]/[M-138] (`permutation_test[time]` / `leave_one_out_test[time]` - +public diagnostics params that forward verbatim into +`DifferenceInDifferences.fit`'s 0/1 post dummy, a rule-1 overload the +phase-1 estimator-roster inventory missed). The `run_placebo_test` / +`run_all_placebo_tests` WRAPPERS keep their single overloaded `time` +(calendar for the timing/group tests, post dummy for the two renamed +callees): a rename cannot express dual semantics - the signature redesign is +tracked in TODO.md and the guard carries honest allowlist reasons. + **Domain vocabulary that is NOT a violation** (recorded so the sweep is not re-litigated): the staggered family's `group`/`groups` on results containers and `GroupTimeEffect.group` name the ATT(g,t) COHORT in Callaway-Sant'Anna's @@ -633,10 +650,10 @@ above; anything only one PR cares about stays in that PR's plan.** | Phase | Ships in | PRs (each: dedicated shim/removal tests + matrix flips + CHANGELOG naming flipped row ids) | |---|---|---| | 1 (this PR) | - | Spec + matrix + enforcement test + support edits | -| 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] (M-020's shim already shipped); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | +| 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] (M-020's shim already shipped); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | | 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple); (b) TripleDifference facade [M-013] + the SDDD alias [M-064]; (c) CiC method= [M-015] | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | -| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | +| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | Citation semantic for the table: a cell may cite a row whose current `phase` diff --git a/tests/test_base_estimator.py b/tests/test_base_estimator.py index 978c51c3..6cab1d0a 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -85,7 +85,7 @@ def _discover(): "TwoStageDiD": {"vcov_type": "hc4"}, "TripleDifference": {"vcov_type": "hc4"}, "EfficientDiD": {"vcov_type": "hc4"}, - "StackedDiD": {"clean_control": "not_a_mode"}, + "StackedDiD": {"control_group": "not_a_mode"}, "LPDiD": {"alpha": 5.0}, "ChangesInChanges": {"alpha": 5.0}, "QDiD": {"alpha": 5.0}, diff --git a/tests/test_br_dr_canonical_datasets.py b/tests/test_br_dr_canonical_datasets.py index 10998a61..74bd05c2 100644 --- a/tests/test_br_dr_canonical_datasets.py +++ b/tests/test_br_dr_canonical_datasets.py @@ -83,7 +83,7 @@ def test_no_significant_disemployment(self, card_krueger_long): card_krueger_long, outcome="employment", treatment="treated", - time="post", + post="post", ) br = BusinessReport( did, @@ -122,7 +122,7 @@ def test_treatment_label_abbreviation_preserved(self, card_krueger_long): card_krueger_long, outcome="employment", treatment="treated", - time="post", + post="post", ) br = BusinessReport( did, diff --git a/tests/test_business_report.py b/tests/test_business_report.py index 57e82013..ba961873 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -71,7 +71,7 @@ @pytest.fixture(scope="module") def did_fit(): df = generate_did_data(n_units=80, n_periods=4, treatment_effect=1.5, seed=7) - did = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", post="post") return did, df @@ -1722,7 +1722,7 @@ def test_stacked_not_yet_treated_surfaces_as_dynamic_without_never_treated_relab from diff_diff import StackedDiD sdf = generate_staggered_data(n_units=80, n_periods=8, treatment_effect=1.5, seed=7) - st = StackedDiD(clean_control="not_yet_treated").fit( + st = StackedDiD(control_group="not_yet_treated").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) assert getattr(st, "clean_control", None) == "not_yet_treated" @@ -1750,7 +1750,7 @@ def test_stacked_strict_clean_control_surfaces_as_dynamic(self): from diff_diff import StackedDiD sdf = generate_staggered_data(n_units=80, n_periods=8, treatment_effect=1.5, seed=7) - st = StackedDiD(clean_control="strict").fit( + st = StackedDiD(control_group="strict").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) sample = BusinessReport(st, auto_diagnostics=False).to_dict()["sample"] @@ -1768,7 +1768,7 @@ def test_stacked_never_treated_surfaces_as_fixed_control(self): from diff_diff import StackedDiD sdf = generate_staggered_data(n_units=80, n_periods=8, treatment_effect=1.5, seed=7) - st = StackedDiD(clean_control="never_treated").fit( + st = StackedDiD(control_group="never_treated").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) sample = BusinessReport(st, auto_diagnostics=False).to_dict()["sample"] @@ -1796,7 +1796,7 @@ def test_stacked_all_eventually_treated_panel_does_not_fabricate_never_treated(s # Sanity: the fixture has no never-treated units. assert sdf[sdf["first_treat"] == 0].empty - st = StackedDiD(clean_control="not_yet_treated", kappa_pre=1, kappa_post=1).fit( + st = StackedDiD(control_group="not_yet_treated", kappa_pre=1, kappa_post=1).fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) sample = BusinessReport(st, auto_diagnostics=False).to_dict()["sample"] @@ -1832,7 +1832,7 @@ class StackedDiDResults: stub.n_control_units = 300 stub.survey_metadata = None stub.event_study_effects = None - stub.clean_control = clean_control + stub.control_group = clean_control return stub def test_not_yet_treated_names_subexperiment_contract(self): @@ -1845,8 +1845,10 @@ def test_not_yet_treated_names_subexperiment_contract(self): assert "IC1" in desc and "IC2" in desc assert "A_s > a + kappa_post" in desc assert "not_yet_treated" not in desc or "``A_s > a + kappa_post``" in desc - # The active clean_control is carried on the block explicitly for - # consumers that want structured access. + # The active control-group rule is carried on the block explicitly + # for consumers that want structured access - under BOTH keys + # through the 3.9 shim window (row M-095). + assert a["control_group"] == "not_yet_treated" assert a["clean_control"] == "not_yet_treated" def test_strict_names_strict_rule(self): @@ -1875,7 +1877,7 @@ def test_summary_does_not_narrate_stacked_dynamic_as_fixed_control(self): from diff_diff import StackedDiD sdf = generate_staggered_data(n_units=80, n_periods=8, treatment_effect=1.5, seed=7) - st = StackedDiD(clean_control="not_yet_treated").fit( + st = StackedDiD(control_group="not_yet_treated").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) summary = BusinessReport(st, auto_diagnostics=False).summary() @@ -1889,13 +1891,13 @@ def test_summary_does_not_narrate_stacked_dynamic_as_fixed_control(self): ) # Must narrate the sub-experiment-specific clean-control contract. assert "sub-experiment-specific clean-control" in summary - assert "clean_control='not_yet_treated'" in summary + assert "control_group='not_yet_treated'" in summary def test_full_report_names_sub_experiment_comparison_for_stacked_strict(self): from diff_diff import StackedDiD sdf = generate_staggered_data(n_units=80, n_periods=8, treatment_effect=1.5, seed=7) - st = StackedDiD(clean_control="strict").fit( + st = StackedDiD(control_group="strict").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) md = BusinessReport(st, auto_diagnostics=False).full_report() @@ -1907,7 +1909,7 @@ def test_full_report_names_sub_experiment_comparison_for_stacked_strict(self): "'- Control: N' line in the Sample section." ) assert "sub-experiment-specific clean controls" in md - assert "clean_control='strict'" in md + assert "control_group='strict'" in md class TestDCDHPhase3AssumptionClause: @@ -2046,7 +2048,7 @@ def test_efficient_did_pt_post_strips_strict_clause(self): assert "PT-Post" in block["description"] def test_stacked_did_strips_strict_clause(self): - stub = self._stub("StackedDiDResults", clean_control="not_yet_treated") + stub = self._stub("StackedDiDResults", control_group="not_yet_treated") block = BusinessReport(stub, auto_diagnostics=False).to_dict()["assumption"] self._assert_no_strict_contract(block["description"]) # Stacked sub-experiment identifying content preserved. @@ -3977,7 +3979,7 @@ def test_analytical_did_result_preserves_native_ci(self): from diff_diff import DifferenceInDifferences, generate_did_data df = generate_did_data(n_units=80, n_periods=4, treatment_effect=1.5, seed=7) - fit = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", time="post") + fit = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", post="post") native_lo, native_hi = fit.conf_int br = BusinessReport(fit, alpha=0.10, auto_diagnostics=False) diff --git a/tests/test_conley_vcov.py b/tests/test_conley_vcov.py index 451de783..1eac3fec 100644 --- a/tests/test_conley_vcov.py +++ b/tests/test_conley_vcov.py @@ -1048,7 +1048,7 @@ def test_did_with_conley_panel_finite_se(self, two_period_panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") assert np.isfinite(res.att) assert np.isfinite(res.se) and res.se > 0 assert res.vcov_type == "conley" @@ -1064,7 +1064,7 @@ def test_did_conley_missing_unit_raises(self, two_period_panel): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(two_period_panel, outcome="y", treatment="treated", time="time") + ).fit(two_period_panel, outcome="y", treatment="treated", post="time") def test_did_conley_unknown_unit_column_raises(self, two_period_panel): """vcov_type='conley' with `unit=` referring to an absent column @@ -1083,7 +1083,7 @@ def test_did_conley_unknown_unit_column_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="missing_unit", ) @@ -1103,7 +1103,7 @@ def test_did_conley_unknown_coord_column_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) @@ -1124,7 +1124,7 @@ def test_did_conley_unknown_cluster_column_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) @@ -1277,7 +1277,7 @@ def test_did_conley_malformed_coord_tuple_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) # Non-string element @@ -1291,7 +1291,7 @@ def test_did_conley_malformed_coord_tuple_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) @@ -1308,7 +1308,7 @@ def test_did_conley_missing_lag_cutoff_raises(self, two_period_panel): two_period_panel, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) @@ -1326,7 +1326,7 @@ def test_did_conley_matches_mpd_post_periods_1(self, two_period_panel): conley_lag_cutoff=1, ) res_did = DifferenceInDifferences(**kwargs).fit( - df, outcome="y", treatment="treated", time="time", unit="unit" + df, outcome="y", treatment="treated", post="time", unit="unit" ) res_mpd = MultiPeriodDiD(**kwargs).fit( df, @@ -1371,7 +1371,7 @@ def _spy(*args, **kwargs): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", absorb=["unit"], ) @@ -2159,14 +2159,14 @@ def test_did_conley_combinations(self, df): vcov_type="conley", conley_coords=("lat", "lon"), conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") # missing conley_lag_cutoff with pytest.raises(ValueError, match="conley_lag_cutoff"): DifferenceInDifferences( vcov_type="conley", conley_coords=("lat", "lon"), conley_cutoff_km=100.0, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") # missing unit with pytest.raises(ValueError, match=r"`unit=`"): DifferenceInDifferences( @@ -2174,7 +2174,7 @@ def test_did_conley_combinations(self, df): conley_coords=("lat", "lon"), conley_cutoff_km=100.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time") + ).fit(df, outcome="y", treatment="treated", post="time") # Valid full kwarg set does NOT raise (separate fixture in # TestConleyEstimatorIntegration covers the finite-SE assertion; # this fixture's treated/time correlation triggers rank deficiency). @@ -2188,7 +2188,7 @@ def test_did_conley_combinations(self, df): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", ) @@ -2209,7 +2209,7 @@ def test_did_conley_with_survey_design_raises(self, df): df, outcome="y", treatment="treated", - time="time", + post="time", unit="unit", survey_design=SurveyDesign(strata="stratum"), ) @@ -2226,7 +2226,7 @@ def test_did_conley_with_wild_bootstrap_raises(self, df): conley_coords=("lat", "lon"), conley_cutoff_km=100.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") def test_synthetic_did_conley_raises(self): from diff_diff import SyntheticDiD @@ -3046,10 +3046,10 @@ def test_did_combined_kernel_finite_se_and_cluster_name(self): conley_lag_cutoff=1, ) res_combined = DifferenceInDifferences(cluster="region", **kwargs).fit( - df, outcome="y", treatment="treated", time="time", unit="unit" + df, outcome="y", treatment="treated", post="time", unit="unit" ) res_bare = DifferenceInDifferences(**kwargs).fit( - df, outcome="y", treatment="treated", time="time", unit="unit" + df, outcome="y", treatment="treated", post="time", unit="unit" ) assert np.isfinite(res_combined.att) assert np.isfinite(res_combined.se) and res_combined.se > 0 @@ -3076,7 +3076,7 @@ def test_did_combined_kernel_time_varying_cluster_raises(self): conley_coords=("lat", "lon"), conley_cutoff_km=2000.0, conley_lag_cutoff=1, - ).fit(df, outcome="y", treatment="treated", time="time", unit="unit") + ).fit(df, outcome="y", treatment="treated", post="time", unit="unit") def test_mpd_combined_kernel_finite_se_and_cluster_name(self): """MultiPeriodDiD(vcov_type='conley', cluster='region') on a 4-period diff --git a/tests/test_continuous_did.py b/tests/test_continuous_did.py index 9ba395dd..0afb1dc5 100644 --- a/tests/test_continuous_did.py +++ b/tests/test_continuous_did.py @@ -1584,8 +1584,10 @@ def _cov_data(seed=5, n_units=120): class TestCovariateAPI: def test_covariates_and_method_in_params(self): - est = ContinuousDiD(covariates=["x1"], estimation_method="reg") + with pytest.warns(FutureWarning, match=r"\(covariates=\) is deprecated"): + est = ContinuousDiD(covariates=["x1"], estimation_method="reg") p = est.get_params() + # raw-keep contract (M-084): the deprecated ctor value round-trips assert p["covariates"] == ["x1"] assert p["estimation_method"] == "reg" assert "pscore_trim" in p and "epv_threshold" in p and "pscore_fallback" in p @@ -1608,9 +1610,18 @@ def test_set_params_transactional(self): def test_ipw_with_covariates_raises(self): data = _cov_data() - est = ContinuousDiD(covariates=["x1"], estimation_method="ipw") + est = ContinuousDiD(estimation_method="ipw") with pytest.raises(NotImplementedError, match="ipw"): - est.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") + est.fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1"], + ) def test_ipw_without_covariates_ok(self): # estimation_method only matters with covariates; ipw default must not @@ -1625,7 +1636,7 @@ def test_survey_with_covariates_raises(self): data = _cov_data() data["w"] = 1.0 - est = ContinuousDiD(covariates=["x1"], estimation_method="reg") + est = ContinuousDiD(estimation_method="reg") with pytest.raises(NotImplementedError, match="survey_design"): est.fit( data, @@ -1635,23 +1646,42 @@ def test_survey_with_covariates_raises(self): "first_treat", "dose", aggregate="dose", + covariates=["x1"], survey_design=SurveyDesign(weights="w"), ) def test_missing_covariate_column_raises(self): data = _cov_data() - est = ContinuousDiD(covariates=["not_a_col"], estimation_method="reg") + est = ContinuousDiD(estimation_method="reg") with pytest.raises(ValueError, match="not found"): - est.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") + est.fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["not_a_col"], + ) def test_missing_covariate_values_raise(self): # Fail closed: a per-cell fallback would silently mix conditional and # unconditional estimands in the aggregate. data = _cov_data() data.loc[data.index[:4], "x1"] = np.nan - est = ContinuousDiD(covariates=["x1"], estimation_method="reg") + est = ContinuousDiD(estimation_method="reg") with pytest.raises(ValueError, match="missing/non-finite covariate"): - est.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") + est.fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1"], + ) def test_default_pscore_fallback_is_error(self): assert ContinuousDiD().pscore_fallback == "error" @@ -1669,13 +1699,21 @@ def test_invalid_nuisance_params_raise(self): def test_covariate_metadata_on_results(self): data = _cov_data() est = ContinuousDiD( - covariates=["x1"], estimation_method="dr", pscore_trim=0.02, epv_threshold=8.0, pscore_fallback="unconditional", ) - res = est.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") + res = est.fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1"], + ) assert res.covariates == ["x1"] assert res.estimation_method == "dr" assert res.pscore_trim == 0.02 @@ -1688,25 +1726,50 @@ def test_covariate_eventstudy_and_bootstrap(self): (reg + dr), with finite inference and bootstrap SE near analytical.""" data = _cov_data(n_units=200) for method in ("reg", "dr"): - es = ContinuousDiD(covariates=["x1"], estimation_method=method).fit( - data, "outcome", "unit", "period", "first_treat", "dose", aggregate="eventstudy" + es = ContinuousDiD(estimation_method=method).fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="eventstudy", + covariates=["x1"], ) assert np.isfinite(es.overall_att) and np.isfinite(es.overall_att_se) - ana = ContinuousDiD(covariates=["x1"], estimation_method=method).fit( - data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose" + ana = ContinuousDiD(estimation_method=method).fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1"], + ) + boot = ContinuousDiD(estimation_method=method, n_bootstrap=199, seed=3).fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1"], ) - boot = ContinuousDiD( - covariates=["x1"], estimation_method=method, n_bootstrap=199, seed=3 - ).fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") assert np.isfinite(boot.overall_att_se) # bootstrap SE within ~30% of analytical (same linearized IF) assert abs(boot.overall_att_se - ana.overall_att_se) / ana.overall_att_se < 0.3 def test_clone_refit_idempotent(self): data = _cov_data() - est = ContinuousDiD(covariates=["x1"], estimation_method="dr", seed=1) + with pytest.warns(FutureWarning, match=r"\(covariates=\) is deprecated"): + est = ContinuousDiD(covariates=["x1"], estimation_method="dr", seed=1) r1 = est.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") - clone = ContinuousDiD(**est.get_params()) + # raw-keep: the clone re-warns because the config still carries the + # deprecated ctor covariates (M-084, documented). + with pytest.warns(FutureWarning, match=r"\(covariates=\) is deprecated"): + clone = ContinuousDiD(**est.get_params()) r2 = clone.fit(data, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") assert abs(float(r1.overall_att) - float(r2.overall_att)) < 1e-12 assert abs(float(r1.overall_att_se) - float(r2.overall_att_se)) < 1e-12 @@ -2003,8 +2066,9 @@ def test_multi_cohort_raises(self): def test_covariates_raises_at_init(self): """covariates + lowest_dose is deferred -> NotImplementedError (config-level).""" - with pytest.raises(NotImplementedError, match="covariates"): - ContinuousDiD(control_group="lowest_dose", covariates=["x1"]) + with pytest.warns(FutureWarning, match=r"\(covariates=\) is deprecated"): + with pytest.raises(NotImplementedError, match="covariates"): + ContinuousDiD(control_group="lowest_dose", covariates=["x1"]) def test_dvals_below_dL_raises(self): """User dvals at/below the reference d_L are rejected on both paths.""" diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 1d812fd6..affc8ca4 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -719,12 +719,12 @@ def test_documented_card_workflow_runs_on_canonical_frame(self): # Without the documented dropna the estimator rejects the frame outright. with pytest.raises(ValueError, match="missing values"): DifferenceInDifferences().fit( - ck_long, outcome="employment", treatment="treated", time="post" + ck_long, outcome="employment", treatment="treated", post="post" ) ck_long = ck_long.dropna(subset=["employment"]) results = DifferenceInDifferences().fit( - ck_long, outcome="employment", treatment="treated", time="post" + ck_long, outcome="employment", treatment="treated", post="post" ) assert np.isfinite(results.att) assert np.isfinite(results.se) @@ -1345,7 +1345,7 @@ def test_card_krueger_with_did(self): # Should be able to fit DiD did = DifferenceInDifferences() - results = did.fit(df_long, outcome="employment", treatment="treated", time="post") + results = did.fit(df_long, outcome="employment", treatment="treated", post="post") assert hasattr(results, "att") assert hasattr(results, "se") @@ -1401,7 +1401,7 @@ def test_prop99_with_did(self): df["post"] = (df["year"] >= 1989).astype(int) did = DifferenceInDifferences() - results = did.fit(df, outcome="lcigsale", treatment="treated_state", time="post") + results = did.fit(df, outcome="lcigsale", treatment="treated_state", post="post") assert hasattr(results, "att") assert hasattr(results, "se") diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py index d3a15a0b..f65b2ff2 100644 --- a/tests/test_diagnostic_report.py +++ b/tests/test_diagnostic_report.py @@ -80,7 +80,7 @@ def did_fit(): warnings.filterwarnings("ignore") df = generate_did_data(n_units=80, n_periods=4, treatment_effect=1.5, seed=7) - did = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences().fit(df, outcome="outcome", treatment="treated", post="post") return did, df diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index ae53085e..f22a68d0 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -380,7 +380,7 @@ def test_basic_permutation(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=50, seed=42, @@ -396,7 +396,7 @@ def test_permutation_p_value_range(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=50, seed=42, @@ -410,7 +410,7 @@ def test_permutation_null_distribution(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=100, seed=42, @@ -426,7 +426,7 @@ def test_permutation_reproducibility(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=50, seed=42, @@ -436,7 +436,7 @@ def test_permutation_reproducibility(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=50, seed=42, @@ -453,7 +453,7 @@ def test_permutation_detects_true_effect(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=100, seed=42, @@ -478,7 +478,7 @@ def test_basic_leave_one_out(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -492,7 +492,7 @@ def test_loo_returns_all_treated_units(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -538,7 +538,7 @@ def test_loo_detects_influential_unit(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -554,7 +554,7 @@ def test_loo_summary_shows_stats(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -590,7 +590,7 @@ def test_loo_single_valid_effect_nan_inference(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -766,7 +766,7 @@ def test_permutation_test_tstat_nan_when_se_zero(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=20, seed=42, @@ -816,7 +816,7 @@ def test_leave_one_out_tstat_nan_when_se_zero(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", ) @@ -836,7 +836,7 @@ def test_permutation_tstat_consistency(self, simple_panel_data): simple_panel_data, outcome="outcome", treatment="treated", - time="post", + post="post", unit="unit", n_permutations=50, seed=42, diff --git a/tests/test_estimators.py b/tests/test_estimators.py index 2494b6b5..e87fbb0a 100644 --- a/tests/test_estimators.py +++ b/tests/test_estimators.py @@ -80,7 +80,7 @@ class TestDifferenceInDifferences: def test_basic_fit(self, simple_2x2_data): """Test basic model fitting.""" did = DifferenceInDifferences() - results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", post="post") assert isinstance(results, DiDResults) assert did.is_fitted_ @@ -91,7 +91,7 @@ def test_basic_fit(self, simple_2x2_data): def test_att_direction(self, simple_did_data): """Test that ATT is estimated in correct direction.""" did = DifferenceInDifferences() - results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_did_data, outcome="outcome", treatment="treated", post="post") # True ATT is 3.0, estimate should be close assert results.att > 0 @@ -114,14 +114,14 @@ def test_formula_with_explicit_interaction(self, simple_2x2_data): def test_robust_vs_classical_se(self, simple_did_data): """Test that robust and classical SEs differ.""" - did_robust = DifferenceInDifferences(robust=True) - did_classical = DifferenceInDifferences(robust=False) + did_robust = DifferenceInDifferences() + did_classical = DifferenceInDifferences(vcov_type="classical") results_robust = did_robust.fit( - simple_did_data, outcome="outcome", treatment="treated", time="post" + simple_did_data, outcome="outcome", treatment="treated", post="post" ) results_classical = did_classical.fit( - simple_did_data, outcome="outcome", treatment="treated", time="post" + simple_did_data, outcome="outcome", treatment="treated", post="post" ) # The vcov matrices should differ (HC1 vs classical) @@ -134,7 +134,7 @@ def test_robust_vs_classical_se(self, simple_did_data): def test_confidence_interval(self, simple_did_data): """Test confidence interval properties.""" did = DifferenceInDifferences(alpha=0.05) - results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_did_data, outcome="outcome", treatment="treated", post="post") lower, upper = results.conf_int assert lower < results.att < upper @@ -142,10 +142,13 @@ def test_confidence_interval(self, simple_did_data): def test_get_set_params(self): """Test sklearn-compatible get_params and set_params.""" - did = DifferenceInDifferences(robust=True, alpha=0.05) + did = DifferenceInDifferences(alpha=0.05) params = did.get_params() - assert params["robust"] is True + # get_params returns the RAW sentinel-era robust arg (None when not + # supplied - row M-045); the resolved legacy bool stays on the attr. + assert params["robust"] is None + assert did.robust is True assert params["alpha"] == 0.05 did.set_params(alpha=0.10) @@ -154,7 +157,7 @@ def test_get_set_params(self): def test_summary_output(self, simple_2x2_data): """Test that summary produces string output.""" did = DifferenceInDifferences() - did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post") + did.fit(simple_2x2_data, outcome="outcome", treatment="treated", post="post") summary = did.summary() assert isinstance(summary, str) @@ -173,7 +176,7 @@ def test_invalid_treatment_values(self): did = DifferenceInDifferences() with pytest.raises(ValueError, match="binary"): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_missing_column_error(self): """Test error when column is missing.""" @@ -186,7 +189,7 @@ def test_missing_column_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError, match="Missing columns"): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_unfitted_model_error(self): """Test error when accessing results before fitting.""" @@ -207,7 +210,7 @@ def test_rank_deficient_action_error_raises(self, simple_2x2_data): data, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["collinear_cov"], ) @@ -227,7 +230,7 @@ def test_rank_deficient_action_silent_no_warning(self, simple_2x2_data): data, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["collinear_cov"], ) @@ -263,7 +266,7 @@ def test_rank_deficient_action_warn_default(self, simple_2x2_data): data, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["collinear_cov"], ) @@ -282,7 +285,7 @@ class TestDiDResults: def test_repr(self, simple_2x2_data): """Test string representation.""" did = DifferenceInDifferences() - results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", post="post") repr_str = repr(results) assert "DiDResults" in repr_str @@ -291,7 +294,7 @@ def test_repr(self, simple_2x2_data): def test_to_dict(self, simple_2x2_data): """Test conversion to dictionary.""" did = DifferenceInDifferences() - results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", post="post") result_dict = results.to_dict() assert "att" in result_dict @@ -301,7 +304,7 @@ def test_to_dict(self, simple_2x2_data): def test_to_dataframe(self, simple_2x2_data): """Test conversion to DataFrame.""" did = DifferenceInDifferences() - results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", post="post") df = results.to_dataframe() assert isinstance(df, pd.DataFrame) @@ -311,7 +314,7 @@ def test_to_dataframe(self, simple_2x2_data): def test_significance_stars(self, simple_did_data): """Test significance star notation.""" did = DifferenceInDifferences() - results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_did_data, outcome="outcome", treatment="treated", post="post") # With true effect of 3.0 and n=200, should be significant assert results.significance_stars in ["*", "**", "***"] @@ -319,7 +322,7 @@ def test_significance_stars(self, simple_did_data): def test_is_significant_property(self, simple_did_data): """Test is_significant property.""" did = DifferenceInDifferences(alpha=0.05) - results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(simple_did_data, outcome="outcome", treatment="treated", post="post") # Boolean check assert isinstance(results.is_significant, bool) @@ -374,7 +377,7 @@ def test_fixed_effects_dummy(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["state"], ) @@ -390,7 +393,7 @@ def test_fixed_effects_coefficients_include_dummies(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["state"], ) @@ -402,7 +405,7 @@ def test_absorb_fixed_effects(self, panel_data_with_fe): """Test absorbed (within-transformed) fixed effects.""" did = DifferenceInDifferences() results = did.fit( - panel_data_with_fe, outcome="outcome", treatment="treated", time="post", absorb=["unit"] + panel_data_with_fe, outcome="outcome", treatment="treated", post="post", absorb=["unit"] ) assert results is not None @@ -416,14 +419,14 @@ def test_fixed_effects_vs_no_fe(self, panel_data_with_fe): did_with_fe = DifferenceInDifferences() results_no_fe = did_no_fe.fit( - panel_data_with_fe, outcome="outcome", treatment="treated", time="post" + panel_data_with_fe, outcome="outcome", treatment="treated", post="post" ) results_with_fe = did_with_fe.fit( panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["state"], ) @@ -442,7 +445,7 @@ def test_invalid_fixed_effects_column(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["nonexistent_column"], ) @@ -454,7 +457,7 @@ def test_invalid_absorb_column(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["nonexistent_column"], ) @@ -468,7 +471,7 @@ def test_multiple_fixed_effects(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["state", "industry"], ) @@ -489,7 +492,7 @@ def test_covariates_with_fixed_effects(self, panel_data_with_fe): panel_data_with_fe, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["size"], fixed_effects=["state"], ) @@ -764,7 +767,7 @@ def test_multicollinearity_detection(self): data, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["duplicate_treated"], ) # Should emit a warning about rank deficiency @@ -1106,12 +1109,12 @@ def test_cluster_robust_se(self): # With clustering did_cluster = DifferenceInDifferences(cluster="cluster") - results_cluster = did_cluster.fit(df, outcome="outcome", treatment="treated", time="post") + results_cluster = did_cluster.fit(df, outcome="outcome", treatment="treated", post="post") # Without clustering - did_no_cluster = DifferenceInDifferences(robust=True) + did_no_cluster = DifferenceInDifferences() results_no_cluster = did_no_cluster.fit( - df, outcome="outcome", treatment="treated", time="post" + df, outcome="outcome", treatment="treated", post="post" ) # ATT should be similar @@ -1432,7 +1435,7 @@ def test_with_absorbed_fe(self, multi_period_data): def test_cluster_robust_se(self, multi_period_data): """Test cluster-robust standard errors.""" did_cluster = MultiPeriodDiD(cluster="unit") - did_robust = MultiPeriodDiD(robust=True) + did_robust = MultiPeriodDiD() results_cluster = did_cluster.fit( multi_period_data, @@ -3220,7 +3223,7 @@ def test_did_with_missing_periods(self): df = pd.DataFrame(data) did = DifferenceInDifferences() - results = did.fit(df, outcome="outcome", treatment="treated", time="post") + results = did.fit(df, outcome="outcome", treatment="treated", post="post") # Should still produce valid results assert np.isfinite(results.att) @@ -3361,7 +3364,7 @@ def test_did_single_treated_unit(self): df = pd.DataFrame(data) did = DifferenceInDifferences() - results = did.fit(df, outcome="outcome", treatment="treated", time="post") + results = did.fit(df, outcome="outcome", treatment="treated", post="post") # Should produce valid results assert np.isfinite(results.att) @@ -3458,7 +3461,7 @@ def test_did_with_redundant_covariate_emits_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") result = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["x1", "x2"] + data, outcome="outcome", treatment="treated", post="post", covariates=["x1", "x2"] ) # Should emit a warning about rank deficiency rank_warnings = [x for x in w if "Rank-deficient" in str(x.message)] @@ -3493,7 +3496,7 @@ def test_did_with_constant_covariate_emits_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") result = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["constant_x"] + data, outcome="outcome", treatment="treated", post="post", covariates=["constant_x"] ) # Should emit a warning about rank deficiency rank_warnings = [x for x in w if "Rank-deficient" in str(x.message)] @@ -3520,7 +3523,7 @@ def test_did_with_near_collinear_covariates(self): # Near-collinear should work (not perfectly rank-deficient) results = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["x1", "x2"] + data, outcome="outcome", treatment="treated", post="post", covariates=["x1", "x2"] ) assert np.isfinite(results.att) @@ -3777,7 +3780,7 @@ def test_did_absorb_spanned_main_effects_nan_with_cause_warning(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["state", "month"], ) assert np.isnan(res.coefficients["treated"]) @@ -3796,7 +3799,7 @@ def test_did_absorb_att_matches_clean_fwl_ground_truth(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["state", "month"], ) d = df.copy() @@ -3908,7 +3911,7 @@ def test_replicate_local_spanning_yields_finite_inference(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["state", "month"], covariates=["xc"], survey_design=design, @@ -3958,7 +3961,7 @@ def test_did_absorb_joint_span_covariate_dropped_att_stable(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], ) with pytest.warns(UserWarning, match=r"xspan.*collinear with the absorbed"): @@ -3966,7 +3969,7 @@ def test_did_absorb_joint_span_covariate_dropped_att_stable(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], covariates=["xspan"], ) diff --git a/tests/test_estimators_vcov_type.py b/tests/test_estimators_vcov_type.py index b4941a9d..67e6bbfe 100644 --- a/tests/test_estimators_vcov_type.py +++ b/tests/test_estimators_vcov_type.py @@ -45,7 +45,7 @@ def _make_did_panel(n_units: int = 30, seed: int = 20260420) -> pd.DataFrame: class TestRobustAliasing: def test_robust_true_aliases_hc1(self): - est = DifferenceInDifferences(robust=True) + est = DifferenceInDifferences() assert est.vcov_type == "hc1" def test_robust_false_aliases_classical(self): @@ -161,7 +161,6 @@ def test_set_params_conflict_leaves_estimator_unchanged(self): atomic behavior: on failure, no attribute moves. """ est = DifferenceInDifferences( - robust=True, vcov_type="hc1", cluster=None, alpha=0.05, @@ -216,7 +215,7 @@ def test_robust_false_with_cluster_preserves_cr1(self): data = _make_did_panel(n_units=20) est = DifferenceInDifferences(robust=False, cluster="unit") with pytest.warns(UserWarning, match="robust=False with cluster"): - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert np.isfinite(res.att) assert np.isfinite(res.se) # The effective vcov_type in the result reflects the remap. @@ -235,7 +234,7 @@ def test_explicit_classical_with_cluster_still_raises(self): est = DifferenceInDifferences(vcov_type="classical", cluster="unit") assert est._vcov_type_explicit is True with pytest.raises(ValueError, match="classical SEs are one-way only"): - est.fit(data, outcome="y", treatment="treated", time="time") + est.fit(data, outcome="y", treatment="treated", post="time") def test_twfe_robust_false_preserves_cr1_via_autocluster(self): """TWFE auto-clusters at unit; `robust=False` on TWFE historically @@ -351,7 +350,7 @@ def test_robust_false_without_cluster_stays_classical(self): should still produce classical non-robust SEs.""" data = _make_did_panel(n_units=20) est = DifferenceInDifferences(robust=False) - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert res.vcov_type == "classical" assert "Classical OLS" in res.summary() @@ -376,9 +375,9 @@ def test_get_params_round_trip_preserves_implicit_classical(self): # Fit both: should behave identically (CR1 via remap, with warning). data = _make_did_panel(n_units=20) with pytest.warns(UserWarning, match="robust=False with cluster"): - res_orig = orig.fit(data, outcome="y", treatment="treated", time="time") + res_orig = orig.fit(data, outcome="y", treatment="treated", post="time") with pytest.warns(UserWarning, match="robust=False with cluster"): - res_clone = clone.fit(data, outcome="y", treatment="treated", time="time") + res_clone = clone.fit(data, outcome="y", treatment="treated", post="time") assert res_orig.vcov_type == res_clone.vcov_type == "hc1" # Point estimate and SE identical. assert res_orig.att == pytest.approx(res_clone.att, abs=1e-12) @@ -402,13 +401,13 @@ def test_set_params_robust_false_then_cluster_preserves_cr1(self): est.set_params(robust=False, cluster="unit") assert est._vcov_type_explicit is False # robust= only, no vcov_type with pytest.warns(UserWarning, match="robust=False with cluster"): - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert res.vcov_type == "hc1" def test_hc1_fit_and_summary_contain_expected_fields(self): data = _make_did_panel() est = DifferenceInDifferences(vcov_type="hc1") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert np.isfinite(res.att) assert np.isfinite(res.se) assert np.isfinite(res.conf_int[0]) @@ -427,8 +426,8 @@ def test_hc1_and_hc2_bm_both_fit(self): data = _make_did_panel() est_hc1 = DifferenceInDifferences(vcov_type="hc1") est_hc2bm = DifferenceInDifferences(vcov_type="hc2_bm") - r_hc1 = est_hc1.fit(data, outcome="y", treatment="treated", time="time") - r_hc2bm = est_hc2bm.fit(data, outcome="y", treatment="treated", time="time") + r_hc1 = est_hc1.fit(data, outcome="y", treatment="treated", post="time") + r_hc2bm = est_hc2bm.fit(data, outcome="y", treatment="treated", post="time") # Point estimate unaffected by vcov choice. assert r_hc1.att == pytest.approx(r_hc2bm.att, abs=1e-10) # Both produce finite SEs and CIs. @@ -440,35 +439,35 @@ def test_hc1_and_hc2_bm_both_fit(self): def test_classical_via_robust_false(self): data = _make_did_panel() est = DifferenceInDifferences(robust=False) - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert np.isfinite(res.att) assert np.isfinite(res.se) def test_classical_via_explicit_vcov_type(self): data = _make_did_panel() est = DifferenceInDifferences(vcov_type="classical") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert np.isfinite(res.se) def test_summary_includes_vcov_label_hc1(self): """`summary()` output includes an HC1 label in the Variance line.""" data = _make_did_panel() est = DifferenceInDifferences(vcov_type="hc1") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") summary = res.summary() assert "HC1 heteroskedasticity-robust" in summary def test_summary_includes_vcov_label_hc2_bm(self): data = _make_did_panel() est = DifferenceInDifferences(vcov_type="hc2_bm") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") summary = res.summary() assert "HC2 + Bell-McCaffrey" in summary def test_summary_includes_vcov_label_classical(self): data = _make_did_panel() est = DifferenceInDifferences(vcov_type="classical") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") summary = res.summary() assert "Classical OLS SEs" in summary @@ -476,7 +475,7 @@ def test_summary_includes_vcov_label_cr1(self): """CR1 cluster-robust (HC1 + cluster) labels with the cluster name.""" data = _make_did_panel() est = DifferenceInDifferences(vcov_type="hc1", cluster="unit") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") summary = res.summary() assert "CR1 cluster-robust at unit" in summary @@ -771,7 +770,7 @@ def test_twfe_hc2_matches_did_fixed_effects_full_dummy(self): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], ) np.testing.assert_allclose(res_twfe.att, res_did.att, atol=1e-12) @@ -792,7 +791,7 @@ def test_twfe_hc2_bm_matches_did_fixed_effects_full_dummy(self): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], ) np.testing.assert_allclose(res_twfe.att, res_did.att, atol=1e-12) @@ -846,7 +845,7 @@ def test_twfe_hc2_bm_auto_clusters_at_unit(self): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], ) np.testing.assert_allclose(res_twfe.att, res_did.att, atol=1e-12) @@ -1092,7 +1091,7 @@ def test_twfe_hc2_full_surface_matches_did_fixed_effects(self, vcov): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], ) assert res_twfe.residuals is not None and res_did.residuals is not None @@ -1145,7 +1144,7 @@ def test_twfe_hc2_with_survey_weights_matches_did_fixed_effects(self, vcov): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], survey_design=sd, ) @@ -1190,7 +1189,7 @@ def test_twfe_hc2_with_survey_strata_psu_matches_did_fixed_effects(self, vcov): data, outcome="y", treatment="treated", - time="time", + post="time", fixed_effects=["unit", "time"], survey_design=sd, ) @@ -1308,14 +1307,14 @@ def test_did_absorb_hc2_and_hc2_bm_auto_route(self): data, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit"], ) res_fe = DifferenceInDifferences(vcov_type=vcov).fit( data, outcome="y", treatment="treated", - time="post", + post="post", fixed_effects=["unit"], ) assert np.isfinite(res_absorb.att) @@ -1355,7 +1354,7 @@ def test_did_fixed_effects_dummies_still_accept_hc2_and_hc2_bm(self): data, outcome="y", treatment="treated", - time="post", + post="post", fixed_effects=["stratum"], ) assert np.isfinite(res.att) @@ -1384,7 +1383,7 @@ def test_summary_suppresses_variance_line_under_wild_bootstrap(self): n_bootstrap=50, seed=7, ) - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") summary = res.summary() # The bootstrap path substitutes SE/CI from resampling; the Variance: # line (which labels the analytical family) must be suppressed so the @@ -1407,7 +1406,7 @@ def test_wild_bootstrap_preserves_vcov_type_no_error(self): n_bootstrap=50, seed=42, ) - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert np.isfinite(res.se) @@ -1478,7 +1477,7 @@ def test_survey_taylor_suppresses_analytical_variance_label(self): data, outcome="y", treatment="treated", - time="time", + post="time", survey_design=sd, ) assert res.survey_metadata is not None @@ -1517,7 +1516,7 @@ def test_survey_replicate_weights_suppresses_analytical_variance_label(self): data, outcome="y", treatment="treated", - time="time", + post="time", survey_design=sd, ) assert res.survey_metadata is not None @@ -1561,7 +1560,7 @@ def test_non_survey_fit_still_prints_variance_label(self): """ data = _make_did_panel(n_units=30) est = DifferenceInDifferences(vcov_type="hc1") - res = est.fit(data, outcome="y", treatment="treated", time="time") + res = est.fit(data, outcome="y", treatment="treated", post="time") assert res.survey_metadata is None summary = res.summary() assert "Variance:" in summary @@ -1614,7 +1613,7 @@ def _fit_absorb(self, d, vcov_type): data, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "period"], unit="unit", ) @@ -1792,7 +1791,7 @@ def test_absorb_hc2_bm_clustered_matches_clubsandwich(self): data, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "period"], unit="unit", ) @@ -1827,7 +1826,7 @@ def test_absorb_plus_fixed_effects_still_rejected_under_hc2_bm(self): data, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit"], fixed_effects=["period"], unit="unit", @@ -1867,7 +1866,7 @@ def test_absorb_hc2_bm_survey_multi_absorb_auto_routes(self): data, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "period"], unit="unit", survey_design=sd, @@ -2582,7 +2581,7 @@ def test_did_absorb_matches_full_dummy_oracle(self, vcov): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], covariates=["x"], ) @@ -2590,7 +2589,7 @@ def test_did_absorb_matches_full_dummy_oracle(self, vcov): df, outcome="y", treatment="treated", - time="post", + post="post", fixed_effects=["unit", "time"], covariates=["x"], ) @@ -2624,7 +2623,7 @@ def test_twfe_classical_matches_full_dummy_oracle(self): df, outcome="y", treatment="treated", time="post", unit="unit" ) fe = DifferenceInDifferences(vcov_type="classical").fit( - df, outcome="y", treatment="treated", time="post", fixed_effects=["unit", "post"] + df, outcome="y", treatment="treated", post="post", fixed_effects=["unit", "post"] ) np.testing.assert_allclose(tw.att, fe.att, rtol=1e-9) np.testing.assert_allclose(tw.se, fe.se, rtol=1e-9) @@ -2644,7 +2643,7 @@ def test_absorb_cluster_converges_on_k_reference(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], covariates=["x"], ) @@ -2652,7 +2651,7 @@ def test_absorb_cluster_converges_on_k_reference(self): df, outcome="y", treatment="treated", - time="post", + post="post", fixed_effects=["unit", "time"], covariates=["x"], ) @@ -2668,7 +2667,7 @@ def test_absorb_hc2_bm_not_rescaled(self): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], covariates=["x"], ) @@ -2676,7 +2675,7 @@ def test_absorb_hc2_bm_not_rescaled(self): df, outcome="y", treatment="treated", - time="post", + post="post", fixed_effects=["unit", "time"], covariates=["x"], ) @@ -2791,10 +2790,10 @@ def test_default_residual_is_noop(self): data = self._clustered_panel() r0 = DifferenceInDifferences(cluster="unit").fit( - data, outcome="y", treatment="group", time="post", unit="unit" + data, outcome="y", treatment="group", post="post", unit="unit" ) r1 = DifferenceInDifferences(cluster="unit", df_convention="residual").fit( - data, outcome="y", treatment="group", time="post", unit="unit" + data, outcome="y", treatment="group", post="post", unit="unit" ) assert (r0.att, r0.se, r0.t_stat, r0.p_value, r0.conf_int) == ( r1.att, @@ -2832,8 +2831,8 @@ def test_twfe_inherits_knob(self): data = self._clustered_panel() data["treated"] = data["group"] * data["post"] kw = dict(outcome="y", treatment="treated", time="post", unit="unit") - r0 = TwoWayFixedEffects(robust=True).fit(data, **kw) - r1 = TwoWayFixedEffects(robust=True, df_convention="cluster").fit(data, **kw) + r0 = TwoWayFixedEffects().fit(data, **kw) + r1 = TwoWayFixedEffects(df_convention="cluster").fit(data, **kw) assert r0.se == r1.se and r0.t_stat == r1.t_stat G = data["unit"].nunique() np.testing.assert_allclose(r1.p_value, 2 * stats.t.sf(abs(r1.t_stat), G - 1), rtol=1e-12) @@ -2892,7 +2891,7 @@ def test_results_metadata_did_twfe(self): included in to_dict() only when set.""" data = self._clustered_panel() r1 = DifferenceInDifferences(cluster="unit", df_convention="cluster").fit( - data, outcome="y", treatment="group", time="post", unit="unit" + data, outcome="y", treatment="group", post="post", unit="unit" ) G = data["unit"].nunique() assert r1.df_convention == "cluster" and r1.inference_df == G - 1 diff --git a/tests/test_fixest_did_twfe_parity.py b/tests/test_fixest_did_twfe_parity.py index c58420ad..7db3f502 100644 --- a/tests/test_fixest_did_twfe_parity.py +++ b/tests/test_fixest_did_twfe_parity.py @@ -59,7 +59,7 @@ def test_did_2x2_classical_se_matches_fixest_iid(self): golden = _load_golden() df = _build_df(golden["did"]) res = DifferenceInDifferences(vcov_type="classical").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden["did"]["iid"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -88,7 +88,7 @@ def test_did_cluster_se_matches_fixest_exactly(self): assert key in golden, f"required golden block {key!r} missing — regenerate the fixture" df = _build_df(golden[key]) res = DifferenceInDifferences(vcov_type="hc1", cluster="unit").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden[key]["cluster_unit"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -138,7 +138,7 @@ def test_did_hetero_hc1_matches_fixest_machine_precision(self): ), "required golden block 'did_hetero' missing — regenerate the fixture" df = _build_df(golden["did_hetero"]) res = DifferenceInDifferences(vcov_type="hc1").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden["did_hetero"]["hetero"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) @@ -153,7 +153,7 @@ def test_did_hetero_iid_matches_fixest_machine_precision(self): ), "required golden block 'did_hetero' missing — regenerate the fixture" df = _build_df(golden["did_hetero"]) res = DifferenceInDifferences(vcov_type="classical").fit( - df, outcome="outcome", treatment="treated", time="post", unit="unit" + df, outcome="outcome", treatment="treated", post="post", unit="unit" ) exp = golden["did_hetero"]["iid"] np.testing.assert_allclose(res.att, exp["att"], atol=1e-10, rtol=0) diff --git a/tests/test_had.py b/tests/test_had.py index 628bfb43..4a8b8c77 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -466,9 +466,10 @@ def test_cr1_cluster_robust_parity(self): def test_robust_alias_maps_to_hc1(self): d, dy = _dgp_mass_point(500, seed=0) panel = _make_panel(d, dy) - r_robust = HeterogeneousAdoptionDiD(design="mass_point", robust=True).fit( - panel, "outcome", "dose", "period", "unit" - ) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + r_robust = HeterogeneousAdoptionDiD(design="mass_point", robust=True).fit( + panel, "outcome", "dose", "period", "unit" + ) r_hc1 = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1").fit( panel, "outcome", "dose", "period", "unit" ) @@ -477,9 +478,10 @@ def test_robust_alias_maps_to_hc1(self): def test_robust_false_maps_to_classical(self): d, dy = _dgp_mass_point(500, seed=0) panel = _make_panel(d, dy) - r_robust = HeterogeneousAdoptionDiD(design="mass_point", robust=False).fit( - panel, "outcome", "dose", "period", "unit" - ) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + r_robust = HeterogeneousAdoptionDiD(design="mass_point", robust=False).fit( + panel, "outcome", "dose", "period", "unit" + ) r_classical = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="classical").fit( panel, "outcome", "dose", "period", "unit" ) @@ -489,9 +491,10 @@ def test_vcov_type_explicit_overrides_robust(self): """When vcov_type is explicit, robust is ignored.""" d, dy = _dgp_mass_point(500, seed=0) panel = _make_panel(d, dy) - r = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="classical", robust=True).fit( - panel, "outcome", "dose", "period", "unit" - ) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + r = HeterogeneousAdoptionDiD( + design="mass_point", vcov_type="classical", robust=True + ).fit(panel, "outcome", "dose", "period", "unit") assert r.vcov_type == "classical" @@ -544,7 +547,8 @@ def test_robust_true_ignored_on_continuous_warns(self): """ d, dy = _dgp_continuous_at_zero(300, seed=0) panel = _make_panel(d, dy) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero", robust=True) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + est = HeterogeneousAdoptionDiD(design="continuous_at_zero", robust=True) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") r = est.fit(panel, "outcome", "dose", "period", "unit") @@ -556,7 +560,8 @@ def test_robust_false_silent_on_continuous(self): """robust=False (the default) on continuous path emits no robust-warn.""" d, dy = _dgp_continuous_at_zero(300, seed=0) panel = _make_panel(d, dy) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero", robust=False) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + est = HeterogeneousAdoptionDiD(design="continuous_at_zero", robust=False) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") r = est.fit(panel, "outcome", "dose", "period", "unit") @@ -812,7 +817,6 @@ def test_get_params_returns_all_constructor_args(self): kernel="triangular", alpha=0.1, vcov_type="hc1", - robust=True, cluster="state", n_bootstrap=500, seed=42, @@ -824,7 +828,7 @@ def test_get_params_returns_all_constructor_args(self): "kernel": "triangular", "alpha": 0.1, "vcov_type": "hc1", - "robust": True, + "robust": None, "cluster": "state", "n_bootstrap": 500, "seed": 42, @@ -5633,6 +5637,8 @@ def test_mass_point_default_vcov_robust_true_survey_allowed(self): sd = SurveyDesign(weights="w") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) + # robust= deliberately exercises the deprecated alias (M-047) + warnings.simplefilter("ignore", FutureWarning) est = HeterogeneousAdoptionDiD(design="mass_point", robust=True) r = est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) assert r.vcov_type == "hc1" diff --git a/tests/test_linalg.py b/tests/test_linalg.py index eb37aeb3..91bc34d7 100644 --- a/tests/test_linalg.py +++ b/tests/test_linalg.py @@ -1061,7 +1061,7 @@ def test_predict(self, simple_data): def test_robust_standard_errors(self, simple_data): """Test that robust=True computes HC1 standard errors.""" X, y, _ = simple_data - reg_robust = LinearRegression(robust=True).fit(X, y) + reg_robust = LinearRegression().fit(X, y) reg_classical = LinearRegression(robust=False).fit(X, y) # SEs should differ @@ -1074,7 +1074,7 @@ def test_cluster_standard_errors(self, clustered_data): """Test cluster-robust standard errors.""" X, y, cluster_ids, _ = clustered_data - reg_hc1 = LinearRegression(robust=True).fit(X, y) + reg_hc1 = LinearRegression().fit(X, y) reg_cluster = LinearRegression(cluster_ids=cluster_ids).fit(X, y) # Cluster SE should typically be larger with correlated errors @@ -1148,7 +1148,7 @@ def test_matches_solve_ols(self, simple_data): ) # Use LinearRegression - reg = LinearRegression(robust=True).fit(X, y) + reg = LinearRegression().fit(X, y) # Should match np.testing.assert_allclose(reg.coefficients_, coef, rtol=1e-10) @@ -1506,7 +1506,7 @@ def test_did_finite_att_with_large_scale_covariate(self): df["outcome"] = df["outcome"].astype(float) + 3e-8 * df["cov"] res = DifferenceInDifferences().fit( - df, outcome="outcome", treatment="treated", time="post", covariates=["cov"] + df, outcome="outcome", treatment="treated", post="post", covariates=["cov"] ) assert np.isfinite( res.att @@ -1520,7 +1520,7 @@ def test_did_finite_att_with_large_scale_covariate(self): df_small, outcome="outcome", treatment="treated", - time="post", + post="post", covariates=["cov"], ) np.testing.assert_allclose(res.att, res_small.att, rtol=1e-6) @@ -1874,8 +1874,8 @@ def test_did_estimator_produces_valid_results(self): data["outcome"] = np.random.randn(n) + 2.0 * data["treated"] * data["post"] # Fit estimator - did = DifferenceInDifferences(robust=True) - result = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences() + result = did.fit(data, outcome="outcome", treatment="treated", post="post") # Coefficient should be close to true effect (within sampling variation) assert abs(result.att - 2.0) < 1.0 diff --git a/tests/test_lpdid.py b/tests/test_lpdid.py index b4d83d74..1f15b8d6 100644 --- a/tests/test_lpdid.py +++ b/tests/test_lpdid.py @@ -291,7 +291,7 @@ def test_results_to_dataframe_and_repr(self): no_composition=False, pmd=None, ) - assert results.to_dataframe(level="event").equals(df) + assert results.to_dataframe(level="event_study").equals(df) with pytest.raises(ValueError, match="not computed"): results.to_dataframe(level="pooled") with pytest.raises(ValueError, match="level must be"): diff --git a/tests/test_methodology_continuous_did.py b/tests/test_methodology_continuous_did.py index 1b2e8ae2..9263f06d 100644 --- a/tests/test_methodology_continuous_did.py +++ b/tests/test_methodology_continuous_did.py @@ -935,12 +935,21 @@ def se(inf): def _fit_cov(df, method, **kw): - est = ContinuousDiD(covariates=["x1", "x2"], estimation_method=method, **kw) + est = ContinuousDiD(estimation_method=method, **kw) import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") - return est.fit(df, "outcome", "unit", "period", "first_treat", "dose", aggregate="dose") + return est.fit( + df, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="dose", + covariates=["x1", "x2"], + ) class TestCovariateReg: diff --git a/tests/test_methodology_did.py b/tests/test_methodology_did.py index 0f999455..bacd35be 100644 --- a/tests/test_methodology_did.py +++ b/tests/test_methodology_did.py @@ -293,7 +293,7 @@ def test_att_equals_double_difference_of_means(self): data, expected_att = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") # ATT should match hand calculation exactly (to numerical precision) assert np.isclose( @@ -309,7 +309,7 @@ def test_att_equals_regression_interaction_coefficient(self): data, expected_att = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") # Check that the interaction coefficient matches ATT assert results.coefficients is not None, "coefficients should not be None" @@ -334,7 +334,7 @@ def test_att_with_covariates_close_to_true_effect(self): did = DifferenceInDifferences() results = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["x1"] + data, outcome="outcome", treatment="treated", post="post", covariates=["x1"] ) # Should recover approximately 2.5 treatment effect @@ -363,7 +363,7 @@ def test_att_sign_matches_data_direction(self): ) did = DifferenceInDifferences() - results_pos = did.fit(data_pos, outcome="outcome", treatment="treated", time="post") + results_pos = did.fit(data_pos, outcome="outcome", treatment="treated", post="post") assert np.isclose( results_pos.att, 10.0, rtol=1e-10 ), f"Expected ATT=10.0, got {results_pos.att}" @@ -386,7 +386,7 @@ def test_att_sign_matches_data_direction(self): } ) - results_neg = did.fit(data_neg, outcome="outcome", treatment="treated", time="post") + results_neg = did.fit(data_neg, outcome="outcome", treatment="treated", post="post") assert np.isclose( results_neg.att, -10.0, rtol=1e-10 ), f"Expected ATT=-10.0, got {results_neg.att}" @@ -548,8 +548,8 @@ def test_att_matches_r_basic(self, require_fixest, benchmark_data): data, csv_path = benchmark_data # Python estimation - did = DifferenceInDifferences(robust=True) - py_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences() + py_results = did.fit(data, outcome="outcome", treatment="treated", post="post") # R estimation r_results = self._run_r_estimation(csv_path, robust=True) @@ -564,8 +564,8 @@ def test_se_matches_r_hc1(self, require_fixest, benchmark_data): """Test HC1 standard errors match R within 5%.""" data, csv_path = benchmark_data - did = DifferenceInDifferences(robust=True) - py_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences() + py_results = did.fit(data, outcome="outcome", treatment="treated", post="post") r_results = self._run_r_estimation(csv_path, robust=True) @@ -579,8 +579,8 @@ def test_pvalue_matches_r(self, require_fixest, benchmark_data): """Test p-value matches R within 0.01.""" data, csv_path = benchmark_data - did = DifferenceInDifferences(robust=True) - py_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences() + py_results = did.fit(data, outcome="outcome", treatment="treated", post="post") r_results = self._run_r_estimation(csv_path, robust=True) @@ -593,8 +593,8 @@ def test_ci_overlaps_r(self, require_fixest, benchmark_data): """Test confidence intervals overlap with R.""" data, csv_path = benchmark_data - did = DifferenceInDifferences(robust=True, alpha=0.05) - py_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences(alpha=0.05) + py_results = did.fit(data, outcome="outcome", treatment="treated", post="post") r_results = self._run_r_estimation(csv_path, robust=True) @@ -611,9 +611,9 @@ def test_att_matches_r_with_covariates(self, require_fixest, benchmark_data_with """Test ATT matches R when covariates are included.""" data, csv_path = benchmark_data_with_covariate - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() py_results = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["x1"] + data, outcome="outcome", treatment="treated", post="post", covariates=["x1"] ) r_results = self._run_r_estimation(csv_path, covariates=["x1"], robust=True) @@ -629,7 +629,7 @@ def test_se_matches_r_with_clustering(self, require_fixest, benchmark_data_clust data, csv_path = benchmark_data_clustered did = DifferenceInDifferences(cluster="cluster_id") - py_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + py_results = did.fit(data, outcome="outcome", treatment="treated", post="post") r_results = self._run_r_estimation(csv_path, cluster="cluster_id") @@ -663,12 +663,12 @@ def test_hc1_vs_classical_se_differ(self): """ data = generate_heteroskedastic_did_data(n_units=200, treatment_effect=3.0, seed=42) - did_hc1 = DifferenceInDifferences(robust=True) + did_hc1 = DifferenceInDifferences() did_classical = DifferenceInDifferences(robust=False) - results_hc1 = did_hc1.fit(data, outcome="outcome", treatment="treated", time="post") + results_hc1 = did_hc1.fit(data, outcome="outcome", treatment="treated", post="post") results_classical = did_classical.fit( - data, outcome="outcome", treatment="treated", time="post" + data, outcome="outcome", treatment="treated", post="post" ) # SE methods should produce both valid and positive SEs @@ -690,11 +690,11 @@ def test_cluster_se_differs_from_robust(self): n_clusters=20, cluster_size=10, treatment_effect=3.0, icc=0.3, seed=42 ) - did_robust = DifferenceInDifferences(robust=True) + did_robust = DifferenceInDifferences() did_cluster = DifferenceInDifferences(cluster="cluster_id") - results_robust = did_robust.fit(data, outcome="outcome", treatment="treated", time="post") - results_cluster = did_cluster.fit(data, outcome="outcome", treatment="treated", time="post") + results_robust = did_robust.fit(data, outcome="outcome", treatment="treated", post="post") + results_cluster = did_cluster.fit(data, outcome="outcome", treatment="treated", post="post") # Both SE methods should produce valid positive SEs assert results_robust.se > 0, "Robust SE should be positive" @@ -727,9 +727,9 @@ def test_se_decreases_with_sample_size(self): } ) - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() results = did.fit( - pd.DataFrame(data), outcome="outcome", treatment="treated", time="post" + pd.DataFrame(data), outcome="outcome", treatment="treated", post="post" ) ses.append(results.se) @@ -745,8 +745,8 @@ def test_vcov_positive_semidefinite(self): """Verify variance-covariance matrix is positive semi-definite.""" data, _ = generate_hand_calculable_data() - did = DifferenceInDifferences(robust=True) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences() + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert results.vcov is not None, "vcov should not be None" @@ -780,7 +780,7 @@ def test_wild_bootstrap_produces_valid_se(self, ci_params): bootstrap_weights="rademacher", seed=42, ) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert np.isfinite(results.se), "Bootstrap SE should be finite" assert results.se > 0, "Bootstrap SE should be positive" @@ -796,7 +796,7 @@ def test_wild_bootstrap_pvalue_in_valid_range(self, ci_params): did = DifferenceInDifferences( inference="wild_bootstrap", cluster="cluster_id", n_bootstrap=n_boot, seed=42 ) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert 0 <= results.p_value <= 1, f"P-value {results.p_value} not in [0, 1]" @@ -810,7 +810,7 @@ def test_wild_bootstrap_ci_contains_point_estimate(self, ci_params): did = DifferenceInDifferences( inference="wild_bootstrap", cluster="cluster_id", n_bootstrap=n_boot, seed=42 ) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") lower, upper = results.conf_int # CI should contain point estimate or be very close @@ -834,7 +834,7 @@ def test_wild_bootstrap_weight_types(self, weight_type, ci_params): bootstrap_weights=weight_type, seed=42, ) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert np.isfinite(results.se), f"SE should be finite for {weight_type}" assert results.se > 0, f"SE should be positive for {weight_type}" @@ -868,7 +868,7 @@ def test_empty_cell_produces_nan_or_warning(self): did = DifferenceInDifferences(rank_deficient_action="warn") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") # Should have rank deficiency warning due to empty cell # causing collinearity between treated and post columns @@ -899,7 +899,7 @@ def test_singleton_cluster_handling(self): did = DifferenceInDifferences(cluster="cluster") # Should run without error (warning may be emitted) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert results is not None def test_rank_deficient_warn_mode(self): @@ -922,7 +922,7 @@ def test_rank_deficient_warn_mode(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["collinear"] + data, outcome="outcome", treatment="treated", post="post", covariates=["collinear"] ) # Should have warning about rank deficiency @@ -951,7 +951,7 @@ def test_rank_deficient_error_mode(self): with pytest.raises(ValueError, match="rank-deficient"): did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["collinear"] + data, outcome="outcome", treatment="treated", post="post", covariates=["collinear"] ) def test_rank_deficient_silent_mode(self): @@ -972,7 +972,7 @@ def test_rank_deficient_silent_mode(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = did.fit( - data, outcome="outcome", treatment="treated", time="post", covariates=["collinear"] + data, outcome="outcome", treatment="treated", post="post", covariates=["collinear"] ) rank_warnings = [x for x in w if "rank" in str(x.message).lower()] @@ -997,7 +997,7 @@ def test_non_binary_treatment_raises_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError, match="binary"): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_non_binary_time_raises_error(self): """ @@ -1013,7 +1013,7 @@ def test_non_binary_time_raises_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError, match="binary"): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_no_treatment_variation_raises_error(self): """Test error when treatment has no variation.""" @@ -1027,7 +1027,7 @@ def test_no_treatment_variation_raises_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_no_time_variation_raises_error(self): """Test error when time has no variation.""" @@ -1041,7 +1041,7 @@ def test_no_time_variation_raises_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") def test_missing_values_raise_error(self): """Test error when data contains missing values.""" @@ -1055,7 +1055,7 @@ def test_missing_values_raise_error(self): did = DifferenceInDifferences() with pytest.raises(ValueError, match="missing"): - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") # ============================================================================= @@ -1131,7 +1131,7 @@ def test_formula_and_params_conflict(self): formula="outcome ~ treated * post", outcome="wrong", # Would fail if used treatment="wrong", - time="wrong", + post="wrong", ) assert np.isclose(results.att, expected_att, rtol=1e-10) @@ -1182,7 +1182,7 @@ def test_absorb_produces_valid_att(self, panel_data): """Test that absorb option produces valid ATT estimate.""" did = DifferenceInDifferences() results = did.fit( - panel_data, outcome="outcome", treatment="treated", time="post", absorb=["unit"] + panel_data, outcome="outcome", treatment="treated", post="post", absorb=["unit"] ) # ATT should be close to 3.0 @@ -1192,7 +1192,7 @@ def test_fixed_effects_produces_valid_att(self, panel_data): """Test that fixed_effects option produces valid ATT estimate.""" did = DifferenceInDifferences() results = did.fit( - panel_data, outcome="outcome", treatment="treated", time="post", fixed_effects=["unit"] + panel_data, outcome="outcome", treatment="treated", post="post", fixed_effects=["unit"] ) # ATT should be close to 3.0 @@ -1206,7 +1206,7 @@ def test_absorb_produces_valid_results(self, panel_data): """ did = DifferenceInDifferences() results = did.fit( - panel_data, outcome="outcome", treatment="treated", time="post", absorb=["unit"] + panel_data, outcome="outcome", treatment="treated", post="post", absorb=["unit"] ) # Verify ATT is estimated (not NaN) @@ -1311,9 +1311,9 @@ def test_absorb_matches_r_feols_fe(self, require_fixest, panel_data_csv): data, csv_path = panel_data_csv # Python with absorb - did = DifferenceInDifferences(robust=True) + did = DifferenceInDifferences() py_results = did.fit( - data, outcome="outcome", treatment="treated", time="post", absorb=["unit"] + data, outcome="outcome", treatment="treated", post="post", absorb=["unit"] ) # R with | unit syntax @@ -1386,9 +1386,9 @@ def test_params_affect_estimation(self): did_classical = DifferenceInDifferences() did_classical.set_params(robust=False) - results_default = did_default.fit(data, outcome="outcome", treatment="treated", time="post") + results_default = did_default.fit(data, outcome="outcome", treatment="treated", post="post") results_classical = did_classical.fit( - data, outcome="outcome", treatment="treated", time="post" + data, outcome="outcome", treatment="treated", post="post" ) # VCoV should differ (robust vs classical) @@ -1410,7 +1410,7 @@ def test_summary_contains_key_info(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") summary = results.summary() @@ -1422,7 +1422,7 @@ def test_to_dict_contains_all_fields(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") result_dict = results.to_dict() @@ -1439,7 +1439,7 @@ def test_to_dataframe_has_correct_shape(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") df = results.to_dataframe() @@ -1451,7 +1451,7 @@ def test_is_significant_property(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences(alpha=0.05) - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert isinstance(results.is_significant, bool) @@ -1460,7 +1460,7 @@ def test_significance_stars_property(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert results.significance_stars in ["", ".", "*", "**", "***"] @@ -1469,7 +1469,7 @@ def test_coefficients_dict(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert results.coefficients is not None, "coefficients should not be None" assert "const" in results.coefficients @@ -1482,7 +1482,7 @@ def test_residuals_and_fitted_values(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") assert results.residuals is not None, "residuals should not be None" assert results.fitted_values is not None, "fitted_values should not be None" @@ -1500,7 +1500,7 @@ def test_predict_contract_points_to_fitted_values(self): data, _ = generate_hand_calculable_data() did = DifferenceInDifferences() - did.fit(data, outcome="outcome", treatment="treated", time="post") + did.fit(data, outcome="outcome", treatment="treated", post="post") with pytest.raises( NotImplementedError, @@ -1556,7 +1556,7 @@ def test_did_multi_absorb_matches_fixed_effects(self, weighted): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["a", "b"], **kw, ) @@ -1564,7 +1564,7 @@ def test_did_multi_absorb_matches_fixed_effects(self, weighted): df, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["a", "b"], **kw, ) @@ -1584,7 +1584,7 @@ def test_did_collinear_regressors_dropped_to_nan(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["a", "b"], ) # 'treated' is constant within 'a' (treated = a>=3) -> absorbed -> NaN coef. @@ -1677,7 +1677,7 @@ def test_did_multi_absorb_zero_total_weight_group_is_inert(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["a", "b"], survey_design=sd, ) @@ -1685,7 +1685,7 @@ def test_did_multi_absorb_zero_total_weight_group_is_inert(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["a", "b"], survey_design=sd, ) diff --git a/tests/test_methodology_placebo.py b/tests/test_methodology_placebo.py index c914a223..7a3afac1 100644 --- a/tests/test_methodology_placebo.py +++ b/tests/test_methodology_placebo.py @@ -118,7 +118,7 @@ def test_sampled_pvalue_matches_phipson_smyth_formula(self): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=200, seed=123, @@ -154,7 +154,7 @@ def test_with_replacement_sampling_tolerates_duplicate_assignments(self): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=50, seed=7, @@ -170,7 +170,7 @@ def test_pvalue_bounded_and_floored(self): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=200, seed=1, @@ -197,7 +197,7 @@ def test_sampled_converges_to_exact(self, ci_params): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=b, seed=20240101, @@ -240,7 +240,7 @@ def test_exact_enumeration_matches_r(self, golden, panel): assert p_exact == pytest.approx(golden["permutation"]["p_exact"], abs=1e-12) def test_leave_one_out_matches_r(self, golden, panel): - res = leave_one_out_test(panel, outcome="y", treatment="treatment", time="t", unit="unit") + res = leave_one_out_test(panel, outcome="y", treatment="treatment", post="t", unit="unit") gl = golden["leave_one_out"] assert res.placebo_effect == pytest.approx(gl["mean"], abs=1e-10) assert res.se == pytest.approx(gl["se"], abs=1e-10) @@ -277,7 +277,7 @@ def test_sampled_permutation_matches_r_exact(self, golden, panel, ci_params): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=b, seed=99, @@ -494,7 +494,7 @@ def test_permutation_nan_decoupling(self): panel, outcome="y", treatment="treatment", - time="t", + post="t", unit="unit", n_permutations=1, seed=5, @@ -512,4 +512,4 @@ def test_leave_one_out_all_fail_raises(self): ] panel = pd.DataFrame(rows) with pytest.raises(RuntimeError, match="leave-one-out"): - leave_one_out_test(panel, outcome="y", treatment="treatment", time="t", unit="unit") + leave_one_out_test(panel, outcome="y", treatment="treatment", post="t", unit="unit") diff --git a/tests/test_methodology_stacked_did.py b/tests/test_methodology_stacked_did.py index dde79599..c9502462 100644 --- a/tests/test_methodology_stacked_did.py +++ b/tests/test_methodology_stacked_did.py @@ -767,7 +767,7 @@ def _fit(self, df, balance="entropy"): kappa_pre=_CB_KP, kappa_post=_CB_KPOST, weighting="aggregate", - clean_control="never_treated", + control_group="never_treated", cluster="unit", balance=balance, ) @@ -926,7 +926,7 @@ def _fit(self, df): kappa_pre=self.KP, kappa_post=self.KPOST, weighting="aggregate", - clean_control="not_yet_treated", + control_group="not_yet_treated", cluster="unit", balance="entropy", ) @@ -999,7 +999,7 @@ def test_dynamic_atts_match_r_cbwsdid(self): kappa_pre=2, kappa_post=2, weighting="aggregate", - clean_control="not_yet_treated", + control_group="not_yet_treated", cluster="unit", balance="entropy", ).fit( diff --git a/tests/test_methodology_triple_diff.py b/tests/test_methodology_triple_diff.py index 98aa218d..b6d6a838 100644 --- a/tests/test_methodology_triple_diff.py +++ b/tests/test_methodology_triple_diff.py @@ -214,7 +214,7 @@ def test_att_hand_calculation_no_covariates(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( results.att, @@ -255,7 +255,7 @@ def test_att_reg_matches_ols_interaction(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( @@ -277,7 +277,7 @@ def test_all_methods_agree_no_covariates(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) atts[method] = results.att @@ -306,7 +306,7 @@ def test_all_methods_se_agree_no_covariates(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) ses[method] = results.se @@ -333,7 +333,7 @@ def test_se_uses_influence_function(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # SE should be positive and finite @@ -358,7 +358,7 @@ def test_safe_inference_used(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Recompute using safe_inference @@ -388,7 +388,7 @@ def test_cell_means_match_direct_computation(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) expected_means = { @@ -447,7 +447,7 @@ def test_att_no_covariates_matches_r_dgp1(self, r_results, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Use atol for near-zero ATTs @@ -479,7 +479,7 @@ def test_se_no_covariates_matches_r_dgp1(self, r_results, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( @@ -503,7 +503,7 @@ def test_att_with_covariates_matches_r_dgp1(self, r_results, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=covariates, ) @@ -536,7 +536,7 @@ def test_se_with_covariates_matches_r_dgp1(self, r_results, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=covariates, ) @@ -564,7 +564,7 @@ def test_dr_robust_across_dgp_types(self, r_results, dgp): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=cov_list, ) @@ -648,7 +648,7 @@ def test_live_att_no_cov(self, require_triplediff, shared_data_csv, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) if abs(r_att) < 0.1: @@ -680,7 +680,7 @@ def test_live_se_no_cov(self, require_triplediff, shared_data_csv, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( @@ -710,7 +710,7 @@ def test_small_sample_sizes(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert np.isfinite(results.att), f"ATT should be finite ({method})" @@ -733,7 +733,7 @@ def test_zero_treatment_effect(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # ATT should be near zero (within ~2 SE) @@ -783,7 +783,7 @@ def test_pscore_trimming_active(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert np.isfinite(results.att) assert np.isfinite(results.se) and results.se > 0 @@ -824,7 +824,7 @@ def test_nan_inference_when_se_zero(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # With zero within-cell variance, SE should be zero @@ -854,7 +854,7 @@ def test_large_treatment_effect(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( @@ -880,7 +880,7 @@ def test_covariates_reduce_se(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # With covariates @@ -890,7 +890,7 @@ def test_covariates_reduce_se(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "education"], ) @@ -924,7 +924,7 @@ def test_att_converges_to_true_effect(self, n_per_cell): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # With n_per_cell >= 200, should be within ~2 SE of true effect @@ -944,7 +944,7 @@ def test_se_decreases_with_sample_size(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) ses[n_per_cell] = results.se @@ -984,7 +984,7 @@ def test_att_nocov_all_dgps(self, r_results, dgp, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) if abs(r_att) < 0.1: @@ -1016,7 +1016,7 @@ def test_se_nocov_all_dgps(self, r_results, dgp, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) np.testing.assert_allclose( @@ -1041,7 +1041,7 @@ def test_att_cov_all_dgps(self, r_results, dgp, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=covariates, ) @@ -1075,7 +1075,7 @@ def test_se_cov_all_dgps(self, r_results, dgp, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=covariates, ) @@ -1129,7 +1129,7 @@ def test_to_dict_contains_required_fields(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) d = results.to_dict() @@ -1153,7 +1153,7 @@ def test_summary_contains_key_info(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) summary = results.summary() @@ -1169,7 +1169,7 @@ def test_n_obs_correct(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.n_obs == len(data) assert results.n_obs == 400 # 8 cells × 50 @@ -1183,7 +1183,7 @@ def test_cell_counts_correct(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Each cell has 50 obs × 2 time periods = 100 assert results.n_treated_eligible == 100 @@ -1214,7 +1214,7 @@ def test_rank_deficient_action_warn(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "age_dup"], ) rank_warnings = [ @@ -1243,7 +1243,7 @@ def test_rank_deficient_action_silent(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "age_dup"], ) rank_warnings = [ @@ -1268,7 +1268,7 @@ def test_cluster_se_functional(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) ddd_cluster = TripleDifference(estimation_method="dr", cluster="cluster_id") @@ -1277,7 +1277,7 @@ def test_cluster_se_functional(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # ATT should be identical (clustering affects SE only) @@ -1299,7 +1299,7 @@ def test_low_cell_count_warning(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) low_count_warnings = [x for x in w if "low observation" in str(x.message).lower()] assert ( @@ -1311,19 +1311,19 @@ def test_robust_param_is_noop(self): """robust param has no effect on IF-based SEs.""" data = generate_ddd_data(n_per_cell=50, seed=42) - result_robust = TripleDifference(robust=True).fit( + result_robust = TripleDifference().fit( data, outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) result_not_robust = TripleDifference(robust=False).fit( data, outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert result_robust.att == result_not_robust.att @@ -1336,7 +1336,7 @@ def test_cluster_single_cluster_raises(self): ddd = TripleDifference(estimation_method="dr", cluster="cluster_id") with pytest.raises(ValueError, match="at least 2 clusters"): - ddd.fit(data, outcome="outcome", group="group", partition="partition", time="time") + ddd.fit(data, outcome="outcome", group="group", partition="partition", post="time") def test_cluster_nan_ids_raises(self): """NaN cluster IDs raise ValueError.""" @@ -1346,7 +1346,7 @@ def test_cluster_nan_ids_raises(self): ddd = TripleDifference(estimation_method="dr", cluster="cluster_id") with pytest.raises(ValueError, match="missing values"): - ddd.fit(data, outcome="outcome", group="group", partition="partition", time="time") + ddd.fit(data, outcome="outcome", group="group", partition="partition", post="time") def test_overlap_warning_on_imbalanced_data(self): """Poor overlap triggers warning for IPW/DR.""" @@ -1382,7 +1382,7 @@ def test_overlap_warning_on_imbalanced_data(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["cov1"], ) overlap_warnings = [ @@ -1426,7 +1426,7 @@ def test_no_overlap_warning_for_reg(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["cov1"], ) overlap_warnings = [x for x in w if "overlap" in str(x.message).lower()] @@ -1452,7 +1452,7 @@ def _failing_lr(*args, **kwargs): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age"], ) ps_warnings = [ @@ -1490,7 +1490,7 @@ def _did_rc_with_nan(self_inner, *args, **kwargs): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age"], ) nonfinite_warnings = [x for x in w if "non-finite" in str(x.message).lower()] @@ -1522,7 +1522,7 @@ def test_r_squared_respects_rank_deficient_action(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "age_dup"], ) rank_silent = [ @@ -1543,7 +1543,7 @@ def test_r_squared_respects_rank_deficient_action(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "age_dup"], ) rank_warn = [ @@ -1575,7 +1575,7 @@ def test_rank_deficient_action_error_raises_in_ps_path(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "age_dup"], ) @@ -1603,7 +1603,7 @@ def test_constant_covariate_finite_se_matches_drop_one(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age"], ) with_const = TripleDifference(estimation_method=method).fit( @@ -1611,7 +1611,7 @@ def test_constant_covariate_finite_se_matches_drop_one(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "xc"], ) @@ -1630,7 +1630,7 @@ def test_constant_covariate_emits_single_rank_guard_warning(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "xc"], ) rank_guard = [w for w in caught if "rank-guarded inverse" in str(w.message)] @@ -1646,7 +1646,7 @@ def test_well_conditioned_covariates_no_rank_guard_warning(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "education"], ) assert not any("rank-guarded inverse" in str(w.message) for w in caught) @@ -1670,7 +1670,7 @@ def test_survey_weighted_constant_covariate_finite_se(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age"], survey_design=sd, ) @@ -1679,7 +1679,7 @@ def test_survey_weighted_constant_covariate_finite_se(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "xc"], survey_design=sd, ) @@ -1704,7 +1704,7 @@ def test_error_mode_raises_before_rank_guard(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "agec"], ) @@ -1736,7 +1736,7 @@ def test_cell_aliasing_rank_guard(self, method, cell): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age"], ) with_deg = TripleDifference(estimation_method=method).fit( @@ -1744,7 +1744,7 @@ def test_cell_aliasing_rank_guard(self, method, cell): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "aged"], ) assert np.isfinite(with_deg.se) and with_deg.se > 0 @@ -1765,7 +1765,7 @@ def test_cell_aliasing_rank_guard(self, method, cell): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "aged_exact"], ) r_full = TripleDifference(estimation_method=method).fit( @@ -1773,7 +1773,7 @@ def test_cell_aliasing_rank_guard(self, method, cell): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["age", "aged_near"], ) assert np.isfinite(r_guard.se) and r_guard.se > 0 # not 1e17 garbage diff --git a/tests/test_methodology_trop.py b/tests/test_methodology_trop.py index e18099ad..5516f2a4 100644 --- a/tests/test_methodology_trop.py +++ b/tests/test_methodology_trop.py @@ -323,7 +323,7 @@ def _fit_did(df: pd.DataFrame) -> float: with warnings.catch_warnings(): warnings.simplefilter("ignore") est = DifferenceInDifferences() - r = est.fit(df2, formula="outcome ~ treat * post_flag", unit="unit", time="period") + r = est.fit(df2, formula="outcome ~ treat * post_flag", unit="unit", post="period") coefs = r.coefficients or {} return float(coefs.get("treat:post_flag", np.nan)) diff --git a/tests/test_methodology_twfe.py b/tests/test_methodology_twfe.py index be5fdd20..5bef2e06 100644 --- a/tests/test_methodology_twfe.py +++ b/tests/test_methodology_twfe.py @@ -193,7 +193,7 @@ def test_twfe_att_matches_hand_calculated_demeaned_ols(self): data = generate_hand_calculable_panel() # Run TWFE - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") # Manual demeaned OLS: demean both y and the interaction term @@ -215,14 +215,14 @@ def test_twfe_att_matches_basic_did_for_two_period_design(self): data = generate_hand_calculable_panel() # TWFE - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() twfe_results = twfe.fit( data, outcome="outcome", treatment="treated", time="post", unit="unit" ) # Basic DiD - did = DifferenceInDifferences(robust=True, cluster="unit") - did_results = did.fit(data, outcome="outcome", treatment="treated", time="post") + did = DifferenceInDifferences(cluster="unit") + did_results = did.fit(data, outcome="outcome", treatment="treated", post="post") np.testing.assert_allclose(twfe_results.att, did_results.att, rtol=1e-10) @@ -439,7 +439,7 @@ class TestRBenchmarkTWFE: def _run_python_twfe(self, data, covariates=None): """Run Python TWFE estimator.""" - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit( data, outcome="outcome", @@ -675,7 +675,7 @@ def test_staggered_treatment_warning_multiperiod_time(self): ) df = pd.DataFrame(data) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") # Use time="period" so staggered detection sees different first-treat times @@ -725,7 +725,7 @@ def test_staggered_warning_not_fired_with_binary_time(self): ) df = pd.DataFrame(data) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") # With binary time="post", staggering is undetectable @@ -740,7 +740,7 @@ def test_multiperiod_time_warning(self): """Multi-period time column triggers UserWarning advising binary post indicator.""" data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") twfe.fit(data, outcome="outcome", treatment="treated", time="period", unit="unit") @@ -757,7 +757,7 @@ def test_binary_time_no_multiperiod_warning(self): """Binary time column does NOT trigger multi-period time warning.""" data = generate_hand_calculable_panel() - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") @@ -772,7 +772,7 @@ def test_non_binary_time_values_warning(self): data = generate_hand_calculable_panel() data["year"] = data["post"].map({0: 2020, 1: 2021}) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = twfe.fit( @@ -789,7 +789,7 @@ def test_boolean_time_no_warning(self): data = generate_hand_calculable_panel() data["post_bool"] = data["post"].astype(bool) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") twfe.fit( @@ -810,7 +810,7 @@ def test_att_invariant_to_time_encoding(self): data = generate_hand_calculable_panel() # Fit with binary {0,1} - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results_binary = twfe.fit( data, outcome="outcome", treatment="treated", time="post", unit="unit" ) @@ -847,13 +847,13 @@ def test_auto_clusters_at_unit_level(self): data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) # Default (auto-clusters at unit) - twfe_default = TwoWayFixedEffects(robust=True) + twfe_default = TwoWayFixedEffects() results_default = twfe_default.fit( data, outcome="outcome", treatment="treated", time="post", unit="unit" ) # Explicit cluster at unit - twfe_explicit = TwoWayFixedEffects(robust=True, cluster="unit") + twfe_explicit = TwoWayFixedEffects(cluster="unit") results_explicit = twfe_explicit.fit( data, outcome="outcome", treatment="treated", time="post", unit="unit" ) @@ -877,7 +877,7 @@ def test_df_adjustment_for_absorbed_fe(self): data = generate_twfe_panel(n_units=20, n_periods=2, noise_sd=0.5, seed=42) # Run TWFE - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") # Manual: demean both y and the interaction, then run LinearRegression @@ -899,7 +899,6 @@ def test_df_adjustment_for_absorbed_fe(self): # its conditional rank n_times - 1. reg = LinearRegression( include_intercept=False, - robust=True, cluster_ids=cluster_ids, rank_deficient_action="silent", ).fit(X, y, df_adjustment=df_adjustment, cluster_k_adjustment=n_times - 1) @@ -931,7 +930,7 @@ def test_covariate_collinear_with_interaction_raises_error(self): # bad_cov = treated * post duplicates the internal _treatment_post column data["bad_cov"] = data["treated"] * data["post"] - twfe = TwoWayFixedEffects(robust=True, rank_deficient_action="error") + twfe = TwoWayFixedEffects(rank_deficient_action="error") with pytest.raises(ValueError): twfe.fit( data, @@ -948,7 +947,7 @@ def test_covariate_collinearity_warns_not_errors(self): # Add a covariate that's collinear with treatment*post data["bad_cov"] = data["treated"] * data["post"] - twfe = TwoWayFixedEffects(robust=True, rank_deficient_action="warn") + twfe = TwoWayFixedEffects(rank_deficient_action="warn") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = twfe.fit( @@ -971,7 +970,7 @@ def test_rank_deficient_action_error_raises(self): data = generate_twfe_panel(n_units=20, n_periods=2, seed=42) data["bad_cov"] = data["treated"] * data["post"] - twfe = TwoWayFixedEffects(robust=True, rank_deficient_action="error") + twfe = TwoWayFixedEffects(rank_deficient_action="error") with pytest.raises(ValueError): twfe.fit( data, @@ -987,7 +986,7 @@ def test_rank_deficient_action_silent_no_warning(self): data = generate_twfe_panel(n_units=20, n_periods=2, seed=42) data["bad_cov"] = data["treated"] * data["post"] - twfe = TwoWayFixedEffects(robust=True, rank_deficient_action="silent") + twfe = TwoWayFixedEffects(rank_deficient_action="silent") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") results = twfe.fit( @@ -1011,7 +1010,7 @@ def test_unbalanced_panel_produces_valid_results(self): drop_indices = [3, 7, 15, 22, 45, 60] data = data.drop(index=drop_indices).reset_index(drop=True) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") assert np.isfinite(results.att), "ATT should be finite for unbalanced panel" @@ -1022,7 +1021,7 @@ def test_unit_column_missing_raises_error(self): """Missing unit column raises ValueError.""" data = generate_hand_calculable_panel() - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() with pytest.raises(ValueError, match="not found"): twfe.fit( data, @@ -1060,7 +1059,7 @@ def test_decompose_integration(self): df = pd.DataFrame(data) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() decomp = twfe.decompose( df, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) @@ -1088,7 +1087,7 @@ def test_cluster_se_differs_from_hc1_se(self): data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) # TWFE: cluster-robust at unit (automatic) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() twfe_results = twfe.fit( data, outcome="outcome", treatment="treated", time="post", unit="unit" ) @@ -1107,7 +1106,6 @@ def test_cluster_se_differs_from_hc1_se(self): hc1_reg = LinearRegression( include_intercept=False, - robust=True, cluster_ids=None, # HC1, no clustering rank_deficient_action="silent", ).fit(X, y, df_adjustment=df_adjustment) @@ -1125,7 +1123,6 @@ def test_cluster_se_differs_from_hc1_se(self): # time FE contributes its conditional rank n_times - 1. cluster_reg = LinearRegression( include_intercept=False, - robust=True, cluster_ids=data["unit"].values, rank_deficient_action="silent", ).fit(X, y, df_adjustment=df_adjustment, cluster_k_adjustment=n_times - 1) @@ -1142,7 +1139,7 @@ def test_vcov_positive_semidefinite(self): """VCoV matrix should be positive semi-definite.""" data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") eigenvalues = np.linalg.eigvalsh(results.vcov) @@ -1164,9 +1161,7 @@ def test_wild_bootstrap_produces_valid_inference(self, ci_params): data = generate_twfe_panel(n_units=20, n_periods=2, seed=42) n_boot = ci_params.bootstrap(999, min_n=199) - twfe = TwoWayFixedEffects( - robust=True, inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 - ) + twfe = TwoWayFixedEffects(inference="wild_bootstrap", n_bootstrap=n_boot, seed=42) results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") assert np.isfinite(results.se) and results.se > 0 @@ -1180,7 +1175,6 @@ def test_wild_bootstrap_weight_types(self, ci_params, weight_type): n_boot = ci_params.bootstrap(199, min_n=99) twfe = TwoWayFixedEffects( - robust=True, inference="wild_bootstrap", n_bootstrap=n_boot, bootstrap_weights=weight_type, @@ -1195,7 +1189,7 @@ def test_inference_parameter_routing(self): """inference='wild_bootstrap' routes to wild bootstrap method.""" data = generate_twfe_panel(n_units=20, n_periods=2, seed=42) - twfe = TwoWayFixedEffects(robust=True, inference="wild_bootstrap", n_bootstrap=99, seed=42) + twfe = TwoWayFixedEffects(inference="wild_bootstrap", n_bootstrap=99, seed=42) results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") assert results.inference_method == "wild_bootstrap" @@ -1211,7 +1205,7 @@ class TestTWFEParamsAndResults: def test_get_params_returns_all_parameters(self): """All inherited constructor params present in get_params().""" - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() params = twfe.get_params() expected_keys = { @@ -1230,7 +1224,7 @@ def test_get_params_returns_all_parameters(self): def test_set_params_modifies_attributes(self): """set_params() modifies estimator attributes.""" - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() twfe.set_params(alpha=0.10, robust=False) assert twfe.alpha == 0.10 @@ -1239,7 +1233,7 @@ def test_set_params_modifies_attributes(self): def test_summary_contains_key_info(self): """summary() output contains ATT.""" data = generate_hand_calculable_panel() - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") summary = results.summary() @@ -1248,7 +1242,7 @@ def test_summary_contains_key_info(self): def test_to_dict_contains_all_fields(self): """to_dict() contains required fields.""" data = generate_hand_calculable_panel() - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") d = results.to_dict() @@ -1263,7 +1257,7 @@ def test_residuals_plus_fitted_equals_demeaned_outcome(self): """ data = generate_twfe_panel(n_units=20, n_periods=4, seed=42) - twfe = TwoWayFixedEffects(robust=True) + twfe = TwoWayFixedEffects() results = twfe.fit(data, outcome="outcome", treatment="treated", time="post", unit="unit") # Within-transform by unit + post (same as TWFE internally does) diff --git a/tests/test_methodology_wooldridge.py b/tests/test_methodology_wooldridge.py index 80e52912..0a57375c 100644 --- a/tests/test_methodology_wooldridge.py +++ b/tests/test_methodology_wooldridge.py @@ -549,7 +549,7 @@ def test_event_aggregation_matches_per_cell_average_at_same_event_time(self) -> res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None # Manually compute event-time aggregates from per-cell ATTs and weights gt = res.group_time_effects @@ -657,7 +657,7 @@ def test_event_aggregation_indexed_by_k_eq_t_minus_g(self) -> None: res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None # All event-time keys should be derivable from (g, t) cells in # group_time_effects as t - g. @@ -691,7 +691,7 @@ def test_event_aggregate_recovers_homogeneous_event_time_atts(self) -> None: res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None expected_by_k = {0: 0.5, 1: 1.0, 2: 1.5} for k, expected in expected_by_k.items(): @@ -710,7 +710,7 @@ def test_event_aggregate_se_is_finite_under_balanced_panel(self) -> None: res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None for k, eff in res.event_study_effects.items(): assert ( @@ -729,7 +729,7 @@ def test_event_aggregate_inference_consistent_under_safe_inference(self) -> None res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None for k, eff in res.event_study_effects.items(): if not np.isfinite(eff["se"]): @@ -858,7 +858,7 @@ def test_aggregate_event_weights_cohort_share_matches_paper_eq76(self) -> None: res = WooldridgeDiD(method="ols").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.event_study_effects is not None n_g = res._n_g_per_cohort gt = res.group_time_effects @@ -960,14 +960,14 @@ def test_aggregate_event_weights_cohort_share_restricts_to_k_geq_0(self) -> None # cohort_share path filters out k < 0 with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.event_study_effects is not None event_keys = sorted(res.event_study_effects.keys()) assert all(k >= 0 for k in event_keys), ( f"cohort_share event aggregation should restrict to k>=0; " f"got {event_keys}" ) # cell path still exposes all k including negative - res.aggregate("event", weights="cell") + res.aggregate("event_study", weights="cell") assert res.event_study_effects is not None event_keys_cell = sorted(res.event_study_effects.keys()) assert any(k < 0 for k in event_keys_cell), ( @@ -1002,7 +1002,7 @@ def test_aggregate_weights_cohort_share_rejects_survey_design(self) -> None: with pytest.raises(ValueError, match=r"cohort_share.*not yet supported on survey-weighted"): res.aggregate("simple", weights="cohort_share") with pytest.raises(ValueError, match=r"cohort_share.*not yet supported on survey-weighted"): - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") def test_aggregate_weights_invalid_value_raises(self) -> None: """``weights="invalid"`` raises ValueError at the aggregate() boundary.""" @@ -1065,7 +1065,7 @@ def test_aggregate_weights_cohort_share_poisson_path(self) -> None: assert np.isnan(res.overall_conf_int[1]) # event aggregation: k >= 0 only with pytest.warns(UserWarning, match=r"cohort_share.*conditional-on-shares"): - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.event_study_effects is not None event_keys = sorted(res.event_study_effects.keys()) assert all( @@ -1133,7 +1133,7 @@ def test_aggregate_weights_cohort_share_logit_path(self) -> None: assert np.isnan(res.overall_conf_int[1]) # event aggregation: k >= 0 only with pytest.warns(UserWarning, match=r"cohort_share.*conditional-on-shares"): - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.event_study_effects is not None event_keys = sorted(res.event_study_effects.keys()) assert all( @@ -1457,7 +1457,7 @@ def test_cohort_trends_true_aggregate_event_finite_inference(self) -> None: res = WooldridgeDiD(method="ols", cohort_trends=True).fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None for k, eff in res.event_study_effects.items(): if np.isfinite(eff["att"]): @@ -1642,7 +1642,7 @@ def test_results_metadata_records_cohort_trends_and_per_surface_weights( warnings.filterwarnings("ignore", category=UserWarning) res_trends.aggregate("simple", weights="cohort_share") assert res_trends.aggregation_weights["simple"] == "cohort_share" - summary_text = res_trends.summary("simple") + summary_text = res_trends.summary() assert "Cohort trends: True" in summary_text assert "Aggregation w: cohort_share" in summary_text @@ -1663,13 +1663,13 @@ def test_aggregation_weights_per_surface_mixed_order(self) -> None: ) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.aggregation_weights["event"] == "cohort_share" assert res.aggregation_weights["simple"] == "cell", ( "simple weight must remain 'cell' after event aggregation — " "overall_* is still fit-time cell-weighted" ) - assert "Aggregation w: cell" in res.summary("simple") + assert "Aggregation w: cell" in res.summary() assert "Aggregation w: cohort_share" in res.summary("event") def test_aggregation_weights_failed_cohort_share_leaves_metadata_unchanged( @@ -1922,7 +1922,7 @@ def test_cohort_trends_true_plus_weights_cohort_share_event_excludes_trend_colum res = WooldridgeDiD(method="ols", cohort_trends=True).fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event", weights="cohort_share") + res.aggregate("event_study", weights="cohort_share") assert res.event_study_effects is not None finite = [k for k, eff in res.event_study_effects.items() if np.isfinite(eff["att"])] assert len(finite) >= 1, "no finite event-time ATTs under cohort_share + cohort_trends" @@ -2671,7 +2671,7 @@ def test_aggregate_event_bm_dof_matches_wald_test_htz( res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( panel, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") r_dofs = golden["hc2_bm"]["aggregate_event_dof"] assert res.event_study_effects is not None for k, eff in res.event_study_effects.items(): diff --git a/tests/test_mmm.py b/tests/test_mmm.py index dc3e73fa..aaa8bb7d 100644 --- a/tests/test_mmm.py +++ b/tests/test_mmm.py @@ -425,7 +425,7 @@ def test_did_workflow(self): n_units=60, n_periods=2, treatment_effect=5.0, treatment_period=1, seed=7 ) result = DifferenceInDifferences().fit( - data, outcome="outcome", treatment="treated", time="post" + data, outcome="outcome", treatment="treated", post="post" ) df = to_pymc_marketing_lift_test( channel="tv", diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index e2451370..16278dc1 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -486,14 +486,10 @@ def _build_rowed_index(): "equivalence_test_trends[time]", "joint_homogeneity_test[time]", "joint_pretrends_test[time]", - "leave_one_out_test[time]", - "permutation_test[time]", "placebo_group_test[time]", "placebo_timing_test[time]", "plot_staircase[time]", "profile_panel[time]", - "run_all_placebo_tests[time]", - "run_placebo_test[time]", "summarize_did_data[time]", "twowayfeweights[time]", "validate_did_data[time]", @@ -506,6 +502,21 @@ def _build_rowed_index(): "TripleDifference.fit[group]": ( "rule-3 reserved treated-group 0/1 indicator (v4-design section 8 rule 3)" ), + "run_placebo_test[time]": ( + "OVERLOADED pass-through, redesign pending (TODO.md): forwarded as " + "the calendar column to placebo_timing_test/placebo_group_test AND " + "as the 0/1 post dummy to permutation_test/leave_one_out_test " + "(M-137/M-138) - workable only because a two-period 0/1 calendar " + "column is both; a rename cannot fix the dual semantics" + ), + "run_all_placebo_tests[time]": ( + "OVERLOADED pass-through, redesign pending (TODO.md) - see " "run_placebo_test[time]" + ), + "StackedDiD.clean_control": ( + "M-043's deprecated estimator-attribute alias PROPERTY (warns and " + "returns control_group; dies with the param at 4.0) - the param row " + "covers the __init__ surface, this entry covers the property lane" + ), **{key: _RULE1_TIME for key in _RULE1_TIME_SURFACES}, "rank_control_units[lambda_reg]": ( "prep helper's own independent regularization param (prep.py) - a " diff --git a/tests/test_power.py b/tests/test_power.py index f68c148c..e5a20967 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -1184,7 +1184,7 @@ def custom_dgp(**kwargs): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ), n_simulations=2, seed=42, @@ -1810,7 +1810,7 @@ def test_staggered_dgp_warns_strict_clean_control(self): """Auto DGP warns when StackedDiD has clean_control='strict'.""" with pytest.warns(UserWarning, match="strict"): simulate_power( - StackedDiD(clean_control="strict"), + StackedDiD(control_group="strict"), n_simulations=3, seed=42, progress=False, @@ -1889,7 +1889,7 @@ def test_cs_not_yet_treated_with_matching_dgp(self): def test_stacked_did_strict_with_matching_dgp(self): """StackedDiD with clean_control='strict' and multi-cohort DGP.""" result = simulate_power( - StackedDiD(clean_control="strict", kappa_pre=1, kappa_post=1), + StackedDiD(control_group="strict", kappa_pre=1, kappa_post=1), n_units=80, n_periods=8, treatment_period=4, @@ -2363,7 +2363,7 @@ def test_stacked_strict_single_cohort_warns(self): """StackedDiD clean_control='strict' with cohort_periods=[2] warns.""" with pytest.warns(UserWarning, match="DGP mismatch"): simulate_power( - StackedDiD(clean_control="strict"), + StackedDiD(control_group="strict"), n_units=60, n_periods=6, treatment_period=3, @@ -2957,10 +2957,10 @@ def test_survey_rejects_last_cohort(self): ) def test_survey_rejects_clean_control_strict(self): - """clean_control='strict' rejected (needs multi-cohort DGP).""" - with pytest.raises(ValueError, match="clean_control.*strict"): + """control_group='strict' rejected (needs multi-cohort DGP).""" + with pytest.raises(ValueError, match="control_group='strict'"): simulate_power( - StackedDiD(clean_control="strict"), + StackedDiD(control_group="strict"), survey_config=_SURVEY_CFG, n_simulations=1, seed=42, diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index daa80634..2545b7e3 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -45,7 +45,7 @@ def staggered_data(): @pytest.fixture(scope="session") def did_results(did_data): did = DifferenceInDifferences() - return did.fit(did_data, outcome="outcome", treatment="treated", time="post") + return did.fit(did_data, outcome="outcome", treatment="treated", post="post") @pytest.fixture(scope="session") @@ -328,7 +328,7 @@ def test_stacked_results(self, mock_stacked_results): assert len(output["next_steps"]) > 0 # StackedDiD uses clean_control, not control_group all_text = " ".join(s.get("code", "") + s.get("why", "") for s in output["next_steps"]) - assert "not_yet_treated" not in all_text or "clean_control" in all_text + assert "not_yet_treated" not in all_text or "control_group" in all_text def test_synth_results(self, mock_synth_results): output = practitioner_next_steps(mock_synth_results, verbose=False) diff --git a/tests/test_prep.py b/tests/test_prep.py index fb452f71..9a47e9a0 100644 --- a/tests/test_prep.py +++ b/tests/test_prep.py @@ -352,7 +352,7 @@ def test_treatment_effect_recovery(self): ) did = DifferenceInDifferences() - results = did.fit(data, outcome="outcome", treatment="treated", time="post") + results = did.fit(data, outcome="outcome", treatment="treated", post="post") # Effect should be within 1 unit of true effect assert abs(results.att - true_effect) < 1.0 @@ -1460,7 +1460,7 @@ def test_smallest_feasible_config_populates_all_cells(self): outcome="outcome", group="group", partition="partition", - time="post", + post="post", ) def test_recommended_clustered_panel_path(self): @@ -1487,7 +1487,7 @@ def test_recommended_clustered_panel_path(self): outcome="outcome", group="group", partition="partition", - time="post", + post="post", ) assert np.isfinite(result.att) assert np.isfinite(result.se) @@ -1500,7 +1500,7 @@ def test_recommended_clustered_panel_path(self): outcome="outcome", group="group", partition="partition", - time="post", + post="post", ) # Point estimate is invariant to clustering. np.testing.assert_allclose(unclustered.att, result.att, atol=1e-10) @@ -1534,7 +1534,7 @@ def test_ddd_effect_recovery_deterministic(self): outcome="outcome", group="group", partition="partition", - time="post", + post="post", ) assert abs(result.att - true_effect) < 1e-6 @@ -1556,7 +1556,7 @@ def test_ddd_effect_recovery(self): outcome="outcome", group="group", partition="partition", - time="post", + post="post", ) assert abs(result.att - true_effect) < 0.7 @@ -2078,12 +2078,12 @@ def test_psu_period_factor_deff_regression(self): c3["post"] = (c3["period"] == 3).astype(int) c3["treat"] = (c3["first_treat"] == 3).astype(int) did = DifferenceInDifferences() - r_naive = did.fit(c3, outcome="outcome", treatment="treat", time="post") + r_naive = did.fit(c3, outcome="outcome", treatment="treat", post="post") r_survey = did.fit( c3, outcome="outcome", treatment="treat", - time="post", + post="post", survey_design=sd, ) assert ( @@ -3259,7 +3259,7 @@ def test_pipeline_with_did(self): panel, outcome="outcome_mean", treatment="treated_bin", - time="period", + post="period", survey_design=stage2, ) assert result.att is not None @@ -3437,7 +3437,7 @@ def test_stage2_handoff_with_nonfinite_cells(self): panel, outcome="outcome_mean", treatment="treated_bin", - time="period", + post="period", survey_design=stage2, ) assert np.isfinite(result.att) diff --git a/tests/test_rust_backend.py b/tests/test_rust_backend.py index 2587e670..9811e05b 100644 --- a/tests/test_rust_backend.py +++ b/tests/test_rust_backend.py @@ -3184,7 +3184,7 @@ def fits(): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], covariates=["xspan"], ) @@ -3351,7 +3351,7 @@ def fit(): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "period"], covariates=["x1", "x2"], ) @@ -3989,7 +3989,7 @@ def fit(): df, outcome="y", treatment="treated", - time="post", + post="post", absorb=["unit", "time"], ) diff --git a/tests/test_stacked_did.py b/tests/test_stacked_did.py index c5f04430..ff23cd86 100644 --- a/tests/test_stacked_did.py +++ b/tests/test_stacked_did.py @@ -212,7 +212,7 @@ def test_ic1_window_trimming(self, staggered_data): def test_ic2_no_controls_trimming(self, no_never_treated_data): """Events without clean controls are trimmed with never_treated mode.""" - est = StackedDiD(kappa_pre=1, kappa_post=1, clean_control="never_treated") + est = StackedDiD(kappa_pre=1, kappa_post=1, control_group="never_treated") # No never-treated units exist → all events should be trimmed with pytest.raises(ValueError, match="All.*adoption events were trimmed"): est.fit( @@ -368,7 +368,7 @@ class TestCleanControl: def test_not_yet_treated_default(self, staggered_data): """Default includes not-yet-treated and never-treated as controls.""" - est = StackedDiD(kappa_pre=1, kappa_post=1, clean_control="not_yet_treated") + est = StackedDiD(kappa_pre=1, kappa_post=1, control_group="not_yet_treated") results = est.fit( staggered_data, outcome="outcome", @@ -380,7 +380,7 @@ def test_not_yet_treated_default(self, staggered_data): def test_strict_excludes_more(self, staggered_data): """Strict mode should have fewer (or equal) controls than not_yet_treated.""" - est_nyt = StackedDiD(kappa_pre=2, kappa_post=2, clean_control="not_yet_treated") + est_nyt = StackedDiD(kappa_pre=2, kappa_post=2, control_group="not_yet_treated") results_nyt = est_nyt.fit( staggered_data, outcome="outcome", @@ -389,7 +389,7 @@ def test_strict_excludes_more(self, staggered_data): first_treat="first_treat", ) - est_strict = StackedDiD(kappa_pre=2, kappa_post=2, clean_control="strict") + est_strict = StackedDiD(kappa_pre=2, kappa_post=2, control_group="strict") with warnings.catch_warnings(record=True): warnings.simplefilter("always") try: @@ -408,7 +408,7 @@ def test_strict_excludes_more(self, staggered_data): def test_never_treated_only(self, staggered_data): """never_treated mode only uses never-treated as controls.""" - est = StackedDiD(kappa_pre=2, kappa_post=2, clean_control="never_treated") + est = StackedDiD(kappa_pre=2, kappa_post=2, control_group="never_treated") results = est.fit( staggered_data, outcome="outcome", @@ -423,7 +423,7 @@ def test_never_treated_only(self, staggered_data): def test_never_treated_no_nevertreated_raises(self, no_never_treated_data): """Error when no never-treated units exist with never_treated mode.""" - est = StackedDiD(kappa_pre=1, kappa_post=1, clean_control="never_treated") + est = StackedDiD(kappa_pre=1, kappa_post=1, control_group="never_treated") with pytest.raises(ValueError, match="All.*adoption events were trimmed"): est.fit( no_never_treated_data, @@ -698,7 +698,7 @@ def test_get_params(self): kappa_pre=3, kappa_post=2, weighting="population", - clean_control="strict", + control_group="strict", cluster="unit_subexp", alpha=0.10, anticipation=1, @@ -708,7 +708,9 @@ def test_get_params(self): assert params["kappa_pre"] == 3 assert params["kappa_post"] == 2 assert params["weighting"] == "population" - assert params["clean_control"] == "strict" + # get_params returns the RAW args; control_group was passed + # explicitly (M-043), clean_control stays at its sentinel. + assert params["control_group"] == "strict" assert params["cluster"] == "unit_subexp" assert params["alpha"] == 0.10 assert params["anticipation"] == 1 @@ -872,9 +874,13 @@ def test_invalid_weighting(self): StackedDiD(weighting="invalid") def test_invalid_clean_control(self): - """Raises on invalid clean_control parameter.""" - with pytest.raises(ValueError, match="clean_control"): - StackedDiD(clean_control="invalid") + """Raises on an invalid control-group value - canonical name and + via the deprecated clean_control alias (M-043).""" + with pytest.raises(ValueError, match="control_group"): + StackedDiD(control_group="invalid") + with pytest.warns(FutureWarning, match="clean_control"): + with pytest.raises(ValueError, match="control_group"): + StackedDiD(clean_control="invalid") def test_invalid_cluster(self): """Raises on invalid cluster parameter.""" @@ -1765,7 +1771,7 @@ def _cb_fit(df, **kw): kappa_pre=2, kappa_post=2, weighting="aggregate", - clean_control="never_treated", + control_group="never_treated", cluster="unit", ) params.update( diff --git a/tests/test_survey.py b/tests/test_survey.py index c988a51c..8f973ba4 100644 --- a/tests/test_survey.py +++ b/tests/test_survey.py @@ -235,8 +235,8 @@ def test_scale_invariance(self): did = DifferenceInDifferences() sd1 = SurveyDesign(weights="w1", weight_type="pweight") sd2 = SurveyDesign(weights="w2", weight_type="pweight") - r1 = did.fit(df, outcome="outcome", treatment="treated", time="post", survey_design=sd1) - r2 = did.fit(df, outcome="outcome", treatment="treated", time="post", survey_design=sd2) + r1 = did.fit(df, outcome="outcome", treatment="treated", post="post", survey_design=sd1) + r2 = did.fit(df, outcome="outcome", treatment="treated", post="post", survey_design=sd2) np.testing.assert_allclose(r1.att, r2.att, atol=1e-10) np.testing.assert_allclose(r1.se, r2.se, atol=1e-10) @@ -593,7 +593,7 @@ def test_did_with_survey_design(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) @@ -628,7 +628,7 @@ def test_psu_overrides_cluster(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) psu_warnings = [ @@ -649,7 +649,7 @@ def test_absorb_with_weights(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["stratum"], survey_design=sd, ) @@ -659,7 +659,7 @@ def test_absorb_with_weights(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["stratum"], ) @@ -682,7 +682,7 @@ def test_summary_output(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) summary_text = result.summary() @@ -707,7 +707,7 @@ def test_to_dict_survey_fields(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) d = result.to_dict() @@ -735,7 +735,7 @@ def test_wild_bootstrap_survey_guard(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) @@ -859,7 +859,7 @@ def test_survey_design_type_error(self, survey_2x2_data): survey_2x2_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design={"weights": "weight"}, # dict instead of SurveyDesign ) @@ -1133,7 +1133,7 @@ def test_survey_coverage_monte_carlo(self, ci_params): df, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) if result.conf_int[0] <= true_att <= result.conf_int[1]: @@ -1443,7 +1443,6 @@ def test_linear_regression_weighted_rank_deficient_robust(self): model = LinearRegression( weights=pw, weight_type="pweight", - robust=True, include_intercept=False, rank_deficient_action="warn", ) @@ -1790,7 +1789,6 @@ def test_linear_regression_auto_derives_weights_from_survey(self): model_explicit = LinearRegression( weights=weights_norm, weight_type="pweight", - robust=True, survey_design=resolved, ) X = np.column_stack([np.ones(n), x1]) @@ -1798,7 +1796,6 @@ def test_linear_regression_auto_derives_weights_from_survey(self): # Auto-derive path: no explicit weights model_auto = LinearRegression( - robust=True, survey_design=resolved, ) model_auto.fit(X, y) @@ -2188,7 +2185,7 @@ def _panel(): def _fit(self, df): sd = SurveyDesign(weights="w", strata="stratum", psu="psu") res = DifferenceInDifferences().fit( - df, outcome="outcome", treatment="treated", time="post", survey_design=sd + df, outcome="outcome", treatment="treated", post="post", survey_design=sd ) return res.att, res.se @@ -2453,7 +2450,7 @@ def test_did_absorb_matches_explicit_wls_dummies(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["region"], survey_design=sd, ) @@ -2464,7 +2461,7 @@ def test_did_absorb_matches_explicit_wls_dummies(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["region"], survey_design=sd, ) @@ -2670,7 +2667,7 @@ def test_zero_se_estimator_nan_inference(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) # SE should be 0 (all certainty strata), inference should be NaN @@ -2762,7 +2759,7 @@ def test_absorbed_did_sample_counts(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["region"], ) @@ -2795,7 +2792,7 @@ def test_repeated_fit_fresh_psu(self): lonely_psu="remove", ) - lr = LinearRegression(survey_design=resolved, robust=True) + lr = LinearRegression(survey_design=resolved) # First fit: 2 clusters → survey_df = 2 - 1 = 1 cluster_1 = np.array([0] * 10 + [1] * 10) @@ -2839,7 +2836,7 @@ def test_multi_absorb_survey_now_supported_did(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["a", "b"], survey_design=sd, ) @@ -2847,7 +2844,7 @@ def test_multi_absorb_survey_now_supported_did(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", fixed_effects=["a", "b"], survey_design=sd, ) @@ -2921,7 +2918,7 @@ def test_single_absorb_survey_allowed(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", absorb=["region"], survey_design=sd, ) @@ -3367,7 +3364,7 @@ def test_did_with_fpc_only_survey(self): df, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) assert np.isfinite(result.att) diff --git a/tests/test_survey_estimator_validation.py b/tests/test_survey_estimator_validation.py index 1c293a84..d80bb3c1 100644 --- a/tests/test_survey_estimator_validation.py +++ b/tests/test_survey_estimator_validation.py @@ -209,7 +209,7 @@ def test_s2_stacked_did(self, golden): kappa_pre=1, kappa_post=1, weighting="sample_share", - clean_control="never_treated", + control_group="never_treated", ) result = est.fit( data, @@ -306,7 +306,7 @@ def test_s4_triple_diff(self, golden): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) diff --git a/tests/test_survey_phase6.py b/tests/test_survey_phase6.py index 7fd0a1aa..2a58878e 100644 --- a/tests/test_survey_phase6.py +++ b/tests/test_survey_phase6.py @@ -111,7 +111,7 @@ def test_subpopulation_with_did(self, basic_did_data): new_data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=new_sd, ) assert np.isfinite(result.att) @@ -563,7 +563,7 @@ def test_replicate_accepted_by_base_did(self, replicate_data): data, outcome="outcome", treatment="treated", - time="post", + post="post", survey_design=sd, ) assert np.isfinite(result.att) @@ -1462,7 +1462,7 @@ def test_triple_diff_replicate_all_methods(self, est_method): outcome="y", group="d1", partition="d2", - time="post", + post="post", survey_design=sd, ) assert np.isfinite(result.att) @@ -1930,7 +1930,7 @@ def test_triple_diff_positive_weight_gate(self): outcome="y", group="d1", partition="d2", - time="post", + post="post", covariates=["x1", "x2", "x3"], survey_design=sd, ) @@ -2009,7 +2009,7 @@ def test_triple_diff_replicate_df_metadata_consistency(self): outcome="y", group="d1", partition="d2", - time="post", + post="post", survey_design=sd, ) sm = result.survey_metadata diff --git a/tests/test_t27_cic_distributional_effects_drift.py b/tests/test_t27_cic_distributional_effects_drift.py index 8e8788a4..68117a3f 100644 --- a/tests/test_t27_cic_distributional_effects_drift.py +++ b/tests/test_t27_cic_distributional_effects_drift.py @@ -165,7 +165,7 @@ def cic(df): @pytest.fixture(scope="module") def did(df): - return DifferenceInDifferences().fit(df, outcome="spend", treatment="treated", time="post") + return DifferenceInDifferences().fit(df, outcome="spend", treatment="treated", post="post") class TestMainStory: @@ -187,7 +187,7 @@ def test_main_fits_warning_free_and_support_clean(self, df, df_log, cic): # BOTH scales - the prose claims it stays monotone). with warnings.catch_warnings(): warnings.simplefilter("error") - DifferenceInDifferences().fit(df, outcome="spend", treatment="treated", time="post") + DifferenceInDifferences().fit(df, outcome="spend", treatment="treated", post="post") ChangesInChanges(n_bootstrap=0).fit( df_log, outcome="log_spend", treatment="treated", time="post" ) diff --git a/tests/test_target_parameter.py b/tests/test_target_parameter.py index db4d68bb..6670ffc1 100644 --- a/tests/test_target_parameter.py +++ b/tests/test_target_parameter.py @@ -8,7 +8,7 @@ - Fit-time config reads are honored: - ``EfficientDiDResults.pt_assumption`` branches the tag between ``pt_all_combined`` and ``pt_post_single_baseline``. - - ``StackedDiDResults.clean_control`` varies the ``definition`` + - ``StackedDiDResults.control_group`` varies the ``definition`` clause (never_treated vs strict vs not_yet_treated). - ``ChaisemartinDHaultfoeuilleResults.L_max`` + ``covariate_residuals`` + ``linear_trends_effects`` branches the dCDH estimand tag. @@ -93,7 +93,7 @@ def test_two_stage(self): def test_stacked(self): tp = describe_target_parameter( - _minimal_result("StackedDiDResults", clean_control="not_yet_treated") + _minimal_result("StackedDiDResults", control_group="not_yet_treated") ) assert tp["aggregation"] == "stacked" assert tp["headline_attribute"] == "overall_att" @@ -380,16 +380,16 @@ class TestTargetParameterFitConfigReads: """Parameterized fit-config-branching tests.""" @pytest.mark.parametrize( - "clean_control, expected_clause", + "control_group, expected_clause", [ ("never_treated", "never-treated"), ("strict", "strictly untreated"), ("not_yet_treated", "not yet treated"), ], ) - def test_stacked_clean_control_branches_definition(self, clean_control, expected_clause): + def test_stacked_control_group_branches_definition(self, control_group, expected_clause): tp = describe_target_parameter( - _minimal_result("StackedDiDResults", clean_control=clean_control) + _minimal_result("StackedDiDResults", control_group=control_group) ) assert tp["aggregation"] == "stacked" assert expected_clause in tp["definition"] diff --git a/tests/test_triple_diff.py b/tests/test_triple_diff.py index ed76a86c..c763a0fd 100644 --- a/tests/test_triple_diff.py +++ b/tests/test_triple_diff.py @@ -157,7 +157,7 @@ def test_fit_basic(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert ddd.is_fitted_ is True @@ -172,7 +172,7 @@ def test_fit_returns_results(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Check results attributes @@ -190,7 +190,7 @@ def test_att_estimate_reasonable(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # True ATT is 2.0, should be within reasonable range @@ -204,7 +204,7 @@ def test_standard_error_positive(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.se > 0 @@ -217,7 +217,7 @@ def test_confidence_interval_contains_att(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.conf_int[0] < results.conf_int[1] @@ -240,7 +240,7 @@ def test_regression_adjustment(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.estimation_method == "reg" @@ -256,7 +256,7 @@ def test_ipw_estimation(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.estimation_method == "ipw" @@ -270,7 +270,7 @@ def test_doubly_robust_estimation(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.estimation_method == "dr" @@ -283,7 +283,7 @@ def test_methods_give_similar_results_no_covariates(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) results_ipw = TripleDifference(estimation_method="ipw").fit( @@ -291,7 +291,7 @@ def test_methods_give_similar_results_no_covariates(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) results_dr = TripleDifference(estimation_method="dr").fit( @@ -299,7 +299,7 @@ def test_methods_give_similar_results_no_covariates(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # All methods should give similar point estimates @@ -324,7 +324,7 @@ def test_with_single_covariate(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1"], ) @@ -340,7 +340,7 @@ def test_with_multiple_covariates(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1", "x2"], ) @@ -356,7 +356,7 @@ def test_covariates_improve_precision(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # With covariates @@ -365,7 +365,7 @@ def test_covariates_improve_precision(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1", "x2"], ) @@ -380,7 +380,7 @@ def test_ipw_with_covariates_has_pscore_stats(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1", "x2"], ) @@ -405,7 +405,7 @@ def test_missing_outcome_column(self, simple_ddd_data): outcome="nonexistent", group="group", partition="partition", - time="time", + post="time", ) def test_missing_cluster_column(self, simple_ddd_data): @@ -417,7 +417,7 @@ def test_missing_cluster_column(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) def test_missing_group_column(self, simple_ddd_data): @@ -429,7 +429,7 @@ def test_missing_group_column(self, simple_ddd_data): outcome="outcome", group="nonexistent", partition="partition", - time="time", + post="time", ) def test_non_binary_group(self, simple_ddd_data): @@ -444,7 +444,7 @@ def test_non_binary_group(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) def test_non_binary_partition(self, simple_ddd_data): @@ -459,7 +459,7 @@ def test_non_binary_partition(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) def test_missing_cell(self, simple_ddd_data): @@ -480,7 +480,7 @@ def test_missing_cell(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) def test_missing_values_in_outcome(self, simple_ddd_data): @@ -495,7 +495,7 @@ def test_missing_values_in_outcome(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) def test_non_dataframe_input(self): @@ -507,7 +507,7 @@ def test_non_dataframe_input(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) @@ -527,7 +527,7 @@ def test_summary_output(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) summary = results.summary() @@ -543,7 +543,7 @@ def test_to_dict(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) result_dict = results.to_dict() @@ -560,7 +560,7 @@ def test_to_dataframe(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) result_df = results.to_dataframe() @@ -576,7 +576,7 @@ def test_is_significant_property(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # With true ATT of 2.0 and reasonable sample size, should be significant @@ -590,7 +590,7 @@ def test_significance_stars_property(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) stars = results.significance_stars @@ -605,7 +605,7 @@ def test_group_means_available(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results.group_means is not None @@ -619,7 +619,7 @@ def test_cell_counts(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) total = ( @@ -734,7 +734,7 @@ def test_small_sample(self, small_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Should still produce results @@ -752,7 +752,7 @@ def test_zero_treatment_effect(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # ATT should be close to zero @@ -768,7 +768,7 @@ def test_large_treatment_effect(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert abs(results.att - 10.0) < 1.0 @@ -783,7 +783,7 @@ def test_low_noise(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Should recover ATT very precisely @@ -801,7 +801,7 @@ def test_high_noise(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Should still run, but with wider confidence intervals @@ -826,7 +826,7 @@ def test_reproducibility(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) ddd2 = TripleDifference() @@ -835,7 +835,7 @@ def test_reproducibility(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert results1.att == results2.att @@ -849,7 +849,7 @@ def test_summary_does_not_raise(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Should not raise @@ -864,7 +864,7 @@ def test_repr_does_not_raise(self, simple_ddd_data): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) # Should not raise @@ -922,7 +922,7 @@ def test_rank_deficient_action_error_raises(self, ddd_data_with_covariates): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1", "x1_dup"], ) @@ -945,7 +945,7 @@ def test_rank_deficient_action_silent_no_warning(self, ddd_data_with_covariates) outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1", "x1_dup"], ) @@ -996,7 +996,7 @@ def test_tstat_nan_when_se_zero(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) se = results.se @@ -1031,7 +1031,7 @@ def test_tstat_consistency_all_methods(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", covariates=["x1"], ) @@ -1120,7 +1120,7 @@ def test_cluster_changes_ses(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) td_cluster = TripleDifference(cluster="state") @@ -1129,7 +1129,7 @@ def test_cluster_changes_ses(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", ) assert np.isfinite(res_unit.se) and res_unit.se > 0 @@ -1232,10 +1232,10 @@ def test_default_hc1_bit_equal_baseline(self, method): data = generate_ddd_data(n_per_cell=80, true_att=2.0, seed=11) r_default = TripleDifference(estimation_method=method).fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) r_explicit = TripleDifference(estimation_method=method, vcov_type="hc1").fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) assert r_default.att == r_explicit.att, f"[{method}] ATT not bit-equal" assert r_default.se == r_explicit.se, f"[{method}] SE not bit-equal" @@ -1247,10 +1247,10 @@ def test_cluster_hc1_bit_equal_baseline(self): data = _generate_ddd_data_with_state_clusters(seed=23) r_default = TripleDifference(cluster="state").fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) r_explicit = TripleDifference(cluster="state", vcov_type="hc1").fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) assert r_default.att == r_explicit.att assert r_default.se == r_explicit.se @@ -1272,7 +1272,7 @@ def test_survey_hc1_bit_equal_baseline(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) r_explicit = TripleDifference(estimation_method=method, vcov_type="hc1").fit( @@ -1280,7 +1280,7 @@ def test_survey_hc1_bit_equal_baseline(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) assert r_default.att == r_explicit.att, f"[{method}] survey ATT not bit-equal" @@ -1309,7 +1309,7 @@ def test_replicate_survey_hc1_bit_equal_baseline(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) r_explicit = TripleDifference(estimation_method=method, vcov_type="hc1").fit( @@ -1317,7 +1317,7 @@ def test_replicate_survey_hc1_bit_equal_baseline(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) assert r_default.att == r_explicit.att, f"[{method}] replicate ATT not bit-equal" @@ -1358,7 +1358,7 @@ def test_cluster_plus_replicate_weights_rejected(self, method): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) @@ -1429,7 +1429,7 @@ def test_get_params_includes_vcov_type(self): def test_results_carries_vcov_type(self): data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=43) res = TripleDifference().fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) assert res.vcov_type == "hc1" @@ -1437,7 +1437,7 @@ def test_to_dict_includes_vcov_type(self): """CS R7 caught the same Results-introspection gap on the dict surface.""" data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=47) res = TripleDifference().fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) d = res.to_dict() assert "vcov_type" in d @@ -1448,7 +1448,7 @@ def test_summary_includes_vcov_type(self): via the shared _format_vcov_label, not the raw vcov_type string.""" data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=51) res = TripleDifference().fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) out = res.summary() assert "Variance estimator" in out @@ -1460,7 +1460,7 @@ def test_summary_cluster_label_is_cr1_not_raw_hc1(self): Addresses codex local-review P2 — raw 'hc1' line was misleading.""" data = _generate_ddd_data_with_state_clusters(seed=53) res = TripleDifference(cluster="state").fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) out = res.summary() assert "CR1 cluster-robust at state" in out @@ -1480,7 +1480,7 @@ def test_summary_no_variance_estimator_line_under_survey(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) out = res.summary() @@ -1494,13 +1494,13 @@ def test_results_cluster_name_carries_through(self): Mirrors CS PR #487 pattern; consumed by _format_vcov_label in summary().""" data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=63) r_none = TripleDifference().fit( - data, outcome="outcome", group="group", partition="partition", time="time" + data, outcome="outcome", group="group", partition="partition", post="time" ) assert r_none.cluster_name is None data2 = _generate_ddd_data_with_state_clusters(seed=67) r_cluster = TripleDifference(cluster="state").fit( - data2, outcome="outcome", group="group", partition="partition", time="time" + data2, outcome="outcome", group="group", partition="partition", post="time" ) assert r_cluster.cluster_name == "state" # And it flows through to_dict @@ -1534,7 +1534,7 @@ def test_cluster_name_suppressed_under_survey_design(self): outcome="outcome", group="group", partition="partition", - time="time", + post="time", survey_design=sd, ) # cluster_name + n_clusters suppressed under survey-backed fit @@ -1560,9 +1560,9 @@ def test_fit_clone_idempotent_on_vcov_type(self): Results construction (sklearn clone() pattern).""" data = generate_ddd_data(n_per_cell=40, true_att=2.0, seed=57) td1 = TripleDifference(vcov_type="hc1") - r1 = td1.fit(data, outcome="outcome", group="group", partition="partition", time="time") + r1 = td1.fit(data, outcome="outcome", group="group", partition="partition", post="time") td2 = TripleDifference(**td1.get_params()) - r2 = td2.fit(data, outcome="outcome", group="group", partition="partition", time="time") + r2 = td2.fit(data, outcome="outcome", group="group", partition="partition", post="time") assert r1.att == r2.att assert r1.se == r2.se assert r2.vcov_type == "hc1" diff --git a/tests/test_utils.py b/tests/test_utils.py index 1d241a33..0085c5ab 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1845,7 +1845,7 @@ def test_estimator_one_way_absorb_nan_raises(self): df.loc[df.index[5], "fe"] = np.nan with pytest.raises(ValueError, match="'fe' contains NaN"): DifferenceInDifferences().fit( - df, outcome="y", treatment="treated", time="post", absorb=["fe"] + df, outcome="y", treatment="treated", post="post", absorb=["fe"] ) def test_weight_aware_snap_zero_weight_domain(self): diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index 8813ebad..9a1508fd 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -120,11 +120,13 @@ # (M-127 behavior + the four M-128..M-131 default-flips) = 110, plus the # alias-diet family (M-132..M-134 alias deprecations + the M-135 __getattr__ # warning mechanism; EDiD was initially slated but RETAINED - it is the CSX -# paper's own estimator label) = 114. +# paper's own estimator label) = 114, plus the 2(c)-ii missed-rename +# amendments (M-136 LPDiD level value; M-137/M-138 diagnostics time->post) +# = 117. # Ids are never reused and terminal rows are never # deleted, so the ledger only grows - raise the floor when rows are added; a # lower parse count means scanner/format drift or an illegal row deletion. -ROW_COUNT_FLOOR = 114 +ROW_COUNT_FLOOR = 117 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -169,6 +171,7 @@ (126, 126), (127, 131), (132, 135), + (136, 138), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -567,13 +570,13 @@ def test_initial_ids_never_deleted(): """The shipped id set is immutable: ids are never deleted or reused (spec section 11). ROW_COUNT_FLOOR alone would let a delete-one-add-one edit pass; this snapshot cannot. - Extends as rows ship (114 as of the alias-diet family: + Extends as rows ship (117 as of the 2(c)-ii missed-rename amendments: Phase 1 + diagnostic-family + M-092/M-093 + M-094..M-096 + the M-097..M-115 public-function completeness sweep + M-117/M-122 + M-123/M-124 + M-125 + - M-126 + M-127..M-131 + M-132..M-135).""" + M-126 + M-127..M-131 + M-132..M-135 + M-136..M-138).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 114 + assert len(EXPECTED_INITIAL_IDS) == 117 def test_version_tuple_pads_to_three_components(): diff --git a/tests/test_v4_rename_semantic.py b/tests/test_v4_rename_semantic.py new file mode 100644 index 00000000..7a8d7917 --- /dev/null +++ b/tests/test_v4_rename_semantic.py @@ -0,0 +1,682 @@ +"""Behavioral pins for the 2(c)-ii semantic rename wave (3.9 shims). + +The dedicated shim test file required by the ledger rows this PR flips +(``test_ref`` on M-030/M-031, M-043/M-095, M-044/M-086/M-087, +M-045..M-047/M-115, M-084, and the missed-rename amendments M-136..M-138 in +``docs/v4-deprecations.yaml``). Per the section 2 per-PR gate, every shimmed +surface pins: the old spelling's ``FutureWarning`` with the migration +message, canonical/positional silence, the both-supplied ``ValueError``, +bit-exact routing parity, the renamed-field trio (dataclass shape + warning +property + pickle migration + dual serialized keys), and the raw-keep rows' +probe re-warn counts. +""" + +import pickle +import warnings + +import numpy as np +import pandas as pd +import pytest + +from diff_diff._deprecation import _NotSupplied + + +def _assert_no_future_warning(record): + fw = [w for w in record if issubclass(w.category, FutureWarning)] + assert fw == [], [str(w.message) for w in fw] + + +@pytest.fixture(scope="module") +def did_2x2(): + rng = np.random.default_rng(0) + n = 200 + return pd.DataFrame( + { + "y": rng.normal(size=n), + "d": np.repeat([0, 1], n // 2), + "p": np.tile(np.repeat([0, 1], n // 4), 2), + } + ) + + +@pytest.fixture(scope="module") +def ddd_2x2x2(): + rng = np.random.default_rng(1) + n = 400 + df = pd.DataFrame( + { + "y": rng.normal(size=n), + "g": np.repeat([0, 1], n // 2), + "q": np.tile(np.repeat([0, 1], n // 4), 2), + "t": np.tile([0, 1], n // 2), + } + ) + df["y"] += 1.5 * df.g * df.q * df.t + return df + + +@pytest.fixture(scope="module") +def placebo_panel(): + rng = np.random.default_rng(2) + rows = [] + for u in range(30): + d = 1 if u < 15 else 0 + for t2 in (0, 1): + rows.append((u, d, t2, 0.5 + 0.3 * t2 + 0.8 * d * t2 + rng.normal(0, 0.3))) + return pd.DataFrame(rows, columns=["u", "d", "p", "y"]) + + +@pytest.fixture(scope="module") +def stacked_panel(): + rng = np.random.default_rng(5) + rows = [] + for u in range(40): + g = 3 if u < 12 else (4 if u < 24 else 0) + for t in range(1, 7): + y = 0.2 * t + (1.0 if g and t >= g else 0) + rng.normal(0, 0.3) + rows.append((u, t, g, y)) + return pd.DataFrame(rows, columns=["unit", "time", "first_treat", "y"]) + + +@pytest.fixture(scope="module") +def etwfe_result(): + from diff_diff import WooldridgeDiD + + rng = np.random.default_rng(3) + rows = [] + for u in range(40): + g = 3 if u < 20 else 0 + for t in range(1, 6): + rows.append( + (u, t, g, 1.0 + 0.5 * t + (1.5 if g and t >= g else 0) + rng.normal(0, 0.3)) + ) + df = pd.DataFrame(rows, columns=["unit", "time", "g", "y"]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return WooldridgeDiD().fit(df, "y", "unit", "time", "g") + + +# --------------------------------------------------------------------------- +# M-030 / M-031: DiD.fit and TripleDifference.fit time= -> post= +# --------------------------------------------------------------------------- + + +class TestTimeToPost: + def test_did_canonical_positional_formula_silent(self, did_2x2): + from diff_diff import DifferenceInDifferences as DiD + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_kw = DiD().fit(did_2x2, outcome="y", treatment="d", post="p") + r_pos = DiD().fit(did_2x2, "y", "d", "p") + r_form = DiD().fit(did_2x2, formula="y ~ d * p") + _assert_no_future_warning(record) + assert r_kw.att == r_pos.att == r_form.att + + def test_did_time_warns_and_routes_identically(self, did_2x2): + from diff_diff import DifferenceInDifferences as DiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = DiD().fit(did_2x2, outcome="y", treatment="d", post="p") + with pytest.warns( + FutureWarning, + match=r"DifferenceInDifferences\.fit\(time=\) is deprecated and " + r"will be removed in 4\.0; use post= instead\.", + ): + r_old = DiD().fit(did_2x2, outcome="y", treatment="d", time="p") + assert r_old.att == r_new.att + assert r_old.se == r_new.se + + def test_did_both_supplied_raises(self, did_2x2): + from diff_diff import DifferenceInDifferences as DiD + + with pytest.raises(ValueError, match=r"pass only post="): + DiD().fit(did_2x2, outcome="y", treatment="d", post="p", time="p") + + def test_ddd_time_warns_with_calendar_note(self, ddd_2x2x2): + from diff_diff import TripleDifference + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", post="t") + r_pos = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", "t") + with pytest.warns(FutureWarning, match=r"calendar column only"): + r_old = TripleDifference().fit(ddd_2x2x2, "y", "g", "q", time="t") + assert r_old.att == r_new.att == r_pos.att + + def test_ddd_missing_post_raises(self, ddd_2x2x2): + from diff_diff import TripleDifference + + with pytest.raises(TypeError, match=r"missing required argument: 'post'"): + TripleDifference().fit(ddd_2x2x2, "y", "g", "q") + + def test_wrapper_forwards_silently(self, ddd_2x2x2): + from diff_diff.triple_diff import triple_difference + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r = triple_difference(ddd_2x2x2, "y", "g", "q", "t") + _assert_no_future_warning(record) + assert np.isfinite(r.att) + + +# --------------------------------------------------------------------------- +# M-137 / M-138: permutation_test / leave_one_out_test time= -> post= +# --------------------------------------------------------------------------- + + +class TestDiagnosticsTimeToPost: + def test_permutation_rename(self, placebo_panel): + from diff_diff import permutation_test + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_new = permutation_test( + placebo_panel, "y", "d", post="p", unit="u", n_permutations=50, seed=1 + ) + r_pos = permutation_test(placebo_panel, "y", "d", "p", "u", n_permutations=50, seed=1) + _assert_no_future_warning(record) + with pytest.warns(FutureWarning, match=r"permutation_test\(time=\) is deprecated"): + r_old = permutation_test( + placebo_panel, "y", "d", time="p", unit="u", n_permutations=50, seed=1 + ) + assert r_new.p_value == r_old.p_value == r_pos.p_value + with pytest.raises(ValueError, match=r"pass only post="): + permutation_test(placebo_panel, "y", "d", post="p", unit="u", time="p") + with pytest.raises(TypeError, match=r"missing required argument: 'unit'"): + permutation_test(placebo_panel, "y", "d", post="p") + + def test_leave_one_out_rename(self, placebo_panel): + from diff_diff import leave_one_out_test + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + l_new = leave_one_out_test(placebo_panel, "y", "d", post="p", unit="u") + _assert_no_future_warning(record) + with pytest.warns(FutureWarning, match=r"leave_one_out_test\(time=\) is deprecated"): + l_old = leave_one_out_test(placebo_panel, "y", "d", time="p", unit="u") + assert l_new.original_effect == l_old.original_effect + + def test_wrapper_paths_silent_and_no_error_dicts(self, placebo_panel): + """The run_all_placebo_tests calls sit inside ``except Exception`` + blocks that would SWALLOW a FutureWarning-raised-as-error into + ``{"error": ...}`` dicts - so the zero-warning contract is pinned + here explicitly (dual-review R2 consensus P1).""" + from diff_diff.diagnostics import run_all_placebo_tests, run_placebo_test + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + run_placebo_test( + placebo_panel, + "y", + "d", + "p", + unit="u", + test_type="permutation", + n_permutations=30, + seed=2, + ) + allr = run_all_placebo_tests( + placebo_panel, + "y", + "d", + "p", + unit="u", + pre_periods=[0], + post_periods=[1], + n_permutations=30, + seed=2, + ) + _assert_no_future_warning(record) + for key in ("permutation", "leave_one_out"): + v = allr.get(key) + assert v is not None + assert not (isinstance(v, dict) and "error" in v), (key, v) + + +# --------------------------------------------------------------------------- +# M-045..M-047 / M-115: the robust drop +# --------------------------------------------------------------------------- + + +class TestRobustDrop: + @pytest.mark.parametrize( + "cls_name,legacy_default", + [ + ("DifferenceInDifferences", True), + ("TwoWayFixedEffects", True), + ("TripleDifference", True), + ("HeterogeneousAdoptionDiD", False), + ("LinearRegression", True), + ], + ) + def test_default_silent_and_resolved_attr(self, cls_name, legacy_default): + import diff_diff + + cls = getattr(diff_diff, cls_name) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + est = cls() + _assert_no_future_warning(record) + # The PUBLIC attr keeps the RESOLVED legacy bool through 3.9 so + # pre-rename attribute readers keep working. + assert est.robust is legacy_default + assert est._robust_arg is None + + @pytest.mark.parametrize( + "cls_name", ["DifferenceInDifferences", "TripleDifference", "LinearRegression"] + ) + def test_explicit_robust_warns(self, cls_name): + import diff_diff + + cls = getattr(diff_diff, cls_name) + with pytest.warns( + FutureWarning, + match=rf"{cls_name}\(robust=\) is deprecated and will be removed " + r"in 4\.0; use vcov_type= instead\.", + ): + est = cls(robust=False) + assert est.robust is False + + def test_did_routing_parity(self, did_2x2): + from diff_diff import DifferenceInDifferences as DiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_old = DiD(robust=False).fit(did_2x2, "y", "d", "p") + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_new = DiD(vcov_type="classical").fit(did_2x2, "y", "d", "p") + _assert_no_future_warning(record) + assert r_old.att == r_new.att + assert r_old.se == r_new.se + + def test_get_params_round_trip_silent_on_default(self): + from diff_diff import DifferenceInDifferences as DiD + + est = DiD() + params = est.get_params() + assert params["robust"] is None + with warnings.catch_warnings(): + warnings.simplefilter("error") + clone = DiD(**params) + assert clone.vcov_type == est.vcov_type + + def test_probe_rewarns_on_deprecated_configured_instance(self): + """Accepted + documented: a robust=-configured instance re-warns on + set_params probe re-init (the config is still deprecated).""" + from diff_diff import DifferenceInDifferences as DiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + est = DiD(robust=False) + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + est.set_params(alpha=0.10) + assert est.vcov_type == "classical" + + def test_set_params_robust_alone_rederives_vcov(self): + from diff_diff import DifferenceInDifferences as DiD + + est = DiD(vcov_type="hc1") + with pytest.warns(FutureWarning, match=r"\(robust=\) is deprecated"): + est.set_params(robust=False) + assert est.vcov_type == "classical" + + def test_conflict_still_raises(self): + from diff_diff import DifferenceInDifferences as DiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises(ValueError, match="conflicts with vcov_type"): + DiD(robust=False, vcov_type="hc1") + + +# --------------------------------------------------------------------------- +# M-043 / M-095: StackedDiD clean_control= -> control_group= +# --------------------------------------------------------------------------- + + +class TestCleanControlRename: + def test_canonical_silent_and_default(self): + from diff_diff import StackedDiD + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + s = StackedDiD() + s2 = StackedDiD(control_group="strict") + _assert_no_future_warning(record) + assert s.control_group == "not_yet_treated" + assert s2.control_group == "strict" + + def test_old_name_warns_and_maps(self): + from diff_diff import StackedDiD + + with pytest.warns( + FutureWarning, + match=r"StackedDiD\(clean_control=\) is deprecated and will be " + r"removed in 4\.0; use control_group= instead\.", + ): + s = StackedDiD(clean_control="never_treated") + assert s.control_group == "never_treated" + + def test_both_supplied_raises(self): + from diff_diff import StackedDiD + + with pytest.raises(ValueError, match=r"pass only control_group="): + StackedDiD(control_group="strict", clean_control="strict") + + def test_estimator_property_alias(self): + from diff_diff import StackedDiD + + s = StackedDiD(control_group="strict") + with pytest.warns(FutureWarning, match=r"StackedDiD\.clean_control is deprecated"): + assert s.clean_control == "strict" + with pytest.raises(AttributeError): + s.clean_control = "never_treated" + + def test_get_params_round_trip_silent(self): + from diff_diff import StackedDiD + + est = StackedDiD(control_group="strict") + params = est.get_params() + assert isinstance(params["clean_control"], _NotSupplied) + with warnings.catch_warnings(): + warnings.simplefilter("error") + clone = StackedDiD(**params) + assert clone.control_group == "strict" + + def test_set_params_migrates_both_directions(self): + from diff_diff import StackedDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + s = StackedDiD(clean_control="strict") + s.set_params(control_group="never_treated") + assert s.control_group == "never_treated" + s2 = StackedDiD(control_group="never_treated") + with pytest.warns(FutureWarning, match=r"clean_control"): + s2.set_params(clean_control="strict") + assert s2.control_group == "strict" + + def test_routing_parity(self, stacked_panel): + from diff_diff import StackedDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_old = StackedDiD(clean_control="not_yet_treated").fit( + stacked_panel, + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + ) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r_new = StackedDiD(control_group="not_yet_treated").fit( + stacked_panel, + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + ) + _assert_no_future_warning(record) + assert r_old.overall_att == r_new.overall_att + + def test_results_field_trio_and_dual_keys(self, stacked_panel): + from diff_diff import StackedDiD + from diff_diff.stacked_did_results import StackedDiDResults + + assert "control_group" in StackedDiDResults.__dataclass_fields__ + assert "clean_control" not in StackedDiDResults.__dataclass_fields__ + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = StackedDiD().fit( + stacked_panel, + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + ) + assert res.control_group == "not_yet_treated" + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + d = res.to_dict() + s = res.summary() + assert d["control_group"] == "not_yet_treated" == d["clean_control"] + assert "Control group:" in s + with pytest.warns(FutureWarning, match=r"StackedDiDResults\.clean_control is deprecated"): + assert res.clean_control == "not_yet_treated" + + def test_pickle_migration(self, stacked_panel): + from diff_diff import StackedDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = StackedDiD().fit( + stacked_panel, + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + ) + clone = pickle.loads(pickle.dumps(res)) + assert clone.control_group == res.control_group + state = dict(res.__dict__) + state["clean_control"] = state.pop("control_group") + old_style = object.__new__(type(res)) + old_style.__setstate__(state) + assert old_style.control_group == "not_yet_treated" + + def test_power_survey_gate_estimator_scoped(self): + """Post-M-095 StackedDiD exposes control_group; the survey_config + gate must keep accepting the default (bit-exact with the + pre-rename gate, which rejected only strict) - dual-review R2 P1 + regression.""" + from diff_diff import StackedDiD + from diff_diff.power import SurveyPowerConfig, simulate_power + + cfg = SurveyPowerConfig(weight_cv=0.5) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + # default (not_yet_treated) passes the gate + simulate_power( + StackedDiD(), + n_units=30, + n_periods=4, + survey_config=cfg, + n_simulations=1, + seed=1, + progress=False, + ) + with pytest.raises(ValueError, match="control_group='strict'"): + simulate_power( + StackedDiD(control_group="strict"), + n_units=30, + n_periods=4, + survey_config=cfg, + n_simulations=1, + seed=1, + progress=False, + ) + + +# --------------------------------------------------------------------------- +# M-044 / M-086 / M-087 (+ M-136): the Wooldridge triple and LPDiD level +# --------------------------------------------------------------------------- + + +class TestWooldridgeTriple: + def test_aggregate_event_study_canonical(self, etwfe_result): + r = etwfe_result + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r.aggregate("event_study") + _assert_no_future_warning(record) + assert r.aggregation_weights["event_study"] == "cell" + assert r.aggregation_weights["event"] == "cell" # dual key (3.9) + + def test_aggregate_event_warns_and_maps(self, etwfe_result): + r = etwfe_result + with pytest.warns(FutureWarning, match=r"use type='event_study' instead"): + r.aggregate("event") + assert r.event_study_effects + + def test_to_dataframe_level_rename(self, etwfe_result): + r = etwfe_result + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r.aggregate("event_study") + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + f_default = r.to_dataframe() + f_level = r.to_dataframe(level="event_study") + f_gt = r.to_dataframe("gt") + _assert_no_future_warning(record) + assert not f_gt.empty + with pytest.warns(FutureWarning, match=r"use level= instead"): + f_old = r.to_dataframe(aggregation="event_study") + pd.testing.assert_frame_equal(f_default, f_level) + pd.testing.assert_frame_equal(f_default, f_old) + with pytest.warns(FutureWarning, match=r"use level='event_study' instead"): + f_oldval = r.to_dataframe(level="event") + pd.testing.assert_frame_equal(f_default, f_oldval) + with pytest.raises(ValueError, match=r"pass only level="): + r.to_dataframe(level="gt", aggregation="gt") + + def test_summary_alpha_keyword_only(self, etwfe_result): + r = etwfe_result + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + s = r.summary() + s_same = r.summary(alpha=r.alpha) + _assert_no_future_warning(record) + assert "95% CI" in s and "95% CI" in s_same + # The shared section-5 contract: a DIFFERENT alpha raises rather + # than silently relabeling stored intervals. + with pytest.raises(ValueError, match="never recomputes"): + r.summary(alpha=0.10) + with pytest.warns(FutureWarning, match=r"summary\(aggregation=\)"): + r.summary("simple") + with pytest.raises(TypeError, match="KEYWORD-ONLY"): + r.summary(0.10) + + def test_aggregation_weights_pickle_mirror(self, etwfe_result): + r = etwfe_result + state = dict(r.__dict__) + state["aggregation_weights"] = {"event": "cell"} + r2 = object.__new__(type(r)) + r2.__setstate__(state) + assert r2.aggregation_weights["event_study"] == "cell" + + +class TestLPDiDLevelValue: + """M-136.""" + + def test_level_value_shim(self): + from diff_diff import LPDiD + + rng = np.random.default_rng(7) + rows = [] + for u in range(40): + g = 4 if u < 20 else 0 + for t in range(1, 9): + d = 1 if (g and t >= g) else 0 + rows.append((u, t, d, 0.2 * t + 1.0 * d + rng.normal(0, 0.3))) + df = pd.DataFrame(rows, columns=["unit", "time", "d", "y"]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = LPDiD(pre_window=2, post_window=2).fit( + df, outcome="y", unit="unit", time="time", treatment="d" + ) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + f_new = res.to_dataframe() + f_canon = res.to_dataframe(level="event_study") + _assert_no_future_warning(record) + with pytest.warns(FutureWarning, match=r"use level='event_study' instead"): + f_old = res.to_dataframe(level="event") + pd.testing.assert_frame_equal(f_new, f_canon) + pd.testing.assert_frame_equal(f_new, f_old) + with pytest.raises(ValueError, match="event_study"): + res.to_dataframe(level="bogus") + + +# --------------------------------------------------------------------------- +# M-084: ContinuousDiD covariates move to fit() +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def cdid_panel(): + rng = np.random.default_rng(6) + rows = [] + for u in range(60): + g = 3 if u < 30 else 0 + d = max(0.1, rng.normal(1, 0.4)) if g else 0.0 + x = rng.normal() + for t in range(1, 6): + rows.append( + ( + u, + t, + g, + d, + x, + 0.3 * t + 0.9 * (d if (g and t >= g) else 0) + 0.2 * x + rng.normal(0, 0.3), + ) + ) + return pd.DataFrame(rows, columns=["u", "t", "g", "dose", "x", "y"]) + + +class TestCovariatesMove: + def test_fit_level_canonical_silent(self, cdid_panel): + from diff_diff import ContinuousDiD + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + r = ContinuousDiD().fit(cdid_panel, "y", "u", "t", "g", "dose", covariates=["x"]) + _assert_no_future_warning(record) + assert r.covariates == ["x"] + + def test_ctor_warns_and_routes_identically(self, cdid_panel): + from diff_diff import ContinuousDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r_new = ContinuousDiD().fit(cdid_panel, "y", "u", "t", "g", "dose", covariates=["x"]) + with pytest.warns( + FutureWarning, + match=r"ContinuousDiD\(covariates=\) is deprecated and will be " + r"removed in 4\.0; pass covariates to fit\(\) instead\.", + ): + est = ContinuousDiD(covariates=["x"]) + r_old = est.fit(cdid_panel, "y", "u", "t", "g", "dose") + assert r_old.att == r_new.att + assert r_old.covariates == r_new.covariates == ["x"] + + def test_both_supplied_raises(self, cdid_panel): + from diff_diff import ContinuousDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + est = ContinuousDiD(covariates=["x"]) + with pytest.raises(ValueError, match=r"fit\(\) only"): + est.fit(cdid_panel, "y", "u", "t", "g", "dose", covariates=["x"]) + + def test_lowest_dose_guard_fires_for_fit_level(self, cdid_panel): + from diff_diff import ContinuousDiD + + with pytest.raises(NotImplementedError, match="lowest_dose"): + ContinuousDiD(control_group="lowest_dose").fit( + cdid_panel, "y", "u", "t", "g", "dose", covariates=["x"] + ) + + def test_probe_rewarns_on_ctor_configured_instance(self): + from diff_diff import ContinuousDiD + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + est = ContinuousDiD(covariates=["x"]) + with pytest.warns(FutureWarning, match=r"\(covariates=\) is deprecated"): + est.set_params(alpha=0.10) diff --git a/tests/test_wild_bootstrap.py b/tests/test_wild_bootstrap.py index 7a5953a7..d5c5fa5a 100644 --- a/tests/test_wild_bootstrap.py +++ b/tests/test_wild_bootstrap.py @@ -388,7 +388,7 @@ def test_did_with_wild_bootstrap(self, clustered_did_data, ci_params): cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 ) - results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") assert results.inference_method == "wild_bootstrap" assert results.n_bootstrap == n_boot @@ -406,9 +406,9 @@ def test_did_wild_bootstrap_reproducibility(self, clustered_did_data, ci_params) cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 ) - results1 = did1.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results1 = did1.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") - results2 = did2.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results2 = did2.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") # se = analytical CR1 SE (reproducible up to the cluster-vcov solve's # bit-reproducibility, ~1e-13 on threaded BLAS / Rust); p-value is exact. @@ -424,11 +424,11 @@ def test_did_analytical_vs_bootstrap_att_same(self, clustered_did_data, ci_param ) results_analytical = did_analytical.fit( - clustered_did_data, outcome="outcome", treatment="treated", time="post" + clustered_did_data, outcome="outcome", treatment="treated", post="post" ) results_bootstrap = did_bootstrap.fit( - clustered_did_data, outcome="outcome", treatment="treated", time="post" + clustered_did_data, outcome="outcome", treatment="treated", post="post" ) # ATT should be identical @@ -445,7 +445,7 @@ def test_did_wild_bootstrap_with_webb_weights(self, clustered_did_data, ci_param seed=42, ) - results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") assert results.inference_method == "wild_bootstrap" assert results.se > 0 @@ -457,7 +457,7 @@ def test_did_wild_bootstrap_requires_cluster(self, clustered_did_data, ci_params inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 # No cluster specified ) - results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") # Should fall back to analytical since no cluster specified assert results.inference_method == "analytical" @@ -484,7 +484,7 @@ def test_summary_shows_bootstrap_info(self, clustered_did_data, ci_params): cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 ) - results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") summary = results.summary() @@ -608,7 +608,7 @@ def test_three_clusters_still_works(self, ci_params): # Should warn about few clusters but still produce valid results with pytest.warns(UserWarning, match="Only 3 clusters"): - results = did.fit(df, outcome="outcome", treatment="treated", time="post") + results = did.fit(df, outcome="outcome", treatment="treated", post="post") assert results.se > 0 assert results.inference_method == "wild_bootstrap" @@ -659,7 +659,7 @@ def test_two_clusters_minimum(self, ci_params): # Should warn about few clusters with pytest.warns(UserWarning, match="Only 2 clusters"): - results = did.fit(df, outcome="outcome", treatment="treated", time="post") + results = did.fit(df, outcome="outcome", treatment="treated", post="post") # Results should still be valid (though may have high variance) assert results.se > 0 @@ -687,12 +687,12 @@ def test_few_clusters_webb_vs_rademacher(self, few_cluster_data, ci_params): with pytest.warns(UserWarning): results_webb = did_webb.fit( - few_cluster_data, outcome="outcome", treatment="treated", time="post" + few_cluster_data, outcome="outcome", treatment="treated", post="post" ) with pytest.warns(UserWarning): results_rademacher = did_rademacher.fit( - few_cluster_data, outcome="outcome", treatment="treated", time="post" + few_cluster_data, outcome="outcome", treatment="treated", post="post" ) # Both should produce valid results @@ -715,7 +715,7 @@ def test_few_clusters_confidence_intervals_valid(self, few_cluster_data, ci_para ) with pytest.warns(UserWarning): - results = did.fit(few_cluster_data, outcome="outcome", treatment="treated", time="post") + results = did.fit(few_cluster_data, outcome="outcome", treatment="treated", post="post") lower, upper = results.conf_int assert lower < upper @@ -895,7 +895,7 @@ def test_strong_effect_is_significant(self, clustered_did_data, ci_params): did = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 ) - res = did.fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + res = did.fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") lower, upper = res.conf_int assert res.p_value < 0.05, f"strong effect should be significant, got p={res.p_value}" assert not (lower <= 0.0 <= upper), "CI should exclude 0 for a strong effect" @@ -909,7 +909,7 @@ def test_p_value_ci_consistency(self, ci_params): did = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=seed ) - res = did.fit(df, outcome="outcome", treatment="treated", time="post") + res = did.fit(df, outcome="outcome", treatment="treated", post="post") lower, upper = res.conf_int zero_in_ci = lower <= 0.0 <= upper rejects = res.p_value < did.alpha @@ -926,7 +926,7 @@ def test_true_null_not_significant(self, ci_params): did = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=7 ) - res = did.fit(df, outcome="outcome", treatment="treated", time="post") + res = did.fit(df, outcome="outcome", treatment="treated", post="post") lower, upper = res.conf_int assert res.p_value > 0.05 assert lower <= 0.0 <= upper @@ -938,10 +938,10 @@ def test_enumeration_is_deterministic(self): df = _make_clustered(6, 2.5, seed=1) # 6 clusters -> 2**5 = 32 <= 999 -> enumerate r1 = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=999, seed=1 - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") r2 = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=999, seed=999 - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") assert r1.n_bootstrap == 2**6 assert r1.p_value == r2.p_value # CI is reproducible up to the cluster-vcov solve's bit-reproducibility @@ -953,11 +953,11 @@ def test_se_matches_analytical_cluster_robust(self, clustered_did_data, ci_param SE — identical to the analytical cluster-robust fit.""" n_boot = ci_params.bootstrap(199, min_n=49) analytical = DifferenceInDifferences(cluster="cluster").fit( - clustered_did_data, outcome="outcome", treatment="treated", time="post" + clustered_did_data, outcome="outcome", treatment="treated", post="post" ) boot = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=42 - ).fit(clustered_did_data, outcome="outcome", treatment="treated", time="post") + ).fit(clustered_did_data, outcome="outcome", treatment="treated", post="post") assert boot.se == pytest.approx(analytical.se, rel=1e-10) def test_many_clusters_ci_comparable_to_analytical(self, ci_params): @@ -967,11 +967,11 @@ def test_many_clusters_ci_comparable_to_analytical(self, ci_params): n_boot = ci_params.bootstrap(999, min_n=199) df = _make_clustered(30, 1.0, seed=11) analytical = DifferenceInDifferences(cluster="cluster").fit( - df, outcome="outcome", treatment="treated", time="post" + df, outcome="outcome", treatment="treated", post="post" ) boot = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_boot, seed=11 - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") a_half = (analytical.conf_int[1] - analytical.conf_int[0]) / 2 b_half = (boot.conf_int[1] - boot.conf_int[0]) / 2 assert 0.7 < (b_half / a_half) < 1.5 @@ -986,7 +986,7 @@ def test_equal_tailed_consistent(self, ci_params): n_bootstrap=n_boot, seed=5, p_val_type="equal-tailed", - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") lower, upper = res.conf_int assert lower < upper assert (not (lower <= 0.0 <= upper)) == (res.p_value < 0.05) @@ -1209,7 +1209,7 @@ def test_low_draw_floor_preserves_consistency(n_clusters, n_bootstrap, att, seed ) with warnings.catch_warnings(): warnings.simplefilter("ignore") # few-cluster warning - res = did.fit(df, outcome="outcome", treatment="treated", time="post") + res = did.fit(df, outcome="outcome", treatment="treated", post="post") lower, upper = res.conf_int zero_in_ci = lower <= 0.0 <= upper rejects = res.p_value < did.alpha @@ -1227,13 +1227,13 @@ def test_p_val_type_surfaced_on_results(): for ptype in ("two-tailed", "equal-tailed"): res = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=999, seed=5, p_val_type=ptype - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") assert res.p_val_type == ptype assert res.to_dict()["p_val_type"] == ptype assert "Test type:" in res.summary() # Analytical inference does not set p_val_type. res_a = DifferenceInDifferences(cluster="cluster").fit( - df, outcome="outcome", treatment="treated", time="post" + df, outcome="outcome", treatment="treated", post="post" ) assert res_a.p_val_type is None @@ -1321,7 +1321,7 @@ def test_saturated_design_returns_degenerate_not_crash(): warnings.simplefilter("ignore") res = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=99, seed=1 - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") # Full inference family is NaN together (no raw exception, no mixed output). assert np.isnan(res.se) assert np.isnan(res.p_value) @@ -1386,7 +1386,7 @@ def _make_clustered_g10(n_bootstrap, seed): df = pd.DataFrame(rows) return DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=n_bootstrap, seed=seed - ).fit(df, outcome="outcome", treatment="treated", time="post") + ).fit(df, outcome="outcome", treatment="treated", post="post") def test_enumeration_trigger_matches_boottest_boundary(): @@ -1470,7 +1470,7 @@ def test_wild_bootstrap_rank_deficient_storage_vcov_does_not_crash(): warnings.simplefilter("ignore") # expected rank-deficient drop warning res = DifferenceInDifferences( cluster="cluster", inference="wild_bootstrap", n_bootstrap=99, seed=1 - ).fit(df, outcome="outcome", treatment="treated", time="post", fixed_effects=["fe"]) + ).fit(df, outcome="outcome", treatment="treated", post="post", fixed_effects=["fe"]) # ATT identified, bootstrap inference finite, no exception. assert np.isfinite(res.att) assert np.isfinite(res.se) and res.se > 0 diff --git a/tests/test_wooldridge.py b/tests/test_wooldridge.py index 59a79bb2..1a5c4680 100644 --- a/tests/test_wooldridge.py +++ b/tests/test_wooldridge.py @@ -79,7 +79,7 @@ def test_summary_default(self): def test_to_dataframe_event(self): r = _make_minimal_results() - r.aggregate("event") + r.aggregate("event_study") df = r.to_dataframe("event") assert isinstance(df, pd.DataFrame) assert "att" in df.columns @@ -104,7 +104,7 @@ def test_aggregate_calendar(self): def test_aggregate_event(self): r = _make_minimal_results() - r.aggregate("event") + r.aggregate("event_study") assert r.event_study_effects is not None # relative period 0 (treatment period itself) should be present assert 0 in r.event_study_effects or 1 in r.event_study_effects @@ -372,7 +372,7 @@ def test_aggregate_group_keys_match_cohorts(self, fitted): assert set(fitted.group_effects.keys()) == set(fitted.groups) def test_aggregate_event_relative_periods(self, fitted): - fitted.aggregate("event") + fitted.aggregate("event_study") for k in fitted.event_study_effects: assert isinstance(k, (int, np.integer)) @@ -382,11 +382,11 @@ def test_aggregate_calendar_finite(self, fitted): assert np.isfinite(eff["att"]) def test_summary_runs(self, fitted): - s = fitted.summary("simple") + s = fitted.summary() assert "ETWFE" in s or "Wooldridge" in s def test_to_dataframe_event(self, fitted): - fitted.aggregate("event") + fitted.aggregate("event_study") df = fitted.to_dataframe("event") assert "relative_period" in df.columns assert "att" in df.columns @@ -516,7 +516,7 @@ def test_never_treated_produces_event_effects_with_placebo_leads(self): df = load_mpdta() est = WooldridgeDiD(control_group="never_treated") r = est.fit(df, outcome="lemp", unit="countyreal", time="year", first_treat="first_treat") - r.aggregate("event") + r.aggregate("event_study") assert r.event_study_effects is not None assert len(r.event_study_effects) > 0 # never_treated includes pre-treatment interaction indicators, @@ -653,7 +653,7 @@ def test_anticipation_aggregate_semantics(self): df = pd.DataFrame(rows) est = WooldridgeDiD(anticipation=1) r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") - r.aggregate("event").aggregate("group").aggregate("simple") + r.aggregate("event_study").aggregate("group").aggregate("simple") assert np.isfinite(r.overall_att) assert r.event_study_effects is not None assert r.group_effects is not None @@ -797,7 +797,7 @@ def test_sparse_panel_no_crash(self): est = WooldridgeDiD() r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") assert np.isfinite(r.overall_att) - r.aggregate("event") + r.aggregate("event_study") assert r.event_study_effects is not None @@ -819,7 +819,7 @@ def test_logit_on_mpdta(self, mpdta): assert np.isfinite(r.overall_att) assert np.isfinite(r.overall_se) assert r.overall_se > 0 - r.aggregate("event") + r.aggregate("event_study") assert r.event_study_effects is not None def test_poisson_on_mpdta(self, mpdta): @@ -1071,7 +1071,7 @@ def test_event_summary_labels_anticipation_cells(self): df = pd.DataFrame(rows) est = WooldridgeDiD(anticipation=1) r = est.fit(df, outcome="y", unit="unit", time="time", first_treat="cohort") - r.aggregate("event") + r.aggregate("event_study") summary = r.summary("event") # k=-1 should be labeled [antic] (within anticipation window) assert "[antic]" in summary, f"Expected [antic] label in summary, got:\n{summary}" @@ -2092,7 +2092,7 @@ def test_hc2_bm_plus_bootstrap_finite_inference(self): res_analytical.group_time_effects[k]["att"], abs=1e-10 ) # Event-study aggregate also produces finite inference under bootstrap - res_boot.aggregate("event") + res_boot.aggregate("event_study") assert res_boot.event_study_effects is not None for k, eff in res_boot.event_study_effects.items(): assert np.isfinite(eff["att"]) @@ -2324,7 +2324,7 @@ def test_aggregate_event_under_hc2_bm_uses_bm_contrast_dof(self): res = WooldridgeDiD(method="ols", vcov_type="hc2_bm").fit( df, outcome="y", unit="unit", time="time", first_treat="cohort" ) - res.aggregate("event") + res.aggregate("event_study") assert res.event_study_effects is not None for k, eff in res.event_study_effects.items(): assert np.isfinite(eff["att"])