From 886ead065137cfcf44fa6811780f48f993cb62fc Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 19 Oct 2026 10:15:45 -0400 Subject: [PATCH] feat(v4): PR 3 of the 4.0 cut - remove the fit-time aggregate=/balance_e= keywords Spec section 9 "4.0 cut sequence" item 3. Thirteen ledger rows flip terminal under the open cut window (__version__ stays 3.13.0): M-020..M-027 (the eight estimators' fit(aggregate=)), M-117..M-120 (fit(balance_e=) on CS / Imputation / TwoStage / EfficientDiD) and M-139 (did_had_pretest_workflow(aggregate=)). - Passing aggregate= / balance_e= to fit() is a native TypeError; the successor is the post-fit results.aggregate(type, balance_e=). HAD and the pretest workflow select their mode from the panel shape (_infer_aggregate_mode is the sole selector; the HADPretestReport.aggregate FIELD survives). - CS / EfficientDiD compute no event-study/group surface at fit any more (post-fit replay covers bootstrapped fits); their legacy fields are None on every fit. Positional slots shift: survey_design 9->7 (+ EDiD store_eif 10->8); StackedDiD population/survey_design/covariates 7/8/9->6/7/8; ContinuousDiD survey_design/covariates 8/9->7/8; dCDH L_max..survey_design each one left. - Bootstrapped ImputationDiD / TwoStageDiD / ContinuousDiD fits ALWAYS materialize the event-study (+ group) surfaces with the existing fit-time bootstrap code and aggregate('event_study' | 'group') RELAYS them with kit-snapshot provenance (NaN df, no vcov); balance_e on such a fit fails closed (NotImplementedError). Materialization is fail-soft with a one-shot overall-only retry, and surfaces are stored iff the fit carries bootstrap inference, so a plain bootstrapped fit's overall quintet and its success/abort outcome are unchanged; analytic fits are untouched (kit recompute). ContinuousDiD's "eventstudy" spelling dies with the param. - Steering messages, guides, API rst, migration guide, REGISTRY / REPORTING / variance-conventions Notes, TODO rows, benchmarks (fit + post-fit aggregate workload, readers rewired to the containers) and two notebook markdown cells updated; DiagnosticReport / BusinessReport / plot_event_study now run on the relayed surfaces of bootstrapped relay-adopter fits. - Tests: tests/test_aggregate_contract.py converted from fit-time-reference parity to committed per-backend numeric oracles (tests/fixtures/v4_aggregate_oracles.json, captured on the unmodified tree by tests/_capture_v4_aggregate_oracles.py), removal pins, relay / storage rule / failure-injection pins; ~45 suites migrated to the containers via tests/helpers/legacy_surface.py; naming-guard allowlist and guide pins updated; warn_deprecated_kwarg keeps a unit pin. --- TODO.md | 4 +- benchmarks/python/benchmark_callaway.py | 41 +- benchmarks/python/benchmark_imputation.py | 25 +- benchmarks/python/benchmark_stacked_did.py | 3 +- benchmarks/speed_review/bench_brfss_panel.py | 16 +- benchmarks/speed_review/bench_callaway.py | 7 +- .../speed_review/bench_campaign_staggered.py | 30 +- .../speed_review/bench_memory_scaling.py | 1 - .../speed_review/bench_solve_ols_fastpath.py | 5 +- benchmarks/speed_review/validate_results.py | 50 +- .../20261019-v4-pr3-aggregate-removals.md | 91 + diff_diff/_deprecation.py | 8 +- diff_diff/_reporting_helpers.py | 20 +- diff_diff/aggregation.py | 22 +- diff_diff/chaisemartin_dhaultfoeuille.py | 45 +- diff_diff/continuous_did.py | 211 +- diff_diff/continuous_did_aggregation.py | 21 +- diff_diff/continuous_did_results.py | 57 +- diff_diff/diagnostic_report.py | 59 +- diff_diff/efficient_did.py | 163 +- diff_diff/efficient_did_aggregation.py | 5 +- diff_diff/efficient_did_results.py | 24 +- diff_diff/guides/llms-autonomous.txt | 2 +- diff_diff/guides/llms-full.txt | 88 +- diff_diff/guides/llms-practitioner.txt | 38 +- diff_diff/guides/llms.txt | 2 +- diff_diff/had.py | 120 +- diff_diff/had_pretests.py | 107 +- diff_diff/honest_did.py | 4 +- diff_diff/imputation.py | 343 +- diff_diff/imputation_aggregation.py | 9 +- diff_diff/imputation_results.py | 91 +- diff_diff/practitioner.py | 44 +- diff_diff/pretrends.py | 5 +- diff_diff/results_base.py | 15 +- diff_diff/stacked_did.py | 66 +- diff_diff/staggered.py | 179 +- diff_diff/staggered_results.py | 8 +- diff_diff/two_stage.py | 261 +- diff_diff/two_stage_aggregation.py | 4 +- diff_diff/two_stage_results.py | 89 +- diff_diff/visualization/_event_study.py | 4 +- docs/api/continuous_did.rst | 8 +- docs/api/had.rst | 4 +- docs/methodology/REGISTRY.md | 65 +- docs/methodology/REPORTING.md | 32 +- docs/methodology/variance-conventions.md | 7 +- docs/migration-4.0.md | 56 +- docs/performance-plan.md | 10 +- docs/performance-scenarios.md | 16 +- docs/troubleshooting.rst | 2 +- docs/tutorials/02_staggered_did.ipynb | 2 +- docs/tutorials/14_continuous_did.ipynb | 8 +- docs/v4-deprecations.yaml | 56 +- docs/v4-design.md | 49 +- tests/_capture_v4_aggregate_oracles.py | 444 + tests/fixtures/README.md | 26 + tests/fixtures/v4_aggregate_oracles.json | 10017 ++++++++++++++++ tests/helpers/legacy_surface.py | 179 + tests/test_aggregate_contract.py | 3266 +++-- tests/test_bootstrap_chunking.py | 89 +- tests/test_br_dr_canonical_datasets.py | 5 - tests/test_business_report.py | 32 +- tests/test_chaisemartin_dhaultfoeuille.py | 39 +- tests/test_continuous_did.py | 105 +- tests/test_csdid_ported.py | 213 +- tests/test_diagnostic_report.py | 184 +- tests/test_did_had_parity.py | 2 +- tests/test_efficient_did.py | 264 +- tests/test_efficient_did_validation.py | 52 +- tests/test_event_study_consumers.py | 50 +- tests/test_event_study_surface.py | 145 +- tests/test_guides.py | 95 +- tests/test_had.py | 144 +- tests/test_had_dual_knob_deprecation.py | 11 +- tests/test_had_pretests.py | 33 +- tests/test_honest_did.py | 174 +- tests/test_imputation.py | 490 +- tests/test_imputation_loo_stata_parity.py | 54 +- tests/test_lpdid.py | 25 +- tests/test_methodology_callaway.py | 170 +- tests/test_methodology_had.py | 2 +- tests/test_methodology_imputation.py | 147 +- tests/test_methodology_two_stage.py | 124 +- tests/test_naming_guard.py | 44 +- tests/test_practitioner.py | 16 +- tests/test_pretrends.py | 11 +- tests/test_pretrends_event_study.py | 173 +- tests/test_replicate_weight_expansion.py | 38 +- tests/test_se_accuracy.py | 2 - tests/test_stacked_did.py | 79 +- tests/test_staggered.py | 330 +- tests/test_staggered_aggregation.py | 53 +- tests/test_staggered_rc.py | 23 +- tests/test_sun_abraham.py | 21 +- tests/test_survey_phase3.py | 17 +- tests/test_survey_phase4.py | 99 +- tests/test_survey_phase7a.py | 26 +- tests/test_survey_phase8.py | 124 +- tests/test_survey_r_crossvalidation.py | 3 - tests/test_two_stage.py | 338 +- tests/test_v4_rename_mechanical.py | 9 + tests/test_variance_conventions.py | 13 +- tests/test_visualization.py | 131 +- 104 files changed, 16206 insertions(+), 4627 deletions(-) create mode 100644 changelog.d/20261019-v4-pr3-aggregate-removals.md create mode 100644 tests/_capture_v4_aggregate_oracles.py create mode 100644 tests/fixtures/v4_aggregate_oracles.json create mode 100644 tests/helpers/legacy_surface.py diff --git a/TODO.md b/TODO.md index 6f36089aa..2dbfa48ac 100644 --- a/TODO.md +++ b/TODO.md @@ -37,8 +37,8 @@ Related tracking surfaces: | Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | `diff_diff/twfe.py`, `diff_diff/estimators.py`, `diff_diff/utils.py` | 3(a) R5 | Mid | Low | | Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE `event_study=True` + MultiPeriodDiD through 3.9): an optional `first_treat=`/`cohort=` column so simultaneous adoption becomes checkable under the contract-valid time-invariant `D_i` indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying `D_it` input, and with valid `D_i` adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal | `diff_diff/twfe.py`, `diff_diff/estimators.py` | 3(a) R2 | Mid | Medium | | CS parity with DMLDiD's replicate conventions: per-cell replicate SEs for `CallawaySantAnna` (CS leaves per-cell SEs on the weighted sqrt-sum under replicate designs; DMLDiD now computes them via `compute_replicate_if_variance`) AND flip the replace-style effective-df relays to min-cap (`min(df_survey, n_valid - 1)`) at all five sites: CS overall (`staggered.py` overall relay), the shared event-study/group aggregation sites (`staggered_aggregation.py` `df_survey_val = min(non_none_dfs)` x2), and the staggered-DDD engine twins (`_staggered_triple_diff_engine.py` overall + event-study) — the replace convention can RAISE df above the QR-rank design df (anti-conservative); latent, fires only when `n_valid < R` (all-zero/non-finite replicate column). DMLDiD's min-cap + the pinned inherited ES/group behavior are the templates (REGISTRY DMLDiD replicate Note) | `diff_diff/staggered.py`, `diff_diff/staggered_aggregation.py`, `diff_diff/_staggered_triple_diff_engine.py` | DML replicate PR | Mid | Low | -| ImputationDiD/TwoStageDiD `aggregate()` recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low | -| ContinuousDiD `aggregate('event_study')` on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low | +| `balance_e` replay on bootstrapped ImputationDiD/TwoStageDiD fits: the unrestricted ES/group surface is relayed since 4.0 PR 3 (computed at fit, `balance_e=None`; M-021/M-022), but `aggregate('event_study', balance_e=k)` still raises there - a balanced-window replay needs retained per-target psi (ImputationDiD: the panel-backed kit retains everything the psi precompute reads, so a seeded replay is tractable) / retained per-level GMM scores (TwoStageDiD: function-locals today) | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low | +| ContinuousDiD post-fit bootstrap-ES REPLAY: since 4.0 PR 3 a bootstrapped fit relays the event-study surface computed at fit (M-025), so the gap is a genuine post-fit replay (fresh draws, no fit-side cost, a route for pre-4.0 pickles); a seeded replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low | | practitioner `step_name="heterogeneity"` producer-side collisions: three OTHER estimators' advice steps reuse the key with non-heterogeneity labels (`:975` ContinuousDiD dose-response, `:1022` Triple placebo-group, `:1413` LPDiD WAS arrays), so DiagnosticReport's heterogeneity completion silently drops that unrelated advice from `next_steps` via `_filter_steps` - the same latent collision fixed for StackedDiD in M-024 (renamed to `sub_experiment_balance`). Renaming these changes those estimators' report output; audit + rename with per-estimator pins. | `diff_diff/practitioner.py` | 2(b) PR-2 review R9 | Quick | Low | | PreTrendsPower `violation='linear'` on CS `base_period='varying'` input targets the wrong alternative: `δ_pre = M · \|t\|` assumes level coefficients against a common reference, but varying-base pre-treatment effects are consecutive-period comparisons (constant increments under a linear trend). Both CS-sourced routes now WARN (REGISTRY PreTrendsPower Note), and universal-base GAPPED grids fail closed via the `reference_event_times` common-reference guard; what remains is the varying-base resolution - either transforming the violation vector through each coefficient's actual base mapping (needs per-horizon base provenance) or requiring `base_period='universal'` for the linear benchmark - a per-estimator methodology decision with a hand-calculated linear-violation gate | `diff_diff/pretrends.py` | 2(b) PR-1 R5 | Mid | Medium | | `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 | diff --git a/benchmarks/python/benchmark_callaway.py b/benchmarks/python/benchmark_callaway.py index 5fb959d82..38ef8536b 100644 --- a/benchmarks/python/benchmark_callaway.py +++ b/benchmarks/python/benchmark_callaway.py @@ -105,7 +105,6 @@ def main(): time="time", unit="unit", first_treat="first_treat", - aggregate="all", ) cs = CallawaySantAnna( @@ -122,8 +121,12 @@ def main(): time="time", unit="unit", first_treat="first_treat", - aggregate="all", # Get event study and group aggregations ) + # Post-fit aggregations stay inside the timed region: the R `aggte` + # yardstick is fit + simple/dynamic/group aggregation (the 3.x + # fit(aggregate="all") route, removed at 4.0). + es = results.aggregate("event_study") + grp = results.aggregate("group") estimation_time = estimation_timer.elapsed total_time = estimation_time @@ -145,25 +148,25 @@ def main(): } ) - # Format event study effects (if available) + # Format event study effects from the post-fit container: one record per + # row INCLUDING the reference row (the legacy dict carried it too); + # run_refresh.py joins these records against R on `event_time`. es_effects = [] - if results.event_study_effects: - for rel_t, effect_data in sorted(results.event_study_effects.items()): - es_effects.append({ - "event_time": int(rel_t), - "att": float(effect_data["effect"]), - "se": float(effect_data["se"]), - }) - - # Format group effects (if available) + for row in es.to_dataframe().sort_values("event_time").itertuples(index=False): + es_effects.append({ + "event_time": int(row.event_time), + "att": float(row.att), + "se": float(row.se), + }) + + # Format group effects from the post-fit container (joined on `group`). grp_effects = [] - if results.group_effects: - for g, effect_data in sorted(results.group_effects.items()): - grp_effects.append({ - "group": int(g), - "att": float(effect_data["effect"]), - "se": float(effect_data["se"]), - }) + for row in grp.to_dataframe().sort_values("label").itertuples(index=False): + grp_effects.append({ + "group": int(row.label), + "att": float(row.att), + "se": float(row.se), + }) # Build output output = { diff --git a/benchmarks/python/benchmark_imputation.py b/benchmarks/python/benchmark_imputation.py index 27f556fed..b71540b4f 100644 --- a/benchmarks/python/benchmark_imputation.py +++ b/benchmarks/python/benchmark_imputation.py @@ -79,8 +79,10 @@ def main(): time="time", unit="unit", first_treat="first_treat", - aggregate="event_study", ) + # Post-fit event study stays inside the timed region (the 3.x + # fit(aggregate="event_study") route, removed at 4.0). + es = results.aggregate("event_study") estimation_time = estimation_timer.elapsed total_time = estimation_time @@ -90,18 +92,17 @@ def main(): n_periods = len(df["time"].unique()) n_obs = len(df) - # Format event study effects (if available) + # Format event study effects from the post-fit container, skipping the + # reference-period marker row exactly as the legacy reader did. es_effects = [] - if results.event_study_effects: - for rel_t, effect_data in sorted(results.event_study_effects.items()): - # Skip reference period marker (n_obs == 0) - if effect_data.get("n_obs", 1) == 0: - continue - es_effects.append({ - "event_time": int(rel_t), - "att": float(effect_data["effect"]), - "se": float(effect_data["se"]), - }) + for row in es.to_dataframe().sort_values("event_time").itertuples(index=False): + if bool(row.is_reference): + continue + es_effects.append({ + "event_time": int(row.event_time), + "att": float(row.att), + "se": float(row.se), + }) # Build output output = { diff --git a/benchmarks/python/benchmark_stacked_did.py b/benchmarks/python/benchmark_stacked_did.py index e070005ef..cd9cd16a7 100644 --- a/benchmarks/python/benchmark_stacked_did.py +++ b/benchmarks/python/benchmark_stacked_did.py @@ -96,8 +96,7 @@ def main(): ) # The event-study surface is always computed at fit (3.9, row M-024); - # the deprecated aggregate= kwarg would emit a FutureWarning inside - # the timed block and contaminate the measurement. + # fit() takes no aggregate= kwarg since 4.0. with Timer() as estimation_timer: results = est.fit( df, diff --git a/benchmarks/speed_review/bench_brfss_panel.py b/benchmarks/speed_review/bench_brfss_panel.py index 412401c86..69b0c1687 100644 --- a/benchmarks/speed_review/bench_brfss_panel.py +++ b/benchmarks/speed_review/bench_brfss_panel.py @@ -93,20 +93,28 @@ def cs_fit(): results["cs"] = cs.fit( results["panel"], outcome="y_mean", unit="state", time="year", first_treat="first_treat", - survey_design=results["stage2"], aggregate="all", + survey_design=results["stage2"], ) + # Materialize BOTH post-fit containers ONCE inside the timed fit + # phase (the 3.x fit(aggregate="all") workload = fit + event-study + # + group aggregation): the fit is bootstrapped, so every + # aggregate() call replays the bootstrap. + results["cs_es"] = results["cs"].aggregate("event_study") + results["cs_grp"] = results["cs"].aggregate("group") def inspect_pretrends(): - es = results["cs"].event_study_effects or {} + es = results["cs_es"].to_dataframe() + pre = es[es["event_time"] < 0] results["pretrends"] = { - rel_t: eff for rel_t, eff in es.items() if rel_t < 0 + int(r.event_time): {"effect": float(r.att), "se": float(r.se)} + for r in pre.itertuples(index=False) } def honest_grid(): out = {} for M in (0.5, 1.0, 1.5): out[M] = compute_honest_did( - results["cs"], method="relative_magnitude", M=M, + results["cs_es"], method="relative_magnitude", M=M, ) results["honest"] = out diff --git a/benchmarks/speed_review/bench_callaway.py b/benchmarks/speed_review/bench_callaway.py index 033daacb7..55a32a7d2 100644 --- a/benchmarks/speed_review/bench_callaway.py +++ b/benchmarks/speed_review/bench_callaway.py @@ -57,15 +57,18 @@ def bench_fit(n_units, n_bootstrap=0, covariates=None, n_cohorts=5, n_runs=3, times = [] for _ in range(n_runs): start = time.perf_counter() - cs.fit( + res = cs.fit( df, outcome="outcome", unit="unit", time="time", first_treat="first_treat", covariates=covariates, - aggregate="all", ) + # Keep "fit + aggregations" as the measured work (the 3.x + # fit(aggregate="all") route, removed at 4.0). + res.aggregate("event_study") + res.aggregate("group") elapsed = time.perf_counter() - start times.append(elapsed) diff --git a/benchmarks/speed_review/bench_campaign_staggered.py b/benchmarks/speed_review/bench_campaign_staggered.py index 1e213858b..a74a831b2 100644 --- a/benchmarks/speed_review/bench_campaign_staggered.py +++ b/benchmarks/speed_review/bench_campaign_staggered.py @@ -1,7 +1,7 @@ """ Scenario 1: Staggered marketing campaign. -CallawaySantAnna with covariates + bootstrap + aggregate='all', wrapped in +CallawaySantAnna with covariates + bootstrap + post-fit aggregate(), wrapped in the 8-step Baker workflow: Bacon -> CS fit -> event-study pre-trend inspection -> HonestDiD M-grid -> SunAbraham + ImputationDiD robustness -> with/without-covariates refit -> practitioner_next_steps. @@ -64,21 +64,27 @@ def cs_fit(): control_group="never_treated", estimation_method="dr", cluster="unit", n_bootstrap=999, seed=123, ) - results["cs"] = cs.fit( - **fit_kwargs, covariates=covars, aggregate="all", - ) + results["cs"] = cs.fit(**fit_kwargs, covariates=covars) + # Materialize BOTH post-fit containers ONCE inside the timed fit + # phase (the 3.x fit(aggregate="all") workload = fit + event-study + # + group aggregation) and reuse them below: the fit has + # n_bootstrap=999, so every aggregate() call replays the bootstrap. + results["cs_es"] = results["cs"].aggregate("event_study") + results["cs_grp"] = results["cs"].aggregate("group") def inspect_pretrends(): - es = results["cs"].event_study_effects or {} + es = results["cs_es"].to_dataframe() + pre = es[es["event_time"] < 0] results["pretrends"] = { - rel_t: eff for rel_t, eff in es.items() if rel_t < 0 + int(r.event_time): {"effect": float(r.att), "se": float(r.se)} + for r in pre.itertuples(index=False) } def honest_did_grid(): out = {} for M in (0.5, 1.0, 1.5, 2.0): out[M] = compute_honest_did( - results["cs"], method="relative_magnitude", M=M, + results["cs_es"], method="relative_magnitude", M=M, ) results["honest"] = out @@ -88,7 +94,8 @@ def sun_abraham(): def imputation(): bjs = ImputationDiD(cluster="unit") - results["bjs"] = bjs.fit(**fit_kwargs, aggregate="event_study") + results["bjs"] = bjs.fit(**fit_kwargs) + results["bjs_es"] = results["bjs"].aggregate("event_study") def cs_no_covariates(): # Match phase 2's estimator config exactly; the only axis that @@ -99,7 +106,10 @@ def cs_no_covariates(): control_group="never_treated", estimation_method="dr", cluster="unit", n_bootstrap=999, seed=123, ) - results["cs_nocov"] = cs.fit(**fit_kwargs, aggregate="all") + results["cs_nocov"] = cs.fit(**fit_kwargs) + # Same fit + event-study + group workload as the covariate arm. + results["cs_nocov_es"] = results["cs_nocov"].aggregate("event_study") + results["cs_nocov_grp"] = results["cs_nocov"].aggregate("group") def next_steps(): results["guidance"] = practitioner_next_steps(results["cs"]) @@ -137,7 +147,7 @@ def run_scale(scale, config): "n_obs": int(len(data)), "covariates": covars, "n_bootstrap": 999, - "aggregate": "all", + "aggregate": "fit + post-fit aggregate", "estimation_method": "dr", }, ) diff --git a/benchmarks/speed_review/bench_memory_scaling.py b/benchmarks/speed_review/bench_memory_scaling.py index 6e0e4ced8..150e67885 100644 --- a/benchmarks/speed_review/bench_memory_scaling.py +++ b/benchmarks/speed_review/bench_memory_scaling.py @@ -128,7 +128,6 @@ def _run_one(cfg): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), ) elif est == "twfe": diff --git a/benchmarks/speed_review/bench_solve_ols_fastpath.py b/benchmarks/speed_review/bench_solve_ols_fastpath.py index 3c668b475..1376daca9 100644 --- a/benchmarks/speed_review/bench_solve_ols_fastpath.py +++ b/benchmarks/speed_review/bench_solve_ols_fastpath.py @@ -124,8 +124,11 @@ def fit(d): time="time", first_treat="first_treat", covariates=covs, - aggregate="all", ) + # fit + aggregations is the measured work (the 3.x + # fit(aggregate="all") route, removed at 4.0). + res.aggregate("event_study") + res.aggregate("group") return float(res.overall_att), float(res.overall_se) return df, fit diff --git a/benchmarks/speed_review/validate_results.py b/benchmarks/speed_review/validate_results.py index 9578a907e..7965efc6e 100644 --- a/benchmarks/speed_review/validate_results.py +++ b/benchmarks/speed_review/validate_results.py @@ -64,8 +64,11 @@ def run_estimator(df, estimation_method="reg", covariates=None, control_group="n time="time", first_treat="first_treat", covariates=covariates, - aggregate="all", ) + # Post-fit containers (the 3.x fit(aggregate="all") route, removed at 4.0); + # the fit is bootstrapped, so each call replays the fit-time bootstrap. + es_container = results.aggregate("event_study") + grp_container = results.aggregate("group") out = { "overall_att": float(results.overall_att), @@ -87,31 +90,30 @@ def run_estimator(df, estimation_method="reg", covariates=None, control_group="n } out["group_time_effects"] = gt_effects - # Event study - if results.event_study_effects: - es = {} - for e, data in sorted(results.event_study_effects.items()): - es[str(e)] = { - "effect": float(data["effect"]), - "se": float(data["se"]), - "t_stat": float(data["t_stat"]), - "p_value": float(data["p_value"]), - "conf_int": [float(data["conf_int"][0]), float(data["conf_int"][1])], - } - out["event_study"] = es + # Event study (same keyed shape as the legacy dict, one record per + # container row including the reference row) + es = {} + for row in es_container.to_dataframe().sort_values("event_time").itertuples(index=False): + es[str(int(row.event_time))] = { + "effect": float(row.att), + "se": float(row.se), + "t_stat": float(row.t_stat), + "p_value": float(row.p_value), + "conf_int": [float(row.conf_int_lower), float(row.conf_int_upper)], + } + out["event_study"] = es # Group effects - if results.group_effects: - ge = {} - for g_key, data in sorted(results.group_effects.items()): - ge[str(g_key)] = { - "effect": float(data["effect"]), - "se": float(data["se"]), - "t_stat": float(data["t_stat"]), - "p_value": float(data["p_value"]), - "conf_int": [float(data["conf_int"][0]), float(data["conf_int"][1])], - } - out["group_effects"] = ge + ge = {} + for row in grp_container.to_dataframe().sort_values("label").itertuples(index=False): + ge[str(row.label)] = { + "effect": float(row.att), + "se": float(row.se), + "t_stat": float(row.t_stat), + "p_value": float(row.p_value), + "conf_int": [float(row.conf_int_lower), float(row.conf_int_upper)], + } + out["group_effects"] = ge return out diff --git a/changelog.d/20261019-v4-pr3-aggregate-removals.md b/changelog.d/20261019-v4-pr3-aggregate-removals.md new file mode 100644 index 000000000..33fc4faf2 --- /dev/null +++ b/changelog.d/20261019-v4-pr3-aggregate-removals.md @@ -0,0 +1,91 @@ +### Removed +- **The fit-time `aggregate=` / `balance_e=` keywords are gone (ledger rows flipped to + `removed`).** `fit(aggregate=)` is removed from `CallawaySantAnna` (`M-020`), `ImputationDiD` + (`M-021`), `TwoStageDiD` (`M-022`), `EfficientDiD` (`M-023`), `StackedDiD` (`M-024`), + `ContinuousDiD` (`M-025`), `ChaisemartinDHaultfoeuille` (`M-026`) and + `HeterogeneousAdoptionDiD` (`M-027`), `fit(balance_e=)` from `CallawaySantAnna` (`M-117`), + `ImputationDiD` (`M-118`), `TwoStageDiD` (`M-119`) and `EfficientDiD` (`M-120`), and + `aggregate=` from `did_had_pretest_workflow` (`M-139`; the `HADPretestReport.aggregate` + output field survives). The successor everywhere is post-fit + `results.aggregate(type, balance_e=)`; HAD selects its mode from the panel shape. With the + keywords go ContinuousDiD's no-underscore `"eventstudy"` spelling, StackedDiD's fit-time + `group`/`all`/unknown-value `ValueError`s (`aggregate('group')` already fails closed in the + mixin), and the private `_VALID_AGGREGATES` sets in `had.py` / `had_pretests.py` + (`_infer_aggregate_mode` is the sole selector). +- **CallawaySantAnna and EfficientDiD compute no event-study/group surface at fit.** Their + legacy `event_study_effects` / `group_effects` fields - and CS's `cband_crit_value`, + `event_study_vcov`, `event_study_vcov_index`, `event_study_df` - are `None` after EVERY fit; + the post-fit replay (`aggregate('event_study'|'group', balance_e=)`) is the only bootstrap + route and is unchanged. + +### Breaking Changes +- Passing `aggregate=` or `balance_e=` to any of the eight `fit()` methods (or `aggregate=` to + `did_had_pretest_workflow`) now raises Python's native + `TypeError: ... unexpected keyword argument`. `TripleDifference`'s staggered-mode + `aggregate=`/`balance_e=` are untouched (`M-140`/`M-141`, canonical until the DDD container + unification). +- **Positional slots after the removed parameters shift left.** `CallawaySantAnna`, + `ImputationDiD`, `TwoStageDiD`: `survey_design` 9 -> 7; `EfficientDiD`: `survey_design` + 9 -> 7, `store_eif` 10 -> 8; `StackedDiD` (`aggregate` sat before them): `population`, + `survey_design`, `covariates` 7/8/9 -> 6/7/8; `ContinuousDiD`: `survey_design`, `covariates` + 8/9 -> 7/8; `ChaisemartinDHaultfoeuille`: `L_max`, `covariates`, `trends_linear`, + `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, `survey_design` each move one + slot left (7..14 -> 6..13). `HeterogeneousAdoptionDiD.fit` shifts nothing (everything after the + removed slot is keyword-only; a seventh positional now raises). Pass these by keyword. +- `CallawaySantAnnaResults.to_dataframe(level="event_study"|"group")` and the `EfficientDiDResults` + twins raise on every fresh fit (the legacy fields are always `None`); use + `results.aggregate(...).to_dataframe()`. The column-schema rewire of these levels onto the + container is `M-093`'s (PR 8). + +### Changed +- **Bootstrapped `ImputationDiD` / `TwoStageDiD` / `ContinuousDiD` fits relay their event-study + (and, on Imputation/TwoStage, group) surface.** The deprecated keyword was the only route to + bootstrap event-study inference there (no per-draw state is retained for a replay), so a fit + with `n_bootstrap > 0` now ALWAYS computes those surfaces at fit - exactly what + `fit(aggregate="all")` computed, `balance_e=None` - stores them in the legacy fields, and + `aggregate('event_study')` / `aggregate('group')` RELAYS them (kit-snapshot alpha / + anticipation / survey metadata; NaN `df` column; `vcov` `None`) instead of raising + `NotImplementedError`. Analytic fits are unchanged (legacy fields `None`, kit recompute on + demand). `aggregate('event_study', balance_e=k)` on such a bootstrapped fit raises + `NotImplementedError` (re-fit with `n_bootstrap=0`). Storage rule: the legacy fields hold a + surface IFF the fit carries bootstrap inference - a bootstrapped fit that ends analytical + (zero valid draws, total bootstrap failure) clears them and `aggregate()` recomputes + analytically, while a fit WITH bootstrap inference and no stored surface (a 3.9-3.13 pickle, + a fail-soft drop) raises a `ValueError` naming the refit. Known limitation: a 3.9-3.13 + bootstrapped pickle fitted with `balance_e=k` stores a restricted surface and the relay + returns it as stored. + - Materialization is FAIL-SOFT with a one-shot overall-only retry: an exception while + building the surfaces, or a bootstrap attempt that raises / returns `None` with surfaces + present, leaves them `None`, emits one `UserWarning` ("... could not be materialized ...") + and re-runs the bootstrap overall-only (a seeded fit reproduces the first attempt's weight + draws), so a plain bootstrapped fit's headline quintet and its success/abort outcome are + unchanged by the removal. ContinuousDiD bootstrap failures still propagate as before. + - Consequences on bootstrapped plain fits: the aggregators' own `UserWarning`s (ImputationDiD + Prop-5 unidentified horizons, empty horizons, LOO single-unit auxiliaries; TwoStageDiD + Stage-1/Stage-2 singular or rank-deficient fallbacks) and the per-target bootstrap-stats + `RuntimeWarning`s now fire where a plain fit never emitted them; `summary()` prints the + event-study/group tables and `to_dataframe(level="event_study"|"group")` returns frames + instead of raising; `DiagnosticReport` / `BusinessReport` run heterogeneity (Imputation / + TwoStage on the relayed group dict, ContinuousDiD on the event study) and parallel trends + (Imputation / TwoStage) on the relayed surface instead of skipping with + `NotImplementedError`; `plot_event_study(results)` plots them natively. Measured cost (199 + draws, 300 units x 12 periods): Imputation 0.01s -> 0.08s, TwoStage 0.01s -> 0.04s, + Continuous unchanged at 0.05s; all three default to `n_bootstrap=0`. +- `ImputationDiD`'s inert-`df_convention` warning predicate is now exact: the per-lead inference + is reachable iff `pretrends=True` and the design is not replicate-weighted (`M-127`); the + unreachable pretrends+replicate fit-time gate is deleted (the combination is still rejected + post-fit by `aggregate('event_study')`). +- Runtime messages, `practitioner_next_steps` advice, the four `llms*.txt` guides, the API + docs, the migration guide and the benchmarks no longer steer users to the fit-time route; + `CallawaySantAnna`'s class docstring, `plot_event_study`, `honest_did` / `pretrends` and the + results-class "not stored" messages point at `results.aggregate('event_study')`. + +### Internal +- Per-backend (NumPy / Rust weight stream) oracle pins for the removed route live in + `tests/test_aggregate_contract.py` (captured from the unmodified tree by + `tests/_capture_v4_aggregate_oracles.py`), with failure-injection pins for the fail-soft + materialization; `tests/helpers/legacy_surface.py` provides `es_dict` / `group_dict` / + `legacy_standin` for the migrated consumer tests. Naming-guard allowlist entries for the + guides' canonical post-fit `aggregate` vocabulary. Ledger row `M-093` gains the fit-side + table of which producers populate the legacy fields. `warn_deprecated_kwarg` loses its last + library caller and is retained (spec section 8 rule 11) with a direct unit pin. diff --git a/diff_diff/_deprecation.py b/diff_diff/_deprecation.py index 1894feed0..f00e65e99 100644 --- a/diff_diff/_deprecation.py +++ b/diff_diff/_deprecation.py @@ -16,9 +16,11 @@ default. ``resolve_renamed_kwarg`` maps old to new with a single ``FutureWarning``, rejects both-supplied calls loudly, and returns the caller-declared default when neither is passed. -- Dropped parameter (no successor name, e.g. the fit-time ``aggregate=`` - override): the parameter keeps its position with a sentinel default; the - owner calls ``warn_deprecated_kwarg`` when a value was actually supplied. +- Dropped parameter (no successor name): the parameter keeps its position + with a sentinel default; the owner calls ``warn_deprecated_kwarg`` when a + value was actually supplied. (The 3.9 fit-time ``aggregate=`` shims were + this shape; they were deleted at the 4.0 cut and the helper currently has + no library caller - it is retained for the 5.0 window.) - Renamed results field: the dataclass field takes the new name and ``deprecated_field_property`` builds the read-only warning alias under the old name (a plain class attribute, so it is a descriptor but NOT a diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index 9bdc69b84..58b160b59 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -50,17 +50,17 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: - ``ImputationDiD`` / ``TwoStageDiD``: horizon / group tables come from post-fit ``results.aggregate('event_study'/'group')`` since - 3.9 (the fit-time ``aggregate`` kwarg is deprecated) and never + 3.9 (the fit-time ``aggregate`` kwarg was removed at 4.0) and never change ``overall_att``. The headline is always the sample-mean overall ATT (per BJS 2024 Step 3 with ``w_it = 1/N_1``); disambiguate via the event-study or group aggregate if you need the horizon / group target. - ``CallawaySantAnna``: ``overall_att`` is cohort-size-weighted - across post-treatment ``ATT(g, t)`` cells regardless of the - fit-time ``aggregate`` kwarg. The event-study / group tables are - produced post-fit via ``results.aggregate('event_study'/'group')`` - (the deprecated fit-time ``aggregate=`` kwarg populates the legacy - ``event_study_effects`` / ``group_effects`` fields until 4.0). + across post-treatment ``ATT(g, t)`` cells regardless of any + aggregation. The event-study / group tables are produced post-fit + via ``results.aggregate('event_study'/'group')``; the legacy + ``event_study_effects`` / ``group_effects`` fields are None on + every CallawaySantAnna fit since 4.0. - ``ContinuousDiD``: the regime (PT vs. SPT) is a user-level assumption, not a library setting. The ``definition`` names both regime readings (``ATT^loc`` under PT, @@ -140,9 +140,9 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "``overall_att`` is the simple-aggregation headline regardless " "of aggregation choices; event-study and group tables are " "produced post-fit via " - "``results.aggregate('event_study'/'group')`` (the deprecated " - "fit-time ``aggregate=`` kwarg populates the legacy " - "``event_study_effects`` / ``group_effects`` fields until 4.0)." + "``results.aggregate('event_study'/'group')`` (the legacy " + "``event_study_effects`` / ``group_effects`` fields are None " + "on every CallawaySantAnna fit since 4.0)." ), "aggregation": "simple", "headline_attribute": "overall_att", @@ -250,7 +250,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: "imputed from a unit+time fixed-effects model fitted on " "untreated observations only (BJS 2024 Step 3). Post-fit " "``results.aggregate('event_study'/'group')`` (3.9; the " - "fit-time ``aggregate`` kwarg is deprecated) yields the " + "fit-time ``aggregate`` kwarg was removed at 4.0) yields the " "horizon / group tables and does NOT change ``overall_att`` " "— for the horizon or group estimand, consult the " "event-study or group aggregate directly." diff --git a/diff_diff/aggregation.py b/diff_diff/aggregation.py index 8cc9ab426..456572e28 100644 --- a/diff_diff/aggregation.py +++ b/diff_diff/aggregation.py @@ -1,7 +1,7 @@ """Post-fit aggregation surface (4.0 program Phase 2, spec section 6). -``results.aggregate(type=...)`` replaces the fit-time ``fit(aggregate=)`` -argument (ledger rows M-020..M-027): estimate once, aggregate as a post-fit +``results.aggregate(type=...)`` is the one aggregation surface - the fit-time +``aggregate=`` keyword was removed at 4.0 (ledger rows M-020..M-027): estimate once, aggregate as a post-fit step - the ecosystem's strongest norm (``did::aggte``, ``etwfe::emfx``, Stata ``estat aggregation``). @@ -72,6 +72,24 @@ ) +def absent_bootstrap_surface_message(cls_name: str, level: str) -> str: + """Shared ValueError text for a bootstrapped fit storing no relay surface. + + Bootstrapped ImputationDiD / TwoStageDiD / ContinuousDiD fits materialize + their event-study (and group) surfaces at fit and ``aggregate()`` RELAYS + them (4.0 PR 3). A fit WITH bootstrap inference but WITHOUT the stored + surface is either a pre-4.0 pickle (a bootstrapped plain fit built none) + or a fit whose fail-soft materialization dropped it; both raise this. + """ + return ( + f"This bootstrapped {cls_name} carries no stored {level} surface: either it " + "was fitted before 4.0 (a bootstrapped plain fit built none) or the surface " + "could not be materialized at fit (see the fit-time warning). Re-fit with " + "diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the " + "analytical post-fit recompute." + ) + + def resolve_inference_df(results: Any) -> Optional[float]: """Return the degrees of freedom that governed ``results``' overall statistic. diff --git a/diff_diff/chaisemartin_dhaultfoeuille.py b/diff_diff/chaisemartin_dhaultfoeuille.py index dd57fee23..8594fdecb 100644 --- a/diff_diff/chaisemartin_dhaultfoeuille.py +++ b/diff_diff/chaisemartin_dhaultfoeuille.py @@ -37,7 +37,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED, warn_deprecated_kwarg from diff_diff.chaisemartin_dhaultfoeuille_bootstrap import ( ChaisemartinDHaultfoeuilleBootstrapMixin, ) @@ -401,8 +400,8 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin, BaseE - TWFE decomposition diagnostic from Theorem 1 of AER 2020 Aggregation is a post-fit step: ``results.aggregate('event_study')`` / - ``results.aggregate('simple')`` (the deprecated fit-time ``aggregate=`` - never computed anything and now warns; row M-026). + ``results.aggregate('simple')``; ``fit()`` takes no aggregation + keyword. Parameters ---------- @@ -878,7 +877,6 @@ def fit( time: str, treatment: str, # ---------- forward-compat parameters ---------- - aggregate: Any = NOT_SUPPLIED, L_max: Optional[int] = None, covariates: Optional[List[str]] = None, trends_linear: Optional[bool] = None, @@ -914,15 +912,6 @@ def fit( within each ``(unit, time)`` cell. Both binary ``{0, 1}`` and non-binary (ordinal or continuous) treatment are supported. Non-binary treatment requires ``L_max >= 1``. - aggregate : str, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-026). - Passing it emits a ``FutureWarning``. The parameter never - computed aggregations here (it was reserved and any non-None - value has always raised); aggregation is a POST-FIT step: - ``results.aggregate('event_study')`` for the multi-horizon - surface, ``results.aggregate('simple')`` for the overall - estimand. A non-None value now raises ``ValueError`` pointing - at that post-fit route. L_max : int, optional Maximum event-study horizon. When set, computes ``DID_l`` for ``l = 1, ..., L_max`` using the per-group building block @@ -1063,28 +1052,8 @@ def fit( # ------------------------------------------------------------------ # Step 2: Forward-compat gates # ------------------------------------------------------------------ - # M-026: fit(aggregate=) is deprecated - the aggregation entry point - # is post-fit (results.aggregate()). The param never computed - # anything here (any non-None value has always raised), so the shim - # warns on supply and rejects non-None values with a pointer at the - # post-fit route. - if aggregate is not NOT_SUPPLIED: - warn_deprecated_kwarg( - "ChaisemartinDHaultfoeuille.fit", - "aggregate", - "aggregation is a post-fit step: " - "results.aggregate('event_study') / results.aggregate('simple')", - ) - if aggregate is not None: - raise ValueError( - f"fit(aggregate={aggregate!r}) never computed aggregations " - "on ChaisemartinDHaultfoeuille (the parameter was reserved " - "and always raised). Aggregate post-fit instead: " - "results.aggregate('event_study') for the multi-horizon " - "surface (Phase-1 fits without L_max return the 2-row " - "l=1 view), or results.aggregate('simple') for the " - "overall estimand." - ) + # Aggregation is a post-fit step (results.aggregate('event_study') / + # results.aggregate('simple')); fit() takes no aggregation keyword. _check_forward_compat_gates( L_max=L_max, controls=controls, @@ -4395,10 +4364,8 @@ def _check_forward_compat_gates( """Raise ``NotImplementedError`` for any non-default Phase 3 parameter. Phase 2 parameters (``L_max``) are validated inline in ``fit()`` - after period detection. The deprecated ``aggregate`` parameter is - resolved in ``fit()`` itself (row M-026: FutureWarning on supply, - ValueError on any non-None value, pointing at the post-fit - ``results.aggregate()`` route). + after period detection. Aggregation is a post-fit step + (``results.aggregate()``); ``fit()`` takes no aggregation keyword. """ # L_max is validated inline in fit() after period detection (needs # the period count). Not gated here. diff --git a/diff_diff/continuous_did.py b/diff_diff/continuous_did.py index a4e6e3104..48bc263a0 100644 --- a/diff_diff/continuous_did.py +++ b/diff_diff/continuous_did.py @@ -17,7 +17,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff._dr_scores import drdid_panel_inf_func from diff_diff.aggregation import AggregationKit from diff_diff.bootstrap_utils import ( @@ -101,11 +100,11 @@ def _build_continuous_aggregation_kit( never read the kit at all. On bootstrap fits (``n_bootstrap > 0``) the kit is SCALARS-ONLY: - ``aggregate('event_study')`` fails closed before reading any payload - and the views never read the kit, so a populated payload there would - be pure dead retention. The scalars still distinguish the - bootstrap-NotImplementedError gate from the legacy-pickle no-kit - ValueError. + ``aggregate('event_study')`` relays the fit-time surface (the + percentile event study materialized by ``fit()``) without reading any + payload, and the views never read the kit, so a populated payload + there would be pure dead retention. The scalars still distinguish the + bootstrap relay gate from the legacy-pickle no-kit ValueError. """ is_bootstrap = estimator.n_bootstrap > 0 gt_summary: Dict[Tuple, Dict[str, Any]] = {} @@ -351,7 +350,6 @@ def fit( time: str, first_treat: str, dose: str, - aggregate: Any = NOT_SUPPLIED, survey_design: Optional["SurveyDesign"] = None, covariates: Optional[List[str]] = None, ) -> ContinuousDiDResults: @@ -372,17 +370,6 @@ def fit( First treatment period column (0 or inf for never-treated). dose : str Continuous dose column. - aggregate : str, optional - DEPRECATED (row M-025, removed in 4.0) - aggregate as a - post-fit step instead: ``results.aggregate('event_study')`` - for the binarized event study (underscored - the - ``"eventstudy"`` spelling dies with this parameter), or - ``results.aggregate('dose')`` / ``results.aggregate('simple')`` - views. The dose-response curves and overall ATT/ACRT are - always computed by ``fit()``, so ``aggregate="dose"`` was - already a no-op. Supplying ANY value (including ``None``) - warns ``FutureWarning``; supplied values still run the legacy - routing unchanged until 4.0. survey_design : SurveyDesign, optional Survey design specification for design-based inference. Supports weighted estimation and Taylor series linearization @@ -402,45 +389,13 @@ def fit( ------- ContinuousDiDResults """ - # M-025 deprecation shim: a plain fit() never warns; supplying - # aggregate= with ANY value (None included) warns once, then the - # legacy routing below runs unchanged - "eventstudy" still - # computes the fit-time surface and invalid strings still reach - # the pre-existing ValueError. Only the SENTINEL normalizes to - # None (it would otherwise fail the _VALID_AGGREGATES check on - # every plain fit). The post-fit successor validates its own - # (unified) vocabulary. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "ContinuousDiD.fit(aggregate=) is deprecated and will be " - "removed in 4.0. Fit once, then aggregate as a post-fit " - "step: results = ContinuousDiD().fit(...); " - "results.aggregate('event_study') (note the underscore - " - "the 'eventstudy' spelling dies with this parameter) / " - ".aggregate('dose') / .aggregate('simple'). The " - "dose-response curves and overall ATT/ACRT are always " - "computed by fit(), so aggregate='dose' was already " - "redundant.", - FutureWarning, - stacklevel=2, - ) - else: - aggregate = None - # Fit-time re-check: __init__ and set_params validate eagerly, so # this only catches DIRECT attribute mutation (est.anticipation = ...) # — an out-of-domain value silently changes the ESTIMAND. The - # assignment also re-normalizes a mutated numpy scalar to int. Placed - # AFTER the deprecation shim so a caller who both mutated and passed - # a deprecated argument still sees the FutureWarning before the raise. + # assignment also re-normalizes a mutated numpy scalar to int. self.anticipation = validate_anticipation(self.anticipation) # 1. Validate & prepare - _VALID_AGGREGATES = (None, "dose", "eventstudy") - if aggregate not in _VALID_AGGREGATES: - raise ValueError( - f"Invalid aggregate: '{aggregate}'. " f"Must be one of {_VALID_AGGREGATES}." - ) # Resolve survey design if provided resolved_survey, survey_weights, survey_weight_type, survey_metadata = ( @@ -949,16 +904,32 @@ def fit( att_d_p = None acrt_d_p = None - # Event study aggregation (binarized) — runs on ALL (g,t) cells - event_study_effects = None - if aggregate == "eventstudy": - event_study_effects = self._aggregate_event_study( - gt_results, - gt_bootstrap_info=gt_bootstrap_info, - unit_survey_weights=precomp.get("unit_survey_weights"), - unit_cohorts=precomp["unit_cohorts"], - anticipation=self.anticipation, - ) + # Event study aggregation (binarized) — runs on ALL (g,t) cells. + # Materialized at fit ONLY where the post-fit route cannot recompute + # it: a bootstrapped fit (n_bootstrap > 0) needs the ES targets + # inside the multiplier bootstrap (per-cell draws are not retained; + # the kit is scalars-only), so the surface is built here and RELAYED + # by results.aggregate('event_study'). Analytic fits leave it None + # and recompute from the kit on demand. FAIL-SOFT by design: any + # exception while materializing leaves the surface None (the + # bootstrap then covers the overall/dose targets only - today's + # plain-fit path) and is reported once the bootstrap outcome is + # known (see the storage rule below). + event_study_effects: Optional[Dict[int, Dict[str, Any]]] = None + _surface_failure: Optional[BaseException] = None + _bootstrap_ran = False + if self.n_bootstrap > 0: + try: + event_study_effects = self._aggregate_event_study( + gt_results, + gt_bootstrap_info=gt_bootstrap_info, + unit_survey_weights=precomp.get("unit_survey_weights"), + unit_cohorts=precomp["unit_cohorts"], + anticipation=self.anticipation, + ) + except Exception as exc: # noqa: BLE001 - fail-soft by design + _surface_failure = exc + event_study_effects = None _survey_df = None # Set by analytical branch when survey is active @@ -1047,23 +1018,46 @@ def fit( # 5. Bootstrap / Analytical SE if self.n_bootstrap > 0: - boot_result = self._run_bootstrap( - precomp, - gt_results, - gt_bootstrap_info, - post_gt, - cell_weights, - knots, - degree, - dvals, - overall_att, - overall_acrt, - agg_att_d, - agg_acrt_d, - event_study_effects, - resolved_survey=resolved_survey, - pre_unit_resolved=_unit_resolved_shared, - ) + # The engine draws the full weight matrix from the seeded + # RNG BEFORE any ES work, so the overall/dose draws are the + # same with or without the surface. Attempt 1 runs WITH the + # materialized ES surface; if it raises (anything but + # NotImplementedError, re-raised as today) the surface is + # dropped and the bootstrap is RETRIED ONCE overall-only - + # today's plain-fit path; a retry failure propagates, as + # today. On a seeded fit the retry reproduces attempt 1's + # draws exactly. + def _run_stage(_es: Optional[Dict[int, Dict[str, Any]]]) -> Dict[str, Any]: + return self._run_bootstrap( + precomp, + gt_results, + gt_bootstrap_info, + post_gt, + cell_weights, + knots, + degree, + dvals, + overall_att, + overall_acrt, + agg_att_d, + agg_acrt_d, + _es, + resolved_survey=resolved_survey, + pre_unit_resolved=_unit_resolved_shared, + ) + + if event_study_effects is not None: + try: + boot_result = _run_stage(event_study_effects) + except NotImplementedError: + raise # explicit rejections (replicate designs) propagate + except Exception as exc: # noqa: BLE001 - fail-soft by design + _surface_failure = exc + event_study_effects = None + boot_result = _run_stage(None) + else: + boot_result = _run_stage(None) + _bootstrap_ran = True att_d_se = boot_result["att_d_se"] att_d_ci_lower = boot_result["att_d_ci_lower"] att_d_ci_upper = boot_result["att_d_ci_upper"] @@ -1152,24 +1146,32 @@ def fit( acrt_d_ci_lower[idx] = ci[0] acrt_d_ci_upper[idx] = ci[1] - # Event study analytical SEs - the body lives in - # continuous_did_aggregation._compute_event_study_inference, - # shared verbatim with the post-fit aggregate('event_study') - # recompute (row M-025). fit passes its full gt_results / - # gt_bootstrap_info locals; the method reads only the - # kit-compatible key subset. - if event_study_effects is not None: - self._compute_event_study_inference( - event_study_effects, - gt_summary=gt_results, - gt_es_payload=gt_bootstrap_info, - n_units=precomp["n_units"], - unit_cohorts=precomp["unit_cohorts"], - unit_survey_weights=precomp.get("unit_survey_weights"), - unit_first_panel_row=precomp["unit_first_panel_row"], - resolved_survey=resolved_survey, - survey_df=_survey_df, - ) + # (The analytical event-study inference lives in + # continuous_did_aggregation._compute_event_study_inference + # and runs ONLY on the post-fit aggregate('event_study') + # kit recompute - analytic fits materialize no ES surface.) + + # Storage rule: the legacy field holds a surface IFF the fit carries + # bootstrap inference. When no post-treatment cells exist the + # bootstrap never runs (the overall ATT is NaN with a UserWarning), + # so an analytical surface must never sit on that bootstrapped fit + # where summary()/to_dataframe()/the DiagnosticReport would read it + # as the fit's inference. The fail-soft warning fires only when the + # bootstrap DID run but the surface was dropped. + if self.n_bootstrap > 0: + if not _bootstrap_ran: + event_study_effects = None + elif _surface_failure is not None: + warnings.warn( + f"{type(self).__name__}: the event-study/group surfaces could not " + f"be materialized on this bootstrapped fit ({_surface_failure}); " + "the bootstrap covered the overall ATT only, so " + "aggregate('event_study') / aggregate('group') are unavailable " + "here - re-fit with n_bootstrap=0 for the analytical post-fit " + "recompute.", + UserWarning, + stacklevel=2, + ) # 6. Assemble results dose_response_att = DoseResponseCurve( @@ -1253,18 +1255,15 @@ def fit( rank_deficient_action=self.rank_deficient_action, event_study_effects=event_study_effects, survey_metadata=survey_metadata, - # Per-row ES df provenance (M-092 completion): the survey df the - # ES rows' safe_inference used. None when no ES surface was - # built, on bootstrap fits (_survey_df stays None there), on - # non-survey fits, and for the replicate-undefined 0 sentinel. - event_study_df=( - float(_survey_df) - if (event_study_effects is not None and _survey_df is not None and _survey_df > 0) - else None - ), + # Per-row ES df provenance (M-092 completion): the only fit-time + # ES surface is the bootstrapped one, whose percentile inference + # used no df - always None here; the analytical post-fit + # recompute threads the kit's survey df onto its own carrier. + event_study_df=None, ) # Post-fit aggregation kit (row M-025): attached on EVERY fit; - # scalars-only on bootstrap fits (the ES route fails closed there). + # scalars-only on bootstrap fits (the ES route relays the fit-time + # surface there). results._aggregation_kit = _build_continuous_aggregation_kit( self, gt_results=gt_results, diff --git a/diff_diff/continuous_did_aggregation.py b/diff_diff/continuous_did_aggregation.py index 90860fcba..d3809305f 100644 --- a/diff_diff/continuous_did_aggregation.py +++ b/diff_diff/continuous_did_aggregation.py @@ -2,9 +2,10 @@ Shared event-study aggregation machinery for ContinuousDiD (row M-025). Leaf module: hosts ``_ContinuousDiDAggregationMixin``, the binarized -event-study aggregation shared between fit-time ``fit(aggregate= -"eventstudy")`` (deprecated) and the post-fit -``ContinuousDiDResults.aggregate("event_study")`` recompute. The import +event-study aggregation shared between the fit-time surface a BOOTSTRAPPED +``fit()`` materializes (relayed by ``aggregate("event_study")``) and the +post-fit ``ContinuousDiDResults.aggregate("event_study")`` recompute on +analytic fits. The import DAG forces the split: ``continuous_did.py`` imports ``continuous_did_results.py``, so the results module cannot reach the estimator module - both instead import this leaf (numpy + diff_diff.utils @@ -108,12 +109,14 @@ def _compute_event_study_inference( """Fill analytical se/t/p/CI on the binarized event-study rows. The verbatim body of fit's former inline "Event study analytical - SEs" block: mutates ``event_study_effects`` rows in place. The - fit-time caller passes its ``gt_results`` / ``gt_bootstrap_info`` - locals; the post-fit caller passes the kit's ``gt_summary`` / - pruned ``gt_es_payload`` (key-compatible by construction). Only - runs when the fit was analytical with post-treatment cells - the - callers own that gating. + SEs" block: mutates ``event_study_effects`` rows in place. Since + 4.0 the post-fit kit recompute is the only caller (the analytical + fit-time arm was deleted - analytic fits materialize no ES surface); + it passes the kit's ``gt_summary`` / pruned ``gt_es_payload`` + (key-compatible with fit's full ``gt_results`` / + ``gt_bootstrap_info`` locals by construction). Only runs when the + fit was analytical with post-treatment cells - the caller owns + that gating. """ unit_sw = unit_survey_weights diff --git a/diff_diff/continuous_did_results.py b/diff_diff/continuous_did_results.py index 76ab0665a..84e9d8d0f 100644 --- a/diff_diff/continuous_did_results.py +++ b/diff_diff/continuous_did_results.py @@ -12,7 +12,11 @@ import numpy as np import pandas as pd -from diff_diff.aggregation import AggregationMixin, AggregationResult +from diff_diff.aggregation import ( + AggregationMixin, + AggregationResult, + absent_bootstrap_surface_message, +) from diff_diff.continuous_did_aggregation import _ContinuousDiDAggregationMixin from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import ( @@ -192,8 +196,8 @@ class ContinuousDiDResults(BaseResults, AggregationMixin): # 'simple'/'dose' are views. _aggregation_kit: Optional[Any] = field(default=None, repr=False, compare=False) # Scalar survey df governing the event-study rows' t-inference. None on - # non-survey fits, on bootstrapped fits (percentile inference; the ES - # recompute also fails closed there), when no event-study surface was + # non-survey fits, on bootstrapped fits (percentile inference; the + # relayed surface carries no df), when no event-study surface was # built, and for the replicate-undefined 0 sentinel. Appended last per # the positional-__init__ convention above. event_study_df: Optional[float] = None @@ -503,12 +507,12 @@ def to_dataframe(self, level: str = "dose_response") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed. Call " + "Event study effects not stored (only bootstrapped fits " + "materialize the surface at fit). Call " "results.aggregate('event_study') for the unified " - "post-fit container (on a bootstrapped fit, re-fit " - "with n_bootstrap=0 or use the deprecated fit-time " - "aggregate='eventstudy'); a result unpickled from an " - "older release must be re-fit with diff-diff >= 3.9." + "post-fit container - analytic fits recompute it from " + "the retained kit; a result unpickled from an older " + "release must be re-fit with diff-diff >= 3.9." ) rows = [] for rel_t, data in sorted(self.event_study_effects.items()): @@ -544,9 +548,11 @@ def significance_stars(self) -> str: # MIXED architecture: 'simple' and 'dose' are pure VIEWS over stored # public fields (the dCDH precedent - nothing recomputed, so they # work on ANY fit including bootstrap fits and legacy pickles, - # relaying the stored inference verbatim); 'event_study' is a KIT - # RECOMPUTE (the EfficientDiD class) from the pruned per-cell IF - # payload, failing closed on bootstrap fits. + # relaying the stored inference verbatim); 'event_study' is a HYBRID: + # a KIT RECOMPUTE (the EfficientDiD class) from the pruned per-cell IF + # payload on analytic fits, and a RELAY of the percentile surface + # fit() materialized on bootstrapped fits (the kit is scalars-only + # there; balance_e is rejected by the mixin for this producer). # ------------------------------------------------------------------ def _stored_inference_df(self) -> float: @@ -675,16 +681,27 @@ def _aggregate_compute( ) bk = kit.bookkeeping if bk["n_bootstrap"] > 0: - raise NotImplementedError( - "aggregate('event_study') on a bootstrapped ContinuousDiD " - "fit is not implemented - the fit-time event study used " - "multiplier-bootstrap inference whose per-cell draws are " - "not retained, and an analytical recompute would silently " - "differ. Until 4.0 the deprecated fit-time " - "aggregate='eventstudy' still computes the bootstrap " - "surface, or re-fit with n_bootstrap=0 for the analytical " - "post-fit route." + # 4.0 PR 3: the kit is scalars-only on every bootstrapped fit + # (no recompute is possible), so RELAY the percentile surface + # fit() materialized, with kit-snapshot provenance. Gate on the + # KIT scalar, not the mutable public field (no new kit key: a + # 3.9-3.13 pickle must not KeyError). A bootstrapped fit storing + # no surface (pre-4.0 pickle, fail-soft drop, or the + # no-post-cells fit whose bootstrap never ran) raises. + if self.event_study_effects is None: + raise ValueError( + absent_bootstrap_surface_message(type(self).__name__, "event_study") + ) + meta_b = bk["survey_metadata"] + carrier_b = dataclasses.replace( + self, + survey_metadata=(dataclasses.replace(meta_b) if meta_b is not None else None), + alpha=kit.alpha, + anticipation=kit.anticipation, + base_period=bk["base_period"], + event_study_df=None, # percentile inference governs no df ) + return build_event_study_surface(carrier_b) host = _ContinuousKitAggregator(alpha=kit.alpha) es = host._aggregate_event_study( bk["gt_summary"], diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index b74d71aac..a35a1fbb7 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -12,14 +12,15 @@ not a traffic-light enum. See ``docs/methodology/REPORTING.md``. - No estimator fitting. Every effect, SE, p-value, CI, and sensitivity bound is either read from ``results``, derived from the result's own - post-fit ``aggregate('event_study')`` surface (a view or retained-kit - recompute — for ImputationDiD a panel-backed recompute, for TwoStageDiD - a fresh Stage-2 OLS + GMM sandwich over the retained frame; used only - when the raw ``event_study_effects`` field is absent, and failing - closed to an explicit skip on kit-less/legacy-pickle fits, the sibling - estimators' bootstrap gates, and backend-mismatched CS bootstrap - replays — bootstrapped CS fits themselves derive successfully via the - percentile-bootstrap replay), or + post-fit ``aggregate('event_study')`` surface (a view, a retained-kit + recompute, or a relay of the surface a bootstrapped fit materialized - + for analytical ImputationDiD a panel-backed recompute, for analytical + TwoStageDiD a fresh Stage-2 OLS + GMM sandwich over the retained frame, + for every CallawaySantAnna / EfficientDiD fit the kit recompute or the + percentile-bootstrap replay; used only when the raw + ``event_study_effects`` field is absent, and failing closed to an + explicit skip on kit-less/legacy-pickle fits, fail-soft-cleared + bootstrapped surfaces, and backend-mismatched CS bootstrap replays), or produced by an existing diff-diff utility. May call ``check_parallel_trends`` / ``BaconDecomposition`` / ``EfficientDiD.hausman_pretest`` when the caller supplies the panel + @@ -806,12 +807,14 @@ def _resolve_event_study_surface( authoritative). The raw ``event_study_effects`` field always takes precedence when it - is not ``None`` — including the requested-but-empty ``{}`` sentinel, - which encodes fit-time configuration (e.g. an EfficientDiD - ``balance_e=`` that emptied the window) that a re-derivation without - those settings would silently overturn. Derivation failures - (bootstrapped fits, missing kits) are cached as skip reasons, never - raised; warnings emitted by a kit recompute are captured into + is not ``None`` — including the materialized-but-empty ``{}`` + sentinel. Since 4.0 the raw field is populated only by producers + that materialize the surface at fit (StackedDiD, SunAbraham, + bootstrapped ImputationDiD / TwoStageDiD / ContinuousDiD - always + with ``balance_e=None``), so an empty dict records that the fit + itself found no estimable horizon, which a re-derivation would + silently overturn. Derivation failures (missing kits, cleared + bootstrapped surfaces) are cached as skip reasons, never raised; warnings emitted by a kit recompute are captured into ``self._derived_es_warnings`` and re-published by the consuming runners (record-and-republish, mirroring the sensitivity helper). """ @@ -827,9 +830,8 @@ def _resolve_event_study_surface( why: Optional[str] = None if getattr(r, "event_study_effects", None) is not None: # Raw precedence, ``is not None`` deliberately: ``{}`` is the - # REQUESTED-but-no-estimable-horizons sentinel and must never be - # silently re-derived (the fit-time balance_e/aggregate settings - # behind it are not retained for replay). + # materialized-but-no-estimable-horizons sentinel (a fit-time + # producer found nothing) and must never be silently re-derived. pass elif name == "WooldridgeDiDResults": # Its bespoke aggregate() MUTATES the results object in place; @@ -889,11 +891,12 @@ def _resolve_event_study_surface( surface = candidate surface_dict = _surface_to_event_study_dict(candidate) except Exception as exc: # noqa: BLE001 — fail-soft by design: - # expected failures are NotImplementedError (the sibling - # estimators' bootstrap gates, pretrends+replicate, CS - # legacy-pickle/backend-mismatch replay refusals — a - # bootstrapped CS fit itself now derives via the replay) - # and ValueError (missing kit), + # expected failures are NotImplementedError + # (pretrends+replicate, CS legacy-pickle/backend-mismatch + # replay refusals — a bootstrapped CS fit itself derives via + # the replay) and ValueError (missing kit; a bootstrapped + # Imputation/TwoStage/Continuous fit whose fail-soft + # materialization left no surface to relay), # but the surface builder can raise bare TypeError and this # resolver runs on the applicable_checks path with no outer # guard; an escaped exception would hard-fail the report. @@ -994,9 +997,9 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: if not pre_coefs and n_dropped_undefined == 0: # SpilloverDiD's event-study switch is the # ``SpilloverDiD(..., event_study=True)`` constructor - # kwarg, not the ``aggregate='event_study'`` argument - # the generic staggered-estimator message points at - # (SpilloverDiD has no ``aggregate`` kwarg). Emit an + # kwarg, not the post-fit ``aggregate('event_study')`` + # route the generic staggered-estimator message points + # at (SpilloverDiD has no post-fit aggregation). Emit an # estimator-accurate remediation for this family. if name == "SpilloverDiDResults": return ( @@ -1006,9 +1009,9 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: "per-event-time direct-effect output." ) # StackedDiD (3.9, row M-024): the event-study surface - # is ALWAYS computed, so ``aggregate='event_study'`` is - # inert (and deprecated - it now warns). Zero estimated - # pre-periods here means the event window has none: + # is ALWAYS computed at fit (no aggregation keyword + # exists since 4.0). Zero estimated pre-periods here + # means the event window has none: # the default ``kappa_pre=1`` grid minus the reference # leaves no pre-period columns. Estimator-accurate # remediation, per the SpilloverDiD precedent above. diff --git a/diff_diff/efficient_did.py b/diff_diff/efficient_did.py index 65bd933e4..339f3bb9b 100644 --- a/diff_diff/efficient_did.py +++ b/diff_diff/efficient_did.py @@ -29,12 +29,7 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationKit, BootstrapReplaySpec -from diff_diff.bootstrap_utils import ( - apply_bootstrap_event_study_overrides, - apply_bootstrap_group_overrides, -) from diff_diff.efficient_did_aggregation import ( _cluster_aggregate, _compute_se_from_eif, @@ -131,13 +126,13 @@ def _build_edid_aggregation_kit( per-(g,t) EIF dict, O(n_units x n_gt) (full enumeration in the ``docs/methodology/REGISTRY.md`` EfficientDiD M-023 Note). - ``df_survey`` MUST be the post-overall snapshot captured in ``fit()`` - immediately after the overall inference and before the ES/group gates — - NOT a read of ``estimator._survey_df`` at kit-build time, which on a - deprecated ``fit(aggregate="group"/"all")`` replicate fit is the - post-group value (``_compute_survey_eif_se`` can set it to ``None`` on - a degenerate replicate design). The snapshot is the exact value every - fit-time aggregation seeded from, so post-fit recompute replays it. + ``df_survey`` is the post-overall snapshot captured in ``fit()`` + immediately after the overall inference - the kit's named seed for + every post-fit recompute (``results.aggregate('event_study' | + 'group')``). Since 4.0 no fit-time aggregation runs after it, so the + snapshot equals a fit-end read of ``estimator._survey_df``; it is + still captured by name so the post-fit route seeds from an explicit + value rather than from estimator state. """ if not eif_by_gt: # Unreachable after fit()'s empty-effects raise; kept for the CS @@ -498,8 +493,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, survey_design: Optional[Any] = None, store_eif: bool = False, ) -> EfficientDiDResults: @@ -522,34 +515,6 @@ def fit( Column names for time-invariant unit-level covariates. When provided, uses the doubly robust path (outcome regression + propensity score ratios). - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0, row M-023): supplying any - value (``None`` included) emits a FutureWarning; the - deprecated path still computes and stores the requested - surfaces exactly as before (``None``, ``"simple"``, - ``"event_study"``, ``"group"``, or ``"all"``; unknown strings - act like ``None``, unchanged). Aggregate as a post-fit step - instead: ``results.aggregate('event_study')`` / - ``.aggregate('group')`` / ``.aggregate('simple')`` / - ``.aggregate('total')``. On - bootstrapped fits (``n_bootstrap > 0``) the post-fit - RECOMPUTE levels (``'event_study'``/``'group'``) REPLAY the - fit-time multiplier bootstrap from the kit-retained RNG - state (percentile inference, allclose to a fit-time - aggregation; no refit needed), while ``aggregate('simple')`` - and, where supported, ``aggregate('total')`` relay the - stored bootstrap inference (the per-level policy converged - with row M-027). - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0, row M-120): moves onto - post-fit ``aggregate()`` — - ``results.aggregate('event_study', balance_e=2)``. EDiD's - balance rule is the ANCHOR-HORIZON rule (keep cohorts with a - finite effect at the anchor horizon), the same rule shape - CallawaySantAnna uses — with one keying-granularity - difference: EDiD anchors on the ``int(t - g)`` bucket while - CS keys raw ``t - g`` (identical on integer-period panels; - see the REGISTRY truncation Note). survey_design : SurveyDesign, optional Survey design specification for design-based inference. Applies survey weights to all means, covariances, and cohort @@ -566,6 +531,16 @@ def fit( Returns ------- EfficientDiDResults + Aggregations are a post-fit step: ``results.aggregate('event_study' + | 'group' | 'simple' | 'total', balance_e=...)``. On bootstrapped + fits (``n_bootstrap > 0``) the recompute levels replay the fit-time + multiplier bootstrap from the kit-retained RNG state. EDiD's + balance rule is the ANCHOR-HORIZON rule (keep cohorts with a + finite effect at the anchor horizon), the same rule shape + CallawaySantAnna uses - with one keying-granularity difference: + EDiD anchors on the ``int(t - g)`` bucket while CS keys raw + ``t - g`` (identical on integer-period panels; see the REGISTRY + truncation Note). Raises ------ @@ -573,35 +548,6 @@ def fit( Missing columns, unbalanced panel, non-absorbing treatment, or PT-Post without a never-treated group. """ - # M-023/M-120 deprecation shim (CS-style joint warning): a plain - # fit() never warns; supplying EITHER param with ANY value (None - # included) warns once, then the legacy routing below runs - # unchanged - the deprecated path returns exactly the numbers it - # always did (no new value validation; unknown strings still act - # like None). The post-fit successor validates its own vocabulary. - _deprecated_passed = [ - n - for n, v in (("aggregate", aggregate), ("balance_e", balance_e)) - if v is not NOT_SUPPLIED - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"EfficientDiD.fit({_args}) is deprecated and will be " - "removed in 4.0. Fit once, then aggregate as a post-fit " - "step: results = EfficientDiD().fit(...); " - "results.aggregate('event_study') / .aggregate('group') / " - ".aggregate('simple') / .aggregate('total'). balance_e moves onto aggregate() " - "alongside it: results.aggregate('event_study', " - "balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if aggregate is NOT_SUPPLIED: - aggregate = None - if balance_e is NOT_SUPPLIED: - balance_e = None - self._validate_params() if self.cluster is not None and survey_design is not None: @@ -1306,44 +1252,15 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any overall_t, overall_p, overall_ci = safe_inference( overall_att, overall_se, alpha=self.alpha, df=self._survey_df ) - # M-023 kit snapshot: the post-overall ``_survey_df`` is the exact - # value fit-time ES/group aggregation seeds from (the gates below run - # next), and post-fit aggregate() must replay from the same seed. - # Captured HERE, not at fit end: ``_aggregate_by_group`` can mutate - # ``self._survey_df`` through ``_compute_survey_eif_se`` on degenerate - # replicate designs, so a fit-end read on a deprecated - # ``fit(aggregate="group"/"all")`` would seed recompute from the - # post-group value and break post-fit/fit-time inertness. + # M-023 kit snapshot: the post-overall ``_survey_df`` is the kit's + # named seed for every post-fit aggregate() recompute. Since 4.0 no + # fit-time ES/group aggregation runs after it (the legacy + # ``event_study_effects`` / ``group_effects`` fields are None on + # every fit; ``results.aggregate('event_study' | 'group')`` is the + # surface), so the snapshot equals a fit-end read - it is kept by + # name so the post-fit route seeds from an explicit value. _survey_df_post_overall = self._survey_df - event_study_effects = None - group_effects = None - - if aggregate in ("event_study", "all"): - event_study_effects = self._aggregate_event_study( - group_time_effects, - eif_by_gt, - n_units, - cohort_fractions, - treatment_groups, - time_periods, - balance_e, - unit_cohorts=unit_cohorts, - cluster_indices=unit_cluster_indices, - n_clusters=n_clusters, - ) - if aggregate in ("group", "all"): - group_effects = self._aggregate_by_group( - group_time_effects, - eif_by_gt, - n_units, - cohort_fractions, - treatment_groups, - unit_cohorts=unit_cohorts, - cluster_indices=unit_cluster_indices, - n_clusters=n_clusters, - ) - # ----- Bootstrap ----- # Reject replicate-weight designs for bootstrap — replicate variance # is an analytical alternative, not compatible with bootstrap @@ -1363,8 +1280,8 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any group_time_effects=group_time_effects, eif_by_gt=eif_by_gt, n_units=n_units, - aggregate=aggregate, - balance_e=balance_e, + aggregate=None, + balance_e=None, treatment_groups=treatment_groups, cohort_fractions=cohort_fractions, cluster_indices=unit_cluster_indices, @@ -1387,15 +1304,6 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any se = float(group_time_effects[gt]["se"]) group_time_effects[gt]["t_stat"] = safe_inference(eff, se, alpha=self.alpha)[0] - # ES/group percentile overrides via the shared appliers (the same - # implementations the post-fit aggregate() replay runs — one - # code path, no fit-vs-replay drift). The appliers carry the - # availability guards and the group df_used clearing internally. - apply_bootstrap_event_study_overrides( - event_study_effects, bootstrap_results, self.alpha - ) - apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha) - # ----- Build results ----- # Raw (pre-normalization) unit weights for the metadata recompute: # compute_survey_metadata expects the ORIGINAL scale (resolve() @@ -1413,16 +1321,6 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any else np.ones(len(data), dtype=np.float64) ) raw_unit_w_meta = raw_obs_w_meta[self._unit_first_panel_row] - # Per-row ES df provenance (M-092 completion): the post-overall - # survey-df snapshot is the df the ES rows' safe_inference used. - # None when no ES surface was built, under bootstrap (percentile - # inference used no df — the shipped producer convention), and for - # the replicate-undefined 0 sentinel (representable only via - # survey_metadata.df_survey). - _es_df_final: Optional[float] = None - if event_study_effects is not None and bootstrap_results is None: - if _survey_df_post_overall is not None and _survey_df_post_overall > 0: - _es_df_final = float(_survey_df_post_overall) self.results_ = EfficientDiDResults( group_time_effects=group_time_effects, overall_att=overall_att, @@ -1441,9 +1339,12 @@ def _finalize_cell(g: Any, att_gt: float, eif_vals: np.ndarray) -> Dict[str, Any n_bootstrap=self.n_bootstrap, bootstrap_weights=self.bootstrap_weights, seed=self.seed, - event_study_effects=event_study_effects, - event_study_df=_es_df_final, - group_effects=group_effects, + # No fit-time ES/group surface since 4.0 (post-fit aggregate() + # recomputes / replays them); the legacy fields are None on + # every fit, as is the ES df provenance. + event_study_effects=None, + event_study_df=None, + group_effects=None, efficient_weights=stored_weights if stored_weights else None, omega_condition_numbers=stored_cond if stored_cond else None, control_group=self.control_group, diff --git a/diff_diff/efficient_did_aggregation.py b/diff_diff/efficient_did_aggregation.py index 3ed450f50..617f3eb4d 100644 --- a/diff_diff/efficient_did_aggregation.py +++ b/diff_diff/efficient_did_aggregation.py @@ -16,7 +16,8 @@ - ``_EfficientAggregationMixin`` — the six estimator methods that compute the overall / event-study / group aggregations from the per-(g,t) EIF dict plus O(n_units) bookkeeping. ``EfficientDiD`` inherits it for the - fit-time path, and ``EfficientDiDResults._aggregate_compute`` runs the + fit-time OVERALL aggregation (since 4.0 the fit materializes no event-study + or group surface), and ``EfficientDiDResults._aggregate_compute`` runs the same methods post-fit on a throwaway ``_EDiDKitAggregator`` host built from the retained :class:`~diff_diff.aggregation.AggregationKit` — which is what keeps ``aggregate()`` off an ``_estimator_ref``. @@ -96,7 +97,7 @@ def _compute_se_from_eif( class _EfficientAggregationMixin: - """EIF-based aggregation methods shared by fit-time and post-fit paths. + """EIF-based aggregation methods shared by the fit (overall) and post-fit paths. Not intended for standalone use. A host class must expose exactly the five attributes declared below — this is the ``_EDiDKitAggregator`` diff --git a/diff_diff/efficient_did_results.py b/diff_diff/efficient_did_results.py index 022df1cf8..9191e931c 100644 --- a/diff_diff/efficient_did_results.py +++ b/diff_diff/efficient_did_results.py @@ -815,12 +815,12 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed at fit time. Use " - "results.aggregate('event_study') for the post-fit " - "event-study container (bootstrapped fits replay the " - "fit-time multiplier bootstrap - no refit needed); a " - "result unpickled from a pre-3.9 release carries no " - "aggregation kit and must be refit." + "Event study effects are not stored on 4.0 fits; use " + "results.aggregate('event_study').to_dataframe() " + "(bootstrapped fits replay the fit-time multiplier " + "bootstrap - no refit needed); a result unpickled from " + "a pre-3.9 release carries no aggregation kit and must " + "be refit." ) rows = [] for rel_t, data in sorted(self.event_study_effects.items()): @@ -840,12 +840,12 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed at fit time. Use " - "results.aggregate('group') for the post-fit group " - "container (bootstrapped fits replay the fit-time " - "multiplier bootstrap - no refit needed); a result " - "unpickled from a pre-3.9 release carries no " - "aggregation kit and must be refit." + "Group effects are not stored on 4.0 fits; use " + "results.aggregate('group').to_dataframe() " + "(bootstrapped fits replay the fit-time multiplier " + "bootstrap - no refit needed); a result unpickled from " + "a pre-3.9 release carries no aggregation kit and must " + "be refit." ) rows = [] for group, data in sorted(self.group_effects.items()): diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 1be5a55fe..d27d3bdbb 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -757,7 +757,7 @@ intensity of exposure differs): comparison under Assumption 6. `fit()` selects the mode from the panel shape: a two-period panel returns a single scalar WAS (Phase 2a); a multi-period panel returns per-event-time WAS estimates (Phase - 2b). The `aggregate=` override is deprecated (M-027). `did_had_pretest_workflow()` + 2b). The `aggregate=` override was removed at 4.0 (M-027). `did_had_pretest_workflow()` runs the paper's three-step TWFE-suitability battery: (1) QUG null via `qug_test`, (2) Assumption 7 pre-trends via `stute_test` / `stute_joint_pretest` (event-study path only; the two-period overall diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index b6e376047..af6e4be6b 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -231,8 +231,6 @@ cs.fit( time: str, first_treat: str, # Column: first treatment period (0 or inf for never-treated) covariates: list[str] = None, - aggregate: str = None, # None, "simple", "event_study", "group", or "all" - balance_e: int = None, # Balance event study at this relative period ) -> CallawaySantAnnaResults ``` @@ -254,29 +252,30 @@ event_study = results.aggregate('event_study') # -> EventStudyResults by_cohort = results.aggregate('group') # -> AggregationResult ``` -Fit-time `aggregate=` / `balance_e=` are DEPRECATED since 3.9 (removed in 4.0, -ledger rows M-020 / M-117) and emit a `FutureWarning`. The downstream consumers -all accept the post-fit container directly - `plot_event_study`, -`compute_honest_did` and `compute_pretrends_power` each take -`results.aggregate('event_study')` - bootstrapped fits included: the -RECOMPUTE levels ('event_study'/'group') REPLAY the fit-time multiplier -bootstrap from the kit-retained RNG state (percentile se/CI/cband matching a -fit-time aggregation to floating-point reassociation; the container carries -vcov=None). Only pre-replay legacy pickles and artifacts moved across the -Rust/NumPy weight backend fail closed with a refit message - never a silent -substitution of analytical inference for percentile statistics. +`fit()` takes no `aggregate=` / `balance_e=` keyword (removed at 4.0, ledger +rows M-020 / M-117; passing them is a `TypeError`) and computes no event-study +or group surface: `results.event_study_effects` / `results.group_effects` are +`None` after every fit. Aggregation is post-fit, and the downstream consumers +all accept the container directly - `plot_event_study`, `compute_honest_did` +and `compute_pretrends_power` each take `results.aggregate('event_study')` - +bootstrapped fits included: the RECOMPUTE levels ('event_study'/'group') +REPLAY the fit-time multiplier bootstrap from the kit-retained RNG state +(percentile se/CI/cband matching a fit-time aggregation to floating-point +reassociation; the container carries the bootstrap-draw covariance as vcov). +Only pre-replay legacy pickles and artifacts moved across the Rust/NumPy +weight backend fail closed with a refit message - never a silent substitution +of analytical inference for percentile statistics. ```python -# Post-fit route (recommended): aggregate once, feed any consumer. +# Aggregate once post-fit, feed any consumer (analytical or bootstrapped fit). es = results.aggregate('event_study') plot_event_study(es) honest = compute_honest_did(es, M=1.0) -# Bootstrap fits only: aggregate at fit time. boot = CallawaySantAnna(estimation_method="dr", n_bootstrap=999, seed=42) -plotted = boot.fit(data, outcome='outcome', unit='unit', time='period', - first_treat='first_treat', aggregate='event_study') -plot_event_study(plotted) +boot_res = boot.fit(data, outcome='outcome', unit='unit', time='period', + first_treat='first_treat') +plot_event_study(boot_res.aggregate('event_study')) # replays the bootstrap ``` ### ChaisemartinDHaultfoeuille @@ -313,7 +312,6 @@ est.fit( # ---- multi-horizon ---- L_max: int | None = None, # Max horizon; None = l=1 only # ---- covariates and extensions ---- - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-026): warns; non-None raises - use results.aggregate() post-fit covariates: list[str] | None = None, # DID^X residualization-style covariates trends_linear: bool | None = None, # DID^{fd} group-specific linear trends trends_nonparam: Any | None = None, # DID^s state-set-specific trends @@ -323,7 +321,7 @@ est.fit( ) -> ChaisemartinDHaultfoeuilleResults ``` -`L_max` controls multi-horizon computation. `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported. Fit-time `aggregate=` is DEPRECATED (row M-026: it never computed anything here; supplying it warns and any non-None value raises) - aggregation is post-fit: `results.aggregate('event_study')` returns the unified `EventStudyResults` view (Phase-1 fits the 2-row l=1 view, `L_max>=1` the multi-horizon surface) and `results.aggregate('simple')` a one-row `AggregationResult` relaying the overall estimand (DID_M / DID_1 / delta) bit-exactly; both are pure views, so bootstrap fits are permitted. The dCDH container is deliberately NOT accepted by `compute_honest_did`/`compute_pretrends_power` (their l1 placebo semantics need the native dCDH branch - pass the results object itself). The 3.8 names `group=` and `controls=` remain accepted as deprecated aliases (FutureWarning; removed in 4.0). +`L_max` controls multi-horizon computation. `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, `design2`, and `survey_design` are all supported. `fit()` takes no `aggregate=` keyword (removed at 4.0, row M-026: it never computed anything here) - aggregation is post-fit: `results.aggregate('event_study')` returns the unified `EventStudyResults` view (Phase-1 fits the 2-row l=1 view, `L_max>=1` the multi-horizon surface) and `results.aggregate('simple')` a one-row `AggregationResult` relaying the overall estimand (DID_M / DID_1 / delta) bit-exactly; both are pure views, so bootstrap fits are permitted. The dCDH container is deliberately NOT accepted by `compute_honest_did`/`compute_pretrends_power` (their l1 placebo semantics need the native dCDH branch - pass the results object itself). The 3.8 names `group=` and `controls=` were removed in 4.0 - use `unit=` and `covariates=`. **Usage:** @@ -460,8 +458,6 @@ imp.fit( time: str, first_treat: str, covariates: list[str] = None, - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-021): warns, still works - use results.aggregate() post-fit - balance_e: Any = NOT_SUPPLIED, # DEPRECATED (M-118): moves onto results.aggregate(balance_e=) survey_design: SurveyDesign = None, # Optional design-based inference (pweight + analytical strata/PSU/FPC or replicate BRR/Fay/JK1/JKn/SDR) ) -> ImputationDiDResults ``` @@ -475,8 +471,10 @@ est = ImputationDiD() results = est.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') results.print_summary() -es = results.aggregate('event_study') # post-fit (M-021); balance_e= lives here too +es = results.aggregate('event_study') # post-fit (M-021); balance_e= lives here (analytical fits) plot_event_study(es) +# n_bootstrap > 0: the percentile ES/group surfaces are computed at fit and +# aggregate('event_study') / aggregate('group') relay them (balance_e unavailable). ``` ### TwoStageDiD @@ -509,8 +507,6 @@ ts.fit( time: str, first_treat: str, covariates: list[str] = None, - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-022): warns, still works - use results.aggregate() post-fit - balance_e: Any = NOT_SUPPLIED, # DEPRECATED (M-119): moves onto results.aggregate(balance_e=) ) -> TwoStageDiDResults ``` @@ -523,7 +519,9 @@ est = TwoStageDiD() results = est.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') results.print_summary() -es = results.aggregate('event_study') # post-fit (M-022); balance_e= lives here too +es = results.aggregate('event_study') # post-fit (M-022); balance_e= lives here (analytical fits) +# n_bootstrap > 0: the percentile ES/group surfaces are computed at fit and +# aggregate('event_study') / aggregate('group') relay them (balance_e unavailable). ``` ### SpilloverDiD @@ -788,10 +786,6 @@ cdid.fit( time: str, first_treat: str, dose: str, # Column with continuous treatment dose - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-025) - removed in 4.0; use post-fit results.aggregate(); - # legacy values None/"dose"/"eventstudy" still route until 4.0 (any - # supplied value warns; "dose" was always a no-op - the curves are - # always computed) covariates: list[str] = None, # Conditional parallel trends (X). None = unconditional ) -> ContinuousDiDResults ``` @@ -807,12 +801,10 @@ results = est.fit(data, outcome='outcome', unit='unit', time='period', results.print_summary() dose_table = results.aggregate('dose') # ATT(d) + ACRT(d) rows (view; any fit) overall = results.aggregate('simple') # att + acrt rows (view; any fit) -# Binarized event study: analytical fits only (bootstrapped fits raise - -# re-fit with n_bootstrap=0, or the deprecated fit-time aggregate='eventstudy' -# until 4.0): -es = ContinuousDiD(seed=42).fit(data, outcome='outcome', unit='unit', - time='period', first_treat='first_treat', - dose='dose').aggregate('event_study') +# Binarized event study: an analytical fit recomputes it from the retained +# kit; a bootstrapped fit (n_bootstrap > 0) computes the percentile surface +# at fit and aggregate('event_study') relays it (4.0): +es = results.aggregate('event_study') ``` ### HeterogeneousAdoptionDiD @@ -844,7 +836,6 @@ had.fit( time: str, unit: str, first_treat: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-027) - removed in 4.0; fit() selects the mode from the panel shape (2 distinct periods -> overall scalar WAS; more -> per-horizon event-study WAS) and post-fit results.aggregate() is the aggregation surface; supplying a value warns then runs the legacy routing *, cband: bool = True, # Simultaneous (sup-t) confidence bands on event-study fits that are survey-weighted OR clustered (keyword-only) survey_design: SurveyDesign | None = None, # Survey-design kwarg (weights, strata, PSU, FPC) — the sole weighting entry @@ -1118,7 +1109,6 @@ stacked.fit( unit: str, time: str, first_treat: str, - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-024): warns; surface always computed - use results.aggregate() post-fit population: str = None, # Required when weighting="population" covariates: list[str] = None, # Columns to balance (requires balance="entropy"); values read at t=a-1-anticipation; balanced windows only ) -> StackedDiDResults @@ -1168,8 +1158,6 @@ edid.fit( time: str, first_treat: str, covariates: list[str] = None, # Time-invariant unit-level covariates; uses doubly-robust sieve path when non-None - aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-023): warns, still works - use results.aggregate() post-fit - balance_e: Any = NOT_SUPPLIED, # DEPRECATED (M-120): moves onto results.aggregate(balance_e=) ) -> EfficientDiDResults ``` @@ -1900,10 +1888,10 @@ Returned by `CallawaySantAnna.fit()`. | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | | `n_obs` | `int` | Number of observations | -| `event_study_effects` | `dict[int, dict]` | Event study effects by relative time. Populated only by the DEPRECATED fit-time `aggregate=`; `None` after a plain fit - use `aggregate("event_study")` instead | -| `group_effects` | `dict` | Group-level aggregated effects. Same: populated only by fit-time `aggregate=` | +| `event_study_effects` | `dict[int, dict] | None` | Legacy field - `None` on EVERY fit since 4.0 (fit() computes no event-study surface); use `aggregate("event_study")` | +| `group_effects` | `dict | None` | Legacy field - `None` on EVERY fit since 4.0; use `aggregate("group")` | -**Methods:** `aggregate(type, weights=None, *, balance_e=None)`, `summary()`, `print_summary()`, `to_dataframe(level="event_study"|"group_time"|"group")` +**Methods:** `aggregate(type, weights=None, *, balance_e=None)`, `summary()`, `print_summary()`, `to_dataframe(level="group_time")` (the event-study / group tables come from `aggregate(...).to_dataframe()`; `level="event_study"|"group"` raises on 4.0 fits) `aggregate("simple"|"event_study"|"group"|"total")` re-aggregates POST-FIT with no @@ -2115,9 +2103,9 @@ ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, and EfficientDiDRes | `overall_t_stat` | `float` | T-statistic | | `overall_p_value` | `float` | P-value | | `overall_conf_int` | `tuple[float, float]` | CI | -| `event_study_effects` | `dict[int, dict]` | Event study effects (if the DEPRECATED fit-time aggregate included event_study; ALWAYS populated for StackedDiD since 3.9 - row M-024; post-fit `aggregate('event_study')` returns a CONTAINER, it does not populate this field) | -| `event_study_df` | `float | dict | None` | Producer-side per-row df channel for the unified event-study container. On `EfficientDiDResults`/`ImputationDiDResults` (3.12, M-092 completion): the scalar survey df governing the ES rows' t-inference (ImputationDiD threads the FINAL replicate-override value; leads included on survey fits); `None` on non-survey fits, on bootstrapped fits (percentile inference used no df), when no fit-time ES surface was built, and for the replicate-undefined `0` sentinel | -| `group_effects` | `dict` | Group-level effects (if the deprecated fit-time aggregate included group; post-fit `aggregate('group')` returns an AggregationResult container) | +| `event_study_effects` | `dict[int, dict] | None` | Legacy field. `None` on analytical fits and on every EfficientDiD fit; populated on bootstrapped ImputationDiD/TwoStageDiD fits (the percentile surface computed at fit, relayed by `aggregate('event_study')`); StackedDiD always populates it (row M-024). The canonical surface is the `aggregate('event_study')` CONTAINER | +| `event_study_df` | `float | dict | None` | Producer-side per-row df channel for the unified event-study container. On `EfficientDiDResults`/`ImputationDiDResults` (3.12, M-092 completion): the scalar survey df governing the ES rows' t-inference (ImputationDiD threads the FINAL replicate-override value; leads included on survey fits); `None` on non-survey fits, on bootstrapped fits (percentile inference used no df), on analytical fits (no surface is stored - the kit recomputes), and for the replicate-undefined `0` sentinel | +| `group_effects` | `dict | None` | Legacy field. `None` on analytical fits and on every EfficientDiD fit; populated on bootstrapped ImputationDiD/TwoStageDiD fits (relayed by `aggregate('group')`). The canonical surface is the `aggregate('group')` AggregationResult container | | `groups` | `list` | Treatment cohorts | | `time_periods` | `list` | All time periods | | `n_obs` | `int` | Number of observations | @@ -2135,7 +2123,7 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con | `n_clusters` | `int | None` | Number of effective clusters; `None` under survey designs and `None` under EfficientDiD's default unclustered fit | | `df_convention` | `str | None` | On the knob-carrying containers (`StackedDiDResults`, `ImputationDiDResults`, `WooldridgeDiDResults`, `LPDiDResults`, `SunAbrahamResults`): the configured df convention (3.9 / M-127); `StackedDiDResults` additionally carries `inference_df` (the overall-ATT df actually used) | -**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `to_dict()` (flat dict of headline aliases + `vcov_type` + conditional `cluster_name`/`n_clusters`/`n_bootstrap`/`inference_method`); `aggregate(type, weights=None, *, balance_e=None)` on the shipped post-fit adopters (`StackedDiDResults` views since 3.9/M-024; `EfficientDiDResults` recomputes from retained EIFs since 3.9/M-023 - on bootstrapped fits its recompute levels REPLAY the fit-time multiplier bootstrap (percentile inference; M-023); `ImputationDiDResults` and `TwoStageDiDResults` recompute from their PANEL-BACKED kits since 3.9/M-021/M-022 - on bootstrapped fits their recompute levels raise while `'simple'` and, where supported, `'total'` relay the stored bootstrap inference with a NaN df column (the per-level rule, converged with M-027); `'total'` (3.10) is the estimator-owned total incremental outcome on `CallawaySantAnnaResults`/`EfficientDiDResults`/`ImputationDiDResults`/`TwoStageDiDResults` - panel non-survey fits only, fails closed with the reason elsewhere; `ContinuousDiDResults` is MIXED since 3.9/M-025 - `'simple'`/`'dose'` are views over stored fields that work on any fit incl. bootstrapped, `'event_study'` recomputes from a pruned per-cell IF kit and raises on bootstrapped fits; `HeterogeneousAdoptionDiDResults` and `HeterogeneousAdoptionDiDEventStudyResults` are pure views since 3.9/M-027 - `'simple'` on the overall class, `'event_study'` on the event-study class, no kit, work on pickles from any release) +**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `to_dict()` (flat dict of headline aliases + `vcov_type` + conditional `cluster_name`/`n_clusters`/`n_bootstrap`/`inference_method`); `aggregate(type, weights=None, *, balance_e=None)` on the shipped post-fit adopters (`StackedDiDResults` views since 3.9/M-024; `EfficientDiDResults` recomputes from retained EIFs since 3.9/M-023 - on bootstrapped fits its recompute levels REPLAY the fit-time multiplier bootstrap (percentile inference; M-023); `ImputationDiDResults` and `TwoStageDiDResults` recompute from their PANEL-BACKED kits since 3.9/M-021/M-022 on analytical fits - on bootstrapped fits the ES/group percentile surfaces are computed at fit and `'event_study'`/`'group'` RELAY them (4.0; `balance_e=` unavailable there) while `'simple'` and, where supported, `'total'` relay the stored bootstrap inference with a NaN df column (the per-level rule, converged with M-027); `'total'` (3.10) is the estimator-owned total incremental outcome on `CallawaySantAnnaResults`/`EfficientDiDResults`/`ImputationDiDResults`/`TwoStageDiDResults` - panel non-survey fits only, fails closed with the reason elsewhere; `ContinuousDiDResults` is MIXED since 3.9/M-025 - `'simple'`/`'dose'` are views over stored fields that work on any fit incl. bootstrapped, `'event_study'` recomputes from a pruned per-cell IF kit on analytical fits and relays the percentile surface computed at fit on bootstrapped fits (4.0); `HeterogeneousAdoptionDiDResults` and `HeterogeneousAdoptionDiDEventStudyResults` are pure views since 3.9/M-027 - `'simple'` on the overall class, `'event_study'` on the event-study class, no kit, work on pickles from any release) ### ContinuousDiDResults @@ -2160,8 +2148,8 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con | `n_obs` | `int` | Number of observations | | `n_treated_units` | `int` | Treated units | | `n_control_units` | `int` | Control units | -| `event_study_effects` | `dict[int, dict] or None` | Event study effects (populated only by the DEPRECATED fit-time `aggregate="eventstudy"`; the post-fit successor `results.aggregate('event_study')` returns a unified `EventStudyResults` container instead of populating this field) | -| `event_study_df` | `float | None` | Scalar survey df governing the ES rows' t-inference (3.12, M-092 completion); `None` on non-survey fits, on bootstrapped fits, when no fit-time ES surface was built, and for the replicate-undefined `0` sentinel | +| `event_study_effects` | `dict[int, dict] or None` | Legacy field - `None` on analytical fits; on bootstrapped fits (`n_bootstrap > 0`) the percentile event study computed at fit, which `results.aggregate('event_study')` relays as a unified `EventStudyResults` container | +| `event_study_df` | `float | None` | Scalar survey df governing the ES rows' t-inference (3.12, M-092 completion); `None` on non-survey fits, on bootstrapped fits, on analytical fits (no surface stored), and for the replicate-undefined `0` sentinel | **DoseResponseCurve** sub-dataclass: @@ -2174,7 +2162,7 @@ Each event study effect dict contains: `effect`, `se`, `t_stat`, `p_value`, `con | `conf_int_upper` | `np.ndarray` | Upper CI bound | | `target` | `str` | `"att"` or `"acrt"` | -**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `aggregate(type, ...)` (M-025 MIXED adopter: `'simple'` = 2-row att+acrt view, `'dose'` = 2N-row target-discriminated curve view - both work on any fit; `'event_study'` = kit recompute, analytical fits only) +**Methods:** `summary()`, `print_summary()`, `to_dataframe()`, `aggregate(type, ...)` (M-025 MIXED adopter: `'simple'` = 2-row att+acrt view, `'dose'` = 2N-row target-discriminated curve view - both work on any fit; `'event_study'` = kit recompute on analytical fits, relay of the percentile surface computed at fit on bootstrapped fits - 4.0) ### HeterogeneousAdoptionDiDResults diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 769f81767..c026aaa07 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -63,13 +63,13 @@ results.att # ATT results.se # Standard error results.conf_int # Confidence interval -# CallawaySantAnna (with aggregate parameter): +# CallawaySantAnna (post-fit aggregate(); fit() takes no aggregate= keyword): results.overall_att # Overall ATT results.overall_se # Standard error -results.group_effects # Per-cohort ATTs (deprecated fit-time route only; - # post-fit: results.aggregate('group')) -results.event_study_effects # Dynamic effects (deprecated fit-time route only; - # post-fit: results.aggregate('event_study')) +results.aggregate('group') # Per-cohort ATTs (results.group_effects is + # None on every 4.0 fit) +results.aggregate('event_study') # Dynamic effects (results.event_study_effects + # is None on every 4.0 fit) # SunAbraham (no aggregate parameter — effects computed automatically): results.overall_att # Overall ATT @@ -405,8 +405,8 @@ Works with MultiPeriodDiD (deprecated - the TWFE event-study surface also admits estimated pre-periods exist). - For CS: pass the post-fit container - `compute_honest_did( - results.aggregate('event_study'))` - no refit needed (the fit-time - `aggregate=` route is deprecated since 3.9). BOOTSTRAPPED fits + results.aggregate('event_study'))` - no refit needed (`fit()` takes no + `aggregate=` keyword since 4.0). BOOTSTRAPPED fits (`n_bootstrap > 0`) included: the recompute levels REPLAY the fit-time multiplier bootstrap from the kit-retained RNG state (percentile inference; the container's vcov is the covariance of the @@ -483,8 +483,8 @@ Aggregate treatment effects may mask important variation. ### For CallawaySantAnna, EfficientDiD, ImputationDiD, TwoStageDiD (post-fit `aggregate()`) ```python -# Post-fit, no refit needed (fit-time aggregate= is deprecated since 3.9 -# for CS/StackedDiD/EfficientDiD/ImputationDiD/TwoStageDiD; removed in 4.0): +# Post-fit, no refit needed (the fit-time aggregate= / balance_e= keywords +# were removed at 4.0 on CS/StackedDiD/EfficientDiD/ImputationDiD/TwoStageDiD): results = cs.fit(data, ...) print(results.overall_att) # Overall ATT print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs @@ -496,23 +496,25 @@ print(results.aggregate('event_study', balance_e=2).to_dataframe()) print(results.aggregate('total').to_dataframe()) # BOOTSTRAPPED fits: the CS, DMLDiD, and EfficientDiD recompute levels # (event_study/group) REPLAY the fit-time multiplier bootstrap post-fit -# (percentile inference; no refit needed) — but they still RAISE on -# ImputationDiD/TwoStageDiD, where aggregate('simple') and, where -# supported, aggregate('total') relay the stored bootstrap inference and -# the deprecated fit-time aggregation remains the ES/group route: -results = imp.fit(data, ..., aggregate='all') # ImputationDiD/TwoStageDiD only +# (percentile inference; no refit needed). ImputationDiD/TwoStageDiD compute +# the percentile ES/group surfaces AT FIT when n_bootstrap > 0 and +# aggregate('event_study') / aggregate('group') RELAY them (balance_e= is +# unavailable there - re-fit with n_bootstrap=0 for a balanced window); +# aggregate('simple') / aggregate('total') relay the stored inference: +results = imp.fit(data, ...) # ImputationDiD(n_bootstrap=199) +print(results.aggregate('event_study').to_dataframe()) # relayed percentile surface ``` ### For ContinuousDiD (MIXED post-fit `aggregate()`, row M-025) ```python # Dose-response curves + overall ATT/ACRT are ALWAYS computed by fit(); # 'dose' and 'simple' are views that work on ANY fit (bootstrapped too): -results = cdid.fit(data, ...) # no aggregate= kwarg (deprecated) +results = cdid.fit(data, ...) # no aggregate= kwarg (removed at 4.0) print(results.aggregate('dose').to_dataframe()) # ATT(d) + ACRT(d) rows print(results.aggregate('simple').to_dataframe()) # att + acrt overall rows -# The binarized event study RECOMPUTES from the retained kit - analytical -# fits only (a bootstrapped fit raises; re-fit with n_bootstrap=0 or use -# the deprecated fit-time aggregate='eventstudy' until 4.0): +# The binarized event study RECOMPUTES from the retained kit on analytical +# fits; a bootstrapped fit (n_bootstrap > 0) computes the percentile event +# study at fit and aggregate('event_study') relays it (4.0): print(results.aggregate('event_study').to_dataframe()) ``` diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 46567e0f5..345431037 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -21,7 +21,7 @@ diagnostic steps produces unreliable results. 4. **Choose estimator** — staggered adoption → CS/SA/BJS (NOT plain TWFE); few treated units → SDiD; factor confounding → TROP; simple 2x2 → DiD. Run `BaconDecomposition` to diagnose TWFE bias. 5. **Estimate** — `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer — for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). 6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. -7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; the container's vcov is the covariance of the bootstrap draws, so its diagonal is the bootstrap se**2), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. +7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (the fit-time `aggregate=`/`balance_e=` keywords were removed at 4.0 and `results.event_study_effects`/`group_effects` are None on every CS fit; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; the container's vcov is the covariance of the bootstrap draws, so its diagonal is the bootstrap se**2), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; the fit-time keywords were removed at 4.0; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the percentile ES/group surfaces are computed at fit and `.aggregate('event_study')`/`.aggregate('group')` RELAY them (4.0; `balance_e=` unavailable there) while `.aggregate('simple')` relays the stored bootstrap inference; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit on analytical fits and relays the percentile surface computed at fit on bootstrapped fits (4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. 8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), MUST report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. After estimation, call `practitioner_next_steps(results)` for context-aware diff --git a/diff_diff/had.py b/diff_diff/had.py index 8e21c467c..8e513fdb4 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -73,7 +73,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationMixin, AggregationResult, resolve_inference_df from diff_diff.bootstrap_chunking import ( compute_block_size, @@ -121,19 +120,19 @@ "continuous_near_d_lower", "mass_point", ) -_VALID_AGGREGATES = ("overall", "event_study") def _infer_aggregate_mode(data: pd.DataFrame, time_col: str) -> str: - """Select the estimation mode from the panel shape (rows M-027/M-139). + """Select the estimation mode from the panel shape. - ``fit(aggregate=)`` and ``did_had_pretest_workflow(aggregate=)`` are - deprecated; when the caller does not supply a mode, both surfaces - resolve it HERE from the same rule so they cannot drift: two distinct - time values -> the two-period ``"overall"`` estimator, more than two - -> the multi-period ``"event_study"`` estimator. Fewer than two - distinct values routes to the overall branch, whose panel validator - raises the existing two-period message. + THE mode selector for both :meth:`HeterogeneousAdoptionDiD.fit` and + :func:`did_had_pretest_workflow` (neither takes a mode keyword since + 4.0); both surfaces resolve the mode HERE from the same rule so they + cannot drift: two distinct time values -> the two-period + ``"overall"`` estimator, more than two -> the multi-period + ``"event_study"`` estimator. Fewer than two distinct values routes to + the overall branch, whose panel validator raises the existing + two-period message. The column check mirrors ``_validate_had_panel`` so a missing time column stays a ``ValueError`` naming the column (never a raw @@ -2790,10 +2789,10 @@ class HeterogeneousAdoptionDiD(BaseEstimator): the last-treatment cohort plus never-treated units (paper Appendix B.2 prescription). - ``fit()`` selects the mode from the panel shape; the deprecated - ``fit(aggregate=)`` override warns and dies in 4.0 (row M-027). - Post-fit, ``results.aggregate('simple')`` (overall fits) and - ``results.aggregate('event_study')`` (event-study fits) expose the + ``fit()`` selects the mode from the panel shape (two distinct periods + -> overall; more -> event study); there is no mode keyword. Post-fit, + ``results.aggregate('simple')`` (two-period fits) and + ``results.aggregate('event_study')`` (multi-period fits) expose the unified aggregation vocabulary. Parameters @@ -2861,7 +2860,7 @@ class HeterogeneousAdoptionDiD(BaseEstimator): clustering route through ``survey_design=SurveyDesign(weights='', psu='')`` instead). Cluster must be constant within unit. On the event-study path - (``aggregate="event_study"``, Phase 2b) ``cluster=`` provides + (multi-period panel, Phase 2b) ``cluster=`` provides cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous sup-t band (``cband=True``, fires even on unweighted fits); ``cluster=`` + ``survey_design=`` is rejected @@ -2910,7 +2909,7 @@ class HeterogeneousAdoptionDiD(BaseEstimator): non-testability caveat is surfaced by the Design 1 fit-time ``UserWarning`` and by T21 (HAD pretest workflow tutorial) prose, NOT by the composite workflow verdict string (which only flags the - Assumption 7 step-2 gap on the two-period ``aggregate="overall"`` + Assumption 7 step-2 gap on the two-period overall path). **Diagnostics coverage.** ``HeterogeneousAdoptionDiDResults.bandwidth_diagnostics`` @@ -2972,7 +2971,7 @@ def __init__( # Event-study sup-t simultaneous-CI support. ``n_bootstrap`` = # number of multiplier-bootstrap replicates for the sup-t band; # ``seed`` = reproducibility seed for the multiplier draws. Both are - # consulted when ``aggregate="event_study"`` + ``cband=True`` + # consulted on a multi-period (event-study) fit with ``cband=True`` # (default) AND either ``survey_design=`` (weighted/survey band, # Phase 4.5 B) OR ``cluster=`` (cluster-robust band, Phase 2b # — fires even unweighted). An unweighted, unclustered event-study @@ -3047,7 +3046,6 @@ def fit( time: str, unit: str, first_treat: Optional[str] = None, - aggregate: Any = NOT_SUPPLIED, *, cband: bool = True, survey_design: Any = None, @@ -3063,8 +3061,8 @@ def fit( "event_study" mode and returns a :class:`HeterogeneousAdoptionDiDEventStudyResults` with per- event-time WAS estimates using a uniform ``F-1`` anchor (paper - Appendix B.2). The deprecated ``aggregate=`` override (row - M-027) warns and is removed in 4.0. + Appendix B.2). There is no mode keyword: the panel shape is the + only selector. Both the overall and event-study paths are **panel-only**: the paper (Section 2) defines HAD on panel or repeated-cross-section data, @@ -3102,30 +3100,13 @@ def fit( (``did_multiplegt_dyn``) for full staggered support. See REGISTRY § "Library extension: Staggered-timing fail- closed" for the rationale on raising vs. warning. - aggregate : {"overall", "event_study"}, optional - DEPRECATED mode override (row M-027; ``FutureWarning``, - removed in 4.0). When omitted, ``fit()`` selects the mode - from the panel shape: two distinct periods -> "overall" - (Phase 2a, single-period WAS, - :class:`HeterogeneousAdoptionDiDResults`); more -> - "event_study" (Phase 2b, paper Appendix B.2, per-event-time - WAS, :class:`HeterogeneousAdoptionDiDEventStudyResults`; - pointwise CIs per horizon - joint cross-horizon covariance - is deferred). Supplying ANY value warns and then runs the - legacy routing unchanged (invalid values still raise - ``ValueError``; a supplied mode that mismatches the panel - shape still raises the shape error). Staggered-timing - panels: see the ``first_treat`` contract above (auto-filter - to last cohort + never-treated with ``UserWarning`` when - supplied; fail-closed ``ValueError`` when omitted on a - staggered panel). survey_design : SurveyDesign or None, keyword-only Survey design (sampling weights + optional strata / PSU / FPC) - for design-based inference. Supported on ALL design × aggregate + for design-based inference. Supported on ALL design × mode combinations after Phase 4.5 B: continuous paths (``continuous_at_zero``, ``continuous_near_d_lower``) on both - ``aggregate="overall"`` and ``aggregate="event_study"``, AND - the ``mass_point`` design on both aggregates. Continuous paths + the two-period (overall) and the multi-period (event-study) + panel, AND the ``mass_point`` design on both. Continuous paths compose the SE via :func:`compute_survey_if_variance` (Binder 1983 TSL); weights propagate pointwise into the lprobust kernel. Mass-point composes the per-unit 2SLS IF on the @@ -3144,15 +3125,15 @@ def fit( dispatch matrix. cband : bool, default True Controls the multiplier-bootstrap simultaneous confidence band - on the event-study path. When ``True`` (default) and - ``aggregate="event_study"`` AND either (a) ``survey_design=`` + on the event-study path. When ``True`` (default) on a + multi-period panel AND either (a) ``survey_design=`` is supplied (weighted/survey band, Phase 4.5 B) OR (b) ``cluster=`` is supplied (cluster-robust band — fires even on an unweighted fit, Phase 2b), the fit populates ``cband_low`` / ``cband_high`` / ``cband_crit_value`` / ``cband_method`` / ``cband_n_bootstrap`` on the result. When - ``False`` those fields stay ``None``. No effect on - ``aggregate="overall"`` or on unweighted, unclustered event- + ``False`` those fields stay ``None``. No effect on a + two-period (overall) fit or on unweighted, unclustered event- study. ``n_bootstrap`` and ``seed`` (constructor params) control replicate count and RNG; defaults are 999 / ``None``. trends_lin : bool, default False, keyword-only @@ -3162,10 +3143,10 @@ def fit( is estimated as ``Y[g, F-1] - Y[g, F-2]``; each event-time ``e`` evolution is replaced by ``dy_dict[e] - (e+1) × slope`` (uniform formula that absorbs both effect-side - detrending and placebo-side anchor swap). Requires - ``aggregate="event_study"`` AND ``F >= 3`` (panel must - include both ``F-1`` and ``F-2``); raises - ``NotImplementedError`` on ``aggregate="overall"`` and + detrending and placebo-side anchor swap). Requires a + multi-period panel (event-study mode) AND ``F >= 3`` (panel + must include both ``F-1`` and ``F-2``); raises + ``NotImplementedError`` on a two-period panel and ``ValueError`` on ``F < 3``. The "consumed" placebo at event time ``e=-2`` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Mutually @@ -3194,31 +3175,13 @@ def fit( Notes ----- - On the ``aggregate="overall"`` path, ``fit()`` emits a ``UserWarning`` + On the two-period (overall) path, ``fit()`` emits a ``UserWarning`` when a non-trivial fraction (``>= 10%``, a library convention) of units have exactly-zero post-period dose — a genuine untreated mass for which a standard DiD may be more appropriate (de Chaisemartin et al. 2026, Section 2). The event-study path does not warn: it *requires* never-treated units per Appendix B.2. """ - # M-027 deprecation shim: a plain fit() never warns; supplying - # aggregate= with ANY value (None included) warns once, then the - # legacy routing below runs unchanged - invalid values still - # reach the pre-existing _VALID_AGGREGATES ValueError. The - # SENTINEL resolves to a panel-inferred mode at the - # _VALID_AGGREGATES check site below. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "HeterogeneousAdoptionDiD.fit(aggregate=) is deprecated " - "and will be removed in 4.0. fit() now selects the mode " - "from the panel shape (two distinct periods -> the " - "overall WAS estimator; more -> the event-study " - "estimator), and aggregation is a post-fit step: " - "results.aggregate('simple') on a two-period fit / " - "results.aggregate('event_study') on a multi-period fit.", - FutureWarning, - stacklevel=2, - ) # Body-local ``*_col`` names feed the private helpers; the public # parameters are the bare names. outcome_col = outcome @@ -3226,15 +3189,10 @@ def fit( time_col = time unit_col = unit first_treat_col = first_treat - # ---- aggregate / survey_design validation ---- - # Sentinel resolution (M-027): infer the mode from the panel shape - # when aggregate= was not supplied. - if aggregate is NOT_SUPPLIED: - aggregate = _infer_aggregate_mode(data, time_col) - elif aggregate not in _VALID_AGGREGATES: - raise ValueError( - f"Invalid aggregate={aggregate!r}. Must be one of " f"{_VALID_AGGREGATES}." - ) + # ---- mode selection ---- + # The panel shape is the only selector (two distinct periods -> + # "overall"; more -> "event_study"); there is no mode keyword. + mode = _infer_aggregate_mode(data, time_col) # ---- covariates= future-work trap (TODO L73) ---- # Covariate-adjusted HAD identification (de Chaisemartin et al. 2026, @@ -3243,7 +3201,7 @@ def fit( # (vs the bare TypeError a missing kwarg would raise) while leaving # unknown-kwarg typos a normal TypeError. Placed after the survey/weights # mutex and before the event-study dispatch so the single raise covers BOTH - # aggregate="overall" and aggregate="event_study". + # the two-period (overall) and the multi-period (event-study) mode. if covariates is not None: raise NotImplementedError( "HeterogeneousAdoptionDiD.fit(covariates=...) is not yet " @@ -3263,7 +3221,7 @@ def fit( # panel) and is currently event-study-only — the overall path is # 2-period and cannot accommodate the F-2 row. if trends_lin: - if aggregate != "event_study": + if mode != "event_study": raise NotImplementedError( "HAD.fit(trends_lin=True) requires a multi-period " "panel (the linear-trend slope estimator needs Y at " @@ -3319,9 +3277,9 @@ def fit( # The static return-type annotation on `fit()` is # Union[HeterogeneousAdoptionDiDResults, # HeterogeneousAdoptionDiDEventStudyResults]; callers should - # narrow via isinstance (or by the aggregate they passed) to - # access aggregate-specific fields. - if aggregate == "event_study": + # narrow via isinstance (or by the panel shape they passed) to + # access mode-specific fields. + if mode == "event_study": return self._fit_event_study( data=data, outcome_col=outcome_col, diff --git a/diff_diff/had_pretests.py b/diff_diff/had_pretests.py index a1c171170..d869479d6 100644 --- a/diff_diff/had_pretests.py +++ b/diff_diff/had_pretests.py @@ -35,7 +35,8 @@ Composite workflow: :func:`did_had_pretest_workflow` has two dispatch modes, selected from -the panel shape (the deprecated ``aggregate=`` override is row M-139): +the panel shape (there is no mode keyword; the selected mode is recorded +on the report's ``aggregate`` field): - The "overall" battery (two-period panel): runs steps 1 + 3 via :func:`qug_test` + :func:`stute_test` + :func:`yatchew_hr_test`. @@ -77,7 +78,6 @@ import pandas as pd from scipy import stats -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.bootstrap_utils import ( apply_stratum_centering, generate_survey_multiplier_weights_batch, @@ -707,7 +707,7 @@ class HADPretestReport(Diagnostic): def __repr__(self) -> str: # Preserve Phase 3 repr bit-exactly on the overall path. The - # aggregate kwarg is only surfaced on the event-study path so + # aggregate FIELD is only surfaced on the event-study path so # downstream consumers comparing repr strings on two-period # reports see identical output. if self.aggregate == "event_study": @@ -1451,8 +1451,8 @@ def qug_test( "boundary tests; no off-the-shelf survey-aware QUG exists.\n" "\n" "For survey-aware HAD pretesting, use the joint Stute family " - "via did_had_pretest_workflow(..., survey_design=..., " - "aggregate=...) -- shipped in Phase 4.5 C. The workflow " + "via did_had_pretest_workflow(..., survey_design=...) on a " + "multi-period panel -- shipped in Phase 4.5 C. The workflow " "skips the QUG step under survey_design= with a UserWarning " "and runs the linearity family with a PSU-level Mammen " "multiplier bootstrap (Stute) + weighted OLS + pweight-" @@ -3405,9 +3405,9 @@ def joint_pretrends_test( that the conditional mean depends on ``D``. Use this wrapper to close the paper's step-2 pre-trends gap that - :func:`did_had_pretest_workflow` otherwise flags. On a panel with - at least one earlier pre-period, the - ``aggregate="event_study"`` dispatch calls this wrapper internally. + :func:`did_had_pretest_workflow` otherwise flags. On a multi-period + panel with at least one earlier pre-period, the workflow's + event-study dispatch calls this wrapper internally. Parameters ---------- @@ -3797,8 +3797,8 @@ def joint_homogeneity_test( ``[1, D_g]``; the joint CvM tests whether the conditional mean is nonlinear in ``D`` in any horizon. - Used by :func:`did_had_pretest_workflow` with - ``aggregate="event_study"`` as the step-3 test (no joint Yatchew + Used by :func:`did_had_pretest_workflow` on a multi-period panel + (the event-study dispatch) as the step-3 test (no joint Yatchew variant exists - the paper does not derive one; users who need Yatchew-style adjacent-difference robustness can call :func:`yatchew_hr_test` on each (base, post) pair manually). @@ -4097,8 +4097,6 @@ def joint_homogeneity_test( ) -_VALID_AGGREGATES = ("overall", "event_study") - _QUG_DEFERRED_SUFFIX = ( " (linearity-conditional verdict; QUG-under-survey deferred per Phase 4.5 C0)" ) @@ -4223,14 +4221,14 @@ def did_had_pretest_workflow( n_bootstrap: int = 999, seed: Optional[int] = None, *, - aggregate: Any = NOT_SUPPLIED, survey_design: Any = None, trends_lin: bool = False, ) -> HADPretestReport: """Run the HAD pre-test workflow (paper Section 4.2-4.3). - Two dispatch modes, selected from the panel shape (row M-139; the - deprecated ``aggregate=`` override warns and dies in 4.0): + Two dispatch modes, selected from the panel shape - the same rule + :meth:`HeterogeneousAdoptionDiD.fit` uses; there is no mode keyword. + The selected mode is recorded on the report's ``aggregate`` field: The overall battery (two-period panel): runs paper steps 1 (:func:`qug_test`) and 3 (:func:`stute_test` + @@ -4262,22 +4260,22 @@ def did_had_pretest_workflow( ``base_period - 1`` is auto-dropped from step 2 and the workflow skips step 2 (``pretrends_joint=None``) if no earlier placebo survives. Mirrors R ``DIDHAD::did_had(..., trends_lin=TRUE)``. - Mutually exclusive with ``aggregate="overall"`` (raises + Mutually exclusive with the two-period (overall) battery (raises ``NotImplementedError``). Parameters ---------- data : pd.DataFrame - HAD panel. For ``aggregate="overall"``: balanced two-period - panel with pre-period dose = 0 for every unit. For - ``aggregate="event_study"``: balanced multi-period panel with + HAD panel. Two-period (overall battery): balanced two-period + panel with pre-period dose = 0 for every unit. Multi-period + (event-study battery): balanced multi-period panel with >= 3 periods, an ordered time dtype (numeric, datetime, or ordered categorical), and the pre-period D=0 invariant across all pre-periods. outcome, dose, time, unit : str first_treat : str or None, default None Optional first-treatment-period column. Required on the - ``aggregate="event_study"`` path when the panel is staggered + multi-period (event-study) path when the panel is staggered (multi-cohort); the panel validator auto-filters to the last cohort and emits ``UserWarning``. The overall path uses this for cross-validation only. @@ -4288,13 +4286,6 @@ def did_had_pretest_workflow( seed : int or None, default None Seed forwarded to the Stute bootstrap. QUG / Yatchew are deterministic. - aggregate : {"overall", "event_study"}, optional, keyword-only - DEPRECATED dispatch override (row M-139; ``FutureWarning``, - removed in 4.0). When omitted, the workflow selects the battery - from the panel shape - the same rule - :meth:`HeterogeneousAdoptionDiD.fit` uses. Supplying ANY value - warns and then runs the legacy routing unchanged; invalid - values still raise ``ValueError``. survey_design : SurveyDesign or None, keyword-only, default None Survey design for design-based pretest inference. Linearity-family pretests use PSU-level Mammen multiplier bootstrap (Stute family) @@ -4327,16 +4318,16 @@ def did_had_pretest_workflow( ``pretrends_joint`` / ``homogeneity_joint`` are ``None``. On the event-study path: ``pretrends_joint`` (``None`` if no earlier pre-period) and ``homogeneity_joint`` populated, ``stute`` / - ``yatchew`` are ``None``. ``aggregate`` is recorded on the - report for serialization dispatch. On the survey_design= path, - ``qug`` is ``None`` (Phase 4.5 C0 deferral); other components - populated as on the unweighted path. + ``yatchew`` are ``None``. The selected mode is recorded on the + report's ``aggregate`` field for serialization dispatch. On the + survey_design= path, ``qug`` is ``None`` (Phase 4.5 C0 deferral); + other components populated as on the unweighted path. Raises ------ ValueError - On invalid ``aggregate``; or any downstream front-door failure - (panel balance, dtype, dose invariant). + On any front-door failure (panel balance, dtype, dose + invariant). NotImplementedError If ``survey.replicate_weights is not None`` (replicate-weight pretests deferred to a parallel follow-up after Phase 4.5 C). @@ -4357,7 +4348,7 @@ def did_had_pretest_workflow( family (``d_lower > 0``). Assumptions 5/6 are non-testable via pre-trends. The composite verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on - the two-period ``aggregate="overall"`` path. The Assumption 5/6 + the two-period (overall) path. The Assumption 5/6 caveat is surfaced separately by (a) the ``HeterogeneousAdoptionDiD.fit()`` fit-time ``UserWarning`` (which fires whenever the resolved design is Design 1 family — @@ -4380,17 +4371,17 @@ def did_had_pretest_workflow( survey deferred per Phase 4.5 C0"`` suffix to remind callers that admissibility is conditional on the linearity family alone. 4. **`all_pass`** drops the QUG-conclusiveness gate (one less - precondition). The linearity-conditional rule splits by aggregate: + precondition). The linearity-conditional rule splits by mode: - - ``aggregate="overall"`` survey: ``True`` iff at least one of + - two-period (overall) survey: ``True`` iff at least one of Stute/Yatchew is conclusive AND no conclusive test rejects (paper Section 4 step-3 "Stute OR Yatchew" wording). - - ``aggregate="event_study"`` survey: ``True`` iff + - multi-period (event-study) survey: ``True`` iff ``pretrends_joint`` is non-None and conclusive, ``homogeneity_joint`` is conclusive, AND neither rejects. Both joint variants must be conclusive on the event-study path (same step-2 + step-3 closure as the unweighted - aggregate, just without the QUG step). + battery, just without the QUG step). Sister pretests are unchanged on the workflow path; direct callers can also pass ``survey_design=`` to :func:`stute_test`, @@ -4405,22 +4396,6 @@ def did_had_pretest_workflow( de Chaisemartin et al. (2026), Section 4.2-4.3, Theorem 4, Appendix D, Theorem 7. """ - # M-139 deprecation shim (the fit-side twin is row M-027): a plain - # workflow call never warns; supplying aggregate= with ANY value - # warns once, then the legacy routing runs unchanged. The SENTINEL - # resolves to the same panel-inferred mode fit() uses at the - # _VALID_AGGREGATES check site below. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "did_had_pretest_workflow(aggregate=) is deprecated and " - "will be removed in 4.0. The workflow now selects its " - "dispatch from the panel shape (two distinct periods -> " - "the two-period pretest battery; more -> the multi-period " - "event-study battery), the same rule " - "HeterogeneousAdoptionDiD.fit() uses.", - FutureWarning, - stacklevel=2, - ) # Body-local ``*_col`` names feed the private helpers; the public # parameters are the bare names. outcome_col = outcome @@ -4428,14 +4403,10 @@ def did_had_pretest_workflow( time_col = time unit_col = unit first_treat_col = first_treat - # Sentinel resolution (M-139, mirroring fit's M-027 shim): infer the - # mode from the panel shape when aggregate= was not supplied. - if aggregate is NOT_SUPPLIED: - aggregate = _infer_aggregate_mode(data, time_col) - elif aggregate not in _VALID_AGGREGATES: - raise ValueError( - f"aggregate must be one of {list(_VALID_AGGREGATES)!r}; " f"got {aggregate!r}." - ) + # Mode selection: the panel shape is the only selector (two distinct + # periods -> the two-period battery; more -> the event-study battery), + # the same rule HeterogeneousAdoptionDiD.fit() uses. + mode = _infer_aggregate_mode(data, time_col) # ---- trends_lin scope gate (PR #392 R1 P1). # `trends_lin=True` is only meaningful on the event-study path because @@ -4443,7 +4414,7 @@ def did_had_pretest_workflow( # overall path runs qug + stute + yatchew on a 2-period panel and has # no joint-pretest surface to receive the kwarg. Front-door reject # rather than silently ignore. - if trends_lin and aggregate != "event_study": + if trends_lin and mode != "event_study": raise NotImplementedError( "did_had_pretest_workflow(trends_lin=True) requires a " "multi-period panel (the trends_lin kwarg forwards into " @@ -4454,11 +4425,11 @@ def did_had_pretest_workflow( ) # R6 P1 fix: do NOT call _resolve_pretest_unit_weights on the FULL panel - # here -- under aggregate='event_study' the panel may be staggered and the - # cohort filter at _validate_multi_period_panel can drop units. If those - # dropped units have zero/invalid weights, eager full-panel resolution + # here -- on a multi-period (event-study) panel the panel may be staggered + # and the cohort filter at _validate_multi_period_panel can drop units. If + # those dropped units have zero/invalid weights, eager full-panel resolution # would abort an otherwise-valid event-study run. Defer resolution to the - # per-aggregate branches: overall path resolves on the original data (no + # per-mode branches: overall path resolves on the original data (no # filtering); event-study path lets the joint wrappers handle resolution # on data_filtered. # Internal variable naming: downstream code reads `survey` (Phase 4.5 C @@ -4479,7 +4450,7 @@ def did_had_pretest_workflow( stacklevel=2, ) - if aggregate == "event_study": + if mode == "event_study": F, t_pre_list, t_post_list, data_filtered, _filter_info = _validate_multi_period_panel( data, outcome_col=outcome_col, diff --git a/diff_diff/honest_did.py b/diff_diff/honest_did.py index b74d9368c..0273fa1b5 100644 --- a/diff_diff/honest_did.py +++ b/diff_diff/honest_did.py @@ -1312,9 +1312,7 @@ def _extract_event_study_params( f"{type(results).__name__} must have event_study_effects " "for HonestDiD. Pass the post-fit container " "(compute_honest_did(results.aggregate('event_study')))" - " instead; CallawaySantAnna fits can alternatively use " - "the deprecated aggregate='event_study' to populate " - "the fit-time surface." + " instead." ) # Common-reference guard, mirroring the container branch: diff --git a/diff_diff/imputation.py b/diff_diff/imputation.py index 41b654a4a..66d326802 100644 --- a/diff_diff/imputation.py +++ b/diff_diff/imputation.py @@ -32,7 +32,6 @@ from scipy import stats from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationKit from diff_diff.imputation_aggregation import ( # noqa: F401 (compat re-exports) _compute_target_weights, @@ -152,8 +151,7 @@ class ImputationDiD(ImputationDiDBootstrapMixin, _ImputationAggregationMixin, Ba df_convention : {"residual", "cluster", "normal"}, default "residual" Degrees-of-freedom convention for the PRETRENDS lead regression's per-lead t/p/CI — the one ImputationDiD surface running the shared - clustered CR1 sandwich (``pretrends=True``, surfaced fit-time via - the deprecated ``aggregate="event_study"``/``"all"`` or post-fit via + clustered CR1 sandwich (``pretrends=True``, surfaced via ``results.aggregate('event_study')``). ``"residual"`` (default) uses the lead regression's residual df (``n − k_kept − absorbed [time, unit] rank``) — the 3.9 fix: previously silent normal-theory @@ -271,8 +269,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, survey_design: Optional["SurveyDesign"] = None, ) -> ImputationDiDResults: """ @@ -293,20 +289,6 @@ def fit( Use 0 (or np.inf) for never-treated units. covariates : list of str, optional List of covariate column names. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; row M-021): aggregate as a - post-fit step instead — ``results.aggregate('event_study')`` / - ``.aggregate('group')`` / ``.aggregate('simple')`` / - ``.aggregate('total')``. Supplying - ANY value (``None`` included) warns; the deprecated path still - works and returns exactly the numbers it always did - (fit-time mode: None/"simple" overall only, "event_study", - "group", or "all"). - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0; row M-118): moves onto - ``results.aggregate('event_study', balance_e=...)``. Restricts - the event study to cohorts observed at every relative time in - ``[-balance_e, max_h]`` (the balanced-window rule). survey_design : SurveyDesign, optional Survey design specification for design-based inference. Supports pweight only (aweight/fweight raise ValueError). Supports strata, @@ -324,38 +306,6 @@ def fit( ValueError If required columns are missing or data validation fails. """ - # M-021/M-118 deprecation shim (CS-style joint warning): a plain - # fit() never warns; supplying EITHER param with ANY value (None - # included) warns once, then the legacy routing below runs - # unchanged - the deprecated path returns exactly the numbers it - # always did (no new value validation; unknown strings still act - # like None). The post-fit successor validates its own vocabulary. - # The sentinel is normalized HERE, before every downstream read of - # ``aggregate`` (the pretrends+replicate gate and the df_convention - # reachability warning below both read it). - _deprecated_passed = [ - n - for n, v in (("aggregate", aggregate), ("balance_e", balance_e)) - if v is not NOT_SUPPLIED - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"ImputationDiD.fit({_args}) is deprecated and will be " - "removed in 4.0. Fit once, then aggregate as a post-fit " - "step: results = ImputationDiD().fit(...); " - "results.aggregate('event_study') / .aggregate('group') / " - ".aggregate('simple') / .aggregate('total'). balance_e moves onto aggregate() " - "alongside it: results.aggregate('event_study', " - "balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if aggregate is NOT_SUPPLIED: - aggregate = None - if balance_e is NOT_SUPPLIED: - balance_e = None - # Re-validate vcov_type at fit-time: set_params validates eagerly # (BaseEstimator probe re-init), so this only catches DIRECT # attribute mutation (est.vcov_type = ...). @@ -375,54 +325,32 @@ def fit( if missing: raise ValueError(f"Missing columns: {missing}") - # pretrends + analytical survey is supported (Phase 8e-iii). - # Replicate-weight surveys need per-replicate lead regression refits - # which are not yet implemented — reject that combination. - if ( - self.pretrends - and survey_design is not None - and survey_design.replicate_method is not None - and aggregate in ("event_study", "all") - ): - raise NotImplementedError( - "pretrends=True is not yet compatible with replicate-weight " - "survey designs. Analytical survey designs (strata/PSU/FPC) " - "are supported. Use pretrends=False with replicate weights." - ) + # (pretrends + replicate-weight survey: the analytical combination + # is rejected post-fit by ImputationDiDResults._aggregate_compute; + # replicate + n_bootstrap > 0 is rejected below, before any surface + # is materialized.) # Inert-config warning (no-silent-failures): the df_convention knob # moves only the pretrends lead regression's per-lead t/p/CI. Since - # the M-021 post-fit migration that inference is REACHABLE from any - # analytical pretrends=True fit via results.aggregate('event_study'), - # so the predicate is reachability-based, not aggregate-keyed: - # reachable iff pretrends AND not replicate-weight (the gate above - # rejects fit-time ES and the post-fit path fails closed too) AND - # (the deprecated fit-time ES/all was supplied OR n_bootstrap <= 0 — - # a bootstrapped fit builds no ES surface and post-fit aggregate() - # fails closed on it; validate_n_bootstrap rejects negatives at - # __init__, so 0 is the only reachable off value and `<= 0` is - # equivalent to `== 0` — kept as-is, no behavior change). Reachability-BASED, not exact: a - # fit whose bootstrap later FAILS (bootstrap_results=None) can still - # aggregate post-fit, so that corner warns spuriously — the warning - # fires before the bootstrap runs and cannot know. (The post-fit - # ``pretrend_test()`` reaches the lead helper too, but consumes only - # gamma/V_gamma — its joint Wald F denominator is knob-independent.) + # 4.0 that inference is REACHABLE from every pretrends=True fit via + # results.aggregate('event_study') - analytical fits recompute it + # from the kit, bootstrapped fits materialize the lead surface at + # fit and relay it - EXCEPT under a replicate-weight design, where + # the post-fit route fails closed (per-replicate lead refits are not + # implemented). So: reachable iff pretrends AND not replicate. + # (The post-fit ``pretrend_test()`` reaches the lead helper too, but + # consumes only gamma/V_gamma — its joint Wald F denominator is + # knob-independent.) _is_replicate_design = ( survey_design is not None and survey_design.replicate_method is not None ) - _lead_inference_reachable = ( - self.pretrends - and not _is_replicate_design - and (aggregate in ("event_study", "all") or self.n_bootstrap <= 0) - ) + _lead_inference_reachable = self.pretrends and not _is_replicate_design if self.df_convention != "residual" and not _lead_inference_reachable: warnings.warn( f"df_convention={self.df_convention!r} affects only the " "pretrends event-study per-lead inference (pretrends=True, " - "surfaced fit-time via the deprecated aggregate=" - "'event_study'/'all' or post-fit via " - "results.aggregate('event_study')); it has no effect on " - "this configuration.", + "surfaced via results.aggregate('event_study')); it has no " + "effect on this configuration.", UserWarning, stacklevel=2, ) @@ -764,56 +692,70 @@ def fit( overall_att, overall_se, alpha=self.alpha, df=_survey_df ) - # Event study and group aggregation (full-sample, for point estimates) - event_study_effects = None - group_effects = None - - if aggregate in ("event_study", "all"): - event_study_effects = self._aggregate_event_study( - df=df, - outcome=outcome, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - omega_0_mask=omega_0_mask, - omega_1_mask=omega_1_mask, - unit_fe=unit_fe, - time_fe=time_fe, - grand_mean=grand_mean, - delta_hat=delta_hat, - cluster_var=cluster_var, - treatment_groups=treatment_groups, - balance_e=balance_e, - kept_cov_mask=kept_cov_mask, - survey_weights=survey_weights, - survey_df=_survey_df, - resolved_survey=(resolved_survey if not _uses_replicate_imp else None), - proj_cache=proj_cache, - ) - - if aggregate in ("group", "all"): - group_effects = self._aggregate_group( - df=df, - outcome=outcome, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - omega_0_mask=omega_0_mask, - omega_1_mask=omega_1_mask, - unit_fe=unit_fe, - time_fe=time_fe, - grand_mean=grand_mean, - delta_hat=delta_hat, - cluster_var=cluster_var, - treatment_groups=treatment_groups, - kept_cov_mask=kept_cov_mask, - survey_weights=survey_weights, - survey_df=_survey_df, - resolved_survey=(resolved_survey if not _uses_replicate_imp else None), - proj_cache=proj_cache, - ) + # Event study and group aggregation (full-sample, for point estimates). + # Materialized at fit ONLY where the post-fit route cannot recompute + # it: a bootstrapped fit (n_bootstrap > 0) needs the ES + group + # targets inside the multiplier bootstrap (per-draw state is not + # retained), so the surfaces are built here with balance_e=None and + # RELAYED by results.aggregate('event_study') / ('group'). Analytic + # fits leave both None and recompute from the kit on demand. + # FAIL-SOFT by design: any exception while materializing leaves the + # surfaces None (the bootstrap then covers the overall ATT only - + # today's plain-fit path) and is reported once the bootstrap + # outcome is known (see the storage rule below). + event_study_effects: Optional[Dict[int, Dict[str, Any]]] = None + group_effects: Optional[Dict[Any, Dict[str, Any]]] = None + _surface_failure: Optional[BaseException] = None + + if self.n_bootstrap > 0: + try: + event_study_effects = self._aggregate_event_study( + df=df, + outcome=outcome, + unit=unit, + time=time, + first_treat=first_treat, + covariates=covariates, + omega_0_mask=omega_0_mask, + omega_1_mask=omega_1_mask, + unit_fe=unit_fe, + time_fe=time_fe, + grand_mean=grand_mean, + delta_hat=delta_hat, + cluster_var=cluster_var, + treatment_groups=treatment_groups, + balance_e=None, + kept_cov_mask=kept_cov_mask, + survey_weights=survey_weights, + survey_df=_survey_df, + resolved_survey=(resolved_survey if not _uses_replicate_imp else None), + proj_cache=proj_cache, + ) + group_effects = self._aggregate_group( + df=df, + outcome=outcome, + unit=unit, + time=time, + first_treat=first_treat, + covariates=covariates, + omega_0_mask=omega_0_mask, + omega_1_mask=omega_1_mask, + unit_fe=unit_fe, + time_fe=time_fe, + grand_mean=grand_mean, + delta_hat=delta_hat, + cluster_var=cluster_var, + treatment_groups=treatment_groups, + kept_cov_mask=kept_cov_mask, + survey_weights=survey_weights, + survey_df=_survey_df, + resolved_survey=(resolved_survey if not _uses_replicate_imp else None), + proj_cache=proj_cache, + ) + except Exception as e: # noqa: BLE001 - fail-soft by design + _surface_failure = e + event_study_effects = None + group_effects = None # Replicate variance: derive keys from actual outputs (after filtering) if _uses_replicate_imp: @@ -834,7 +776,7 @@ def fit( overall_att=overall_att, event_study_effects=event_study_effects, group_effects=group_effects, - balance_e=balance_e, + balance_e=None, survey_df_seed=_survey_df, ) overall_se = float(np.sqrt(max(_vcov_rep_imp[0, 0], 0.0))) @@ -883,15 +825,29 @@ def fit( "survey_weights": survey_weights, } - # Pre-compute cluster psi sums for bootstrap - psi_data = None + # Bootstrap stage: psi precompute + multiplier bootstrap. The + # engine draws the full weight matrix from the seeded RNG BEFORE any + # ES/group work, so the overall-ATT draws are the same with or + # without the surfaces. Attempt 1 runs WITH the materialized + # surfaces; if it fails while they are present (anything but an + # explicit NotImplementedError, which is re-raised), the surfaces + # are dropped and the stage is RETRIED ONCE overall-only - today's + # plain-fit path with today's failure semantics (psi failure -> + # warn + skip; _run_bootstrap failure -> propagates). On a seeded + # fit the retry reproduces attempt 1's draws exactly. + psi_data: Optional[Dict[str, Any]] = None + bootstrap_results: Optional[ImputationBootstrapResults] = None if self.n_bootstrap > 0 and n_valid > 0: - try: - # Extract survey weights for untreated obs (same as analytical path) - _sw_0 = survey_weights[omega_0_mask.values] if survey_weights is not None else None - # Extract survey weights for treated obs (event-study/group bootstrap paths) - _sw_1 = survey_weights[omega_1_mask.values] if survey_weights is not None else None - psi_data = self._precompute_bootstrap_psi( + # Extract survey weights for untreated obs (same as analytical path) + _sw_0 = survey_weights[omega_0_mask.values] if survey_weights is not None else None + # Extract survey weights for treated obs (event-study/group bootstrap paths) + _sw_1 = survey_weights[omega_1_mask.values] if survey_weights is not None else None + + def _precompute_psi( + _es: Optional[Dict[int, Dict[str, Any]]], + _grp: Optional[Dict[Any, Dict[str, Any]]], + ) -> Dict[str, Any]: + return self._precompute_bootstrap_psi( df=df, outcome=outcome, unit=unit, @@ -907,34 +863,79 @@ def fit( cluster_var=cluster_var, kept_cov_mask=kept_cov_mask, overall_weights=overall_weights, - event_study_effects=event_study_effects, - group_effects=group_effects, + event_study_effects=_es, + group_effects=_grp, treatment_groups=treatment_groups, tau_hat=tau_hat, - balance_e=balance_e, + balance_e=None, survey_weights_0=_sw_0, survey_weights_1=_sw_1, proj_cache=proj_cache, ) - except Exception as e: - warnings.warn( - f"Bootstrap pre-computation failed: {e}. " "Skipping bootstrap inference.", - UserWarning, - stacklevel=2, - ) - psi_data = None - - # Bootstrap - bootstrap_results = None - if self.n_bootstrap > 0 and psi_data is not None: - bootstrap_results = self._run_bootstrap( - original_att=overall_att, - original_event_study=event_study_effects, - original_group=group_effects, - psi_data=psi_data, - resolved_survey=resolved_survey, + + _surfaces_present = event_study_effects is not None or group_effects is not None + if _surfaces_present: + try: + psi_data = _precompute_psi(event_study_effects, group_effects) + bootstrap_results = self._run_bootstrap( + original_att=overall_att, + original_event_study=event_study_effects, + original_group=group_effects, + psi_data=psi_data, + resolved_survey=resolved_survey, + ) + except NotImplementedError: + raise # explicit rejections are never retried around + except Exception as e: # noqa: BLE001 - fail-soft by design + _surface_failure = e + event_study_effects = None + group_effects = None + psi_data = None + bootstrap_results = None + if not _surfaces_present or bootstrap_results is None: + # Plain overall-only stage (no surfaces), or the one-shot + # retry after a failure with surfaces - identical semantics. + try: + psi_data = _precompute_psi(None, None) + except Exception as e: + warnings.warn( + f"Bootstrap pre-computation failed: {e}. " "Skipping bootstrap inference.", + UserWarning, + stacklevel=2, + ) + psi_data = None + if psi_data is not None: + bootstrap_results = self._run_bootstrap( + original_att=overall_att, + original_event_study=None, + original_group=None, + psi_data=psi_data, + resolved_survey=resolved_survey, + ) + + # Storage rule: the legacy fields hold a surface IFF the fit carries + # bootstrap inference. Without it (n_valid == 0 skipped the stage, or + # the bootstrap failed outright and the fit fell back to analytical + # inference) an analytical surface must never sit on a bootstrapped + # fit where summary()/to_dataframe()/the DiagnosticReport would read + # it as the fit's inference; aggregate() recomputes analytically + # there, exactly as on an n_bootstrap=0 fit. + if self.n_bootstrap > 0 and bootstrap_results is None: + event_study_effects = None + group_effects = None + elif bootstrap_results is not None and _surface_failure is not None: + warnings.warn( + f"{type(self).__name__}: the event-study/group surfaces could not " + f"be materialized on this bootstrapped fit ({_surface_failure}); " + "the bootstrap covered the overall ATT only, so " + "aggregate('event_study') / aggregate('group') are unavailable " + "here - re-fit with n_bootstrap=0 for the analytical post-fit " + "recompute.", + UserWarning, + stacklevel=2, ) + if bootstrap_results is not None: # Update inference with bootstrap results overall_se = bootstrap_results.overall_att_se overall_t = ( @@ -1046,9 +1047,9 @@ def fit( ) # Attach the post-fit aggregation kit (M-021/M-118). Unconditional — - # including bootstrap fits, whose gate lives in _aggregate_compute + # including bootstrap fits, whose relay lives in _aggregate_compute # (a FAILED bootstrap leaves bootstrap_results=None and the fit - # aggregates normally). + # aggregates normally from the kit). self.results_._aggregation_kit = _build_imputation_aggregation_kit( fit_data=self._fit_data, treatment_groups=treatment_groups, @@ -1405,5 +1406,5 @@ def _build_imputation_aggregation_kit( alpha=alpha, anticipation=anticipation, cband=False, # no simultaneous-band concept on this estimator - bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) + bootstrap=None, # no replay: bootstrapped fits RELAY the fit-time surfaces (4.0 PR 3) ) diff --git a/diff_diff/imputation_aggregation.py b/diff_diff/imputation_aggregation.py index a9feaebc8..379276770 100644 --- a/diff_diff/imputation_aggregation.py +++ b/diff_diff/imputation_aggregation.py @@ -1723,10 +1723,11 @@ def _replicate_override_aggregates( dicts are non-None. ``compute_replicate_refit_variance`` validates replicates JOINTLY (all-finite rows, ``survey.py``), so inference is LEVEL-MATCHED: post-fit ``aggregate('event_study')`` replays - ``[overall, ES]`` and reproduces ``fit(aggregate='event_study')`` - exactly; a ``fit(aggregate='all')`` surface is NOT the equivalence - target when a replicate NaNs on exactly one family's targets (its - joint stack drops that replicate for every row). + ``[overall, ES]`` and ``aggregate('group')`` replays + ``[overall, groups]``; a joint ``[overall, ES, groups]`` stack is + NOT the equivalence target when a replicate NaNs on exactly one + family's targets (its joint stack drops that replicate for every + row). Overrides ``se``/``t_stat``/``p_value``/``conf_int`` (and, on group rows, the ``df_used`` provenance key) IN PLACE on the passed dicts diff --git a/diff_diff/imputation_results.py b/diff_diff/imputation_results.py index f9e31587e..e1f232786 100644 --- a/diff_diff/imputation_results.py +++ b/diff_diff/imputation_results.py @@ -12,7 +12,12 @@ import numpy as np import pandas as pd -from diff_diff.aggregation import AggregationMixin, AggregationResult, build_total_relay_row +from diff_diff.aggregation import ( + AggregationMixin, + AggregationResult, + absent_bootstrap_surface_message, + build_total_relay_row, +) from diff_diff.imputation_aggregation import _ImputationAggregationMixin from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import ( @@ -261,37 +266,59 @@ def _aggregate_compute( # Per-level bootstrap policy (v4-design section 6, converged with row # M-027): 'simple' is a bit-exact RELAY of the stored overall quintet # - faithful under any inference regime, bootstrap included - so it - # dispatches BEFORE the bootstrap gate. Only the RECOMPUTE levels - # below fail closed on bootstrapped fits. (This supersedes the + # dispatches BEFORE the bootstrap branch. (This supersedes the # uniform-conservatism decision recorded with M-021; its rationale is # honored by the relay's NaN df column.) if level == "simple": return self._aggregate_simple_result(kit) if level == "total": return self._aggregate_total_result(kit) - if self.bootstrap_results is not None: - raise NotImplementedError( - f"aggregate({level!r}) is not yet available on a bootstrapped " - "fit (n_bootstrap > 0): the per-target bootstrap draws are " - "not retained, so post-fit re-aggregation cannot replay " - "percentile inference and analytical inference would " - "misrepresent the fit. aggregate('simple') and, where " - "supported, aggregate('total') relay the stored " - "bootstrap inference and remain available; otherwise re-fit " - "with the aggregation you need, or use n_bootstrap=0." - ) bk = dict(kit.bookkeeping) + if self.bootstrap_results is not None: # the fit's bootstrap SUCCEEDED + # 4.0 PR 3: bootstrapped fits materialize ES + group at fit + # (balance_e=None, percentile overrides applied, df cleared); no + # replay state is retained, so RELAY the stored surface with + # kit-snapshot provenance (StackedDiD view mechanics). The relay + # reads the MUTABLE public field by definition (a view); only + # alpha/anticipation/survey metadata come from kit snapshots. + cls_name = type(self).__name__ + if level == "group": + if self.group_effects is None: + raise ValueError(absent_bootstrap_surface_message(cls_name, "group")) + return self._group_effects_to_aggregation(self.group_effects, kit) + if balance_e is not None: + raise NotImplementedError( + "aggregate('event_study', balance_e=...) is not available on a " + "bootstrapped ImputationDiD fit: the fit-time percentile surface " + "was computed with balance_e=None and the per-target draws are " + "not retained. Call aggregate('event_study') for the stored " + "surface, or re-fit with n_bootstrap=0 to recompute with " + "balance_e." + ) + if self.event_study_effects is None: + raise ValueError(absent_bootstrap_surface_message(cls_name, "event_study")) + meta = bk["survey_metadata"] + carrier = dataclasses.replace( + self, + survey_metadata=(dataclasses.replace(meta) if meta is not None else None), + anticipation=kit.anticipation, + alpha=kit.alpha, + event_study_df=None, # percentile inference governs no df + ) + return build_event_study_surface(carrier) + # bootstrap_results is None: n_bootstrap == 0, OR the fit's bootstrap + # failed and the fit fell back to analytical inference (imputation.py + # stores no surface then) - the analytical kit RECOMPUTE below matches + # the fit's analytical provenance. if level == "event_study" and bk["uses_replicate"] and bk["pretrends"]: - # The same unsupported combination fit(aggregate='event_study') - # rejects: the pre-period lead regression's per-replicate refits - # are not implemented, and the replicate replay re-estimates + # The pre-period lead regression's per-replicate refits are not + # implemented, and the replicate replay re-estimates # post-treatment targets only. raise NotImplementedError( "aggregate('event_study') is not available on this fit: it " "used pretrends=True with a replicate-weight survey design, " "and the pre-period lead regression's per-replicate refits " - "are not yet implemented (fit(aggregate='event_study') " - "rejects the same combination). Re-fit with pretrends=False, " + "are not yet implemented. Re-fit with pretrends=False, " "or use an analytical (strata/PSU/FPC) survey design." ) # Fresh throwaway host per call, populated from KIT snapshots only @@ -333,8 +360,8 @@ def _aggregate_compute( if level == "group": effects = agg._aggregate_group(**common) if bk["uses_replicate"]: - # LEVEL-MATCHED replay: [overall, groups] - reproduces - # fit(aggregate='group') exactly (see the replay docstring). + # LEVEL-MATCHED replay: [overall, groups] (see the replay + # docstring). agg._replicate_override_aggregates( df=bk["df"], outcome=bk["outcome"], @@ -792,12 +819,11 @@ def to_dataframe(self, level: str = "observation") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed. Aggregate post-fit " - "instead - results.aggregate('event_study') returns the " - "EventStudyResults container (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or use the deprecated " - "fit-time aggregate=; a result unpickled from a pre-3.9 " - "release carries no kit and must be re-fit)." + "Event study effects not stored on this fit. Use " + "results.aggregate('event_study') - analytical fits " + "recompute from the retained kit; bootstrapped fits relay " + "the surface computed at fit. A result unpickled from a " + "pre-3.9 release carries no kit and must be re-fit." ) rows = [] for h, data in sorted(self.event_study_effects.items()): @@ -818,12 +844,11 @@ def to_dataframe(self, level: str = "observation") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed. Aggregate post-fit instead " - "- results.aggregate('group') returns the " - "AggregationResult container (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or use the deprecated " - "fit-time aggregate=; a result unpickled from a pre-3.9 " - "release carries no kit and must be re-fit)." + "Group effects not stored on this fit. Use " + "results.aggregate('group') - analytical fits recompute " + "from the retained kit; bootstrapped fits relay the " + "surface computed at fit. A result unpickled from a " + "pre-3.9 release carries no kit and must be re-fit." ) rows = [] for g, data in sorted(self.group_effects.items()): diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index e34d8e3d0..48326c45b 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -650,22 +650,16 @@ def _handle_imputation(results: Any): "ImputationDiD aggregates post-fit from its panel-backed kit " "(M-021) - no refit needed." if getattr(results, "bootstrap_results", None) is None - else "This fit is BOOTSTRAPPED: the post-fit event-study/group " - "recompute levels raise on bootstrap fits, while " - "aggregate('simple') and, where supported, " - "aggregate('total') relay the stored inference - " - "refit with the deprecated fit-time aggregation (or " - "n_bootstrap=0) to obtain the recomputed surfaces." + else "This fit is BOOTSTRAPPED: ImputationDiD aggregates post-fit " + "(M-021) - no refit needed; bootstrapped fits relay the " + "percentile event-study/group surfaces computed at fit " + "(balance_e unavailable there - re-fit with n_bootstrap=0 " + "for a balanced window)." ), code=( "# Aggregate post-fit - no refit needed:\n" "print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs\n" "print(results.aggregate('event_study').to_dataframe()) # Dynamic effects" - if getattr(results, "bootstrap_results", None) is None - else "# Bootstrap fit: aggregate at fit time (deprecated kwarg):\n" - "results = imp.fit(data, ..., aggregate='all')\n" - "print(results.group_effects) # Per-cohort ATTs\n" - "print(results.event_study_effects) # Dynamic effects" ), priority="medium", # NON-STEPS key (the M-024 "sub_experiment_balance" lesson): @@ -709,22 +703,16 @@ def _handle_two_stage(results: Any): "TwoStageDiD aggregates post-fit from its panel-backed kit " "(M-022) - no refit needed." if getattr(results, "bootstrap_results", None) is None - else "This fit is BOOTSTRAPPED: the post-fit event-study/group " - "recompute levels raise on bootstrap fits, while " - "aggregate('simple') and, where supported, " - "aggregate('total') relay the stored inference - " - "refit with the deprecated fit-time aggregation (or " - "n_bootstrap=0) to obtain the recomputed surfaces." + else "This fit is BOOTSTRAPPED: TwoStageDiD aggregates post-fit " + "(M-022) - no refit needed; bootstrapped fits relay the " + "percentile event-study/group surfaces computed at fit " + "(balance_e unavailable there - re-fit with n_bootstrap=0 " + "for a balanced window)." ), code=( "# Aggregate post-fit - no refit needed:\n" "print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs\n" "print(results.aggregate('event_study').to_dataframe()) # Dynamic effects" - if getattr(results, "bootstrap_results", None) is None - else "# Bootstrap fit: aggregate at fit time (deprecated kwarg):\n" - "results = ts.fit(data, ..., aggregate='all')\n" - "print(results.group_effects) # Per-cohort ATTs\n" - "print(results.event_study_effects) # Dynamic effects" ), priority="medium", # NON-STEPS key (the M-024 "sub_experiment_balance" lesson): @@ -1232,18 +1220,16 @@ def _handle_continuous(results: Any): baker_step=7, label="Aggregate post-fit (event study / dose / simple)", why=( - "The fit-time aggregate= kwarg is deprecated (row M-025; " - "removed in 4.0). Aggregate as a post-fit step instead: " + "Aggregate as a post-fit step (row M-025): " "aggregate('event_study') recomputes the binarized event " - "study from the retained kit (analytical fits only - on a " - "bootstrapped fit it raises; re-fit with n_bootstrap=0 or " - "use the deprecated fit-time aggregate='eventstudy' until " - "4.0), while aggregate('dose') and aggregate('simple') are " + "study from the retained kit on analytic fits and relays " + "the percentile surface computed at fit on bootstrapped " + "fits, while aggregate('dose') and aggregate('simple') are " "views over the always-computed curves and overall " "ATT/ACRT, available on any fit." ), code=( - "es = results.aggregate('event_study') # analytical fits\n" + "es = results.aggregate('event_study')\n" "dose_table = results.aggregate('dose')\n" "overall = results.aggregate('simple') # att + acrt rows" ), diff --git a/diff_diff/pretrends.py b/diff_diff/pretrends.py index 11060a94e..ad8e8a6eb 100644 --- a/diff_diff/pretrends.py +++ b/diff_diff/pretrends.py @@ -1182,10 +1182,7 @@ def _extract_pre_period_params( f"{type(results).__name__} must have " "event_study_effects. Pass the post-fit container " "(compute_pretrends_power(results.aggregate(" - "'event_study'), ...)) instead; CallawaySantAnna " - "fits can alternatively use the deprecated " - "aggregate='event_study' to populate the fit-time " - "surface." + "'event_study'), ...)) instead." ) # Common-reference guard, mirroring the container branch diff --git a/diff_diff/results_base.py b/diff_diff/results_base.py index e1634e951..5f524393c 100644 --- a/diff_diff/results_base.py +++ b/diff_diff/results_base.py @@ -774,12 +774,14 @@ def summary(self, alpha: Optional[float] = None) -> str: # Post-fit only by design (no fit-time surface ever exists). "DMLDiDResults": "call results.aggregate('event_study')", "ImputationDiDResults": ( - "call results.aggregate('event_study') (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or the deprecated fit-time aggregate=)" + "call results.aggregate('event_study') (a bootstrapped fit from before " + "4.0 stores no surface - re-fit with diff-diff >= 4.0, which computes " + "it at fit)" ), "TwoStageDiDResults": ( - "call results.aggregate('event_study') (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or the deprecated fit-time aggregate=)" + "call results.aggregate('event_study') (a bootstrapped fit from before " + "4.0 stores no surface - re-fit with diff-diff >= 4.0, which computes " + "it at fit)" ), # Absence only possible on pre-3.9 pickles: 3.9+ fits always # materialize the surface (row M-024). @@ -787,8 +789,9 @@ def summary(self, alpha: Optional[float] = None) -> str: "StaggeredTripleDiffResults": "refit with aggregate='event_study' (or 'all')", "EfficientDiDResults": "call results.aggregate('event_study')", "ContinuousDiDResults": ( - "call results.aggregate('event_study') (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or the deprecated fit-time aggregate=)" + "call results.aggregate('event_study') (a bootstrapped fit from before " + "4.0 stores no surface - re-fit with diff-diff >= 4.0, which computes " + "it at fit)" ), "WooldridgeDiDResults": "call results.aggregate('event_study') first", "SpilloverDiDResults": "refit with event_study=True", diff --git a/diff_diff/stacked_did.py b/diff_diff/stacked_did.py index a7072123e..f86ecb63e 100644 --- a/diff_diff/stacked_did.py +++ b/diff_diff/stacked_did.py @@ -25,7 +25,6 @@ import pandas as pd from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.balancing import BalanceError, entropy_balance from diff_diff.linalg import effective_cluster_count, solve_ols from diff_diff.stacked_did_results import StackedDiDResults # noqa: F401 (re-export) @@ -301,7 +300,6 @@ def fit( unit: str, time: str, first_treat: str, - aggregate: Any = NOT_SUPPLIED, population: Optional[str] = None, survey_design=None, covariates: Optional[List[str]] = None, @@ -322,17 +320,6 @@ def fit( first_treat : str Name of column indicating when unit was first treated. Use 0 or np.inf for never-treated units. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; ledger row M-024): the - event-study surface is now always computed at fit, so this - parameter is behaviorally inert - passing it (any value, - ``None`` included) emits a ``FutureWarning``. Aggregate as a - post-fit step instead: ``results.aggregate('event_study')`` / - ``results.aggregate('simple')``. Value validation is - unchanged: ``"group"``/``"all"`` raise ``ValueError`` - (the pooled stacked regression cannot produce - cohort-specific effects - use CallawaySantAnna or - ImputationDiD), as do unknown values. population : str, optional Column name for population weights. Required only when weighting="population". @@ -352,56 +339,25 @@ def fit( Returns ------- StackedDiDResults - Object containing all estimation results. + Object containing all estimation results. The event-study surface + is always computed at fit; aggregate as a post-fit step via + ``results.aggregate('event_study')`` / ``results.aggregate('simple')`` + (``'group'`` is not available: the pooled stacked regression + cannot produce cohort-specific effects - use CallawaySantAnna or + ImputationDiD for cohort-level estimates). Raises ------ ValueError If required columns are missing or data validation fails. """ - # ---- fit(aggregate=) shim (row M-024) ---- - # Deprecated in 3.9, removed at 4.0: the event-study surface is now - # ALWAYS computed at fit (see the extraction block below), so the - # param is behaviorally inert and aggregation moves to the post-fit - # results.aggregate(type=...). The sentinel default means the - # warning fires ONLY when the caller supplies the argument (None - # included), never on a plain fit(). Value validation below is - # unchanged, so the deprecated path returns exactly what a plain - # fit does - or raises exactly as it always did. - if aggregate is not NOT_SUPPLIED: - warnings.warn( - "StackedDiD.fit(aggregate=) is deprecated and will be " - "removed in 4.0. The event-study surface is now always " - "computed; aggregate as a post-fit step: " - "results.aggregate('event_study') / " - "results.aggregate('simple').", - FutureWarning, - stacklevel=2, - ) - else: - aggregate = None - # Fit-time re-check: __init__ and set_params validate eagerly, so # this only catches DIRECT attribute mutation (est.anticipation = ...) # — an out-of-domain value silently changes the ESTIMAND. The - # assignment also re-normalizes a mutated numpy scalar to int. Placed - # AFTER the deprecation shim so a caller who both mutated and passed - # a deprecated argument still sees the FutureWarning before the raise. + # assignment also re-normalizes a mutated numpy scalar to int. self.anticipation = validate_anticipation(self.anticipation) # ---- Validate inputs ---- - if aggregate in ("group", "all"): - raise ValueError( - f"aggregate='{aggregate}' is not supported by StackedDiD. " - "The pooled stacked regression cannot produce cohort-specific " - "effects. Use CallawaySantAnna or ImputationDiD for " - "cohort-level estimates." - ) - if aggregate not in (None, "simple", "event_study"): - raise ValueError( - f"aggregate must be None, 'simple', or 'event_study', " f"got '{aggregate}'" - ) - required_cols = [outcome, unit, time, first_treat] if population is not None: required_cols.append(population) @@ -780,8 +736,8 @@ def fit( # 3.9 (row M-024): the per-event contrasts are ALWAYS built - # the event-study surface is always materialized so the # post-fit results.aggregate("event_study") view is total. - # (The pre-M-024 gate skipped them when aggregate != - # "event_study"; that gate's premise died with the post-fit + # (The pre-3.9 gate skipped them unless an event study was + # requested at fit; that gate's premise died with the post-fit # surface.) NB: the dof helper's degeneracy guard is # batch-relative for m > 1 (linalg.py), so the overall-ATT # contrast now shares a batch with the per-event contrasts; @@ -939,8 +895,8 @@ def _refit_stacked(w_r): # ---- Extract event study effects ---- # 3.9 (row M-024): the surface is ALWAYS materialized - the # regression always includes the event-time interactions, and the - # post-fit results.aggregate("event_study") view must be total. - # fit(aggregate=) no longer affects what is computed or stored. + # post-fit results.aggregate("event_study") view must be total: + # the event-study surface is always computed at fit. event_study_effects: Dict[int, Dict[str, Any]] = {} es_vcov: Optional[np.ndarray] = None es_vcov_index: Optional[List[int]] = None diff --git a/diff_diff/staggered.py b/diff_diff/staggered.py index f241bbfe8..b35b60530 100644 --- a/diff_diff/staggered.py +++ b/diff_diff/staggered.py @@ -17,10 +17,6 @@ AggregationKit, BootstrapReplaySpec, ) -from diff_diff.bootstrap_utils import ( - apply_bootstrap_event_study_overrides, - apply_bootstrap_group_overrides, -) from diff_diff.linalg import ( _check_propensity_diagnostics, _detect_rank_deficiency, @@ -36,7 +32,6 @@ from diff_diff.staggered_bootstrap import ( CallawaySantAnnaBootstrapMixin, CSBootstrapResults, - apply_cband_conf_ints, ) # Import from split modules @@ -67,22 +62,6 @@ PrecomputedData = Dict[str, Any] -class _DeprecatedFitArg: - """Sentinel for `fit(aggregate=)` / `fit(balance_e=)` (rows M-020 / M-117). - - A plain ``None`` default cannot distinguish "not passed" from "passed - None", so a bare ``None`` default would fire the FutureWarning on EVERY - fit. The warning must fire only when the caller actually supplies the - argument. - """ - - def __repr__(self) -> str: # pragma: no cover - debugging aid - return "" - - -_DEPRECATED_FIT_ARG = _DeprecatedFitArg() - - def _linear_regression( X: np.ndarray, y: np.ndarray, @@ -535,14 +514,11 @@ class CallawaySantAnna( >>> event_study.to_dataframe() # doctest: +SKIP Plotting and the sensitivity analyses (``plot_event_study``, - ``compute_honest_did``, ``compute_pretrends_power``) still read the - fit-time surface, so they take a fit that requested aggregation: + ``compute_honest_did``, ``compute_pretrends_power``) take the post-fit + container: >>> from diff_diff import plot_event_study - >>> plotted = cs.fit(data, outcome='outcome', unit='unit', - ... time='time', first_treat='first_treat', - ... aggregate='event_study') # doctest: +SKIP - >>> plot_event_study(plotted) # doctest: +SKIP + >>> plot_event_study(results.aggregate('event_study')) # doctest: +SKIP With covariate adjustment (conditional parallel trends): @@ -1870,8 +1846,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = _DEPRECATED_FIT_ARG, - balance_e: Any = _DEPRECATED_FIT_ARG, survey_design: Optional["SurveyDesign"] = None, ) -> CallawaySantAnnaResults: """ @@ -1894,36 +1868,6 @@ def fit( Use 0 (or np.inf) for never-treated units. covariates : list, optional List of covariate column names for conditional parallel trends. - aggregate : str, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-020). Passing - it emits a ``FutureWarning``. Aggregate as a post-fit step - instead - ``results.aggregate("simple" | "event_study" | - "group")`` - which needs no refit and returns a new object, - leaving ``results`` unchanged. Accepted values are unchanged: - - None: Only compute ATT(g,t) (default) - - "simple": Simple weighted average (overall ATT) - - "event_study": Aggregate by relative time (event study) - - "group": Aggregate by treatment cohort - - "all": Compute all aggregations - - ``compute_honest_did``, ``compute_pretrends_power`` and - ``plot_event_study`` all accept the post-fit container from - ``results.aggregate('event_study')`` directly, so no consumer - requires the fit-time surface anymore. Bootstrapped fits - (``n_bootstrap > 0``) included: the post-fit RECOMPUTE levels - (``'event_study'``/``'group'``) REPLAY the fit-time multiplier - bootstrap from the kit-retained RNG state (percentile - inference matching a fit-time aggregation to floating-point - reassociation; ``aggregate('simple')``/``('total')`` still - relay the stored inference bit-exactly). This parameter - remains only as the deprecated compatibility path through - 3.9. - balance_e : int, optional - DEPRECATED since 3.9, removed in 4.0 (ledger row M-117). Passing - it emits a ``FutureWarning``; it moves onto the post-fit call as - ``results.aggregate("event_study", balance_e=...)``. For event - study, balance the panel at relative time e. Ensures all groups - contribute to each relative period. survey_design : SurveyDesign, optional Survey design specification. Supports pweight with strata/PSU/FPC. Aggregated SEs (overall, event study, group) use design-based @@ -1935,41 +1879,15 @@ def fit( Returns ------- CallawaySantAnnaResults - Object containing all estimation results. + Object containing all estimation results. Aggregations are a + post-fit step: ``results.aggregate("simple" | "event_study" | + "group" | "total", balance_e=...)``. Raises ------ ValueError If required columns are missing or data validation fails. """ - # ---- fit(aggregate=) / fit(balance_e=) shims (rows M-020 / M-117) ---- - # Deprecated in 3.9, removed at 4.0: aggregation moves to the post-fit - # results.aggregate(type=...). The sentinel default means the warning - # fires ONLY when the caller supplies the argument, never on a plain - # fit(). Routing is otherwise untouched, so the deprecated path returns - # exactly the numbers it always did. - _deprecated_passed = [ - name - for name, value in (("aggregate", aggregate), ("balance_e", balance_e)) - if not isinstance(value, _DeprecatedFitArg) - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"CallawaySantAnna.fit({_args}) 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') / .aggregate('total'). balance_e moves onto aggregate() alongside " - "it: results.aggregate('event_study', balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if isinstance(aggregate, _DeprecatedFitArg): - aggregate = None - if isinstance(balance_e, _DeprecatedFitArg): - balance_e = None - # Validate pscore_trim (may have been changed by direct mutation). # Return discarded: fit() must not mutate constructor config. validate_pscore_trim(self.pscore_trim) @@ -2814,44 +2732,14 @@ def fit( df=df_survey, ) - # Compute additional aggregations if requested + # Since 4.0 fit() computes no event-study / group surface: both are + # post-fit steps (``results.aggregate('event_study' | 'group')``, + # which recomputes from the retained kit and, on bootstrapped fits, + # replays the multiplier bootstrap). The legacy fields are None on + # every fit. event_study_effects = None group_effects = None - # Aggregation outputs that used to arrive as ``self._event_study_*`` - # side channels; the aggregator is now pure and returns them. - es_aggregation = None - if aggregate in ["event_study", "all"]: - es_aggregation = self._aggregate_event_study( - group_time_effects, - influence_func_info, - treatment_groups, - time_periods, - balance_e, - df, - unit, - precomputed, - ) - event_study_effects = es_aggregation.effects - # The stored fit-time surface is THIS aggregation's (possibly - # balance_e-restricted): its common-reference provenance must - # describe the RETAINED cohorts, not the fit-wide set, or the - # native consumer route would disagree with the equivalent - # post-fit container (route parity). The fit-wide tuple - # computed above stands only when no event-study surface was - # produced. - reference_event_times = es_aggregation.reference_event_times - - if aggregate in ["group", "all"]: - group_effects = self._aggregate_by_group( - group_time_effects, - influence_func_info, - treatment_groups, - precomputed=precomputed, - df=df, - unit=unit, - ) - # Reject replicate-weight designs for bootstrap — replicate variance # is an analytical alternative, not compatible with bootstrap if ( @@ -2872,8 +2760,8 @@ def fit( bootstrap_results = self._run_multiplier_bootstrap( group_time_effects=group_time_effects, influence_func_info=influence_func_info, - aggregate=aggregate, - balance_e=balance_e, + aggregate=None, + balance_e=None, treatment_groups=treatment_groups, time_periods=time_periods, df=df, @@ -2906,23 +2794,11 @@ def fit( group_time_effects[gt]["p_value"] = bootstrap_results.group_time_p_values[gt] group_time_effects[gt]["t_stat"] = float(gt_t_stats[idx]) - # Update event study effects with bootstrap SEs (batched). - # Shared helper (verbatim extraction) so the post-fit aggregate() - # replay applies exactly the same overrides. - apply_bootstrap_event_study_overrides( - event_study_effects, bootstrap_results, self.alpha - ) - - # Update group effects with bootstrap SEs (batched) — same shared - # helper, including the df_used clearing rule. - apply_bootstrap_group_overrides(group_effects, bootstrap_results, self.alpha) - - # Compute simultaneous confidence band CIs if cband is available + # No fit-time event-study surface -> the bootstrap never builds + # ``event_study_info`` and no simultaneous band is computed at fit; + # the post-fit replay (``results.aggregate('event_study')``) carries + # its own ``cband_crit_value``. cband_crit_value = None - if bootstrap_results is not None: - cband_crit_value = bootstrap_results.cband_crit_value - - apply_cband_conf_ints(event_study_effects, cband_crit_value) # Consolidated _safe_inv rank-guard warning (sibling of PR #9 # finding #17). Rank-deficient PS Hessian / OR bread matrices in the @@ -2948,21 +2824,12 @@ def fit( ) # Store results - # Retrieve event-study VCV from aggregation mixin (Phase 7d). - # When bootstrap overwrites the event-study SEs, the VCV is the - # covariance of the same bootstrap draws (diagonal = bootstrap SE**2), - # so HonestDiD never mixes the analytical VCV with bootstrap SEs. - event_study_vcov = es_aggregation.vcov if es_aggregation is not None else None - event_study_vcov_index = es_aggregation.vcov_index if es_aggregation is not None else None - if bootstrap_results is not None: - event_study_vcov = bootstrap_results.event_study_vcov - event_study_vcov_index = bootstrap_results.event_study_vcov_index - # Same clearing rule for the ES df provenance: bootstrap replaces the - # stored ES se/p/CI with percentile values that never used the - # analytical df, so surfacing it would be false provenance. - event_study_df = es_aggregation.df_used if es_aggregation is not None else None - if bootstrap_results is not None: - event_study_df = None + # No fit-time event-study surface since 4.0, so there is no VCV / + # vcov index / df provenance to store; the post-fit container from + # ``results.aggregate('event_study')`` carries them. + event_study_vcov = None + event_study_vcov_index = None + event_study_df = None # Resolve canonical cluster_name + n_clusters for Results metadata. # Canonical PSU column wins when explicit: if survey_design has diff --git a/diff_diff/staggered_results.py b/diff_diff/staggered_results.py index 8fd09c8cb..05167cb7e 100644 --- a/diff_diff/staggered_results.py +++ b/diff_diff/staggered_results.py @@ -970,8 +970,8 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed. " - "Call results.aggregate('event_study') to compute them post-fit " + "Event study effects are not stored on 4.0 fits; use " + "results.aggregate('event_study').to_dataframe() " "(no refit required)." ) rows = [] @@ -995,8 +995,8 @@ def to_dataframe(self, level: str = "group_time") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed. " - "Call results.aggregate('group') to compute them post-fit " + "Group effects are not stored on 4.0 fits; use " + "results.aggregate('group').to_dataframe() " "(no refit required)." ) rows = [] diff --git a/diff_diff/two_stage.py b/diff_diff/two_stage.py index d38a7328a..6a6730209 100644 --- a/diff_diff/two_stage.py +++ b/diff_diff/two_stage.py @@ -31,7 +31,6 @@ from scipy.sparse.linalg import factorized as sparse_factorized from diff_diff._base import BaseEstimator -from diff_diff._deprecation import NOT_SUPPLIED from diff_diff.aggregation import AggregationKit from diff_diff.conley import ( ConleyMetric, @@ -1363,8 +1362,6 @@ def fit( time: str, first_treat: str, covariates: Optional[List[str]] = None, - aggregate: Any = NOT_SUPPLIED, - balance_e: Any = NOT_SUPPLIED, survey_design: Optional["SurveyDesign"] = None, ) -> TwoStageDiDResults: """ @@ -1385,20 +1382,6 @@ def fit( Use 0 (or np.inf) for never-treated units. covariates : list of str, optional List of covariate column names. - aggregate : str, optional - DEPRECATED (3.9, removed in 4.0; row M-022): aggregate as a - post-fit step instead — ``results.aggregate('event_study')`` / - ``.aggregate('group')`` / ``.aggregate('simple')`` / - ``.aggregate('total')``. Supplying - ANY value (``None`` included) warns; the deprecated path still - works and returns exactly the numbers it always did - (fit-time mode: None/"simple" overall only, "event_study", - "group", or "all"). - balance_e : int, optional - DEPRECATED (3.9, removed in 4.0; row M-119): moves onto - ``results.aggregate('event_study', balance_e=...)``. Restricts - the event study to cohorts observed at every relative time in - ``[-balance_e, max_h]`` (the balanced-window rule). survey_design : SurveyDesign, optional Survey design specification for design-based inference. Supports pweight only (aweight/fweight raise ValueError). Supports strata, @@ -1416,35 +1399,6 @@ def fit( ValueError If required columns are missing or data validation fails. """ - # M-022/M-119 deprecation shim (CS-style joint warning): a plain - # fit() never warns; supplying EITHER param with ANY value (None - # included) warns once, then the legacy routing below runs - # unchanged - the deprecated path returns exactly the numbers it - # always did (no new value validation; unknown strings still act - # like None). The post-fit successor validates its own vocabulary. - _deprecated_passed = [ - n - for n, v in (("aggregate", aggregate), ("balance_e", balance_e)) - if v is not NOT_SUPPLIED - ] - if _deprecated_passed: - _args = " / ".join(f"{n}=" for n in _deprecated_passed) - warnings.warn( - f"TwoStageDiD.fit({_args}) is deprecated and will be " - "removed in 4.0. Fit once, then aggregate as a post-fit " - "step: results = TwoStageDiD().fit(...); " - "results.aggregate('event_study') / .aggregate('group') / " - ".aggregate('simple') / .aggregate('total'). balance_e moves onto aggregate() " - "alongside it: results.aggregate('event_study', " - "balance_e=2).", - FutureWarning, - stacklevel=2, - ) - if aggregate is NOT_SUPPLIED: - aggregate = None - if balance_e is NOT_SUPPLIED: - balance_e = None - # Re-validate vcov_type at fit-time: set_params validates eagerly # (BaseEstimator probe re-init), so this only catches DIRECT # attribute mutation (est.vcov_type = ...). @@ -1796,61 +1750,77 @@ def fit( overall_att, overall_se, alpha=self.alpha, df=_survey_df ) - # Event study and group aggregation (full-sample, for point estimates) - event_study_effects = None - group_effects = None + # Event study and group aggregation (full-sample, for point estimates). + # Materialized at fit ONLY where the post-fit route cannot recompute + # it: a bootstrapped fit (n_bootstrap > 0) needs the ES + group + # targets inside the multiplier bootstrap (per-draw state is not + # retained), so the surfaces are built here with balance_e=None and + # RELAYED by results.aggregate('event_study') / ('group'). Analytic + # fits leave both None and recompute from the kit on demand. + # FAIL-SOFT by design: any exception while materializing leaves the + # surfaces None (the bootstrap then covers the overall ATT only - + # today's plain-fit path) and is reported once the bootstrap + # outcome is known (see the storage rule below). + event_study_effects: Optional[Dict[int, Dict[str, Any]]] = None + group_effects: Optional[Dict[Any, Dict[str, Any]]] = None _es_vcov_ts: Optional[np.ndarray] = None _es_vcov_index_ts: Optional[List[int]] = None + _surface_failure: Optional[BaseException] = None - if aggregate in ("event_study", "all"): - event_study_effects, _es_vcov_ts, _es_vcov_index_ts = self._stage2_event_study( - df=df, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - omega_0_mask=omega_0_mask, - omega_1_mask=omega_1_mask, - unit_fe=unit_fe, - time_fe=time_fe, - grand_mean=grand_mean, - delta_hat=delta_hat, - cluster_var=cluster_var, - treatment_groups=treatment_groups, - ref_period=ref_period, - balance_e=balance_e, - kept_cov_mask=kept_cov_mask, - survey_weights=survey_weights, - survey_weight_type=survey_weight_type, - survey_df=_survey_df, - resolved_survey=(resolved_survey if not _uses_replicate_ts else None), - score_pad_mask=score_pad_mask_arg, - cluster_ids_full=cluster_ids_full_arg, - ) - - if aggregate in ("group", "all"): - group_effects = self._stage2_group( - df=df, - unit=unit, - time=time, - first_treat=first_treat, - covariates=covariates, - omega_0_mask=omega_0_mask, - omega_1_mask=omega_1_mask, - unit_fe=unit_fe, - time_fe=time_fe, - grand_mean=grand_mean, - delta_hat=delta_hat, - cluster_var=cluster_var, - treatment_groups=treatment_groups, - kept_cov_mask=kept_cov_mask, - survey_weights=survey_weights, - survey_weight_type=survey_weight_type, - survey_df=_survey_df, - resolved_survey=(resolved_survey if not _uses_replicate_ts else None), - score_pad_mask=score_pad_mask_arg, - cluster_ids_full=cluster_ids_full_arg, - ) + if self.n_bootstrap > 0: + try: + event_study_effects, _es_vcov_ts, _es_vcov_index_ts = self._stage2_event_study( + df=df, + unit=unit, + time=time, + first_treat=first_treat, + covariates=covariates, + omega_0_mask=omega_0_mask, + omega_1_mask=omega_1_mask, + unit_fe=unit_fe, + time_fe=time_fe, + grand_mean=grand_mean, + delta_hat=delta_hat, + cluster_var=cluster_var, + treatment_groups=treatment_groups, + ref_period=ref_period, + balance_e=None, + kept_cov_mask=kept_cov_mask, + survey_weights=survey_weights, + survey_weight_type=survey_weight_type, + survey_df=_survey_df, + resolved_survey=(resolved_survey if not _uses_replicate_ts else None), + score_pad_mask=score_pad_mask_arg, + cluster_ids_full=cluster_ids_full_arg, + ) + group_effects = self._stage2_group( + df=df, + unit=unit, + time=time, + first_treat=first_treat, + covariates=covariates, + omega_0_mask=omega_0_mask, + omega_1_mask=omega_1_mask, + unit_fe=unit_fe, + time_fe=time_fe, + grand_mean=grand_mean, + delta_hat=delta_hat, + cluster_var=cluster_var, + treatment_groups=treatment_groups, + kept_cov_mask=kept_cov_mask, + survey_weights=survey_weights, + survey_weight_type=survey_weight_type, + survey_df=_survey_df, + resolved_survey=(resolved_survey if not _uses_replicate_ts else None), + score_pad_mask=score_pad_mask_arg, + cluster_ids_full=cluster_ids_full_arg, + ) + except Exception as e: # noqa: BLE001 - fail-soft by design + _surface_failure = e + event_study_effects = None + group_effects = None + _es_vcov_ts = None + _es_vcov_index_ts = None # Replicate variance override: derive keys from actual outputs, then refit _n_valid_rep_ts = None @@ -1872,7 +1842,7 @@ def fit( cluster_var=cluster_var, treatment_groups=treatment_groups, ref_period=ref_period, - balance_e=balance_e, + balance_e=None, keep_mask=keep_mask, resolved_survey=resolved_survey, overall_att=overall_att, @@ -1905,10 +1875,26 @@ def fit( treated_df["weight"] = 0.0 # ---- Bootstrap ---- - bootstrap_results = None + # The engine draws the full weight matrix from the seeded RNG BEFORE + # any ES/group work, so the overall-ATT draws are the same with or + # without the surfaces. Attempt 1 runs WITH the materialized + # surfaces (aggregate="all" is the private selector gating the + # ES/group draws); it counts as failed-with-surfaces if it RAISES + # (anything but NotImplementedError, re-raised as today) or RETURNS + # None (an LSMR-unconverged ES/group column) while surfaces are + # present. Then the surfaces are dropped and the bootstrap is + # RETRIED ONCE overall-only - today's plain-fit path with today's + # catch-all semantics (failure -> "Bootstrap failed" warning + + # analytical overall). On a seeded fit the retry reproduces attempt + # 1's draws exactly. + bootstrap_results: Optional[TwoStageBootstrapResults] = None if self.n_bootstrap > 0: - try: - bootstrap_results = self._run_bootstrap( + + def _run_stage( + _es: Optional[Dict[int, Dict[str, Any]]], + _grp: Optional[Dict[Any, Dict[str, Any]]], + ) -> Optional[TwoStageBootstrapResults]: + return self._run_bootstrap( df=df, unit=unit, time=time, @@ -1924,18 +1910,67 @@ def fit( kept_cov_mask=kept_cov_mask, treatment_groups=treatment_groups, ref_period=ref_period, - balance_e=balance_e, + balance_e=None, original_att=overall_att, - original_event_study=event_study_effects, - original_group=group_effects, - aggregate=aggregate, + original_event_study=_es, + original_group=_grp, + aggregate=("all" if (_es is not None or _grp is not None) else None), resolved_survey=resolved_survey, ) - except NotImplementedError: - raise # Don't swallow explicit rejections (e.g. lonely_psu="adjust") - except Exception as e: + + _surfaces_present = event_study_effects is not None or group_effects is not None + if _surfaces_present: + try: + bootstrap_results = _run_stage(event_study_effects, group_effects) + if bootstrap_results is None: + raise RuntimeError( + "the multiplier bootstrap returned no results for the " + "event-study/group targets" + ) + except NotImplementedError: + raise # Don't swallow explicit rejections (e.g. lonely_psu="adjust") + except Exception as e: # noqa: BLE001 - fail-soft by design + _surface_failure = e + event_study_effects = None + group_effects = None + _es_vcov_ts = None + _es_vcov_index_ts = None + bootstrap_results = None + if not _surfaces_present or bootstrap_results is None: + # Plain overall-only stage (no surfaces), or the one-shot + # retry after a failure with surfaces - identical semantics. + try: + bootstrap_results = _run_stage(None, None) + except NotImplementedError: + raise # Don't swallow explicit rejections (e.g. lonely_psu="adjust") + except Exception as e: + warnings.warn( + f"Bootstrap failed: {e}. Skipping bootstrap inference.", + UserWarning, + stacklevel=2, + ) + bootstrap_results = None + + # Storage rule: the legacy fields hold a surface IFF the fit + # carries bootstrap inference. A failed bootstrap falls back to + # analytical inference, and an analytical surface must never sit + # on a bootstrapped fit where summary()/to_dataframe()/the + # DiagnosticReport would read it as the fit's inference; + # aggregate() recomputes analytically there, exactly as on an + # n_bootstrap=0 fit. + if bootstrap_results is None: + event_study_effects = None + group_effects = None + _es_vcov_ts = None + _es_vcov_index_ts = None + elif _surface_failure is not None: warnings.warn( - f"Bootstrap failed: {e}. Skipping bootstrap inference.", + f"{type(self).__name__}: the event-study/group surfaces could not " + f"be materialized on this bootstrapped fit ({_surface_failure}); " + "the bootstrap covered the overall ATT only, so " + "aggregate('event_study') / aggregate('group') are unavailable " + "here - re-fit with n_bootstrap=0 for the analytical post-fit " + "recompute.", UserWarning, stacklevel=2, ) @@ -2075,9 +2110,9 @@ def fit( ) # Attach the post-fit aggregation kit (M-022/M-119). Unconditional — - # including bootstrap fits, whose gate lives in _aggregate_compute + # including bootstrap fits, whose relay lives in _aggregate_compute # (a FAILED bootstrap leaves bootstrap_results=None and the fit - # aggregates normally). + # aggregates normally from the kit). self.results_._aggregation_kit = _build_twostage_aggregation_kit( df=df, outcome=outcome, @@ -2329,5 +2364,5 @@ def _build_twostage_aggregation_kit( alpha=alpha, anticipation=anticipation, cband=False, # no simultaneous-band concept on this estimator - bootstrap=None, # replay not wired; recompute levels fail closed ('simple' relays, M-027) + bootstrap=None, # no replay: bootstrapped fits RELAY the fit-time surfaces (4.0 PR 3) ) diff --git a/diff_diff/two_stage_aggregation.py b/diff_diff/two_stage_aggregation.py index 37692131a..603fa48e6 100644 --- a/diff_diff/two_stage_aggregation.py +++ b/diff_diff/two_stage_aggregation.py @@ -1384,8 +1384,8 @@ def _replay_replicate_inference( dicts are non-None. ``compute_replicate_refit_variance`` validates replicates JOINTLY (all-finite rows), so inference is LEVEL-MATCHED: post-fit ``aggregate('event_study')`` replays ``[overall, ES]`` and - reproduces ``fit(aggregate='event_study')`` exactly; a - ``fit(aggregate='all')`` surface is NOT the equivalence target when + ``aggregate('group')`` replays ``[overall, groups]``; a joint + ``[overall, ES, groups]`` stack is NOT the equivalence target when a replicate NaNs on exactly one family's targets. Overrides ``se``/``t_stat``/``p_value``/``conf_int`` IN PLACE on the diff --git a/diff_diff/two_stage_results.py b/diff_diff/two_stage_results.py index 19e56105d..549fb8218 100644 --- a/diff_diff/two_stage_results.py +++ b/diff_diff/two_stage_results.py @@ -12,7 +12,12 @@ import numpy as np import pandas as pd -from diff_diff.aggregation import AggregationMixin, AggregationResult, build_total_relay_row +from diff_diff.aggregation import ( + AggregationMixin, + AggregationResult, + absent_bootstrap_surface_message, + build_total_relay_row, +) from diff_diff.results import _format_survey_block, _get_significance_stars from diff_diff.results_base import ( BaseResults, @@ -250,26 +255,54 @@ def _aggregate_compute( # Per-level bootstrap policy (v4-design section 6, converged with row # M-027): 'simple' is a bit-exact RELAY of the stored overall quintet # - faithful under any inference regime, bootstrap included - so it - # dispatches BEFORE the bootstrap gate. Only the RECOMPUTE levels - # below fail closed on bootstrapped fits. (This supersedes the + # dispatches BEFORE the bootstrap branch. (This supersedes the # uniform-conservatism decision recorded with M-022; its rationale is # honored by the relay's NaN df column.) if level == "simple": return self._aggregate_simple_result(kit) if level == "total": return self._aggregate_total_result(kit) - if self.bootstrap_results is not None: - raise NotImplementedError( - f"aggregate({level!r}) is not yet available on a bootstrapped " - "fit (n_bootstrap > 0): the per-target bootstrap draws are " - "not retained, so post-fit re-aggregation cannot replay " - "percentile inference and analytical inference would " - "misrepresent the fit. aggregate('simple') and, where " - "supported, aggregate('total') relay the stored " - "bootstrap inference and remain available; otherwise re-fit " - "with the aggregation you need, or use n_bootstrap=0." - ) bk = dict(kit.bookkeeping) + if self.bootstrap_results is not None: # the fit's bootstrap SUCCEEDED + # 4.0 PR 3: bootstrapped fits materialize ES + group at fit + # (balance_e=None, percentile overrides applied, vcov/df cleared); + # no replay state is retained, so RELAY the stored surface with + # kit-snapshot provenance (StackedDiD view mechanics). The relay + # reads the MUTABLE public field by definition (a view); only + # alpha/anticipation/survey metadata come from kit snapshots. + cls_name = type(self).__name__ + if level == "group": + if self.group_effects is None: + raise ValueError(absent_bootstrap_surface_message(cls_name, "group")) + # The bootstrap override replaced se/p/CI - no analytical df + # may leak beside percentile inference -> NaN df column. + return self._group_effects_to_aggregation(self.group_effects, kit, group_df=None) + if balance_e is not None: + raise NotImplementedError( + "aggregate('event_study', balance_e=...) is not available on a " + "bootstrapped TwoStageDiD fit: the fit-time percentile surface " + "was computed with balance_e=None and the per-target draws are " + "not retained. Call aggregate('event_study') for the stored " + "surface, or re-fit with n_bootstrap=0 to recompute with " + "balance_e." + ) + if self.event_study_effects is None: + raise ValueError(absent_bootstrap_surface_message(cls_name, "event_study")) + meta = bk["survey_metadata"] + carrier = dataclasses.replace( + self, + survey_metadata=(dataclasses.replace(meta) if meta is not None else None), + anticipation=kit.anticipation, + alpha=kit.alpha, + event_study_vcov=None, + event_study_vcov_index=None, + event_study_df=None, # percentile inference governs no df + ) + return build_event_study_surface(carrier) + # bootstrap_results is None: n_bootstrap == 0, OR the fit's bootstrap + # failed and the fit fell back to analytical inference (two_stage.py + # stores no surface then) - the analytical kit RECOMPUTE below matches + # the fit's analytical provenance. # Fresh throwaway host per call, populated from KIT snapshots only # (estimator/config mutation after fit() must not leak in). agg = _TwoStageKitAggregator( @@ -304,8 +337,8 @@ def _aggregate_compute( effects = agg._stage2_group(**common) replay_df_g: Optional[int] = bk["survey_df_stage2"] if bk["uses_replicate"]: - # LEVEL-MATCHED replay: [overall, groups] - reproduces - # fit(aggregate='group') exactly (see the replay docstring). + # LEVEL-MATCHED replay: [overall, groups] (see the replay + # docstring). _, _, replay_df_g = agg._replay_replicate_inference( df=bk["df"], outcome=bk["outcome"], @@ -356,7 +389,7 @@ def _aggregate_compute( # Carrier + shared builder, reproducing fit's M-092 mode gates # exactly (two_stage.py): analytical -> recomputed V + index + the # finite-and->0 df scalar; replicate -> vcov/index None with the - # REPLAYED level-matched df; bootstrap unreachable (failed closed + # REPLAYED level-matched df; bootstrap unreachable (relayed # above). The carrier's metadata is a copy-on-use of the KIT's # fit-final metadata copy (never the mutable public field). if bk["uses_replicate"]: @@ -752,12 +785,11 @@ def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: elif level == "event_study": if self.event_study_effects is None: raise ValueError( - "Event study effects not computed. Aggregate post-fit " - "instead - results.aggregate('event_study') returns the " - "EventStudyResults container (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or use the deprecated " - "fit-time aggregate=; a result unpickled from a pre-3.9 " - "release carries no kit and must be re-fit)." + "Event study effects not stored on this fit. Use " + "results.aggregate('event_study') - analytical fits " + "recompute from the retained kit; bootstrapped fits relay " + "the surface computed at fit. A result unpickled from a " + "pre-3.9 release carries no kit and must be re-fit." ) rows = [] for h, data in sorted(self.event_study_effects.items()): @@ -778,12 +810,11 @@ def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: elif level == "group": if self.group_effects is None: raise ValueError( - "Group effects not computed. Aggregate post-fit instead " - "- results.aggregate('group') returns the " - "AggregationResult container (on a bootstrapped fit, " - "re-fit with n_bootstrap=0 or use the deprecated " - "fit-time aggregate=; a result unpickled from a pre-3.9 " - "release carries no kit and must be re-fit)." + "Group effects not stored on this fit. Use " + "results.aggregate('group') - analytical fits recompute " + "from the retained kit; bootstrapped fits relay the " + "surface computed at fit. A result unpickled from a " + "pre-3.9 release carries no kit and must be re-fit." ) rows = [] for g, data in sorted(self.group_effects.items()): diff --git a/diff_diff/visualization/_event_study.py b/diff_diff/visualization/_event_study.py index 31fb5baef..fc79a61d8 100644 --- a/diff_diff/visualization/_event_study.py +++ b/diff_diff/visualization/_event_study.py @@ -1066,7 +1066,9 @@ def _extract_plot_data( "Expected MultiPeriodDiDResults, CallawaySantAnnaResults, " "SunAbrahamResults, ImputationDiDResults, " "ChaisemartinDHaultfoeuilleResults, EventStudyResults, or DataFrame. " - "For DMLDiDResults (no fit-time event-study surface), plot the " + "For a result that stores no event-study surface (DMLDiD, " + "EfficientDiD, every CallawaySantAnna fit since 4.0, and ANALYTICAL " + "ImputationDiD / TwoStageDiD / ContinuousDiD fits), plot the " "post-fit container: plot_event_study(results.aggregate(" "'event_study'))." ) diff --git a/docs/api/continuous_did.rst b/docs/api/continuous_did.rst index 5b762132a..9aed4fa2b 100644 --- a/docs/api/continuous_did.rst +++ b/docs/api/continuous_did.rst @@ -131,10 +131,10 @@ Accessing dose-response curves (always computed by ``fit()``):: Event study aggregation (post-fit, row M-025):: - # Dynamic effects (binarized ATT by relative period). The event-study - # recompute needs an ANALYTICAL fit - on a bootstrapped fit - # aggregate('event_study') raises; the deprecated fit-time - # aggregate='eventstudy' remains available for that case until 4.0. + # Dynamic effects (binarized ATT by relative period). On an analytical + # fit aggregate('event_study') recomputes from the retained kit; on a + # bootstrapped fit (n_bootstrap > 0) the percentile event study is + # computed at fit and aggregate('event_study') relays it (4.0). est_es = ContinuousDiD(seed=42) results_es = est_es.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat', diff --git a/docs/api/had.rst b/docs/api/had.rst index f57459641..3a106fd1a 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -160,8 +160,8 @@ et al. (2026). The composite orchestrator does NOT pick the HAD design path (``continuous_at_zero`` / ``continuous_near_d_lower`` / ``mass_point``); that is auto-detected inside :meth:`HeterogeneousAdoptionDiD.fit` from the dose support. The workflow has -two modes selected from the panel shape (row M-139; the deprecated -``aggregate=`` override warns and dies in 4.0): a two-period +two modes selected from the panel shape (row M-139; the former +``aggregate=`` override was removed at 4.0): a two-period first-differenced sample runs the single-period tests; a multi-period panel (three or more periods) runs the joint multi-period tests. Both modes return a unified :class:`~diff_diff.HADPretestReport`. diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index faedec817..1fcfa55e6 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1283,7 +1283,7 @@ The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1: - **Note:** Repeated cross-sections (`panel=False`, Phase 7b): supports surveys like BRFSS, ACS annual, and CPS monthly where units are not followed over time. Uses cross-sectional DRDID (Sant'Anna & Zhao 2020, Section 4): `reg` matches `DRDID::reg_did_rc` (Eq 2.2), `dr` matches `DRDID::drdid_rc` (locally efficient, Eq 3.3+3.4 with 4 OLS fits), `ipw` matches `DRDID::std_ipw_did_rc`. Per-observation influence functions instead of per-unit. All three estimation methods support covariates and survey weights. - **Note:** Panel and RCS influence functions use the library-wide `phi_i = psi_i / n` convention (SE = `sqrt(sum(phi^2))`, algebraically equivalent to R's `sd(psi)*sqrt(n-1)/n`). Leading IF terms are computed on psi scale and divided by n; PS nuisance corrections are computed on psi scale (`score @ solve(Hessian)`) with a single `/n` conversion to phi. - **Note:** Non-survey DR path also includes nuisance IF corrections (PS + OR), matching the survey path structure (Phase 7a). Previously used plug-in IF only. As of v3.7 the non-survey reg and ipw paths carry their corrections too (OR estimation-effect / PS score), so the nuisance-IF treatment is method-uniform. -- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), df cleared (no analytical provenance beside percentile inference), vcov/vcov_index from the same bootstrap draws (see the bootstrap event-study covariance Note above). **Note (df_survey two-channel contract):** the container-level `df_survey` SCALAR - the fit's resolved scalar inference df (`EventStudyResults.df_survey`) - DELIBERATELY persists on bootstrapped fit-time and replayed surfaces (CS, DMLDiD, EfficientDiD publish it identically): it is a live consumer channel (HonestDiD's container branches consume it as the `df=` for their critical values on the containers they admit), and only the PER-ROW `df` column is the per-row inference-provenance channel that percentile bootstrap clears. A bootstrapped ImputationDiD survey fit with `pretrends=True` clears the per-row channel even though the lead rows retained analytical survey-df inference (the bootstrap loop skips pre-period horizons) - the shipped producer convention, backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run without the deprecated fit-time kwarg — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries the covariance of the bootstrap draws as `vcov`, so `parallel_trends` runs the joint Wald test and `pretrends_power`/`sensitivity` use the full covariance (the Bonferroni and diagonal-covariance fallbacks remain where no bootstrap covariance is stored); the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and the sibling estimators' bootstrap gates. The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). +- **Note (post-fit aggregate() - rows M-020/M-117):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut: `fit()` computes no event-study/group surface, so the legacy `event_study_effects`/`group_effects` fields - and `cband_crit_value`/`event_study_vcov`/`event_study_vcov_index`/`event_study_df` - are None after EVERY fit, `to_dataframe(level='event_study'|'group')` raises on fresh fits until the M-093 schema rewire, and `survey_design` is positional slot 7) in favor of post-fit `results.aggregate(type, balance_e=)` on the fit-retained aggregation kit ('simple' relays the stored overall inference; 'event_study'/'group' recompute from the kit — on BOOTSTRAPPED fits they REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` RNG-state snapshot: percentile se/CI/cband matching a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose`, never bit-identity; the discrete percentile p-value is a count statistic outside that claim), df cleared (no analytical provenance beside percentile inference), vcov/vcov_index from the same bootstrap draws (see the bootstrap event-study covariance Note above). **Note (df_survey two-channel contract):** the container-level `df_survey` SCALAR - the fit's resolved scalar inference df (`EventStudyResults.df_survey`) - DELIBERATELY persists on bootstrapped replayed (CS, DMLDiD, EfficientDiD publish it identically) and relayed (ImputationDiD/TwoStageDiD/ContinuousDiD, 4.0 PR 3) surfaces: it is a live consumer channel (HonestDiD's container branches consume it as the `df=` for their critical values on the containers they admit), and only the PER-ROW `df` column is the per-row inference-provenance channel that percentile bootstrap clears. A bootstrapped ImputationDiD survey fit with `pretrends=True` clears the per-row channel even though the lead rows retained analytical survey-df inference (the bootstrap loop skips pre-period horizons) - the shipped producer convention, backend-stamped and failing closed on a Rust-vs-NumPy weight-backend mismatch or a pre-replay legacy pickle — the M-027 per-level policy, with the former blanket fail-closed retired). `DiagnosticReport` now derives the event-study container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends`, `pretrends_power`, and `sensitivity` checks run on every CS fit — CS containers are M-093-admitted into `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` with pinned raw-route parity, and the raw field, when present (the requested-but-empty `{}` included), always takes precedence. `heterogeneity` is unaffected (it reads `group_time_effects` on plain fits). Bootstrapped fits now DERIVE successfully via the percentile-bootstrap replay: the derived container carries the covariance of the bootstrap draws as `vcov`, so `parallel_trends` runs the joint Wald test and `pretrends_power`/`sensitivity` use the full covariance (the Bonferroni and diagonal-covariance fallbacks remain where no bootstrap covariance is stored); the replay's re-emitted fit-time warnings are recorded and republished per section. The fail-closed skip remains for kit-less/legacy pickles, backend-mismatched replays, and bootstrapped ImputationDiD/TwoStageDiD/ContinuousDiD fits whose fit-materialized surface was fail-soft cleared (since 4.0 PR 3 those three RELAY the surface computed at fit - see their bootstrapped-fit relay Notes). The recompute levels' replay RE-EMITS the fit-time bootstrap warnings by design (the relay levels' no-re-warn convention is scoped to 'simple'/'total'). - **Note (post-fit `aggregate('total')` - the estimator-owned total incremental outcome, 3.10):** (a) **Estimand:** `total = C x overall_att` with `se = C x overall_se`, CI scaled by `C`, `t`/`p`/`df` inherited unchanged - an exact relay CONDITIONAL ON THE REALIZED AGGREGATION MASS (never call it unqualified "exact"): `C` is the design-fixed complete-case treated-observation count on every admitted routing, the inherited SE prices the overall's full inference (WIF/estimated-share terms included) but treats `C` as fixed at its realized value, and the random-mass `att*dC` variance term is deliberately out of scope (the DEFERRED survey/RC remainder). Every SE branch is homogeneous of degree 1 in the scaling, so the relay is exact under analytical AND percentile-bootstrap inference. (b) **Fail-closed routing gates**, in order: (1) repeated-cross-section-routed fits (`panel=False`, or genuinely-unbalanced `allow_unbalanced_panel=True` fits - a balanced panel with the flag stays panel-routed and admitted) raise `NotImplementedError` - the RC cohort mass counts UNITS and weights every kept post period by it, a ~T-fold overcount of treated observations on a T-wave RCS (execution-verified); (2) fits declaring a `survey_design=` raise - the realized-mass relay omits the survey mass-uncertainty term and design-aware population-scale totals are not implemented (retained weight scale differs by design family: analytic pweight resolves normalized to sum = n and CS retains no PER-OBSERVATION raw-weight record - the `survey_metadata` scalars report raw-scale `sum_weights`/`weight_range` provenance, but not the per-obs raw vector a population-scale total would need; CS accepts pweight only - while replicate designs retain raw scale); the gate reads the fit-time `is_survey_fit` KIT snapshot, so bare-`cluster=` fits are ADMITTED (their synthesized all-ones internal design is not a survey) and a post-fit edit of the public `survey_metadata` cannot bypass it; the DECLARATION BOUNDARY is deliberate - an explicit unweighted `SurveyDesign(psu=...)` fit is numerically identical to bare `cluster=` yet fails closed by declaration (declaring survey_design opts into the survey contract; conservative, never wrong numbers; the message points at the `cluster=` route); a combined survey+RC fit deterministically gets the RC message; (3) the coincidence check: on bare-`cluster=` fits `_aggregate_simple` weights kept cells by the synthesized cohort masses (full cohort size per kept cell), and when kept cells have INCOMPLETE treated support that realized mass diverges from the complete-case count - the same overcount class as (1) - so the fit raises rather than publishing an ambiguous mass; clean bare-`cluster=` fits (the normal case) are admitted with `C` equal to both counts. Panel-routed pre-upgrade (legacy) kits missing the fit-time snapshots raise the refit message - never a mutable-field or public-replay fallback. (c) **Filter-replay contract:** `C` replays `_aggregate_simple`'s cell selection verbatim over the immutable `agg_gt_cells` fit-time kit snapshot (EDiD snapshot discipline; the kit builder stashes `(g, t, effect, n_treated)` 4-tuples in dict order) - anticipation filter FIRST (cells with `g - anticipation <= t < g` COUNT as post, the business-facing subtlety; universal-base reference cells are excluded by this filter alone, their base period being `< g - anticipation` by construction), finite-effect mask SECOND, weight = cohort mass where `fixed_cohort_agg_weights` provides one else per-cell complete-case `n_treated` (the source's `agg_weight` fallback is deliberately absent - RC-only, gated out), pairwise `np.sum`; the overall scalars come from the same stored fields the `'simple'` relay reads, so a post-fit mutation of `group_time_effects` moves NEITHER factor and `total == C x aggregate('simple').att` cannot desynchronize (pinned by a mutation test). NEVER use `agg_total_weight` (the RC all-units WIF mass). (d) **NaN rules:** empty post set / all-NaN effects give `C = NaN` and an all-NaN row WITHOUT re-emitting the fit-time UserWarnings (the RELAY-level no-re-warn convention — the NaN row is the signal; the recompute levels' bootstrap replay re-emits by design); with finite `C` the relay is VERBATIM - inherited NaNs (a degenerate fit's `(nan, nan)` CI beside finite att/se = 0) pass through, mirroring `aggregate('simple')` per the repo's non-estimable-row convention (att/n are never blanked by NaN inference fields); the ONLY additional blanking is true float overflow (a finite overall value made non-finite BY the `x C` scaling - unreachable in practice, guarded per this code family's finiteness convention). (e) **Bootstrap:** 'total' is a RELAY level - available on bootstrapped fits with the df column NaN (the M-027 per-level policy). (f) **Container conventions:** single row, `level="total"`, `label=["total"]`, `target=["total"]` (a total incremental outcome, NOT an ATT - the single-non-att neutral "estimate" rendering applies), `n = [C]` with `n_kind="obs"`, `weight=[1.0]`, per-class df carrier (CS: `resolve_inference_df` - finite on admitted bare-`cluster=` fits, NaN on plain analytical panel fits). Per-level `n` semantics are deliberately DIFFERENT: 'total''s `n` is the treated observations entering the support, while 'simple''s `n` stays treated+control units by contract - `n_kind` is per-container and each is documented, satisfying the never-conflate rule. (g) **MMM admission:** both `diff_diff.mmm` exporters accept the total container ALONE - `scale` (numeric or `"auto"`) is rejected as double-counting; for overall-total exports this route supersedes `scale="auto"` (see the MMM section). The total remains meaningful only for outcomes additive in levels - the same unverifiable caveat every scale route carries. @@ -1407,7 +1407,7 @@ Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, - **Note (Phase 2 cost-benefit delta SE):** When `L_max >= 2`, `overall_att` holds the cost-benefit `delta`. Its SE is computed via the delta method from per-horizon SEs: `SE(delta) = sqrt(sum w_l^2 * SE(DID_l)^2)`, treating horizons as independent (conservative under Assumption 8). When bootstrap is enabled, per-horizon bootstrap SEs flow through the delta-method formula, so `overall_se` reflects bootstrap-derived per-horizon uncertainty but the delta aggregation itself uses normal-theory (not bootstrap percentile). This is an intentional exception to the general bootstrap-inference-surface contract: `overall_p_value` and `overall_conf_int` for `delta` use `safe_inference(delta, delta_se)`, not percentile bootstrap, because the delta is a derived aggregate rather than a directly bootstrapped estimand. -- **Note (post-fit `aggregate()` is a view - row M-026):** `results.aggregate('event_study')` and `results.aggregate('simple')` are pure VIEWS over stored fields - nothing is recomputed. `'event_study'` returns the unified `EventStudyResults` container (Phase-1 `L_max=None` fits: the 2-row l=1 view; `L_max >= 1`: the multi-horizon `l1_first_switch` surface); `'simple'` a one-row `AggregationResult` relaying `overall_att/se/t/p/CI` bit-exactly with the estimand-aware `target` label (DID_M / DID_1 / delta, or the trends-linear first-difference label whose overall row is all-NaN by design). Because it is a view, BOOTSTRAP FITS ARE PERMITTED - the library-wide per-level relay rule (since M-027 the recompute adopters' 'simple' relays are permitted on bootstrap fits too; their kit-based recompute levels REPLAY the fit-time multiplier bootstrap on CallawaySantAnna, DMLDiD (via the inherited CS kit channel), and EfficientDiD and stay closed on the remaining adopters - a recompute must never silently substitute analytical inference); here each row relays exactly the inference the fit stored, including the delta's analytical-with-df numbers under bootstrap per the `Note (Phase 2 cost-benefit delta SE)` above (the view's `df` column resolves from the actual inference path, not the bootstrap-cleared `event_study_df` channel). The dCDH event-study CONTAINER is deliberately rejected by `compute_honest_did`/`compute_pretrends_power` - its l1 placebo semantics need HonestDiD's native dCDH branch (mandatory reinterpretation warning + horizon trimming); pass the results object itself. +- **Note (post-fit `aggregate()` is a view - row M-026):** `results.aggregate('event_study')` and `results.aggregate('simple')` are pure VIEWS over stored fields - nothing is recomputed. `'event_study'` returns the unified `EventStudyResults` container (Phase-1 `L_max=None` fits: the 2-row l=1 view; `L_max >= 1`: the multi-horizon `l1_first_switch` surface); `'simple'` a one-row `AggregationResult` relaying `overall_att/se/t/p/CI` bit-exactly with the estimand-aware `target` label (DID_M / DID_1 / delta, or the trends-linear first-difference label whose overall row is all-NaN by design). Because it is a view, BOOTSTRAP FITS ARE PERMITTED - the library-wide per-level relay rule (since M-027 the recompute adopters' 'simple' relays are permitted on bootstrap fits too; their kit-based recompute levels REPLAY the fit-time multiplier bootstrap on CallawaySantAnna, DMLDiD (via the inherited CS kit channel), and EfficientDiD, while the three hybrids ImputationDiD/TwoStageDiD/ContinuousDiD RELAY the percentile surface materialized at fit since the fit-time keyword was removed at 4.0 (PR 3) - a recompute must never silently substitute analytical inference); here each row relays exactly the inference the fit stored, including the delta's analytical-with-df numbers under bootstrap per the `Note (Phase 2 cost-benefit delta SE)` above (the view's `df` column resolves from the actual inference path, not the bootstrap-cleared `event_study_df` channel). The dCDH event-study CONTAINER is deliberately rejected by `compute_honest_did`/`compute_pretrends_power` - its l1 placebo semantics need HonestDiD's native dCDH branch (mandatory reinterpretation warning + horizon trimming); pass the results object itself. - **Note (dynamic placebo SE - library extension):** Dynamic placebos `DID^{pl}_l` (negative horizons in `placebo_event_study`) now have analytical SE and bootstrap SE when `L_max >= 1`. The placebo IF uses the same cohort-recentered structure as positive horizons, applied to backward outcome differences `Y_{g, F_g-1-l} - Y_{g, F_g-1}` with the dual-eligibility control pool (forward + backward observation required). The paper's Theorem 1 variance result is stated for `DID_l`, not `DID^{pl}_l` - this extension applies the same IF/variance structure to the placebo estimand as a library enhancement. The single-period placebo `DID_M^pl` (`L_max=None`) retains NaN SE because the per-period aggregation path has no IF derivation. @@ -1661,7 +1661,8 @@ labels.* 6. **Note (discrete-treatment saturated regression — library extension beyond `contdid` v0.1.0):** `treatment_type="discrete"` estimates the dose-response by a **saturated regression** (CGBS 2024 Eq. 4.1) — one indicator per distinct dose level, so `beta_j = mean_{D=d_j}(ΔY − control) = ATT(d_j)` (a per-level 2×2 DiD) — instead of the B-spline sieve. `ACRT(d_j)` is the paper's **backward difference** on the grid `{d_0 = 0, d_1, …, d_J}` (Eq. 4.1 makes `d_0 = 0` the omitted category with `ATT(0) = 0`): `ACRT(d_j) = [ATT(d_j) − ATT(d_{j-1})]/(d_j − d_{j-1})` for `j ≥ 2`, and at the lowest positive level it references the zero-dose baseline, `ACRT(d_1) = [ATT(d_1) − 0]/(d_1 − 0) = ATT(d_1)/d_1`. So a single positive dose (`J = 1`, e.g. binary `D ∈ {0,1}`) yields `ACRT(d_1) = ATT(d_1)/d_1`, and for `d_1 = 1` the documented binary identity `ACRT = ATT` holds exactly. This is a **library extension**: `contdid` v0.1.0 accepts `treatment_type` in its signature but **does not implement the discrete path** (documented "Discrete treatment not yet implemented"), so there is **no external R anchor**. It is instead an *exact* basis swap of the B-spline design/evaluation/derivative trio for an indicator/identity/finite-difference trio; every downstream quantity is linear in `beta`, so the analytical-SE / multiplier-bootstrap / covariate (reg,dr) / survey machinery is reused unchanged and reduces *analytically* to the per-level 2×2 DiD (`bread @ psi_bar = ones(J)`; the common control mean cancels in the `j ≥ 2` adjacent differences whose `L`-rows sum to 0). **reg vs dr:** the constant DR augmentation `η̄_cont` cancels in the `j ≥ 2` differences, so `ACRT(d_j)` point AND SE are identical for `reg`/`dr` there; but `ACRT(d_1) = ATT(d_1)/d_1` references the fixed baseline `ATT(0) = 0` (not shifted by `η̄_cont`), so `reg` and `dr` genuinely **differ at `ACRT(d_1)` by `η̄_cont/d_1`** (and correspondingly in `ACRT^glob` via the `d_1` mass) — the dr influence function carries the augmentation variance at `d_1` (validated: analytical `ACRT(d_1)` SE matches the multiplier bootstrap). Validation (R-free, in CI): exact hand-calc of `ATT(d_j)`/`ACRT`/`overall_att` and the analytical SE against a direct per-level 2×2 reconstruction (`~1e-12`/`~1e-10`), DGP recovery, and MC coverage for analytical + bootstrap (`tests/test_methodology_continuous_did.py::TestDiscreteSaturated`, `tests/test_continuous_did.py::TestDiscreteSaturatedAPI`). **Fail-closed policies (no-silent-failures):** (i) multi-cohort fits with **heterogeneous dose support** across cohorts raise `NotImplementedError` — an absent global level yields a dropped zero column (`att_d[level]=0`) that the plain-sum dose aggregation would bias toward zero (support-aware aggregation is deferred; single-cohort, 2-period, and shared-support multi-cohort are supported); (ii) a requested `dvals` value that is not an observed dose level raises `ValueError` (a saturated model cannot be evaluated off-support); (iii) an over-parameterized fit (`< 2` treated units per level, or `J > n_treated/2`) warns (degenerate per-level SE); (iv) with `survey_design=`, any dose level with **zero effective treated mass in a `(g,t)` cell** raises `ValueError` — a per-cell check (not just the global positive-weight check), so a level that survey/subpopulation weights zero out for one cohort while another cohort keeps it cannot silently drop to a zero-coefficient saturated column. Cross-references `docs/methodology/continuous-did.md` § 5.1. 7. **Note (lowest-dose-as-control, Remark 3.1 — library extension beyond `contdid` v0.1.0):** `control_group="lowest_dose"` implements CGBS 2024 Remark 3.1 for settings with no untreated group (`P(D=0) = 0`): the lowest-dose group `d_L` becomes the comparison and the estimand is `ATT(d) − ATT(d_L)` (SPT), with `ATT(d_L) = 0` the omitted reference. Mechanically it is a **control-group swap** — the D=0 control pool is replaced by the `d_L` group; the entire linear influence-function / bootstrap / event-study / survey machinery is control-group-generic and reused unchanged (`ee_control` already carries the reference-group variance, so **no new SE plumbing**). On the discrete saturated basis the backward-difference operator's reference shifts from `0` to `d_L` (`ACRT(d_1) = ATT(d_1)/(d_1 − d_L)`); on the continuous B-spline path the reference shifts only `μ_0` (the level), leaving `ACRT = spline'` unchanged. `contdid` v0.1.0 does **not** implement Remark 3.1, so there is **no external R anchor**; validation (R-free, in CI): an **exact `d_L → 0` equivalence** anchor (relabelling a `never_treated` panel's D=0 group as a tiny common dose `d_L = ε` reproduces the `never_treated` ATT and SE exactly, for any ε), a discrete hand-calc of `ATT(d)−ATT(d_L)`/`ACRT`/`overall_att`/`overall_acrt` and the per-level 2×2 SE (`~1e-10`), continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement, a pre-period placebo, and MC coverage (`tests/test_methodology_continuous_did.py::TestLowestDose`, `tests/test_continuous_did.py::TestLowestDoseAPI`). The continuous path requires a genuine **mass point** at the minimum dose (`>= 2` units at `d_L`, i.e. `P(D=d_L) > 0`) — the Remark 3.1 identification condition; a singleton minimum fails closed. **Fail-closed policies (no-silent-failures):** (i) never-treated units present with `lowest_dose` → `ValueError` (they would be silently dropped); (ii) singleton `d_L` (no mass point) → `ValueError`; (iii) no treated dose above `d_L` → `ValueError`; (iv) user `dvals ≤ d_L` → `ValueError` (`d_L` is the omitted reference); (v) survey/subpopulation weighting that leaves the `d_L` group with `< 2` positive-weight units → `ValueError` (a single positive-weight reference unit gives `ee_control = 0`, i.e. zero control-side variance — the effective-`>= 2` analogue of the raw mass-point guard, applied after weighting); (vi) a boundary gap `d_1 − d_L` that is a tiny fraction of the dose range warns (huge boundary ACRT/SE). **Deferred (fail-closed `NotImplementedError` + TODO):** multi-cohort `lowest_dose` (needs a within-cohort reference + support-aware cross-cohort aggregation) and `covariates=` × `lowest_dose` (conditional-PT-relative-to-`d_L` estimand). Cross-references `docs/methodology/continuous-did.md` § 5.6. -8. **Note (post-fit aggregate() - rows M-025/M-122):** `fit(aggregate=)` is deprecated in 3.9 (removed in 4.0; the no-underscore `"eventstudy"` spelling dies with it) in favor of post-fit `results.aggregate(type)` on the unified vocabulary + `'dose'` as this estimator's documented extra level; the PRE-EXISTING fit-time value validation is retained (unknown strings still raise `ValueError` after the deprecation warning - unlike the EfficientDiD/Imputation shims, which never validated). (a) **MIXED view/recompute architecture** (unique among the aggregate-postfit adopters): the dose-response curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by `fit()` (`aggregate="dose"` was a fit-time no-op), so `aggregate('simple')` (2 rows, targets att/acrt - the dual-estimand case the `AggregationResult.target` column exists for; `n` = the DISJOINT treated+control unit total, `n_kind='units'`) and `aggregate('dose')` (2N target-discriminated rows; labels = the dose grid twice; no count/mass per row) are pure VIEWS over stored public fields, PERMITTED on bootstrap fits (the library-wide per-level relay rule, since M-027 converged CS/EDiD/Imputation/TwoStage onto it) - they relay stored inference verbatim, including the FINITE `safe_inference` t-stat fit stores beside the percentile p/CI on bootstrapped overall rows and the `DoseResponseCurve.to_dataframe`-exact NaN-t derivation on dose rows; only the df column is uniformly NaN under bootstrap. (b) **`aggregate('event_study')` recomputes** the binarized event study from a pruned per-cell IF payload retained on the fit-built kit: per-(g,t) treated/control positional indices, `delta_y_treated`, `ee_control`, masses and the covariate-path `if_att_glob` (O(n_treated+n_control) per cell), unit-level arrays, and - on survey fits - the PANEL-LEVEL `ResolvedSurveyDesign` ref (the recompute performs the unit collapse itself, keeping the moved body verbatim; on replicate designs the (n_obs x R) replicate matrix rides along - the documented memory cost). The K-dimensional spline machinery (bread, `ee_treated`, `Psi_eval`, `dPsi_*`) is NOT retained; no panel data columns and no raw unit identifiers are retained. Replicate-weight designs ARE supported post-fit (IF-based `compute_replicate_if_variance` - no refit replay). Bootstrap fits carry a SCALARS-ONLY kit and the event-study route fails closed (`NotImplementedError`; the deprecated fit-time `aggregate='eventstudy'` computes the bootstrap surface until 4.0, or re-fit with `n_bootstrap=0`) - a seeded post-fit bootstrap replay is the TODO.md row. (c) **Fit-faithful quirk:** when no post-treatment (g,t) cells exist, event-study rows keep NaN inference on BOTH routes (the fit-time surface never fills them). (d) **df provenance:** the stored `dose_response_att.df_survey` channel (the value every fit-time `safe_inference` received) drives the views' df column (finite-and-positive else NaN - the replicate-undefined 0 sentinel reports NaN in the column but feeds the t/p derivation raw); the event-study container's PER-ROW df column now threads the same survey df via the results-level `event_study_df` channel (M-092 completion; finite on analytical survey fits, None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel), beside the scalar `df_survey` channel (the two-channel contract - see the CallawaySantAnna post-fit Note). (e) **Rendering:** this is the FIRST heterogeneous-`target` `AggregationResult`; `summary()`/`to_dataframe()` gained the target column / first-appearance target-block ordering amendment (normative rule in `docs/v4-design.md` section 6; uniform-target producers byte-stable). (f) **Consumer admission:** `compute_honest_did`/`compute_pretrends_power` reject ContinuousDiD containers BY DESIGN - no joint event-study covariance exists (per-bin IF SEs only) and the binarized bins carry no reference-period normalization at all (see M-093). Warning stacklevels in the moved bodies remain tuned for the fit-time frame depth, so post-fit-route warnings attribute to a library frame (the shipped EfficientDiD convention). (g) **Report-layer consumption:** `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent) for its `heterogeneity` check — the only ES-gated check in ContinuousDiD's applicability; captured recompute warnings are re-published on the section and derivation failures (bootstrap fits) surface as explicit skip context. +8. **Note (post-fit aggregate() - rows M-025/M-122):** `fit(aggregate=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut; the no-underscore `"eventstudy"` spelling and the fit-time value validation died with it; `survey_design`/`covariates` are positional slots 7/8) in favor of post-fit `results.aggregate(type)` on the unified vocabulary + `'dose'` as this estimator's documented extra level. (a) **MIXED view/recompute architecture** (unique among the aggregate-postfit adopters): the dose-response curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by `fit()` (`aggregate="dose"` was a fit-time no-op), so `aggregate('simple')` (2 rows, targets att/acrt - the dual-estimand case the `AggregationResult.target` column exists for; `n` = the DISJOINT treated+control unit total, `n_kind='units'`) and `aggregate('dose')` (2N target-discriminated rows; labels = the dose grid twice; no count/mass per row) are pure VIEWS over stored public fields, PERMITTED on bootstrap fits (the library-wide per-level relay rule, since M-027 converged CS/EDiD/Imputation/TwoStage onto it) - they relay stored inference verbatim, including the FINITE `safe_inference` t-stat fit stores beside the percentile p/CI on bootstrapped overall rows and the `DoseResponseCurve.to_dataframe`-exact NaN-t derivation on dose rows; only the df column is uniformly NaN under bootstrap. (b) **`aggregate('event_study')` recomputes** the binarized event study from a pruned per-cell IF payload retained on the fit-built kit: per-(g,t) treated/control positional indices, `delta_y_treated`, `ee_control`, masses and the covariate-path `if_att_glob` (O(n_treated+n_control) per cell), unit-level arrays, and - on survey fits - the PANEL-LEVEL `ResolvedSurveyDesign` ref (the recompute performs the unit collapse itself, keeping the moved body verbatim; on replicate designs the (n_obs x R) replicate matrix rides along - the documented memory cost). The K-dimensional spline machinery (bread, `ee_treated`, `Psi_eval`, `dPsi_*`) is NOT retained; no panel data columns and no raw unit identifiers are retained. Replicate-weight designs ARE supported post-fit (IF-based `compute_replicate_if_variance` - no refit replay). Bootstrap fits carry a SCALARS-ONLY kit; since 4.0 (PR 3) they ALWAYS materialize the binarized event study at fit and `aggregate('event_study')` RELAYS it (see the bootstrapped-fit relay Note below) - a genuine seeded post-fit bootstrap replay is the TODO.md row. (c) **Fit-faithful quirk:** when no post-treatment (g,t) cells exist, event-study rows keep NaN inference on BOTH routes (the fit-time surface never fills them). (d) **df provenance:** the stored `dose_response_att.df_survey` channel (the value every fit-time `safe_inference` received) drives the views' df column (finite-and-positive else NaN - the replicate-undefined 0 sentinel reports NaN in the column but feeds the t/p derivation raw); the event-study container's PER-ROW df column now threads the same survey df via the results-level `event_study_df` channel (M-092 completion; finite on analytical survey fits, None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel), beside the scalar `df_survey` channel (the two-channel contract - see the CallawaySantAnna post-fit Note). (e) **Rendering:** this is the FIRST heterogeneous-`target` `AggregationResult`; `summary()`/`to_dataframe()` gained the target column / first-appearance target-block ordering amendment (normative rule in `docs/v4-design.md` section 6; uniform-target producers byte-stable). (f) **Consumer admission:** `compute_honest_did`/`compute_pretrends_power` reject ContinuousDiD containers BY DESIGN - no joint event-study covariance exists (per-bin IF SEs only) and the binarized bins carry no reference-period normalization at all (see M-093). Warning stacklevels in the moved bodies remain tuned for the fit-time frame depth, so post-fit-route warnings attribute to a library frame (the shipped EfficientDiD convention). (g) **Report-layer consumption:** `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent) for its `heterogeneity` check — the only ES-gated check in ContinuousDiD's applicability; captured recompute warnings are re-published on the section; bootstrapped fits are read from the fit-materialized raw field (raw-field precedence), and derivation failures (kit-less pickles, a fail-soft-cleared bootstrapped surface) surface as explicit skip context. +- **Note (bootstrapped-fit relay, 4.0):** (i) RELAY: a fit with `n_bootstrap > 0` ALWAYS computes the binarized event study at fit (the only route - the kit stays SCALARS-ONLY there, so no recompute is ever possible on a bootstrapped fit and no new kit key is added: a 3.9-3.13 pickle must not `KeyError`), stores it in `event_study_effects` (percentile se/p/CI, `event_study_df=None`), and `aggregate('event_study')` RELAYS it (alpha/anticipation/`base_period`/survey metadata from the kit snapshot; per-row `df` column NaN; `df_survey` scalar from the carrier's survey metadata; `vcov`/`vcov_index` None; no reference row). The gate stays the kit scalar `n_bootstrap > 0` (not the mutable public field); `balance_e` applies to no level here (the mixin rejects it). Analytic fits are unchanged (kit recompute; legacy field None) - the dead analytic fit-time inference arm was deleted. (ii) FAIL-SOFT materialization + storage rule: the surface is built inside one guard - any exception leaves it None and `_run_bootstrap` runs for the overall/dose targets only (today's plain-fit path); a `_run_bootstrap` attempt that raises (anything but the replicate-design `NotImplementedError`/`ValueError`, which are re-raised - the check fires inside `_run_bootstrap` after the ES is computed) WITH the surface present clears it and RETRIES ONCE overall-only (`np.random.default_rng(self.seed)` is rebuilt on entry, so a seeded retry reproduces attempt 1's weight draws and the overall/dose quintets equal a plain fit's); a retry failure propagates out of `fit()` exactly as today, so a RETURNED bootstrapped fit always carries bootstrap inference. STORAGE RULE: the surface is stored IFF `_run_bootstrap` ran (post-treatment cells exist); a bootstrapped fit with no post cells clears it and `aggregate('event_study')` raises the shared absent-surface `ValueError` (today that fit raised `NotImplementedError` from the bootstrap gate; its overall ATT is already NaN with a `UserWarning`). The fail-soft `UserWarning` fires only when the fit carries bootstrap inference and the surface was dropped: 'ContinuousDiD: the event-study/group surfaces could not be materialized on this bootstrapped fit ({e}); the bootstrap covered the overall ATT only, so aggregate('event_study') / aggregate('group') are unavailable here - re-fit with n_bootstrap=0 for the analytical post-fit recompute.'; the relay then raises 'This bootstrapped ContinuousDiDResults carries no stored event_study surface: either it was fitted before 4.0 (a bootstrapped plain fit built none) or the surface could not be materialized at fit (see the fit-time warning). Re-fit with diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the analytical post-fit recompute.' Net invariant: a bootstrapped plain fit's overall/dose quintets and success/abort outcome are unchanged by the 4.0 removal. (iii) The ES aggregator's `UserWarning`s and the per-target bootstrap-stats `RuntimeWarning`s ('Dropping n/N non-finite bootstrap samples in ... event study (h=...)', 'Too few valid bootstrap samples', 'Bootstrap SE is non-finite or zero') now fire on bootstrapped plain fits that never emitted them. (iv) Pre-4.0 pickles: a 3.9-3.13 bootstrapped pickle fitted with `fit(aggregate='eventstudy')` relays its stored surface; one fitted plain stores none and the relay raises the refit `ValueError`. (v) The relay reads the MUTABLE public `event_study_effects` field (a view by definition - the StackedDiD precedent); only alpha/anticipation/base_period/survey metadata come from kit snapshots, so the recompute route's 'kit snapshots only' invariant does not hold for the relayed dict. (vi) Downstream: `summary()` prints the event-study table and `to_dataframe(level='event_study')` returns a frame on bootstrapped plain fits; `DiagnosticReport`/`BusinessReport` run `heterogeneity` (the only ES-gated check here; no sensitivity) on the relayed surface instead of skipping with `NotImplementedError`; `plot_event_study(results)` plots it natively. 9. **Note:** `survey_metadata` is recomputed from the UNIT-level resolved design on EVERY inference arm - analytic, bootstrap, and the degenerate no-post-cells arm - matching the CallawaySantAnna/EfficientDiD convention (3.12; previously the bootstrap arm published the obs-level metadata from `_resolve_survey_for_fit`, so `sum_weights`/`effective_n`/`n_psu` - and, on implicit-PSU designs, `df_survey` - diverged from the analytic arm by panel length). Provenance-only: estimates and inference are unaffected (the bootstrap arm's inference remains percentile with no df). ### Implementation Checklist @@ -1872,7 +1873,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`. - **Note:** `vcov_type` is permanently narrow to `{"hc1"}` per the Chen-Sant'Anna-Xie (2025) EIF-based variance achieving the semiparametric efficiency bound. Analytical-sandwich families `{classical, hc2, hc2_bm}` are rejected at `__init__` — the per-unit EIF aggregation has no equivalent single design matrix on which hat-matrix leverage or Bell-McCaffrey Satterthwaite DOF can be defined. `cluster=` invokes Liang-Zeger CR1 on cluster-aggregated EIF (`_compute_se_from_eif` with `cluster_indices`); `survey_design=` invokes TSL on the combined IF (`_compute_survey_eif_se`); both live in `diff_diff/efficient_did_aggregation.py` since the M-023 post-fit aggregate() extraction. `vcov_type='conley'` deferred to the EfficientDiD Conley follow-up row in DEFERRED.md. - **Note:** Default `cluster=None` (no survey design) renders summary label "HC1 heteroskedasticity-robust" because the per-unit EIF SE `sqrt(mean(EIF²)/n)` is methodologically HC1-style (no Liang-Zeger G/(G-1) finite-sample correction). `EfficientDiDResults.cluster_name` and `n_clusters` stay None under unclustered fits. This diverges from `ImputationDiD` which auto-clusters at unit per Borusyak-Jaravel-Spiess (2024) Theorem 3 — there the default summary renders the CR1 unit-clustered label. - **Note:** `set_params(vcov_type=bad)` raises immediately on EVERY estimator: since the shared `BaseEstimator` mixin (`diff_diff/_base.py`, v4 2(c)-i), `set_params` validates transactionally by constructor probe re-init, so it enforces exactly `__init__`'s validation, eagerly, library-wide. The former split — EfficientDiD eager vs `ImputationDiD`/`TripleDifference`/`CallawaySantAnna` (and six more: SunAbraham, StackedDiD, StaggeredTripleDifference, SpilloverDiD, TROP, PreTrendsPower) accepting constructor-rejected values until `fit()` — is retired; the fit-time re-validation layers remain as a second check against DIRECT attribute mutation (`est.vcov_type = ...`), which no setter can see. -- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. +- **Note (post-fit aggregate() - rows M-023/M-120):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut: `fit()` computes no event-study/group surface, the legacy `event_study_effects`/`group_effects`/`event_study_df` fields are None after EVERY fit, `to_dataframe(level='event_study'|'group')` raises on fresh fits until the M-093 schema rewire, and `survey_design`/`store_eif` are positional slots 7/8) in favor of post-fit `EfficientDiDResults.aggregate(type, balance_e=)` - a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a StackedDiD/dCDH view relay): `fit()` computes nothing extra, the results object retains an `AggregationKit`, and `aggregate('event_study'/'group', balance_e=)` re-runs the extracted `_EfficientAggregationMixin` aggregators on a throwaway host while `aggregate('simple')` relays the stored overall row bit-exact. (a) RETAINED BUFFERS (memory contract; phrased as maxima - optional design fields stay None when unsupplied): the per-(g,t) EIF dict, O(n_units x n_gt), the dominant payload - retained on EVERY fit regardless of `store_eif`, which since 3.9 governs only the public `influence_functions` field; `unit_cohorts` (cohort labels), `unit_level_weights`, factorized cluster codes - O(n_units) each; on ordinary (TSL) survey fits the unit-level `ResolvedSurveyDesign` adds `weights` plus, where supplied, `strata`/`psu`/`fpc` (factorized int codes / float values, never raw labels) - up to four O(n_units) arrays; on replicate designs it adds the O(n_units x n_replicates) replicate matrix plus, where supplied, `replicate_strata`/`replicate_rscales` (O(n_replicates)); per-row dict SNAPSHOTS of `group_time_effects` plus copies of the `groups`/`time_periods` lists and the scalar `pt_assumption`/`n_treated+n_control` provenance (aggregate() recomputes exclusively from these private snapshots, never from the mutable public result fields - a user edit of the public rows cannot mix altered point estimates with the retained EIF variance); scalars `n_units`, `cohort_fractions`, and the POST-OVERALL `df_survey` snapshot (captured after the overall inference and before the ES/group gates: the group pass can degenerate the working df to None on replicate designs with `n_valid <= 1`, and every fit-time aggregation seeds from the post-overall value, so recompute replays the exact seed). The data-minimization guarantee is scoped to unit identifiers - no unit-label container is retained. (b) `balance_e` uses the ANCHOR-HORIZON rule (keep cohorts with a finite effect at the anchor horizon, then retain all their horizons) - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference (EDiD anchors on the `int(t - g)` bucket, CS on raw `t - g` - identical on integer-period panels; see the truncation Note below), divergent from ImputationDiD/TwoStageDiD's balanced-window rule; an anchor no cohort reaches warns and yields a legal zero-row container. (c) BOOTSTRAP fits: 'simple' (and 'total') RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the RECOMPUTE levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's `BootstrapReplaySpec` (the fit-captured RNG state + run params BY VALUE - seed=None fits replay; post-fit `set_params`/attribute mutation cannot alter it; pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, `assert_allclose` - never bit-identity), the percentile p-value is a count statistic (compared at 2/n_bootstrap), and the replay publishes no analytical provenance (percentile overrides via the shared `bootstrap_utils` appliers; group rows' `df_used` cleared exactly as at fit time; EDiD has no sup-t cband). The spec stamps the weight backend at capture ('rust'/'numpy', or 'portable' for backend-independent branches - stratified survey generation, census-FPC zero weights, the single-PSU degenerate early return); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. Replays RE-EMIT the fit-time bootstrap warnings for the replayed configuration (low n_bootstrap, single-PSU, empty balance_e anchor, non-finite-draw RuntimeWarnings); the relay levels stay silent. Per-call cost O(n_bootstrap x n_units x n_gt), no memoization. The M-027 rationale - no level publishes analytical-provenance fields beside percentile inference - is honored by the percentile overrides and NaN df channels. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the primary ground is the absent joint event-study covariance (container `vcov=None`; the PER-ROW df column now threads the post-overall `df_survey` snapshot via the results-level `event_study_df` channel on analytical survey fits - M-092 completion; None on non-survey/bootstrapped/no-surface fits and for the replicate-undefined 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). Reference semantics are regime-dependent: under `pt_assumption="all"` there is NO reference row (universal first-period baseline; e=-1 is a genuine estimate); under `"post"` the per-cohort baseline cell is materialized as a mechanical zero anchor at `e = -1 - anticipation` whenever it is not the panel's first period, and the MEMBERSHIP-GATED `reference_period` property (the SunAbraham rule - never synthesized when the anchor cell was not estimated) marks it `is_reference` in the container and corrects `plot_event_study`'s inferred reference (previously the `-1` fallback) on PT-Post `anticipation>0` fits. (e) 'simple' relay conventions: `target="att"`, `n = n_treated_units + n_control_units` with `n_kind="units"` (DISJOINT by construction - `last_cohort` trimming reassigns before the counts, so a true total exists, unlike StackedDiD's overlapping sets), `df` = the post-overall snapshot (provenance-exact where `survey_metadata.df_survey` can diverge in the degenerate replicate state); 'group' relay: `n_kind="cells"`, `weight=None` (equal within-cohort weights, no cross-cohort mass), per-row `df_used` array captured at each row's `safe_inference` call (exact by construction; a stated divergence from CS's conservative-min scalar broadcast); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose `survey_metadata` copy carries the snapshot `df_survey` and whose `event_study_df` threads the same snapshot into the per-row df column (cleared on bootstrap replays and for the 0 sentinel). (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C = sum(n_treated)` over the kept post-anticipation FINITE-effect cells of the kit's deep-copied `group_time_effects` snapshot (an exact integer sum; NEVER the routinely-non-integral `n_units x sum(cohort_fractions)` float product). Unweighted fits only: ANY declared `survey_design=` fails closed via the kit's `unit_level_weights`/`resolved_survey_unit` markers (weight-type-agnostic - unweighted psu-only and analytic fweight designs, whose resolved weights stay RAW, gate identically; EDiD never synthesizes an internal design, so the markers are exact declaration provenance and post-fit mutation of the public `survey_metadata` cannot bypass them). No keepers -> NaN mass -> all-NaN row (no re-warn); with finite `C` the relay is verbatim (inherited NaNs pass through; att/n never blanked). Bootstrap fits relay with a NaN df column (M-027); df carrier = the post-overall `df_survey` snapshot; container: single `target='total'` row, `n_kind='obs'`, the per-level n-semantics note in the CS total Note applies. MMM: the container is admitted with NO scale (see the MMM section); the survey/RC totals remainder and its att*dC variance term are the DEFERRED row. - **Note (fractional-period truncation bucketing):** the event-study equation defines `ES(e)` at an exact relative time, but the implementation buckets horizons by `int(t - g)` - truncation TOWARD ZERO - on the analytical aggregator, the bootstrap ES prep (all three keying sites, the `balance_e` anchor filter included; aligned 2026-08), and `hausman_pretest`'s internal aggregation. On integer-period panels `int()` is the identity and nothing below applies. On fractional-period panels (accepted by `fit()` without an integrality check): (i) bucket 0 is DOUBLE-WIDTH, spanning `(-1, 1)`, so the pre-treatment horizon e=-0.5 pools into the post-treatment e=0 bucket; e=1.0/1.5 pool into bucket 1 and e=-1.5/-1.0 into bucket -1; (ii) under `pt_assumption="post"` the bucket at `-1 - anticipation` pools genuine estimated fractional pre-treatment horizons (e.g. raw e=-1.5) with the mechanical zero anchor, and the container's reference normalization then publishes the WHOLE pooled row as `att=0.0` with NaN inference (`is_reference=True`) - genuine fractional-horizon estimates are subsumed into the reference row (the fit-time `event_study_effects` dict keeps the pooled effect + percentile inference unrewritten; the rewrite is container-only); (iii) WEIGHTING is CELL-MASS within the bucket - the aggregator appends one `(effect, pi_g)` term per CELL and normalizes, so a cohort contributing k cells to a bucket carries k*pi_g mass, whereas the exact-relative-time equation has one term per cohort - while `n_groups` counts DISTINCT cohorts in the bucket (identity with the cell count on integer panels). Every aggregation that truncation-buckets a fractional horizon emits a UserWarning naming this Note. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. @@ -2173,14 +2174,14 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. inert-config warning predicate is REACHABILITY-based, no longer aggregate-keyed: an explicitly non-default value warns iff the per-lead inference is unreachable for the fit config — reachable iff - `pretrends=True` AND not a replicate-weight design AND (the deprecated - fit-time `aggregate='event_study'/'all'` was supplied OR - `n_bootstrap <= 0`, since post-fit `results.aggregate('event_study')` - reaches the leads on any analytical pretrends fit but fails closed on - bootstrapped ones). Reachability-BASED, not exact: a fit whose bootstrap - later FAILS (`bootstrap_results=None`) can still aggregate post-fit, so - that corner warns spuriously — the warning fires before the bootstrap - runs and cannot know. + `pretrends=True` AND not a replicate-weight design. Since 4.0 (PR 3, + the fit-time `aggregate=` removal) the predicate is EXACT: analytical + pretrends fits reach the leads through post-fit + `results.aggregate('event_study')`, and bootstrapped pretrends fits + materialize the lead inference at fit (relayed by `aggregate()`), so + the former bootstrap spurious-warning corner no longer exists; the + replicate-weight exclusion stands because the per-replicate lead refits + are unimplemented (`aggregate('event_study')` raises there). *Edge cases:* - **Unbalanced panels:** FE estimated via iterative alternating projection (Gauss-Seidel), equivalent to OLS with unit+time dummies. Converges in O(max_iter) passes; typically 5-20 iterations for unbalanced panels, 1-2 for balanced. One-pass demeaning is only exact for balanced panels. @@ -2225,7 +2226,8 @@ where `W_it(h) = 1[K_it = h]` are lead indicators, estimated on `Omega_0` only. - [x] Supports balanced and unbalanced panels (iterative Gauss-Seidel demeaning for exact FE) - [x] Event study and group aggregation -- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `imputation_did()` wrapper that used to forward the shared sentinel was removed at 4.0 per M-070 - PR 2 of the cut) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (the per-target psi machinery makes exact replay tractable - a TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run without the deprecated fit-time kwarg; the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (bootstrap, missing kit, the replicate gate above) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (post-fit aggregate() - rows M-021/M-118):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut; `survey_design` is positional slot 7; the `imputation_did()` wrapper that used to forward the shared sentinel was removed per M-070 - PR 2) in favor of post-fit `ImputationDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit (not an EIF-payload kit: ES/group aggregation is a target-specific Theorem-3 recompute - each `balance_e` re-masks which treated observations enter every horizon and re-solves the untreated projection - so no compact influence payload can replace the frame). (a) RETAINED BUFFERS (memory contract): the kit's bookkeeping holds REFERENCES to the SAME per-fit objects `_fit_data` already retains for `pretrend_test()` - the working panel copy (all user columns plus `_tau_hat`/`_rel_time`/`_never_treated`), the Omega masks, `unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`, the resolved survey design, and `survey_weights` - ZERO marginal memory, and pickles are unchanged via memoization (`_estimator_ref` already ships these objects); plus value SNAPSHOTS for isolation (a `treatment_groups` copy, config scalars, a `dataclasses.replace` copy of `survey_metadata`, `overall_att`, `n_treated_obs`) and TWO df-provenance scalars (`survey_df_seed`, what the analytical aggregators received; `survey_df_final`, what the stored overall inference received). Each `aggregate()` call runs on a fresh throwaway host with a call-local projection cache (the fit-local factorizations are unpicklable and never retained). (b) `balance_e` uses the BALANCED-WINDOW rule: a cohort is retained iff its observed relative-time set - checked against the FULL panel via `_build_cohort_rel_times()` - covers the contiguous window `[-balance_e, max_h]`; the SAME rule TwoStageDiD uses, divergent from CS/EfficientDiD's anchor-horizon rule. A window no cohort satisfies warns and yields the reference-marker-only dict (a legal near-empty container). (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column; since 4.0 (PR 3) the ES/group levels RELAY the percentile surface materialized at fit (see the bootstrapped-fit relay Note below; `balance_e` fails closed there - the balanced-window replay via the retained psi machinery is the TODO row); the prior uniform fail-closed rule was superseded 2026-08-05 with the M-027 per-level convergence. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is REJECTED BY DESIGN (both terminal TypeErrors state it): the surface carries no joint event-study covariance - per-horizon conservative SEs only (container `vcov=None`; the PER-ROW df column now threads the FINAL survey df via the results-level `event_study_df` channel - M-092 completion; `survey_df_final` at fit, the level-matched replay value on replicate replays; leads included on survey fits; None on non-survey/bootstrapped/no-surface fits and for the 0 sentinel - beside the scalar `df_survey` channel; see the CS post-fit Note's two-channel contract). (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (the treated/control UNIT sets overlap - a treated unit with pre-periods counts in both - so the CS/EDiD disjoint-units convention cannot apply; |Omega_1| is the population the ATT averages over, of which only finite-tau-hat observations enter the average - `n` reports the raw count, so on partially unidentified fits `n` exceeds the averaged support) and `df = survey_df_final`; 'group' rows carry per-row `df_used` captured at each row's `safe_inference` (the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via `.get`); 'event_study' rides the shared `_from_relative_dict` builder via a carrier whose metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replicate_override_aggregates` with a LEVEL-MATCHED stack: `compute_replicate_refit_variance` validates replicates JOINTLY (all-finite rows), so `aggregate(L)` reproduces `fit(aggregate=L)` exactly, a `fit(aggregate='all')` surface is NOT the equivalence target when a replicate NaNs on exactly one family's targets, and - the documented migration delta - moving a replicate fit from `fit(aggregate=)` to plain fit + post-fit `aggregate()` can change the public OVERALL row's se/CI/df on such degenerate designs (each surface self-consistent; pinned in the contract tests). `pretrends=True` + replicate: post-fit `aggregate('event_study')` raises NotImplementedError (per-replicate lead refits unimplemented; the unreachable fit-time twin of this gate was deleted at 4.0); 'group'/'simple' still work. Recompute re-emits the fit-time warnings (LSMR, Prop-5, empty-window) with fit-tuned stacklevels - post-fit attribution lands on a library frame, an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits) and `heterogeneity` checks run on analytic plain fits (bootstrapped fits are read from the fit-materialized raw fields - raw-field precedence); the re-emitted recompute warnings are captured and re-published on the consuming report section (record-and-republish), and derivation failures (missing kit, the replicate gate above, a fail-soft-cleared bootstrapped surface) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the FINITE-tau complete-case support (`total_support`, a float SNAPSHOT stashed at kit build precisely because the kit's `df` is a live `_fit_data` reference - a post-fit frame edit must not move the total; pre-upgrade kits missing the stash raise the refit message). Since the overall IS the finite-support mean, `C x overall = sum(tau)` exactly - this total uses the FINITE support and thereby fixes the documented raw-|Omega_1| overcount of the MMM `scale=\"auto\"` route for total exports ('simple''s `n` stays the raw count by contract; different levels, different documented n semantics). Zero support (the all-unidentified fit, overall already NaN) -> NaN mass -> all-NaN row (no re-warn); finite `C` relays verbatim - the constant-outcome degenerate fit keeps finite att/se = 0 beside its inherited `(nan, nan)` CI, mirroring 'simple'. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (bootstrapped-fit relay, 4.0):** (i) RELAY: a fit with `n_bootstrap > 0` ALWAYS computes the event-study AND group percentile surfaces at fit (`balance_e=None` - exactly what the 3.x `fit(aggregate='all')` computed; percentile se/p/CI, recomputed t, `df_used`/`event_study_df` cleared), stores them in `event_study_effects`/`group_effects`, and `aggregate('event_study')`/`aggregate('group')` RELAY them (alpha/anticipation/survey metadata from the kit snapshot; per-row `df` column NaN - the `pretrends=True` lead rows show NaN df too, identical to the 3.x bootstrapped surface, by design; `df_survey` scalar from the carrier's survey metadata; `vcov`/`vcov_index` None; reference rows via the zero-count sentinel). `aggregate('event_study', balance_e=k)` on a bootstrapped fit FAILS CLOSED (`NotImplementedError`: the stored surface is unrestricted and the per-target draws are not retained; re-fit with `n_bootstrap=0`). Analytic fits are unchanged (kit recompute; legacy fields None). The gate is `bootstrap_results is not None`: `None` (n_bootstrap == 0, OR a bootstrapped fit that ended analytical) means the analytical kit RECOMPUTE, today's behaviour. (ii) FAIL-SOFT materialization + storage rule: the surfaces are built inside one guard - any exception (e.g. the `pretrends=True` + `rank_deficient_action='error'` lead regression raising `ValueError` on a lead-collinear covariate) leaves them None and the bootstrap runs overall-only (today's plain-fit path, which SUCCEEDS where the 3.x `fit(aggregate='event_study')` raised); a bootstrap attempt that raises (anything but `NotImplementedError`, re-raised) WITH surfaces present clears them and RETRIES ONCE overall-only - `_precompute_bootstrap_psi` + `_run_bootstrap` re-run with `event_study_effects=None, group_effects=None`; `np.random.default_rng(self.seed)` is rebuilt on entry, so a seeded retry reproduces attempt 1's weight draws and the overall quintet equals a plain fit's (an unseeded retry draws a fresh, equally valid realization). The retry keeps today's failure semantics: a psi-precompute failure -> the 'Bootstrap pre-computation failed ... Skipping bootstrap inference.' warning + analytical overall; a `_run_bootstrap` failure -> RE-RAISED (the fit aborts exactly as before). STORAGE RULE: the legacy fields hold a surface IFF `bootstrap_results is not None`; when the fit ends analytical (`n_valid == 0` skipped the psi precompute, total failure) they are cleared and `aggregate()` RECOMPUTES analytically - an analytical surface must never sit on a bootstrapped fit where `summary()`/`to_dataframe()`/the DiagnosticReport raw-field precedence would read it as the fit's inference. The fail-soft `UserWarning` fires only when the fit DOES carry bootstrap inference and a surface was dropped: 'ImputationDiD: the event-study/group surfaces could not be materialized on this bootstrapped fit ({e}); the bootstrap covered the overall ATT only, so aggregate('event_study') / aggregate('group') are unavailable here - re-fit with n_bootstrap=0 for the analytical post-fit recompute.'; the relay then raises the shared absent-surface `ValueError` ('This bootstrapped ImputationDiDResults carries no stored event_study surface: either it was fitted before 4.0 (a bootstrapped plain fit built none) or the surface could not be materialized at fit (see the fit-time warning). Re-fit with diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the analytical post-fit recompute.'). Net invariant: a bootstrapped plain fit's overall quintet and success/abort outcome are unchanged by the 4.0 removal. (iii) Newly reachable warnings on bootstrapped plain fits: the aggregators' own `UserWarning`s (Prop-5 unidentified horizons, empty horizon after filters, LOO single-unit auxiliary groups) and the per-target bootstrap-stats `RuntimeWarning`s ('Dropping n/N non-finite bootstrap samples in ... event study (h=...)', 'Too few valid bootstrap samples', 'Bootstrap SE is non-finite or zero'). (iv) Pre-4.0 pickle limitation: a 3.9-3.13 bootstrapped pickle fitted with `fit(aggregate=..., balance_e=k)` stores a balance_e-RESTRICTED surface and records no fit-time `balance_e`, so the relay returns that surface as stored - re-fit under 4.0 for the unrestricted one; one fitted plain stores none and the relay raises the refit `ValueError`. (v) The relay reads the MUTABLE public `event_study_effects`/`group_effects` fields (a view by definition - the StackedDiD precedent); only alpha/anticipation/survey metadata come from kit snapshots, so the recompute route's 'kit snapshots only' invariant does not hold for the relayed dict. (vi) Downstream: `summary()` prints the ES/group tables and `to_dataframe(level='event_study'|'group')` returns frames on bootstrapped plain fits; `DiagnosticReport`/`BusinessReport` run `heterogeneity` (on the relayed GROUP percentile dict - `_collect_effect_scalars` reads `group_effects` first) and `parallel_trends` (`pretrends=True`) on the relayed surface instead of skipping with `NotImplementedError` (no sensitivity check applies); `plot_event_study(results)` plots them natively. Replicate-weight designs never meet the always-on surfaces (replicate + `n_bootstrap > 0` is rejected before them), so the fit-time replicate stack is always `[overall]`-only - the degenerate-design overall-row migration delta in (e) stands. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. --- @@ -2319,7 +2321,8 @@ Our implementation uses multiplier bootstrap on the GMM influence function: clus - [x] Multiplier bootstrap on GMM influence function - [x] Event study and overall ATT aggregation -- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` is deprecated (3.9; removed 4.0; joint FutureWarning, warn-and-still-work; the `two_stage_did()` wrapper that used to forward the shared sentinel was removed at 4.0 per M-071 - PR 2 of the cut) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while the RECOMPUTE levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally. (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class; the pre-filter |Omega_1| count, while the ATT's Stage-2 support excludes rows whose `y_tilde` is non-finite - on such degenerate fits `n` exceeds the averaged support) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits; the recomputed joint vcov drives the joint-Wald path) and `heterogeneity` checks run without the deprecated fit-time kwarg; re-emitted recompute warnings are captured and re-published on the consuming report section, and derivation failures surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the POST-FILTER treatment-indicator support the second stage actually regressed on (`omega_1_mask` minus non-finite `_y_tilde` rows, computed inline from the kit's private column-subset frame COPY - snapshot-grade, no fit-time stash needed, and the fit-time masker is never re-called so its warnings are not re-emitted; `score_pad_mask` is irrelevant here - it pads scores/variance only). This is the post-filter count 'simple''s raw pre-filter `n` documents as exceeding on degenerate fits. `D.sum() == 0` -> NaN mass -> all-NaN row (mirrors the fit's `(nan, nan)`); finite `C` relays verbatim. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (post-fit aggregate() - rows M-022/M-119):** `fit(aggregate=, balance_e=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut; `survey_design` is positional slot 7; the `two_stage_did()` wrapper that used to forward the shared sentinel was removed per M-071 - PR 2) in favor of post-fit `TwoStageDiDResults.aggregate(type, balance_e=)` - a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich on a level-specific design, so no compact influence payload exists. (a) RETAINED BUFFERS (memory contract - the FIRST panel retention on TwoStageDiD results, a deliberate break from the CS/EDiD identifier-minimization guarantee, with a `store_kit` opt-out tracked in DEFERRED.md): a COLUMN-SUBSET COPY of the working frame - `unit`/`time`/`outcome`/`first_treat` + covariates + the cluster column (deduplicated: `cluster=` may legally name a core column) + `_never_treated`/`_rel_time`/`_y_tilde` - O(n_obs) on every results object and pickle; the Stage-1 FE model (`unit_fe`/`time_fe`/`grand_mean`/`delta_hat`/`kept_cov_mask`), the Omega masks, the full-domain `keep_mask`, the Wave-E.3-GATED `score_pad_mask`/`cluster_ids_full` values fit actually passed (None unless the always-treated pad was active), `survey_weights`, and the resolved survey design - on replicate designs that adds the O(n_obs x R) replicate matrix; plus value snapshots (`treatment_groups` copy, `ref_period`, `overall_att`, `n_treated_obs`, a `dataclasses.replace` copy of `survey_metadata`) and TWO df scalars (`survey_df_stage2`, the recompute seed; `survey_df_final`, what the stored overall inference received). (b) `balance_e` uses the BALANCED-WINDOW rule (`[-balance_e, max_h]` coverage against the full panel - the ImputationDiD rule, divergent from CS/EfficientDiD's anchor-horizon rule); zero qualifying cohorts warns and yields the reference-row-only dict with `vcov=None`. (c) BOOTSTRAP fits: 'simple' RELAYS the stored overall quintet verbatim (finite safe_inference t included) with a NaN df column, while since 4.0 (PR 3) the ES/group levels RELAY the percentile surface materialized at fit (see the bootstrapped-fit relay Note below; `balance_e` fails closed there - a balanced-window replay needs retained per-level GMM scores, which are function-locals today: the TODO row) - the prior uniform rule superseded 2026-08-05 with the M-027 per-level convergence; a fit whose bootstrap FAILED (`bootstrap_results=None`, analytical inference retained) aggregates normally (analytical recompute). (d) CONTAINER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (both terminal TypeErrors state it): analytical surfaces DO carry the real joint Gardner-GMM covariance (M-092), but the pre-period coefficients are stage-1 residual MEANS - the reference horizon is dropped from the no-intercept Stage-2 design and the zero anchor row is appended mechanically - not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the `delta_0 = 0` normalization into its boundary/bridge constraints; admission awaits a normalization derivation (either re-estimating Stage 2 with the reference horizon in the design or deriving the residual-to-reference mapping) - the DEFERRED.md paper-gated row. (e) RELAY CONVENTIONS: 'simple' relays the stored overall quintet bit-exact with `n = n_treated_obs`, `n_kind="obs"` (overlapping unit sets - the StackedDiD carve-out class; the pre-filter |Omega_1| count, while the ATT's Stage-2 support excludes rows whose `y_tilde` is non-finite - on such degenerate fits `n` exceeds the averaged support) and `df = survey_df_final` (on replicate fits that value came from the `[overall]`-only joint stack - snapshotted, never re-derived); 'group' relays a SCALAR df broadcast (deliberate divergence from ImputationDiD's per-row `df_used`: `_stage2_group` passes one immutable `survey_df` to every row's `safe_inference`, so the scalar is provenance-exact by construction and the moved method stays verbatim); 'event_study' reproduces the M-092 container contract exactly - analytical fits thread the recomputed joint vcov + `vcov_index` + the finite-and->0 df scalar through the carrier, replicate fits thread `vcov=None`/`index=None` with the REPLAYED level-matched df, and the carrier's metadata is a copy-on-use of the KIT's fit-final metadata copy. REPLICATE-WEIGHT fits replay the extracted `_replay_replicate_inference` with a LEVEL-MATCHED stack (the ImputationDiD semantics: `aggregate(L)` reproduces `fit(aggregate=L)`; `fit(aggregate='all')` is not the equivalence target on degenerate designs; the OVERALL-row migration delta on such designs is documented and pinned). Recompute re-emits fit-time warnings with fit-tuned stacklevels - an accepted verbatim-move trade-off. `DiagnosticReport` now derives this container internally on plain fits (raw `event_study_effects` absent), so its `parallel_trends` (`pretrends=True` fits; the recomputed joint vcov drives the joint-Wald path) and `heterogeneity` checks run on analytic plain fits (bootstrapped fits are read from the fit-materialized raw fields - raw-field precedence, no joint vcov there); re-emitted recompute warnings are captured and re-published on the consuming report section, and derivation failures (missing kit, a fail-soft-cleared bootstrapped surface) surface as explicit per-check skip reasons. (f) `aggregate('total')` (3.10): the estimator-owned total incremental outcome - an exact relay `C x overall` CONDITIONAL on the realized aggregation mass, with `C` the POST-FILTER treatment-indicator support the second stage actually regressed on (`omega_1_mask` minus non-finite `_y_tilde` rows, computed inline from the kit's private column-subset frame COPY - snapshot-grade, no fit-time stash needed, and the fit-time masker is never re-called so its warnings are not re-emitted; `score_pad_mask` is irrelevant here - it pads scores/variance only). This is the post-filter count 'simple''s raw pre-filter `n` documents as exceeding on degenerate fits. `D.sum() == 0` -> NaN mass -> all-NaN row (mirrors the fit's `(nan, nan)`); finite `C` relays verbatim. Unweighted fits only: declared `survey_design=` fails closed via the kit's `survey_metadata` snapshot (mutation-proof). Bootstrap fits relay with a NaN df column (M-027); df carrier = `survey_df_final`; container: single `target='total'` row, `n_kind='obs'`. MMM: admitted with NO scale (see the MMM section). +- **Note (bootstrapped-fit relay, 4.0):** (i) RELAY: a fit with `n_bootstrap > 0` ALWAYS computes the event-study (`_stage2_event_study`) AND group (`_stage2_group`) percentile surfaces at fit (`balance_e=None` - exactly what the 3.x `fit(aggregate='all')` computed; percentile se/p/CI; `event_study_vcov`/`event_study_vcov_index`/`event_study_df` None), stores them in `event_study_effects`/`group_effects`, and `aggregate('event_study')`/`aggregate('group')` RELAY them (alpha/anticipation/survey metadata from the kit snapshot; per-row `df` column NaN on both levels - the group relay passes `group_df=None` so no analytical df leaks beside percentile inference; `df_survey` scalar from the carrier's survey metadata; `vcov`/`vcov_index` None; reference rows via the zero-count sentinel). `aggregate('event_study', balance_e=k)` on a bootstrapped fit FAILS CLOSED (`NotImplementedError`: the stored surface is unrestricted and the per-level GMM scores are not retained; re-fit with `n_bootstrap=0`). Analytic fits are unchanged (kit recompute; legacy fields None). The gate is `bootstrap_results is not None`: `None` (n_bootstrap == 0, OR a bootstrapped fit that ended analytical) means the analytical kit RECOMPUTE, today's behaviour. (ii) FAIL-SOFT materialization + storage rule: the surfaces are built inside one guard - any exception leaves them None (the `_es_vcov_ts`/`_es_vcov_index_ts` locals reset too) and the bootstrap runs overall-only (today's plain-fit path); a `_run_bootstrap` attempt that raises (anything but `NotImplementedError`, re-raised) OR RETURNS `None` WITH surfaces present - `_run_bootstrap` returns `None` without an exception when `_compute_cluster_S_scores` hits `_TS_LSMRUnconverged` in the ES or group stage, since the LSMR fallback certifies each RHS column separately and the overall column can succeed while an ES/group column does not - clears them and RETRIES ONCE overall-only (`original_event_study=None, original_group=None, aggregate=None`; `np.random.default_rng(self.seed)` is rebuilt on entry, so a seeded retry reproduces attempt 1's weight draws and the overall quintet equals a plain fit's). A retry failure (a `None` return included) keeps today's catch-all 'Bootstrap failed' warning + analytical overall. STORAGE RULE: the legacy fields hold a surface IFF `bootstrap_results is not None`; when the fit ends analytical they are cleared and `aggregate()` RECOMPUTES analytically. The fail-soft `UserWarning` fires only when the fit DOES carry bootstrap inference and a surface was dropped: 'TwoStageDiD: the event-study/group surfaces could not be materialized on this bootstrapped fit ({e}); the bootstrap covered the overall ATT only, so aggregate('event_study') / aggregate('group') are unavailable here - re-fit with n_bootstrap=0 for the analytical post-fit recompute.'; the relay then raises the shared absent-surface `ValueError` ('This bootstrapped TwoStageDiDResults carries no stored event_study surface: either it was fitted before 4.0 (a bootstrapped plain fit built none) or the surface could not be materialized at fit (see the fit-time warning). Re-fit with diff-diff >= 4.0 for the relayed surface, or with n_bootstrap=0 for the analytical post-fit recompute.'). Net invariant: a bootstrapped plain fit's overall quintet and success/abort outcome are unchanged by the 4.0 removal. (iii) Newly reachable warnings on bootstrapped plain fits: the aggregators' own `UserWarning`s (Stage-1/Stage-2 singular or rank-deficient fallbacks) and the per-target bootstrap-stats `RuntimeWarning`s ('Dropping n/N non-finite bootstrap samples in ... event study (h=...)', 'Too few valid bootstrap samples', 'Bootstrap SE is non-finite or zero'). (iv) Pre-4.0 pickle limitation: a 3.9-3.13 bootstrapped pickle fitted with `fit(aggregate=..., balance_e=k)` stores a balance_e-RESTRICTED surface and records no fit-time `balance_e`, so the relay returns that surface as stored - re-fit under 4.0 for the unrestricted one; one fitted plain stores none and the relay raises the refit `ValueError`. (v) The relay reads the MUTABLE public `event_study_effects`/`group_effects` fields (a view by definition - the StackedDiD precedent); only alpha/anticipation/survey metadata come from kit snapshots, so the recompute route's 'kit snapshots only' invariant does not hold for the relayed dict. (vi) Downstream: `summary()` prints the ES/group tables and `to_dataframe(level='event_study'|'group')` returns frames on bootstrapped plain fits; `DiagnosticReport`/`BusinessReport` run `heterogeneity` (on the relayed GROUP percentile dict - `_collect_effect_scalars` reads `group_effects` first) and `parallel_trends` (`pretrends=True`) on the relayed surface instead of skipping with `NotImplementedError` (no sensitivity check applies); `plot_event_study(results)` plots them natively. Replicate-weight designs never meet the always-on surfaces (replicate + `n_bootstrap > 0` is rejected before them), so the fit-time replicate stack is always `[overall]`-only - the degenerate-design overall-row migration delta in (e) stands. - **Note:** `anticipation` is validated at construction (non-negative integer; `bool` rejected) via the shared `utils.validate_anticipation`, and re-checked on the fit path (direct-mutation defense) — see the family-wide adoption note (ledger row [M-144]) in the TripleDifference staggered-mode section. --- @@ -2467,7 +2470,7 @@ The pooled estimator is `DID^{CBWSDID}_e = Σ_a (N^D_a/N^D_Ω)(Δ̄^D_{a,e} − - **Note:** Provenance exposure (4.0 program row M-092, no numeric change): the event-time sub-block of the pooled-regression coefficient covariance is now persisted as `StackedDiDResults.event_study_vcov` (+ `event_study_vcov_index` = the estimated event times; the synthesized reference period is not a regression column and is absent). Persisted in every inference mode - analytical hc1/hc2_bm sandwich, survey replicate refit, and survey TSL all reassign the coefficient covariance the reported ES SEs are read from, so the marginal SEs are always this matrix's diagonal. `event_study_df` records, per event time, the df actually passed to `safe_inference` (the per-event Bell-McCaffrey Satterthwaite contrast df under `hc2_bm`, the scalar survey df under survey designs, NaN on the normal-theory hc1 path and on hc2_bm fail-closed rows). DiagnosticReport's parallel-trends check consumes the persisted VCV via its joint-Wald path for hc1/survey fits; `hc2_bm` fits are routed to Bonferroni over the BM-adjusted per-row p-values instead (the chi-square Wald reference would discard the CR2/BM small-sample correction; multi-constraint AHT/HTZ CR2 testing is tracked in DEFERRED.md - see REPORTING.md "hc2_bm parallel-trends policy"). - **Note:** Inference is conditional-on-the-estimated-weights cluster-robust (the existing `hc1`/`hc2_bm` path with `W_{sa}` as the WLS weights) — the paper's default. The paper's weight-re-estimating bootstrap is NOT implemented in v1 (deliberate scope; entropy balancing is smooth so the Abadie–Imbens (2008) nonsmooth-matching bootstrap caveat does not apply). `cluster` is orthogonal to `b_{sa}` (weights conditioned-on); default `unit` matches the paper. - **Note:** v1 scope — only `balance="entropy"` with `weighting="aggregate"`. `balance` + `population`/`sample_share` and `balance` + `survey_design=` raise `NotImplementedError`; matching-based balancing and the repeated `0→1/1→0` episode extension are out of scope. -- **Note (post-fit aggregate() - row M-024):** `fit(aggregate=)` is deprecated (3.9; removed 4.0) and the event-study surface is ALWAYS materialized at fit — the pooled regression always included the event-time interactions, so only the extraction and, under `hc2_bm`, the per-event Bell-McCaffrey contrast DOFs were fit-time-gated; both gates are gone and the param is behaviorally inert (its `group`/`all`/unknown ValueErrors persist after the shim warning). `aggregate('event_study')` returns the unified `EventStudyResults` container (a pure view; no recomputation, so every stored inference mode — survey TSL, replicate refit, hc2_bm fail-close — relays faithfully); `aggregate('simple')` relays `overall_att/se/t/p/CI` bit-exactly with `target="att"` (the scalar is the equally-weighted post-period average, NOT the per-event-time trimmed aggregate ATT `θ^e_κ` — `describe_target_parameter` remains the estimand's prose source of truth), `n = n_treated_units` with `n_kind="units"` (TREATED-only scope: under dynamic clean-control definitions a later-treated unit is treated in its own sub-experiment and a clean control in earlier ones, so the treated and control unit sets OVERLAP and no disjoint total exists — cross-container `n` comparisons vs CallawaySantAnna's treated+control convention are out of contract), and `df = inference_df` (the stored overall inference's exact provenance). Consequences of the always-computed surface: plain-fit `summary()` renders the event-study table, `to_dataframe('event_study')` is total on 3.9+ fits, `plot_event_study` renders plain fits, DiagnosticReport's `heterogeneity` (always) and `parallel_trends` (`kappa_pre >= 2`) checks run on plain fits, and the `hc2_bm` overall-ATT contrast now shares the m>1 batch of `_compute_cr2_bm_contrast_dof`, whose degeneracy guard is batch-relative — the noise-floor carve-out on the `Wald_test(test="HTZ")$df_denom` parity claim in the variance-families bullets above widens quantitatively (unchanged behavior when tripped: fail-closed NaN inference plus linalg's loud noise-floor UserWarning; the absolute floor already made this reachable at m=1). +- **Note (post-fit aggregate() - row M-024):** `fit(aggregate=)` was deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut; its `group`/`all`/unknown ValueErrors died with it - `aggregate('group')` already fails closed in the mixin - and `population`/`survey_design`/`covariates` are positional slots 6/7/8) and the event-study surface is ALWAYS materialized at fit — the pooled regression always included the event-time interactions, so only the extraction and, under `hc2_bm`, the per-event Bell-McCaffrey contrast DOFs were fit-time-gated; both gates are gone (the param had been behaviorally inert since 3.9). `aggregate('event_study')` returns the unified `EventStudyResults` container (a pure view; no recomputation, so every stored inference mode — survey TSL, replicate refit, hc2_bm fail-close — relays faithfully); `aggregate('simple')` relays `overall_att/se/t/p/CI` bit-exactly with `target="att"` (the scalar is the equally-weighted post-period average, NOT the per-event-time trimmed aggregate ATT `θ^e_κ` — `describe_target_parameter` remains the estimand's prose source of truth), `n = n_treated_units` with `n_kind="units"` (TREATED-only scope: under dynamic clean-control definitions a later-treated unit is treated in its own sub-experiment and a clean control in earlier ones, so the treated and control unit sets OVERLAP and no disjoint total exists — cross-container `n` comparisons vs CallawaySantAnna's treated+control convention are out of contract), and `df = inference_df` (the stored overall inference's exact provenance). Consequences of the always-computed surface: plain-fit `summary()` renders the event-study table, `to_dataframe('event_study')` is total on 3.9+ fits, `plot_event_study` renders plain fits, DiagnosticReport's `heterogeneity` (always) and `parallel_trends` (`kappa_pre >= 2`) checks run on plain fits, and the `hc2_bm` overall-ATT contrast now shares the m>1 batch of `_compute_cr2_bm_contrast_dof`, whose degeneracy guard is batch-relative — the noise-floor carve-out on the `Wald_test(test="HTZ")$df_denom` parity claim in the variance-families bullets above widens quantitatively (unchanged behavior when tripped: fail-closed NaN inference plus linalg's loud noise-floor UserWarning; the absolute floor already made this reachable at m=1). - **Note (container admission - rows M-024/M-093):** `compute_honest_did` and `compute_pretrends_power` admit Stacked-sourced `aggregate('event_study')` containers. Provenance: `base_period` is the class-level constant `"universal"` (every sub-experiment normalizes against the single omitted reference `e = -1 - anticipation`) and `reference_event_times` is the singleton `(-1 - anticipation,)`. Requirements: `kappa_pre >= 2` (the default `kappa_pre=1` grid minus the reference has ZERO estimated pre-periods — both consumers reject), and for HonestDiD a non-singular FULL retained event-study covariance — the validated sub-block spans the retained pre AND post rows (`allow_singular=False` per Rambachan-Roth's eigenvalues-bounded-away-from-zero assumption on the full Σ — keep `kappa_pre` small relative to the cluster count; PreTrendsPower keeps its documented singular support). The container's scalar `df_survey` is None on analytical fits — the generic resolver's fallback reads `df_inference`, a name StackedDiD does not carry (`inference_df` is the overall POST-AVERAGE contrast df, not a design-level cluster df, and misusing it for FLCI critical values would be worse than the asymptotic default) — so honest FLCI critical values are NORMAL-THEORY on analytical Stacked fits, diverging from bare-`cluster=` CS fits (which thread `G-1`); survey fits thread the survey df and replicate-undefined designs the fail-closed `0.0` sentinel (NaN FLCI CIs; the identified-set `lb`/`ub` bounds are df-independent and stay finite). Withheld-inference rows (finite `se`, non-finite `p_value` — the hc2_bm BM-DOF fail-close and replicate-undefined designs both emit this shape) are ADMITTED with a source-scoped warning in BOTH consumers: bounds/power consume only the point estimates and covariance, which remain valid; the warning is shape-descriptive because containers are publicly constructible. A rank-dropped interior event-time column (NaN-se row) fails closed via honest's consecutive-grid ValueError with a Stacked-specific remedy (no `balance_e` — Stacked's aggregate() has no such level). REFERENCE-SUPPORT GUARD: `fit()` raises when either omitted-reference cell (treated / control) carries no POSITIVE COMPOSED WLS WEIGHT — Equation 3 is Q-weighted, so raw row presence is not support: a gapped panel can empty the cell outright (IC1 checks only min/max periods), aggregate Q-weights zero a sub-experiment's controls at any event time where that sub-experiment has no treated rows, and survey pweights can zero a cell — without the guard the design rank-drops and QR pivoting silently re-normalizes against an arbitrary horizon while the surface certifies `delta_0 = 0` at the reference. Partial support (some cohorts' own ref period absent while the POOLED omitted category is populated) still fits: the coefficient-vector normalization is real; the per-cohort baseline raggedness is estimand composition, the documented ragged-window behavior. *Covariate-balancing edge cases:* @@ -4858,7 +4861,7 @@ Under `survey_design=SurveyDesign(weights, strata, psu, fpc)`, the variance comp **Reduction invariant**: at `H=1`, the sup collapses to the marginal and `q → Φ⁻¹(1 - alpha/2) ≈ 1.96` at `alpha=0.05` up to MC noise. Locked by `TestSupTReducesToNormalAtH1` (G=500, B=5000, seed=42, `atol=0.15` on the quantile), its clustered variant `test_clustered_sup_t_h1_reduces_to_normal` (both the continuous scale-1.0 and mass-point `√(G/(G-1))` scalars), and `TestEventStudySurveyCband::test_trivial_survey_h1_sup_t_matches_analytical` / `test_stratified_h1_sup_t_matches_analytical` for the trivial-survey and stratified cases respectively. -**Scope**: sup-t bootstrap runs in the event-study mode (multi-period panels; the deprecated `aggregate="event_study"` override maps to it, row M-027) when `cband=True` (default) AND either (a) `survey_design=` is supplied (the survey band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. +**Scope**: sup-t bootstrap runs in the event-study mode (multi-period panels; the mode is selected from the panel shape - the fit-time `aggregate=` override was removed at 4.0, PR 3, row M-027) when `cband=True` (default) AND either (a) `survey_design=` is supplied (the survey band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. - **Deviation from shared survey-bootstrap contract:** `_sup_t_multiplier_bootstrap` raises `NotImplementedError` on `SurveyDesign(lonely_psu="adjust")` with singleton strata. The shared `generate_survey_multiplier_weights_batch` helper pools singleton PSUs into a pseudo-stratum with NONZERO multipliers, but `compute_survey_if_variance` centers singleton PSU scores at the GLOBAL mean of PSU scores (rather than the pseudo-stratum mean). Matching the two would require a pooled-singleton pseudo-stratum centering transform in the HAD sup-t path that has not been derived. The HAD-specific limitation is scoped to: weighted event-study + `cband=True` + `lonely_psu="adjust"` + at least one singleton stratum. Practitioners can use `lonely_psu="remove"` or `"certainty"` (matches the analytical target bit-exactly on the HAD sup-t path), or pass `cband=False` to skip the simultaneous band. All other survey-bootstrap consumers (CallawaySantAnna, dCDH, SDID) retain full `lonely_psu="adjust"` support through the shared helper. @@ -4982,15 +4985,15 @@ Shipped in `diff_diff/had_pretests.py` as `yatchew_hr_test()`. Alternative to St - **Note (mean-independence null is the R-parity extension, not paper-derived):** The paper (Appendix E, Theorem 7) defines `yatchew_hr_test` only under the linearity null. The `null="mean_independence"` mode mirrors R `YatchewTest::yatchew_test(order=0)` and is used by R `DIDHAD::did_had(yatchew=TRUE)` on placebo rows ("non-parametric pre-trends test" per the package README). Exposed for parity coverage of the placebo-Yatchew rows in `tests/test_did_had_parity.py::TestYatchewParity` (PR #397). The default `"linearity"` mode is paper-derived; users invoking `"mean_independence"` are running an R-parity extension, not a paper-prescribed test. *Four-step pre-testing workflow (Section 4.2-4.3):* -Shipped as `did_had_pretest_workflow()` in Phase 3 (two-period `aggregate="overall"`) and extended in the Phase 3 follow-up with `aggregate="event_study"` dispatch that closes the step-2 pre-trends gap on multi-period panels. The paper's decision rule for TWFE reliability in HADs: +Shipped as `did_had_pretest_workflow()` in Phase 3 (two-period panels, the overall battery) and extended in the Phase 3 follow-up with the multi-period event-study dispatch that closes the step-2 pre-trends gap on multi-period panels. The battery is selected from the panel shape (`_infer_aggregate_mode`; the `aggregate=` keyword that used to select it was deprecated in 3.9 and removed at 4.0, row M-139) - the mode names below (`"overall"`/`"event_study"`) survive only as the `HADPretestReport.aggregate` output field. The paper's decision rule for TWFE reliability in HADs: 1. Test the null of a QUG (`H_0: d̲ = 0`) using `qug_test()`. 2. Run a pre-trends test of Assumption 7 (requires at least one earlier pre-period). 3. Test that `E(ΔY | D_2)` is linear (`stute_test` or `yatchew_hr_test`; or the joint Stute variants below in event-study dispatch). 4. If NONE of the three is rejected, `β̂_{fe}` from TWFE may be used to estimate the treatment effect. -**Phase 3 delivery (`aggregate="overall"`, two-period):** `did_had_pretest_workflow()` runs steps 1 + 3 (QUG + Stute + Yatchew). Step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon to test against; the verdict explicitly flags the Assumption 7 gap via the "paper step 2 deferred" caveat. +**Phase 3 delivery (two-period panel, overall battery):** `did_had_pretest_workflow()` runs steps 1 + 3 (QUG + Stute + Yatchew). Step 2 is NOT run on this path because a two-period panel has no pre-period placebo horizon to test against; the verdict explicitly flags the Assumption 7 gap via the "paper step 2 deferred" caveat. -**Phase 3 follow-up delivery (`aggregate="event_study"`, multi-period):** `did_had_pretest_workflow(..., aggregate="event_study")` dispatches on a balanced ≥3-period panel. Runs QUG at `F` + joint pre-trends Stute across earlier pre-periods (step 2, mean-independence null) + joint homogeneity-linearity Stute across post-periods (step 3 joint extension, linearity null). Step 2 closure requires at least TWO pre-periods (the base pre-period plus one earlier placebo); on panels with only a single pre-period (the base `F-1`) the workflow emits `pretrends_joint=None` and the verdict flags the skip ("joint pre-trends skipped (no earlier pre-period)"). `all_pass` is False in this degenerate case. The verdict on the event-study path does NOT emit the "paper step 2 deferred" caveat when step 2 runs. +**Phase 3 follow-up delivery (multi-period panel, event-study battery):** `did_had_pretest_workflow()` dispatches the event-study battery on a balanced ≥3-period panel. Runs QUG at `F` + joint pre-trends Stute across earlier pre-periods (step 2, mean-independence null) + joint homogeneity-linearity Stute across post-periods (step 3 joint extension, linearity null). Step 2 closure requires at least TWO pre-periods (the base pre-period plus one earlier placebo); on panels with only a single pre-period (the base `F-1`) the workflow emits `pretrends_joint=None` and the verdict flags the skip ("joint pre-trends skipped (no earlier pre-period)"). `all_pass` is False in this degenerate case. The verdict on the event-study path does NOT emit the "paper step 2 deferred" caveat when step 2 runs. *Algorithm variant - Joint Stute tests (Section 4.2-4.3 joint; Phase 3 follow-up 2026-04):* Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in core) plus two thin data-in wrappers `joint_pretrends_test()` (mean-independence null) and `joint_homogeneity_test()` (linearity null). Generalizes the single-horizon Stute CvM (above) to K horizons with joint inference. @@ -5003,12 +5006,12 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - `joint_homogeneity_test(post_periods, base_period)`: `null_form="linearity"`, design matrix `[1, D]`; residuals from `OLS(Y_t - Y_base ~ 1 + D)` per post-period (paper Section 4.3 page 32 joint across post-periods, Pierce-Schott reports p=0.40). - **Note:** Sum-of-CvMs aggregation is a standard joint specification-test construction (Delgado 1993; Escanciano 2006); the paper does not prescribe an aggregation rule. Sum-of-CvMs balances power across diffuse vs concentrated alternatives and bootstraps cleanly with shared-η. - **Note:** Event-study dispatch adjudicates step 3 via joint Stute only; there is no joint Yatchew variant because the paper does not derive one. The overall two-period path still uses the Phase 3 "Stute OR Yatchew" adjudication. Users who need Yatchew-style adjacent-difference variance-ratio robustness under multi-period data can run `yatchew_hr_test` on each (base, post) pair manually. -- **Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped):** `trends_lin: bool = False` (keyword-only) on `HeterogeneousAdoptionDiD.fit(aggregate="event_study")`, `joint_pretrends_test`, and `joint_homogeneity_test` (PR #389, 2026-04). Mirrors R `DIDHAD::did_had(..., trends_lin=TRUE)` (Credible-Answers/did_had v2.0.0, SHA `edc09197`). Per-group linear-trend slope estimated as `Y[g, F-1] - Y[g, F-2]` and applied as `(t - base) × slope` adjustment to per-event-time outcome evolutions (HAD.fit) or to `Y[g, t] - Y[g, base]` directly (joint pretests). The "consumed" placebo at our event-time `e=-2` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Requires F ≥ 3 / `base_period - 1` in panel — front-door `ValueError` if not. Mutually exclusive with survey weighting (raises `NotImplementedError` per `feedback_per_method_survey_element_contract`; weighted slope estimator not derived from paper). Pierce-Schott published-number replication (paper p=0.51 / p=0.40 anchors) deferred indefinitely — primary analysis panel is LBD-restricted (Census FSRDC); the public-deposit proxy panel has filtering ambiguity that prevents exact published-number parity. Replaced by end-to-end R-package parity below, which is a strictly stronger correctness signal. +- **Note (Phase 4 — Eq 17 / Eq 18 linear-trend detrending shipped):** `trends_lin: bool = False` (keyword-only) on `HeterogeneousAdoptionDiD.fit()` (multi-period panels), `joint_pretrends_test`, and `joint_homogeneity_test` (PR #389, 2026-04). Mirrors R `DIDHAD::did_had(..., trends_lin=TRUE)` (Credible-Answers/did_had v2.0.0, SHA `edc09197`). Per-group linear-trend slope estimated as `Y[g, F-1] - Y[g, F-2]` and applied as `(t - base) × slope` adjustment to per-event-time outcome evolutions (HAD.fit) or to `Y[g, t] - Y[g, base]` directly (joint pretests). The "consumed" placebo at our event-time `e=-2` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Requires F ≥ 3 / `base_period - 1` in panel — front-door `ValueError` if not. Mutually exclusive with survey weighting (raises `NotImplementedError` per `feedback_per_method_survey_element_contract`; weighted slope estimator not derived from paper). Pierce-Schott published-number replication (paper p=0.51 / p=0.40 anchors) deferred indefinitely — primary analysis panel is LBD-restricted (Census FSRDC); the public-deposit proxy panel has filtering ambiguity that prevents exact published-number parity. Replaced by end-to-end R-package parity below, which is a strictly stronger correctness signal. - **Note (R-package end-to-end parity, PR #389):** Validated against `DIDHAD` v2.0.0 (Credible-Answers/did_had, SHA `edc09197`) on the **`design="continuous_at_zero"` (Design 1') surface**, on 3 paper-derived synthetic DGPs (Uniform, Beta(2,2), Beta(0.5,1)) × 5 method combinations (overall, event-study, placebo, yatchew, trends_lin). Generator: `benchmarks/R/generate_did_had_golden.R`; fixture: `benchmarks/data/did_had_golden.json`; test: `tests/test_did_had_parity.py`. **Scope qualifier (PR #392 R8 P3):** the harness explicitly forces `HeterogeneousAdoptionDiD(design="continuous_at_zero")` because R `did_had` always evaluates the local-linear at `d=0` regardless of dose distribution. Our default `design="auto"` may legitimately resolve to `continuous_near_d_lower` (`d_lower=d.min()`, Design 1) or `mass_point` (Design 2) on dose distributions with boundary density bounded away from zero (e.g., Beta(2,2) at G=200), in which case the WAS estimand evaluates at a different point and diverges from R's `did_had` numerically. That divergence is methodologically defensible — our auto-detect uses more information when boundary mass is sparse — but is out of scope for this parity contract. Tolerances: point estimate / SE / CI bounds at `atol=1e-8`; closed-form Yatchew T-stat at `atol=1e-10` after a documented `× G/(G-1)` finite-sample convention shift. Two intentional convention deviations from R: **(a)** we report the **bias-corrected** point estimate `att = (mean(ΔY) - tau.bc) / mean(D)` (modern CCF 2018 convention); R's `Estimate` column reports the **conventional** estimate `(mean(ΔY) - tau.us) / mean(D)` with the bias-corrected CI separately — our `att` matches R's CI midpoint, our `se` / `conf_int_low` / `conf_int_high` match R's `se` / `ci_lo` / `ci_hi` directly. **(b)** Our `yatchew_hr_test` follows paper Appendix E's literal `(1/G)` and `(1/(2G))` variance-denominator convention; R's `YatchewTest::yatchew_test` uses base-R `var()`'s `(1/(N-1))` sample-variance convention. Ratio is exactly `N/(N-1)`; both converge to the same asymptotic null distribution. Yatchew on placebos with R's mean-independence null (`order=0`, fits `Y ~ 1`) is exposed via `yatchew_hr_test(null="mean_independence")` (added post-PR #392). The parity test routes effect rows through `null="linearity"` (R `order=1`) and placebo rows through `null="mean_independence"` (R `order=0`); both modes share the same `(1/G)` vs `(1/(N-1))` finite-sample convention shift and parity holds at `atol=1e-10` after the documented `× G/(G-1)` transform. - **Note:** Horizon labels in `StuteJointResult.horizon_labels` are `str(t)` verbatim and carry STRING IDENTITY ONLY — NOT a chronological ordering key. Callers who need chronological order must preserve the original period values alongside (e.g. from the `pre_periods` / `post_periods` argument). - **Note:** NaN propagation is explicit: when any horizon has NaN in residuals, `cvm_stat_joint=NaN`, `p_value=NaN`, `reject=False`, AND `per_horizon_stats={label: np.nan for every horizon}` (full dict preserved with NaN values — not empty, not partial). -**Phase 3 follow-up delivery:** `stute_joint_pretest()`, `joint_pretrends_test()`, `joint_homogeneity_test()`, `StuteJointResult`, and `did_had_pretest_workflow(aggregate="event_study")` shipped together in PR #353 (2026-04). The `practitioner_next_steps()` HAD handlers landed in Phase 5 wave 1 (PR #402); the T21 HAD pretest workflow tutorial landed in PR #409 (Phase 5 wave 2 first slice). The T22 survey-weighted HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) shipped as the follow-up to PR #432 (2026-05). +**Phase 3 follow-up delivery:** `stute_joint_pretest()`, `joint_pretrends_test()`, `joint_homogeneity_test()`, `StuteJointResult`, and the multi-period `did_had_pretest_workflow()` dispatch shipped together in PR #353 (2026-04). The `practitioner_next_steps()` HAD handlers landed in Phase 5 wave 1 (PR #402); the T21 HAD pretest workflow tutorial landed in PR #409 (Phase 5 wave 2 first slice). The T22 survey-weighted HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) shipped as the follow-up to PR #432 (2026-05). **Reference implementation(s):** - R: `did_had` (de Chaisemartin, Ciccia, D'Haultfœuille, Knau 2024a); `stute_test` (2024c); `yatchew_test` (Online Appendix, Table 3). @@ -5021,11 +5024,11 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - **Note:** Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (`w_g = 1` default, matching `diff_diff/_nprobust_port.lprobust`'s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 — `[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)` (computed as `att = (mean(ΔY) − τ_bc) / mean(D)`), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied weights (via `survey_design=SurveyDesign(weights=...)`, pweight) override the equal-weight default and thread through as `W_combined = k((D − d̲)/h) · w_g`. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell`. - **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs in the event-study mode when `cband=True` (default) AND either `survey_design=` is supplied (survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. -- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` are deprecated in 3.9 (removed in 4.0). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the deprecated column-alias reconciliation (the M-035..M-039 renamed-kwarg shims). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call, and a supplied legacy value warns then runs the legacy routing unchanged (invalid values still raise; the one sentinel-reachable shape error is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message reworded to panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units; the adapter now also threads the scalar `event_study_df` - the unit-level design's survey df governing every horizon's t-inference - into the container's per-row df column, M-092 completion; None on non-survey fits, and no bootstrap gate applies since pointwise inference is always analytical). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). +- **Note (post-fit aggregate() + mode inference - rows M-027/M-139):** `fit(aggregate=)` and `did_had_pretest_workflow(aggregate=)` were deprecated in 3.9 and REMOVED at 4.0 (PR 3 of the cut: `_VALID_AGGREGATES` deleted from `had.py` and `had_pretests.py`, `_infer_aggregate_mode` is the sole selector shared by both surfaces, and no positional slot shifts - everything after the removed `fit()` slot is keyword-only, so a seventh positional now raises natively). UNLIKE every prior aggregate-postfit adopter, the param was a MODE SELECTOR over mutually exclusive panel shapes ('overall' = exactly two periods -> `HeterogeneousAdoptionDiDResults`; 'event_study' = more than two -> `HeterogeneousAdoptionDiDEventStudyResults`), so the successor is panel-shape INFERENCE: both surfaces resolve the mode from the shared `_infer_aggregate_mode` helper (distinct time values <= 2 -> overall, else event_study), running after the column-name reconciliation (the M-035..M-039 renames, whose shims were removed at PR 2). BEHAVIOR DELTA: a plain multi-period `fit()` (or workflow call) previously raised the two-period shape error and now runs the event-study mode - error -> works; no numbers change on any previously-working call; since 4.0 a supplied value is a `TypeError` (the one shape error still reachable is the staggered last-cohort auto-filter dropping a T>2 panel below three periods, its message worded in panel-shape vocabulary). POST-FIT `aggregate()` is a PURE VIEW with per-class supported subsets - the overall class supports only `'simple'` (one-row bit-exact relay: `target = target_parameter`, the estimand label "WAS"/"WAS_d_lower"; `n = n_obs` contributing units with `n_kind='units'` - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band at the `d_lower` boundary, so `n_obs` is the single source; `df = resolve_inference_df`, provenance-exact: the finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable) and the event-study class supports only `'event_study'` (a `build_event_study_surface` passthrough via the `_from_had` adapter, cband fields included; the adapter's `n_kind` was corrected "obs" -> "units" with this row - `n_obs_per_horizon` counts units; the adapter now also threads the scalar `event_study_df` - the unit-level design's survey df governing every horizon's t-inference - into the container's per-row df column, M-092 completion; None on non-survey fits, and no bootstrap gate applies since pointwise inference is always analytical). Cross-mode levels fail closed (the two modes share no computable estimand; joint cross-horizon covariance is the DEFERRED.md row). NO kit is retained (views work on pickles from any release) and NO bootstrap gate exists (`n_bootstrap` feeds only the sup-t cband critical value; pointwise inference is always analytical). `AggregationResult.summary()` gained the single-non-'att'-target rendering with this row (target column + neutral 'estimate' heading, column sized to the longest label - previously a WAS or dCDH estimand row rendered under a hard-coded 'ATT' heading; uniform-'att' and ContinuousDiD att/acrt output byte-stable). CONSUMER ADMISSION into `compute_honest_did`/`compute_pretrends_power` is DEFERRED, not by-design (see M-093): HAD's event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (its coefficient is identically zero AND the WAS is not identified there) and no joint covariance exists; both terminal TypeErrors state the deferral in exactly that wording. The `HADPretestReport.aggregate` FIELD survives as output metadata recording which battery ran (only the params die). - **Note:** Pierce-Schott (2016) Figure 2 replication harness deferred. The paper's empirical application self-acknowledges (Section 5.2; mirrored in `dechaisemartin-2026-review.md:321`) that "NP estimators are too noisy to be informative" on the LBD-restricted PNTR panel. R parity at `atol=1e-8` on 3 DGPs × 5 method combos via `tests/test_did_had_parity.py` (bit-exact, `rtol=0`) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. **Scope caveat:** R parity locks point estimate, SE, and CI bounds bit-exactly to R's bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery. - **Note:** Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at `G ∈ {100, 500, 2500}` on DGPs 1/2/3. The existing `tests/test_did_had_parity.py` R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. **Scope caveat (mirrors above):** R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1's 89% / 93% / 95% under-coverage band is valid for both R and Python. - **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1624` when multiple first-treat cohorts are detected without `first_treat`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. -- **Note:** Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only "warn" with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at `f_{D_2}(0) = 0`). The library therefore emits a `UserWarning` at `HeterogeneousAdoptionDiD.fit()` time only when the fraction of units with EXACTLY-zero post-period dose is `>= 0.10` (`_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRAC` in `diff_diff/had.py`) — a 10% library-convention cutoff chosen to sit ~25× above the paper's kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. **Overall path only:** the warning is emitted after the `aggregate="event_study"` dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only via `qug_test()`'s zero-dose `UserWarning` (which fires only when the user runs the pretests). Lock in `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. +- **Note:** Extensive-margin / positive-untreated-mass fit-time warning (library convention). The paper (de Chaisemartin et al. 2026, Section 2 / Assumption 3) defines HAD for the case where no genuine untreated group exists and recommends (Section 4 practitioner checklist) that a user with a positive mass of untreated units consider a standard DiD instead — but it prescribes only "warn" with NO numeric cutoff, and explicitly RETAINS small untreated shares (the Garrett et al. bonus-depreciation application keeps 12 untreated counties out of 2,954 ≈ 0.4%, with simulations showing close-to-nominal coverage even at `f_{D_2}(0) = 0`). The library therefore emits a `UserWarning` at `HeterogeneousAdoptionDiD.fit()` time only when the fraction of units with EXACTLY-zero post-period dose is `>= 0.10` (`_HAD_EXTENSIVE_MARGIN_ZERO_DOSE_FRAC` in `diff_diff/had.py`) — a 10% library-convention cutoff chosen to sit ~25× above the paper's kept 0.4% example, so valid small-share fits are not nagged while a substantial untreated mass is flagged. **Overall path only:** the warning is emitted after the multi-period event-study dispatch returns, because the event-study path REQUIRES never-treated (zero-dose) units per Appendix B.2 (the last-cohort filter retains them), so an untreated mass is expected there, not a misuse signal. Surfaces the recommendation at fit time rather than only via `qug_test()`'s zero-dose `UserWarning` (which fires only when the user runs the pretests). Lock in `tests/test_methodology_had.py::TestHADDeviations::test_extensive_margin_warning_is_10pct_library_convention`. - **Note:** `covariates=` is reserved but NOT implemented. `HeterogeneousAdoptionDiD.fit(covariates=...)` raises `NotImplementedError` — an explicit keyword-only param, so the message points to the deferred extension instead of letting an unknown kwarg surface as a bare `TypeError`. Covariate-adjusted HAD identification is the paper's Appendix B.1 / Theorem 6 multivariate-covariate extension (a multivariate nonparametric regression of ΔY on (D, X) at the dose boundary), which is not derived in the library. Workaround: pre-residualize the outcome on the covariates before calling `fit()`, or omit `covariates=` for the unconditional WAS estimand. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented`. **Requirements checklist (tracks implementation phase completion):** @@ -5045,22 +5048,22 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 1c: Bias-corrected CI (Equation 8) with `nprobust` parity. Public wrapper `diff_diff.bias_corrected_local_linear` returns `BiasCorrectedFit` with μ̂-scale point estimate, robust SE, and bias-corrected 95% CI `[tau.bc ± z_{1-α/2} * se.rb]`. The β-scale rescaling from Equation 8, `(1/G) Σ D_{g,2}`, is applied by Phase 2's `HeterogeneousAdoptionDiD.fit()`. Parity against `nprobust::lprobust(..., bwselect="mse-dpi")` is asserted at `atol=1e-12` on `tau_cl`/`tau_bc`/`se_cl`/`se_rb`/`ci_low`/`ci_high` across the three unclustered golden DGPs (DGP 1 and DGP 3 typically land closer to `1e-13`). The Python wrapper computes its own `z_{1-α/2}` via `scipy.stats.norm.ppf` inside `safe_inference()`; R's `qnorm` value is stored in the golden JSON for audit, and the parity harness compares Python's CI bounds to R's pre-computed CI bounds so any residual drift is purely the floating-point arithmetic in `tau.bc ± z * se.rb`, not a critical-value disagreement. The clustered DGP achieves bit-parity (`atol=1e-14`) when cluster IDs are in first-appearance order; otherwise BLAS reduction ordering can drift to `atol=1e-10`. Generator: `benchmarks/R/generate_nprobust_lprobust_golden.R`. **Note:** The wrapper matches nprobust's `rho=1` default (`b = h` in auto mode), so Phase 1b's separately-computed `b_mse` is surfaced via `bandwidth_diagnostics.b_mse` but not applied. **Note (public-API surface restriction):** Phase 1c restricts the public wrapper's `vce` parameter to `"nn"`; hc0/hc1/hc2/hc3 raise `NotImplementedError` and are queued for Phase 2+ pending dedicated R parity goldens. The port-level `diff_diff._nprobust_port.lprobust` still accepts all five vce modes (matching R's `nprobust::lprobust` signature) for callers who need the broader surface and accept that the hc-mode variance path — which reuses p-fit hat-matrix leverage for the q-fit residual in R (lprobust.R:229-241) — has not been separately parity-tested. **Note (Phase 1c internal bug workaround):** The clustered golden DGP 4 uses manual `h=b=0.3` to sidestep an nprobust-internal singleton-cluster shape bug in `lprobust.vce` fired by the mse-dpi pilot fits; the Python port has no equivalent bug. - [x] Phase 2a: `HeterogeneousAdoptionDiD` class with separate code paths for Design 1' (`continuous_at_zero`), Design 1 continuous-near-`d̲` (`continuous_near_d_lower`), and Design 1 mass-point. Continuous paths compose Phase 1c's `bias_corrected_local_linear` and form the beta-scale WAS estimate `β̂ = (mean(ΔY) - τ̂_bc) / den` where `τ̂_bc` is the bias-corrected local-linear estimate of the boundary limit `lim_{d↓d̲} E[ΔY | D_2 ≤ d]` and `den = E[D_2]` for Design 1' (paper Theorem 1 / Equation 3 identification; Equation 7 sample estimator) or `den = E[D_2 - d̲]` for Design 1 (paper Theorem 3 / Equation 11, `WAS_{d̲}` under Assumption 6). Mass-point path uses a sample-average 2SLS estimator with instrument `1{D_{g,2} > d̲}` (paper Section 3.2.4). - [x] Phase 2a: `design="auto"` detection rule (`min_g D_{g,2} < 0.01 · median_g D_{g,2}` → continuous_at_zero; modal-min fraction > 2% → mass_point; else continuous_near_lower). Implemented as strict first-match in `diff_diff.had._detect_design`; when `d.min() == 0` exactly, resolves `continuous_at_zero` unconditionally (modal-min check runs only when `d.min() > 0`). Edge case covered: 3% at `D=0` + 97% `Uniform(0.5, 1)` resolves to `continuous_at_zero`, matching the paper-endorsed Design 1' handling of small-share-of-treated samples. -- [x] Phase 2a: Panel validator (`diff_diff.had._validate_had_panel`) verifies `D_{g,1} = 0` for all units, rejects negative post-period doses (`D_{g,2} < 0`) front-door on the original (unshifted) scale, rejects `>2` time periods on the `aggregate="overall"` path (multi-period panels must use `aggregate="event_study"`, Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (`continuous_near_d_lower` and `mass_point`) additionally require `d_lower == float(d.min())` within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand. +- [x] Phase 2a: Panel validator (`diff_diff.had._validate_had_panel`) verifies `D_{g,1} = 0` for all units, rejects negative post-period doses (`D_{g,2} < 0`) front-door on the original (unshifted) scale, rejects `>2` time periods on the two-period (overall) path (multi-period panels run the event-study mode, Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (`continuous_near_d_lower` and `mass_point`) additionally require `d_lower == float(d.min())` within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand. - [x] Phase 2a: NaN-propagation tests covering constant-y, degenerate-mass-point, and single-cluster-CR1 inputs. The guaranteed NaN coupling is on the DOWNSTREAM triple (`t_stat`, `p_value`, `conf_int`) via the `safe_inference()` gate, which returns NaN on all three whenever `se` is non-finite, zero, or negative. `att` and `se` themselves are raw estimator outputs: on constant-y / no-dose-variation / divide-by-zero the fit paths return `(att=nan, se=nan)` so all five fields move to NaN together; on the degenerate single-cluster CR1 configuration on the mass-point path, `_fit_mass_point_2sls` returns `(att=beta_hat, se=nan)` - `att` is finite (Wald-IV is well defined) while `se` is NaN, so the downstream triple is NaN while `att` remains the raw 2SLS coefficient. The `assert_nan_inference` fixture in `tests/conftest.py` checks the downstream triple against this contract without requiring `att` to be NaN. - **Note (mass-point SE):** Standard errors on the mass-point path use the structural-residual 2SLS sandwich `[Z'X]^{-1} · Ω · [Z'X]^{-T}` with `Ω` built from the structural residuals `u = ΔY - α̂ - β̂·D` (not the reduced-form residuals from an OLS-on-indicator shortcut). Supported: `classical`, `hc1`, and CR1 (cluster-robust) when `cluster=` is supplied. `hc2` and `hc2_bm` raise `NotImplementedError` pending a 2SLS-specific leverage derivation (the OLS leverage `x_i' (X'X)^{-1} x_i` is wrong for 2SLS; the correct finite-sample correction depends on `(Z'X)^{-1}` rather than `(X'X)^{-1}`) plus a dedicated R parity anchor. Queued for the follow-up PR. - **Note (continuous cluster-robust SE, Phase 2a):** On the continuous designs (`continuous_at_zero` / `continuous_near_d_lower`), `cluster=` threads the per-unit cluster IDs into `bias_corrected_local_linear`, whose CCT-2014 robust variance then becomes the cluster-robust nonparametric SE; the β̂-scale SE is `se_robust / |den|`. Because the β-scale rescale is a deterministic linear transform, the estimator-level clustered SE equals the direct `bias_corrected_local_linear(cluster=...).se_robust / |den|` to machine precision — this is the in-library validation anchor, and the clustered CCT SE is itself golden-tested against `nprobust` on DGP 4 (see the Phase 1c note above: `atol=1e-14` in first-appearance cluster order). Cluster IDs must be unit-constant (validated up front; a nonexistent column, NaN, or within-unit-varying cluster now raises rather than being silently ignored, mirroring the mass-point path). Cluster-robust inference is **unidentified with fewer than two clusters in the active kernel window** (`eC = cluster[ind]`, the in-bandwidth subset the CCT variance is actually computed on — a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth): the guard lives in `_nprobust_port.lprobust` and NaNs `se_rb`/`se_cl`, so `bias_corrected_local_linear.se_robust` is NaN and HAD's `safe_inference` NaNs the t-stat / p-value / CI while the point estimate stays finite — the same single-cluster NaN contract as the mass-point CR1 path (`_fit_mass_point_2sls`), applied at the variance-computation site so it also covers the direct `bias_corrected_local_linear` API. A window with ≥2 distinct clusters is bit-identical (DGP-4 golden parity preserved). A bare `cluster=` gives unweighted cluster-robust inference; the `cluster=` + `survey_design=` composition raises `NotImplementedError`: the Binder (1983) TSL survey variance is composed from the per-unit influence function via `compute_survey_if_variance` and would silently override the cluster-robust SE — for weighted clustering route through `survey_design=SurveyDesign(weights='', psu='')` instead. Result metadata reports `vcov_type="cr1"` + `cluster_name=` with `inference_method="analytical_nonparametric"` (distinguishing the clustered CCT variance from the mass-point 2SLS CR1 sandwich). Estimator-level cluster threading also extends to the **Phase 2b event-study** path (cluster-robust per-horizon pointwise CIs on both designs plus a cluster-robust simultaneous sup-t band; the per-horizon variance-family reconciliation is documented in "Note (HAD clustered event-study sup-t band)" above) — the former "cluster ignored" `UserWarning` is removed. - **Note (Design 1 identification):** `continuous_near_d_lower` and `mass_point` fits emit a `UserWarning` surfacing that `WAS_{d̲}` identification requires Assumption 6 (or Assumption 5 for sign identification only) beyond parallel trends, and that neither is testable via pre-trends. `continuous_at_zero` (Design 1', Assumption 3 only) does not emit this warning. - **Note (CI endpoints):** Because the continuous-path `att` is `(mean(ΔY) - τ̂_bc) / den`, the beta-scale CI endpoints reverse relative to the Phase 1c boundary-limit CI: `CI_lower(β̂) = (mean(ΔY) - CI_upper(τ̂_bc)) / den` and `CI_upper(β̂) = (mean(ΔY) - CI_lower(τ̂_bc)) / den`. The `HeterogeneousAdoptionDiD.fit()` implementation computes `att ± z · se` directly via `safe_inference`, which handles the reversal naturally from the transformed point estimate. - - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period `aggregate="overall"` path; Phase 2b lifts `aggregate="event_study"` (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey_design=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. + - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period (two-period panel) overall path; Phase 2b lifts the multi-period event-study mode (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey_design=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. - **Note (panel-only):** The paper (Section 2) defines HAD on *panel or repeated cross-section* data, but both the overall and event-study paths ship a panel-only implementation: `HeterogeneousAdoptionDiD.fit()` requires a balanced panel with a unit identifier so that unit-level first differences `ΔY_{g,t} = Y_{g,t} - Y_{g,t_anchor}` can be formed. Repeated-cross-section inputs (disjoint unit IDs between periods) are rejected by the balanced-panel validator. RCS support is queued for a follow-up PR (tracked in `DEFERRED.md`); it will need a separate identification path based on pre/post cell means rather than unit-level differences. -- [x] Phase 2b: Multi-period event-study extension (Appendix B.2). `aggregate="event_study"` produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (initially deferred; shipped in Phase 3 via `stute_joint_pretest()` — see the Phase 3 entries below). +- [x] Phase 2b: Multi-period event-study extension (Appendix B.2). A multi-period panel produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (initially deferred; shipped in Phase 3 via `stute_joint_pretest()` — see the Phase 3 entries below). - [x] Phase 2b: event-study `cluster=` threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of `_sup_t_multiplier_bootstrap` (continuous scale 1.0; mass-point `√(G/(G-1))`). Closes the former "cluster ignored on the nonparametric path" deferral (`TODO.md`). See "Note (HAD clustered event-study sup-t band)". `cluster=` + `survey_design=` remains rejected (for weighted clustering route through `survey_design=SurveyDesign(weights=..., psu=...)`). - **Note (Phase 2b last-cohort filter):** When `first_treat` indicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (`F_last = max(cohorts)`) **plus never-treated units** (`first_treat = 0`), with a `UserWarning` naming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD "may be used only for the LAST treatment cohort in a staggered design"; the auto-filter implements this prescription, retaining never-treated units per the paper's "there must be an untreated group, at least till the period where the last cohort gets treated" requirement. Only earlier-cohort units (with `first_treat > 0` and `< F_last`) are dropped — never-treated units satisfy the dose invariant at every period (`D = 0` throughout) and preserve Design 1' identifiability (boundary at `0`) when last-cohort doses are uniformly positive. When `first_treat` is omitted on a >2-period panel, the validator infers each unit's first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-door `ValueError` directing users to pass `first_treat` (which activates the auto-filter) or use `ChaisemartinDHaultfoeuille` for full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged with `F` inferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer to `ChaisemartinDHaultfoeuille`. - **Note (Phase 2b constant-dose requirement):** The event-study aggregation uses `D_{g, F}` (first-treatment-period dose) as the single regressor for every event-time horizon, per paper Appendix B.2's "once treated, stay treated with the same dose" convention. The validator REJECTS panels where a unit has time-varying dose across post-treatment periods (`D_{g, t} != D_{g, F}` for any `t >= F` within-unit, beyond float tolerance) with a front-door `ValueError`, directing users with genuinely time-varying post-treatment doses to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Silent acceptance would misattribute later-horizon treatment-effect heterogeneity to the period-F dose. A follow-up PR could implement a time-varying-dose estimator; tracked in `DEFERRED.md`. - **Note (Phase 2b per-horizon SE):** Each event-time horizon uses an INDEPENDENT sandwich computed on that horizon's first differences: continuous paths use the CCT-2014 robust SE from Phase 1c divided by `|den|`; mass-point path uses the structural-residual 2SLS sandwich from Phase 2a. This produces pointwise CIs per horizon, matching the paper's Pierce-Schott application (Section 5.2, Figure 2: "nonparametric pointwise CIs"). Joint cross-horizon covariance (IF-based stacking or block bootstrap) is NOT computed — the paper does not derive it and all reported CIs are pointwise. Follow-up PRs may add joint covariance for cross-horizon hypothesis tests; current tracking in `DEFERRED.md`. - **Note (Phase 2b baseline convention):** All event-time horizons use a uniform `F-1` anchor: `ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every `t`. This is consistent with the paper's Garrett-et-al. application (Section 5.1: "outcome `Y_{g,t} - Y_{g,2001}`" where `F = 2002`), simplifies event-time indexing (`e = t - F` so `e = -1` is the anchor, skipped), and keeps the implementation symmetric for pre- and post-period horizons. The paper review text's asymmetric "`Y_{g,t} - Y_{g,1}` for pre" / "`Y_{g,t} - Y_{g,F-1}` for post" phrasing is covered by the uniform convention since both give the same placebo interpretation under parallel trends (the paper's own applications use the uniform anchor). - - **Note (Phase 2b result class):** `aggregate="event_study"` returns a new `HeterogeneousAdoptionDiDEventStudyResults` dataclass (distinct from the single-period `HeterogeneousAdoptionDiDResults`) with per-horizon arrays (`event_times`, `att`, `se`, `t_stat`, `p_value`, `conf_int_low`, `conf_int_high`, `n_obs_per_horizon`) and shared metadata. `to_dataframe()` returns a tidy per-horizon DataFrame; `to_dict()` returns a dict with list-of-per-horizon fields. The static return-type annotation on `fit()` is `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]`, matching the runtime polymorphism on `aggregate`; callers should narrow via `isinstance` (or via the aggregate they passed) when reading aggregate-specific fields. - - **Note (Phase 3 pretest workflow):** Phase 3 ships the Section 4 pre-test diagnostics in a new module `diff_diff/had_pretests.py` (separate from the 2,800-line `had.py` estimator module). The three tests — `qug_test`, `stute_test`, `yatchew_hr_test` — accept raw `(d, dy)` arrays and return their own result dataclasses (`QUGTestResults`, `StuteTestResults`, `YatchewTestResults`); the composite `did_had_pretest_workflow` dispatches on `aggregate` (`"overall"` requires a balanced two-period panel; `"event_study"` accepts a multi-period panel with >= 3 periods) and returns `HADPretestReport` with a priority-ordered verdict string. The `data`-only workflow signature (no `result=`-consuming variant) avoids coupling pretests to the `BiasCorrectedFit` internal state, which does not expose residuals. Below-minimum sample sizes emit `UserWarning` and return all-NaN inference (no `ValueError` raise) for library consistency with the `safe_inference` convention; input-shape violations (non-1D, NaN-containing, mismatched length) still raise. No multiple-testing adjustment (Bonferroni/Holm) is applied — the paper does not prescribe one. **Partial-workflow semantic (path-dependent):** `aggregate="overall"` (default; two-period panel) runs paper steps 1 (QUG) + 3 (linearity via Stute + Yatchew-HR) ONLY; step 2 (Assumption 7 pre-trends test via Equation 18) is NOT covered on this path and a fail-to-reject verdict appends an explicit Assumption 7 gap flag (`"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`). `aggregate="event_study"` (multi-period panel, requires >=3 periods + earlier pre-period for joint pretrends) closes step 2 via joint Stute pre-trends (Equation 18) over pre-period horizons AND joint Stute homogeneity over post-period horizons; the verdict on a fail-to-reject across QUG + joint pretrends + joint homogeneity reads "TWFE admissible under Section 4 assumptions" without the Assumption 7 caveat. The `HADPretestReport` docstring and structural fields (`pretrends_joint`, `homogeneity_joint`) reflect the aggregate-dependent coverage. + - **Note (Phase 2b result class):** a multi-period panel returns a new `HeterogeneousAdoptionDiDEventStudyResults` dataclass (distinct from the single-period `HeterogeneousAdoptionDiDResults`) with per-horizon arrays (`event_times`, `att`, `se`, `t_stat`, `p_value`, `conf_int_low`, `conf_int_high`, `n_obs_per_horizon`) and shared metadata. `to_dataframe()` returns a tidy per-horizon DataFrame; `to_dict()` returns a dict with list-of-per-horizon fields. The static return-type annotation on `fit()` is `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]`, matching the runtime polymorphism on the panel shape; callers should narrow via `isinstance` (or via the panel shape they supplied) when reading aggregate-specific fields. + - **Note (Phase 3 pretest workflow):** Phase 3 ships the Section 4 pre-test diagnostics in a new module `diff_diff/had_pretests.py` (separate from the 2,800-line `had.py` estimator module). The three tests — `qug_test`, `stute_test`, `yatchew_hr_test` — accept raw `(d, dy)` arrays and return their own result dataclasses (`QUGTestResults`, `StuteTestResults`, `YatchewTestResults`); the composite `did_had_pretest_workflow` dispatches on the panel shape (a balanced two-period panel runs the overall battery; a multi-period panel with >= 3 periods runs the event-study battery) and returns `HADPretestReport` with a priority-ordered verdict string. The `data`-only workflow signature (no `result=`-consuming variant) avoids coupling pretests to the `BiasCorrectedFit` internal state, which does not expose residuals. Below-minimum sample sizes emit `UserWarning` and return all-NaN inference (no `ValueError` raise) for library consistency with the `safe_inference` convention; input-shape violations (non-1D, NaN-containing, mismatched length) still raise. No multiple-testing adjustment (Bonferroni/Holm) is applied — the paper does not prescribe one. **Partial-workflow semantic (path-dependent):** a two-period panel (the overall battery) runs paper steps 1 (QUG) + 3 (linearity via Stute + Yatchew-HR) ONLY; step 2 (Assumption 7 pre-trends test via Equation 18) is NOT covered on this path and a fail-to-reject verdict appends an explicit Assumption 7 gap flag (`"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`). a multi-period panel (the event-study battery; requires >=3 periods + earlier pre-period for joint pretrends) closes step 2 via joint Stute pre-trends (Equation 18) over pre-period horizons AND joint Stute homogeneity over post-period horizons; the verdict on a fail-to-reject across QUG + joint pretrends + joint homogeneity reads "TWFE admissible under Section 4 assumptions" without the Assumption 7 caveat. The `HADPretestReport` docstring and structural fields (`pretrends_joint`, `homogeneity_joint`) reflect the aggregate-dependent coverage. - **Note (Phase 3 Stute bootstrap):** The Stute CvM statistic is implemented via the simplified-form `S = (1/G²) · Σ cumsum²`, which is algebraically equivalent to the paper's stated `Σ(g/G)² · ((1/g) Σ eps_{(h)})²` form. Bootstrap follows the paper's Appendix D Algorithm literally: each iteration refits OLS on `dy_b = a_hat + b_hat · d + eps · eta` (null DGP multiplied by Mammen weights). The ~2× performance follow-up landed 2026-07-07 as **loop-invariant hoisting**, not the once-sketched `M = I - X(X'X)⁻¹X'` materialization (O(G²) memory — would not scale): Mammen draws are batched through memory-bounded `rng.choice` calls whose variate stream is identical to per-iteration draws, and the d-moments + CvM tie-block indices are precomputed once per test — each replicate still runs the paper-literal O(G) refit on the same values, so `bootstrap_S`/`cvm_stat`/`p_value` are bit-identical to the per-iteration form (frozen byte-copy parity test at `tests/test_had_pretests.py::TestStuteBootstrapHoistedInvariants`). Bootstrap reproducibility uses `np.random.default_rng(seed)` (library convention, matching `wild_bootstrap_se`); `seed=42` in tests for bitwise stability. - **Note (Phase 3 Yatchew normalizer):** `σ̂²_diff` in `yatchew_hr_test` divides by `2G` (paper-literal from Theorem 7), NOT by `2(G-1)`. A unit test hand-computes `σ̂²_diff` at `G=4` on deterministic inputs and asserts the `2G`-normalizer form at `atol=1e-12` so any later regression to the (asymptotically equivalent but finite-sample different) `2(G-1)` form would fail the test. `σ̂⁴_W` uses `np.mean(eps_{(g)}² · eps_{(g-1)}²)` which divides by `G-1` via `np.mean` length, matching the paper's Theorem 7 / Equation 29 normalization. - **Note (Phase 3 Yatchew tie policy):** `yatchew_hr_test` REJECTS duplicate dose values with a `UserWarning` + all-NaN result at the front door. The difference-based variance estimator `σ̂²_diff` and the heteroskedasticity-robust scale `σ̂⁴_W` both use adjacent differences (of sorted dy and of adjacent squared residuals, respectively); under tied doses the within-tie row ordering is arbitrary (stable sort falls back to input order), so the statistic becomes non-methodological and order-dependent. Callers with tied doses (mass-point designs, discretised dose registers) should use `stute_test` instead — its tie-safe Cramer-von Mises statistic collapses tie blocks to the post-tie cumulative sum and is provably order-invariant under within-tie permutations. A regression test on `stute_test` asserts bit-identical `cvm_stat` across tied-dose permutations at `atol=1e-14`; a matching regression on `yatchew_hr_test` asserts the NaN+warning behavior on duplicated and constant doses. This tie policy is a Phase 3 diff-diff choice (the paper describes Yatchew only as "sort by D" and does not specify a tie rule); an alternative implementation could document a justified tie-resolution convention and back it with permutation tests, but at the cost of a methodology surface the paper does not cover. **Workflow fallback:** `did_had_pretest_workflow` follows the paper's "Stute OR Yatchew" step-3 wording — a conclusive Stute (which is tie-safe) is sufficient to adjudicate linearity even when Yatchew returns NaN on tied-dose panels. The composite verdict then reads "QUG and linearity diagnostics fail-to-reject (Yatchew NaN - skipped); ..." rather than forcing the whole report to "inconclusive", and `all_pass` is `True` when QUG + at least one conclusive linearity test fail-to-reject. @@ -5068,15 +5071,15 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 3: `qug_test()` (`T = D_{2,(1)} / (D_{2,(2)} - D_{2,(1)})`, rejection `{T > 1/α - 1}`). One-sided asymptotic p-value `1 / (1 + T)` under the Exp(1)/Exp(1) limit law (Theorem 4). Zero-dose observations filtered upfront (with `UserWarning`); `D_{(1)} == D_{(2)}` tie returns all-NaN inference (conservative). Closed-form tight parity tested at `atol=1e-12`. - [x] Phase 3: `stute_test()` Cramér-von Mises with Mammen wild bootstrap. Statistic `S = (1/G^2) Σ (cumsum_g)^2` (algebraically equivalent to paper's `Σ(g/G)^2 · ((1/g) Σ eps_{(h)})^2`). Bootstrap follows paper Appendix D Algorithm literal (per-iteration OLS refit). `n_bootstrap=999` default, `n_bootstrap >= 99` validated. `G < 10` returns NaN; `G > 100_000` emits a `UserWarning` pointing to `yatchew_hr_test`. The ~2× performance follow-up landed 2026-07-07 (loop-invariant hoisting, bit-identical — see the Phase 3 Stute bootstrap Note above). - [x] Phase 3: `yatchew_hr_test()` heteroskedasticity-robust specification test. Test statistic `T_hr = sqrt(G) · (σ̂²_lin - σ̂²_diff) / σ̂²_W` from paper Equation 29. Normalizer `σ̂²_diff` divides by `2G` (paper-literal Theorem 7), NOT `2(G-1)`; hand-computed tight parity asserted at `atol=1e-12`. One-sided standard-normal critical value. `G < 3` returns NaN. Phase 3 shipped only the linearity null (paper Theorem 7); the `null="mean_independence"` R-parity extension shipped post-PR #392 (see the algorithm-variant block above for the contract). -- [x] Phase 3: `did_had_pretest_workflow()` composite helper. `data`-only entry point with `aggregate` dispatch: `aggregate="overall"` (default) requires a balanced two-period panel — multi-period panels are rejected at the front door by `_validate_had_panel` with a pointer to `aggregate="event_study"` — and runs steps 1 (QUG) + 3 (Stute + Yatchew-HR) only; `aggregate="event_study"` takes a multi-period panel (>=3 periods) and additionally runs step 2 (joint Stute pre-trends over pre-period horizons) + joint Stute homogeneity over post-period horizons, populating `pretrends_joint` / `homogeneity_joint`. `seed` forwards to all bootstrap-based tests (QUG and Yatchew are deterministic). Returns `HADPretestReport` with priority-ordered verdict string. On `aggregate="overall"` a fail-to-reject verdict explicitly flags the Assumption 7 gap rather than claiming unconditional TWFE safety: `"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`; on `aggregate="event_study"` a fail-to-reject across all three covered diagnostics reads `"TWFE admissible under Section 4 assumptions"` without the Assumption 7 caveat. Verdict priority follows the paper's one-way rule (TWFE admissible only if NO test rejects): **conclusive rejections are the primary verdict and are NEVER hidden by inconclusive status** — any unresolved-step note is appended via `"; additional steps unresolved: ..."` rather than replacing the rejection. The pure `"inconclusive - QUG NaN"` / `"inconclusive - both Stute and Yatchew linearity tests NaN"` forms only fire when NO conclusive test rejects AND a required step is unresolved. The partial-workflow fail-to-reject verdict may carry a `"(Yatchew NaN - skipped)"` (or Stute) suffix when one linearity test is NaN but the other is conclusive (step 3 resolved via the paper's "Stute OR Yatchew" wording). Bundled rejection-reason strings name each failed assumption in the conclusive-rejection case. `all_pass` is `True` iff QUG is conclusive AND at least one of Stute/Yatchew is conclusive AND no conclusive test rejects. **Non-negative-dose contract**: all three raw linearity helpers (`qug_test`, `stute_test`, `yatchew_hr_test`) raise a front-door `ValueError` on any `d < 0`, mirroring the `_validate_had_panel` guard (paper Section 2 HAD support restriction). On the `aggregate="overall"` path, the panel must already be exactly two periods (`_validate_had_panel` raises with a pointer to `aggregate="event_study"` otherwise); the first-difference helper computes `(t_post, t_pre)` per unit and feeds each raw helper directly. On the `aggregate="event_study"` path, joint Stute is dispatched across pre-period and post-period horizons directly (the joint Equation-18 form, no per-horizon pre-slicing). +- [x] Phase 3: `did_had_pretest_workflow()` composite helper. `data`-only entry point with panel-shape dispatch: a balanced two-period panel runs the overall battery - steps 1 (QUG) + 3 (Stute + Yatchew-HR) only; a multi-period panel (>=3 periods) runs the event-study battery and additionally runs step 2 (joint Stute pre-trends over pre-period horizons) + joint Stute homogeneity over post-period horizons, populating `pretrends_joint` / `homogeneity_joint`. `seed` forwards to all bootstrap-based tests (QUG and Yatchew are deterministic). Returns `HADPretestReport` with priority-ordered verdict string. On the two-period (overall) path a fail-to-reject verdict explicitly flags the Assumption 7 gap rather than claiming unconditional TWFE safety: `"QUG and linearity diagnostics fail-to-reject; Assumption 7 pre-trends test NOT run (paper step 2 deferred)"`; on the multi-period (event-study) path a fail-to-reject across all three covered diagnostics reads `"TWFE admissible under Section 4 assumptions"` without the Assumption 7 caveat. Verdict priority follows the paper's one-way rule (TWFE admissible only if NO test rejects): **conclusive rejections are the primary verdict and are NEVER hidden by inconclusive status** — any unresolved-step note is appended via `"; additional steps unresolved: ..."` rather than replacing the rejection. The pure `"inconclusive - QUG NaN"` / `"inconclusive - both Stute and Yatchew linearity tests NaN"` forms only fire when NO conclusive test rejects AND a required step is unresolved. The partial-workflow fail-to-reject verdict may carry a `"(Yatchew NaN - skipped)"` (or Stute) suffix when one linearity test is NaN but the other is conclusive (step 3 resolved via the paper's "Stute OR Yatchew" wording). Bundled rejection-reason strings name each failed assumption in the conclusive-rejection case. `all_pass` is `True` iff QUG is conclusive AND at least one of Stute/Yatchew is conclusive AND no conclusive test rejects. **Non-negative-dose contract**: all three raw linearity helpers (`qug_test`, `stute_test`, `yatchew_hr_test`) raise a front-door `ValueError` on any `d < 0`, mirroring the `_validate_had_panel` guard (paper Section 2 HAD support restriction). On the two-period (overall) path the panel must be exactly two periods (`_validate_had_panel` raises, in panel-shape vocabulary, otherwise); the first-difference helper computes `(t_post, t_pre)` per unit and feeds each raw helper directly. On the multi-period (event-study) path, joint Stute is dispatched across pre-period and post-period horizons directly (the joint Equation-18 form, no per-horizon pre-slicing). - [x] Phase 4: Pierce-Schott (2016) replication harness reproduces Figure 2 values. **Waived 2026-05-20:** see Deviations block above; the paper itself self-acknowledges that NP estimators are too noisy to be informative on the LBD-restricted PNTR panel (Section 5.2), and R parity at `atol=1e-8` via `tests/test_did_had_parity.py` is a strictly stronger correctness anchor than Figure-2 reproduction on a proxy panel. Recorded as waived in the `DEFERRED.md` decision record. - [x] Phase 4: Full DGP 1/2/3 coverage-rate reproduction from Table 1. **Waived 2026-05-20:** see Deviations block above; R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm (Python ≡ R ≡ paper) at sample-mean level — stronger than coverage-rate MC, which re-verifies asymptotic-coverage already pinned by R parity. Recorded as waived in the `DEFERRED.md` decision record. - [x] Phase 5 (wave 1, PR #402): `practitioner_next_steps()` integration for HAD results - `_handle_had` and `_handle_had_event_study` route both result classes through HAD-specific Baker et al. (2025) step guidance with bidirectional HAD ↔ ContinuousDiD Step-4 routing closure. The `_check_nan_att` helper extends to ndarray `att` (HAD event-study) via `np.all(np.isnan(arr))` semantics; scalar path bit-exact preserved. The `llms-full.txt` HAD section's documented constructor and `fit()` parameter lists are regression-locked against `inspect.signature(HeterogeneousAdoptionDiD.__init__)` and `HeterogeneousAdoptionDiD.fit` for parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned by the current `inspect.signature` test). The `fit()` return annotation is widened to `Union[HeterogeneousAdoptionDiDResults, HeterogeneousAdoptionDiDEventStudyResults]` at the source-code level to match the runtime polymorphism, AND that union is now pinned at the test level by `tests/test_had.py::TestFitReturnAnnotation::test_fit_return_annotation_is_union_of_result_classes` via `typing.get_type_hints` so the contract cannot drift silently. - [x] Phase 5 (wave 1, PR #402): `llms-full.txt` HeterogeneousAdoptionDiD section + result-class blocks + `## HAD Pretests` index + Choosing-an-Estimator row landed; constructor / fit() parameter names are regression-locked against `inspect.signature(HeterogeneousAdoptionDiD.__init__)` and `HeterogeneousAdoptionDiD.fit` for parameter-name presence (parameter defaults and the non-return parameter type annotations remain unpinned; the `fit()` return-type union is locked BOTH at the source-code level AND at the test level by `TestFitReturnAnnotation`); result-class field tables enumerate every public dataclass field (regression-tested via `dataclasses.fields()`); `llms-practitioner.txt` Step 4 decision tree distinguishes ContinuousDiD (per-dose ATT(d), needs never-treated) from HeterogeneousAdoptionDiD (WAS, universal-rollout-compatible). - [x] Phase 5 (partial): README catalog one-liner, bundled `llms.txt` `## Estimators` entry, `docs/api/had.rst` (autoclass for the three classes), and `docs/references.rst` citation landed in PR #372 docs refresh. -- [x] Phase 5 (wave 2 first slice, PR #409): T21 HAD pretest workflow tutorial (`docs/tutorials/21_had_pretest_workflow.ipynb`) — composite pre-test walkthrough for `did_had_pretest_workflow`. Uses a `Uniform[$0.01K, $50K]` dose-distribution variant of T20's brand-campaign panel (true support strictly positive but near-zero, chosen so QUG fails-to-reject `H0: d_lower = 0` in finite sample). Walks through `aggregate="overall"` (Steps 1 + 3 only, verdict explicitly flags Step 2 deferral) and upgrades to `aggregate="event_study"` (joint pre-trends Stute + joint homogeneity Stute close the gap). Side panel exercises both `yatchew_hr_test` null modes (`linearity` vs `mean_independence`). Companion drift-test file `tests/test_t21_had_pretest_workflow_drift.py` (17 tests pinning panel composition, both verdict pivots, structural anchors, deterministic stats, bootstrap p-value tolerance bands per backend, and `HAD(design="auto")` resolution to `continuous_at_zero` on this panel). +- [x] Phase 5 (wave 2 first slice, PR #409): T21 HAD pretest workflow tutorial (`docs/tutorials/21_had_pretest_workflow.ipynb`) — composite pre-test walkthrough for `did_had_pretest_workflow`. Uses a `Uniform[$0.01K, $50K]` dose-distribution variant of T20's brand-campaign panel (true support strictly positive but near-zero, chosen so QUG fails-to-reject `H0: d_lower = 0` in finite sample). Walks through the two-period overall battery (Steps 1 + 3 only, verdict explicitly flags Step 2 deferral) and upgrades to the multi-period event-study battery (joint pre-trends Stute + joint homogeneity Stute close the gap). Side panel exercises both `yatchew_hr_test` null modes (`linearity` vs `mean_independence`). Companion drift-test file `tests/test_t21_had_pretest_workflow_drift.py` (17 tests pinning panel composition, both verdict pivots, structural anchors, deterministic stats, bootstrap p-value tolerance bands per backend, and `HAD(design="auto")` resolution to `continuous_at_zero` on this panel). - [x] Phase 5 (wave 2 second slice): T22 weighted/survey HAD tutorial (`docs/tutorials/22_had_survey_design.ipynb`) - shipped as the follow-up to PR #432. End-to-end walkthrough of `HeterogeneousAdoptionDiD` + `did_had_pretest_workflow` under `SurveyDesign(weights, strata, psu, fpc)` on a BRFSS-shape state-rollout panel (5 strata x 6 PSUs/stratum x 2 states/PSU = 60 states; post-stratification raking weights with CV ~ 0.30; FPC = 30 PSUs/stratum). Companion drift-test file `tests/test_t22_had_survey_design_drift.py` (32 tests pinning panel composition, naive-vs-survey SE inflation direction, design auto-detection, event-study cband-vs-pointwise width ordering, `_QUG_DEFERRED_SUFFIX` substring on `report.verdict` for both overall and event-study paths, the distinct `report.summary()` QUG-skip note on the event-study path, deterministic Yatchew sigma2_*, bootstrap p-value anchored windows of total width 0.30 (± 0.15 around seeded centers) per `feedback_strata_bootstrap_path_divergence`, workflow-surface separation between overall and event-study paths, and the weighted point-estimation contract via the `_fit_continuous` algebraic identity). -- [x] Documentation of non-testability of Assumptions 5 and 6. **Closed 2026-05-20:** `HeterogeneousAdoptionDiD` class docstring carries a "Non-testable assumptions (paper Section 3.1.2)" Notes block; `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections carry "Scope (what this test does NOT cover)" clauses explicitly stating they verify ADJACENT identifying conditions (QUG: support-infimum null `d_lower = 0`; Stute / Yatchew: Assumption 8 linearity; `joint_pretrends_test`: Assumption 7 mean-independence) and CANNOT test Assumptions 5 or 6. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-period `aggregate="overall"` path. The Assumption 5/6 non-testability caveat is surfaced separately by (a) `HAD.fit()`'s fit-time `UserWarning` in `diff_diff/had.py` (search for "---- Assumption 5/6 warning on Design 1 paths ----") which fires whenever the resolved design is Design 1 family (`continuous_near_d_lower` or `mass_point`), and (b) T21 (HAD pretest workflow tutorial) tutorial prose. +- [x] Documentation of non-testability of Assumptions 5 and 6. **Closed 2026-05-20:** `HeterogeneousAdoptionDiD` class docstring carries a "Non-testable assumptions (paper Section 3.1.2)" Notes block; `qug_test` / `stute_test` / `yatchew_hr_test` / `did_had_pretest_workflow` Notes sections carry "Scope (what this test does NOT cover)" clauses explicitly stating they verify ADJACENT identifying conditions (QUG: support-infimum null `d_lower = 0`; Stute / Yatchew: Assumption 8 linearity; `joint_pretrends_test`: Assumption 7 mean-independence) and CANNOT test Assumptions 5 or 6. The composite workflow verdict string does NOT mention Assumptions 5 or 6 — it only flags the Assumption 7 step-2 gap on the two-period (overall) path. The Assumption 5/6 non-testability caveat is surfaced separately by (a) `HAD.fit()`'s fit-time `UserWarning` in `diff_diff/had.py` (search for "---- Assumption 5/6 warning on Design 1 paths ----") which fires whenever the resolved design is Design 1 family (`continuous_near_d_lower` or `mass_point`), and (b) T21 (HAD pretest workflow tutorial) tutorial prose. - [x] Warnings for staggered treatment timing (redirect to `ChaisemartinDHaultfoeuille`). **Closed 2026-05-20:** fail-closed `ValueError` at `diff_diff/had.py:1624` (see Deviations § "Library extension: Staggered-timing fail-closed" for the rationale on raising vs warning). - [x] `NotImplementedError` phase pointer when `covariates=` is passed (Theorem 6 future work). **Closed 2026-06-01:** `HAD.fit()` now takes an explicit keyword-only `covariates=None` param and raises `NotImplementedError` (with the Appendix B.1 / Theorem 6 multivariate-covariate-extension pointer + a pre-residualization workaround) when it is not None, replacing the prior bare `TypeError` from the absent kwarg. See the `- **Note:**` ("`covariates=` is reserved but NOT implemented") above and `diff_diff/had.py::HeterogeneousAdoptionDiD.fit`; locked by `tests/test_methodology_had.py::TestHADDeviations::test_covariates_not_implemented_is_documented`. diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index 2e0e70673..0106d972d 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -23,8 +23,9 @@ existing diff-diff utility (`compute_honest_did`, `HonestDiD.sensitivity`, `BaconDecomposition`, `check_parallel_trends`, `compute_pretrends_power`). The post-fit derivation is the one report-layer data source beyond direct reads: -when a fit's raw `event_study_effects` field is absent (the modern, -no-fit-time-`aggregate=` path), `DiagnosticReport` resolves +when a fit's raw `event_study_effects` field is absent (every analytic +CS/DMLDiD/EfficientDiD/ImputationDiD/TwoStageDiD/ContinuousDiD fit since +the 4.0 removal of the fit-time `aggregate=` keyword), `DiagnosticReport` resolves `results.aggregate('event_study')` once (cached) and consumes the returned `EventStudyResults` container, where applicable per estimator: CallawaySantAnna and DMLDiD for parallel_trends / @@ -48,14 +49,20 @@ joint-Wald path and pretrends_power/sensitivity the full covariance - the Bonferroni and diagonal fallbacks remain for fits where no bootstrap covariance is stored, e.g. `n_bootstrap` at or below the number of event times - and the replay's re-emitted fit-time warnings -are recorded and republished per section). Kit-less/legacy-pickle fits, -backend-mismatched CS bootstrap replays, and the other estimators' -bootstrap gates still fail closed: the derivation exception is caught -and surfaced as an explicit per-check skip reason, never substituted -with analytical numbers. The raw field, when present — including the -requested-but-empty `{}` sentinel, which encodes fit-time -configuration such as a `balance_e=` that emptied the window — always -takes precedence and is never re-derived. When the caller +are recorded and republished per section). Bootstrapped ImputationDiD / +TwoStageDiD / ContinuousDiD fits are HYBRIDS since 4.0 (PR 3): they +materialize the percentile event-study (and group) surface at fit into +the raw fields, so the report reads those directly (raw-field +precedence) rather than deriving. Only kit-less/legacy-pickle fits, +backend-mismatched CS bootstrap replays, and bootstrapped hybrid fits +whose surface could not be materialized (fail-soft cleared, the relay +raises) still skip: the derivation exception is caught and surfaced as +an explicit per-check skip reason, never substituted with analytical +numbers. The raw field, when present — including the +requested-but-empty `{}` sentinel, which now arises only from producers +that materialize at fit (StackedDiD, SunAbraham, the bootstrapped +hybrids; always `balance_e=None`) — always takes precedence and is +never re-derived. When the caller passes the raw panel + column kwargs, `DiagnosticReport` may call those utilities on the supplied data (2x2 PT via `check_parallel_trends`, Goodman-Bacon decomposition via @@ -484,8 +491,9 @@ a library setting. fields. Pretrends POWER: natively `compute_pretrends_power()` supports MPD / CS / SunAbraham fits; since row M-024 a Stacked `results.aggregate('event_study')` - CONTAINER also admits (kappa_pre >= 2). On plain (no fit-time - `aggregate=`) CS fits, `DiagnosticReport` internally derives the CS + CONTAINER also admits (kappa_pre >= 2). On CS fits (whose raw + `event_study_effects` is None on every fit since 4.0), + `DiagnosticReport` internally derives the CS container and feeds it to `compute_pretrends_power` / `HonestDiD.sensitivity_analysis` — CS is M-093-admitted with pinned raw-route parity; the admission set itself is unchanged (widening diff --git a/docs/methodology/variance-conventions.md b/docs/methodology/variance-conventions.md index 1f90769a4..aef3a0e98 100644 --- a/docs/methodology/variance-conventions.md +++ b/docs/methodology/variance-conventions.md @@ -143,9 +143,10 @@ output). characterization-anchored only (DoubleML's RCS score differs and omits the Chang lambda-correction; equation-level fixtures validate the variance). **ImputationDiD is conditional**: its pretrends=True lead regression DOES - run the shared clustered CR1 — surfaced fit-time via the deprecated - aggregate="event_study" or post-fit via results.aggregate('event_study') - since the M-021 migration — and carries the K_reference increment there + run the shared clustered CR1 — surfaced via results.aggregate('event_study') + since the M-021 migration (analytic fits recompute it; bootstrapped fits + relay the surface materialized at fit, 4.0 PR 3) — and carries the + K_reference increment there (+6, the [time, unit] no-intercept increment on df_0 — pinned via expected_adjustment on its matrix row). - **L4 — hc2/hc2_bm/hc3** (leverage / Satterthwaite DOF — no CR1 factor; hc3 squares the leverage denominator and is one-way only; under `fweight` the leverage is each replicate row's UNWEIGHTED quadratic form against the weighted bread — frequency weights are replicated data, and compressed HC2/HC3 equal literal expansion exactly — while aweight/pweight keep the WLS-hat `w_i x_i'(X'WX)^{-1}x_i` convention), diff --git a/docs/migration-4.0.md b/docs/migration-4.0.md index d9bade867..d443869d3 100644 --- a/docs/migration-4.0.md +++ b/docs/migration-4.0.md @@ -131,13 +131,24 @@ it to `post=`; if you pass a calendar column to an event-study or staggered fit, **Dropped parameters are not always deletions.** A parameter with no successor in the appendix may still need a replacement call — see its `Fix` cell. +**Dropped `fit()` parameters shift the positional slots after them.** Removing the fit-time +`aggregate=`/`balance_e=` keywords moves every later positional parameter left: on +`CallawaySantAnna`, `ImputationDiD` and `TwoStageDiD` `survey_design` moves from slot 9 to 7; +on `EfficientDiD` `survey_design` 9 -> 7 and `store_eif` 10 -> 8; on `StackedDiD` (`aggregate` +sat before them) `population`/`survey_design`/`covariates` move 7/8/9 -> 6/7/8; on +`ContinuousDiD` `survey_design`/`covariates` move 8/9 -> 7/8; on `ChaisemartinDHaultfoeuille` +`L_max`, `covariates`, `trends_linear`, `trends_nonparam`, `honest_did`, `heterogeneity`, +`design2` and `survey_design` each move one slot left (7..14 -> 6..13). `HeterogeneousAdoptionDiD` +and `did_had_pretest_workflow` shift nothing (everything after the removed slot is keyword-only; +a seventh positional on `fit()` now raises). Pass these by keyword. + ## Post-fit aggregation Aggregation moves off `fit()` onto the results object: ```python -# 3.x -results = CallawaySantAnna().fit(data, ..., aggregate="event_study") +# 3.x (a TypeError from 4.0 - the keyword is gone): +# results = CallawaySantAnna().fit(data, ..., aggregate="event_study") # 4.0 results = CallawaySantAnna().fit(data, ...) @@ -157,18 +168,27 @@ surface (nothing to derive), and ChaisemartinDHaultfoeuille's pre-period checks read `placebo_event_study` directly. ```{warning} -**Bootstrapped fits: CallawaySantAnna, DMLDiD, and EfficientDiD are covered; the remaining recompute adopters are not yet.** -On `CallawaySantAnna`, `DMLDiD` (which inherits the CS replay channel), and `EfficientDiD`, the post-fit recompute levels now REPLAY the +**Bootstrapped fits: every adopter has a post-fit route - two mechanisms.** +On `CallawaySantAnna`, `DMLDiD` (which inherits the CS replay channel), and `EfficientDiD`, the post-fit recompute levels REPLAY the fit-time multiplier bootstrap from the fit-retained RNG state (percentile inference -matching a fit-time aggregation to floating-point reassociation) — no fit-time keyword -needed; only pre-replay legacy pickles and artifacts moved across the Rust/NumPy weight -backend fail closed with a refit message. On `ImputationDiD`, `TwoStageDiD` and -`ContinuousDiD`, the recompute levels still raise `NotImplementedError` when the fit used -`n_bootstrap > 0` — keep the fit-time call there for now and track their open `TODO.md` -rows. -`aggregate("simple")` — and, on its five adopters, `aggregate("total")` (3.10; DMLDiD joined in 3.11 — panel non-survey fits only: RCS fits, declared-`survey_design=` fits, and bare-`cluster=` fits with incomplete treated support fail `total` closed) — does relay, -and `StackedDiD`, `ChaisemartinDHaultfoeuille` and `HeterogeneousAdoptionDiD` are unaffected — -their `aggregate()` is a pure view over stored fields. +matching a fit-time aggregation to floating-point reassociation); only pre-replay legacy +pickles and artifacts moved across the Rust/NumPy weight backend fail closed with a refit +message. On `ImputationDiD`, `TwoStageDiD` and `ContinuousDiD` a fit with `n_bootstrap > 0` +ALWAYS computes the event-study (and, on Imputation/TwoStage, group) percentile surface at fit +and `aggregate('event_study')` / `aggregate('group')` RELAYS it - the same numbers the +3.x `fit(aggregate="all")` route produced, with a NaN `df` column and no `vcov`. Two limits +there: `balance_e=` is unavailable on those bootstrapped fits (`NotImplementedError` - the +stored surface was computed unrestricted; re-fit with `n_bootstrap=0` for a balanced window), +and a bootstrapped pickle written by 3.9-3.13 stores no surface (or, if it was fitted with +`balance_e=`, a restricted one), so re-fit under 4.0 for the relayed surface. If the surface +could not be materialized at fit, the fit warns once, the headline bootstrap is unchanged, +and the relay raises with that reason. +`aggregate("simple")` - and, on its five adopters, `aggregate("total")` (3.10; DMLDiD joined in 3.11 - panel non-survey fits only: RCS fits, declared-`survey_design=` fits, and bare-`cluster=` fits with incomplete treated support fail `total` closed) - relays on every adopter, +and `StackedDiD`, `ChaisemartinDHaultfoeuille` and `HeterogeneousAdoptionDiD` are unaffected - +their `aggregate()` is a pure view over stored fields. One residual delta on +replicate-weight `ImputationDiD`/`TwoStageDiD` designs: the plain fit's replicate stack is +`[overall]`-only, so on degenerate designs the OVERALL row's se/CI/df can differ from what a +3.x `fit(aggregate=...)` call reported (each surface self-consistent). ``` ## Results fields @@ -377,16 +397,16 @@ does not mean no action is required, so read the `Fix` cell. | Row | Group | Old | New | Fix | |---|---|---|---|---| | M-020 | aggregate-postfit | `diff_diff:CallawaySantAnna.fit[aggregate]` | `diff_diff:CallawaySantAnnaResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits replay the fit-time bootstrap post-fit - see the aggregation section. | -| M-021 | aggregate-postfit | `diff_diff:ImputationDiD.fit[aggregate]` | `diff_diff:ImputationDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | -| M-022 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[aggregate]` | `diff_diff:TwoStageDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-021 | aggregate-postfit | `diff_diff:ImputationDiD.fit[aggregate]` | `diff_diff:ImputationDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits compute the surface at fit and `aggregate()` relays it - see the aggregation section. | +| M-022 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[aggregate]` | `diff_diff:TwoStageDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits compute the surface at fit and `aggregate()` relays it - see the aggregation section. | | M-023 | aggregate-postfit | `diff_diff:EfficientDiD.fit[aggregate]` | `diff_diff:EfficientDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits replay the fit-time bootstrap post-fit - see the aggregation section. | | M-024 | aggregate-postfit | `diff_diff:StackedDiD.fit[aggregate]` | `diff_diff:StackedDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | -| M-025 | aggregate-postfit | `diff_diff:ContinuousDiD.fit[aggregate]` | `diff_diff:ContinuousDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-025 | aggregate-postfit | `diff_diff:ContinuousDiD.fit[aggregate]` | `diff_diff:ContinuousDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits compute the surface at fit and `aggregate()` relays it - see the aggregation section. | | M-026 | aggregate-postfit | `diff_diff:ChaisemartinDHaultfoeuille.fit[aggregate]` | `diff_diff:ChaisemartinDHaultfoeuilleResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | | M-027 | aggregate-postfit | `diff_diff:HeterogeneousAdoptionDiD.fit[aggregate]` | `diff_diff:HeterogeneousAdoptionDiDResults.aggregate` | Move `aggregate=` off `fit()` onto post-fit `results.aggregate(...)`. | | M-117 | aggregate-postfit | `diff_diff:CallawaySantAnna.fit[balance_e]` | `diff_diff:CallawaySantAnnaResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits replay the fit-time bootstrap post-fit - see the aggregation section. | -| M-118 | aggregate-postfit | `diff_diff:ImputationDiD.fit[balance_e]` | `diff_diff:ImputationDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | -| M-119 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[balance_e]` | `diff_diff:TwoStageDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. On a bootstrapped fit the recompute levels raise today - see the aggregation section. | +| M-118 | aggregate-postfit | `diff_diff:ImputationDiD.fit[balance_e]` | `diff_diff:ImputationDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. Unavailable on a bootstrapped fit (`NotImplementedError`; re-fit with `n_bootstrap=0`) - see the aggregation section. | +| M-119 | aggregate-postfit | `diff_diff:TwoStageDiD.fit[balance_e]` | `diff_diff:TwoStageDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. Unavailable on a bootstrapped fit (`NotImplementedError`; re-fit with `n_bootstrap=0`) - see the aggregation section. | | M-120 | aggregate-postfit | `diff_diff:EfficientDiD.fit[balance_e]` | `diff_diff:EfficientDiDResults.aggregate[balance_e]` | Move `balance_e=` off `fit()` onto post-fit `results.aggregate(...)`. Bootstrapped fits replay the fit-time bootstrap post-fit - see the aggregation section. | | M-139 | aggregate-postfit | `diff_diff:did_had_pretest_workflow[aggregate]` | — | Remove `aggregate=`; the battery is inferred from panel shape - two periods select the overall battery, more than two select the event-study battery. It is not selected post-fit (`HADPretestReport.aggregate` is a metadata field, not a method). | | M-060 | alias-table | `EventStudy` | — | Import `TwoWayFixedEffects(...).fit(..., event_study=True)` - the alias is dropped, not retargeted instead of the `EventStudy` alias. | diff --git a/docs/performance-plan.md b/docs/performance-plan.md index 9cbf202bd..7e0fcef3c 100644 --- a/docs/performance-plan.md +++ b/docs/performance-plan.md @@ -257,7 +257,8 @@ QR — Phase 3 candidate). **R `did` yardstick** (did 2.5.1 — the 2.5.0 performance release with `faster_mode` and the tiled-contraction bootstrap — R 4.5.2 + OpenBLAS 0.3.33; equal work: `att_gt` + -`aggte` simple+dynamic+group vs `fit(aggregate="all")`; identical `biters`/`cband`; +`aggte` simple+dynamic+group vs a plain `fit()` followed by post-fit `aggregate("event_study")` + +`aggregate("group")` (the 3.x `fit(aggregate="all")` route, removed at 4.0 - same work); identical `biters`/`cband`; medians of 3; run configs recorded in each JSON artifact's metadata): | Scenario | diff-diff | R 1-core | R 14-core (`pl=TRUE, cores=14`) | @@ -834,11 +835,12 @@ These are developer-ergonomics / API-consistency smells surfaced during scenario development. None are silent-failures and none belong in this PR or in the silent-failures audit; logging here for awareness. -1. **`aggregate` / `level` parameter naming is inconsistent.** CS accepts - `aggregate="event_study"`; ContinuousDiD historically required +1. **`aggregate` / `level` parameter naming was inconsistent (historical; resolved at + 4.0).** CS accepted `aggregate="event_study"` on `fit()`; ContinuousDiD required `aggregate="eventstudy"` on `fit()` **but** `level="event_study"` on `to_dataframe()`. Two different spellings within one estimator plus a - third cross-estimator spelling. Surfaced when the P1 exit-propagation + third cross-estimator spelling. Both fit-time keywords were removed at 4.0 (PR 3); + `results.aggregate("event_study")` is the single post-fit spelling. Surfaced when the P1 exit-propagation fix stopped silently swallowing the resulting `ValueError` in the dose-response benchmark. RESOLVED by row M-025: the fit-time kwarg is deprecated (removed in 4.0, its `"eventstudy"` spelling with it) and diff --git a/docs/performance-scenarios.md b/docs/performance-scenarios.md index cafeff418..90399120e 100644 --- a/docs/performance-scenarios.md +++ b/docs/performance-scenarios.md @@ -113,13 +113,14 @@ serves a different purpose: R-parity accuracy). They complement it. cluster="unit", n_bootstrap=999, ).fit(data, outcome="y", unit="unit", time="period", - first_treat="first_treat", covariates=["log_pop", "baseline_spend"], - aggregate="all") + first_treat="first_treat", covariates=["log_pop", "baseline_spend"]) + es = results.aggregate("event_study") # replays the fit-time bootstrap ``` - **Operation chain.** (1) `BaconDecomposition.fit()` for TWFE diagnostic; - (2) CS fit with `aggregate="all"` (populates simple, group, event_study); + (2) CS fit followed by post-fit `aggregate("event_study")` (+ `aggregate("group")`; + the 3.x `fit(aggregate="all")` route, removed at 4.0); (3) inspect event-study pre-period ATTs for pre-trends; (4) - `compute_honest_did(results, method="relative_magnitude", M=[0.5, 1.0, 1.5, 2.0])`; + `compute_honest_did(es, method="relative_magnitude", M=[0.5, 1.0, 1.5, 2.0])`; (5) robustness: refit with `SunAbraham()` and `ImputationDiD()` for cross-estimator comparison; (6) refit CS without covariates for the Baker-mandated with/without comparison; (7) `practitioner_next_steps()`. @@ -196,9 +197,10 @@ serves a different purpose: R-parity accuracy). They complement it. CallawaySantAnna(control_group="never_treated", estimation_method="reg", n_bootstrap=199).fit( panel, outcome="y_mean", unit="state", time="year", - first_treat="first_treat", survey_design=stage2, aggregate="all", + first_treat="first_treat", survey_design=stage2, ) - compute_honest_did(results, method="relative_magnitude", M=[0.5, 1.0, 1.5]) + es = results.aggregate("event_study") + compute_honest_did(es, method="relative_magnitude", M=[0.5, 1.0, 1.5]) ``` - **Operation chain.** (1) `aggregate_survey()` - the microdata-to-panel collapse; (2) CS fit with the second-stage SurveyDesign returned by @@ -305,7 +307,7 @@ serves a different purpose: R-parity accuracy). They complement it. ``` - **Operation chain.** (1) ContinuousDiD fit - produces overall ATT, overall ACRT, and the dose-response curves - unconditionally (the fit-time `aggregate=` kwarg is deprecated, row + unconditionally (`fit()` takes no `aggregate=` kwarg since 4.0, row M-025); (2) extract `results.to_dataframe(level="dose_response")` and `level="group_time"`; (3) a second analytical ContinuousDiD fit followed by diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index b64157b93..ebe037023 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -212,7 +212,7 @@ Staggered Adoption Issues # Use bootstrap for better inference. Post-fit aggregation works on # BOOTSTRAPPED fits too: results.aggregate('event_study') replays the # fit-time multiplier bootstrap from the retained RNG state (percentile - # inference - no refit and no deprecated fit-time aggregate= needed). + # inference - no refit needed; fit() takes no aggregate= keyword). cs = CallawaySantAnna(n_bootstrap=999) results = cs.fit(data, outcome='y', unit='unit_id', time='period', first_treat='first_treat') diff --git a/docs/tutorials/02_staggered_did.ipynb b/docs/tutorials/02_staggered_did.ipynb index c605038b8..db2043a4f 100644 --- a/docs/tutorials/02_staggered_did.ipynb +++ b/docs/tutorials/02_staggered_did.ipynb @@ -1177,7 +1177,7 @@ "source": [ "# Event study with bootstrap confidence intervals: post-fit aggregation\n", "# replays the fit-time multiplier bootstrap (percentile inference), so no\n", - "# refit and no deprecated fit-time aggregate= are needed.\n", + "# refit is needed (fit() takes no aggregate= keyword since 4.0).\n", "es_boot = results_boot.aggregate('event_study')\n", "\n", "print(\"\\nEvent Study with Bootstrap Inference:\")\n", diff --git a/docs/tutorials/14_continuous_did.ipynb b/docs/tutorials/14_continuous_did.ipynb index 8f1a5219c..1f79ae3f9 100644 --- a/docs/tutorials/14_continuous_did.ipynb +++ b/docs/tutorials/14_continuous_did.ipynb @@ -177,7 +177,7 @@ "source": [ "## Basic Estimation\n", "\n", - "The `ContinuousDiD` estimator follows an sklearn-like API. Call `fit()` with column names for outcome, unit, time, first treatment period, and dose. The dose-response curve and global summary parameters are always computed (the fit-time `aggregate=` keyword is deprecated; aggregation is a post-fit step via `results.aggregate(...)`)." + "The `ContinuousDiD` estimator follows an sklearn-like API. Call `fit()` with column names for outcome, unit, time, first treatment period, and dose. The dose-response curve and global summary parameters are always computed; aggregation is a post-fit step via `results.aggregate(...)` (`fit()` takes no `aggregate=` keyword since 4.0)." ] }, { @@ -317,7 +317,7 @@ "\n", "Event study works best with multiple cohorts (to have richer variation in event-time). A single cohort can still produce event-time estimates, but diagnostics are more informative with multiple cohorts. We generate a new dataset with two treatment cohorts.\n", "\n", - "The event study is a **post-fit** aggregation: call `results.aggregate(\"event_study\")` on an analytical fit (the fit-time `aggregate=\"eventstudy\"` keyword is deprecated). On a bootstrapped fit the post-fit event study raises - re-fit with `n_bootstrap=0`, or keep the deprecated fit-time keyword until 4.0 if you need bootstrap event-study inference." + "The event study is a **post-fit** aggregation: call `results.aggregate(\"event_study\")`. On an analytical fit it recomputes the binarized event study from the retained kit; on a bootstrapped fit (`n_bootstrap > 0`) the percentile event study is computed at fit and `aggregate(\"event_study\")` relays it (4.0). The 3.x fit-time `aggregate=\"eventstudy\"` keyword was removed at 4.0." ] }, { @@ -646,7 +646,7 @@ "\n", "- **`level=\"dose_response\"`** (default): The dose-response curve with ATT(d) and ACRT(d)\n", "- **`level=\"group_time\"`**: Underlying group-time cell estimates\n", - "- **`level=\"event_study\"`**: Event study effects (legacy surface - populated only by the deprecated fit-time `aggregate=\"eventstudy\"`; prefer the post-fit `results.aggregate(\"event_study\")` container and its `to_dataframe()`)" + "- **`level=\"event_study\"`**: Event study effects (legacy surface - populated only on bootstrapped fits since 4.0; prefer the post-fit `results.aggregate(\"event_study\")` container and its `to_dataframe()`)" ] }, { @@ -697,7 +697,7 @@ "| `n_bootstrap` | 0 | Bootstrap iterations (0 = analytical SEs only) |\n", "| `bootstrap_weights` | `\"rademacher\"` | `\"rademacher\"`, `\"mammen\"`, or `\"webb\"` |\n", "| `seed` | `None` | Random seed for reproducibility |\n", - "| `aggregate` (fit) | deprecated | Fit-time aggregation is deprecated (row M-025); use post-fit `results.aggregate(\"event_study\"/\"dose\"/\"simple\")` - the dose curves are always computed |\n", + "| `aggregate` (fit) | removed at 4.0 | Fit-time aggregation was removed (row M-025); use post-fit `results.aggregate(\"event_study\"/\"dose\"/\"simple\")` - the dose curves are always computed |\n", "\n", "### Reference\n", "\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index b593bb007..570de0b1d 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -237,12 +237,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/staggered.py, diff_diff/staggered_results.py, diff_diff/staggered_bootstrap.py, diff_diff/bootstrap_utils.py, diff_diff/bootstrap_chunking.py, diff_diff/aggregation.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, diff_diff/diagnostic_report.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via a sentinel default (so a plain fit() never warns) and still returns the fully populated legacy surface; results.aggregate(type=) is the successor. balance_e moves alongside it as its own row [M-117] - it was previously tracked only as prose here, which nothing asserted. VOCABULARY: the closed set is library-wide (simple|event_study|group|calendar|total - 'total' promoted 2026-08-16 as the estimator-owned total incremental outcome, an exact relay C x overall over the finite-masked complete-case support); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group|total - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported; 'total' is panel/non-survey only (RC-routed, declared-survey_design, divergent bare-cluster, and pre-upgrade-kit fits raise NotImplementedError naming the reason; the mass replays from the fit-time agg_gt_cells/is_survey_fit kit snapshots). BOOTSTRAP fits: 'simple' and, where supported, 'total' RELAY the stored overall quintet verbatim (percentile se/p/CI beside the finite safe_inference t; 'total' scales att/se/CI by C) with a NaN df column - the relay levels never re-warn; the recompute levels (event_study/group) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (the fit-captured RNG state + run params BY VALUE, so seed=None fits replay and post-fit set_params/attribute mutation cannot alter it; pickles carry it): se/CI/cband match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the discrete percentile P-VALUE is a count statistic carved out of that claim (compared at 2/n_bootstrap), and the container publishes NO analytical provenance (df cleared; vcov/vcov_index are the covariance of the replayed bootstrap draws - diagonal = bootstrap se**2 - or None when n_bootstrap is at or below the number of covered horizons or a draw is non-finite; sup-t cband recomputed from the replayed draws). Each replaying call regenerates the full weight stream and re-runs the fused perturbation GEMM over the per-cell + per-event-time influence columns - O(n_bootstrap x n_units x (n_gt + n_event_times)) FLOPs per call, no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (never suppresses; per-site Python warning-registry semantics govern re-display). BACKEND GUARD: the spec stamps the weight-generation backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified/single-PSU survey generation and census-FPC zero weights); Rust and NumPy produce DIFFERENT draws from the same bit-generator state, so a replay under a different backend (DIFF_DIFF_BACKEND flip, missing extension, another machine) fails closed naming both backends rather than silently desynchronizing from the artifact's stored relay quintet. Legacy pickles without the spec fail closed with a refit message. The per-level policy converged with [M-027]; the SDDD engine keeps its own fit-time override-loop copy (unification sequenced with the M-014 container port - twin-drift risk on record). DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits BOOTSTRAPPED INCLUDED (percentile replay; parallel_trends runs the joint Wald test and pretrends_power/sensitivity read the full covariance - the derived container carries the bootstrap-draw covariance as vcov; the Bonferroni/diagonal fallbacks remain where none is stored); derivation failures (kit-less/legacy pickles, backend mismatch, sibling estimators' gates) still surface as explicit skip reasons." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via a sentinel default (so a plain fit() never warns) and still returns the fully populated legacy surface; results.aggregate(type=) is the successor. balance_e moves alongside it as its own row [M-117] - it was previously tracked only as prose here, which nothing asserted. VOCABULARY: the closed set is library-wide (simple|event_study|group|calendar|total - 'total' promoted 2026-08-16 as the estimator-owned total incremental outcome, an exact relay C x overall over the finite-masked complete-case support); CallawaySantAnna's SUPPORTED SUBSET is simple|event_study|group|total - it has no calendar aggregator (the DEFERRED 'Calendar-time aggregation' row), and aggregate('calendar') raises naming what is supported; 'total' is panel/non-survey only (RC-routed, declared-survey_design, divergent bare-cluster, and pre-upgrade-kit fits raise NotImplementedError naming the reason; the mass replays from the fit-time agg_gt_cells/is_survey_fit kit snapshots). BOOTSTRAP fits: 'simple' and, where supported, 'total' RELAY the stored overall quintet verbatim (percentile se/p/CI beside the finite safe_inference t; 'total' scales att/se/CI by C) with a NaN df column - the relay levels never re-warn; the recompute levels (event_study/group) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (the fit-captured RNG state + run params BY VALUE, so seed=None fits replay and post-fit set_params/attribute mutation cannot alter it; pickles carry it): se/CI/cband match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the discrete percentile P-VALUE is a count statistic carved out of that claim (compared at 2/n_bootstrap), and the container publishes NO analytical provenance (df cleared; vcov/vcov_index are the covariance of the replayed bootstrap draws - diagonal = bootstrap se**2 - or None when n_bootstrap is at or below the number of covered horizons or a draw is non-finite; sup-t cband recomputed from the replayed draws). Each replaying call regenerates the full weight stream and re-runs the fused perturbation GEMM over the per-cell + per-event-time influence columns - O(n_bootstrap x n_units x (n_gt + n_event_times)) FLOPs per call, no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (never suppresses; per-site Python warning-registry semantics govern re-display). BACKEND GUARD: the spec stamps the weight-generation backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified/single-PSU survey generation and census-FPC zero weights); Rust and NumPy produce DIFFERENT draws from the same bit-generator state, so a replay under a different backend (DIFF_DIFF_BACKEND flip, missing extension, another machine) fails closed naming both backends rather than silently desynchronizing from the artifact's stored relay quintet. Legacy pickles without the spec fail closed with a refit message. The per-level policy converged with [M-027]; the SDDD engine keeps its own fit-time override-loop copy (unification sequenced with the M-014 container port - twin-drift risk on record). DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits BOOTSTRAPPED INCLUDED (percentile replay; parallel_trends runs the joint Wald test and pretrends_power/sensitivity read the full covariance - the derived container carries the bootstrap-draw covariance as vcov; the Bonferroni/diagonal fallbacks remain where none is stored); derivation failures (kit-less/legacy pickles, backend mismatch, sibling estimators' gates) still surface as explicit skip reasons. AT 4.0 (PR 3): fit() computes no ES/group surface; the legacy event_study_effects/group_effects fields are None on EVERY fit (cband_crit_value, event_study_vcov/_index/_df None too); the post-fit replay is unchanged; to_dataframe(level='event_study'|'group') raises on every fresh fit (use aggregate(...).to_dataframe(); the column-schema rewire is M-093's). Positional slot shift: survey_design 9 -> 7." - id: M-021 kind: param group: aggregate-postfit @@ -251,12 +251,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/imputation.py, diff_diff/imputation_aggregation.py, diff_diff/imputation_results.py, diff_diff/imputation_bootstrap.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, diff_diff/diagnostic_report.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-070]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the FINITE-tau complete-case support snapshot 'total_support', stashed at kit build alongside the panel-backed refs BECAUSE the kit's df is a live _fit_data reference - the one non-ref value snapshot this row's zero-marginal-memory claim gains; pickles still unchanged via memoization since the stash is a float; declared-survey_design fits and pre-upgrade kits missing the stash raise NotImplementedError); calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (the per-target psi machinery makes replay tractable - a TODO row). The prior fail-closed-for-ALL-levels uniform-parity decision was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale - never publish analytical provenance beside percentile inference - is honored by the NaN df column. Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons)." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-118], warn-and-still-work; the imputation_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-070]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary - a behavior improvement). The successor is a PANEL-BACKED lazy recompute kit: ES/group aggregation is a target-specific Theorem-3 recompute from the working panel + untreated FE model (no compact influence payload can honor a different balance_e), so the kit's bookkeeping holds REFERENCES to the SAME per-fit objects self._fit_data already retains for pretrend_test() - ZERO marginal memory and unchanged pickles via memoization (the _estimator_ref field already ships the panel; enumeration in the REGISTRY ImputationDiD Note). Value snapshots (treatment_groups copy, config scalars, a dataclasses.replace copy of survey_metadata, and the survey_df_seed/survey_df_final df channels) isolate recompute and the ES carrier from public-field mutation; aggregate() reads NOTHING mutable off the results object except the deliberate overall-quintet relay and the bootstrap_results gate. SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the FINITE-tau complete-case support snapshot 'total_support', stashed at kit build alongside the panel-backed refs BECAUSE the kit's df is a live _fit_data reference - the one non-ref value snapshot this row's zero-marginal-memory claim gains; pickles still unchanged via memoization since the stash is a float; declared-survey_design fits and pre-upgrade kits missing the stash raise NotImplementedError); calendar/'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (the per-target psi machinery makes replay tractable - a TODO row). The prior fail-closed-for-ALL-levels uniform-parity decision was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale - never publish analytical provenance beside percentile inference - is honored by the NaN df column. Replicate-weight fits REPLAY the extracted _replicate_override_aggregates with a LEVEL-MATCHED stack ([overall, ES] or [overall, groups]): compute_replicate_refit_variance validates replicates jointly, so aggregate(L) reproduces fit(aggregate=L) exactly and a fit(aggregate='all') surface is NOT the equivalence target when a replicate NaNs on one family's targets; the same joint-stack coupling means migrating a replicate fit from fit(aggregate=) to plain fit changes the OVERALL row's se/CI/df on degenerate designs (documented migration delta, CHANGELOG + REGISTRY note (e) + a contract-test pin). pretrends=True + replicate: post-fit aggregate('event_study') raises the same NotImplementedError the fit-time gate raises (per-replicate lead refits unimplemented); group/simple still work. Simple relay: n = n_treated_obs with n_kind='obs' (the treated/control unit sets OVERLAP, so the CS/EDiD disjoint-units convention cannot apply - the StackedDiD carve-out class); df = the survey_df_final snapshot (what the stored overall inference received). Group rows record per-row df_used at each safe_inference call (additive row-dict key; the replicate override rewrites it, the bootstrap override clears it, the all-NaN cohort branch writes no key - consumers read via .get). The M-127 df_convention inert-config warning predicate is REVISED to reachability (pretrends AND not-replicate AND (deprecated fit-time ES/all supplied OR n_bootstrap <= 0)) because post-fit aggregate() made the old aggregate-keyed claim false; reachability-BASED, not exact - a fit whose bootstrap later fails (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously (recorded on M-127 too). Container admission NOT widened: ImputationDiD is rejected BY DESIGN (no joint ES covariance - per-horizon conservative SEs only; see M-093). balance_e moves as its own row [M-118]. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons). AT 4.0 (PR 3): fits with n_bootstrap > 0 ALWAYS compute the event-study AND group surfaces at fit (balance_e=None - exactly what fit(aggregate='all') computed), store them in the legacy event_study_effects/group_effects fields, and aggregate('event_study'|'group') RELAYS the stored surface (kit-snapshot alpha/anticipation/survey metadata; NaN df column, vcov None) - the recompute levels no longer fail closed on bootstrapped fits; balance_e is not None on a bootstrapped fit raises NotImplementedError [M-118]. Analytic fits are unchanged (legacy fields None, kit recompute on demand). STORAGE RULE: the legacy fields hold a surface IFF the fit carries bootstrap inference (bootstrap_results is not None); a bootstrapped fit that ends analytical (n_valid == 0, total bootstrap failure) clears them and aggregate() recomputes analytically; a fit WITH bootstrap inference and no stored surface (pre-4.0 pickle, fail-soft drop) raises the shared absent-surface ValueError. Materialization is FAIL-SOFT with a one-shot overall-only retry (any exception while building the surfaces, or a bootstrap attempt that raises/returns None with surfaces present, leaves them None, warns once 'could not be materialized', and re-runs the bootstrap overall-only - on a seeded fit the retry reproduces attempt 1's weight draws), so the headline bootstrap quintet and the fit's success/abort outcome are unchanged by the removal. The pretrends+replicate fit-time gate was deleted (unreachable: replicate + bootstrap already raises earlier; the analytic combination is caught post-fit); the df_convention reachability predicate is now pretrends AND not replicate [M-127]. The aggregators' own UserWarnings and the per-target bootstrap-stats RuntimeWarnings now fire on bootstrapped plain fits; summary() prints and to_dataframe(level=) returns the ES/group tables there; DiagnosticReport/BusinessReport run on the relayed surface. Known limitation: a 3.9-3.13 bootstrapped pickle fitted with balance_e=k stores a balance_e-RESTRICTED surface and the relay returns it as stored. Positional slot shift: survey_design 9 -> 7." - id: M-022 kind: param group: aggregate-postfit @@ -265,12 +265,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/two_stage.py, diff_diff/two_stage_aggregation.py, diff_diff/two_stage_results.py, diff_diff/two_stage_bootstrap.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, diff_diff/diagnostic_report.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-071]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the post-filter treatment-indicator support - omega_1_mask minus non-finite _y_tilde rows, read from the kit's private column-subset frame COPY, so no fit-time stash is needed; declared-survey_design fits raise NotImplementedError); calendar/'all' fail closed; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform fail-closed decision superseded 2026-08-05 with the [M-027] per-level convergence; a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons)." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-119], warn-and-still-work; the two_stage_did wrapper forwards the sentinel so a plain wrapper call never fires the aggregate warning; since 3.9 the wrapper itself warns per [M-071]). NO fit-time value validation existed and none is added (unknown strings silently act like None; the post-fit successor fails closed via the mixin vocabulary). The successor is a PANEL-BACKED lazy recompute kit: each level is a fresh Stage-2 OLS + joint Gardner-GMM sandwich, so the kit retains a COLUMN-SUBSET COPY of the working frame (only the columns the moved methods read by name, deduplicated - cluster= may legally name the unit/time/first_treat column) plus the Stage-1 FE model, masks, and survey objects. MEMORY CONTRACT: this is the FIRST panel retention on TwoStageDiD results - O(n_obs) incl. unit/time/cluster identifier columns on every results object and pickle, and replicate designs additionally retain the (n_obs x R) replicate matrix via resolved_survey; the CS/EDiD identifier-minimization guarantee deliberately does NOT hold (a store_kit opt-out is a DEFERRED row). score_pad_mask/cluster_ids_full are stored as the Wave-E.3-GATED values fit actually passed. Value snapshots (treatment_groups copy, overall_att, survey_df_stage2/survey_df_final, a dataclasses.replace copy of survey_metadata) isolate recompute and the ES carrier from public-field mutation. SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the post-filter treatment-indicator support - omega_1_mask minus non-finite _y_tilde rows, read from the kit's private column-subset frame COPY, so no fit-time stash is needed; declared-survey_design fits raise NotImplementedError); calendar/'all' fail closed; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored percentile quintet verbatim (finite safe_inference t included) with a NaN df column, while the recompute levels fail closed (per-level GMM scores are function-locals; replay is a TODO row) - the prior uniform fail-closed decision superseded 2026-08-05 with the [M-027] per-level convergence; a fit whose bootstrap FAILED (bootstrap_results=None, analytical inference retained) aggregates normally. Replicate-weight fits REPLAY the extracted _replay_replicate_inference with a LEVEL-MATCHED stack - aggregate(L) reproduces fit(aggregate=L) exactly; fit(aggregate='all') is NOT the equivalence target on degenerate designs, and the same joint-stack coupling makes the migration to plain fit change the OVERALL row's se/CI/df there (documented migration delta). Post-fit aggregate('event_study') reproduces the M-092 container contract exactly: analytical fits thread the recomputed joint vcov + vcov_index + the finite-and->0 df scalar through the carrier; replicate fits thread vcov=None/index=None with the REPLAYED level-matched df. Simple relay: n = n_treated_obs with n_kind='obs' (overlapping unit sets - StackedDiD carve-out class); df = the survey_df_final snapshot (on replicate fits that value came from the [overall]-only stack - snapshotted, never re-derived). Group relay df is a SCALAR broadcast (deliberate divergence from ImputationDiD's per-row df_used: _stage2_group passes one immutable survey_df to every row, so the scalar is provenance-exact by construction and the moved method stays verbatim). Container admission NOT widened: DEFERRED pending a normalization derivation - analytical surfaces carry the real joint Gardner-GMM covariance (M-092), but pre-period coefficients are stage-1 residual means, not reference-normalized contrasts, while HonestDiD's Delta arithmetic hard-codes delta_0=0 (see M-093 + the DEFERRED.md paper-gated row). balance_e moves as its own row [M-119]. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons). AT 4.0 (PR 3): fits with n_bootstrap > 0 ALWAYS compute the event-study AND group surfaces at fit (balance_e=None - exactly what fit(aggregate='all') computed), store them in the legacy event_study_effects/group_effects fields, and aggregate('event_study'|'group') RELAYS the stored surface (kit-snapshot alpha/anticipation/survey metadata; NaN df column, vcov None) - the recompute levels no longer fail closed on bootstrapped fits; balance_e is not None on a bootstrapped fit raises NotImplementedError [M-119]. Analytic fits are unchanged (legacy fields None, kit recompute on demand). STORAGE RULE: the legacy fields hold a surface IFF the fit carries bootstrap inference (bootstrap_results is not None); a bootstrapped fit that ends analytical (n_valid == 0, total bootstrap failure) clears them and aggregate() recomputes analytically; a fit WITH bootstrap inference and no stored surface (pre-4.0 pickle, fail-soft drop) raises the shared absent-surface ValueError. Materialization is FAIL-SOFT with a one-shot overall-only retry (any exception while building the surfaces, or a bootstrap attempt that raises/returns None with surfaces present, leaves them None, warns once 'could not be materialized', and re-runs the bootstrap overall-only - on a seeded fit the retry reproduces attempt 1's weight draws), so the headline bootstrap quintet and the fit's success/abort outcome are unchanged by the removal. The pretrends+replicate fit-time gate was deleted (unreachable: replicate + bootstrap already raises earlier; the analytic combination is caught post-fit); the df_convention reachability predicate is now pretrends AND not replicate [M-127]. The aggregators' own UserWarnings and the per-target bootstrap-stats RuntimeWarnings now fire on bootstrapped plain fits; summary() prints and to_dataframe(level=) returns the ES/group tables there; DiagnosticReport/BusinessReport run on the relayed surface. Known limitation: a 3.9-3.13 bootstrapped pickle fitted with balance_e=k stores a balance_e-RESTRICTED surface and the relay returns it as stored. Positional slot shift: survey_design 9 -> 7." - id: M-023 kind: param group: aggregate-postfit @@ -279,12 +279,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/efficient_did.py, diff_diff/efficient_did_aggregation.py, diff_diff/efficient_did_bootstrap.py, diff_diff/efficient_did_results.py, diff_diff/aggregation.py, diff_diff/bootstrap_utils.py, diff_diff/bootstrap_chunking.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-120], warn-and-still-work since the params genuinely worked). NO fit-time value validation existed and none is added: unknown strings silently act like None on the deprecated path, unchanged; the post-fit successor fails closed on unknown types via the mixin vocabulary - a behavior improvement. The successor is a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a view relay): fit() computes nothing extra, the results object retains an AggregationKit referencing the per-(g,t) EIF dict (O(n_units x n_gt) dominant payload; full buffer enumeration in the REGISTRY EfficientDiD Note) plus O(n_units) bookkeeping, PRIVATE per-row snapshots of group_time_effects/groups/time_periods plus the pt_assumption/alpha/anticipation/n-total provenance (recompute and the ES carrier never read the mutable public fields), and the POST-OVERALL df_survey snapshot (captured before the ES/group gates - the group pass can degenerate the working df on replicate designs, and every aggregation seeds from the post-overall value), and aggregate('event_study'/'group', balance_e=) recomputes on demand while 'simple' relays the stored overall row bit-exact (n = treated+control units - disjoint by construction; df = the snapshot, provenance-exact where survey_metadata.df_survey can diverge in the degenerate n_valid<=1 replicate state). SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the integer sum of kept post-anticipation finite cells' n_treated from the kit's deep-copied group_time_effects snapshot - never the non-integral n_units x sum(cohort_fractions) float product; fits declaring ANY survey_design - the kit's unit_level_weights/resolved_survey_unit markers, weight-type-agnostic - raise NotImplementedError); calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the recompute levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (fit-captured RNG state + run params BY VALUE - seed=None fits replay, post-fit set_params/attribute mutation cannot alter it, pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the percentile P-VALUE is a count statistic compared at 2/n_bootstrap, and the replay publishes no analytical provenance (percentile overrides + all-NaN df channels; EDiD has no cband). The ES/group percentile overrides are the SHARED appliers in bootstrap_utils (apply_bootstrap_event_study_overrides/apply_bootstrap_group_overrides, relocated from staggered_bootstrap - one implementation for the CS and EDiD fit paths and replays; verified bit-inert at the swap). Per replaying call the engine regenerates the full weight stream and re-runs the fused perturbation GEMM over the n_gt per-cell EIF columns - O(n_bootstrap x n_units x n_gt) FLOPs, ES/group targets re-aggregated cheaply from the dense (n_bootstrap, n_gt) matrix - no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (re-emits, never suppresses). BACKEND GUARD (the M-020 contract): the spec stamps the weight backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified survey generation, census-FPC zero weights, and the single-PSU degenerate early return, which is stamped before any generation); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. FRACTIONAL-PERIOD KEY FIX shipped with the replay: the bootstrap ES prep now keys horizons by the analytical int(t - g) truncation at all three sites (the two horizon keys AND the balance_e anchor filter) - previously raw t - g attached a strict sub-aggregate's percentile inference to the pooled analytical row on fractional panels; n_groups now counts DISTINCT cohorts per bucket (identity on integer panels) and fractional truncation emits a UserWarning (see the REGISTRY truncation Note). The prior uniform-conservatism BY-DECISION rule (no level publishes analytical-provenance fields beside percentile inference) was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale is honored by the percentile overrides and NaN df channels. Group rows record per-row df_used at each safe_inference call (additive public row-dict key; the fit-time bootstrap override clears it - CS precedent); the post-fit group relay publishes the per-row array, a stated divergence from CS's conservative-min scalar broadcast. PT-Post reference provenance: the membership-gated reference_period property (SunAbraham rule - never synthesized when the anchor cell was not estimated) marks the materialized mechanical zero anchor is_reference in the container, and plot_event_study's inferred reference correctly shifts to -1-anticipation on PT-Post anticipation>0 fits. store_eif now governs only the public influence_functions field - the kit ALWAYS retains the EIF dict (memory-contract change; a store_kit opt-out is a DEFERRED row). Container admission NOT widened to EfficientDiD (see M-093); balance_e moves as its own row [M-120]." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style joint warning with balance_e [M-120], warn-and-still-work since the params genuinely worked). NO fit-time value validation existed and none is added: unknown strings silently act like None on the deprecated path, unchanged; the post-fit successor fails closed on unknown types via the mixin vocabulary - a behavior improvement. The successor is a LAZY RECOMPUTING KIT (the CallawaySantAnna class, not a view relay): fit() computes nothing extra, the results object retains an AggregationKit referencing the per-(g,t) EIF dict (O(n_units x n_gt) dominant payload; full buffer enumeration in the REGISTRY EfficientDiD Note) plus O(n_units) bookkeeping, PRIVATE per-row snapshots of group_time_effects/groups/time_periods plus the pt_assumption/alpha/anticipation/n-total provenance (recompute and the ES carrier never read the mutable public fields), and the POST-OVERALL df_survey snapshot (captured before the ES/group gates - the group pass can degenerate the working df on replicate designs, and every aggregation seeds from the post-overall value), and aggregate('event_study'/'group', balance_e=) recomputes on demand while 'simple' relays the stored overall row bit-exact (n = treated+control units - disjoint by construction; df = the snapshot, provenance-exact where survey_metadata.df_survey can diverge in the degenerate n_valid<=1 replicate state). SUPPORTED SUBSET simple|event_study|group|total ('total' since 2026-08-16: exact relay C x overall with C = the integer sum of kept post-anticipation finite cells' n_treated from the kit's deep-copied group_time_effects snapshot - never the non-integral n_units x sum(cohort_fractions) float product; fits declaring ANY survey_design - the kit's unit_level_weights/resolved_survey_unit markers, weight-type-agnostic - raise NotImplementedError); calendar and 'all' fail closed via the mixin; weights= rejected. Bootstrap fits: 'simple' and 'total' RELAY the stored overall row verbatim (percentile se/p/CI beside the finite safe_inference t) with a NaN df column and never re-warn, while the recompute levels (ES/group, balance_e included) REPLAY the fit-time multiplier bootstrap from the kit's BootstrapReplaySpec (fit-captured RNG state + run params BY VALUE - seed=None fits replay, post-fit set_params/attribute mutation cannot alter it, pickles carry it): se/CI/t match a fit-time aggregation to BLAS reassociation (~1 ULP, assert_allclose - never bit-identity), the percentile P-VALUE is a count statistic compared at 2/n_bootstrap, and the replay publishes no analytical provenance (percentile overrides + all-NaN df channels; EDiD has no cband). The ES/group percentile overrides are the SHARED appliers in bootstrap_utils (apply_bootstrap_event_study_overrides/apply_bootstrap_group_overrides, relocated from staggered_bootstrap - one implementation for the CS and EDiD fit paths and replays; verified bit-inert at the swap). Per replaying call the engine regenerates the full weight stream and re-runs the fused perturbation GEMM over the n_gt per-cell EIF columns - O(n_bootstrap x n_units x n_gt) FLOPs, ES/group targets re-aggregated cheaply from the dense (n_bootstrap, n_gt) matrix - no memoization (immutability discipline; DR caches its derived surface once per report). The replay re-runs the same warning sites (re-emits, never suppresses). BACKEND GUARD (the M-020 contract): the spec stamps the weight backend at capture ('rust'/'numpy' per bootstrap_chunking.effective_weight_backend, or 'portable' for provably backend-independent branches - stratified survey generation, census-FPC zero weights, and the single-PSU degenerate early return, which is stamped before any generation); a replay under a different backend fails closed naming both, and legacy pickles without the spec fail closed with a refit message. FRACTIONAL-PERIOD KEY FIX shipped with the replay: the bootstrap ES prep now keys horizons by the analytical int(t - g) truncation at all three sites (the two horizon keys AND the balance_e anchor filter) - previously raw t - g attached a strict sub-aggregate's percentile inference to the pooled analytical row on fractional panels; n_groups now counts DISTINCT cohorts per bucket (identity on integer panels) and fractional truncation emits a UserWarning (see the REGISTRY truncation Note). The prior uniform-conservatism BY-DECISION rule (no level publishes analytical-provenance fields beside percentile inference) was superseded 2026-08-05 with the [M-027] per-level convergence; its rationale is honored by the percentile overrides and NaN df channels. Group rows record per-row df_used at each safe_inference call (additive public row-dict key; the fit-time bootstrap override clears it - CS precedent); the post-fit group relay publishes the per-row array, a stated divergence from CS's conservative-min scalar broadcast. PT-Post reference provenance: the membership-gated reference_period property (SunAbraham rule - never synthesized when the anchor cell was not estimated) marks the materialized mechanical zero anchor is_reference in the container, and plot_event_study's inferred reference correctly shifts to -1-anticipation on PT-Post anticipation>0 fits. store_eif now governs only the public influence_functions field - the kit ALWAYS retains the EIF dict (memory-contract change; a store_kit opt-out is a DEFERRED row). Container admission NOT widened to EfficientDiD (see M-093); balance_e moves as its own row [M-120]. AT 4.0 (PR 3): fit() computes no ES/group surface; the legacy event_study_effects/group_effects/event_study_df fields are None on EVERY fit; the post-fit replay is unchanged; to_dataframe(level='event_study'|'group') raises on every fresh fit. Positional slot shifts: survey_design 9 -> 7, store_eif 10 -> 8." - id: M-024 kind: param group: aggregate-postfit @@ -293,12 +293,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/stacked_did.py, diff_diff/stacked_did_results.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/diagnostic_report.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style warn-and-still-work, since the param genuinely worked, unlike dCDH's raise; group/all + unknown values keep their ValueErrors AFTER the warning). The event-study surface is now ALWAYS computed at fit - the pooled regression always included the interactions, and the extraction + per-event Bell-McCaffrey contrast DOFs (hc2_bm) are no longer gated on fit-time aggregate, so the param is behaviorally inert; consequences: plain-fit summary() prints the ES table, to_dataframe('event_study') is total on 3.9+ fits, DiagnosticReport's heterogeneity and (kappa_pre>=2) parallel_trends checks now run on plain fits, and the hc2_bm overall contrast shares the m>1 batch-relative noise floor (fail-closed + warned; REGISTRY Note). Both aggregate() levels are pure VIEWS: 'event_study' -> build_event_study_surface(self); 'simple' relays overall_att/se/t/p/CI bit-exact with target='att' (the scalar is the post-average, NOT the per-event trimmed aggregate ATT), n=n_treated_units/'units' (treated-only scope: control units overlap treated across sub-experiments, so no disjoint total exists), df=inference_df. SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; StackedDiD is STAGED OUT of totals - estimand ill-defined under weighting= variants, the DEFERRED remainder); balance_e applies to no level (empty vocabulary); weights= rejected. Every inference mode relays (survey TSL, replicate refit, hc2_bm fail-close). honest/pretrends container admission widened to Stacked-sourced containers (see M-093): base_period='universal' ClassVar + singleton reference_event_times property thread the provenance; analytical fits carry df_survey=None (the resolver reads df_inference, a name Stacked does not use - deliberate: normal-theory FLCI, see the REGISTRY Note and the TODO adapter-naming row's cross-reference)." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns - CS-style warn-and-still-work, since the param genuinely worked, unlike dCDH's raise; group/all + unknown values keep their ValueErrors AFTER the warning). The event-study surface is now ALWAYS computed at fit - the pooled regression always included the interactions, and the extraction + per-event Bell-McCaffrey contrast DOFs (hc2_bm) are no longer gated on fit-time aggregate, so the param is behaviorally inert; consequences: plain-fit summary() prints the ES table, to_dataframe('event_study') is total on 3.9+ fits, DiagnosticReport's heterogeneity and (kappa_pre>=2) parallel_trends checks now run on plain fits, and the hc2_bm overall contrast shares the m>1 batch-relative noise floor (fail-closed + warned; REGISTRY Note). Both aggregate() levels are pure VIEWS: 'event_study' -> build_event_study_surface(self); 'simple' relays overall_att/se/t/p/CI bit-exact with target='att' (the scalar is the post-average, NOT the per-event trimmed aggregate ATT), n=n_treated_units/'units' (treated-only scope: control units overlap treated across sub-experiments, so no disjoint total exists), df=inference_df. SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; StackedDiD is STAGED OUT of totals - estimand ill-defined under weighting= variants, the DEFERRED remainder); balance_e applies to no level (empty vocabulary); weights= rejected. Every inference mode relays (survey TSL, replicate refit, hc2_bm fail-close). honest/pretrends container admission widened to Stacked-sourced containers (see M-093): base_period='universal' ClassVar + singleton reference_event_times property thread the provenance; analytical fits carry df_survey=None (the resolver reads df_inference, a name Stacked does not use - deliberate: normal-theory FLCI, see the REGISTRY Note and the TODO adapter-naming row's cross-reference). AT 4.0 (PR 3): the group/all/unknown-value ValueErrors died with the param (aggregate('group') already fails closed in the mixin); the ES surface is still always computed at fit. Positional slot shifts (aggregate sat BEFORE them): population 7 -> 6, survey_design 8 -> 7, covariates 9 -> 8." - id: M-025 kind: param group: aggregate-postfit @@ -307,12 +307,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/continuous_did.py, diff_diff/continuous_did_aggregation.py, diff_diff/continuous_did_results.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-practitioner.txt, diff_diff/diagnostic_report.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value incl. None warns once, then the legacy routing runs unchanged). Unlike the EfficientDiD/Imputation shims, fit-time VALUE VALIDATION pre-existed and STAYS: unknown strings still raise ValueError after the warning ((None, 'dose', 'eventstudy') only). The no-underscore 'eventstudy' spelling dies with the param in 4.0; aggregate() accepts only the unified vocabulary + 'dose' as this estimator's documented extra level. MIXED VIEW/RECOMPUTE architecture (unique in the register): 'simple' and 'dose' are pure VIEWS over stored public fields - the dose curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by fit (aggregate='dose' was a fit-time no-op) - so both levels are PERMITTED on bootstrap fits (the library-wide per-level relay rule, since [M-027] converged CS/EDiD/Imputation/TwoStage onto it), relaying stored inference verbatim: the overall rows carry the FINITE safe_inference t fit stores beside percentile p/CI, the dose rows reproduce DoseResponseCurve.to_dataframe (NaN t under bootstrap), and only the df column is uniformly NaN there. 'event_study' is a PRUNED-IF-PAYLOAD kit recompute (see the AggregationKit docstring variant): per-(g,t) O(n_treated+n_control) IF-ingredient arrays + unit-level arrays + the PANEL-LEVEL resolved survey design (on replicate designs the (n_obs x R) replicate matrix rides along - a unit-level collapse was reviewed and declined for verbatim-move safety); no panel data columns and no raw unit identifiers are retained; bootstrap fits get a SCALARS-ONLY kit and the ES route fails closed (NotImplementedError naming the fit-time route / n_bootstrap=0 refit; ContinuousDiDResults has no bootstrap_results field, so the config gate is the honest one). Replicate-weight designs ARE supported post-fit (IF-based compute_replicate_if_variance - no refit replay, the contrast with M-021/M-022). Fit-faithful quirk: empty-post_gt fits leave ES rows at NaN inference on both routes (has_post_cells flag). CONTAINER SHAPES: simple = 2 rows (targets att/acrt - the dual-estimand case the AggregationResult target column exists for; n = disjoint treated+control units total, n_kind='units'); dose = 2N target-discriminated rows (labels = the dose grid twice; n NaN / n_kind None / weight None - grid evaluation points carry no count or mass); df from the stored dose_response_att.df_survey channel (finite-and->0 else NaN; the raw stored value incl. the replicate 0-sentinel feeds the to_dataframe-exact t/p derivation). Ships the FIRST heterogeneous-target container, with the AggregationResult summary()/to_dataframe rendering amendment (target column + neutral estimate heading when targets mixed; FIRST-APPEARANCE target-block ordering, labels ascending within block under the _sortable guard; uniform-target producers byte-stable) - normative rule in v4-design section 6. Supported subset simple|event_study|dose - group/calendar fail closed via the mixin; balance_e applies to no level (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. Admission: honest/pretrends containers rejected BY DESIGN (no joint ES covariance; bins not reference-normalized - see M-093). Bootstrap-ES post-fit replay is the TODO.md row. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons)." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value incl. None warns once, then the legacy routing runs unchanged). Unlike the EfficientDiD/Imputation shims, fit-time VALUE VALIDATION pre-existed and STAYS: unknown strings still raise ValueError after the warning ((None, 'dose', 'eventstudy') only). The no-underscore 'eventstudy' spelling dies with the param in 4.0; aggregate() accepts only the unified vocabulary + 'dose' as this estimator's documented extra level. MIXED VIEW/RECOMPUTE architecture (unique in the register): 'simple' and 'dose' are pure VIEWS over stored public fields - the dose curves and the overall binarized ATT (ATT^{loc} under PT; equals ATT^{glob} under SPT) plus ACRT^{glob} are ALWAYS computed by fit (aggregate='dose' was a fit-time no-op) - so both levels are PERMITTED on bootstrap fits (the library-wide per-level relay rule, since [M-027] converged CS/EDiD/Imputation/TwoStage onto it), relaying stored inference verbatim: the overall rows carry the FINITE safe_inference t fit stores beside percentile p/CI, the dose rows reproduce DoseResponseCurve.to_dataframe (NaN t under bootstrap), and only the df column is uniformly NaN there. 'event_study' is a PRUNED-IF-PAYLOAD kit recompute (see the AggregationKit docstring variant): per-(g,t) O(n_treated+n_control) IF-ingredient arrays + unit-level arrays + the PANEL-LEVEL resolved survey design (on replicate designs the (n_obs x R) replicate matrix rides along - a unit-level collapse was reviewed and declined for verbatim-move safety); no panel data columns and no raw unit identifiers are retained; bootstrap fits get a SCALARS-ONLY kit and the ES route fails closed (NotImplementedError naming the fit-time route / n_bootstrap=0 refit; ContinuousDiDResults has no bootstrap_results field, so the config gate is the honest one). Replicate-weight designs ARE supported post-fit (IF-based compute_replicate_if_variance - no refit replay, the contrast with M-021/M-022). Fit-faithful quirk: empty-post_gt fits leave ES rows at NaN inference on both routes (has_post_cells flag). CONTAINER SHAPES: simple = 2 rows (targets att/acrt - the dual-estimand case the AggregationResult target column exists for; n = disjoint treated+control units total, n_kind='units'); dose = 2N target-discriminated rows (labels = the dose grid twice; n NaN / n_kind None / weight None - grid evaluation points carry no count or mass); df from the stored dose_response_att.df_survey channel (finite-and->0 else NaN; the raw stored value incl. the replicate 0-sentinel feeds the to_dataframe-exact t/p derivation). Ships the FIRST heterogeneous-target container, with the AggregationResult summary()/to_dataframe rendering amendment (target column + neutral estimate heading when targets mixed; FIRST-APPEARANCE target-block ordering, labels ascending within block under the _sortable guard; uniform-target producers byte-stable) - normative rule in v4-design section 6. Supported subset simple|event_study|dose - group/calendar fail closed via the mixin; balance_e applies to no level (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. Admission: honest/pretrends containers rejected BY DESIGN (no joint ES covariance; bins not reference-normalized - see M-093). Bootstrap-ES post-fit replay is the TODO.md row. DiagnosticReport now derives the event-study surface via post-fit aggregate('event_study') when the raw field is absent, so its ES-gated checks run on plain fits (derivation failures surface as explicit skip reasons). AT 4.0 (PR 3): the 'eventstudy' spelling is gone with the param; fits with n_bootstrap > 0 ALWAYS compute the binarized event study at fit (the only route - the kit stays scalars-only there) and aggregate('event_study') RELAYS the stored surface (kit-snapshot alpha/anticipation/base_period/survey metadata; NaN df column) instead of raising; the analytic fit-time ES inference arm was deleted (dead: the surface was None whenever it ran). STORAGE RULE: the surface is stored IFF _run_bootstrap ran (post cells exist); a bootstrapped fit with no post-treatment cells clears it and the relay raises the shared absent-surface ValueError (previously NotImplementedError from the bootstrap gate). Materialization is FAIL-SOFT with a one-shot overall-only retry (warns once 'could not be materialized'; the overall/dose bootstrap quintets are unchanged on seeded fits; bootstrap failures still propagate as before). The ES aggregator's warnings now fire on bootstrapped plain fits; summary() prints and to_dataframe(level='event_study') returns the table there. Positional slot shifts: survey_design 8 -> 7, covariates 9 -> 8." - id: M-026 kind: param group: aggregate-postfit @@ -321,12 +321,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/chaisemartin_dhaultfoeuille.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/aggregation.py, diff_diff/results_base.py] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns). The param NEVER computed aggregations on dCDH - every non-None value raised 'reserved for Phase 3' NotImplementedError since introduction - so there is no legacy surface to keep populating: a non-None value now raises ValueError pointing at the post-fit route, and the kwargs-splitting chaisemartin_dhaultfoeuille() wrapper forwards the shim unchanged. The successor is a pure VIEW, not a kit recompute: aggregate('event_study') returns build_event_study_surface(self) (Phase-1 L_max=None fits return the 2-row l=1 view - NOT an error; L_max>=1 the multi-horizon l1_first_switch surface), aggregate('simple') a one-row AggregationResult relaying overall_att/se/t/p/CI bit-exact with target = _estimand_label() (DID_M / DID_1 / delta / the trends-linear first-difference label whose overall row is all-NaN by design) and estimand-aware n/n_kind (N_S switcher_cells at L_max=None; N_1 groups at L_max==1; NaN/None for the delta, which averages horizon-specific N_l). SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; no dCDH adoption); balance_e applies to no level here (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. BOOTSTRAP FITS ARE PERMITTED (the library-wide per-level relay rule, since [M-027] converged the recompute adopters' 'simple' relays onto it too) because nothing is recomputed - each row relays the STORED inference: percentile-bootstrap DID_M/DID_1 rows carry NaN df, while the L_max>=2 delta keeps analytical safe_inference with a possibly-finite survey df even under bootstrap (REGISTRY Note, Phase 2 cost-benefit delta SE), resolved by _overall_inference_df from event_study_df / the post-fit-refreshed survey_metadata.df_survey. dCDH placebo/normalized surfaces stay estimator-native; only the aggregation entry point moves. NOTE: the dCDH event-study CONTAINER is deliberately rejected by compute_honest_did/compute_pretrends_power (source-scoped admission; the l1 placebo semantics need honest_did's native dCDH branch) - see M-093." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns). The param NEVER computed aggregations on dCDH - every non-None value raised 'reserved for Phase 3' NotImplementedError since introduction - so there is no legacy surface to keep populating: a non-None value now raises ValueError pointing at the post-fit route, and the kwargs-splitting chaisemartin_dhaultfoeuille() wrapper forwards the shim unchanged. The successor is a pure VIEW, not a kit recompute: aggregate('event_study') returns build_event_study_surface(self) (Phase-1 L_max=None fits return the 2-row l=1 view - NOT an error; L_max>=1 the multi-horizon l1_first_switch surface), aggregate('simple') a one-row AggregationResult relaying overall_att/se/t/p/CI bit-exact with target = _estimand_label() (DID_M / DID_1 / delta / the trends-linear first-difference label whose overall row is all-NaN by design) and estimand-aware n/n_kind (N_S switcher_cells at L_max=None; N_1 groups at L_max==1; NaN/None for the delta, which averages horizon-specific N_l). SUPPORTED SUBSET simple|event_study - group/calendar/total fail closed via the mixin message ('total' joined the vocabulary 2026-08-16; no dCDH adoption); balance_e applies to no level here (empty _AGGREGATE_BALANCE_E_TYPES); weights= rejected. BOOTSTRAP FITS ARE PERMITTED (the library-wide per-level relay rule, since [M-027] converged the recompute adopters' 'simple' relays onto it too) because nothing is recomputed - each row relays the STORED inference: percentile-bootstrap DID_M/DID_1 rows carry NaN df, while the L_max>=2 delta keeps analytical safe_inference with a possibly-finite survey df even under bootstrap (REGISTRY Note, Phase 2 cost-benefit delta SE), resolved by _overall_inference_df from event_study_df / the post-fit-refreshed survey_metadata.df_survey. dCDH placebo/normalized surfaces stay estimator-native; only the aggregation entry point moves. NOTE: the dCDH event-study CONTAINER is deliberately rejected by compute_honest_did/compute_pretrends_power (source-scoped admission; the l1 placebo semantics need honest_did's native dCDH branch) - see M-093. AT 4.0 (PR 3): the NOT_SUPPLIED/warn_deprecated_kwarg import is gone (the helper is retained without library callers, spec section 8 rule 11). Positional slot shifts (slot 6 removed, no '*' in the signature): L_max, covariates, trends_linear, trends_nonparam, honest_did, heterogeneity, design2 and survey_design each move one slot left (7..14 -> 6..13)." - id: M-027 kind: param group: aggregate-postfit @@ -335,12 +335,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/had.py, diff_diff/had_pretests.py, diff_diff/aggregation.py, diff_diff/results_base.py, diff_diff/honest_did.py, diff_diff/pretrends.py, diff_diff/practitioner.py, diff_diff/guides/llms-full.txt, diff_diff/guides/llms.txt, diff_diff/guides/llms-practitioner.txt] - notes: "Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns once, then the legacy routing runs unchanged - the PRE-EXISTING value validation survives, so None/unknown strings still raise ValueError after the warning, and a supplied mode that mismatches the panel shape still raises the shape error). UNLIKE every prior adopter, the param is a MODE SELECTOR, not an aggregation choice: 'overall' (exactly two periods -> HeterogeneousAdoptionDiDResults, scalar WAS) vs 'event_study' (more than two -> the per-horizon HeterogeneousAdoptionDiDEventStudyResults) - mutually exclusive panel shapes, so the SENTINEL resolves by panel-shape INFERENCE (_infer_aggregate_mode: distinct time values <= 2 -> overall, else event_study), running AFTER the time/time_col alias reconciliation (rows M-035..M-039 keep both spellings live). BEHAVIOR DELTA: a plain multi-period fit() previously raised the two-period shape error and now runs the event-study mode (error -> works; no numbers change on any previously-working call). One shape error stays sentinel-reachable: the staggered last-cohort auto-filter can drop a T>2 panel below three periods post-inference (its message no longer teaches the kwarg). POST-FIT SUCCESSOR: both results classes take the M-122 mixin as PURE VIEWS with per-class supported subsets - the overall class supports only 'simple' (one-row bit-exact relay: target = target_parameter, the estimand label 'WAS'/'WAS_d_lower' per the dCDH precedent; n = n_obs, the contributing-unit count, n_kind='units' - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band, so n_obs is the single source; df = resolve_inference_df, provenance-exact: finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable), and the event-study class supports only 'event_study' (build_event_study_surface passthrough via the _from_had adapter, cband fields included). Cross-mode levels fail closed via the mixin vocabulary message - the two modes share no computable estimand (ES fits store no overall WAS; joint cross-horizon covariance is the DEFERRED.md row). NO KIT is retained (views work on pickles from any release) and NO bootstrap gate exists (HAD's n_bootstrap feeds only the sup-t cband critical value; pointwise inference is always analytical). The _from_had adapter's n_kind was corrected 'obs' -> 'units' with this row (n_obs_per_horizon counts units), and AggregationResult.summary() gained the single-non-'att'-target rendering (the estimand-heading widening; see the v4-design section 6 rule). Consumer admission is DEFERRED, not by-design (M-093 amendment): HAD ES coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted (identically zero AND the WAS is not identified there) and no joint covariance exists. The workflow twin did_had_pretest_workflow(aggregate=) is row M-139 (same inference, same release)." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). Shimmed in 3.9: fit(aggregate=) warns via the shared NOT_SUPPLIED sentinel (a plain fit() never warns; supplying ANY value, None included, warns once, then the legacy routing runs unchanged - the PRE-EXISTING value validation survives, so None/unknown strings still raise ValueError after the warning, and a supplied mode that mismatches the panel shape still raises the shape error). UNLIKE every prior adopter, the param is a MODE SELECTOR, not an aggregation choice: 'overall' (exactly two periods -> HeterogeneousAdoptionDiDResults, scalar WAS) vs 'event_study' (more than two -> the per-horizon HeterogeneousAdoptionDiDEventStudyResults) - mutually exclusive panel shapes, so the SENTINEL resolves by panel-shape INFERENCE (_infer_aggregate_mode: distinct time values <= 2 -> overall, else event_study), running AFTER the time/time_col alias reconciliation (rows M-035..M-039 keep both spellings live). BEHAVIOR DELTA: a plain multi-period fit() previously raised the two-period shape error and now runs the event-study mode (error -> works; no numbers change on any previously-working call). One shape error stays sentinel-reachable: the staggered last-cohort auto-filter can drop a T>2 panel below three periods post-inference (its message no longer teaches the kwarg). POST-FIT SUCCESSOR: both results classes take the M-122 mixin as PURE VIEWS with per-class supported subsets - the overall class supports only 'simple' (one-row bit-exact relay: target = target_parameter, the estimand label 'WAS'/'WAS_d_lower' per the dCDH precedent; n = n_obs, the contributing-unit count, n_kind='units' - the treated/control split is disjoint on continuous designs but the mass-point masks can overlap in a ~1-ULP tolerance band, so n_obs is the single source; df = resolve_inference_df, provenance-exact: finite survey df on TSL fits, NaN otherwise - replicate designs are rejected at fit so the 0-sentinel branch is unreachable), and the event-study class supports only 'event_study' (build_event_study_surface passthrough via the _from_had adapter, cband fields included). Cross-mode levels fail closed via the mixin vocabulary message - the two modes share no computable estimand (ES fits store no overall WAS; joint cross-horizon covariance is the DEFERRED.md row). NO KIT is retained (views work on pickles from any release) and NO bootstrap gate exists (HAD's n_bootstrap feeds only the sup-t cband critical value; pointwise inference is always analytical). The _from_had adapter's n_kind was corrected 'obs' -> 'units' with this row (n_obs_per_horizon counts units), and AggregationResult.summary() gained the single-non-'att'-target rendering (the estimand-heading widening; see the v4-design section 6 rule). Consumer admission is DEFERRED, not by-design (M-093 amendment): HAD ES coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted (identically zero AND the WAS is not identified there) and no joint covariance exists. The workflow twin did_had_pretest_workflow(aggregate=) is row M-139 (same inference, same release). AT 4.0 (PR 3): _VALID_AGGREGATES deleted from had.py and had_pretests.py; _infer_aggregate_mode is the sole mode selector (panel shape); the HADPretestReport.aggregate FIELD survives. No positional shift (everything after the removed slot is keyword-only; a 7th positional now raises natively)." # ---- Contract renames (params) ------------------------------------------- - id: M-030 @@ -1026,7 +1026,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/staggered_results.py, diff_diff/sun_abraham.py, diff_diff/imputation_results.py, diff_diff/two_stage_results.py, diff_diff/stacked_did_results.py, diff_diff/efficient_did_results.py, diff_diff/continuous_did_results.py, diff_diff/wooldridge_results.py, diff_diff/chaisemartin_dhaultfoeuille_results.py, diff_diff/lpdid_results.py, diff_diff/staggered_triple_diff_results.py, diff_diff/results.py, diff_diff/had.py, diff_diff/visualization/_event_study.py, diff_diff/honest_did.py, diff_diff/pretrends.py] - notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." + notes: "4.0 sentinel retirement + schema enforcement (spec section 5): the n_groups==0 / n_obs==0 reference-row sentinels retire; every estimator's to_dataframe(level='event_study') emits the [M-092] column schema; the plotter / HonestDiD / PreTrendsPower consume the unified surface. Thirteen ES-carrying source modules enumerated file-by-file (results.py covers MultiPeriodDiD + SpilloverDiD) plus the plotter. behavior-at-done requires test_ref. PARTIAL PRE-CUT DELIVERY (2(b) PR-1, with M-026): the consumer half shipped for CS-SOURCED containers - compute_honest_did, compute_pretrends_power (both with SOURCE-SCOPED admission: source == CallawaySantAnnaResults only, rejecting dCDH l1 containers BY DESIGN since their placebo semantics need honest_did's native branch, and every other producer pending its own aggregate() migration) and plot_event_study / plot_honest_event_study (no source scoping - plotting is label-faithful). Admission widening is each later shim PR's methodology decision, not automatic. SECOND PRE-CUT AMENDMENT (2(b) PR-2, with M-024): admission widened to STACKED-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults}; kappa_pre >= 2 required for estimated pre-periods; honest additionally needs a non-singular full retained event-study covariance (pre+post sub-block); withheld-inference rows admitted with a source-scoped warning; the seven producer-derived guard messages now derive the producer from surface.source). Source scoping continues for all other producers. THIRD PRE-CUT AMENDMENT (2(b) PR-3a, with M-023): admission NOT widened to EfficientDiD-sourced containers BY DESIGN (the dCDH precedent) - the PRIMARY ground is the absent joint event-study covariance (container vcov=None; per-row df was all-NaN at that amendment and is finite on analytical survey fits since the 3.12 M-092 completion - the rejection ground is the missing joint covariance, unchanged); reference semantics are regime-dependent (PT-All: no reference row, universal first-period baseline; PT-Post: a materialized mechanical zero anchor at e=-1-anticipation when estimated, marked via the membership-gated reference_period property - never fabricated when absent); both terminal TypeErrors now state the EfficientDiD rejection explicitly; source scoping continues to reject. FOURTH PRE-CUT AMENDMENT (2(b) PR-3b, with M-021/M-022): admission NOT widened by the Imputation/TwoStage migrations - ImputationDiD rejected BY DESIGN (no joint ES covariance; per-horizon Theorem-3 conservative SEs only); TwoStageDiD DEFERRED despite carrying the joint Gardner-GMM covariance on analytical fits (M-092), because its pre-period coefficients are stage-1 residual means (the reference horizon is dropped from the no-intercept Stage-2 design; the zero anchor row is appended mechanically), not contrasts against the advertised reference, while HonestDiD's Delta^RM/Delta^SD arithmetic hard-codes the delta_0=0 normalization into its boundary/bridge constraints - admission awaits a normalization derivation (DEFERRED.md paper-gated row); both terminal TypeErrors state the per-producer grounds. FIFTH PRE-CUT AMENDMENT (2(b) PR-3c, with M-025): admission NOT widened by the ContinuousDiD migration - rejected BY DESIGN on two independent grounds: no joint event-study covariance exists (per-bin IF SEs only), and the binarized bins carry NO reference-period normalization at all (no reference row exists in the surface; each bin is a raw reweighted binarized ATT level), so HonestDiD's delta_0=0 arithmetic has no anchor even in principle without new methodology; both terminal TypeErrors name the ContinuousDiD rejection alongside EfficientDiD/Imputation. Amended pre-cut (2(b) PR-4, with M-027): HeterogeneousAdoptionDiD admission NOT widened by the HAD migration - DEFERRED, not by-design (the TwoStage class): its event-study coefficients ARE reference-normalized (each horizon differences against the F-1 anchor) but the anchor row is omitted from the container (identically zero AND the WAS is not identified there - no reference row exists for the consumer grid) and no joint cross-horizon covariance exists (per-horizon independent sandwiches; the DEFERRED.md row); both terminal TypeErrors name the HAD deferral in that corrected wording. Source scoping continues to reject all other producers. SIXTH PRE-CUT AMENDMENT (Phase 3(a), with M-010): admission widened to TWFE-SOURCED CALENDAR containers in compute_honest_did + compute_pretrends_power via a dedicated calendar route that reconstructs the native MultiPeriodDiDResults-branch inputs from the container's post_periods partition provenance (single-reference gate; HonestDiD additionally GEOMETRY-SCOPED to chronological partitions - suffix post_periods + last-pre reference - with the pre-existing native non-suffix geometry limitation documented in the REGISTRY HonestDiD Note and the DEFERRED.md transform-or-reject row; PreTrendsPower plumbs reference_period through the gamma-unit helper, string-label degradation included); relative-scale admission unchanged. SEVENTH PRE-CUT AMENDMENT (DML PR-B1): admission widened to DMLDiD-SOURCED containers in compute_honest_did + compute_pretrends_power (source in {CallawaySantAnnaResults, StackedDiDResults, DMLDiDResults}): DMLDiDResults subclasses CallawaySantAnnaResults and its aggregate('event_study') container is built by the INHERITED CS kit channel, so it carries the same staggered ATT(g,t) payload, reference semantics, base_period provenance, and (bootstrapped) replay behavior as a CS container - the same-semantics ground the original CS admission rests on; the producer-derived guard messages treat DMLDiDResults like CS (both expose base_period). DMLDiD adds NO builder (M-092 untouched) and never populates fit-time event_study_effects (its sentinel-retirement obligation is discharged by the shared CS code). Source scoping continues to reject all other producers. EIGHTH PRE-CUT AMENDMENT (4.0 cut PR 3, with M-020..M-027/M-117..M-120/M-139): the fit-time aggregate=/balance_e= keywords are gone, which fixes WHICH producers populate the legacy event_study_effects/group_effects fields - CallawaySantAnna and EfficientDiD: None on EVERY fit (analytic and bootstrapped; the post-fit replay is the only bootstrap route); ImputationDiD and TwoStageDiD: None on analytic fits, ES + group dicts (percentile se/p/CI, df cleared) on bootstrapped fits IFF bootstrap_results is not None, relayed by aggregate(); ContinuousDiD: None on analytic fits, the ES dict on bootstrapped fits IFF _run_bootstrap ran, relayed by aggregate(); StackedDiD and SunAbraham: always populated (unchanged). CS/EDiD to_dataframe(level='event_study'|'group') therefore raises on every fresh fit until this row's schema rewire lands; admission is unchanged by the removals. This row stays planned: its transition remains the 4.0 sentinel retirement + full-producer schema enforcement." # ---- Behavior policies (schema-tracked, spec-governed; no reality probe) - - id: M-080 @@ -1405,12 +1405,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/staggered.py, diff_diff/staggered_results.py, diff_diff/aggregation.py] - notes: "balance_e moves from fit() onto aggregate() with [M-020]. Previously tracked only as the prose 'balance_e moves to aggregate() in the same PR' inside M-020's notes, which no test could assert - the same un-rowed-obligation class the gating-completeness amendment closed. Applies to event-study aggregation ONLY (the shipped code threads it nowhere else), so aggregate(type='simple'|'group'|'total', balance_e=...) raises rather than silently ignoring it. The other three balance_e sites (ImputationDiD, TwoStageDiD, EfficientDiD) get their own rows in the PRs that migrate them - EfficientDiD's is [M-120]." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). balance_e moves from fit() onto aggregate() with [M-020]. Previously tracked only as the prose 'balance_e moves to aggregate() in the same PR' inside M-020's notes, which no test could assert - the same un-rowed-obligation class the gating-completeness amendment closed. Applies to event-study aggregation ONLY (the shipped code threads it nowhere else), so aggregate(type='simple'|'group'|'total', balance_e=...) raises rather than silently ignoring it. The other three balance_e sites (ImputationDiD, TwoStageDiD, EfficientDiD) get their own rows in the PRs that migrate them - EfficientDiD's is [M-120]. AT 4.0 (PR 3): fit computes no ES/group surface; legacy fields None on every fit; the post-fit replay (balance_e included) is unchanged. Slot shift recorded on [M-020]." - id: M-120 kind: param group: aggregate-postfit @@ -1419,12 +1419,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/efficient_did.py, diff_diff/efficient_did_aggregation.py, diff_diff/efficient_did_bootstrap.py, diff_diff/efficient_did_results.py, diff_diff/aggregation.py, diff_diff/bootstrap_utils.py] - notes: "balance_e moves from fit() onto aggregate() with [M-023] (joint FutureWarning; the M-117 twin). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. EfficientDiD's balance rule is the ANCHOR-HORIZON rule - keep cohorts with a finite effect at the anchor horizon, then retain all their horizons - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference: EDiD anchors on the int(t - g) bucket while CS keys raw t - g (identical on integer-period panels; the REGISTRY truncation Note documents the fractional case), and divergent from ImputationDiD/TwoStageDiD's balanced-window rule (their own rows document theirs). On bootstrapped fits balance_e rides the M-023 replay (the bootstrap ES prep applies the same int-bucketed anchor rule). An anchor no cohort reaches warns (UserWarning, re-emitted on replay) and yields a legal zero-row EventStudyResults container." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). balance_e moves from fit() onto aggregate() with [M-023] (joint FutureWarning; the M-117 twin). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. EfficientDiD's balance rule is the ANCHOR-HORIZON rule - keep cohorts with a finite effect at the anchor horizon, then retain all their horizons - the same rule SHAPE CallawaySantAnna uses, with one keying-granularity difference: EDiD anchors on the int(t - g) bucket while CS keys raw t - g (identical on integer-period panels; the REGISTRY truncation Note documents the fractional case), and divergent from ImputationDiD/TwoStageDiD's balanced-window rule (their own rows document theirs). On bootstrapped fits balance_e rides the M-023 replay (the bootstrap ES prep applies the same int-bucketed anchor rule). An anchor no cohort reaches warns (UserWarning, re-emitted on replay) and yields a legal zero-row EventStudyResults container. AT 4.0 (PR 3): fit computes no ES/group surface; legacy fields None on every fit; the post-fit replay (balance_e included) is unchanged. Slot shifts recorded on [M-023]." - id: M-118 kind: param group: aggregate-postfit @@ -1433,12 +1433,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/imputation.py, diff_diff/imputation_aggregation.py, diff_diff/imputation_results.py, diff_diff/aggregation.py] - notes: "balance_e moves from fit() onto aggregate() with [M-021] (joint FutureWarning; the M-117/M-120 twin; the imputation_did wrapper forwards the sentinel). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. ImputationDiD's balance rule is the BALANCED-WINDOW rule - a cohort is retained iff its observed relative-time set (checked against the FULL panel via _build_cohort_rel_times) covers the contiguous window [-balance_e, max_h] (imputation_aggregation.py _compute_balanced_cohort_mask) - divergent from CS/EfficientDiD's anchor-horizon rule ([M-117]/[M-120] document theirs; TwoStageDiD shares the balanced-window rule, [M-119]). A window no cohort satisfies warns (UserWarning) and yields the reference-marker-only dict -> a legal near-empty container." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). balance_e moves from fit() onto aggregate() with [M-021] (joint FutureWarning; the M-117/M-120 twin; the imputation_did wrapper forwards the sentinel). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. ImputationDiD's balance rule is the BALANCED-WINDOW rule - a cohort is retained iff its observed relative-time set (checked against the FULL panel via _build_cohort_rel_times) covers the contiguous window [-balance_e, max_h] (imputation_aggregation.py _compute_balanced_cohort_mask) - divergent from CS/EfficientDiD's anchor-horizon rule ([M-117]/[M-120] document theirs; TwoStageDiD shares the balanced-window rule, [M-119]). A window no cohort satisfies warns (UserWarning) and yields the reference-marker-only dict -> a legal near-empty container. AT 4.0 (PR 3): aggregate('event_study', balance_e=k) on a BOOTSTRAPPED ImputationDiD fit raises NotImplementedError (the relayed fit-time surface was computed with balance_e=None and the per-target draws are not retained; re-fit with n_bootstrap=0); analytic fits recompute with balance_e as before." - id: M-119 kind: param group: aggregate-postfit @@ -1447,12 +1447,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/two_stage.py, diff_diff/two_stage_aggregation.py, diff_diff/two_stage_results.py, diff_diff/aggregation.py] - notes: "balance_e moves from fit() onto aggregate() with [M-022] (joint FutureWarning; the M-117/M-120 twin; the two_stage_did wrapper forwards the sentinel). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. TwoStageDiD's balance rule is the BALANCED-WINDOW rule - a cohort is retained iff its observed relative-time set (checked against the full panel via _build_cohort_rel_times) covers the contiguous window [-balance_e, max_h] (two_stage_aggregation.py _stage2_event_study) - the ImputationDiD rule ([M-118]), divergent from CS/EfficientDiD's anchor-horizon rule. Zero qualifying cohorts warns ('No cohorts satisfy balance_e=...') and yields the reference-row-only dict with vcov=None -> a legal near-empty container." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). balance_e moves from fit() onto aggregate() with [M-022] (joint FutureWarning; the M-117/M-120 twin; the two_stage_did wrapper forwards the sentinel). Applies to event-study aggregation only (the mixin default vocabulary), so aggregate(type='simple'|'group'|'total', balance_e=...) raises. TwoStageDiD's balance rule is the BALANCED-WINDOW rule - a cohort is retained iff its observed relative-time set (checked against the full panel via _build_cohort_rel_times) covers the contiguous window [-balance_e, max_h] (two_stage_aggregation.py _stage2_event_study) - the ImputationDiD rule ([M-118]), divergent from CS/EfficientDiD's anchor-horizon rule. Zero qualifying cohorts warns ('No cohorts satisfy balance_e=...') and yields the reference-row-only dict with vcov=None -> a legal near-empty container. AT 4.0 (PR 3): aggregate('event_study', balance_e=k) on a BOOTSTRAPPED TwoStageDiD fit raises NotImplementedError (the relayed fit-time surface was computed with balance_e=None and the per-level GMM scores are not retained; re-fit with n_bootstrap=0); analytic fits recompute with balance_e as before." - id: M-126 kind: behavior group: variance-cr1-convergence @@ -1530,7 +1530,7 @@ rows: phase: 2 test_ref: tests/test_variance_conventions.py code_refs: [diff_diff/utils.py, diff_diff/linalg.py, diff_diff/estimators.py, diff_diff/twfe.py, diff_diff/results.py, diff_diff/sun_abraham.py, diff_diff/wooldridge.py, diff_diff/wooldridge_results.py, diff_diff/stacked_did.py, diff_diff/stacked_did_results.py, diff_diff/imputation.py, diff_diff/imputation_results.py, diff_diff/lpdid.py, diff_diff/lpdid_results.py, docs/methodology/REGISTRY.md, docs/methodology/variance-conventions.md] - notes: "Tail-df consolidation (D4 + the normal-theory tail-df defect family of the 3.9 variance-consolidation program, PR C). df_convention becomes a THREE-VALUE knob {'residual','cluster','normal'} on DiD/MPD/TWFE/LinearRegression (previously two-value; existing values unchanged, M-004..M-006 old_default untouched; 'normal' is deliberate z at the fallback level on EVERY fit, unclustered and conley included) and is EXTENDED to SunAbraham / WooldridgeDiD(OLS arms) / StackedDiD / ImputationDiD(pretrends lead regression) / LPDiD via the new utils.resolve_tail_df fallback resolver (survey/replicate df and hc2_bm Bell-McCaffrey per-coefficient/contrast DOF keep precedence under every value; 'cluster' fails closed warn+NaN at G<=1; unclustered/conley fits are inert for 'cluster'; a non-positive residual df falls back to normal theory with a warning). DEFECT FIX shipped as the new 'residual' default: WooldridgeDiD default-hc1 (within AND the hc1+cohort_trends full-dummy arm), StackedDiD, SunAbraham aggregates (D4: one fit reported residual-t cells and normal-theory aggregates - aggregates now reuse the saturated regression's df_ so cells and aggregates share ONE df source), and ImputationDiD pretrends leads previously passed df=None (silent normal-theory z) on clustered analytical paths; at |t|=2 z understates the t(G-1) p-value by 24.2% at G=20. Under the residual default the numeric movement is z vs t(large residual df) - small but honest; the G-1 convergence with fixest/Stata is the 4.0 flip. 'normal' reproduces the pre-3.9 numbers on the fixed surfaces' affected lanes (on lanes already at t - unclustered DiD-family fits, Wooldridge classical/hc2, SA cells - it is a NEW deliberate z option, not a compatibility path). LPDiD keeps its Stata-anchored t(G-1) as default 'cluster' (bit-identical; already at the 4.0 target, no flip row; degenerate lanes - unclustered refit, RA G<=1, saturated early return - keep literal df=None under all values; RA-path 'residual' = n_total - k0_kept - 1, the pooled M-estimator parameter count, a library convention with no external anchor since Stata teffects ra reports z). Wooldridge logit/poisson arms (survey df or normal theory, knob-independent, explicit-non-default warns) and all IF-based estimators (CallawaySantAnna, TwoStageDiD, dCDH, EfficientDiD, ContinuousDiD, ChangesInChanges, SyntheticDiD, HAD - L3) plus OLS-based TripleDifference/TROP are out of the knob's scope. Wild-cluster-bootstrap p/CIs are test-inversion based and invariant. Provenance: df_convention echoed on the five standalone results containers; SunAbraham/StackedDiD gain inference_df (the overall-ATT df actually used; None under bootstrap); WooldridgeDiDResults._df_one_way renamed _df_analytic_fallback (now the resolved survey-first fallback on ALL OLS arms, live on bootstrap fits so post-fit aggregate() reproduces fit-time inference) with __setstate__ pickle migration. Defaults for the four new residual surfaces flip to 'cluster' at 4.0 (M-128..M-131, same policy as M-004..M-006). introduced_in gates the 3.9 cut; deprecated_in stays null so the early-flip guard does not fire against the PR that ships it. AMENDED (2(b) PR-3b, with M-021): the ImputationDiD inert-config warning predicate is REVISED from aggregate-keyed to REACHABILITY-based - warn iff df_convention is explicitly non-default AND the per-lead inference is unreachable for this fit config (reachable iff pretrends AND not replicate-weight AND (the deprecated fit-time aggregate='event_study'/'all' was supplied OR n_bootstrap <= 0)), because post-fit results.aggregate('event_study') made the old 'aggregate outside {event_study,all} is inert' claim false on analytical pretrends fits. Reachability-BASED, not exact: a fit whose bootstrap later FAILS (bootstrap_results=None) can still aggregate post-fit, so that corner warns spuriously - the warning fires before the bootstrap runs and cannot know." + notes: "Tail-df consolidation (D4 + the normal-theory tail-df defect family of the 3.9 variance-consolidation program, PR C). df_convention becomes a THREE-VALUE knob {'residual','cluster','normal'} on DiD/MPD/TWFE/LinearRegression (previously two-value; existing values unchanged, M-004..M-006 old_default untouched; 'normal' is deliberate z at the fallback level on EVERY fit, unclustered and conley included) and is EXTENDED to SunAbraham / WooldridgeDiD(OLS arms) / StackedDiD / ImputationDiD(pretrends lead regression) / LPDiD via the new utils.resolve_tail_df fallback resolver (survey/replicate df and hc2_bm Bell-McCaffrey per-coefficient/contrast DOF keep precedence under every value; 'cluster' fails closed warn+NaN at G<=1; unclustered/conley fits are inert for 'cluster'; a non-positive residual df falls back to normal theory with a warning). DEFECT FIX shipped as the new 'residual' default: WooldridgeDiD default-hc1 (within AND the hc1+cohort_trends full-dummy arm), StackedDiD, SunAbraham aggregates (D4: one fit reported residual-t cells and normal-theory aggregates - aggregates now reuse the saturated regression's df_ so cells and aggregates share ONE df source), and ImputationDiD pretrends leads previously passed df=None (silent normal-theory z) on clustered analytical paths; at |t|=2 z understates the t(G-1) p-value by 24.2% at G=20. Under the residual default the numeric movement is z vs t(large residual df) - small but honest; the G-1 convergence with fixest/Stata is the 4.0 flip. 'normal' reproduces the pre-3.9 numbers on the fixed surfaces' affected lanes (on lanes already at t - unclustered DiD-family fits, Wooldridge classical/hc2, SA cells - it is a NEW deliberate z option, not a compatibility path). LPDiD keeps its Stata-anchored t(G-1) as default 'cluster' (bit-identical; already at the 4.0 target, no flip row; degenerate lanes - unclustered refit, RA G<=1, saturated early return - keep literal df=None under all values; RA-path 'residual' = n_total - k0_kept - 1, the pooled M-estimator parameter count, a library convention with no external anchor since Stata teffects ra reports z). Wooldridge logit/poisson arms (survey df or normal theory, knob-independent, explicit-non-default warns) and all IF-based estimators (CallawaySantAnna, TwoStageDiD, dCDH, EfficientDiD, ContinuousDiD, ChangesInChanges, SyntheticDiD, HAD - L3) plus OLS-based TripleDifference/TROP are out of the knob's scope. Wild-cluster-bootstrap p/CIs are test-inversion based and invariant. Provenance: df_convention echoed on the five standalone results containers; SunAbraham/StackedDiD gain inference_df (the overall-ATT df actually used; None under bootstrap); WooldridgeDiDResults._df_one_way renamed _df_analytic_fallback (now the resolved survey-first fallback on ALL OLS arms, live on bootstrap fits so post-fit aggregate() reproduces fit-time inference) with __setstate__ pickle migration. Defaults for the four new residual surfaces flip to 'cluster' at 4.0 (M-128..M-131, same policy as M-004..M-006). introduced_in gates the 3.9 cut; deprecated_in stays null so the early-flip guard does not fire against the PR that ships it. AMENDED (2(b) PR-3b, with M-021): the ImputationDiD inert-config warning predicate is REVISED from aggregate-keyed to REACHABILITY-based - warn iff df_convention is explicitly non-default AND the per-lead inference is unreachable for this fit config (reachable iff pretrends AND not replicate-weight AND (the deprecated fit-time aggregate='event_study'/'all' was supplied OR n_bootstrap <= 0)), because post-fit results.aggregate('event_study') made the old 'aggregate outside {event_study,all} is inert' claim false on analytical pretrends fits. AMENDED AGAIN (4.0 cut PR 3, with M-021): the fit-time keyword is gone and bootstrapped ImputationDiD fits now always materialize the pretrends lead inference at fit (relayed by aggregate()), so the predicate is simply pretrends AND not replicate-weight - exact on every fit config (the former bootstrap spurious-warning corner no longer exists)." - id: M-128 kind: default-flip group: df-convention-flip @@ -1691,12 +1691,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: shimmed + status: removed phase: 5 warning: FutureWarning test_ref: tests/test_aggregate_contract.py code_refs: [diff_diff/had_pretests.py, diff_diff/had.py] - notes: "The workflow twin of [M-027], added as a pre-cut amendment (next free id - the reserved 2(b) pool is spent/earmarked: M-118/M-119/M-120 claimed, M-116/M-121 intentionally unused). did_had_pretest_workflow's aggregate= routed WHICH pretest battery runs on the same 'overall'/'event_study' vocabulary the fit param used; it never calls fit(), so the successor is the SAME panel-shape inference (_infer_aggregate_mode, shared with fit so the two surfaces cannot drift), applied after the workflow's own alias reconciliation. A plain workflow call never warns; supplying ANY value warns once, then the legacy routing runs unchanged (invalid values still raise ValueError after the warning). `new` is null: there is no successor symbol - the HADPretestReport.aggregate FIELD survives as honest output metadata recording which battery ran (SURFACE_ALLOWLIST entry in tests/test_naming_guard.py; the MODES survive 4.0, only the param dies). The mode vocabulary dies from both public surfaces at 4.0." + notes: "REMOVED at the 4.0 cut (PR 3, 2026-10-19). The workflow twin of [M-027], added as a pre-cut amendment (next free id - the reserved 2(b) pool is spent/earmarked: M-118/M-119/M-120 claimed, M-116/M-121 intentionally unused). did_had_pretest_workflow's aggregate= routed WHICH pretest battery runs on the same 'overall'/'event_study' vocabulary the fit param used; it never calls fit(), so the successor is the SAME panel-shape inference (_infer_aggregate_mode, shared with fit so the two surfaces cannot drift), applied after the workflow's own alias reconciliation. A plain workflow call never warns; supplying ANY value warns once, then the legacy routing runs unchanged (invalid values still raise ValueError after the warning). `new` is null: there is no successor symbol - the HADPretestReport.aggregate FIELD survives as honest output metadata recording which battery ran (SURFACE_ALLOWLIST entry in tests/test_naming_guard.py; the MODES survive 4.0, only the param dies). The mode vocabulary dies from both public surfaces at 4.0. AT 4.0 (PR 3): _VALID_AGGREGATES deleted from both modules; _infer_aggregate_mode is the sole battery selector (shared with fit); the HADPretestReport.aggregate FIELD survives as output metadata. The param was keyword-only, so no positional shift." - id: M-140 kind: param diff --git a/docs/v4-design.md b/docs/v4-design.md index fe79acd26..c727fa53a 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -631,7 +631,38 @@ provenance beside percentile statistics; a RECOMPUTE level on a bootstrapped fit either REPLAYS the fit-time bootstrap from retained RNG state (CS [M-020], EfficientDiD [M-023], DMLDiD via the inherited CS kit channel [M-093 seventh amendment]) or RAISES - it never silently -returns analytical inference. **View-relay exception (Phase 2b PRs 1-2):** +returns analytical inference. **Amendment (4.0 cut PR 3, the fit-time +`aggregate=`/`balance_e=` removals [M-020]..[M-027] [M-117]..[M-120] +[M-139]):** the deprecated keyword was the only route to bootstrap +event-study/group inference on ImputationDiD, TwoStageDiD and ContinuousDiD +(no per-draw state is retained for a replay), so its removal RELAYS that +surface instead of closing it: a fit with `n_bootstrap > 0` on those three +estimators ALWAYS computes the fit-time bootstrap surfaces with the +pre-existing code (event study on all three, group on Imputation/TwoStage - +exactly what `aggregate="all"` computed, `balance_e=None`), stores them in +the legacy `event_study_effects`/`group_effects` fields, and +`aggregate('event_study'|'group')` RELAYS the stored dict with kit-snapshot +provenance (alpha, anticipation, survey metadata from the kit; the per-row df +column NaN, `vcov`/`vcov_index` None). `balance_e is not None` on such a fit +fails closed (`NotImplementedError`) - the stored surface was computed +unrestricted and cannot be re-masked. Analytic fits are unchanged (legacy +fields None, kit recompute on demand). Storage rule: the legacy fields hold a +surface IFF the fit carries bootstrap inference; a bootstrapped fit that ends +analytical (zero valid draws, total bootstrap failure) clears them and +`aggregate()` recomputes analytically, while a fit WITH bootstrap inference +and no stored surface (pre-4.0 pickle, fail-soft drop) raises the shared +absent-surface error. Materialization is FAIL-SOFT with a one-shot +overall-only retry: an exception while building the surfaces, or a bootstrap +attempt that raises/returns `None` with surfaces present, leaves them None, +warns once, and re-runs the bootstrap overall-only (a seeded fit reproduces +attempt 1's weight draws), so a plain bootstrapped fit's headline quintet and +its success/abort outcome are unchanged by the removal. On CallawaySantAnna +and EfficientDiD the fit-time branches are deleted outright (the post-fit +replay exists) and their legacy fields are None after EVERY fit. Measured +cost of the always-on surfaces (199 draws, 300 units x 12 periods): +Imputation 0.01s -> 0.08s, TwoStage 0.01s -> 0.04s, Continuous 0.05s -> +0.05s - a few hundredths of a second per small bootstrapped fit; all three +default to `n_bootstrap=0`. **View-relay exception (Phase 2b PRs 1-2):** estimators whose `aggregate()` RELAYS stored fields without recomputation need no influence-function kit - there is nothing to re-weight. The retention requirement binds RECOMPUTING estimators (the CallawaySantAnna @@ -640,7 +671,13 @@ verbatim, and StackedDiD [M-024] always materializes its event-study surface at fit (the pooled regression always includes the interactions) so both its levels are pure views - which is also why bootstrap-style fail-closing does not apply to them: every stored inference mode relays -faithfully. Estimators whose +faithfully. ImputationDiD [M-021], TwoStageDiD [M-022] and ContinuousDiD +[M-025] are HYBRIDS since the 4.0 cut (PR 3): they RECOMPUTE from their kits +on analytic fits and RELAY the fit-materialized surface on bootstrapped fits, +so the retention requirement binds their analytic path only - the relayed +dict is read from the mutable public field (the StackedDiD view precedent), +with only alpha/anticipation/survey metadata taken from kit snapshots. +Estimators whose estimand is already a single aggregation (SunAbraham's saturated event study, LPDiD's per-horizon design) expose `aggregate()` where meaningful as additive surface; their native params (`only_event`/`only_pooled` etc.) are documented @@ -1015,9 +1052,11 @@ its rows terminal and names them in its changelog fragment. 2. Mechanical shim removals (SHIPPED, PR 2): the rename rows, the wrapper functions, the alias diet, the SyntheticDiD params, the legacy-CR1 remap branches named on [M-045]/[M-115]; `_deprecation.py` stays (section 8 rule 11 corollary). -3. Fit-time `aggregate=`/`balance_e=` removals, plus the runtime messages that - still steer users to the deprecated route (readers recorded in the rows' - `code_refs`) and the benchmark/guide sweep. +3. Fit-time `aggregate=`/`balance_e=` removals (SHIPPED, PR 3), plus the runtime + messages that still steered users to the deprecated route (readers recorded + in the rows' `code_refs`) and the benchmark/guide sweep; bootstrapped + ImputationDiD/TwoStageDiD/ContinuousDiD fits relay their fit-materialized + surfaces (section 6 amendment). 4. The three merges: the MultiPeriodDiD class, `MultiPeriodDiDResults`, `PeriodEffect` and `EventStudy`; the QDiD class and `QDiDResults`; the `StaggeredTripleDifference` CLASS and `SDDD` - but NOT diff --git a/tests/_capture_v4_aggregate_oracles.py b/tests/_capture_v4_aggregate_oracles.py new file mode 100644 index 000000000..501e79c97 --- /dev/null +++ b/tests/_capture_v4_aggregate_oracles.py @@ -0,0 +1,444 @@ +"""Capture the pre-removal aggregation oracles for tests/test_aggregate_contract.py. + +MUST run on the UNMODIFIED tree (before the 4.0 PR 3 removal of the fit-time +``aggregate=`` / ``balance_e=`` keywords), ONCE PER BACKEND:: + + DIFF_DIFF_BACKEND=python python tests/_capture_v4_aggregate_oracles.py > numpy.json + DIFF_DIFF_BACKEND=rust python tests/_capture_v4_aggregate_oracles.py > rust.json + +and the two payloads are merged into ``tests/fixtures/v4_aggregate_oracles.json`` +(``{"analytic": ..., "boot": {"numpy": ..., "rust": ...}, "provenance": ...}``). + +Why. PR 3 deletes the deprecated fit-time aggregation route that +``test_aggregate_contract.py`` used as its NUMERICAL REFERENCE (the ``*_fit_time`` +fixtures and ``_boot_fit_time`` helpers): post-fit ``aggregate()`` was asserted +equal to ``fit(aggregate=...)``. Once the route is gone there is no independent +second implementation left to disagree with, so the reference becomes these +committed numbers, captured while the route still existed: + +* ANALYTIC oracles - the ``fit(aggregate="all")`` (ContinuousDiD: + ``"eventstudy"``) and ``balance_e=1`` surfaces on every design arm the contract + file exercises. After the removal the kit recompute must reproduce them. +* BOOTSTRAPPED oracles, per weight backend - ``generate_bootstrap_weights_batch`` + draws a DIFFERENT seeded stream under the Rust backend than under NumPy, so + the percentile surfaces are backend-specific. After the removal the CS/EDiD + replays and the Imputation/TwoStage/ContinuousDiD relays must reproduce them, + and a bootstrapped PLAIN fit's overall quintet must be unchanged (the + always-on surface materialization draws no extra weights). + +The panel builders are imported from the contract test module itself so the +oracle panels are, by construction, the test panels. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import warnings +from typing import Any, Dict, Optional + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "helpers")) + +import test_aggregate_contract as T # noqa: E402 (the contract module's panel builders) + +from diff_diff import CallawaySantAnna, SurveyDesign # noqa: E402 +from diff_diff.bootstrap_chunking import effective_weight_backend # noqa: E402 + + +# --------------------------------------------------------------------------- # +# serialization helpers +# --------------------------------------------------------------------------- # +def _f(x: Any) -> Optional[float]: + if x is None: + return None + try: + v = float(x) + except (TypeError, ValueError): + return None + return None if np.isnan(v) else v + + +def _key(k: Any) -> Any: + # JSON object keys must be strings; keep a lossless repr of the label. + if isinstance(k, (np.integer, int)): + return str(int(k)) + if isinstance(k, (np.floating, float)): + return repr(float(k)) + return str(k) + + +def _row(d: Dict[str, Any]) -> Dict[str, Any]: + ci = d.get("conf_int", (np.nan, np.nan)) + out = { + "effect": _f(d.get("effect")), + "se": _f(d.get("se")), + "t_stat": _f(d.get("t_stat")), + "p_value": _f(d.get("p_value")), + "ci_lo": _f(ci[0]), + "ci_hi": _f(ci[1]), + } + for k in ("n_obs", "n_groups", "n_units", "df_used"): + if k in d: + out[k] = _f(d[k]) + cb = d.get("cband_conf_int") + if cb is not None: + out["cband_lo"], out["cband_hi"] = _f(cb[0]), _f(cb[1]) + return out + + +def _dict_surface(surface: Optional[Dict[Any, Dict[str, Any]]]) -> Optional[Dict[str, Any]]: + if surface is None: + return None + return {_key(k): _row(v) for k, v in sorted(surface.items(), key=lambda kv: float(kv[0]))} + + +def _overall(r: Any) -> Dict[str, Optional[float]]: + ci = getattr(r, "overall_conf_int", None) or (np.nan, np.nan) + return { + "att": _f(r.overall_att), + "se": _f(r.overall_se), + "t_stat": _f(getattr(r, "overall_t_stat", np.nan)), + "p_value": _f(r.overall_p_value), + "ci_lo": _f(ci[0]), + "ci_hi": _f(ci[1]), + } + + +def _matrix(m: Any) -> Optional[list]: + if m is None: + return None + arr = np.asarray(m, dtype=float) + return [[_f(x) for x in row] for row in arr] + + +def _es_bundle(r: Any) -> Dict[str, Any]: + """Event-study surface + its provenance side fields where the class has them.""" + out: Dict[str, Any] = {"es": _dict_surface(r.event_study_effects)} + for attr in ("event_study_vcov_index", "reference_event_times"): + v = getattr(r, attr, None) + out[attr] = None if v is None else [_f(x) for x in v] + out["event_study_vcov"] = _matrix(getattr(r, "event_study_vcov", None)) + out["event_study_df"] = _f(getattr(r, "event_study_df", None)) + out["cband_crit_value"] = _f(getattr(r, "cband_crit_value", None)) + return out + + +def _all_bundle(r: Any) -> Dict[str, Any]: + out = _es_bundle(r) + out["group"] = _dict_surface(getattr(r, "group_effects", None)) + out["overall"] = _overall(r) + return out + + +def _quiet(fn, *a, **kw): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(*a, **kw) + + +# --------------------------------------------------------------------------- # +# analytic oracles (backend-independent in principle; the two runs are diffed) +# --------------------------------------------------------------------------- # +def capture_analytic() -> Dict[str, Any]: + out: Dict[str, Any] = {} + panel = T._panel() + + # ---- CallawaySantAnna -------------------------------------------------- + cs: Dict[str, Any] = {} + cs["default_all"] = _all_bundle( + _quiet(CallawaySantAnna().fit, panel, aggregate="all", **T.FIT_KW) + ) + cs["default_balance_e1"] = _es_bundle( + _quiet(CallawaySantAnna().fit, panel, aggregate="event_study", balance_e=1, **T.FIT_KW) + ) + cs["rcs_all"] = _all_bundle( + _quiet(CallawaySantAnna(panel=False).fit, T._rcs(), aggregate="all", **T.FIT_KW) + ) + cs["anticipation1_all"] = _all_bundle( + _quiet(CallawaySantAnna(anticipation=1).fit, panel, aggregate="all", **T.FIT_KW) + ) + cs["universal_all"] = _all_bundle( + _quiet(CallawaySantAnna(base_period="universal").fit, panel, aggregate="all", **T.FIT_KW) + ) + cs["nyt_anticipation1_all"] = _all_bundle( + _quiet( + CallawaySantAnna(control_group="not_yet_treated", anticipation=1).fit, + panel, + aggregate="all", + **T.FIT_KW, + ) + ) + thinned = panel.drop(panel.index[::13]).reset_index(drop=True) + cs["unbalanced_all"] = _all_bundle( + _quiet( + CallawaySantAnna(allow_unbalanced_panel=True).fit, thinned, aggregate="all", **T.FIT_KW + ) + ) + cs["survey_all"] = _all_bundle( + _quiet( + CallawaySantAnna().fit, + T._survey_panel(), + survey_design=T._survey_design(), + aggregate="all", + **T.FIT_KW, + ) + ) + out["CallawaySantAnna"] = cs + + # ---- EfficientDiD ------------------------------------------------------ + ed: Dict[str, Any] = {} + ep = T._efficient_panel() + ed["default_all"] = _all_bundle(T._fit_efficient(ep, aggregate="all")) + ed["default_balance_e1"] = _es_bundle( + T._fit_efficient(ep, aggregate="event_study", balance_e=1) + ) + ed["fractional_balance_e1"] = _es_bundle( + T._fit_efficient(T._efficient_fractional_panel(), aggregate="event_study", balance_e=1) + ) + ed["clustered_all"] = _all_bundle( + T._fit_efficient(T._efficient_clustered_panel(), est_kw={"cluster": "cl"}, aggregate="all") + ) + d_sw, _ = T._efficient_survey_panel() + ed["survey_weights_all"] = _all_bundle( + T._fit_efficient(d_sw, survey_design=T._efficient_survey_design(), aggregate="all") + ) + out["EfficientDiD"] = ed + + # ---- ImputationDiD ----------------------------------------------------- + im: Dict[str, Any] = {} + ip = T._imputation_panel() + im["default_all"] = _all_bundle(T._fit_imputation(ip, aggregate="all")) + im["default_balance_e1"] = _es_bundle( + T._fit_imputation(ip, aggregate="event_study", balance_e=1) + ) + im["pretrends_all"] = _all_bundle( + T._fit_imputation(ip, est_kw={"pretrends": True}, aggregate="all") + ) + im["clustered_all"] = _all_bundle( + T._fit_imputation( + T._imputation_clustered_panel(), est_kw={"cluster": "cl"}, aggregate="all" + ) + ) + d_sw, _ = T._imputation_survey_panel() + im["survey_tsl_all"] = _all_bundle( + T._fit_imputation(d_sw, survey_design=T._imputation_survey_design(), aggregate="all") + ) + for degenerate in (None, "dropped", "undefined", "cohort_zero"): + d_rep, rep_cols = T._imputation_survey_panel(replicate=True, degenerate=degenerate) + sd = T._imputation_survey_design(rep_cols) + tag = degenerate or "none" + # LEVEL-MATCHED references: the joint replicate stack couples rows, so + # fit(aggregate="event_study") and fit(aggregate="group") are separate. + im[f"replicate_{tag}_event_study"] = _es_bundle( + T._fit_imputation(d_rep, survey_design=sd, aggregate="event_study") + ) + im[f"replicate_{tag}_group"] = _all_bundle( + T._fit_imputation(d_rep, survey_design=sd, aggregate="group") + ) + d_cov = T._imputation_panel(seed=23).copy() + d_cov["x1"] = np.random.default_rng(23).normal(size=len(d_cov)) + 0.1 * d_cov["period"] + im["covariates_all"] = _all_bundle(T._fit_imputation(d_cov, covariates=["x1"], aggregate="all")) + out["ImputationDiD"] = im + + # ---- TwoStageDiD ------------------------------------------------------- + ts: Dict[str, Any] = {} + tp = T._twostage_panel() + ts["default_all"] = _all_bundle(T._fit_twostage(tp, aggregate="all")) + ts["default_balance_e1"] = _es_bundle(T._fit_twostage(tp, aggregate="event_study", balance_e=1)) + ts["pretrends_all"] = _all_bundle( + T._fit_twostage(tp, est_kw={"pretrends": True}, aggregate="all") + ) + ts["prop5_all"] = _all_bundle(T._fit_twostage(T._twostage_prop5_panel(), aggregate="all")) + d_sw, _ = T._twostage_survey_panel() + ts["survey_tsl_all"] = _all_bundle( + T._fit_twostage(d_sw, survey_design=T._twostage_survey_design(), aggregate="all") + ) + ts["survey_psu_all"] = _all_bundle( + T._fit_twostage(d_sw, survey_design=T._twostage_survey_design(psu=True), aggregate="all") + ) + ts["always_treated_survey_all"] = _all_bundle( + T._fit_twostage( + T._twostage_always_treated_panel(), + survey_design=T._twostage_survey_design(), + aggregate="all", + ) + ) + for degenerate in (None, "dropped", "undefined", "cohort_zero"): + d_rep, rep_cols = T._twostage_survey_panel(replicate=True, degenerate=degenerate) + sd = T._twostage_survey_design(rep_cols) + tag = degenerate or "none" + ts[f"replicate_{tag}_event_study"] = _es_bundle( + T._fit_twostage(d_rep, survey_design=sd, aggregate="event_study") + ) + ts[f"replicate_{tag}_group"] = _all_bundle( + T._fit_twostage(d_rep, survey_design=sd, aggregate="group") + ) + ts["always_treated_survey_psu_all"] = _all_bundle( + T._fit_twostage( + T._twostage_always_treated_panel(), + survey_design=T._twostage_survey_design(psu=True), + aggregate="all", + ) + ) + d_cov = T._twostage_panel(seed=23).copy() + d_cov["x1"] = np.random.default_rng(23).normal(size=len(d_cov)) + 0.1 * d_cov["period"] + ts["covariates_all"] = _all_bundle(T._fit_twostage(d_cov, covariates=["x1"], aggregate="all")) + out["TwoStageDiD"] = ts + + # ---- ContinuousDiD (no group level; "eventstudy" is the only ES spelling) - + co: Dict[str, Any] = {} + cp = T._cont_panel() + co["default_eventstudy"] = _all_bundle(T._fit_cont(cp, aggregate="eventstudy")) + co["covariate_eventstudy"] = _all_bundle( + T._fit_cont(T._cont_covariate_panel(), covariates=["x1"], aggregate="eventstudy") + ) + co["anticipation1_eventstudy"] = _all_bundle( + T._fit_cont(T._cont_panel(seed=47), est_kw={"anticipation": 1}, aggregate="eventstudy") + ) + d_sw, _ = T._cont_survey_panel() + co["survey_tsl_eventstudy"] = _all_bundle( + T._fit_cont(d_sw, survey_design=T._cont_survey_design(), aggregate="eventstudy") + ) + co["empty_post_eventstudy"] = _all_bundle( + T._fit_cont(T._cont_empty_post_panel(), aggregate="eventstudy") + ) + out["ContinuousDiD"] = co + return out + + +# --------------------------------------------------------------------------- # +# bootstrapped oracles (backend-specific) +# --------------------------------------------------------------------------- # +def capture_boot() -> Dict[str, Any]: + out: Dict[str, Any] = {} + panel = T._panel() + + # ---- CallawaySantAnna: fit-time reference surfaces the replay must match - + cs: Dict[str, Any] = {} + cs["default_all"] = _all_bundle(T._boot_fit_time(panel)) + cs["default_plain_overall"] = _overall(T._boot_fit(panel)) + d = panel.copy() + d["psu"] = d["unit"] % 7 + cs["psu_cluster_all"] = _all_bundle(T._boot_fit_time(d, cluster="psu")) + d2 = panel.copy() + rng = np.random.default_rng(5) + wmap = {u: rng.uniform(0.5, 2.0) for u in d2["unit"].unique()} + d2["w"] = d2["unit"].map(wmap) + d2["psu"] = d2["unit"] % 7 + d2["stratum"] = d2["unit"] % 2 + sd = SurveyDesign(weights="w", strata="stratum", psu="psu", nest=True) + cs["stratified_survey_all"] = _all_bundle( + T._boot_fit_time(d2, fit_kwargs={"survey_design": sd}) + ) + cs["cband_all"] = _all_bundle(T._boot_fit_time(panel, cband=True)) + out["CallawaySantAnna"] = cs + + # ---- EfficientDiD ------------------------------------------------------ + ed: Dict[str, Any] = {} + ep = T._efficient_panel() + ed["default_all"] = _all_bundle(T._efficient_boot_fit_time(ep)) + ed["default_plain_overall"] = _overall(T._efficient_boot_fit(ep)) + ed["clustered_all"] = _all_bundle( + T._efficient_boot_fit_time(T._efficient_clustered_panel(), est_kw={"cluster": "cl"}) + ) + d_sw, _ = T._efficient_survey_panel() + ed["survey_weights_all"] = _all_bundle( + T._efficient_boot_fit_time(d_sw, survey_design=T._efficient_survey_design()) + ) + d_ps, sd_ps = T.TestEfficientBootstrapReplayDesigns._psu_survey_frame(strata=True) + ed["stratified_psu_survey_all"] = _all_bundle( + T._efficient_boot_fit_time(d_ps, survey_design=sd_ps) + ) + d_fpc, sd_fpc = T.TestEfficientBootstrapReplayDesigns._psu_survey_frame(fpc=200.0) + ed["fpc_survey_all"] = _all_bundle(T._efficient_boot_fit_time(d_fpc, survey_design=sd_fpc)) + ed["fractional_balance_e1_es"] = _es_bundle( + T._efficient_boot_fit_time( + T._efficient_fractional_panel(), aggregate="event_study", balance_e=1 + ) + ) + out["EfficientDiD"] = ed + + # ---- ImputationDiD: the fit-time route the relay must reproduce --------- + im: Dict[str, Any] = {} + ip = T._imputation_panel() + boot_kw = {"n_bootstrap": 19, "seed": 1} + im["default_all"] = _all_bundle(T._fit_imputation(ip, est_kw=boot_kw, aggregate="all")) + im["default_plain_overall"] = _overall(T._fit_imputation(ip, est_kw=boot_kw)) + im["pretrends_all"] = _all_bundle( + T._fit_imputation(ip, est_kw={**boot_kw, "pretrends": True}, aggregate="all") + ) + im["pretrends_plain_overall"] = _overall( + T._fit_imputation(ip, est_kw={**boot_kw, "pretrends": True}) + ) + d_sw, _ = T._imputation_survey_panel() + im["survey_tsl_all"] = _all_bundle( + T._fit_imputation( + d_sw, est_kw=boot_kw, survey_design=T._imputation_survey_design(), aggregate="all" + ) + ) + im["survey_tsl_plain_overall"] = _overall( + T._fit_imputation(d_sw, est_kw=boot_kw, survey_design=T._imputation_survey_design()) + ) + out["ImputationDiD"] = im + + # ---- TwoStageDiD ------------------------------------------------------- + ts: Dict[str, Any] = {} + tp = T._twostage_panel() + ts["default_all"] = _all_bundle(T._fit_twostage(tp, est_kw=boot_kw, aggregate="all")) + ts["default_plain_overall"] = _overall(T._fit_twostage(tp, est_kw=boot_kw)) + ts["pretrends_all"] = _all_bundle( + T._fit_twostage(tp, est_kw={**boot_kw, "pretrends": True}, aggregate="all") + ) + ts["prop5_all"] = _all_bundle( + T._fit_twostage(T._twostage_prop5_panel(), est_kw=boot_kw, aggregate="all") + ) + ts["prop5_plain_overall"] = _overall(T._fit_twostage(T._twostage_prop5_panel(), est_kw=boot_kw)) + out["TwoStageDiD"] = ts + + # ---- ContinuousDiD ----------------------------------------------------- + co: Dict[str, Any] = {} + cp = T._cont_panel() + cont_kw = {"n_bootstrap": 30, "seed": 3} + + def _cont_bundle(r: Any) -> Dict[str, Any]: + b = _es_bundle(r) + b["overall"] = _overall(r) + b["overall_acrt"] = _f(getattr(r, "overall_acrt", np.nan)) + b["overall_acrt_se"] = _f(getattr(r, "overall_acrt_se", np.nan)) + return b + + co["default_eventstudy"] = _cont_bundle(T._fit_cont(cp, est_kw=cont_kw, aggregate="eventstudy")) + co["default_plain"] = _cont_bundle(T._fit_cont(cp, est_kw=cont_kw)) + out["ContinuousDiD"] = co + return out + + +def _git_head() -> str: + try: + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=HERE, text=True, stderr=subprocess.DEVNULL + ).strip() + except Exception: # noqa: BLE001 + return "unknown" + + +if __name__ == "__main__": + import diff_diff + + payload = { + "provenance": { + "captured_at_head": _git_head(), + "diff_diff_version": diff_diff.__version__, + "weight_backend": effective_weight_backend(), + "numpy": np.__version__, + }, + "analytic": capture_analytic(), + "boot": capture_boot(), + } + print(json.dumps(payload, indent=1, sort_keys=True)) diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index fed6ee02c..45bb24f39 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -32,3 +32,29 @@ capturing `2> ` from a real run and applying the same rules. There is deliberately NO 0.153.4 transcript: the format was first observed on that CLI, but the live pin is 0.162.1 and the older CLI is no longer installed. + +## `v4_aggregate_oracles.json` - pre-removal aggregation oracles (4.0 PR 3) + +Numeric reference for `tests/test_aggregate_contract.py`, captured by +`tests/_capture_v4_aggregate_oracles.py` on the UNMODIFIED tree at the commit +named in `provenance.captured_at_head` - i.e. while the deprecated fit-time +`fit(aggregate=...)` / `fit(balance_e=...)` route still existed and could serve +as an independent second implementation of every aggregation surface. Layout: + +- `analytic` - per estimator, the `fit(aggregate="all")` (ContinuousDiD: + `"eventstudy"`) and `balance_e=1` surfaces on every design arm the contract + file exercises. The NumPy and Rust captures agreed to rtol 1e-12 / + atol 1e-13, so one copy is stored; the post-fit kit recompute must reproduce + it after the removal. +- `boot` - keyed by weight backend (`"numpy"` / `"rust"`, the + `effective_weight_backend()` vocabulary), because the seeded multiplier + stream differs per backend: the fit-time bootstrapped surfaces the CS/EDiD + replays and the Imputation/TwoStage/ContinuousDiD relays must reproduce, plus + the overall quintets of bootstrapped PLAIN fits, which the always-on surface + materialization must leave unchanged. A pin whose backend key is absent skips. + +Rows are stored as `{label: {effect, se, t_stat, p_value, ci_lo, ci_hi, +n_obs|n_groups, df_used, cband_lo/hi}}` with NaN serialized as `null`; labels +are stringified numbers (`repr(float)` for fractional horizons). Re-capturing +on a post-removal tree is NOT a substitute: the fit-time route is gone, so +nothing independent remains to disagree with. diff --git a/tests/fixtures/v4_aggregate_oracles.json b/tests/fixtures/v4_aggregate_oracles.json new file mode 100644 index 000000000..71e9268ac --- /dev/null +++ b/tests/fixtures/v4_aggregate_oracles.json @@ -0,0 +1,10017 @@ +{ + "analytic": { + "CallawaySantAnna": { + "anticipation1_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.16215645467523254, + "ci_lo": -0.03617378917805543, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.21313113221560442, + "se": 0.05059537966454783, + "t_stat": 1.2450016813042433 + }, + "-2": { + "ci_hi": 0.13040474791524795, + "ci_lo": -0.12039901995021106, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.9376752677805849, + "se": 0.06398172870618214, + "t_stat": 0.07819207270082792 + }, + "-3": { + "ci_hi": 0.15112521874149912, + "ci_lo": -0.15077979064092137, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.9982107330753857, + "se": 0.07701799924993742, + "t_stat": 0.002242515411603801 + }, + "-4": { + "ci_hi": 0.17259215344718637, + "ci_lo": -0.21742570722209376, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.8217436524938793, + "se": 0.09949618047721573, + "t_stat": -0.2253028888137777 + }, + "0": { + "ci_hi": 1.6238338587609582, + "ci_lo": 1.4099865007439334, + "effect": 1.5169101797524458, + "n_groups": 4.0, + "p_value": 3.699718967459427e-170, + "se": 0.05455389989403515, + "t_stat": 27.8057147646433 + }, + "1": { + "ci_hi": 1.9803139761216155, + "ci_lo": 1.7353326515827125, + "effect": 1.857823313852164, + "n_groups": 4.0, + "p_value": 3.450291518119317e-194, + "se": 0.06249638423748716, + "t_stat": 29.72689278778767 + }, + "2": { + "ci_hi": 2.2916019077590333, + "ci_lo": 1.9779459480986163, + "effect": 2.134773927928825, + "n_groups": 3.0, + "p_value": 8.157759365200267e-157, + "se": 0.08001574573168058, + "t_stat": 26.679423011158732 + }, + "3": { + "ci_hi": 2.6214371422372893, + "ci_lo": 2.2290675525850934, + "effect": 2.4252523474111913, + "n_groups": 2.0, + "p_value": 1.0947709569017035e-129, + "se": 0.1000961223642774, + "t_stat": 24.2292337617738 + }, + "4": { + "ci_hi": 2.9414501991423885, + "ci_lo": 2.512945391902159, + "effect": 2.7271977955222737, + "n_groups": 1.0, + "p_value": 2.2342327100197705e-137, + "se": 0.10931445950543507, + "t_stat": 24.948188994034027 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.009899489929554682, + 0.0014181451479144305, + 1.7962010870062695e-05, + 0.0008272097767922567, + 0.0009571149337842271, + 0.0006504845372393448, + 0.0015578018056016977, + 0.001658920513869697, + 0.003938387070789138 + ], + [ + 0.0014181451479144305, + 0.005931772208463364, + -0.0016041354657931298, + 0.001043139715992548, + 0.001302221046313346, + 0.0007839814765109008, + 0.0003878155641285595, + 0.0013963852711583369, + 0.0021060996028580614 + ], + [ + 1.7962010870062695e-05, + -0.0016041354657931298, + 0.004093661608231491, + -0.0008755179531925678, + -0.0009275974387503431, + -0.0010884005146517633, + -0.0007291420134011654, + 0.00015510766539264986, + 0.0008487902045114534 + ], + [ + 0.0008272097767922567, + 0.001043139715992548, + -0.0008755179531925678, + 0.0025598924433997403, + 0.001566884417001228, + 0.0016182850516806785, + 0.0023131849334562336, + 0.0021132518896300337, + 0.002116463519493331 + ], + [ + 0.0009571149337842271, + 0.001302221046313346, + -0.0009275974387503431, + 0.001566884417001228, + 0.0029761279936484073, + 0.0023174098071227353, + 0.0031446906417378377, + 0.003351739456096941, + 0.0027336343834122685 + ], + [ + 0.0006504845372393448, + 0.0007839814765109008, + -0.0010884005146517633, + 0.0016182850516806785, + 0.0023174098071227353, + 0.003905798042759632, + 0.00368157999942266, + 0.004213778264835235, + 0.003634661181110358 + ], + [ + 0.0015578018056016977, + 0.0003878155641285595, + -0.0007291420134011654, + 0.0023131849334562336, + 0.0031446906417378377, + 0.00368157999942266, + 0.006402519564996956, + 0.0055122791922999174, + 0.004845535909388172 + ], + [ + 0.001658920513869697, + 0.0013963852711583369, + 0.00015510766539264986, + 0.0021132518896300337, + 0.003351739456096941, + 0.004213778264835235, + 0.0055122791922999174, + 0.010019233712364389, + 0.006076503520543358 + ], + [ + 0.003938387070789138, + 0.0021060996028580614, + 0.0008487902045114534, + 0.002116463519493331, + 0.0027336343834122685, + 0.003634661181110358, + 0.004845535909388172, + 0.006076503520543358, + 0.011949651056965403 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.001158549206537, + "ci_lo": 1.6456968521569708, + "df_used": null, + "effect": 1.8234277006817539, + "p_value": 6.251621614501271e-90, + "se": 0.09068067062798163, + "t_stat": 20.108229108300097 + }, + "4": { + "ci_hi": 1.7705350957130925, + "ci_lo": 1.3968571482584176, + "df_used": null, + "effect": 1.583696121985755, + "p_value": 5.595782544754999e-62, + "se": 0.09532775867367943, + "t_stat": 16.613168546288534 + }, + "5": { + "ci_hi": 1.555025020923708, + "ci_lo": 1.1795502831315854, + "df_used": null, + "effect": 1.3672876520276467, + "p_value": 3.1608615059507244e-46, + "se": 0.09578613197839843, + "t_stat": 14.274380056770596 + }, + "6": { + "ci_hi": 1.343333825178485, + "ci_lo": 0.9264836130975396, + "df_used": null, + "effect": 1.1349087191380123, + "p_value": 1.371527279081729e-26, + "se": 0.10634129386279717, + "t_stat": 10.672323778590519 + } + }, + "overall": { + "att": 1.5407179766413293, + "ci_hi": 1.6648589094422162, + "ci_lo": 1.4165770438404424, + "p_value": 1.0614623238290957e-130, + "se": 0.06333837447019167, + "t_stat": 24.325189737327765 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.16215645467523254, + "ci_lo": -0.03617378917805543, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.21313113221560442, + "se": 0.05059537966454783, + "t_stat": 1.2450016813042433 + }, + "-2": { + "ci_hi": 0.13040474791524795, + "ci_lo": -0.12039901995021106, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.9376752677805849, + "se": 0.06398172870618214, + "t_stat": 0.07819207270082792 + }, + "-3": { + "ci_hi": 0.15112521874149912, + "ci_lo": -0.15077979064092137, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.9982107330753857, + "se": 0.07701799924993742, + "t_stat": 0.002242515411603801 + }, + "-4": { + "ci_hi": 0.17259215344718637, + "ci_lo": -0.21742570722209376, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.8217436524938793, + "se": 0.09949618047721573, + "t_stat": -0.2253028888137777 + }, + "0": { + "ci_hi": 1.5499821105859704, + "ci_lo": 1.3578555834217447, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 2.2325981431362827e-193, + "se": 0.04901276979569397, + "t_stat": 29.66408250471068 + }, + "1": { + "ci_hi": 1.9062076201705753, + "ci_lo": 1.6834563420365758, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 5.922208367217076e-219, + "se": 0.056825349825566536, + "t_stat": 31.585058193448287 + }, + "2": { + "ci_hi": 2.25597724608233, + "ci_lo": 1.966785600885477, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 3.865135818581669e-180, + "se": 0.07377473450480726, + "t_stat": 28.619302226649467 + }, + "3": { + "ci_hi": 2.5420267744233493, + "ci_lo": 2.1754679645254207, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 2.1790525859688993e-140, + "se": 0.09351161878210465, + "t_stat": 25.22411011802289 + }, + "4": { + "ci_hi": 2.891765222268375, + "ci_lo": 2.445148849615699, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 2.6173950505761986e-121, + "se": 0.11393484170513579, + "t_stat": 23.420904404712505 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.009899489929554682, + 0.0014181451479144305, + 1.7962010870062695e-05, + 0.0008272097767922567, + 0.00012990515699197038, + -0.0001767252395529118, + 0.00136922250527329, + 0.00015679289707945984, + -0.0011655025362148886 + ], + [ + 0.0014181451479144305, + 0.005931772208463364, + -0.0016041354657931298, + 0.001043139715992548, + 0.00025908133032079815, + -0.00025915823948164665, + -0.00016227218117475977, + -0.00047757140817898024, + 0.0006039719860678242 + ], + [ + 1.7962010870062695e-05, + -0.0016041354657931298, + 0.004093661608231491, + -0.0008755179531925678, + -5.2079485557775425e-05, + -0.00021288256145919604, + -0.00019555049733657937, + 0.0002670041009863298, + 0.0006602109041830465 + ], + [ + 0.0008272097767922567, + 0.001043139715992548, + -0.0008755179531925678, + 0.0025598924433997403, + -0.0009930080263985117, + -0.0009416073917190606, + 0.00012801894702241804, + -0.00046309611763112037, + -0.0008627001959194525 + ], + [ + 0.00012990515699197038, + 0.00025908133032079815, + -5.2079485557775425e-05, + -0.0009930080263985117, + 0.00240225160304569, + 0.001692132781840569, + 0.0017656899409002383, + 0.002239277942519361, + 0.001670576325608205 + ], + [ + -0.0001767252395529118, + -0.00025915823948164665, + -0.00021288256145919604, + -0.0009416073917190606, + 0.001692132781840569, + 0.003229120382798015, + 0.002336229148550142, + 0.0030193445758969484, + 0.0024956522813723604 + ], + [ + 0.00136922250527329, + -0.00016227218117475977, + -0.00019555049733657937, + 0.00012801894702241804, + 0.0017656899409002383, + 0.002336229148550142, + 0.005442711451254799, + 0.004512076081287001, + 0.0030230559230661896 + ], + [ + 0.00015679289707945984, + -0.00047757140817898024, + 0.0002670041009863298, + -0.00046309611763112037, + 0.002239277942519361, + 0.0030193445758969484, + 0.004512076081287001, + 0.008744422847249671, + 0.004837919396035595 + ], + [ + -0.0011655025362148886, + 0.0006039719860678242, + 0.0006602109041830465, + -0.0008627001959194525, + 0.001670576325608205, + 0.0024956522813723604, + 0.0030230559230661896, + 0.004837919396035595, + 0.012981148154374351 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.291199373047453, + "ci_lo": 1.944049285596187, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 2.316557917855421e-126, + "se": 0.08856032309510324, + "t_stat": 23.911659932041392 + }, + "4": { + "ci_hi": 2.0617043593451987, + "ci_lo": 1.711862954885753, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 3.3273822810518135e-99, + "se": 0.089246896172315, + "t_stat": 21.141168354723906 + }, + "5": { + "ci_hi": 2.119567287249691, + "ci_lo": 1.6940862982609604, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 4.372566413685329e-69, + "se": 0.10854306312383041, + "t_stat": 17.56746804335104 + }, + "6": { + "ci_hi": 1.6377784847646388, + "ci_lo": 1.2215842196706277, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 2.4975231449802003e-41, + "se": 0.10617395737291561, + "t_stat": 13.465461659266898 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 2.021352186881718, + "ci_lo": 1.7950905785143174, + "p_value": 1.136922422856294e-239, + "se": 0.057720858687231855, + "t_stat": 33.05947669694189 + }, + "reference_event_times": null + }, + "default_balance_e1": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.16215645467523254, + "ci_lo": -0.03617378917805543, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.21313113221560442, + "se": 0.05059537966454783, + "t_stat": 1.2450016813042433 + }, + "-2": { + "ci_hi": 0.13040474791524795, + "ci_lo": -0.12039901995021106, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.9376752677805849, + "se": 0.06398172870618214, + "t_stat": 0.07819207270082792 + }, + "-3": { + "ci_hi": 0.15112521874149912, + "ci_lo": -0.15077979064092137, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.9982107330753857, + "se": 0.07701799924993742, + "t_stat": 0.002242515411603801 + }, + "-4": { + "ci_hi": 0.17259215344718637, + "ci_lo": -0.21742570722209376, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.8217436524938793, + "se": 0.09949618047721573, + "t_stat": -0.2253028888137777 + }, + "0": { + "ci_hi": 1.5499821105859704, + "ci_lo": 1.3578555834217447, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 2.2325981431362827e-193, + "se": 0.04901276979569397, + "t_stat": 29.66408250471068 + }, + "1": { + "ci_hi": 1.9062076201705753, + "ci_lo": 1.6834563420365758, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 5.922208367217076e-219, + "se": 0.056825349825566536, + "t_stat": 31.585058193448287 + }, + "2": { + "ci_hi": 2.25597724608233, + "ci_lo": 1.966785600885477, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 3.865135818581669e-180, + "se": 0.07377473450480726, + "t_stat": 28.619302226649467 + }, + "3": { + "ci_hi": 2.5420267744233493, + "ci_lo": 2.1754679645254207, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 2.1790525859688993e-140, + "se": 0.09351161878210465, + "t_stat": 25.22411011802289 + }, + "4": { + "ci_hi": 2.891765222268375, + "ci_lo": 2.445148849615699, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 2.6173950505761986e-121, + "se": 0.11393484170513579, + "t_stat": 23.420904404712505 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.009899489929554682, + 0.0014181451479144305, + 1.7962010870062695e-05, + 0.0008272097767922567, + 0.00012990515699197038, + -0.0001767252395529118, + 0.00136922250527329, + 0.00015679289707945984, + -0.0011655025362148886 + ], + [ + 0.0014181451479144305, + 0.005931772208463364, + -0.0016041354657931298, + 0.001043139715992548, + 0.00025908133032079815, + -0.00025915823948164665, + -0.00016227218117475977, + -0.00047757140817898024, + 0.0006039719860678242 + ], + [ + 1.7962010870062695e-05, + -0.0016041354657931298, + 0.004093661608231491, + -0.0008755179531925678, + -5.2079485557775425e-05, + -0.00021288256145919604, + -0.00019555049733657937, + 0.0002670041009863298, + 0.0006602109041830465 + ], + [ + 0.0008272097767922567, + 0.001043139715992548, + -0.0008755179531925678, + 0.0025598924433997403, + -0.0009930080263985117, + -0.0009416073917190606, + 0.00012801894702241804, + -0.00046309611763112037, + -0.0008627001959194525 + ], + [ + 0.00012990515699197038, + 0.00025908133032079815, + -5.2079485557775425e-05, + -0.0009930080263985117, + 0.00240225160304569, + 0.001692132781840569, + 0.0017656899409002383, + 0.002239277942519361, + 0.001670576325608205 + ], + [ + -0.0001767252395529118, + -0.00025915823948164665, + -0.00021288256145919604, + -0.0009416073917190606, + 0.001692132781840569, + 0.003229120382798015, + 0.002336229148550142, + 0.0030193445758969484, + 0.0024956522813723604 + ], + [ + 0.00136922250527329, + -0.00016227218117475977, + -0.00019555049733657937, + 0.00012801894702241804, + 0.0017656899409002383, + 0.002336229148550142, + 0.005442711451254799, + 0.004512076081287001, + 0.0030230559230661896 + ], + [ + 0.00015679289707945984, + -0.00047757140817898024, + 0.0002670041009863298, + -0.00046309611763112037, + 0.002239277942519361, + 0.0030193445758969484, + 0.004512076081287001, + 0.008744422847249671, + 0.004837919396035595 + ], + [ + -0.0011655025362148886, + 0.0006039719860678242, + 0.0006602109041830465, + -0.0008627001959194525, + 0.001670576325608205, + 0.0024956522813723604, + 0.0030230559230661896, + 0.004837919396035595, + 0.012981148154374351 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "reference_event_times": null + }, + "nyt_anticipation1_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.16898652052958094, + "ci_lo": -0.048480857757560364, + "effect": 0.060252831386010286, + "n_groups": 4.0, + "p_value": 0.27744402968527837, + "se": 0.055477391422111895, + "t_stat": 1.0860790286184043 + }, + "-2": { + "ci_hi": 0.1531272415947705, + "ci_lo": -0.14116681702474623, + "effect": 0.005980212285012129, + "n_groups": 3.0, + "p_value": 0.9365116284230134, + "se": 0.07507639450032519, + "t_stat": 0.07965502771961466 + }, + "-3": { + "ci_hi": 0.15612812234521145, + "ci_lo": -0.1611207229084996, + "effect": -0.002496300281644076, + "n_groups": 2.0, + "p_value": 0.9753937140992579, + "se": 0.08093231502112526, + "t_stat": -0.03084429601442245 + }, + "-4": { + "ci_hi": 0.17655605408274752, + "ci_lo": -0.15099909485656046, + "effect": 0.01277847961309353, + "n_groups": 1.0, + "p_value": 0.8784589892018173, + "se": 0.08356152243689711, + "t_stat": 0.15292301097964572 + }, + "0": { + "ci_hi": 1.6190218747793874, + "ci_lo": 1.3947871861554513, + "effect": 1.5069045304674193, + "n_groups": 4.0, + "p_value": 6.215168391036675e-153, + "se": 0.05720377782262088, + "t_stat": 26.342744969398215 + }, + "1": { + "ci_hi": 1.9848516008294237, + "ci_lo": 1.7257995457680584, + "effect": 1.855325573298741, + "n_groups": 4.0, + "p_value": 2.009892663515184e-173, + "se": 0.06608592226814752, + "t_stat": 28.07444474741002 + }, + "2": { + "ci_hi": 2.2916019077590333, + "ci_lo": 1.9779459480986163, + "effect": 2.134773927928825, + "n_groups": 3.0, + "p_value": 8.157759365200267e-157, + "se": 0.08001574573168058, + "t_stat": 26.679423011158732 + }, + "3": { + "ci_hi": 2.6214371422372893, + "ci_lo": 2.2290675525850934, + "effect": 2.4252523474111913, + "n_groups": 2.0, + "p_value": 1.0947709569017035e-129, + "se": 0.1000961223642774, + "t_stat": 24.2292337617738 + }, + "4": { + "ci_hi": 2.9414501991423885, + "ci_lo": 2.512945391902159, + "effect": 2.7271977955222737, + "n_groups": 1.0, + "p_value": 2.2342327100197705e-137, + "se": 0.10931445950543507, + "t_stat": 24.948188994034027 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.006982528031972061, + -0.002062635923061642, + -0.0008521579095748296, + 0.00031240630315058606, + 0.00032769885398484857, + -0.0003782597916087535, + 0.001150538836179655, + 0.000870761236611041, + 0.0013127956902630456 + ], + [ + -0.002062635923061642, + 0.006550039614678656, + -0.004568553311449108, + 0.00127478987494083, + 0.0006331677330158608, + 0.0011507376755470764, + 0.0006032674293146269, + 0.0007183207911100319, + 0.0007248508788632691 + ], + [ + -0.0008521579095748296, + -0.004568553311449108, + 0.005636465011168458, + -0.0020405600439165616, + -0.0015467054943632346, + -0.0016363909584042869, + -0.0009574869853712986, + -0.0004151160830631299, + -7.20422781517411e-05 + ], + [ + 0.00031240630315058606, + 0.00127478987494083, + -0.0020405600439165616, + 0.0030777409590022147, + 0.0019075611287429478, + 0.0019971237841853806, + 0.0023832983249850986, + 0.001898141749956585, + 0.0015406091619219232 + ], + [ + 0.00032769885398484857, + 0.0006331677330158608, + -0.0015467054943632346, + 0.0019075611287429478, + 0.003272272197179773, + 0.0026678538938474283, + 0.0031907550177352586, + 0.002930252306232027, + 0.0022056032396164287 + ], + [ + -0.0003782597916087535, + 0.0011507376755470764, + -0.0016363909584042869, + 0.0019971237841853806, + 0.0026678538938474283, + 0.004367349122031634, + 0.003556177967363973, + 0.004042714330630157, + 0.003316364854418563 + ], + [ + 0.001150538836179655, + 0.0006032674293146269, + -0.0009574869853712986, + 0.0023832983249850986, + 0.0031907550177352586, + 0.003556177967363973, + 0.006402519564996956, + 0.0055122791922999174, + 0.004845535909388172 + ], + [ + 0.000870761236611041, + 0.0007183207911100319, + -0.0004151160830631299, + 0.001898141749956585, + 0.002930252306232027, + 0.004042714330630157, + 0.0055122791922999174, + 0.010019233712364389, + 0.006076503520543358 + ], + [ + 0.0013127956902630456, + 0.0007248508788632691, + -7.20422781517411e-05, + 0.0015406091619219232, + 0.0022056032396164287, + 0.003316364854418563, + 0.004845535909388172, + 0.006076503520543358, + 0.011949651056965403 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 1.981973943645983, + "ci_lo": 1.6659436003511565, + "df_used": null, + "effect": 1.8239587719985697, + "p_value": 2.531136739367264e-113, + "se": 0.0806214669727693, + "t_stat": 22.623735842150204 + }, + "4": { + "ci_hi": 1.7445034465870992, + "ci_lo": 1.4003968549396029, + "df_used": null, + "effect": 1.572450150763351, + "p_value": 9.380506582158234e-72, + "se": 0.08778390683751462, + "t_stat": 17.91273830719233 + }, + "5": { + "ci_hi": 1.5494440248799441, + "ci_lo": 1.1811692108788567, + "df_used": null, + "effect": 1.3653066178794004, + "p_value": 7.556702913570926e-48, + "se": 0.09394938297488932, + "t_stat": 14.532363860700586 + }, + "6": { + "ci_hi": 1.343333825178485, + "ci_lo": 0.9264836130975396, + "df_used": null, + "effect": 1.1349087191380123, + "p_value": 1.371527279081729e-26, + "se": 0.10634129386279717, + "t_stat": 10.672323778590519 + } + }, + "overall": { + "att": 1.5373308897077675, + "ci_hi": 1.6631713120329592, + "ci_lo": 1.4114904673825759, + "p_value": 1.0690753370413593e-126, + "se": 0.06420547689539442, + "t_stat": 23.943921360672007 + }, + "reference_event_times": null + }, + "rcs_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2403450441027863, + "ci_lo": -0.15489149333418528, + "effect": 0.0427267753843005, + "n_groups": 3.0, + "p_value": 0.6717400281394849, + "se": 0.10082750003432384, + "t_stat": 0.4237611303439577 + }, + "-2": { + "ci_hi": 0.17115957452791575, + "ci_lo": -0.3048366237302024, + "effect": -0.06683852460114334, + "n_groups": 2.0, + "p_value": 0.5820250319711249, + "se": 0.12142983289813371, + "t_stat": -0.550429190306253 + }, + "-3": { + "ci_hi": 0.35558895871387397, + "ci_lo": -0.5032241289119656, + "effect": -0.07381758509904579, + "n_groups": 1.0, + "p_value": 0.7361699127881354, + "se": 0.21908899714485766, + "t_stat": -0.33692967725914114 + }, + "0": { + "ci_hi": 1.5865265533564499, + "ci_lo": 1.2041504592952552, + "effect": 1.3953385063258525, + "n_groups": 3.0, + "p_value": 2.0566559862275245e-46, + "se": 0.09754671439815442, + "t_stat": 14.304310657051227 + }, + "1": { + "ci_hi": 1.689486444970567, + "ci_lo": 1.2566722806147976, + "effect": 1.4730793627926824, + "n_groups": 3.0, + "p_value": 1.3286466967659662e-40, + "se": 0.11041380550095627, + "t_stat": 13.341441825224694 + }, + "2": { + "ci_hi": 1.6686972798572384, + "ci_lo": 1.1200096679184746, + "effect": 1.3943534738878565, + "n_groups": 2.0, + "p_value": 2.2459160911686854e-23, + "se": 0.13997390162950485, + "t_stat": 9.961524667495182 + }, + "3": { + "ci_hi": 1.747716353586616, + "ci_lo": 0.895616157001141, + "effect": 1.3216662552938785, + "n_groups": 1.0, + "p_value": 1.2012334137479834e-09, + "se": 0.21737649347302626, + "t_stat": 6.080079010281215 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.04799998866993945, + 0.0028663044393755083, + 0.0034510262203502843, + -0.008883550229415534, + -0.008883550229415534, + -0.0133253253441233, + -0.0266506506882466 + ], + [ + 0.0028663044393755083, + 0.014745204317668672, + -0.0016881921246718495, + -1.6328703418135744e-05, + -0.0010759168507964553, + -0.00171569726828955, + 0.0 + ], + [ + 0.0034510262203502843, + -0.0016881921246718495, + 0.010166184763171573, + -0.002316583035464825, + -0.0024658163560372474, + -0.00252054780176246, + -0.003093275322057309 + ], + [ + -0.008883550229415534, + -1.6328703418135744e-05, + -0.002316583035464825, + 0.00951536148987511, + 0.006483631048457987, + 0.008909532790016333, + 0.011976825551472843 + ], + [ + -0.008883550229415534, + -0.0010759168507964553, + -0.0024658163560372474, + 0.006483631048457987, + 0.012191208445203, + 0.010851395237176778, + 0.01380418913898376 + ], + [ + -0.0133253253441233, + -0.00171569726828955, + -0.00252054780176246, + 0.008909532790016333, + 0.010851395237176778, + 0.0195926931373863, + 0.02070628370847564 + ], + [ + -0.0266506506882466, + 0.0, + -0.003093275322057309, + 0.011976825551472843, + 0.01380418913898376, + 0.02070628370847564, + 0.04725253991462863 + ] + ], + "event_study_vcov_index": [ + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "3": { + "ci_hi": 1.7023879448065795, + "ci_lo": 0.9093105841362874, + "df_used": null, + "effect": 1.3058492644714335, + "p_value": 1.0865162277084667e-10, + "se": 0.20231937089813515, + "t_stat": 6.454395635348775 + }, + "4": { + "ci_hi": 1.7143549504307611, + "ci_lo": 1.166561687346286, + "df_used": null, + "effect": 1.4404583188885236, + "p_value": 6.503396978963198e-25, + "se": 0.1397457472191832, + "t_stat": 10.307707730305719 + }, + "5": { + "ci_hi": 2.0063369583151927, + "ci_lo": 1.1645178375587, + "df_used": null, + "effect": 1.5854273979369462, + "p_value": 1.553008079232467e-13, + "se": 0.21475372185322136, + "t_stat": 7.382537467828125 + } + }, + "overall": { + "att": 1.4128474233805774, + "ci_hi": 1.6211217491808865, + "ci_lo": 1.2045730975802684, + "p_value": 2.4552846403822455e-40, + "se": 0.10626436375522731, + "t_stat": 13.295590106152376 + }, + "reference_event_times": null + }, + "survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.06305242208539347, + "ci_lo": -0.5490449829528414, + "effect": -0.24299628043372395, + "n_groups": 2.0, + "p_value": 0.11175118200872357, + "se": 0.14436904451889626, + "t_stat": -1.6831605504039926 + }, + "-2": { + "ci_hi": 0.3350718459671376, + "ci_lo": -0.2633067648049015, + "effect": 0.03588254058111806, + "n_groups": 2.0, + "p_value": 0.8025443593928684, + "se": 0.1411333352937636, + "t_stat": 0.2542456784318881 + }, + "-3": { + "ci_hi": 0.19793310050233898, + "ci_lo": -0.5516009118064895, + "effect": -0.1768339056520753, + "n_groups": 1.0, + "p_value": 0.33206463602490016, + "se": 0.1767847867034837, + "t_stat": -1.0002778460155284 + }, + "-4": { + "ci_hi": 0.6478420295428329, + "ci_lo": -0.18534467096860502, + "effect": 0.23124867928711396, + "n_groups": 1.0, + "p_value": 0.2565066902270988, + "se": 0.19651507565397106, + "t_stat": 1.1767477813982208 + }, + "0": { + "ci_hi": 2.539200804244224, + "ci_lo": 2.055758501083632, + "effect": 2.297479652663928, + "n_groups": 2.0, + "p_value": 8.534005357227127e-13, + "se": 0.1140245046177732, + "t_stat": 20.148999202981983 + }, + "1": { + "ci_hi": 2.3684274400757257, + "ci_lo": 1.8848236369228923, + "effect": 2.126625538499309, + "n_groups": 2.0, + "p_value": 2.8158800286713316e-12, + "se": 0.11406259594013106, + "t_stat": 18.644372600598427 + }, + "2": { + "ci_hi": 2.4296734240114835, + "ci_lo": 1.991857868303597, + "effect": 2.2107656461575402, + "n_groups": 2.0, + "p_value": 3.3410990731014977e-13, + "se": 0.1032629985567557, + "t_stat": 21.40907853787001 + }, + "3": { + "ci_hi": 2.4272117780839584, + "ci_lo": 1.5981841492089865, + "effect": 2.0126979636464726, + "n_groups": 1.0, + "p_value": 1.834373613234197e-08, + "se": 0.1955341187126412, + "t_stat": 10.293333853435332 + }, + "4": { + "ci_hi": 2.2581507682745747, + "ci_lo": 1.6255363921826604, + "effect": 1.9418435802286176, + "n_groups": 1.0, + "p_value": 6.295655126959685e-10, + "se": 0.14920816895082636, + "t_stat": 13.014324844831915 + } + }, + "event_study_df": 16.0, + "event_study_vcov": [ + [ + 0.03861817495928597, + -0.021365623038974126, + 0.0042117990032355355, + -0.012967146006298651, + 0.00827494055165137, + 0.0009206953628820844, + 0.010303204607573075, + -0.0011550709391477945, + 0.005691841846969219 + ], + [ + -0.021365623038974126, + 0.03125286080979622, + -0.015122597193305243, + 0.017472268577626102, + -0.011232408987329663, + -0.0010544032984044866, + -0.013558945269131846, + -0.00906892563503207, + -0.007455894336591355 + ], + [ + 0.0042117990032355355, + -0.015122597193305243, + 0.019918618331141894, + -0.012670318924642418, + 0.006624329308282802, + 0.002215918716922371, + 0.005448531417766656, + 0.015499209743999747, + 0.005239687227570692 + ], + [ + -0.012967146006298651, + 0.017472268577626102, + -0.012670318924642418, + 0.020842421015299047, + -0.011459445052354291, + -0.00389443927122821, + -0.008075958598388753, + -0.00832417806279835, + 0.00043026738464958 + ], + [ + 0.00827494055165137, + -0.011232408987329663, + 0.006624329308282802, + -0.011459445052354291, + 0.013001587653328582, + 0.006791784084885521, + 0.006793856776640319, + 0.00968029338087329, + 0.0012905597546445317 + ], + [ + 0.0009206953628820844, + -0.0010544032984044866, + 0.002215918716922371, + -0.00389443927122821, + 0.006791784084885521, + 0.013010275792601602, + 0.0006308325080012554, + 0.010624647518131438, + 0.0012869544023455094 + ], + [ + 0.010303204607573075, + -0.013558945269131846, + 0.005448531417766656, + -0.008075958598388753, + 0.006793856776640319, + 0.0006308325080012554, + 0.010663246870932532, + 0.006359533491099557, + 0.005028141534539605 + ], + [ + -0.0011550709391477945, + -0.00906892563503207, + 0.015499209743999747, + -0.00832417806279835, + 0.00968029338087329, + 0.010624647518131438, + 0.006359533491099557, + 0.03823359158072926, + 0.019474129949428095 + ], + [ + 0.005691841846969219, + -0.007455894336591355, + 0.005239687227570692, + 0.00043026738464958, + 0.0012905597546445317, + 0.0012869544023455094, + 0.005028141534539605, + 0.019474129949428095, + 0.022263077681658344 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "4": { + "ci_hi": 2.3932158987173153, + "ci_lo": 1.7839734164980532, + "df_used": 16.0, + "effect": 2.0885946576076844, + "p_value": 1.2243052838757856e-10, + "se": 0.14369568358621185, + "t_stat": 14.534846179666966 + }, + "6": { + "ci_hi": 2.574596615542614, + "ci_lo": 1.9503444240305765, + "df_used": 16.0, + "effect": 2.262470519786595, + "p_value": 5.318287531848915e-11, + "se": 0.14723586750345774, + "t_stat": 15.366300060910515 + } + }, + "overall": { + "att": 2.1521431960690123, + "ci_hi": 2.340781870520201, + "ci_lo": 1.9635045216178235, + "p_value": 5.0187150689601094e-14, + "se": 0.08898448176929655, + "t_stat": 24.185601278756828 + }, + "reference_event_times": null + }, + "unbalanced_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.16012719694144062, + "ci_lo": -0.09078378126266735, + "effect": 0.034671707839386626, + "n_groups": 4.0, + "p_value": 0.5838069214176409, + "se": 0.06302869996810885, + "t_stat": 0.5500939707931427 + }, + "-2": { + "ci_hi": 0.14035515384162178, + "ci_lo": -0.16776653508017997, + "effect": -0.013705690619279079, + "n_groups": 3.0, + "p_value": 0.8599018175846732, + "se": 0.07739999909020026, + "t_stat": -0.17707610827368056 + }, + "-3": { + "ci_hi": 0.25409114937877186, + "ci_lo": -0.11442174474108975, + "effect": 0.06983470231884105, + "n_groups": 2.0, + "p_value": 0.4528538399580236, + "se": 0.09257023667958403, + "t_stat": 0.7543969295504976 + }, + "-4": { + "ci_hi": 0.27954685715066224, + "ci_lo": -0.22588498625503867, + "effect": 0.026830935447811788, + "n_groups": 1.0, + "p_value": 0.8331764965116059, + "se": 0.1269642015680625, + "t_stat": 0.21132677649635245 + }, + "0": { + "ci_hi": 1.583633352529269, + "ci_lo": 1.3551117136955353, + "effect": 1.4693725331124021, + "n_groups": 4.0, + "p_value": 5.401371013270853e-40, + "se": 0.057404510210610285, + "t_stat": 25.59681334657242 + }, + "1": { + "ci_hi": 1.95535841775937, + "ci_lo": 1.6577079699959099, + "effect": 1.8065331938776399, + "n_groups": 4.0, + "p_value": 3.097701291949369e-38, + "se": 0.07476962906021313, + "t_stat": 24.16132347564291 + }, + "2": { + "ci_hi": 2.313352493326447, + "ci_lo": 1.9360514513136506, + "effect": 2.124701972320049, + "n_groups": 3.0, + "p_value": 5.4649059574744875e-36, + "se": 0.09477781460536361, + "t_stat": 22.417714326574153 + }, + "3": { + "ci_hi": 2.6087141999545014, + "ci_lo": 2.1012301789584176, + "effect": 2.3549721894564595, + "n_groups": 2.0, + "p_value": 2.1033523976880707e-30, + "se": 0.12747970745206683, + "t_stat": 18.473310274436766 + }, + "4": { + "ci_hi": 3.106089460328497, + "ci_lo": 2.46405333636247, + "effect": 2.7850713983454836, + "n_groups": 1.0, + "p_value": 1.5233162640510215e-28, + "se": 0.16127912184545357, + "t_stat": 17.268641882948064 + } + }, + "event_study_df": 79.0, + "event_study_vcov": [ + [ + 0.016119908479815606, + 0.0035942753027381385, + -0.0012718892569674746, + 0.0005483518851426506, + 0.0012760551811625178, + 0.0012916447901105146, + 0.0030506250361085735, + 0.0017616109124787799, + -0.00042836426353781525 + ], + [ + 0.0035942753027381385, + 0.008569248718914206, + -0.002874296420655473, + 0.0014917550301567185, + -1.9541673634603666e-05, + -0.00034010869666223755, + -0.0003508399474333856, + -0.0009770107176791537, + 0.0010140565269676507 + ], + [ + -0.0012718892569674746, + -0.002874296420655473, + 0.005990759859163002, + -0.0010372234180487227, + 0.00031170187300190704, + -0.00033854881182831793, + 0.00019062040642808453, + 0.0007906542136677535, + 0.0006324857211530199 + ], + [ + 0.0005483518851426506, + 0.0014917550301567185, + -0.0010372234180487227, + 0.003972617019669884, + -0.001566557370020595, + -0.0014157252516670714, + -0.0002414496184740009, + -0.0017430671876029575, + -0.003303599620206488 + ], + [ + 0.0012760551811625178, + -1.9541673634603666e-05, + 0.00031170187300190704, + -0.001566557370020595, + 0.00329527779252006, + 0.0028345215091216294, + 0.003041351380631268, + 0.004132688308340615, + 0.0038745930942409184 + ], + [ + 0.0012916447901105146, + -0.00034010869666223755, + -0.00033854881182831793, + -0.0014157252516670714, + 0.0028345215091216294, + 0.005590497429801868, + 0.004398402134370539, + 0.005961173311620288, + 0.0048140845687418745 + ], + [ + 0.0030506250361085735, + -0.0003508399474333856, + 0.00019062040642808453, + -0.0002414496184740009, + 0.003041351380631268, + 0.004398402134370539, + 0.008982834141368672, + 0.008950138054963279, + 0.0065743580437580525 + ], + [ + 0.0017616109124787799, + -0.0009770107176791537, + 0.0007906542136677535, + -0.0017430671876029575, + 0.004132688308340615, + 0.005961173311620288, + 0.008950138054963279, + 0.016251075812064544, + 0.01085446502511428 + ], + [ + -0.00042836426353781525, + 0.0010140565269676507, + 0.0006324857211530199, + -0.003303599620206488, + 0.0038745930942409184, + 0.0048140845687418745, + 0.0065743580437580525, + 0.01085446502511428, + 0.026010955143240665 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.4509080543538264, + "ci_lo": 1.9701921490451717, + "df_used": 79.0, + "effect": 2.210550101699499, + "p_value": 3.773054485148066e-30, + "se": 0.1207555714877882, + "t_stat": 18.305988489508728 + }, + "4": { + "ci_hi": 2.0227664116733526, + "ci_lo": 1.5877739785566771, + "df_used": 79.0, + "effect": 1.805270195115015, + "p_value": 2.375021142908374e-27, + "se": 0.10926986037655856, + "t_stat": 16.521208949053403 + }, + "5": { + "ci_hi": 2.2002394561757734, + "ci_lo": 1.686890232065834, + "df_used": 79.0, + "effect": 1.9435648441208038, + "p_value": 5.969424344121814e-25, + "se": 0.12895304325411575, + "t_stat": 15.071880392080407 + }, + "6": { + "ci_hi": 1.7179628664250794, + "ci_lo": 1.1502559144336753, + "df_used": 79.0, + "effect": 1.4341093904293774, + "p_value": 8.583408496949165e-16, + "se": 0.1426076746541095, + "t_stat": 10.056326869558495 + } + }, + "overall": { + "att": 1.926624614441337, + "ci_hi": 2.0792031887291786, + "ci_lo": 1.7740460401534954, + "p_value": 1.9560163450676445e-39, + "se": 0.0766553081828663, + "t_stat": 25.133609923597813 + }, + "reference_event_times": null + }, + "universal_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.0, + "n_groups": 0.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "-2": { + "ci_hi": 0.03617378917805543, + "ci_lo": -0.16215645467523254, + "effect": -0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.21313113221560442, + "se": 0.05059537966454783, + "t_stat": -1.2450016813042433 + }, + "-3": { + "ci_hi": 0.06838126948770742, + "ci_lo": -0.20720337839548894, + "effect": -0.06941105445389076, + "n_groups": 3.0, + "p_value": 0.32349272599668843, + "se": 0.07030349793592457, + "t_stat": -0.9873058452532875 + }, + "-4": { + "ci_hi": 0.07343770410619782, + "ci_lo": -0.2871278687484276, + "effect": -0.10684508232111489, + "n_groups": 2.0, + "p_value": 0.24540693448700368, + "se": 0.0919827036870883, + "t_stat": -1.161577971056235 + }, + "-5": { + "ci_hi": 0.07775474760100509, + "ci_lo": -0.4812942317749539, + "effect": -0.2017697420869744, + "n_groups": 1.0, + "p_value": 0.15713744806197613, + "se": 0.1426171561788038, + "t_stat": -1.41476486765736 + }, + "0": { + "ci_hi": 1.5499821105859704, + "ci_lo": 1.3578555834217447, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 2.2325981431362827e-193, + "se": 0.04901276979569397, + "t_stat": 29.66408250471068 + }, + "1": { + "ci_hi": 1.9062076201705753, + "ci_lo": 1.6834563420365758, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 5.922208367217076e-219, + "se": 0.056825349825566536, + "t_stat": 31.585058193448287 + }, + "2": { + "ci_hi": 2.25597724608233, + "ci_lo": 1.966785600885477, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 3.865135818581669e-180, + "se": 0.07377473450480726, + "t_stat": 28.619302226649467 + }, + "3": { + "ci_hi": 2.5420267744233493, + "ci_lo": 2.1754679645254207, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 2.1790525859688993e-140, + "se": 0.09351161878210465, + "t_stat": 25.22411011802289 + }, + "4": { + "ci_hi": 2.891765222268375, + "ci_lo": 2.445148849615699, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 2.6173950505761986e-121, + "se": 0.11393484170513579, + "t_stat": 23.420904404712505 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.020339653236529313, + 0.008299600016515161, + 0.004644459180849902, + 0.0035261611838809707, + 0.0006806452582775707, + 0.0013185834869704068, + -0.0030392506904203674, + -0.0016058600966099958, + -0.00222980765181627 + ], + [ + 0.008299600016515161, + 0.008460817777586687, + 0.004608171451967142, + 0.0022419298162267513, + 0.0010036353834108813, + 0.0013947123761282787, + 4.5797789597992955e-05, + -0.000533115979999261, + -0.0026879714502901484 + ], + [ + 0.004644459180849902, + 0.004608171451967142, + 0.00494258182202655, + 0.0015444919316732438, + 0.0010237466370474947, + 0.00114150703204825, + 0.00013947087589322887, + -0.00025656594934418523, + -0.001791980966860099 + ], + [ + 0.0035261611838809707, + 0.0022419298162267513, + 0.0015444919316732438, + 0.0025598924433997403, + 0.0009930080263985117, + 0.0009416073917190606, + -0.00012801894702241804, + 0.00046309611763112037, + 0.0008627001959194525 + ], + [ + 0.0006806452582775707, + 0.0010036353834108813, + 0.0010237466370474947, + 0.0009930080263985117, + 0.00240225160304569, + 0.001692132781840569, + 0.0017656899409002383, + 0.002239277942519361, + 0.001670576325608205 + ], + [ + 0.0013185834869704068, + 0.0013947123761282787, + 0.00114150703204825, + 0.0009416073917190606, + 0.001692132781840569, + 0.003229120382798015, + 0.002336229148550142, + 0.0030193445758969484, + 0.0024956522813723604 + ], + [ + -0.0030392506904203674, + 4.5797789597992955e-05, + 0.00013947087589322887, + -0.00012801894702241804, + 0.0017656899409002383, + 0.002336229148550142, + 0.005442711451254799, + 0.004512076081287001, + 0.0030230559230661896 + ], + [ + -0.0016058600966099958, + -0.000533115979999261, + -0.00025656594934418523, + 0.00046309611763112037, + 0.002239277942519361, + 0.0030193445758969484, + 0.004512076081287001, + 0.008744422847249671, + 0.004837919396035595 + ], + [ + -0.00222980765181627, + -0.0026879714502901484, + -0.001791980966860099, + 0.0008627001959194525, + 0.001670576325608205, + 0.0024956522813723604, + 0.0030230559230661896, + 0.004837919396035595, + 0.012981148154374351 + ] + ], + "event_study_vcov_index": [ + -5.0, + -4.0, + -3.0, + -2.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.291199373047453, + "ci_lo": 1.944049285596187, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 2.316557917855421e-126, + "se": 0.08856032309510324, + "t_stat": 23.911659932041392 + }, + "4": { + "ci_hi": 2.0617043593451987, + "ci_lo": 1.711862954885753, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 3.3273822810518135e-99, + "se": 0.089246896172315, + "t_stat": 21.141168354723906 + }, + "5": { + "ci_hi": 2.119567287249691, + "ci_lo": 1.6940862982609604, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 4.372566413685329e-69, + "se": 0.10854306312383041, + "t_stat": 17.56746804335104 + }, + "6": { + "ci_hi": 1.6377784847646388, + "ci_lo": 1.2215842196706277, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 2.4975231449802003e-41, + "se": 0.10617395737291561, + "t_stat": 13.465461659266898 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 2.021352186881718, + "ci_lo": 1.7950905785143174, + "p_value": 1.136922422856294e-239, + "se": 0.057720858687231855, + "t_stat": 33.05947669694189 + }, + "reference_event_times": [ + -1.0 + ] + } + }, + "ContinuousDiD": { + "anticipation1_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.5632543047168012, + "ci_lo": -0.18564639552062256, + "effect": 0.1888039545980893, + "p_value": 0.32303232796586, + "se": 0.191049607580715, + "t_stat": 0.98824570743137 + }, + "0": { + "ci_hi": 5.30547494834385, + "ci_lo": 4.169640550858567, + "effect": 4.7375577496012085, + "p_value": 4.350257064173576e-60, + "se": 0.2897589972174488, + "t_stat": 16.349993598459072 + }, + "1": { + "ci_hi": 5.317072613738213, + "ci_lo": 4.1600244549419285, + "effect": 4.738548534340071, + "p_value": 5.395693979499819e-58, + "se": 0.2951707704638792, + "t_stat": 16.053583242314772 + }, + "2": { + "ci_hi": 5.050399273403116, + "ci_lo": 4.0167442129497495, + "effect": 4.533571743176433, + "p_value": 3.0140883116910266e-66, + "se": 0.26369236083078684, + "t_stat": 17.192654837982417 + }, + "3": { + "ci_hi": 5.41701771690124, + "ci_lo": 3.926300352709818, + "effect": 4.671659034805529, + "p_value": 1.0985838480664398e-34, + "se": 0.38029202984085686, + "t_stat": 12.284399009783368 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": null, + "overall": { + "att": 3.670258050724984, + "ci_hi": 4.093891186267547, + "ci_lo": 3.2466249151824207, + "p_value": 1.1417921640251344e-64, + "se": 0.21614332655300164, + "t_stat": 16.980667917244165 + }, + "reference_event_times": null + }, + "covariate_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.8466320434003214, + "ci_lo": 0.03107198281704182, + "effect": 0.4388520131086816, + "p_value": 0.03491790738504462, + "se": 0.20805485891993764, + "t_stat": 2.1093091283081153 + }, + "-2": { + "ci_hi": 0.8949050740331412, + "ci_lo": -0.29675595081445433, + "effect": 0.2990745616093435, + "p_value": 0.32521607850518663, + "se": 0.30400074548493383, + "t_stat": 0.9837954875152288 + }, + "0": { + "ci_hi": 4.75055556934943, + "ci_lo": 3.5946059553831886, + "effect": 4.17258076236631, + "p_value": 1.8784249668176888e-45, + "se": 0.29489052428621776, + "t_stat": 14.149592539353502 + }, + "1": { + "ci_hi": 4.8345246260246055, + "ci_lo": 3.622062884176901, + "effect": 4.228293755100753, + "p_value": 1.5295257670198974e-42, + "se": 0.3093071483485022, + "t_stat": 13.670210267292804 + }, + "2": { + "ci_hi": 4.8129176265252065, + "ci_lo": 3.486334315160728, + "effect": 4.149625970842967, + "p_value": 1.4532437701217397e-34, + "se": 0.3384203285949126, + "t_stat": 12.26175149723363 + }, + "3": { + "ci_hi": 5.672056393915652, + "ci_lo": 3.8489347206733435, + "effect": 4.760495557294497, + "p_value": 1.3729802053226833e-24, + "se": 0.46509060564960863, + "t_stat": 10.235630433010668 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": null, + "overall": { + "att": 4.240788455640767, + "ci_hi": 4.8099854259752535, + "ci_lo": 3.6715914853062808, + "p_value": 2.7007827424712023e-48, + "se": 0.29041195390539787, + "t_stat": 14.602664933765814 + }, + "reference_event_times": null + }, + "default_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.18345278379253552, + "ci_lo": -0.6610708496665441, + "effect": -0.23880903293700426, + "p_value": 0.26766650634754985, + "se": 0.21544366124086317, + "t_stat": -1.1084523515872622 + }, + "-2": { + "ci_hi": 0.17899313361928743, + "ci_lo": -1.0125456115269085, + "effect": -0.4167762389538105, + "p_value": 0.17034010648367948, + "se": 0.3039695511103524, + "t_stat": -1.3711118019268482 + }, + "0": { + "ci_hi": 5.03076924232823, + "ci_lo": 4.044519050989685, + "effect": 4.537644146658957, + "p_value": 1.0308619836747304e-72, + "se": 0.2515990597577204, + "t_stat": 18.035219014842596 + }, + "1": { + "ci_hi": 5.037696997495454, + "ci_lo": 3.89498577951186, + "effect": 4.466341388503657, + "p_value": 5.516726744027995e-53, + "se": 0.2915133203969955, + "t_stat": 15.321225741661475 + }, + "2": { + "ci_hi": 5.402617560241142, + "ci_lo": 4.357209148111314, + "effect": 4.879913354176228, + "p_value": 8.580400877050968e-75, + "se": 0.2666907199254364, + "t_stat": 18.298024601458177 + }, + "3": { + "ci_hi": 4.825180975813696, + "ci_lo": 3.195317878669007, + "effect": 4.010249427241352, + "p_value": 5.165463128438495e-22, + "se": 0.4157890425540575, + "t_stat": 9.644913686536055 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": null, + "overall": { + "att": 4.641957051061234, + "ci_hi": 5.123728191937191, + "ci_lo": 4.160185910185277, + "p_value": 1.5261011214223842e-79, + "se": 0.24580611923285658, + "t_stat": 18.884627712070195 + }, + "reference_event_times": null + }, + "empty_post_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": -0.07599983738405297, + "p_value": null, + "se": null, + "t_stat": null + }, + "-2": { + "ci_hi": null, + "ci_lo": null, + "effect": -0.03497868484900135, + "p_value": null, + "se": null, + "t_stat": null + }, + "-3": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.032986095023899153, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": null, + "overall": { + "att": null, + "ci_hi": null, + "ci_lo": null, + "p_value": null, + "se": null, + "t_stat": null + }, + "reference_event_times": null + }, + "survey_tsl_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.25733287333638755, + "ci_lo": -0.6642554534240335, + "effect": -0.20346129004382296, + "p_value": 0.38333257869972726, + "se": 0.23241944910681148, + "t_stat": -0.8754056118183104 + }, + "-2": { + "ci_hi": 0.6371493818107479, + "ci_lo": -1.0889861984323679, + "effect": -0.22591840831081, + "p_value": 0.6048643496860584, + "se": 0.4353217906459717, + "t_stat": -0.5189687563665739 + }, + "0": { + "ci_hi": 5.973028938394066, + "ci_lo": 4.013569507722805, + "effect": 4.993299223058435, + "p_value": 3.246693284087112e-17, + "se": 0.4941647677164561, + "t_stat": 10.10452292285538 + }, + "1": { + "ci_hi": 6.1397756445086955, + "ci_lo": 4.062715458133212, + "effect": 5.101245551320954, + "p_value": 2.1735245550567547e-16, + "se": 0.5238230240784919, + "t_stat": 9.73849051460663 + }, + "2": { + "ci_hi": 5.962609096234344, + "ci_lo": 3.921952556521221, + "effect": 4.942280826377782, + "p_value": 4.3837006332936423e-16, + "se": 0.5146422269079298, + "t_stat": 9.60333328275832 + }, + "3": { + "ci_hi": 6.319934893921338, + "ci_lo": 2.8498924166922945, + "effect": 4.584913655306816, + "p_value": 8.257561797152738e-07, + "se": 0.8751254085105956, + "t_stat": 5.239150424291793 + } + }, + "event_study_df": 106.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": null, + "overall": { + "att": 4.970179123231914, + "ci_hi": 5.932401595552254, + "ci_lo": 4.0079566509115745, + "p_value": 1.599954979442042e-17, + "se": 0.48533430912916814, + "t_stat": 10.24073309828409 + }, + "reference_event_times": null + } + }, + "EfficientDiD": { + "clustered_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.07345692105801568, + "ci_lo": -0.3420838450325637, + "effect": -0.134313461987274, + "n_groups": 2.0, + "p_value": 0.20514759243179237, + "se": 0.10600724538009676, + "t_stat": -1.267021527685992 + }, + "-2": { + "ci_hi": 0.2615490713261905, + "ci_lo": -0.041502571869040994, + "effect": 0.11002324972857475, + "n_groups": 2.0, + "p_value": 0.15469720219064959, + "se": 0.07731051325066791, + "t_stat": 1.4231343849941938 + }, + "-3": { + "ci_hi": 0.2867769840849672, + "ci_lo": 0.00042351549121141807, + "effect": 0.1436002497880893, + "n_groups": 2.0, + "p_value": 0.04932616233519001, + "se": 0.07305069655679274, + "t_stat": 1.9657615403632507 + }, + "-4": { + "ci_hi": 0.04605843215891903, + "ci_lo": -0.27927254536649015, + "effect": -0.11660705660378556, + "n_groups": 1.0, + "p_value": 0.16002014565445544, + "se": 0.08299412134395796, + "t_stat": -1.4050038088905517 + }, + "-5": { + "ci_hi": -0.028013067573306683, + "ci_lo": -0.45006607365998713, + "effect": -0.2390395706166469, + "n_groups": 1.0, + "p_value": 0.026409082760295216, + "se": 0.10766856162046362, + "t_stat": -2.220142695499842 + }, + "0": { + "ci_hi": 2.1895302268223618, + "ci_lo": 1.8649513450386688, + "effect": 2.0272407859305153, + "n_groups": 2.0, + "p_value": 2.246085494735641e-132, + "se": 0.08280225665979828, + "t_stat": 24.48291710526255 + }, + "1": { + "ci_hi": 2.376261607445581, + "ci_lo": 2.116113815800811, + "effect": 2.246187711623196, + "n_groups": 2.0, + "p_value": 4.1928789869885235e-251, + "se": 0.06636545204319641, + "t_stat": 33.84573814341205 + }, + "2": { + "ci_hi": 2.4288248251575846, + "ci_lo": 1.8712668915342754, + "effect": 2.15004585834593, + "n_groups": 1.0, + "p_value": 1.2710002118261154e-51, + "se": 0.14223678037485762, + "t_stat": 15.115962641164936 + }, + "3": { + "ci_hi": 2.8445382047477983, + "ci_lo": 2.397886784454424, + "effect": 2.621212494601111, + "n_groups": 1.0, + "p_value": 4.2082659776578635e-117, + "se": 0.11394378259409464, + "t_stat": 23.004436353835427 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3316219868783983, + "ci_lo": 2.1037326180078195, + "df_used": null, + "effect": 2.217677302443109, + "p_value": 0.0, + "se": 0.058136111343917704, + "t_stat": 38.14629584225065 + }, + "6": { + "ci_hi": 2.3650715410094088, + "ci_lo": 2.05207761627639, + "df_used": null, + "effect": 2.2085745786428994, + "p_value": 2.1080412209649096e-168, + "se": 0.07984685616722419, + "t_stat": 27.660131965840414 + } + }, + "overall": { + "att": 2.214245127895489, + "ci_hi": 2.327117735710094, + "ci_lo": 2.1013725200808837, + "p_value": 0.0, + "se": 0.057589123425190354, + "t_stat": 38.449015998165805 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2709856105933327, + "ci_lo": -0.05816572037999203, + "effect": 0.10640994510667033, + "n_groups": 2.0, + "p_value": 0.20506341936935923, + "se": 0.08396871921362546, + "t_stat": 1.2672569750165175 + }, + "-2": { + "ci_hi": 0.11697447968922686, + "ci_lo": -0.23656565158958748, + "effect": -0.05979558595018031, + "n_groups": 2.0, + "p_value": 0.5073354765114999, + "se": 0.09019046627067992, + "t_stat": -0.6629923141845347 + }, + "-3": { + "ci_hi": 0.22082500062751104, + "ci_lo": -0.04774980495688662, + "effect": 0.08653759783531222, + "n_groups": 2.0, + "p_value": 0.20657423263505748, + "se": 0.0685152400000412, + "t_stat": 1.2630415924290739 + }, + "-4": { + "ci_hi": 0.12174279226139513, + "ci_lo": -0.2793102104847449, + "effect": -0.07878370911167488, + "n_groups": 1.0, + "p_value": 0.4412767413768576, + "se": 0.10231131947056042, + "t_stat": -0.770039029106105 + }, + "-5": { + "ci_hi": 0.17622133798852047, + "ci_lo": -0.23226670424785045, + "effect": -0.028022683129665008, + "n_groups": 1.0, + "p_value": 0.7879982256940536, + "se": 0.10420804807090144, + "t_stat": -0.2689109300905323 + }, + "0": { + "ci_hi": 2.2847017505459717, + "ci_lo": 1.9934056143098826, + "effect": 2.139053682427927, + "n_groups": 2.0, + "p_value": 3.312798938201983e-182, + "se": 0.07431160432890503, + "t_stat": 28.784921301933164 + }, + "1": { + "ci_hi": 2.37539394539002, + "ci_lo": 2.0428676038131157, + "effect": 2.209130774601568, + "n_groups": 2.0, + "p_value": 1.6597989190426364e-149, + "se": 0.0848297071272302, + "t_stat": 26.04194744287217 + }, + "2": { + "ci_hi": 2.788829854510437, + "ci_lo": 2.360697993352844, + "effect": 2.5747639239316404, + "n_groups": 1.0, + "p_value": 7.081667471367201e-123, + "se": 0.10921931845040075, + "t_stat": 23.57425371685418 + }, + "3": { + "ci_hi": 2.9748255058745325, + "ci_lo": 2.5278939160075815, + "effect": 2.751359710941057, + "n_groups": 1.0, + "p_value": 1.1677819938852978e-128, + "se": 0.11401525573742445, + "t_stat": 24.131504974013303 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.5958288199018664, + "ci_lo": 2.30773927722724, + "df_used": null, + "effect": 2.4517840485645532, + "p_value": 5.127222339006657e-244, + "se": 0.07349358073593183, + "t_stat": 33.360519708163416 + }, + "6": { + "ci_hi": 2.28138179947517, + "ci_lo": 1.9401526064706776, + "df_used": null, + "effect": 2.110767202972924, + "p_value": 6.976023946500123e-130, + "se": 0.08704986308321609, + "t_stat": 24.24779463415258 + } + }, + "overall": { + "att": 2.3344742536810323, + "ci_hi": 2.4645714278546444, + "ci_lo": 2.2043770795074202, + "p_value": 5.800930872836493e-271, + "se": 0.0663773289712474, + "t_stat": 35.169752833716075 + }, + "reference_event_times": null + }, + "default_balance_e1": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2709856105933327, + "ci_lo": -0.05816572037999203, + "effect": 0.10640994510667033, + "n_groups": 2.0, + "p_value": 0.20506341936935923, + "se": 0.08396871921362546, + "t_stat": 1.2672569750165175 + }, + "-2": { + "ci_hi": 0.11697447968922686, + "ci_lo": -0.23656565158958748, + "effect": -0.05979558595018031, + "n_groups": 2.0, + "p_value": 0.5073354765114999, + "se": 0.09019046627067992, + "t_stat": -0.6629923141845347 + }, + "-3": { + "ci_hi": 0.22082500062751104, + "ci_lo": -0.04774980495688662, + "effect": 0.08653759783531222, + "n_groups": 2.0, + "p_value": 0.20657423263505748, + "se": 0.0685152400000412, + "t_stat": 1.2630415924290739 + }, + "-4": { + "ci_hi": 0.12174279226139513, + "ci_lo": -0.2793102104847449, + "effect": -0.07878370911167488, + "n_groups": 1.0, + "p_value": 0.4412767413768576, + "se": 0.10231131947056042, + "t_stat": -0.770039029106105 + }, + "-5": { + "ci_hi": 0.17622133798852047, + "ci_lo": -0.23226670424785045, + "effect": -0.028022683129665008, + "n_groups": 1.0, + "p_value": 0.7879982256940536, + "se": 0.10420804807090144, + "t_stat": -0.2689109300905323 + }, + "0": { + "ci_hi": 2.2847017505459717, + "ci_lo": 1.9934056143098826, + "effect": 2.139053682427927, + "n_groups": 2.0, + "p_value": 3.312798938201983e-182, + "se": 0.07431160432890503, + "t_stat": 28.784921301933164 + }, + "1": { + "ci_hi": 2.37539394539002, + "ci_lo": 2.0428676038131157, + "effect": 2.209130774601568, + "n_groups": 2.0, + "p_value": 1.6597989190426364e-149, + "se": 0.0848297071272302, + "t_stat": 26.04194744287217 + }, + "2": { + "ci_hi": 2.788829854510437, + "ci_lo": 2.360697993352844, + "effect": 2.5747639239316404, + "n_groups": 1.0, + "p_value": 7.081667471367201e-123, + "se": 0.10921931845040075, + "t_stat": 23.57425371685418 + }, + "3": { + "ci_hi": 2.9748255058745325, + "ci_lo": 2.5278939160075815, + "effect": 2.751359710941057, + "n_groups": 1.0, + "p_value": 1.1677819938852978e-128, + "se": 0.11401525573742445, + "t_stat": 24.131504974013303 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "fractional_balance_e1": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.3146093598929288, + "ci_lo": -0.2622394722841087, + "effect": 0.026184943804410075, + "n_groups": 1.0, + "p_value": 0.8587719858017362, + "se": 0.14715801839399795, + "t_stat": 0.17793759449996824 + }, + "0": { + "ci_hi": 0.4744597998730013, + "ci_lo": 0.015147611354018375, + "effect": 0.24480370561350984, + "n_groups": 2.0, + "p_value": 0.036686223159691944, + "se": 0.11717362975594932, + "t_stat": 2.0892389023314375 + }, + "1": { + "ci_hi": 0.986702075354783, + "ci_lo": 0.28499042836241567, + "effect": 0.6358462518585993, + "n_groups": 2.0, + "p_value": 0.00038233197115598855, + "se": 0.1790113625881341, + "t_stat": 3.551988223907005 + }, + "2": { + "ci_hi": 1.3936424795222435, + "ci_lo": 0.21450892977573222, + "effect": 0.8040757046489879, + "n_groups": 1.0, + "p_value": 0.007515822291562017, + "se": 0.300804902296003, + "t_stat": 2.673080453515176 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "survey_weights_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.267519566015824, + "ci_lo": -0.04161819618559991, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.15053966189644433, + "se": 0.07806115385315175, + "t_stat": 1.4469512598749732 + }, + "-2": { + "ci_hi": 0.2248611026673304, + "ci_lo": -0.07569358301803729, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.32773009565878386, + "se": 0.0758938196145844, + "t_stat": 0.982738254622171 + }, + "-3": { + "ci_hi": 0.1439935104903139, + "ci_lo": -0.1667986856240219, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.884723845236879, + "se": 0.07847891912160686, + "t_stat": -0.1452949109707428 + }, + "-4": { + "ci_hi": 0.17608191819562358, + "ci_lo": -0.21421518029160125, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.8469269695135784, + "se": 0.09855490198443846, + "t_stat": -0.19346202638402896 + }, + "-5": { + "ci_hi": 0.009591345137165197, + "ci_lo": -0.3989750884023271, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.061578894730832306, + "se": 0.10316813772812161, + "t_stat": -1.8871317823498113 + }, + "0": { + "ci_hi": 2.1725825069769638, + "ci_lo": 1.9046604942557408, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 1.569930461666579e-57, + "se": 0.06765366128920351, + "t_stat": 30.13320287133795 + }, + "1": { + "ci_hi": 2.278861855573475, + "ci_lo": 1.9289048047812756, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 4.408466512313737e-47, + "se": 0.08836853508076307, + "t_stat": 23.80805937605012 + }, + "2": { + "ci_hi": 2.7256707971138354, + "ci_lo": 2.279310831814035, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 3.9311032336517034e-44, + "se": 0.11271147748831938, + "t_stat": 22.202626300620324 + }, + "3": { + "ci_hi": 2.6498379553287457, + "ci_lo": 2.1673650365431456, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 2.097656359849327e-39, + "se": 0.12183045020155887, + "t_stat": 19.77011077239807 + } + }, + "event_study_df": 119.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.4016034347275013, + "ci_lo": 2.1340989258805103, + "df_used": 119.0, + "effect": 2.267851180304006, + "p_value": 1.5568170738855175e-62, + "se": 0.0675482363358473, + "t_stat": 33.57380300839143 + }, + "6": { + "ci_hi": 2.226677589780442, + "ci_lo": 1.8981477747502113, + "df_used": 119.0, + "effect": 2.0624126822653266, + "p_value": 6.014113516928942e-49, + "se": 0.08295788988636275, + "t_stat": 24.86095879596814 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.319421649289587, + "ci_lo": 2.0786616605986086, + "p_value": 4.806697219094109e-66, + "se": 0.06079491028547976, + "t_stat": 36.171476273554376 + }, + "reference_event_times": null + } + }, + "ImputationDiD": { + "clustered_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.2159054453557054, + "ci_lo": 1.89618861628084, + "effect": 2.056047030818273, + "n_obs": 84.0, + "p_value": 3.238610970500986e-140, + "se": 0.08156191429963794, + "t_stat": 25.20842047999111 + }, + "1": { + "ci_hi": 2.352040303841366, + "ci_lo": 2.097017136469497, + "effect": 2.2245287201554316, + "n_obs": 84.0, + "p_value": 3.0786199047882803e-256, + "se": 0.06505812591033798, + "t_stat": 34.192941911994815 + }, + "2": { + "ci_hi": 2.4433114745425133, + "ci_lo": 1.872173603228359, + "effect": 2.157742538885436, + "n_obs": 38.0, + "p_value": 1.2741818175899908e-49, + "se": 0.14570111385189144, + "t_stat": 14.809375727071183 + }, + "3": { + "ci_hi": 2.876346949872234, + "ci_lo": 2.4301070282791555, + "effect": 2.6532269890756948, + "n_obs": 38.0, + "p_value": 3.775737968071735e-120, + "se": 0.11383880650689567, + "t_stat": 23.30687636746243 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3210630691403344, + "ci_lo": 2.0951075824372807, + "df_used": null, + "effect": 2.2080853257888076, + "n_obs": 152.0, + "p_value": 0.0, + "se": 0.057642764990928914, + "t_stat": 38.306374202144674 + }, + "6": { + "ci_hi": 2.409225957453245, + "ci_lo": 2.085474816990568, + "df_used": null, + "effect": 2.2473503872219065, + "n_obs": 92.0, + "p_value": 4.87045717484717e-163, + "se": 0.08259109427938083, + "t_stat": 27.210565580107176 + } + }, + "overall": { + "att": 2.2228901850176808, + "ci_hi": 2.336664859712262, + "ci_lo": 2.1091155103230994, + "p_value": 0.0, + "se": 0.05804937008640027, + "t_stat": 38.293097439457945 + }, + "reference_event_times": null + }, + "covariates_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.0667601467972876, + "ci_lo": 1.755278610253442, + "effect": 1.9110193785253646, + "n_obs": 84.0, + "p_value": 8.394800705547965e-128, + "se": 0.07946103576411921, + "t_stat": 24.049766783788755 + }, + "1": { + "ci_hi": 2.413042058669716, + "ci_lo": 2.1103369121060034, + "effect": 2.2616894853878597, + "n_obs": 84.0, + "p_value": 1.4701123052632405e-188, + "se": 0.07722211962857788, + "t_stat": 29.2881041891379 + }, + "2": { + "ci_hi": 2.7352531776680205, + "ci_lo": 2.261379550962076, + "effect": 2.4983163643150483, + "n_obs": 49.0, + "p_value": 6.963867534441308e-95, + "se": 0.12088835061353145, + "t_stat": 20.666311945159446 + }, + "3": { + "ci_hi": 2.978777223475571, + "ci_lo": 2.4680388232186536, + "effect": 2.7234080233471123, + "n_obs": 49.0, + "p_value": 5.11183802574655e-97, + "se": 0.1302928023896247, + "t_stat": 20.902213885945084 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.446604794900199, + "ci_lo": 2.1667825297071155, + "df_used": null, + "effect": 2.3066936623036574, + "n_obs": 196.0, + "p_value": 4.5015632591896784e-229, + "se": 0.07138454262432534, + "t_stat": 32.31362949879877 + }, + "6": { + "ci_hi": 2.3885787481502128, + "ci_lo": 2.0188521590680706, + "df_used": null, + "effect": 2.2037154536091417, + "n_obs": 70.0, + "p_value": 9.860446633121675e-121, + "se": 0.09431974056628045, + "t_stat": 23.36430783607324 + } + }, + "overall": { + "att": 2.2795941336998373, + "ci_hi": 2.408043187198349, + "ci_lo": 2.1511450802013257, + "p_value": 4.303191349062899e-265, + "se": 0.06553643562417542, + "t_stat": 34.78361482416246 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.2939030232819024, + "ci_lo": 1.9979777038935498, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 9.689747789053283e-178, + "se": 0.07549254009833184, + "t_stat": 28.42585983717807 + }, + "1": { + "ci_hi": 2.4089072341547855, + "ci_lo": 2.075976074529496, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 1.2830604132067007e-153, + "se": 0.08493297893517628, + "t_stat": 26.402484434858316 + }, + "2": { + "ci_hi": 2.8153981515675794, + "ci_lo": 2.3811395559002286, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 1.2081848360326175e-121, + "se": 0.11078228964734226, + "t_stat": 23.453828784412007 + }, + "3": { + "ci_hi": 2.995785182944395, + "ci_lo": 2.5370755701019942, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 1.4718445943547193e-123, + "se": 0.11701990864644539, + "t_stat": 23.640681389364833 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.606436721671823, + "ci_lo": 2.3152673072750503, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 1.109545391138316e-240, + "se": 0.07427927673505222, + "t_stat": 33.129725040957034 + }, + "6": { + "ci_hi": 2.3271589354063664, + "ci_lo": 1.9750975197798244, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 8.993663607114609e-127, + "se": 0.08981323595830264, + "t_stat": 23.95112707654576 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4824381082496836, + "ci_lo": 2.2261759553235154, + "p_value": 5.2770465230089974e-284, + "se": 0.06537419946170739, + "t_stat": 36.01278564283794 + }, + "reference_event_times": null + }, + "default_balance_e1": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.357542634758938, + "ci_lo": 1.9364833434338276, + "effect": 2.1470129890963827, + "n_obs": 41.0, + "p_value": 7.003712394613622e-89, + "se": 0.10741505829861472, + "t_stat": 19.988007483342507 + }, + "1": { + "ci_hi": 2.5747666322558524, + "ci_lo": 2.0886250448246804, + "effect": 2.3316958385402664, + "n_obs": 41.0, + "p_value": 7.372829612721875e-79, + "se": 0.12401798993904861, + "t_stat": 18.801271006619523 + }, + "2": { + "ci_hi": 2.8153981515675794, + "ci_lo": 2.3811395559002286, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 1.2081848360326175e-121, + "se": 0.11078228964734226, + "t_stat": 23.453828784412007 + }, + "3": { + "ci_hi": 2.995785182944395, + "ci_lo": 2.5370755701019942, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 1.4718445943547193e-123, + "se": 0.11701990864644539, + "t_stat": 23.640681389364833 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.059815163957579826, + "ci_lo": -0.3559461519303694, + "effect": -0.14806549398639476, + "n_obs": 84.0, + "p_value": 0.1623681759350742, + "se": 0.10584141425966392, + "t_stat": -1.3989372215222033 + }, + "-3": { + "ci_hi": 0.1584251692403737, + "ci_lo": -0.20715293281084962, + "effect": -0.024363881785237962, + "n_obs": 84.0, + "p_value": 0.7935758227509604, + "se": 0.09306614604301801, + "t_stat": -0.261791025213145 + }, + "-4": { + "ci_hi": 0.06957272135423254, + "ci_lo": -0.34423198704465785, + "effect": -0.13732963284521266, + "n_obs": 84.0, + "p_value": 0.19287575347672523, + "se": 0.10534331572120142, + "t_stat": -1.3036387919349843 + }, + "-5": { + "ci_hi": 0.1567750477585551, + "ci_lo": -0.43065589815781424, + "effect": -0.13694042519962957, + "n_obs": 43.0, + "p_value": 0.3601949931340407, + "se": 0.14954378803351004, + "t_stat": -0.9157212546263955 + }, + "-6": { + "ci_hi": 0.07789021576946764, + "ci_lo": -0.4705808158646776, + "effect": -0.19634530004760498, + "n_obs": 43.0, + "p_value": 0.16019426463331482, + "se": 0.13962566369272308, + "t_stat": -1.4062264404322253 + }, + "0": { + "ci_hi": 2.2939030232819024, + "ci_lo": 1.9979777038935498, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 9.689747789053283e-178, + "se": 0.07549254009833184, + "t_stat": 28.42585983717807 + }, + "1": { + "ci_hi": 2.4089072341547855, + "ci_lo": 2.075976074529496, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 1.2830604132067007e-153, + "se": 0.08493297893517628, + "t_stat": 26.402484434858316 + }, + "2": { + "ci_hi": 2.8153981515675794, + "ci_lo": 2.3811395559002286, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 1.2081848360326175e-121, + "se": 0.11078228964734226, + "t_stat": 23.453828784412007 + }, + "3": { + "ci_hi": 2.995785182944395, + "ci_lo": 2.5370755701019942, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 1.4718445943547193e-123, + "se": 0.11701990864644539, + "t_stat": 23.640681389364833 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.606436721671823, + "ci_lo": 2.3152673072750503, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 1.109545391138316e-240, + "se": 0.07427927673505222, + "t_stat": 33.129725040957034 + }, + "6": { + "ci_hi": 2.3271589354063664, + "ci_lo": 1.9750975197798244, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 8.993663607114609e-127, + "se": 0.08981323595830264, + "t_stat": 23.95112707654576 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4824381082496836, + "ci_lo": 2.2261759553235154, + "p_value": 5.2770465230089974e-284, + "se": 0.06537419946170739, + "t_stat": 36.01278564283794 + }, + "reference_event_times": null + }, + "replicate_cohort_zero_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.1680508884019085, + "ci_lo": 1.9043438037087796, + "effect": 2.036197346055344, + "n_obs": 84.0, + "p_value": 2.293215235007041e-08, + "se": 0.05388569362842653, + "t_stat": 37.787345934453754 + }, + "1": { + "ci_hi": 2.299918954264144, + "ci_lo": 1.9564364620909478, + "effect": 2.128177708177546, + "n_obs": 84.0, + "p_value": 8.53842772143812e-08, + "se": 0.07018693624220074, + "t_stat": 30.32156441240918 + }, + "2": { + "ci_hi": 2.8036175732664543, + "ci_lo": 2.1763872908836377, + "effect": 2.490002432075046, + "n_obs": 46.0, + "p_value": 1.2044634532663274e-06, + "se": 0.12816773152030753, + "t_stat": 19.42768591235086 + }, + "3": { + "ci_hi": 2.92045633505207, + "ci_lo": 1.9500889838677034, + "effect": 2.4352726594598866, + "n_obs": 46.0, + "p_value": 1.775233893645019e-05, + "se": 0.19828408422851526, + "t_stat": 12.281735414796694 + } + }, + "event_study_df": 6.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_cohort_zero_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.4032310731914537, + "ci_lo": 2.0854758976995678, + "df_used": 6.0, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 3.90581008847123e-08, + "se": 0.06492983703994071, + "t_stat": 34.56582655620908 + }, + "6": { + "ci_hi": 2.309922293726208, + "ci_lo": 1.9658814992440419, + "df_used": 6.0, + "effect": 2.137901896485125, + "n_obs": 76.0, + "p_value": 8.390230560937787e-08, + "se": 0.07030101928706174, + "t_stat": 30.410681355207412 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.3062345655767293, + "ci_lo": 2.1111626617160617, + "p_value": 2.3202656603532574e-09, + "se": 0.039860835969507374, + "t_stat": 55.41024316037926 + }, + "reference_event_times": null + }, + "replicate_dropped_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.136782065574815, + "ci_lo": 1.9356126265358726, + "effect": 2.036197346055344, + "n_obs": 84.0, + "p_value": 4.9543946201583643e-08, + "se": 0.03912916450472498, + "t_stat": 52.03784368586419 + }, + "1": { + "ci_hi": 2.27405473304184, + "ci_lo": 1.9823006833132517, + "effect": 2.128177708177546, + "n_obs": 84.0, + "p_value": 2.539445057626956e-07, + "se": 0.05674864065480501, + "t_stat": 37.50182706794668 + }, + "2": { + "ci_hi": 2.7526664230984723, + "ci_lo": 2.2273384410516197, + "effect": 2.490002432075046, + "n_obs": 46.0, + "p_value": 2.1694339963138596e-06, + "se": 0.10218075432654233, + "t_stat": 24.368604914753956 + }, + "3": { + "ci_hi": 2.8897497837648305, + "ci_lo": 1.9807955351549427, + "effect": 2.4352726594598866, + "n_obs": 46.0, + "p_value": 3.620355289148721e-05, + "se": 0.17679932146273952, + "t_stat": 13.77421948971179 + } + }, + "event_study_df": 5.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_dropped_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.404655956514118, + "ci_lo": 2.0840510143769033, + "df_used": 5.0, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 3.117495193273171e-07, + "se": 0.062360384270328646, + "t_stat": 35.990052205521515 + }, + "6": { + "ci_hi": 2.30022339985521, + "ci_lo": 1.97558039311504, + "df_used": 5.0, + "effect": 2.137901896485125, + "n_obs": 76.0, + "p_value": 4.2270488084337436e-07, + "se": 0.0631458221324841, + "t_stat": 33.856585032651346 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.3075450626660454, + "ci_lo": 2.1098521646267456, + "p_value": 3.025940744574244e-08, + "se": 0.038452947752655994, + "t_stat": 57.438993438255665 + }, + "reference_event_times": null + }, + "replicate_none_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.1688072307535076, + "ci_lo": 1.9035874613571804, + "effect": 2.036197346055344, + "n_obs": 84.0, + "p_value": 3.123033441217508e-09, + "se": 0.05608074289555267, + "t_stat": 36.308316204862884 + }, + "1": { + "ci_hi": 2.2945995849020826, + "ci_lo": 1.961755831453009, + "effect": 2.128177708177546, + "n_obs": 84.0, + "p_value": 1.1157136251777773e-08, + "se": 0.07037984010036138, + "t_stat": 30.23845614230968 + }, + "2": { + "ci_hi": 2.7944818811565653, + "ci_lo": 2.1855229829935268, + "effect": 2.490002432075046, + "n_obs": 46.0, + "p_value": 2.465383218809266e-07, + "se": 0.12876441103757838, + "t_stat": 19.33766024331341 + }, + "3": { + "ci_hi": 2.907120306474585, + "ci_lo": 1.9634250124451882, + "effect": 2.4352726594598866, + "n_obs": 46.0, + "p_value": 5.677230931463324e-06, + "se": 0.19954445053875566, + "t_stat": 12.204161292808823 + } + }, + "event_study_df": 7.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_none_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.399306089281152, + "ci_lo": 2.0894008816098695, + "df_used": 7.0, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 4.689798509195468e-09, + "se": 0.06552948263609615, + "t_stat": 34.24952243113293 + }, + "6": { + "ci_hi": 2.305457193114026, + "ci_lo": 1.970346599856224, + "df_used": 7.0, + "effect": 2.137901896485125, + "n_obs": 76.0, + "p_value": 1.13313568324369e-08, + "se": 0.07085916357156435, + "t_stat": 30.171142146293427 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.3052116853616416, + "ci_lo": 2.1121855419311495, + "p_value": 1.9291321481698958e-10, + "se": 0.040815394517854514, + "t_stat": 54.11435169835759 + }, + "reference_event_times": null + }, + "replicate_undefined_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.036197346055344, + "n_obs": 84.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "1": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.128177708177546, + "n_obs": 84.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "2": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.490002432075046, + "n_obs": 46.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "3": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.4352726594598866, + "n_obs": 46.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_undefined_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": null, + "ci_lo": null, + "df_used": 0.0, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "6": { + "ci_hi": null, + "ci_lo": null, + "df_used": 0.0, + "effect": 2.137901896485125, + "n_obs": 76.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": null, + "ci_lo": null, + "p_value": null, + "se": null, + "t_stat": null + }, + "reference_event_times": null + }, + "survey_tsl_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.188071726851769, + "ci_lo": 1.8843229652589164, + "effect": 2.0361973460553426, + "n_obs": 84.0, + "p_value": 7.96612226199571e-52, + "se": 0.07670036375547899, + "t_stat": 26.547427500432022 + }, + "1": { + "ci_hi": 2.3070154714381226, + "ci_lo": 1.9493399449169722, + "effect": 2.1281777081775473, + "n_obs": 84.0, + "p_value": 1.218116737632538e-46, + "se": 0.09031754680000065, + "t_stat": 23.563280708788383 + }, + "2": { + "ci_hi": 2.730075258232533, + "ci_lo": 2.2499296059175635, + "effect": 2.4900024320750482, + "n_obs": 46.0, + "p_value": 6.258218704259847e-41, + "se": 0.12124278629169719, + "t_stat": 20.537324390453783 + }, + "3": { + "ci_hi": 2.6907567582364753, + "ci_lo": 2.1797885606833076, + "effect": 2.4352726594598915, + "n_obs": 46.0, + "p_value": 1.39128774786627e-37, + "se": 0.12902586471230448, + "t_stat": 18.874298303599378 + } + }, + "event_study_df": 119.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3845208310087664, + "ci_lo": 2.104186139882255, + "df_used": 119.0, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 7.18482203623246e-60, + "se": 0.07078801793274278, + "t_stat": 31.70527373118314 + }, + "6": { + "ci_hi": 2.3194403426048797, + "ci_lo": 1.9563634503653775, + "df_used": 119.0, + "effect": 2.1379018964851286, + "n_obs": 76.0, + "p_value": 3.3848134601287466e-46, + "se": 0.09168145924264394, + "t_stat": 23.318803105292666 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.3344309010229924, + "ci_lo": 2.0829663262697986, + "p_value": 3.3908506819933194e-64, + "se": 0.0634979522354065, + "t_stat": 34.783777049345915 + }, + "reference_event_times": null + } + }, + "TwoStageDiD": { + "always_treated_survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.0165930246514527, + "ci_lo": 1.6462320409397084, + "effect": 1.8314125327955806, + "n_obs": 92.0, + "p_value": 3.726531470166178e-40, + "se": 0.09358129359870074, + "t_stat": 19.5702844272395 + }, + "1": { + "ci_hi": 2.290954642175793, + "ci_lo": 1.8910905755993215, + "effect": 2.0910226088875574, + "n_obs": 92.0, + "p_value": 1.651106610777188e-42, + "se": 0.10103601151191301, + "t_stat": 20.69581506234545 + }, + "2": { + "ci_hi": 2.3066580186901473, + "ci_lo": 1.5741092476348801, + "effect": 1.9403836331625137, + "n_obs": 47.0, + "p_value": 6.599380151986617e-19, + "se": 0.1850974174775541, + "t_stat": 10.483040009987254 + }, + "3": { + "ci_hi": 2.4035758014866477, + "ci_lo": 1.7651064004311579, + "effect": 2.084341100958903, + "n_obs": 47.0, + "p_value": 6.725552089887526e-25, + "se": 0.16132582831798364, + "t_stat": 12.92007065880692 + }, + "4": { + "ci_hi": 0.37185836366550307, + "ci_lo": -1.2391289797745204, + "effect": -0.4336353080545087, + "n_obs": 8.0, + "p_value": 0.28876490638278013, + "se": 0.40705767130046416, + "t_stat": -1.065292067016289 + }, + "5": { + "ci_hi": 0.057220117025725936, + "ci_lo": -1.015056008861297, + "effect": -0.4789179459177856, + "n_obs": 8.0, + "p_value": 0.07952484892385321, + "se": 0.27093833143506935, + "t_stat": -1.767627132643499 + }, + "6": { + "ci_hi": 0.4471006489812237, + "ci_lo": -0.9422623230482301, + "effect": -0.2475808370335032, + "n_obs": 8.0, + "p_value": 0.48195180478065625, + "se": 0.3510585345616383, + "t_stat": -0.7052409004744857 + } + }, + "event_study_df": 127.0, + "event_study_vcov": [ + [ + 0.008757458511606227, + 0.004146487434122724, + 0.010074864365616481, + 0.004444937450505918, + 0.005881320769607086, + 0.007543776458122666, + 0.0028848518114763513 + ], + [ + 0.004146487434122724, + 0.010208275622235417, + 0.00847297102264899, + 0.011193162986745902, + 0.009181937942576346, + 0.006636350563387816, + 0.007460826990613974 + ], + [ + 0.010074864365616481, + 0.00847297102264899, + 0.034261053956859945, + 0.019024783300514207, + 0.0029261362869892333, + 0.012062865344136897, + 0.0029535986866108003 + ], + [ + 0.004444937450505918, + 0.0111931629867459, + 0.019024783300514207, + 0.026026022882483534, + 1.7937653228008368e-05, + 0.0038278508794065565, + 0.004667851231119962 + ], + [ + 0.005881320769607085, + 0.009181937942576344, + 0.0029261362869892333, + 1.793765322800837e-05, + 0.1656959477645567, + -0.00026043277378823753, + 0.05269359949418312 + ], + [ + 0.007543776458122667, + 0.0066363505633878165, + 0.012062865344136899, + 0.0038278508794065565, + -0.00026043277378823753, + 0.07340757944081949, + 0.03423566208614829 + ], + [ + 0.0028848518114763517, + 0.007460826990613973, + 0.0029535986866108003, + 0.004667851231119962, + 0.05269359949418312, + 0.03423566208614829, + 0.12324209468856501 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0 + ], + "group": { + "1": { + "ci_hi": 0.1945177330957572, + "ci_lo": -0.654284393016846, + "effect": -0.2298833299605444, + "n_obs": 56.0, + "p_value": 0.2858164948174116, + "se": 0.21447183819116233, + "t_stat": -1.0718578807332528 + }, + "4": { + "ci_hi": 2.374582231477566, + "ci_lo": 2.0975847464345883, + "effect": 2.2360834889560772, + "n_obs": 156.0, + "p_value": 1.4680535172538918e-62, + "se": 0.06999058786949273, + "t_stat": 31.948345585060213 + }, + "6": { + "ci_hi": 2.320447443894367, + "ci_lo": 1.940860802557352, + "effect": 2.1306541232258596, + "n_obs": 90.0, + "p_value": 1.4158709788296335e-45, + "se": 0.095912394910235, + "t_stat": 22.214585770900122 + } + }, + "overall": { + "att": 1.8215502043857208, + "ci_hi": 2.090378408033829, + "ci_lo": 1.5527220007376128, + "p_value": 4.3728256907716846e-26, + "se": 0.13585281473809396, + "t_stat": 13.408262522182008 + }, + "reference_event_times": null + }, + "always_treated_survey_psu_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.0165930246514527, + "ci_lo": 1.6462320409397084, + "effect": 1.8314125327955806, + "n_obs": 92.0, + "p_value": 3.726531470166178e-40, + "se": 0.09358129359870074, + "t_stat": 19.5702844272395 + }, + "1": { + "ci_hi": 2.290954642175793, + "ci_lo": 1.8910905755993215, + "effect": 2.0910226088875574, + "n_obs": 92.0, + "p_value": 1.651106610777188e-42, + "se": 0.10103601151191301, + "t_stat": 20.69581506234545 + }, + "2": { + "ci_hi": 2.3066580186901473, + "ci_lo": 1.5741092476348801, + "effect": 1.9403836331625137, + "n_obs": 47.0, + "p_value": 6.599380151986617e-19, + "se": 0.1850974174775541, + "t_stat": 10.483040009987254 + }, + "3": { + "ci_hi": 2.4035758014866477, + "ci_lo": 1.7651064004311579, + "effect": 2.084341100958903, + "n_obs": 47.0, + "p_value": 6.725552089887526e-25, + "se": 0.16132582831798364, + "t_stat": 12.92007065880692 + }, + "4": { + "ci_hi": 0.37185836366550307, + "ci_lo": -1.2391289797745204, + "effect": -0.4336353080545087, + "n_obs": 8.0, + "p_value": 0.28876490638278013, + "se": 0.40705767130046416, + "t_stat": -1.065292067016289 + }, + "5": { + "ci_hi": 0.057220117025725936, + "ci_lo": -1.015056008861297, + "effect": -0.4789179459177856, + "n_obs": 8.0, + "p_value": 0.07952484892385321, + "se": 0.27093833143506935, + "t_stat": -1.767627132643499 + }, + "6": { + "ci_hi": 0.4471006489812237, + "ci_lo": -0.9422623230482301, + "effect": -0.2475808370335032, + "n_obs": 8.0, + "p_value": 0.48195180478065625, + "se": 0.3510585345616383, + "t_stat": -0.7052409004744857 + } + }, + "event_study_df": 127.0, + "event_study_vcov": [ + [ + 0.008757458511606227, + 0.004146487434122724, + 0.010074864365616481, + 0.004444937450505918, + 0.005881320769607086, + 0.007543776458122666, + 0.0028848518114763513 + ], + [ + 0.004146487434122724, + 0.010208275622235417, + 0.00847297102264899, + 0.011193162986745902, + 0.009181937942576346, + 0.006636350563387816, + 0.007460826990613974 + ], + [ + 0.010074864365616481, + 0.00847297102264899, + 0.034261053956859945, + 0.019024783300514207, + 0.0029261362869892333, + 0.012062865344136897, + 0.0029535986866108003 + ], + [ + 0.004444937450505918, + 0.0111931629867459, + 0.019024783300514207, + 0.026026022882483534, + 1.7937653228008368e-05, + 0.0038278508794065565, + 0.004667851231119962 + ], + [ + 0.005881320769607085, + 0.009181937942576344, + 0.0029261362869892333, + 1.793765322800837e-05, + 0.1656959477645567, + -0.00026043277378823753, + 0.05269359949418312 + ], + [ + 0.007543776458122667, + 0.0066363505633878165, + 0.012062865344136899, + 0.0038278508794065565, + -0.00026043277378823753, + 0.07340757944081949, + 0.03423566208614829 + ], + [ + 0.0028848518114763517, + 0.007460826990613973, + 0.0029535986866108003, + 0.004667851231119962, + 0.05269359949418312, + 0.03423566208614829, + 0.12324209468856501 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0 + ], + "group": { + "1": { + "ci_hi": 0.1945177330957572, + "ci_lo": -0.654284393016846, + "effect": -0.2298833299605444, + "n_obs": 56.0, + "p_value": 0.2858164948174116, + "se": 0.21447183819116233, + "t_stat": -1.0718578807332528 + }, + "4": { + "ci_hi": 2.374582231477566, + "ci_lo": 2.0975847464345883, + "effect": 2.2360834889560772, + "n_obs": 156.0, + "p_value": 1.4680535172538918e-62, + "se": 0.06999058786949273, + "t_stat": 31.948345585060213 + }, + "6": { + "ci_hi": 2.320447443894367, + "ci_lo": 1.940860802557352, + "effect": 2.1306541232258596, + "n_obs": 90.0, + "p_value": 1.4158709788296335e-45, + "se": 0.095912394910235, + "t_stat": 22.214585770900122 + } + }, + "overall": { + "att": 1.8215502043857208, + "ci_hi": 2.090378408033829, + "ci_lo": 1.5527220007376128, + "p_value": 4.3728256907716846e-26, + "se": 0.13585281473809396, + "t_stat": 13.408262522182008 + }, + "reference_event_times": null + }, + "covariates_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.0681011373082723, + "ci_lo": 1.7539376197424565, + "effect": 1.9110193785253644, + "n_obs": 84.0, + "p_value": 1.1560288875298786e-125, + "se": 0.08014522716843206, + "t_stat": 23.844456445412444 + }, + "1": { + "ci_hi": 2.4141502384231557, + "ci_lo": 2.109228732352562, + "effect": 2.261689485387859, + "n_obs": 84.0, + "p_value": 7.387292341585388e-186, + "se": 0.07778752785147476, + "t_stat": 29.075219998072992 + }, + "2": { + "ci_hi": 2.735253177668089, + "ci_lo": 2.261379550962006, + "effect": 2.4983163643150474, + "n_obs": 49.0, + "p_value": 6.963867535311036e-95, + "se": 0.12088835061356668, + "t_stat": 20.666311945153417 + }, + "3": { + "ci_hi": 2.9787772234755887, + "ci_lo": 2.4680388232186323, + "effect": 2.7234080233471105, + "n_obs": 49.0, + "p_value": 5.111838025917359e-97, + "se": 0.13029280238963456, + "t_stat": 20.90221388594349 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.00642325743787958, + 0.0013599605974883074, + 0.004559300805275063, + 0.001693740094331948 + ], + [ + 0.0013599605974883074, + 0.006050899489243962, + 0.0024190372773471706, + 0.004527070665223844 + ], + [ + 0.004559300805275063, + 0.0024190372773471706, + 0.014613993314068627, + 0.0031251713472249323 + ], + [ + 0.001693740094331948, + 0.004527070665223844, + 0.0031251713472249323, + 0.01697621435454436 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "4": { + "ci_hi": 2.44660479490025, + "ci_lo": 2.1667825297070613, + "effect": 2.3066936623036556, + "n_obs": 196.0, + "p_value": 4.501563260946723e-229, + "se": 0.07138454262435194, + "t_stat": 32.3136294987867 + }, + "6": { + "ci_hi": 2.388578748150253, + "ci_lo": 2.0188521590680284, + "effect": 2.203715453609141, + "n_obs": 70.0, + "p_value": 9.860446634319966e-121, + "se": 0.09431974056630137, + "t_stat": 23.364307836068054 + } + }, + "overall": { + "att": 2.279594133699835, + "ci_hi": 2.408400908621343, + "ci_lo": 2.1507873587783273, + "p_value": 1.2366723273465232e-263, + "se": 0.06571894990801841, + "t_stat": 34.68701397223178 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.293908957498144, + "ci_lo": 1.9979717696773065, + "effect": 2.145940363587725, + "n_obs": 84.0, + "p_value": 1.0009287652680278e-177, + "se": 0.07549556781531501, + "t_stat": 28.42471983040573 + }, + "1": { + "ci_hi": 2.410429895674018, + "ci_lo": 2.0744534130102625, + "effect": 2.2424416543421404, + "n_obs": 84.0, + "p_value": 6.97981762805575e-151, + "se": 0.08570986133263034, + "t_stat": 26.16316978555684 + }, + "2": { + "ci_hi": 2.815398151567543, + "ci_lo": 2.3811395559002624, + "effect": 2.5982688537339027, + "n_obs": 41.0, + "p_value": 1.208184835925906e-121, + "se": 0.11078228964732444, + "t_stat": 23.453828784415766 + }, + "3": { + "ci_hi": 2.9957851829443607, + "ci_lo": 2.5370755701020267, + "effect": 2.7664303765231937, + "n_obs": 41.0, + "p_value": 1.4718445942354515e-123, + "se": 0.11701990864642843, + "t_stat": 23.640681389368254 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.005699580759756827, + 0.0021237246279570804, + 0.004694454408243992, + 0.0023631068662294725 + ], + [ + 0.0021237246279570804, + 0.007346180329658721, + 0.0020270336522953784, + 0.003804898371103219 + ], + [ + 0.004694454408243992, + 0.002027033652295379, + 0.012272715699503688, + 0.004183516038185585 + ], + [ + 0.0023631068662294725, + 0.003804898371103219, + 0.004183516038185585, + 0.013693659019618455 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "4": { + "ci_hi": 2.6064367216717703, + "ci_lo": 2.3152673072751013, + "effect": 2.460852014473436, + "n_obs": 164.0, + "p_value": 1.1095453907055126e-240, + "se": 0.07427927673502582, + "t_stat": 33.12972504096879 + }, + "6": { + "ci_hi": 2.327158935406365, + "ci_lo": 1.9750975197798237, + "effect": 2.1511282275930945, + "n_obs": 86.0, + "p_value": 8.993663607111023e-127, + "se": 0.08981323595830254, + "t_stat": 23.951127076545774 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.486708263656467, + "ci_lo": 2.22190579991673, + "p_value": 4.066833864881585e-266, + "se": 0.06755289021340831, + "t_stat": 34.85131464174277 + }, + "reference_event_times": null + }, + "default_balance_e1": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.357542634758937, + "ci_lo": 1.9364833434338267, + "effect": 2.147012989096382, + "n_obs": 41.0, + "p_value": 7.003712394616416e-89, + "se": 0.1074150582986148, + "t_stat": 19.988007483342486 + }, + "1": { + "ci_hi": 2.574766632255852, + "ci_lo": 2.088625044824679, + "effect": 2.3316958385402655, + "n_obs": 41.0, + "p_value": 7.37282961272776e-79, + "se": 0.12401798993904885, + "t_stat": 18.80127100661948 + }, + "2": { + "ci_hi": 2.815398151567543, + "ci_lo": 2.3811395559002624, + "effect": 2.5982688537339027, + "n_obs": 41.0, + "p_value": 1.208184835925906e-121, + "se": 0.11078228964732444, + "t_stat": 23.453828784415766 + }, + "3": { + "ci_hi": 2.9957851829443607, + "ci_lo": 2.5370755701020267, + "effect": 2.7664303765231937, + "n_obs": 41.0, + "p_value": 1.4718445942354515e-123, + "se": 0.11701990864642843, + "t_stat": 23.640681389368254 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.011537994749294815, + 0.003854389507149603, + 0.0028535048676717094, + 0.0034123441254676657 + ], + [ + 0.003854389507149603, + 0.01538046182852202, + 0.0025509113328664066, + 0.000842206098417954 + ], + [ + 0.0028535048676717094, + 0.0025509113328664066, + 0.012272715699503688, + 0.004183516038185585 + ], + [ + 0.0034123441254676657, + 0.000842206098417954, + 0.004183516038185585, + 0.013693659019618455 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0 + ], + "reference_event_times": null + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.04063576079673298, + "ci_lo": -0.10034972823002453, + "effect": -0.02985698371664577, + "n_obs": 84.0, + "p_value": 0.4064615116607474, + "se": 0.03596634686627741, + "t_stat": -0.8301366782579782 + }, + "-3": { + "ci_hi": 0.10859632307117377, + "ci_lo": -0.03940259393571609, + "effect": 0.03459686456772884, + "n_obs": 84.0, + "p_value": 0.3594889860225441, + "se": 0.037755519533595114, + "t_stat": 0.9163392530446924 + }, + "-4": { + "ci_hi": 0.08462802785008987, + "ci_lo": -0.08967148307729171, + "effect": -0.002521727613600925, + "n_obs": 84.0, + "p_value": 0.9547740812498333, + "se": 0.04446497800526793, + "t_stat": -0.05671266976230522 + }, + "-5": { + "ci_hi": 0.07422880954615485, + "ci_lo": -0.14253409272093076, + "effect": -0.034152641587387955, + "n_obs": 43.0, + "p_value": 0.5368295343838174, + "se": 0.05529767484935533, + "t_stat": -0.6176144237606424 + }, + "-6": { + "ci_hi": 0.07322299031555263, + "ci_lo": -0.14057126632090916, + "effect": -0.03367413800267827, + "n_obs": 43.0, + "p_value": 0.5369597523913643, + "se": 0.05454035337456291, + "t_stat": -0.6174169384531997 + }, + "0": { + "ci_hi": 2.293908957498144, + "ci_lo": 1.9979717696773065, + "effect": 2.145940363587725, + "n_obs": 84.0, + "p_value": 1.0009287652680278e-177, + "se": 0.075495567815315, + "t_stat": 28.424719830405735 + }, + "1": { + "ci_hi": 2.410429895674018, + "ci_lo": 2.0744534130102625, + "effect": 2.2424416543421404, + "n_obs": 84.0, + "p_value": 6.979817628056545e-151, + "se": 0.08570986133263035, + "t_stat": 26.163169785556832 + }, + "2": { + "ci_hi": 2.815398151567543, + "ci_lo": 2.3811395559002624, + "effect": 2.5982688537339027, + "n_obs": 41.0, + "p_value": 1.208184835925906e-121, + "se": 0.11078228964732444, + "t_stat": 23.453828784415766 + }, + "3": { + "ci_hi": 2.9957851829443607, + "ci_lo": 2.5370755701020267, + "effect": 2.7664303765231937, + "n_obs": 41.0, + "p_value": 1.4718445942354515e-123, + "se": 0.11701990864642843, + "t_stat": 23.640681389368254 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.0029746501462221955, + -0.0002650826832603199, + -0.0016144965708846215, + -0.00012485970044849535, + 0.0003483893209992448, + 0.000275170184157467, + -0.00015995093636165776, + 0.0003080120706126748, + -0.00011370693707910455 + ], + [ + -0.0002650826832603199, + 0.0030578328437450247, + -0.0002912316450706456, + -0.0009561997675826662, + -0.0001954228732153961, + 0.00017584929105873346, + -0.000361734091322721, + 0.0005037134940451455, + -0.0002361806666820629 + ], + [ + -0.0016144965708846217, + -0.0002912316450706456, + 0.0019771342690089607, + 8.951576021501633e-05, + -0.0008341845900340859, + -0.0001766436323657939, + -6.271618395012136e-05, + -1.8816121884652606e-05, + -0.00028962160307614306 + ], + [ + -0.00012485970044849538, + -0.0009561997675826663, + 8.951576021501633e-05, + 0.0014254792552516823, + -0.00024764321482010634, + -0.00016385353845332678, + 5.5604351051812014e-05, + -0.0004789980568244183, + -0.0003673350995655875 + ], + [ + 0.00034838932099924487, + -0.00019542287321539612, + -0.0008341845900340859, + -0.00024764321482010634, + 0.0012935781069053829, + 0.00022222923115049502, + 9.52174900419265e-05, + -0.00024031674802400514, + 0.0006254291031602148 + ], + [ + 0.00027517018415746693, + 0.00017584929105873346, + -0.0001766436323657939, + -0.00016385353845332678, + 0.00022222923115049502, + 0.005699580759756826, + 0.00212372462795708, + 0.004694454408243992, + 0.0023631068662294716 + ], + [ + -0.00015995093636165776, + -0.000361734091322721, + -6.271618395012136e-05, + 5.5604351051812014e-05, + 9.52174900419265e-05, + 0.00212372462795708, + 0.007346180329658723, + 0.0020270336522953806, + 0.003804898371103219 + ], + [ + 0.0003080120706126748, + 0.0005037134940451455, + -1.8816121884652603e-05, + -0.00047899805682441834, + -0.00024031674802400517, + 0.004694454408243992, + 0.0020270336522953806, + 0.012272715699503688, + 0.004183516038185584 + ], + [ + -0.00011370693707910455, + -0.00023618066668206286, + -0.00028962160307614306, + -0.00036733509956558743, + 0.0006254291031602148, + 0.0023631068662294716, + 0.003804898371103219, + 0.004183516038185584, + 0.013693659019618455 + ] + ], + "event_study_vcov_index": [ + -6.0, + -5.0, + -4.0, + -3.0, + -2.0, + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "4": { + "ci_hi": 2.6064367216717703, + "ci_lo": 2.3152673072751013, + "effect": 2.460852014473436, + "n_obs": 164.0, + "p_value": 1.1095453907055126e-240, + "se": 0.07427927673502582, + "t_stat": 33.12972504096879 + }, + "6": { + "ci_hi": 2.327158935406365, + "ci_lo": 1.9750975197798237, + "effect": 2.1511282275930945, + "n_obs": 86.0, + "p_value": 8.993663607111023e-127, + "se": 0.08981323595830254, + "t_stat": 23.951127076545774 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.486708263656467, + "ci_lo": 2.22190579991673, + "p_value": 4.066833864881585e-266, + "se": 0.06755289021340831, + "t_stat": 34.85131464174277 + }, + "reference_event_times": null + }, + "prop5_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.154880923793283, + "ci_lo": 1.6520643539647772, + "effect": 1.9034726388790302, + "n_obs": 36.0, + "p_value": 8.15311783634353e-50, + "se": 0.12827189014559934, + "t_stat": 14.83935908887309 + }, + "1": { + "ci_hi": 2.531707036369357, + "ci_lo": 2.072970733295254, + "effect": 2.3023388848323054, + "n_obs": 36.0, + "p_value": 3.629398566410162e-86, + "se": 0.11702671750413689, + "t_stat": 19.673617563022887 + }, + "2": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "3": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.016453677801524708, + 0.0030754834831884597 + ], + [ + 0.0030754834831884597, + 0.013695252609793058 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0 + ], + "group": { + "4": { + "ci_hi": 2.2896171440554287, + "ci_lo": 1.9161943796559084, + "effect": 2.1029057618556686, + "n_obs": 72.0, + "p_value": 5.51874900161238e-108, + "se": 0.09526265975931998, + "t_stat": 22.074816797774027 + }, + "6": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 0.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "overall": { + "att": 2.1029057618556686, + "ci_hi": 2.2896171440554287, + "ci_lo": 1.9161943796559084, + "p_value": 5.51874900161238e-108, + "se": 0.09526265975931998, + "t_stat": 22.074816797774027 + }, + "reference_event_times": null + }, + "replicate_cohort_zero_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.168050888401908, + "ci_lo": 1.9043438037087792, + "effect": 2.0361973460553435, + "n_obs": 84.0, + "p_value": 2.2932152350070424e-08, + "se": 0.05388569362842653, + "t_stat": 37.78734593445375 + }, + "1": { + "ci_hi": 2.2999189542641423, + "ci_lo": 1.9564364620909487, + "effect": 2.1281777081775455, + "n_obs": 84.0, + "p_value": 8.538427721437762e-08, + "se": 0.07018693624220022, + "t_stat": 30.321564412409394 + }, + "2": { + "ci_hi": 2.803617573266457, + "ci_lo": 2.1763872908836404, + "effect": 2.4900024320750487, + "n_obs": 46.0, + "p_value": 1.2044634532663213e-06, + "se": 0.12816773152030758, + "t_stat": 19.427685912350874 + }, + "3": { + "ci_hi": 2.9204563350520716, + "ci_lo": 1.9500889838677062, + "effect": 2.435272659459889, + "n_obs": 46.0, + "p_value": 1.775233893644997e-05, + "se": 0.198284084228515, + "t_stat": 12.28173541479672 + } + }, + "event_study_df": 6.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_cohort_zero_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.4032310731914563, + "ci_lo": 2.0854758976995686, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": 3.905810088471317e-08, + "se": 0.06492983703994101, + "t_stat": 34.56582655620895 + }, + "6": { + "ci_hi": 2.3099222937262094, + "ci_lo": 1.9658814992440428, + "effect": 2.137901896485126, + "n_obs": 76.0, + "p_value": 8.390230560937804e-08, + "se": 0.0703010192870618, + "t_stat": 30.410681355207398 + } + }, + "overall": { + "att": 2.2086986136463946, + "ci_hi": 2.3062345655767267, + "ci_lo": 2.1111626617160626, + "p_value": 2.3202656603530585e-09, + "se": 0.039860835969506785, + "t_stat": 55.410243160380055 + }, + "reference_event_times": null + }, + "replicate_dropped_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.1367820655748115, + "ci_lo": 1.9356126265358757, + "effect": 2.0361973460553435, + "n_obs": 84.0, + "p_value": 4.954394620157524e-08, + "se": 0.039129164504723646, + "t_stat": 52.03784368586595 + }, + "1": { + "ci_hi": 2.274054733041839, + "ci_lo": 1.9823006833132524, + "effect": 2.1281777081775455, + "n_obs": 84.0, + "p_value": 2.539445057626865e-07, + "se": 0.056748640654804595, + "t_stat": 37.50182706794695 + }, + "2": { + "ci_hi": 2.7526664230984763, + "ci_lo": 2.227338441051621, + "effect": 2.4900024320750487, + "n_obs": 46.0, + "p_value": 2.16943399631389e-06, + "se": 0.10218075432654272, + "t_stat": 24.368604914753888 + }, + "3": { + "ci_hi": 2.8897497837648327, + "ci_lo": 1.980795535154945, + "effect": 2.435272659459889, + "n_obs": 46.0, + "p_value": 3.620355289148702e-05, + "se": 0.17679932146273947, + "t_stat": 13.774219489711806 + } + }, + "event_study_df": 5.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_dropped_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.4046559565141195, + "ci_lo": 2.0840510143769055, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": 3.117495193273137e-07, + "se": 0.062360384270328556, + "t_stat": 35.99005220552159 + }, + "6": { + "ci_hi": 2.300223399855212, + "ci_lo": 1.9755803931150402, + "effect": 2.137901896485126, + "n_obs": 76.0, + "p_value": 4.227048808433817e-07, + "se": 0.06314582213248435, + "t_stat": 33.856585032651225 + } + }, + "overall": { + "att": 2.2086986136463946, + "ci_hi": 2.3075450626660428, + "ci_lo": 2.1098521646267465, + "p_value": 3.025940744573983e-08, + "se": 0.038452947752655314, + "t_stat": 57.43899343825666 + }, + "reference_event_times": null + }, + "replicate_none_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.1688072307535067, + "ci_lo": 1.9035874613571806, + "effect": 2.0361973460553435, + "n_obs": 84.0, + "p_value": 3.123033441217411e-09, + "se": 0.056080742895552406, + "t_stat": 36.30831620486305 + }, + "1": { + "ci_hi": 2.2945995849020813, + "ci_lo": 1.9617558314530097, + "effect": 2.1281777081775455, + "n_obs": 84.0, + "p_value": 1.1157136251777283e-08, + "se": 0.07037984010036093, + "t_stat": 30.23845614230987 + }, + "2": { + "ci_hi": 2.7944818811565684, + "ci_lo": 2.185522982993529, + "effect": 2.4900024320750487, + "n_obs": 46.0, + "p_value": 2.4653832188092595e-07, + "se": 0.1287644110375785, + "t_stat": 19.337660243313415 + }, + "3": { + "ci_hi": 2.907120306474587, + "ci_lo": 1.9634250124451906, + "effect": 2.435272659459889, + "n_obs": 46.0, + "p_value": 5.677230931463275e-06, + "se": 0.19954445053875558, + "t_stat": 12.20416129280884 + } + }, + "event_study_df": 7.0, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_none_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.399306089281154, + "ci_lo": 2.089400881609871, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": 4.689798509195582e-09, + "se": 0.06552948263609643, + "t_stat": 34.24952243113281 + }, + "6": { + "ci_hi": 2.305457193114027, + "ci_lo": 1.9703465998562248, + "effect": 2.137901896485126, + "n_obs": 76.0, + "p_value": 1.1331356832436942e-08, + "se": 0.07085916357156442, + "t_stat": 30.17114214629341 + } + }, + "overall": { + "att": 2.2086986136463946, + "ci_hi": 2.305211685361639, + "ci_lo": 2.1121855419311504, + "p_value": 1.929132148169655e-10, + "se": 0.04081539451785377, + "t_stat": 54.114351698358554 + }, + "reference_event_times": null + }, + "replicate_undefined_event_study": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": null, + "ci_lo": null, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "0": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.0361973460553435, + "n_obs": 84.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "1": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.1281777081775455, + "n_obs": 84.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "2": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.4900024320750487, + "n_obs": 46.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "3": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.435272659459889, + "n_obs": 46.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "replicate_undefined_group": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "6": { + "ci_hi": null, + "ci_lo": null, + "effect": 2.137901896485126, + "n_obs": 76.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "overall": { + "att": 2.2086986136463946, + "ci_hi": null, + "ci_lo": null, + "p_value": null, + "se": null, + "t_stat": null + }, + "reference_event_times": null + }, + "survey_psu_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.190082439558195, + "ci_lo": 1.8823122525524882, + "effect": 2.0361973460553418, + "n_obs": 84.0, + "p_value": 3.037332996371183e-51, + "se": 0.07771582400086065, + "t_stat": 26.200550174090573 + }, + "1": { + "ci_hi": 2.307570690322095, + "ci_lo": 1.948784726033002, + "effect": 2.1281777081775486, + "n_obs": 84.0, + "p_value": 1.6511071384703697e-46, + "se": 0.09059794623367204, + "t_stat": 23.490352669678735 + }, + "2": { + "ci_hi": 2.729101569325406, + "ci_lo": 2.2509032948246905, + "effect": 2.4900024320750482, + "n_obs": 46.0, + "p_value": 4.286564449946556e-41, + "se": 0.120751049021924, + "t_stat": 20.62095900817354 + }, + "3": { + "ci_hi": 2.6896939894444376, + "ci_lo": 2.180851329475347, + "effect": 2.4352726594598924, + "n_obs": 46.0, + "p_value": 9.578962482444616e-38, + "se": 0.12848913987095953, + "t_stat": 18.953140023395086 + } + }, + "event_study_df": 119.0, + "event_study_vcov": [ + [ + 0.006039749300132747, + 0.0009122105474724166, + 0.00347327195903601, + 0.0006267600776265658 + ], + [ + 0.0009122105474724166, + 0.00820798786175933, + 0.0019989898586376753, + 0.0056241547555801975 + ], + [ + 0.0034732719590360103, + 0.0019989898586376753, + 0.014580815839895095, + 0.0007538373004725811 + ], + [ + 0.0006267600776265658, + 0.005624154755580198, + 0.0007538373004725811, + 0.016509459064779 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "4": { + "ci_hi": 2.3839857957507595, + "ci_lo": 2.1047211751402655, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": 4.7801766523344554e-60, + "se": 0.07051781173538364, + "t_stat": 31.82676022147984 + }, + "6": { + "ci_hi": 2.3187371940696404, + "ci_lo": 1.9570665989006186, + "effect": 2.1379018964851295, + "n_obs": 76.0, + "p_value": 2.315113512572155e-46, + "se": 0.09132635163236602, + "t_stat": 23.409474464623834 + } + }, + "overall": { + "att": 2.2086986136463964, + "ci_hi": 2.335225426756549, + "ci_lo": 2.0821718005362437, + "p_value": 6.710965133892381e-64, + "se": 0.06389920761802759, + "t_stat": 34.56535215349473 + }, + "reference_event_times": null + }, + "survey_tsl_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.190082439558195, + "ci_lo": 1.8823122525524882, + "effect": 2.0361973460553418, + "n_obs": 84.0, + "p_value": 3.037332996371183e-51, + "se": 0.07771582400086065, + "t_stat": 26.200550174090573 + }, + "1": { + "ci_hi": 2.307570690322095, + "ci_lo": 1.948784726033002, + "effect": 2.1281777081775486, + "n_obs": 84.0, + "p_value": 1.6511071384703697e-46, + "se": 0.09059794623367204, + "t_stat": 23.490352669678735 + }, + "2": { + "ci_hi": 2.729101569325406, + "ci_lo": 2.2509032948246905, + "effect": 2.4900024320750482, + "n_obs": 46.0, + "p_value": 4.286564449946556e-41, + "se": 0.120751049021924, + "t_stat": 20.62095900817354 + }, + "3": { + "ci_hi": 2.6896939894444376, + "ci_lo": 2.180851329475347, + "effect": 2.4352726594598924, + "n_obs": 46.0, + "p_value": 9.578962482444616e-38, + "se": 0.12848913987095953, + "t_stat": 18.953140023395086 + } + }, + "event_study_df": 119.0, + "event_study_vcov": [ + [ + 0.006039749300132747, + 0.0009122105474724166, + 0.00347327195903601, + 0.0006267600776265658 + ], + [ + 0.0009122105474724166, + 0.00820798786175933, + 0.0019989898586376753, + 0.0056241547555801975 + ], + [ + 0.0034732719590360103, + 0.0019989898586376753, + 0.014580815839895095, + 0.0007538373004725811 + ], + [ + 0.0006267600776265658, + 0.005624154755580198, + 0.0007538373004725811, + 0.016509459064779 + ] + ], + "event_study_vcov_index": [ + 0.0, + 1.0, + 2.0, + 3.0 + ], + "group": { + "4": { + "ci_hi": 2.3839857957507595, + "ci_lo": 2.1047211751402655, + "effect": 2.2443534854455125, + "n_obs": 184.0, + "p_value": 4.7801766523344554e-60, + "se": 0.07051781173538364, + "t_stat": 31.82676022147984 + }, + "6": { + "ci_hi": 2.3187371940696404, + "ci_lo": 1.9570665989006186, + "effect": 2.1379018964851295, + "n_obs": 76.0, + "p_value": 2.315113512572155e-46, + "se": 0.09132635163236602, + "t_stat": 23.409474464623834 + } + }, + "overall": { + "att": 2.2086986136463964, + "ci_hi": 2.335225426756549, + "ci_lo": 2.0821718005362437, + "p_value": 6.710965133892381e-64, + "se": 0.06389920761802759, + "t_stat": 34.56535215349473 + }, + "reference_event_times": null + } + } + }, + "boot": { + "numpy": { + "CallawaySantAnna": { + "cband_all": { + "cband_crit_value": 2.3503719520172743, + "es": { + "-1": { + "cband_hi": 0.19516957587855796, + "cband_lo": -0.06918691038138083, + "ci_hi": 0.1441877963462948, + "ci_lo": -0.04499070699452153, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.28, + "se": 0.05623715983188262, + "t_stat": 1.1201016007368991 + }, + "-2": { + "cband_hi": 0.14471235336700228, + "cband_lo": -0.13470662540196537, + "ci_hi": 0.11684252371326793, + "ci_lo": -0.08577173934805524, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.8, + "se": 0.059441438307062056, + "t_stat": 0.08416458492600216 + }, + "-3": { + "cband_hi": 0.16676857819771282, + "cband_lo": -0.16642315009713507, + "ci_hi": 0.11297371250678216, + "ci_lo": -0.12884471501435063, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 1.0, + "se": 0.07088063827703452, + "t_stat": 0.0024366886993007564 + }, + "-4": { + "cband_hi": 0.25510748253343274, + "cband_lo": -0.2999410363083401, + "ci_hi": 0.19786249324652264, + "ci_lo": -0.21858723977972763, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.72, + "se": 0.11807674065489644, + "t_stat": -0.18984921808581534 + }, + "0": { + "cband_hi": 1.5699282098576621, + "cband_lo": 1.337909484150053, + "ci_hi": 1.5427104461156507, + "ci_lo": 1.358846153875199, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04935787408211546, + "t_stat": 29.45667482730332 + }, + "1": { + "cband_hi": 1.932886183298838, + "cband_lo": 1.6567777789083131, + "ci_hi": 1.9009139876482957, + "ci_lo": 1.6975457252715178, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.0587371722491725, + "t_stat": 30.557003552871265 + }, + "2": { + "cband_hi": 2.263798782225143, + "cband_lo": 1.9589640647426647, + "ci_hi": 2.2013194843407495, + "ci_lo": 1.9706079514860055, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.0648481865223172, + "t_stat": 32.55883528458088 + }, + "3": { + "cband_hi": 2.566577676798864, + "cband_lo": 2.150917062149906, + "ci_hi": 2.4832311040359816, + "ci_lo": 2.193152316015116, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08842443305456527, + "t_stat": 26.675289713408066 + }, + "4": { + "cband_hi": 2.929965671350734, + "cband_lo": 2.40694840053334, + "ci_hi": 2.913130037505706, + "ci_lo": 2.491481851299588, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11126266001610917, + "t_stat": 23.98340139949633 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.013942116683683675, + -0.0005956656548591118, + -0.001138670143573453, + 0.00016573192314804966, + -0.00036394232929632265, + 0.001269797110119199, + 0.0012012584106901372, + 6.16355732146296e-05, + -0.0002465972045182631 + ], + [ + -0.0005956656548591118, + 0.005024064882559809, + -0.0003493077020280771, + 0.00032985948607312923, + 0.0005874188794070946, + -0.0001857389502446375, + -0.0002403711303387929, + -0.000765648781189367, + 0.0007099296659948749 + ], + [ + -0.001138670143573453, + -0.0003493077020280771, + 0.003533284588012265, + -0.0005107115845477108, + -0.00016970527996405257, + -0.0011997563139342984, + -0.0011668723069694197, + -0.0009233392984879418, + -0.00044113983051368414 + ], + [ + 0.00016573192314804966, + 0.00032985948607312923, + -0.0005107115845477108, + 0.0031626181459567114, + -0.0013462637052243924, + -0.000974192687644395, + 0.0003130365717833466, + -0.0005445313498226878, + -0.001536297124972345 + ], + [ + -0.00036394232929632265, + 0.0005874188794070946, + -0.00016970527996405257, + -0.0013462637052243924, + 0.002436199733905964, + 0.001597621457378677, + 0.001548773254731629, + 0.0019157607759756133, + 0.0020191941883636647 + ], + [ + 0.001269797110119199, + -0.0001857389502446375, + -0.0011997563139342984, + -0.000974192687644395, + 0.001597621457378677, + 0.0034500554038289595, + 0.0024401552989299293, + 0.003301352865965306, + 0.0032879518727625284 + ], + [ + 0.0012012584106901372, + -0.0002403711303387929, + -0.0011668723069694197, + 0.0003130365717833466, + 0.001548773254731629, + 0.0024401552989299293, + 0.0042052872952332435, + 0.003388475246944632, + 0.0025449206742504116 + ], + [ + 6.16355732146296e-05, + -0.000765648781189367, + -0.0009233392984879418, + -0.0005445313498226878, + 0.0019157607759756133, + 0.003301352865965306, + 0.003388475246944632, + 0.007818880361021295, + 0.004611790191592932 + ], + [ + -0.0002465972045182631, + 0.0007099296659948749, + -0.00044113983051368414, + -0.001536297124972345, + 0.0020191941883636647, + 0.0032879518727625284, + 0.0025449206742504116, + 0.004611790191592932, + 0.012379379513860297 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.292975262873179, + "ci_lo": 1.9642542132504837, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.08903611464777396, + "t_stat": 23.783880706150782 + }, + "4": { + "ci_hi": 2.033444610661108, + "ci_lo": 1.6962644258188386, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.09410568127127582, + "t_stat": 20.049625395904606 + }, + "5": { + "ci_hi": 2.062784103433676, + "ci_lo": 1.724497263090421, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.09649166150111434, + "t_stat": 19.761570721147802 + }, + "6": { + "ci_hi": 1.6131610200020865, + "ci_lo": 1.1990178634324093, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.11413158539632841, + "t_stat": 12.526605560178487 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.9983178748675663, + "ci_lo": 1.801026843282318, + "p_value": 0.0196078431372549, + "se": 0.05791140759360639, + "t_stat": 32.950699387052914 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": 2.3503719520172743, + "es": { + "-1": { + "cband_hi": 0.19516957587855796, + "cband_lo": -0.06918691038138083, + "ci_hi": 0.1441877963462948, + "ci_lo": -0.04499070699452153, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.28, + "se": 0.05623715983188262, + "t_stat": 1.1201016007368991 + }, + "-2": { + "cband_hi": 0.14471235336700228, + "cband_lo": -0.13470662540196537, + "ci_hi": 0.11684252371326793, + "ci_lo": -0.08577173934805524, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.8, + "se": 0.059441438307062056, + "t_stat": 0.08416458492600216 + }, + "-3": { + "cband_hi": 0.16676857819771282, + "cband_lo": -0.16642315009713507, + "ci_hi": 0.11297371250678216, + "ci_lo": -0.12884471501435063, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 1.0, + "se": 0.07088063827703452, + "t_stat": 0.0024366886993007564 + }, + "-4": { + "cband_hi": 0.25510748253343274, + "cband_lo": -0.2999410363083401, + "ci_hi": 0.19786249324652264, + "ci_lo": -0.21858723977972763, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.72, + "se": 0.11807674065489644, + "t_stat": -0.18984921808581534 + }, + "0": { + "cband_hi": 1.5699282098576621, + "cband_lo": 1.337909484150053, + "ci_hi": 1.5427104461156507, + "ci_lo": 1.358846153875199, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04935787408211546, + "t_stat": 29.45667482730332 + }, + "1": { + "cband_hi": 1.932886183298838, + "cband_lo": 1.6567777789083131, + "ci_hi": 1.9009139876482957, + "ci_lo": 1.6975457252715178, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.0587371722491725, + "t_stat": 30.557003552871265 + }, + "2": { + "cband_hi": 2.263798782225143, + "cband_lo": 1.9589640647426647, + "ci_hi": 2.2013194843407495, + "ci_lo": 1.9706079514860055, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.0648481865223172, + "t_stat": 32.55883528458088 + }, + "3": { + "cband_hi": 2.566577676798864, + "cband_lo": 2.150917062149906, + "ci_hi": 2.4832311040359816, + "ci_lo": 2.193152316015116, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08842443305456527, + "t_stat": 26.675289713408066 + }, + "4": { + "cband_hi": 2.929965671350734, + "cband_lo": 2.40694840053334, + "ci_hi": 2.913130037505706, + "ci_lo": 2.491481851299588, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11126266001610917, + "t_stat": 23.98340139949633 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.013942116683683675, + -0.0005956656548591118, + -0.001138670143573453, + 0.00016573192314804966, + -0.00036394232929632265, + 0.001269797110119199, + 0.0012012584106901372, + 6.16355732146296e-05, + -0.0002465972045182631 + ], + [ + -0.0005956656548591118, + 0.005024064882559809, + -0.0003493077020280771, + 0.00032985948607312923, + 0.0005874188794070946, + -0.0001857389502446375, + -0.0002403711303387929, + -0.000765648781189367, + 0.0007099296659948749 + ], + [ + -0.001138670143573453, + -0.0003493077020280771, + 0.003533284588012265, + -0.0005107115845477108, + -0.00016970527996405257, + -0.0011997563139342984, + -0.0011668723069694197, + -0.0009233392984879418, + -0.00044113983051368414 + ], + [ + 0.00016573192314804966, + 0.00032985948607312923, + -0.0005107115845477108, + 0.0031626181459567114, + -0.0013462637052243924, + -0.000974192687644395, + 0.0003130365717833466, + -0.0005445313498226878, + -0.001536297124972345 + ], + [ + -0.00036394232929632265, + 0.0005874188794070946, + -0.00016970527996405257, + -0.0013462637052243924, + 0.002436199733905964, + 0.001597621457378677, + 0.001548773254731629, + 0.0019157607759756133, + 0.0020191941883636647 + ], + [ + 0.001269797110119199, + -0.0001857389502446375, + -0.0011997563139342984, + -0.000974192687644395, + 0.001597621457378677, + 0.0034500554038289595, + 0.0024401552989299293, + 0.003301352865965306, + 0.0032879518727625284 + ], + [ + 0.0012012584106901372, + -0.0002403711303387929, + -0.0011668723069694197, + 0.0003130365717833466, + 0.001548773254731629, + 0.0024401552989299293, + 0.0042052872952332435, + 0.003388475246944632, + 0.0025449206742504116 + ], + [ + 6.16355732146296e-05, + -0.000765648781189367, + -0.0009233392984879418, + -0.0005445313498226878, + 0.0019157607759756133, + 0.003301352865965306, + 0.003388475246944632, + 0.007818880361021295, + 0.004611790191592932 + ], + [ + -0.0002465972045182631, + 0.0007099296659948749, + -0.00044113983051368414, + -0.001536297124972345, + 0.0020191941883636647, + 0.0032879518727625284, + 0.0025449206742504116, + 0.004611790191592932, + 0.012379379513860297 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.292975262873179, + "ci_lo": 1.9642542132504837, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.08903611464777396, + "t_stat": 23.783880706150782 + }, + "4": { + "ci_hi": 2.033444610661108, + "ci_lo": 1.6962644258188386, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.09410568127127582, + "t_stat": 20.049625395904606 + }, + "5": { + "ci_hi": 2.062784103433676, + "ci_lo": 1.724497263090421, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.09649166150111434, + "t_stat": 19.761570721147802 + }, + "6": { + "ci_hi": 1.6131610200020865, + "ci_lo": 1.1990178634324093, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.11413158539632841, + "t_stat": 12.526605560178487 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.9983178748675663, + "ci_lo": 1.801026843282318, + "p_value": 0.0196078431372549, + "se": 0.05791140759360639, + "t_stat": 32.950699387052914 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 1.9082213826980177, + "ci_hi": 1.9983178748675663, + "ci_lo": 1.801026843282318, + "p_value": 0.0196078431372549, + "se": 0.05791140759360639, + "t_stat": 32.950699387052914 + }, + "psu_cluster_all": { + "cband_crit_value": 2.184238347643952, + "es": { + "-1": { + "cband_hi": 0.16924033717529835, + "cband_lo": -0.04325767167812124, + "ci_hi": 0.1430755649635514, + "ci_lo": -0.004080448907026893, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.2, + "se": 0.04864350291318538, + "t_stat": 1.2949588120948017 + }, + "-2": { + "cband_hi": 0.07867996117518301, + "cband_lo": -0.06867423321014614, + "ci_hi": 0.06232135050962796, + "ci_lo": -0.05301588287510689, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 1.0, + "se": 0.033731253401048024, + "t_stat": 0.14831538938197314 + }, + "-3": { + "cband_hi": 0.1382755485278721, + "cband_lo": -0.13793012042729436, + "ci_hi": 0.11671752917977099, + "ci_lo": -0.12035677448406452, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 1.0, + "se": 0.06322699838437919, + "t_stat": 0.0027316503187275335 + }, + "-4": { + "cband_hi": 0.18150832578975928, + "cband_lo": -0.22634187956466667, + "ci_hi": 0.15338066620495105, + "ci_lo": -0.15830189042523726, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.88, + "se": 0.09336211082328931, + "t_stat": -0.24010572050885762 + }, + "0": { + "cband_hi": 1.5396199870355747, + "cband_lo": 1.3682177069721404, + "ci_hi": 1.5219792287715384, + "ci_lo": 1.379747292227955, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.03923616675083074, + "t_stat": 37.055578243332704 + }, + "1": { + "cband_hi": 1.9229669213968006, + "cband_lo": 1.6666970408103505, + "ci_hi": 1.8851667154854796, + "ci_lo": 1.7053286756526582, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.05866344230767625, + "t_stat": 30.595408494613988 + }, + "2": { + "cband_hi": 2.1912487113615735, + "cband_lo": 2.031514135606234, + "ci_hi": 2.181268953455749, + "ci_lo": 2.0584923931500803, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.036565280507880125, + "t_stat": 57.7427930035675 + }, + "3": { + "cband_hi": 2.5173383147053787, + "cband_lo": 2.2001564242433913, + "ci_hi": 2.4793686547888076, + "ci_lo": 2.247837108064675, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.0726069778062725, + "t_stat": 32.486510811232435 + }, + "4": { + "cband_hi": 2.7605766781134897, + "cband_lo": 2.5763373937705842, + "ci_hi": 2.7454892957996955, + "ci_lo": 2.5914247760843785, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.04217472066215604, + "t_stat": 63.27148097358901 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.008716483737380154, + 0.0031368017555590353, + -0.0007823413227427847, + -0.00023605926554679427, + -0.00032309081590136275, + -0.001862680353307813, + 0.0018895848756440704, + 0.0017763102898080416, + 0.0009039814905905331 + ], + [ + 0.0031368017555590353, + 0.003997653324698289, + -0.0006871169622722578, + -0.00210486850624056, + 0.0018007024961598083, + 0.0013319777692532098, + 0.0011398162164730165, + 0.0026535796916937124, + 0.00016929699112616897 + ], + [ + -0.0007823413227427847, + -0.0006871169622722578, + 0.0011377974560057137, + -0.00012668458994523498, + -0.0002519225106441739, + -0.00020728224428964083, + -0.00029917936528020187, + 0.0007137784249973768, + 0.0006227283010727826 + ], + [ + -0.00023605926554679427, + -0.00210486850624056, + -0.00012668458994523498, + 0.002366190375665075, + -0.00118349564869507, + -0.0012038059781428819, + -0.00038597925611077516, + -0.001525797373927881, + 0.00012599006177169128 + ], + [ + -0.00032309081590136275, + 0.0018007024961598083, + -0.0002519225106441739, + -0.00118349564869507, + 0.001539476781298995, + 0.0012462435729113925, + -3.528562811855558e-05, + 0.001724610439409343, + -0.0005502158100394466 + ], + [ + -0.001862680353307813, + 0.0013319777692532098, + -0.00020728224428964083, + -0.0012038059781428819, + 0.0012462435729113925, + 0.0034413994633860586, + 0.001017304189394465, + 0.0020452457324781777, + 0.00029107073980289027 + ], + [ + 0.0018895848756440704, + 0.0011398162164730165, + -0.00029917936528020187, + -0.00038597925611077516, + -3.528562811855558e-05, + 0.001017304189394465, + 0.0013370197386199582, + 0.0009787841764979373, + 0.0008105559416001281 + ], + [ + 0.0017763102898080416, + 0.0026535796916937124, + 0.0007137784249973768, + -0.001525797373927881, + 0.001724610439409343, + 0.0020452457324781777, + 0.0009787841764979373, + 0.005271773226160545, + 0.00042660473113220114 + ], + [ + 0.0009039814905905331, + 0.00016929699112616897, + 0.0006227283010727826, + 0.00012599006177169128, + -0.0005502158100394466, + 0.00029107073980289027, + 0.0008105559416001281, + 0.00042660473113220114, + 0.0017787070629308912 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.2215490997241383, + "ci_lo": 1.9825486444060603, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.06176690040916711, + "t_stat": 34.28412815430081 + }, + "4": { + "ci_hi": 2.063272787234798, + "ci_lo": 1.7036433202687882, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.10201104531625374, + "t_stat": 18.49587612072875 + }, + "5": { + "ci_hi": 2.031294855380226, + "ci_lo": 1.7782393777825032, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.07305278237293848, + "t_stat": 26.10204198685917 + }, + "6": { + "ci_hi": 1.6236682371990814, + "ci_lo": 1.2055309289654856, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.11716959048196342, + "t_stat": 12.201812316120643 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.9692574622530463, + "ci_lo": 1.8495002921441916, + "p_value": 0.0196078431372549, + "se": 0.03590486646829275, + "t_stat": 53.146594609484204 + }, + "reference_event_times": null + }, + "stratified_survey_all": { + "cband_crit_value": 2.4385334214792573, + "es": { + "-1": { + "cband_hi": 0.20863066827218424, + "cband_lo": -0.06936405467022061, + "ci_hi": 0.18121772004419043, + "ci_lo": -0.04343260960903114, + "effect": 0.06963330680098181, + "n_groups": 4.0, + "p_value": 0.16, + "se": 0.05700039222217597, + "t_stat": 1.221628555283713 + }, + "-2": { + "cband_hi": 0.19850133416910964, + "cband_lo": -0.1946240760020796, + "ci_hi": 0.16118426417141604, + "ci_lo": -0.15522783972859977, + "effect": 0.001938629083515029, + "n_groups": 3.0, + "p_value": 0.88, + "se": 0.08060693503489332, + "t_stat": 0.02405040065939526 + }, + "-3": { + "cband_hi": 0.17689858770985728, + "cband_lo": -0.16901862231076187, + "ci_hi": 0.13440593977912665, + "ci_lo": -0.12929571759247466, + "effect": 0.0039399826995477095, + "n_groups": 2.0, + "p_value": 1.0, + "se": 0.07092730552177129, + "t_stat": 0.05554958940796536 + }, + "-4": { + "cband_hi": 0.2909088521650203, + "cband_lo": -0.26569578531593013, + "ci_hi": 0.19435867258874057, + "ci_lo": -0.212428598208563, + "effect": 0.012606533424545097, + "n_groups": 1.0, + "p_value": 0.92, + "se": 0.11412692411312211, + "t_stat": 0.11046064302976881 + }, + "0": { + "cband_hi": 1.533860107960097, + "cband_lo": 1.3696470244733665, + "ci_hi": 1.4976640759526723, + "ci_lo": 1.3908911347703121, + "effect": 1.4517535662167318, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.03367045988385837, + "t_stat": 43.11653512379565 + }, + "1": { + "cband_hi": 1.8861103310888025, + "cband_lo": 1.6657497352750505, + "ci_hi": 1.84894275994513, + "ci_lo": 1.6848815996561057, + "effect": 1.7759300331819265, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.045183017356407104, + "t_stat": 39.305255316909324 + }, + "2": { + "cband_hi": 2.2830101412365114, + "cband_lo": 1.934336642405787, + "ci_hi": 2.2532482613613998, + "ci_lo": 1.9689431103907475, + "effect": 2.1086733918211493, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.07149245849154959, + "t_stat": 29.495046558937325 + }, + "3": { + "cband_hi": 2.558832763930004, + "cband_lo": 2.0846790217486126, + "ci_hi": 2.494965560845798, + "ci_lo": 2.1720816183825393, + "effect": 2.3217558928393083, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09722108747924431, + "t_stat": 23.881196487695934 + }, + "4": { + "cband_hi": 2.902900940587747, + "cband_lo": 2.4325932522829046, + "ci_hi": 2.8763288469837063, + "ci_lo": 2.514209781713328, + "effect": 2.667747096435326, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.09643248769162777, + "t_stat": 27.664401907438698 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.013024954807522332, + 0.002369297928261079, + -0.0015222581491149403, + -0.00043509722725348026, + 0.000466631111583956, + -0.001965308128820638, + 0.0017065348615906647, + 6.931227285932411e-05, + -0.0038179835016279337 + ], + [ + 0.002369297928261079, + 0.005030682668578689, + -0.0019876890512482022, + -0.0016408360456225315, + 0.0011277152001795507, + 0.0004735862835932727, + 0.00030447170826796485, + 0.0013180341684036964, + -0.0017646704877972482 + ], + [ + -0.0015222581491149403, + -0.0019876890512482022, + 0.006497477975719511, + -0.0007376533689779569, + -0.0009826008711669055, + -0.0005728700411489965, + -0.001910074212906339, + -0.0021802547089884677, + 0.0033412775067301254 + ], + [ + -0.00043509722725348026, + -0.0016408360456225315, + -0.0007376533689779569, + 0.003249044713481899, + -0.00042347399388194685, + 4.085591130641185e-05, + 0.0021645588948692733, + 0.0020216649628543946, + -0.0005527979436737165 + ], + [ + 0.000466631111583956, + 0.0011277152001795507, + -0.0009826008711669055, + -0.00042347399388194685, + 0.0011336998687905158, + 0.00040647671199136047, + 0.0003272692170020557, + 0.0013488591934123879, + 0.00023155567709411704 + ], + [ + -0.001965308128820638, + 0.0004735862835932727, + -0.0005728700411489965, + 4.085591130641185e-05, + 0.00040647671199136047, + 0.0020415050574293853, + 0.0010414737398037912, + 0.0025351769689276097, + 0.0015249602164618796 + ], + [ + 0.0017065348615906647, + 0.00030447170826796485, + -0.001910074212906339, + 0.0021645588948692733, + 0.0003272692170020557, + 0.0010414737398037912, + 0.005111171621165941, + 0.0042776781765350436, + 0.0005787036378496957 + ], + [ + 6.931227285932411e-05, + 0.0013180341684036964, + -0.0021802547089884677, + 0.0020216649628543946, + 0.0013488591934123879, + 0.0025351769689276097, + 0.0042776781765350436, + 0.009451939850646874, + 0.0028236024819365277 + ], + [ + -0.0038179835016279337, + -0.0017646704877972482, + 0.0033412775067301254, + -0.0005527979436737165, + 0.00023155567709411704, + 0.0015249602164618796, + 0.0005787036378496957, + 0.0028236024819365277, + 0.009299224682395938 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.30062747087495, + "ci_lo": 1.9457647932554265, + "df_used": null, + "effect": 2.1242700768040943, + "p_value": 0.0196078431372549, + "se": 0.09624435241511488, + "t_stat": 22.071633540031844 + }, + "4": { + "ci_hi": 2.013229590875692, + "ci_lo": 1.5954562636154388, + "df_used": null, + "effect": 1.8300828461356922, + "p_value": 0.0196078431372549, + "se": 0.10911131754759941, + "t_stat": 16.772621642455423 + }, + "5": { + "ci_hi": 2.0973034921814397, + "ci_lo": 1.6390768741483428, + "df_used": null, + "effect": 1.9033747125067557, + "p_value": 0.0196078431372549, + "se": 0.11729583583619467, + "t_stat": 16.22712945380982 + }, + "6": { + "ci_hi": 1.5956134547819238, + "ci_lo": 1.1898156451856987, + "df_used": null, + "effect": 1.4106784908973298, + "p_value": 0.0196078431372549, + "se": 0.12670562914080272, + "t_stat": 11.13351080345216 + } + }, + "overall": { + "att": 1.899842807641477, + "ci_hi": 1.9667359583706414, + "ci_lo": 1.8187567819914907, + "p_value": 0.0196078431372549, + "se": 0.041664313181091844, + "t_stat": 45.59880297039593 + }, + "reference_event_times": null + } + }, + "ContinuousDiD": { + "default_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.09710063057106542, + "ci_lo": -0.5124216763917435, + "effect": -0.23880903293700426, + "p_value": 0.5333333333333333, + "se": 0.21454099569378204, + "t_stat": -1.1131160837803717 + }, + "-2": { + "ci_hi": 0.031124731733537636, + "ci_lo": -0.996368373344308, + "effect": -0.4167762389538105, + "p_value": 0.06666666666666667, + "se": 0.29660876771575784, + "t_stat": -1.4051379605649754 + }, + "0": { + "ci_hi": 4.981600139743175, + "ci_lo": 4.100072507765267, + "effect": 4.537644146658957, + "p_value": 0.03225806451612903, + "se": 0.2675254958160739, + "t_stat": 16.961539059359886 + }, + "1": { + "ci_hi": 4.823392079878206, + "ci_lo": 3.8264605856879523, + "effect": 4.466341388503657, + "p_value": 0.03225806451612903, + "se": 0.307478923835168, + "t_stat": 14.525683038028173 + }, + "2": { + "ci_hi": 5.280577805151575, + "ci_lo": 4.265318902151936, + "effect": 4.879913354176228, + "p_value": 0.03225806451612903, + "se": 0.27636217673802127, + "t_stat": 17.65767447548426 + }, + "3": { + "ci_hi": 4.532441654446876, + "ci_lo": 3.5195431546392206, + "effect": 4.010249427241352, + "p_value": 0.03225806451612903, + "se": 0.3320130299147904, + "t_stat": 12.078590494688004 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "overall": { + "att": 4.641957051061234, + "ci_hi": 4.967273156162676, + "ci_lo": 4.134387805824363, + "p_value": 0.03225806451612903, + "se": 0.2543921845714363, + "t_stat": 18.247247095587237 + }, + "overall_acrt": 1.5251919678360473, + "overall_acrt_se": 0.2016199172383717, + "reference_event_times": null + }, + "default_plain": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "overall": { + "att": 4.641957051061234, + "ci_hi": 4.967273156162676, + "ci_lo": 4.134387805824363, + "p_value": 0.03225806451612903, + "se": 0.2543921845714363, + "t_stat": 18.247247095587237 + }, + "overall_acrt": 1.5251919678360473, + "overall_acrt_se": 0.2016199172383717, + "reference_event_times": null + } + }, + "EfficientDiD": { + "clustered_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.06140673774260856, + "ci_lo": -0.31404606857946105, + "effect": -0.134313461987274, + "n_groups": 2.0, + "p_value": 0.16, + "se": 0.09928418226921563, + "t_stat": -1.352818333368292 + }, + "-2": { + "ci_hi": 0.22981016540940377, + "ci_lo": -0.01702825129935269, + "effect": 0.11002324972857475, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.07021359998656145, + "t_stat": 1.5669791856511086 + }, + "-3": { + "ci_hi": 0.2672323393524546, + "ci_lo": 0.03181730364093322, + "effect": 0.1436002497880893, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.06403693269280925, + "t_stat": 2.2424598391829953 + }, + "-4": { + "ci_hi": 0.01870147771689904, + "ci_lo": -0.2769337196011382, + "effect": -0.11660705660378556, + "n_groups": 1.0, + "p_value": 0.16, + "se": 0.07870932472850917, + "t_stat": -1.4814897346152625 + }, + "-5": { + "ci_hi": -0.06367160729172608, + "ci_lo": -0.45358108626115884, + "effect": -0.2390395706166469, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.09994447698608666, + "t_stat": -2.391723663228772 + }, + "0": { + "ci_hi": 2.1966367267700275, + "ci_lo": 1.8485635956736268, + "effect": 2.0272407859305153, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09115942957587225, + "t_stat": 22.238410171744626 + }, + "1": { + "ci_hi": 2.3862123030442994, + "ci_lo": 2.1466698825817487, + "effect": 2.246187711623196, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.06303311366536404, + "t_stat": 35.63504293232225 + }, + "2": { + "ci_hi": 2.4384632300589923, + "ci_lo": 1.9699706047649748, + "effect": 2.15004585834593, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.14006235458773447, + "t_stat": 15.350633399491725 + }, + "3": { + "ci_hi": 2.8740652257063366, + "ci_lo": 2.414588824667343, + "effect": 2.621212494601111, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.12398718288794852, + "t_stat": 21.140995654123305 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3514262384213946, + "ci_lo": 2.140349256781182, + "df_used": null, + "effect": 2.217677302443109, + "p_value": 0.0196078431372549, + "se": 0.05839383988637902, + "t_stat": 37.9779323770829 + }, + "6": { + "ci_hi": 2.3647658441654524, + "ci_lo": 2.065501633683991, + "df_used": null, + "effect": 2.2085745786428994, + "p_value": 0.0196078431372549, + "se": 0.08472807309705341, + "t_stat": 26.06662110813077 + } + }, + "overall": { + "att": 2.214245127895489, + "ci_hi": 2.3260154344745962, + "ci_lo": 2.1347163378870384, + "p_value": 0.0196078431372549, + "se": 0.05709102466386134, + "t_stat": 38.78446990455065 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2605749520355526, + "ci_lo": -0.035048967394950475, + "effect": 0.10640994510667033, + "n_groups": 2.0, + "p_value": 0.2, + "se": 0.08472980304307906, + "t_stat": 1.255873863563314 + }, + "-2": { + "ci_hi": 0.12435262021680903, + "ci_lo": -0.1875069894306046, + "effect": -0.05979558595018031, + "n_groups": 2.0, + "p_value": 0.6, + "se": 0.08985729450712923, + "t_stat": -0.6654505488748735 + }, + "-3": { + "ci_hi": 0.23435645131917457, + "ci_lo": -0.02967738704445458, + "effect": 0.08653759783531222, + "n_groups": 2.0, + "p_value": 0.16, + "se": 0.07113622041795355, + "t_stat": 1.216505421947771 + }, + "-4": { + "ci_hi": 0.057629806978680434, + "ci_lo": -0.29349999008931327, + "effect": -0.07878370911167488, + "n_groups": 1.0, + "p_value": 0.4, + "se": 0.11240838715227047, + "t_stat": -0.7008703808280161 + }, + "-5": { + "ci_hi": 0.14032826360085635, + "ci_lo": -0.22157050663066102, + "effect": -0.028022683129665008, + "n_groups": 1.0, + "p_value": 0.76, + "se": 0.10205268372250821, + "t_stat": -0.2745903596799235 + }, + "0": { + "ci_hi": 2.2730956766537713, + "ci_lo": 2.0240927701086133, + "effect": 2.139053682427927, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.0710813088208181, + "t_stat": 30.09305424890329 + }, + "1": { + "ci_hi": 2.333395710107787, + "ci_lo": 2.041687404210498, + "effect": 2.209130774601568, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08918558875238361, + "t_stat": 24.770041948537628 + }, + "2": { + "ci_hi": 2.804114042639948, + "ci_lo": 2.3877126006806453, + "effect": 2.5747639239316404, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11166693464763619, + "t_stat": 23.05753204434491 + }, + "3": { + "ci_hi": 2.971445529708958, + "ci_lo": 2.550115501326433, + "effect": 2.751359710941057, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.1201103620587699, + "t_stat": 22.906930457796964 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.5871780715675565, + "ci_lo": 2.3249228067217893, + "df_used": null, + "effect": 2.4517840485645532, + "p_value": 0.0196078431372549, + "se": 0.07790950399807581, + "t_stat": 31.46964006631472 + }, + "6": { + "ci_hi": 2.2518896489681466, + "ci_lo": 1.9261714280339786, + "df_used": null, + "effect": 2.110767202972924, + "p_value": 0.0196078431372549, + "se": 0.08591637300079445, + "t_stat": 24.56769448302253 + } + }, + "overall": { + "att": 2.3344742536810323, + "ci_hi": 2.4413523584679644, + "ci_lo": 2.237210144978274, + "p_value": 0.0196078431372549, + "se": 0.0666262699770178, + "t_stat": 35.03834530263049 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3344742536810323, + "ci_hi": 2.4413523584679644, + "ci_lo": 2.237210144978274, + "p_value": 0.0196078431372549, + "se": 0.0666262699770178, + "t_stat": 35.03834530263049 + }, + "fpc_survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2654898985976349, + "ci_lo": -0.029546895280431588, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.07619477432858715, + "t_stat": 1.4823941131187868 + }, + "-2": { + "ci_hi": 0.20550821973543668, + "ci_lo": -0.08469002677251986, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.36, + "se": 0.07455090278162223, + "t_stat": 1.0004407329998481 + }, + "-3": { + "ci_hi": 0.11127020711596734, + "ci_lo": -0.12138927171243204, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.8, + "se": 0.06283516651493215, + "t_stat": -0.18146824778675938 + }, + "-4": { + "ci_hi": 0.1391680776765107, + "ci_lo": -0.16028687555616358, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.92, + "se": 0.09395987110355748, + "t_stat": -0.20292312903425136 + }, + "-5": { + "ci_hi": 0.020511715721675726, + "ci_lo": -0.4111304161866572, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.08, + "se": 0.11999664016599068, + "t_stat": -1.6224776907358804 + }, + "0": { + "ci_hi": 2.1436625890052734, + "ci_lo": 1.9442280433715498, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.05731270458326901, + "t_stat": 35.57015002937894 + }, + "1": { + "ci_hi": 2.279218055191279, + "ci_lo": 1.9607415627755733, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08951432301375273, + "t_stat": 23.503314992999954 + }, + "2": { + "ci_hi": 2.726121676654111, + "ci_lo": 2.3213404164151057, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10559337299624555, + "t_stat": 23.699316950060048 + }, + "3": { + "ci_hi": 2.601274114600099, + "ci_lo": 2.220020301209764, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11048622106660316, + "t_stat": 21.80001698568363 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.39003271662755, + "ci_lo": 2.1711750250233814, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.055309240471839265, + "t_stat": 41.00311559076071 + }, + "6": { + "ci_hi": 2.148749361253044, + "ci_lo": 1.9495825949116592, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.05485191290632769, + "t_stat": 37.59964918247013 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.287984327865623, + "ci_lo": 2.097667311536792, + "p_value": 0.0196078431372549, + "se": 0.048238828046649765, + "t_stat": 45.58654809809011 + }, + "reference_event_times": null + }, + "fractional_balance_e1_es": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.23119857226080878, + "ci_lo": -0.23028584066490967, + "effect": 0.026184943804410075, + "n_groups": 1.0, + "p_value": 0.84, + "se": 0.1382116969754096, + "t_stat": 0.1894553382776919 + }, + "0": { + "ci_hi": 0.4581907251744048, + "ci_lo": 0.07950931065579732, + "effect": 0.24480370561350984, + "n_groups": 2.0, + "p_value": 0.04, + "se": 0.1108351331638737, + "t_stat": 2.2087193710640363 + }, + "1": { + "ci_hi": 0.9191250916004011, + "ci_lo": 0.3604761016581456, + "effect": 0.6358462518585993, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.14731426401160125, + "t_stat": 4.316257194269561 + }, + "2": { + "ci_hi": 1.4696357627539216, + "ci_lo": 0.2636018803591971, + "effect": 0.8040757046489879, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.32158781006538734, + "t_stat": 2.5003301726066605 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "stratified_psu_survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2341681818279641, + "ci_lo": -0.012605995579570668, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.07382329655029328, + "t_stat": 1.530014103856262 + }, + "-2": { + "ci_hi": 0.245876271222739, + "ci_lo": -0.0681235255341213, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.32, + "se": 0.08444394222328314, + "t_stat": 0.8832339876723816 + }, + "-3": { + "ci_hi": 0.1285795168194135, + "ci_lo": -0.13635567927805417, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.8, + "se": 0.06358199239982865, + "t_stat": -0.17933674514554412 + }, + "-4": { + "ci_hi": 0.14356637440823122, + "ci_lo": -0.15041804226742786, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.76, + "se": 0.08503290448100671, + "t_stat": -0.224226505778685 + }, + "-5": { + "ci_hi": 0.044372534115678805, + "ci_lo": -0.3867279606592377, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.12, + "se": 0.12606335987951608, + "t_stat": -1.5443969748121575 + }, + "0": { + "ci_hi": 2.1537261129444243, + "ci_lo": 1.9388589224111425, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.059276136953055, + "t_stat": 34.39194261648464 + }, + "1": { + "ci_hi": 2.2583816766098894, + "ci_lo": 1.9380914210633695, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08633551187403303, + "t_stat": 24.36869006170978 + }, + "2": { + "ci_hi": 2.705681821089329, + "ci_lo": 2.3565597898157056, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10069712926475424, + "t_stat": 24.851659950348264 + }, + "3": { + "ci_hi": 2.609072967054054, + "ci_lo": 2.2184296329305195, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10884064212889258, + "t_stat": 22.1296149014226 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3752330135384976, + "ci_lo": 2.158980257271318, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.056314729245228005, + "t_stat": 40.27101276521149 + }, + "6": { + "ci_hi": 2.1375228715477235, + "ci_lo": 1.9471671672818898, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.05147453168670459, + "t_stat": 40.066662380107275 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.2961195691978564, + "ci_lo": 2.1074255353198432, + "p_value": 0.0196078431372549, + "se": 0.04788565840007229, + "t_stat": 45.92276118606689 + }, + "reference_event_times": null + }, + "survey_weights_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.25431157023796336, + "ci_lo": -0.014375068126058226, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.07544541841931844, + "t_stat": 1.497117880470129 + }, + "-2": { + "ci_hi": 0.19985573302070553, + "ci_lo": -0.06532168096359618, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.44, + "se": 0.0754114840868331, + "t_stat": 0.9890238963970865 + }, + "-3": { + "ci_hi": 0.10911411454532711, + "ci_lo": -0.11105482725807203, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.92, + "se": 0.0670354829586452, + "t_stat": -0.17009779095480454 + }, + "-4": { + "ci_hi": 0.11309086737544662, + "ci_lo": -0.2088589463899141, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.84, + "se": 0.08749542398036779, + "t_stat": -0.21791575125422546 + }, + "-5": { + "ci_hi": 0.002106435862849542, + "ci_lo": -0.341026255826165, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.08, + "se": 0.09832228590036135, + "t_stat": -1.9801398009590563 + }, + "0": { + "ci_hi": 2.135715223163936, + "ci_lo": 1.9145153281112819, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.06391514657548718, + "t_stat": 31.895749440371418 + }, + "1": { + "ci_hi": 2.2482942628486886, + "ci_lo": 1.9606836228520357, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09033760899954524, + "t_stat": 23.289119044405595 + }, + "2": { + "ci_hi": 2.7319703078942243, + "ci_lo": 2.3309800447594795, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11904925455042854, + "t_stat": 21.020634055326195 + }, + "3": { + "ci_hi": 2.5960693757767013, + "ci_lo": 2.154538806967436, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.12304990563388465, + "t_stat": 19.57418401524301 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.4007652054016155, + "ci_lo": 2.1392241661065134, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.06805052453928004, + "t_stat": 33.32599117579115 + }, + "6": { + "ci_hi": 2.2291417762758665, + "ci_lo": 1.9221679802557796, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.08080094432595937, + "t_stat": 25.52461112267872 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.2927633157503373, + "ci_lo": 2.1091408555163262, + "p_value": 0.0196078431372549, + "se": 0.056641695596933314, + "t_stat": 38.82372573364767 + }, + "reference_event_times": null + } + }, + "ImputationDiD": { + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.3060480256636122, + "ci_lo": 2.027751271589125, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.08156915520915543, + "t_stat": 26.308233278685016 + }, + "1": { + "ci_hi": 2.339046004176374, + "ci_lo": 2.1207335731351606, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06987184726545864, + "t_stat": 32.093636308520765 + }, + "2": { + "ci_hi": 2.7961219749685484, + "ci_lo": 2.4322016495426557, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.11100400667818662, + "t_stat": 23.40698260799346 + }, + "3": { + "ci_hi": 2.9324719817762177, + "ci_lo": 2.53139383275232, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.12354164101325206, + "t_stat": 22.39269572456501 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.571627829224781, + "ci_lo": 2.3050703901774883, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.08100030104413747, + "t_stat": 30.38077615455412 + }, + "6": { + "ci_hi": 2.3255341366205866, + "ci_lo": 2.0032444014190465, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.10039252979248592, + "t_stat": 21.427174233377084 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4758495125487974, + "ci_lo": 2.2339808544115445, + "p_value": 0.05, + "se": 0.0674211743735065, + "t_stat": 34.91940111787398 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4758495125487974, + "ci_lo": 2.2339808544115445, + "p_value": 0.05, + "se": 0.0674211743735065, + "t_stat": 34.91940111787398 + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.059815163957579826, + "ci_lo": -0.3559461519303694, + "effect": -0.14806549398639476, + "n_obs": 84.0, + "p_value": 0.1623681759350742, + "se": 0.10584141425966392, + "t_stat": -1.3989372215222033 + }, + "-3": { + "ci_hi": 0.1584251692403737, + "ci_lo": -0.20715293281084962, + "effect": -0.024363881785237962, + "n_obs": 84.0, + "p_value": 0.7935758227509604, + "se": 0.09306614604301801, + "t_stat": -0.261791025213145 + }, + "-4": { + "ci_hi": 0.06957272135423254, + "ci_lo": -0.34423198704465785, + "effect": -0.13732963284521266, + "n_obs": 84.0, + "p_value": 0.19287575347672523, + "se": 0.10534331572120142, + "t_stat": -1.3036387919349843 + }, + "-5": { + "ci_hi": 0.1567750477585551, + "ci_lo": -0.43065589815781424, + "effect": -0.13694042519962957, + "n_obs": 43.0, + "p_value": 0.3601949931340407, + "se": 0.14954378803351004, + "t_stat": -0.9157212546263955 + }, + "-6": { + "ci_hi": 0.07789021576946764, + "ci_lo": -0.4705808158646776, + "effect": -0.19634530004760498, + "n_obs": 43.0, + "p_value": 0.16019426463331482, + "se": 0.13962566369272308, + "t_stat": -1.4062264404322253 + }, + "0": { + "ci_hi": 2.3060480256636122, + "ci_lo": 2.027751271589125, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.08156915520915543, + "t_stat": 26.308233278685016 + }, + "1": { + "ci_hi": 2.339046004176374, + "ci_lo": 2.1207335731351606, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06987184726545864, + "t_stat": 32.093636308520765 + }, + "2": { + "ci_hi": 2.7961219749685484, + "ci_lo": 2.4322016495426557, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.11100400667818662, + "t_stat": 23.40698260799346 + }, + "3": { + "ci_hi": 2.9324719817762177, + "ci_lo": 2.53139383275232, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.12354164101325206, + "t_stat": 22.39269572456501 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.571627829224781, + "ci_lo": 2.3050703901774883, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.08100030104413747, + "t_stat": 30.38077615455412 + }, + "6": { + "ci_hi": 2.3255341366205866, + "ci_lo": 2.0032444014190465, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.10039252979248592, + "t_stat": 21.427174233377084 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4758495125487974, + "ci_lo": 2.2339808544115445, + "p_value": 0.05, + "se": 0.0674211743735065, + "t_stat": 34.91940111787398 + }, + "reference_event_times": null + }, + "pretrends_plain_overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4758495125487974, + "ci_lo": 2.2339808544115445, + "p_value": 0.05, + "se": 0.0674211743735065, + "t_stat": 34.91940111787398 + }, + "survey_tsl_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.0614800976250596, + "ci_lo": 1.9232838880591523, + "effect": 2.0361973460553426, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.05110820464469081, + "t_stat": 39.84090930626861 + }, + "1": { + "ci_hi": 2.2616110022002758, + "ci_lo": 1.9480020790996089, + "effect": 2.1281777081775473, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.09267938062775614, + "t_stat": 22.962795972119267 + }, + "2": { + "ci_hi": 2.673975059553055, + "ci_lo": 2.367430710487471, + "effect": 2.4900024320750482, + "n_obs": 46.0, + "p_value": 0.05, + "se": 0.08887132991490315, + "t_stat": 28.01806200559052 + }, + "3": { + "ci_hi": 2.816791739465061, + "ci_lo": 2.308734840770423, + "effect": 2.4352726594598915, + "n_obs": 46.0, + "p_value": 0.05, + "se": 0.1529122151542249, + "t_stat": 15.925952396960001 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.420472976901457, + "ci_lo": 2.1267973389224313, + "df_used": null, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 0.05, + "se": 0.07975913859456671, + "t_stat": 28.13913897508415 + }, + "6": { + "ci_hi": 2.271924746304451, + "ci_lo": 1.9563874258342548, + "df_used": null, + "effect": 2.1379018964851286, + "n_obs": 76.0, + "p_value": 0.05, + "se": 0.09539976421054529, + "t_stat": 22.409928516875826 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.320700304079089, + "ci_lo": 2.0966707213213933, + "p_value": 0.05, + "se": 0.06301094530299538, + "t_stat": 35.05261828759455 + }, + "reference_event_times": null + }, + "survey_tsl_plain_overall": { + "att": 2.2086986136463955, + "ci_hi": 2.320700304079089, + "ci_lo": 2.0966707213213933, + "p_value": 0.05, + "se": 0.06301094530299538, + "t_stat": 35.05261828759455 + } + }, + "TwoStageDiD": { + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.2642399550189514, + "ci_lo": 1.9858728804243493, + "effect": 2.145940363587725, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.08154313850706789, + "t_stat": 26.316627037868088 + }, + "1": { + "ci_hi": 2.3485697900520406, + "ci_lo": 2.147903099025333, + "effect": 2.2424416543421404, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06615023187989777, + "t_stat": 33.899225907681064 + }, + "2": { + "ci_hi": 2.764336057924927, + "ci_lo": 2.4004157324983204, + "effect": 2.5982688537339027, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.11100400667836488, + "t_stat": 23.406982607955857 + }, + "3": { + "ci_hi": 3.001466920294402, + "ci_lo": 2.6003887712697877, + "effect": 2.7664303765231937, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.12354164101342824, + "t_stat": 22.39269572453307 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.616633638769783, + "ci_lo": 2.350076199721541, + "effect": 2.460852014473436, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.08100030104425085, + "t_stat": 30.380776154511583 + }, + "6": { + "ci_hi": 2.299012053766804, + "ci_lo": 1.976722318564882, + "effect": 2.1511282275930945, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.10039252979263526, + "t_stat": 21.4271742333452 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4633962373267533, + "ci_lo": 2.2360788433223795, + "p_value": 0.05, + "se": 0.06315261378178635, + "t_stat": 37.27964514535417 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4633962373267533, + "ci_lo": 2.2360788433223795, + "p_value": 0.05, + "se": 0.06315261378178635, + "t_stat": 37.27964514535417 + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.01924623551227377, + "ci_lo": -0.09396845261566486, + "effect": -0.02985698371664577, + "n_obs": 84.0, + "p_value": 0.5263157894736842, + "se": 0.037194572669387, + "t_stat": -0.8027242033948563 + }, + "-3": { + "ci_hi": 0.10529958766448326, + "ci_lo": -0.030845886551268072, + "effect": 0.03459686456772884, + "n_obs": 84.0, + "p_value": 0.631578947368421, + "se": 0.045456175254620056, + "t_stat": 0.7611037306578604 + }, + "-4": { + "ci_hi": 0.08700029442668869, + "ci_lo": -0.044211008350224276, + "effect": -0.002521727613600925, + "n_obs": 84.0, + "p_value": 1.0, + "se": 0.03906518771601502, + "t_stat": -0.06455178538837858 + }, + "-5": { + "ci_hi": 0.09276364317560559, + "ci_lo": -0.14996338707692644, + "effect": -0.034152641587387955, + "n_obs": 43.0, + "p_value": 0.631578947368421, + "se": 0.06710147786684861, + "t_stat": -0.5089700357294369 + }, + "-6": { + "ci_hi": 0.03240640435441551, + "ci_lo": -0.13906560665947076, + "effect": -0.03367413800267827, + "n_obs": 43.0, + "p_value": 0.10526315789473684, + "se": 0.05013672637303242, + "t_stat": -0.6716461252801487 + }, + "0": { + "ci_hi": 2.2642399550189514, + "ci_lo": 1.9858728804243493, + "effect": 2.145940363587725, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.08154313850706789, + "t_stat": 26.316627037868088 + }, + "1": { + "ci_hi": 2.3485697900520406, + "ci_lo": 2.147903099025333, + "effect": 2.2424416543421404, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06615023187989783, + "t_stat": 33.899225907681036 + }, + "2": { + "ci_hi": 2.764336057924927, + "ci_lo": 2.4004157324983204, + "effect": 2.5982688537339027, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.11100400667836488, + "t_stat": 23.406982607955857 + }, + "3": { + "ci_hi": 3.001466920294402, + "ci_lo": 2.6003887712697877, + "effect": 2.7664303765231937, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.12354164101342824, + "t_stat": 22.39269572453307 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.616633638769783, + "ci_lo": 2.350076199721541, + "effect": 2.460852014473436, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.08100030104425085, + "t_stat": 30.380776154511583 + }, + "6": { + "ci_hi": 2.299012053766804, + "ci_lo": 1.976722318564882, + "effect": 2.1511282275930945, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.10039252979263526, + "t_stat": 21.4271742333452 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4633962373267533, + "ci_lo": 2.2360788433223795, + "p_value": 0.05, + "se": 0.06315261378178635, + "t_stat": 37.27964514535417 + }, + "reference_event_times": null + }, + "prop5_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.139071141854362, + "ci_lo": 1.734124092890948, + "effect": 1.9034726388790302, + "n_obs": 36.0, + "p_value": 0.05, + "se": 0.11122136576199927, + "t_stat": 17.114271397747796 + }, + "1": { + "ci_hi": 2.496262490086056, + "ci_lo": 2.1246248208374805, + "effect": 2.3023388848323054, + "n_obs": 36.0, + "p_value": 0.05, + "se": 0.1098242533981663, + "t_stat": 20.9638473615223 + }, + "2": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "3": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.211593169391866, + "ci_lo": 1.9643843614584682, + "effect": 2.1029057618556686, + "n_obs": 72.0, + "p_value": 0.05, + "se": 0.07324916315889564, + "t_stat": 28.708939067248362 + }, + "6": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 0.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "overall": { + "att": 2.1029057618556686, + "ci_hi": 2.211593169391866, + "ci_lo": 1.9643843614584684, + "p_value": 0.05, + "se": 0.07324916315889562, + "t_stat": 28.70893906724837 + }, + "reference_event_times": null + }, + "prop5_plain_overall": { + "att": 2.1029057618556686, + "ci_hi": 2.211593169391866, + "ci_lo": 1.9643843614584684, + "p_value": 0.05, + "se": 0.07324916315889562, + "t_stat": 28.70893906724837 + } + } + }, + "rust": { + "CallawaySantAnna": { + "cband_all": { + "cband_crit_value": 2.6040884451808886, + "es": { + "-1": { + "cband_hi": 0.17975452776202336, + "cband_lo": -0.05377186226484623, + "ci_hi": 0.1383020908309624, + "ci_lo": -0.018122331027180152, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.12, + "se": 0.04483841369885731, + "t_stat": 1.4048519461827855 + }, + "-2": { + "cband_hi": 0.18928382576292235, + "cband_lo": -0.17927809779788545, + "ci_hi": 0.1523371721407983, + "ci_lo": -0.14238567774290867, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 1.0, + "se": 0.07076601492604186, + "t_stat": 0.07069585574017383 + }, + "-3": { + "cband_hi": 0.21134585666275663, + "cband_lo": -0.21100042856217888, + "ci_hi": 0.12434521944668774, + "ci_lo": -0.156788135544127, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.96, + "se": 0.08109292255540075, + "t_stat": 0.002129828903020242 + }, + "-4": { + "cband_hi": 0.20155862551695183, + "cband_lo": -0.24639217929185922, + "ci_hi": 0.11632440673805822, + "ci_lo": -0.20400189129157686, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.84, + "se": 0.08600913798411615, + "t_stat": -0.26063250269516175 + }, + "0": { + "cband_hi": 1.5673872303415597, + "cband_lo": 1.3404504636661554, + "ci_hi": 1.5415374421679076, + "ci_lo": 1.368208423073332, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04357316801112729, + "t_stat": 33.36729720071237 + }, + "1": { + "cband_hi": 1.915820850854157, + "cband_lo": 1.6738431113529941, + "ci_hi": 1.8620294053554955, + "ci_lo": 1.705826580818813, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04646112153927904, + "t_stat": 38.63083631302773 + }, + "2": { + "cband_hi": 2.2749242055141607, + "cband_lo": 1.9478386414536466, + "ci_hi": 2.2334184090136393, + "ci_lo": 2.017323275290894, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.06280231469591921, + "t_stat": 33.61948414969962 + }, + "3": { + "cband_hi": 2.5660352098040353, + "cband_lo": 2.1514595291447347, + "ci_hi": 2.4874425791723134, + "ci_lo": 2.2203312249189118, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.07960092166349271, + "t_stat": 29.6321615401116 + }, + "4": { + "cband_hi": 2.951878071538104, + "cband_lo": 2.38503600034597, + "ci_hi": 2.8351030413642038, + "ci_lo": 2.502427034472139, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10883694680976154, + "t_stat": 24.517933607658904 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.0073975718167707295, + 0.00016446371867586494, + -0.0002088961399349469, + -0.00021236165833216837, + -0.0001633312780568242, + 0.0014476594611222385, + 0.000666349669921231, + 0.001179347445298157, + 0.0013592959379791015 + ], + [ + 0.00016446371867586494, + 0.0065760620885762225, + -0.002522081555470615, + 0.0019071768396986158, + -0.0001407528669437694, + -0.0005392420997478858, + -0.0010878301732690446, + -0.0017264698348421908, + 0.0006937139554424842 + ], + [ + -0.0002088961399349469, + -0.002522081555470615, + 0.005007828868512777, + -0.0014681134471507247, + 7.209808136776544e-06, + 0.00034075358171953713, + 0.0002868594739125574, + 0.0017837388184845857, + 0.0021586787024569817 + ], + [ + -0.00021236165833216837, + 0.0019071768396986158, + -0.0014681134471507247, + 0.002010483343029875, + -0.0004594846748256816, + -0.00048820978703640553, + -5.365828426266825e-05, + -0.0004304779916969216, + 7.423688070610573e-05 + ], + [ + -0.0001633312780568242, + -0.0001407528669437694, + 7.209808136776544e-06, + -0.0004594846748256816, + 0.001898620970525926, + 0.0002966210423376534, + 0.0011423083319685183, + 0.0008730765307925354, + 0.0008774114921566766 + ], + [ + 0.0014476594611222385, + -0.0005392420997478858, + 0.00034075358171953713, + -0.00048820978703640553, + 0.0002966210423376534, + 0.002158635814687659, + 0.0010768991364652517, + 0.0019264693682071116, + 0.0017553877425780344 + ], + [ + 0.000666349669921231, + -0.0010878301732690446, + 0.0002868594739125574, + -5.365828426266825e-05, + 0.0011423083319685183, + 0.0010768991364652517, + 0.003944130731165269, + 0.002688437695846199, + 0.0025195292692069414 + ], + [ + 0.001179347445298157, + -0.0017264698348421908, + 0.0017837388184845857, + -0.0004304779916969216, + 0.0008730765307925354, + 0.0019264693682071116, + 0.002688437695846199, + 0.006336306729677503, + 0.003978413514594727 + ], + [ + 0.0013592959379791015, + 0.0006937139554424842, + 0.0021586787024569817, + 7.423688070610573e-05, + 0.0008774114921566766, + 0.0017553877425780344, + 0.0025195292692069414, + 0.003978413514594727, + 0.011845480990870861 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.2951208963267224, + "ci_lo": 1.9775561783486255, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.08591158728014282, + "t_stat": 24.648879113554422 + }, + "4": { + "ci_hi": 2.022216284914674, + "ci_lo": 1.7653129426768104, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.0725752691070692, + "t_stat": 25.997611587659875 + }, + "5": { + "ci_hi": 2.088078317762701, + "ci_lo": 1.7350726741746059, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.0949065559582727, + "t_stat": 20.09162352908154 + }, + "6": { + "ci_hi": 1.6145137520317316, + "ci_lo": 1.2614122062588298, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.08946080880594207, + "t_stat": 15.981091287905643 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.976801238969689, + "ci_lo": 1.8119756021686342, + "p_value": 0.0196078431372549, + "se": 0.04722597436226548, + "t_stat": 40.40618342906495 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": 2.6040884451808886, + "es": { + "-1": { + "cband_hi": 0.17975452776202336, + "cband_lo": -0.05377186226484623, + "ci_hi": 0.1383020908309624, + "ci_lo": -0.018122331027180152, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.12, + "se": 0.04483841369885731, + "t_stat": 1.4048519461827855 + }, + "-2": { + "cband_hi": 0.18928382576292235, + "cband_lo": -0.17927809779788545, + "ci_hi": 0.1523371721407983, + "ci_lo": -0.14238567774290867, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 1.0, + "se": 0.07076601492604186, + "t_stat": 0.07069585574017383 + }, + "-3": { + "cband_hi": 0.21134585666275663, + "cband_lo": -0.21100042856217888, + "ci_hi": 0.12434521944668774, + "ci_lo": -0.156788135544127, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.96, + "se": 0.08109292255540075, + "t_stat": 0.002129828903020242 + }, + "-4": { + "cband_hi": 0.20155862551695183, + "cband_lo": -0.24639217929185922, + "ci_hi": 0.11632440673805822, + "ci_lo": -0.20400189129157686, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 0.84, + "se": 0.08600913798411615, + "t_stat": -0.26063250269516175 + }, + "0": { + "cband_hi": 1.5673872303415597, + "cband_lo": 1.3404504636661554, + "ci_hi": 1.5415374421679076, + "ci_lo": 1.368208423073332, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04357316801112729, + "t_stat": 33.36729720071237 + }, + "1": { + "cband_hi": 1.915820850854157, + "cband_lo": 1.6738431113529941, + "ci_hi": 1.8620294053554955, + "ci_lo": 1.705826580818813, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.04646112153927904, + "t_stat": 38.63083631302773 + }, + "2": { + "cband_hi": 2.2749242055141607, + "cband_lo": 1.9478386414536466, + "ci_hi": 2.2334184090136393, + "ci_lo": 2.017323275290894, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.06280231469591921, + "t_stat": 33.61948414969962 + }, + "3": { + "cband_hi": 2.5660352098040353, + "cband_lo": 2.1514595291447347, + "ci_hi": 2.4874425791723134, + "ci_lo": 2.2203312249189118, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.07960092166349271, + "t_stat": 29.6321615401116 + }, + "4": { + "cband_hi": 2.951878071538104, + "cband_lo": 2.38503600034597, + "ci_hi": 2.8351030413642038, + "ci_lo": 2.502427034472139, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10883694680976154, + "t_stat": 24.517933607658904 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.0073975718167707295, + 0.00016446371867586494, + -0.0002088961399349469, + -0.00021236165833216837, + -0.0001633312780568242, + 0.0014476594611222385, + 0.000666349669921231, + 0.001179347445298157, + 0.0013592959379791015 + ], + [ + 0.00016446371867586494, + 0.0065760620885762225, + -0.002522081555470615, + 0.0019071768396986158, + -0.0001407528669437694, + -0.0005392420997478858, + -0.0010878301732690446, + -0.0017264698348421908, + 0.0006937139554424842 + ], + [ + -0.0002088961399349469, + -0.002522081555470615, + 0.005007828868512777, + -0.0014681134471507247, + 7.209808136776544e-06, + 0.00034075358171953713, + 0.0002868594739125574, + 0.0017837388184845857, + 0.0021586787024569817 + ], + [ + -0.00021236165833216837, + 0.0019071768396986158, + -0.0014681134471507247, + 0.002010483343029875, + -0.0004594846748256816, + -0.00048820978703640553, + -5.365828426266825e-05, + -0.0004304779916969216, + 7.423688070610573e-05 + ], + [ + -0.0001633312780568242, + -0.0001407528669437694, + 7.209808136776544e-06, + -0.0004594846748256816, + 0.001898620970525926, + 0.0002966210423376534, + 0.0011423083319685183, + 0.0008730765307925354, + 0.0008774114921566766 + ], + [ + 0.0014476594611222385, + -0.0005392420997478858, + 0.00034075358171953713, + -0.00048820978703640553, + 0.0002966210423376534, + 0.002158635814687659, + 0.0010768991364652517, + 0.0019264693682071116, + 0.0017553877425780344 + ], + [ + 0.000666349669921231, + -0.0010878301732690446, + 0.0002868594739125574, + -5.365828426266825e-05, + 0.0011423083319685183, + 0.0010768991364652517, + 0.003944130731165269, + 0.002688437695846199, + 0.0025195292692069414 + ], + [ + 0.001179347445298157, + -0.0017264698348421908, + 0.0017837388184845857, + -0.0004304779916969216, + 0.0008730765307925354, + 0.0019264693682071116, + 0.002688437695846199, + 0.006336306729677503, + 0.003978413514594727 + ], + [ + 0.0013592959379791015, + 0.0006937139554424842, + 0.0021586787024569817, + 7.423688070610573e-05, + 0.0008774114921566766, + 0.0017553877425780344, + 0.0025195292692069414, + 0.003978413514594727, + 0.011845480990870861 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.2951208963267224, + "ci_lo": 1.9775561783486255, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.08591158728014282, + "t_stat": 24.648879113554422 + }, + "4": { + "ci_hi": 2.022216284914674, + "ci_lo": 1.7653129426768104, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.0725752691070692, + "t_stat": 25.997611587659875 + }, + "5": { + "ci_hi": 2.088078317762701, + "ci_lo": 1.7350726741746059, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.0949065559582727, + "t_stat": 20.09162352908154 + }, + "6": { + "ci_hi": 1.6145137520317316, + "ci_lo": 1.2614122062588298, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.08946080880594207, + "t_stat": 15.981091287905643 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.976801238969689, + "ci_lo": 1.8119756021686342, + "p_value": 0.0196078431372549, + "se": 0.04722597436226548, + "t_stat": 40.40618342906495 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 1.9082213826980177, + "ci_hi": 1.976801238969689, + "ci_lo": 1.8119756021686342, + "p_value": 0.0196078431372549, + "se": 0.04722597436226548, + "t_stat": 40.40618342906495 + }, + "psu_cluster_all": { + "cband_crit_value": 2.173244570172544, + "es": { + "-1": { + "cband_hi": 0.1476703093792444, + "cband_lo": -0.0216876438820673, + "ci_hi": 0.1295408113758293, + "ci_lo": -0.000990485259906229, + "effect": 0.06299133274858856, + "n_groups": 4.0, + "p_value": 0.12, + "se": 0.03896431068682381, + "t_stat": 1.6166417841927778 + }, + "-2": { + "cband_hi": 0.07997565193095012, + "cband_lo": -0.06996992396591324, + "ci_hi": 0.07490562557228783, + "ci_lo": -0.05231562254459107, + "effect": 0.005002863982518443, + "n_groups": 3.0, + "p_value": 0.84, + "se": 0.03449809054048585, + "t_stat": 0.14501857651069824 + }, + "-3": { + "cband_hi": 0.0951601165421576, + "cband_lo": -0.09481468844157985, + "ci_hi": 0.08657415168941751, + "ci_lo": -0.07281551397813087, + "effect": 0.00017271405028887465, + "n_groups": 2.0, + "p_value": 0.76, + "se": 0.04370764514751657, + "t_stat": 0.003951575284048175 + }, + "-4": { + "cband_hi": 0.12804736703498143, + "cband_lo": -0.17288092080988882, + "ci_hi": 0.11362479644795043, + "ci_lo": -0.1330264678220342, + "effect": -0.022416776887453693, + "n_groups": 1.0, + "p_value": 1.0, + "se": 0.06923479574620038, + "t_stat": -0.3237790571323225 + }, + "0": { + "cband_hi": 1.5379537681884359, + "cband_lo": 1.3698839258192792, + "ci_hi": 1.5219792287715384, + "ci_lo": 1.3838318976643895, + "effect": 1.4539188470038575, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.03866795405263867, + "t_stat": 37.60009761635277 + }, + "1": { + "cband_hi": 1.9022693035728484, + "cband_lo": 1.6873946586343027, + "ci_hi": 1.8794723320847229, + "ci_lo": 1.709002763572227, + "effect": 1.7948319811035756, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.049436369906928145, + "t_stat": 36.30590159598355 + }, + "2": { + "cband_hi": 2.18865729103232, + "cband_lo": 2.0341055559354873, + "ci_hi": 2.184767486987823, + "ci_lo": 2.048554398875639, + "effect": 2.1113814234839037, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.03555783302487729, + "t_stat": 59.37879909629813 + }, + "3": { + "cband_hi": 2.5162189152480705, + "cband_lo": 2.2012758237006995, + "ci_hi": 2.4845168016464587, + "ci_lo": 2.2438787818826396, + "effect": 2.358747369474385, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.07245919209230238, + "t_stat": 32.55276937768899 + }, + "4": { + "cband_hi": 2.754900041074348, + "cband_lo": 2.582014030809726, + "ci_hi": 2.7418858589334234, + "ci_lo": 2.595383187627167, + "effect": 2.668457035942037, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.0397760133943176, + "t_stat": 67.08709114431394 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.004793456942018086, + 0.000683526581101642, + -0.00044390461494074764, + 0.0002914120288491085, + -0.0010722763760465984, + -0.00120052797668387, + 0.001345577150299979, + 9.063620376320222e-05, + 0.0006114258906619185 + ], + [ + 0.000683526581101642, + 0.001910358244341229, + -0.00015382509249728876, + -0.0007796170405323712, + 0.001193907161242858, + 0.0008317351031483285, + 0.00022694576205742388, + 0.0015478722532630876, + 0.00022780825387102343 + ], + [ + -0.00044390461494074764, + -0.00015382509249728876, + 0.0011901182509395593, + -0.0003417271075439688, + 0.00014784849399063048, + -0.0001379076013106159, + -0.0003474141068648317, + 0.0014460767050406297, + 0.0005457412417765935 + ], + [ + 0.0002914120288491085, + -0.0007796170405323712, + -0.0003417271075439688, + 0.0015182175072993313, + -0.00048728647974170347, + -0.000301484707588978, + 7.413997135648223e-05, + -0.0005260436744299985, + 0.00020186511559477864 + ], + [ + -0.0010722763760465984, + 0.001193907161242858, + 0.00014784849399063048, + -0.00048728647974170347, + 0.0014952106706169757, + 0.000802204903291636, + -0.0005855444791999376, + 0.001689066959047903, + -0.00031336691159789036 + ], + [ + -0.00120052797668387, + 0.0008317351031483285, + -0.0001379076013106159, + -0.000301484707588978, + 0.000802204903291636, + 0.002443954669574631, + 0.0007865546434961742, + 0.0015271581767485184, + 0.0005644782212722514 + ], + [ + 0.001345577150299979, + 0.00022694576205742388, + -0.0003474141068648317, + 7.413997135648223e-05, + -0.0005855444791999376, + 0.0007865546434961742, + 0.001264359489425054, + 4.1087071235788835e-05, + 0.0007490868256899574 + ], + [ + 9.063620376320222e-05, + 0.0015478722532630876, + 0.0014460767050406297, + -0.0005260436744299985, + 0.001689066959047903, + 0.0015271581767485184, + 4.1087071235788835e-05, + 0.005250334518669174, + 0.0007537524504042382 + ], + [ + 0.0006114258906619185, + 0.00022780825387102343, + 0.0005457412417765935, + 0.00020186511559477864, + -0.00031336691159789036, + 0.0005644782212722514, + 0.0007490868256899574, + 0.0007537524504042382, + 0.0015821312415449328 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.281728332848565, + "ci_lo": 1.990452897959769, + "df_used": null, + "effect": 2.11762432932182, + "p_value": 0.0196078431372549, + "se": 0.08096399175052113, + "t_stat": 26.15513740783649 + }, + "4": { + "ci_hi": 2.0660689853785597, + "ci_lo": 1.705836182804786, + "df_used": null, + "effect": 1.886783657115476, + "p_value": 0.0196078431372549, + "se": 0.09289407427562829, + "t_stat": 20.311130412012652 + }, + "5": { + "ci_hi": 2.0496023680956625, + "ci_lo": 1.8243748527668917, + "df_used": null, + "effect": 1.9068267927553257, + "p_value": 0.0196078431372549, + "se": 0.07235224620319872, + "t_stat": 26.354769793878553 + }, + "6": { + "ci_hi": 1.6058486209245848, + "ci_lo": 1.2501188418433753, + "df_used": null, + "effect": 1.4296813522176333, + "p_value": 0.0196078431372549, + "se": 0.10729732732104728, + "t_stat": 13.324482425735027 + } + }, + "overall": { + "att": 1.9082213826980177, + "ci_hi": 1.9692574622530463, + "ci_lo": 1.8573773397271987, + "p_value": 0.0196078431372549, + "se": 0.03243141563709646, + "t_stat": 58.838670628836546 + }, + "reference_event_times": null + }, + "stratified_survey_all": { + "cband_crit_value": 2.4385334214792573, + "es": { + "-1": { + "cband_hi": 0.20863066827218424, + "cband_lo": -0.06936405467022061, + "ci_hi": 0.18121772004419043, + "ci_lo": -0.04343260960903114, + "effect": 0.06963330680098181, + "n_groups": 4.0, + "p_value": 0.16, + "se": 0.05700039222217597, + "t_stat": 1.221628555283713 + }, + "-2": { + "cband_hi": 0.19850133416910964, + "cband_lo": -0.1946240760020796, + "ci_hi": 0.16118426417141604, + "ci_lo": -0.15522783972859977, + "effect": 0.001938629083515029, + "n_groups": 3.0, + "p_value": 0.88, + "se": 0.08060693503489332, + "t_stat": 0.02405040065939526 + }, + "-3": { + "cband_hi": 0.17689858770985728, + "cband_lo": -0.16901862231076187, + "ci_hi": 0.13440593977912665, + "ci_lo": -0.12929571759247466, + "effect": 0.0039399826995477095, + "n_groups": 2.0, + "p_value": 1.0, + "se": 0.07092730552177129, + "t_stat": 0.05554958940796536 + }, + "-4": { + "cband_hi": 0.2909088521650203, + "cband_lo": -0.26569578531593013, + "ci_hi": 0.19435867258874057, + "ci_lo": -0.212428598208563, + "effect": 0.012606533424545097, + "n_groups": 1.0, + "p_value": 0.92, + "se": 0.11412692411312211, + "t_stat": 0.11046064302976881 + }, + "0": { + "cband_hi": 1.533860107960097, + "cband_lo": 1.3696470244733665, + "ci_hi": 1.4976640759526723, + "ci_lo": 1.3908911347703121, + "effect": 1.4517535662167318, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.03367045988385837, + "t_stat": 43.11653512379565 + }, + "1": { + "cband_hi": 1.8861103310888025, + "cband_lo": 1.6657497352750505, + "ci_hi": 1.84894275994513, + "ci_lo": 1.6848815996561057, + "effect": 1.7759300331819265, + "n_groups": 4.0, + "p_value": 0.0196078431372549, + "se": 0.045183017356407104, + "t_stat": 39.305255316909324 + }, + "2": { + "cband_hi": 2.2830101412365114, + "cband_lo": 1.934336642405787, + "ci_hi": 2.2532482613613998, + "ci_lo": 1.9689431103907475, + "effect": 2.1086733918211493, + "n_groups": 3.0, + "p_value": 0.0196078431372549, + "se": 0.07149245849154959, + "t_stat": 29.495046558937325 + }, + "3": { + "cband_hi": 2.558832763930004, + "cband_lo": 2.0846790217486126, + "ci_hi": 2.494965560845798, + "ci_lo": 2.1720816183825393, + "effect": 2.3217558928393083, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09722108747924431, + "t_stat": 23.881196487695934 + }, + "4": { + "cband_hi": 2.902900940587747, + "cband_lo": 2.4325932522829046, + "ci_hi": 2.8763288469837063, + "ci_lo": 2.514209781713328, + "effect": 2.667747096435326, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.09643248769162777, + "t_stat": 27.664401907438698 + } + }, + "event_study_df": null, + "event_study_vcov": [ + [ + 0.013024954807522332, + 0.002369297928261079, + -0.0015222581491149403, + -0.00043509722725348026, + 0.000466631111583956, + -0.001965308128820638, + 0.0017065348615906647, + 6.931227285932411e-05, + -0.0038179835016279337 + ], + [ + 0.002369297928261079, + 0.005030682668578689, + -0.0019876890512482022, + -0.0016408360456225315, + 0.0011277152001795507, + 0.0004735862835932727, + 0.00030447170826796485, + 0.0013180341684036964, + -0.0017646704877972482 + ], + [ + -0.0015222581491149403, + -0.0019876890512482022, + 0.006497477975719511, + -0.0007376533689779569, + -0.0009826008711669055, + -0.0005728700411489965, + -0.001910074212906339, + -0.0021802547089884677, + 0.0033412775067301254 + ], + [ + -0.00043509722725348026, + -0.0016408360456225315, + -0.0007376533689779569, + 0.003249044713481899, + -0.00042347399388194685, + 4.085591130641185e-05, + 0.0021645588948692733, + 0.0020216649628543946, + -0.0005527979436737165 + ], + [ + 0.000466631111583956, + 0.0011277152001795507, + -0.0009826008711669055, + -0.00042347399388194685, + 0.0011336998687905158, + 0.00040647671199136047, + 0.0003272692170020557, + 0.0013488591934123879, + 0.00023155567709411704 + ], + [ + -0.001965308128820638, + 0.0004735862835932727, + -0.0005728700411489965, + 4.085591130641185e-05, + 0.00040647671199136047, + 0.0020415050574293853, + 0.0010414737398037912, + 0.0025351769689276097, + 0.0015249602164618796 + ], + [ + 0.0017065348615906647, + 0.00030447170826796485, + -0.001910074212906339, + 0.0021645588948692733, + 0.0003272692170020557, + 0.0010414737398037912, + 0.005111171621165941, + 0.0042776781765350436, + 0.0005787036378496957 + ], + [ + 6.931227285932411e-05, + 0.0013180341684036964, + -0.0021802547089884677, + 0.0020216649628543946, + 0.0013488591934123879, + 0.0025351769689276097, + 0.0042776781765350436, + 0.009451939850646874, + 0.0028236024819365277 + ], + [ + -0.0038179835016279337, + -0.0017646704877972482, + 0.0033412775067301254, + -0.0005527979436737165, + 0.00023155567709411704, + 0.0015249602164618796, + 0.0005787036378496957, + 0.0028236024819365277, + 0.009299224682395938 + ] + ], + "event_study_vcov_index": [ + -4.0, + -3.0, + -2.0, + -1.0, + 0.0, + 1.0, + 2.0, + 3.0, + 4.0 + ], + "group": { + "3": { + "ci_hi": 2.30062747087495, + "ci_lo": 1.9457647932554265, + "df_used": null, + "effect": 2.1242700768040943, + "p_value": 0.0196078431372549, + "se": 0.09624435241511488, + "t_stat": 22.071633540031844 + }, + "4": { + "ci_hi": 2.013229590875692, + "ci_lo": 1.5954562636154388, + "df_used": null, + "effect": 1.8300828461356922, + "p_value": 0.0196078431372549, + "se": 0.10911131754759941, + "t_stat": 16.772621642455423 + }, + "5": { + "ci_hi": 2.0973034921814397, + "ci_lo": 1.6390768741483428, + "df_used": null, + "effect": 1.9033747125067557, + "p_value": 0.0196078431372549, + "se": 0.11729583583619467, + "t_stat": 16.22712945380982 + }, + "6": { + "ci_hi": 1.5956134547819238, + "ci_lo": 1.1898156451856987, + "df_used": null, + "effect": 1.4106784908973298, + "p_value": 0.0196078431372549, + "se": 0.12670562914080272, + "t_stat": 11.13351080345216 + } + }, + "overall": { + "att": 1.899842807641477, + "ci_hi": 1.9667359583706414, + "ci_lo": 1.8187567819914907, + "p_value": 0.0196078431372549, + "se": 0.041664313181091844, + "t_stat": 45.59880297039593 + }, + "reference_event_times": null + } + }, + "ContinuousDiD": { + "default_eventstudy": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.24868843189268658, + "ci_lo": -0.5776295436330691, + "effect": -0.23880903293700426, + "p_value": 0.4666666666666667, + "se": 0.2417569258465497, + "t_stat": -0.9878063765940813 + }, + "-2": { + "ci_hi": 0.020162307092176122, + "ci_lo": -0.915836085693537, + "effect": -0.4167762389538105, + "p_value": 0.13333333333333333, + "se": 0.254468135843636, + "t_stat": -1.637832719495806 + }, + "0": { + "ci_hi": 4.981169730623575, + "ci_lo": 4.1360750485967746, + "effect": 4.537644146658957, + "p_value": 0.03225806451612903, + "se": 0.24097042409197178, + "t_stat": 18.830709883828163 + }, + "1": { + "ci_hi": 4.907211255187454, + "ci_lo": 4.046594982396809, + "effect": 4.466341388503657, + "p_value": 0.03225806451612903, + "se": 0.26531715243641507, + "t_stat": 16.833971522342654 + }, + "2": { + "ci_hi": 5.380108465443674, + "ci_lo": 4.45128715797091, + "effect": 4.879913354176228, + "p_value": 0.03225806451612903, + "se": 0.2606991744649117, + "t_stat": 18.71856082472187 + }, + "3": { + "ci_hi": 4.850856481392669, + "ci_lo": 3.3032538263056765, + "effect": 4.010249427241352, + "p_value": 0.03225806451612903, + "se": 0.4513864331348498, + "t_stat": 8.884293219427148 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "overall": { + "att": 4.641957051061234, + "ci_hi": 5.023688056681055, + "ci_lo": 4.283074795160602, + "p_value": 0.03225806451612903, + "se": 0.22673492373259108, + "t_stat": 20.473057148160873 + }, + "overall_acrt": 1.5251919678360484, + "overall_acrt_se": 0.16600191982346912, + "reference_event_times": null + }, + "default_plain": { + "cband_crit_value": null, + "es": null, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "overall": { + "att": 4.641957051061234, + "ci_hi": 5.023688056681055, + "ci_lo": 4.283074795160602, + "p_value": 0.03225806451612903, + "se": 0.22673492373259108, + "t_stat": 20.473057148160873 + }, + "overall_acrt": 1.5251919678360484, + "overall_acrt_se": 0.16600191982346912, + "reference_event_times": null + } + }, + "EfficientDiD": { + "clustered_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0769575250137137, + "ci_lo": -0.29293613062148505, + "effect": -0.134313461987274, + "n_groups": 2.0, + "p_value": 0.16, + "se": 0.1043324660537642, + "t_stat": -1.2873601772056276 + }, + "-2": { + "ci_hi": 0.2646284088636564, + "ci_lo": 0.015814777229796153, + "effect": 0.11002324972857475, + "n_groups": 2.0, + "p_value": 0.04, + "se": 0.07018613850266579, + "t_stat": 1.5675922920933436 + }, + "-3": { + "ci_hi": 0.2673279156349315, + "ci_lo": 0.02795626875005316, + "effect": 0.1436002497880893, + "n_groups": 2.0, + "p_value": 0.04, + "se": 0.06047988466803218, + "t_stat": 2.374347282179789 + }, + "-4": { + "ci_hi": 0.0451249932540678, + "ci_lo": -0.2840699228037753, + "effect": -0.11660705660378556, + "n_groups": 1.0, + "p_value": 0.24, + "se": 0.09875410149944121, + "t_stat": -1.1807819101513002 + }, + "-5": { + "ci_hi": -0.041127672281621945, + "ci_lo": -0.39171971574337977, + "effect": -0.2390395706166469, + "n_groups": 1.0, + "p_value": 0.04, + "se": 0.09287400502700738, + "t_stat": -2.573804915025848 + }, + "0": { + "ci_hi": 2.181469232542371, + "ci_lo": 1.9046962579883426, + "effect": 2.0272407859305153, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.078864917490311, + "t_stat": 25.70522927611727 + }, + "1": { + "ci_hi": 2.356905496747323, + "ci_lo": 2.123916236454315, + "effect": 2.246187711623196, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.061886193380769006, + "t_stat": 36.29545766053198 + }, + "2": { + "ci_hi": 2.3895220659536145, + "ci_lo": 1.9249456780507133, + "effect": 2.15004585834593, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.14606900131940634, + "t_stat": 14.719384940850421 + }, + "3": { + "ci_hi": 2.8078556385784514, + "ci_lo": 2.3843688726893153, + "effect": 2.621212494601111, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10861079734652748, + "t_stat": 24.13399550173652 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.29691690476249, + "ci_lo": 2.1247494088206818, + "df_used": null, + "effect": 2.217677302443109, + "p_value": 0.0196078431372549, + "se": 0.05473518261226614, + "t_stat": 40.51648677510995 + }, + "6": { + "ci_hi": 2.337424933705111, + "ci_lo": 2.06444227383698, + "df_used": null, + "effect": 2.2085745786428994, + "p_value": 0.0196078431372549, + "se": 0.07523875477035036, + "t_stat": 29.354214930644247 + } + }, + "overall": { + "att": 2.214245127895489, + "ci_hi": 2.298379525627497, + "ci_lo": 2.117371772658049, + "p_value": 0.0196078431372549, + "se": 0.052215366360726184, + "t_stat": 42.40600578378656 + }, + "reference_event_times": null + }, + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2950846771617092, + "ci_lo": -0.03273380191629378, + "effect": 0.10640994510667033, + "n_groups": 2.0, + "p_value": 0.28, + "se": 0.08821742950973835, + "t_stat": 1.2062235966071049 + }, + "-2": { + "ci_hi": 0.1189650046330156, + "ci_lo": -0.2144256270211968, + "effect": -0.05979558595018031, + "n_groups": 2.0, + "p_value": 0.48, + "se": 0.08905494890157543, + "t_stat": -0.6714459632812443 + }, + "-3": { + "ci_hi": 0.22427582199968346, + "ci_lo": -0.06368449705087242, + "effect": 0.08653759783531222, + "n_groups": 2.0, + "p_value": 0.28, + "se": 0.07610892325690728, + "t_stat": 1.137023020851349 + }, + "-4": { + "ci_hi": 0.09005630431861018, + "ci_lo": -0.300037559708117, + "effect": -0.07878370911167488, + "n_groups": 1.0, + "p_value": 0.32, + "se": 0.10955965253134237, + "t_stat": -0.7190941856002763 + }, + "-5": { + "ci_hi": 0.16955739764998407, + "ci_lo": -0.23109205841272243, + "effect": -0.028022683129665008, + "n_groups": 1.0, + "p_value": 0.88, + "se": 0.10932803058821132, + "t_stat": -0.2563174602057329 + }, + "0": { + "ci_hi": 2.2397550957454255, + "ci_lo": 2.021497183359017, + "effect": 2.139053682427927, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.06192403077775023, + "t_stat": 34.543191965412326 + }, + "1": { + "ci_hi": 2.388555283000411, + "ci_lo": 2.0611375558370506, + "effect": 2.209130774601568, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09429774255024284, + "t_stat": 23.427186217363786 + }, + "2": { + "ci_hi": 2.756755023497587, + "ci_lo": 2.3504191325757886, + "effect": 2.5747639239316404, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11182754285798713, + "t_stat": 23.024416508922172 + }, + "3": { + "ci_hi": 2.9334178487858735, + "ci_lo": 2.5825189620206497, + "effect": 2.751359710941057, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.11112905360609585, + "t_stat": 24.75823937719681 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.5888250563955078, + "ci_lo": 2.323746289846125, + "df_used": null, + "effect": 2.4517840485645532, + "p_value": 0.0196078431372549, + "se": 0.07087923642548828, + "t_stat": 34.591005380567104 + }, + "6": { + "ci_hi": 2.280987570553098, + "ci_lo": 1.9645913278054095, + "df_used": null, + "effect": 2.110767202972924, + "p_value": 0.0196078431372549, + "se": 0.09779588543005005, + "t_stat": 21.5833947787372 + } + }, + "overall": { + "att": 2.3344742536810323, + "ci_hi": 2.455323387619704, + "ci_lo": 2.229831791181562, + "p_value": 0.0196078431372549, + "se": 0.06149222602398572, + "t_stat": 37.96372980172234 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3344742536810323, + "ci_hi": 2.455323387619704, + "ci_lo": 2.229831791181562, + "p_value": 0.0196078431372549, + "se": 0.06149222602398572, + "t_stat": 37.96372980172234 + }, + "fpc_survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2080668951510884, + "ci_lo": -0.013233877441019162, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.06049144734858437, + "t_stat": 1.8672174309903553 + }, + "-2": { + "ci_hi": 0.2213478334561589, + "ci_lo": -0.02478114852587132, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.24, + "se": 0.07549122642190782, + "t_stat": 0.9879791779750724 + }, + "-3": { + "ci_hi": 0.12463313496574259, + "ci_lo": -0.14211839192105763, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.92, + "se": 0.07182468884336507, + "t_stat": -0.15875582269100671 + }, + "-4": { + "ci_hi": 0.14522912159412443, + "ci_lo": -0.1796174496115894, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 1.0, + "se": 0.09490407338283532, + "t_stat": -0.2009042432886477 + }, + "-5": { + "ci_hi": -0.027699444417426694, + "ci_lo": -0.401554211356682, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.04, + "se": 0.1114273305151771, + "t_stat": -1.7472542035462537 + }, + "0": { + "ci_hi": 2.1299191962105595, + "ci_lo": 1.9526175242434007, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.046257362293041754, + "t_stat": 44.071287240755865 + }, + "1": { + "ci_hi": 2.2438057468317743, + "ci_lo": 1.9419279285817606, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.09038666642982485, + "t_stat": 23.27647885776168 + }, + "2": { + "ci_hi": 2.709613408523424, + "ci_lo": 2.3611436845330642, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.101589730491553, + "t_stat": 24.633304984227838 + }, + "3": { + "ci_hi": 2.642733028186197, + "ci_lo": 2.149379949465344, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.13837582575695007, + "t_stat": 17.40623033510585 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.367998933184762, + "ci_lo": 2.1755528896298806, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.05374577284541157, + "t_stat": 42.19589858400592 + }, + "6": { + "ci_hi": 2.1719747586846045, + "ci_lo": 1.955613462138116, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.054029961584698215, + "t_stat": 38.17164813327243 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.2855534394791994, + "ci_lo": 2.1042555276218216, + "p_value": 0.0196078431372549, + "se": 0.050056493921646114, + "t_stat": 43.93119618777691 + }, + "reference_event_times": null + }, + "fractional_balance_e1_es": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2777064924348559, + "ci_lo": -0.3165727867106564, + "effect": 0.026184943804410075, + "n_groups": 1.0, + "p_value": 0.72, + "se": 0.14117302462526216, + "t_stat": 0.1854812126744249 + }, + "0": { + "ci_hi": 0.5053580355957054, + "ci_lo": 0.04843947527034796, + "effect": 0.24480370561350984, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.11921519605698168, + "t_stat": 2.0534605797779353 + }, + "1": { + "ci_hi": 1.0047566550455853, + "ci_lo": 0.34004991430453557, + "effect": 0.6358462518585993, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.20053945804803613, + "t_stat": 3.170679017723745 + }, + "2": { + "ci_hi": 1.4023682848122243, + "ci_lo": 0.25916510539259974, + "effect": 0.8040757046489879, + "n_groups": 1.0, + "p_value": 0.04, + "se": 0.3079850596961206, + "t_stat": 2.6107620461925807 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "reference_event_times": null + }, + "stratified_psu_survey_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.2341681818279641, + "ci_lo": -0.012605995579570668, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.12, + "se": 0.07382329655029328, + "t_stat": 1.530014103856262 + }, + "-2": { + "ci_hi": 0.245876271222739, + "ci_lo": -0.0681235255341213, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.32, + "se": 0.08444394222328314, + "t_stat": 0.8832339876723816 + }, + "-3": { + "ci_hi": 0.1285795168194135, + "ci_lo": -0.13635567927805417, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.8, + "se": 0.06358199239982865, + "t_stat": -0.17933674514554412 + }, + "-4": { + "ci_hi": 0.14356637440823122, + "ci_lo": -0.15041804226742786, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.76, + "se": 0.08503290448100671, + "t_stat": -0.224226505778685 + }, + "-5": { + "ci_hi": 0.044372534115678805, + "ci_lo": -0.3867279606592377, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.12, + "se": 0.12606335987951608, + "t_stat": -1.5443969748121575 + }, + "0": { + "ci_hi": 2.1537261129444243, + "ci_lo": 1.9388589224111425, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.059276136953055, + "t_stat": 34.39194261648464 + }, + "1": { + "ci_hi": 2.2583816766098894, + "ci_lo": 1.9380914210633695, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.08633551187403303, + "t_stat": 24.36869006170978 + }, + "2": { + "ci_hi": 2.705681821089329, + "ci_lo": 2.3565597898157056, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10069712926475424, + "t_stat": 24.851659950348264 + }, + "3": { + "ci_hi": 2.609072967054054, + "ci_lo": 2.2184296329305195, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.10884064212889258, + "t_stat": 22.1296149014226 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3752330135384976, + "ci_lo": 2.158980257271318, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.056314729245228005, + "t_stat": 40.27101276521149 + }, + "6": { + "ci_hi": 2.1375228715477235, + "ci_lo": 1.9471671672818898, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.05147453168670459, + "t_stat": 40.066662380107275 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.2961195691978564, + "ci_lo": 2.1074255353198432, + "p_value": 0.0196078431372549, + "se": 0.04788565840007229, + "t_stat": 45.92276118606689 + }, + "reference_event_times": null + }, + "survey_weights_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.24981359224310373, + "ci_lo": -0.030781329527225308, + "effect": 0.11295068491511205, + "n_groups": 2.0, + "p_value": 0.08, + "se": 0.07832729282730308, + "t_stat": 1.442034836620066 + }, + "-2": { + "ci_hi": 0.20845260426605097, + "ci_lo": -0.07588254636234743, + "effect": 0.07458375982464656, + "n_groups": 2.0, + "p_value": 0.4, + "se": 0.07701222714392936, + "t_stat": 0.9684664707236138 + }, + "-3": { + "ci_hi": 0.12831232059427325, + "ci_lo": -0.1508515979629058, + "effect": -0.011402587566853993, + "n_groups": 2.0, + "p_value": 0.72, + "se": 0.06781076188021756, + "t_stat": -0.16815306672111688 + }, + "-4": { + "ci_hi": 0.14163107640343256, + "ci_lo": -0.22976314548174362, + "effect": -0.019066631047988822, + "n_groups": 1.0, + "p_value": 0.92, + "se": 0.10694078013209038, + "t_stat": -0.17829149015406687 + }, + "-5": { + "ci_hi": 0.0383954617068058, + "ci_lo": -0.4107733308581979, + "effect": -0.19469187163258095, + "n_groups": 1.0, + "p_value": 0.08, + "se": 0.12065722044259501, + "t_stat": -1.6135948674966314 + }, + "0": { + "ci_hi": 2.194297955513491, + "ci_lo": 1.9085141138216501, + "effect": 2.0386215006163524, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.0720997774366947, + "t_stat": 28.275004072048766 + }, + "1": { + "ci_hi": 2.2336126970295407, + "ci_lo": 1.9587053965594128, + "effect": 2.1038833301773754, + "n_groups": 2.0, + "p_value": 0.0196078431372549, + "se": 0.07867884018729762, + "t_stat": 26.740141633621064 + }, + "2": { + "ci_hi": 2.739783179581819, + "ci_lo": 2.300041840945201, + "effect": 2.5024908144639353, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.106637540713229, + "t_stat": 23.46725925716596 + }, + "3": { + "ci_hi": 2.641148083707181, + "ci_lo": 2.1651279087896564, + "effect": 2.4086014959359456, + "n_groups": 1.0, + "p_value": 0.0196078431372549, + "se": 0.13476397816153352, + "t_stat": 17.87273965041978 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3913814803057063, + "ci_lo": 2.1511502302633945, + "df_used": null, + "effect": 2.267851180304006, + "p_value": 0.0196078431372549, + "se": 0.06878951579779898, + "t_stat": 32.967977082004246 + }, + "6": { + "ci_hi": 2.2246075931989067, + "ci_lo": 1.925620434041747, + "df_used": null, + "effect": 2.0624126822653266, + "p_value": 0.0196078431372549, + "se": 0.08507845584431388, + "t_stat": 24.24130365082508 + } + }, + "overall": { + "att": 2.199041654944098, + "ci_hi": 2.307007867079546, + "ci_lo": 2.1001498623329025, + "p_value": 0.0196078431372549, + "se": 0.06172586389268372, + "t_stat": 35.625935649395544 + }, + "reference_event_times": null + } + }, + "ImputationDiD": { + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.244194579906093, + "ci_lo": 2.0759929420176784, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.05530889329763085, + "t_stat": 38.79919187751398 + }, + "1": { + "ci_hi": 2.3576847763043283, + "ci_lo": 2.1606791601654245, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.060823150057492385, + "t_stat": 36.86822619713873 + }, + "2": { + "ci_hi": 2.7879575301476263, + "ci_lo": 2.474642715050153, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09662278671372519, + "t_stat": 26.89084989270778 + }, + "3": { + "ci_hi": 2.8626691734943193, + "ci_lo": 2.598801947853138, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09451615379064816, + "t_stat": 29.269392221046104 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.5409735465284364, + "ci_lo": 2.378212505064488, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.05575674613997778, + "t_stat": 44.13550260439243 + }, + "6": { + "ci_hi": 2.3603208389178514, + "ci_lo": 2.027066634034936, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.09332024816422878, + "t_stat": 23.05103415292523 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4498889778888646, + "ci_lo": 2.291396781980644, + "p_value": 0.05, + "se": 0.04875538870512177, + "t_stat": 48.288139922865156 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4498889778888646, + "ci_lo": 2.291396781980644, + "p_value": 0.05, + "se": 0.04875538870512177, + "t_stat": 48.288139922865156 + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.05981516395757938, + "ci_lo": -0.3559461519303688, + "effect": -0.1480654939863947, + "n_obs": 84.0, + "p_value": 0.16236817593507333, + "se": 0.10584141425966366, + "t_stat": -1.3989372215222062 + }, + "-3": { + "ci_hi": 0.1584251692403736, + "ci_lo": -0.20715293281084962, + "effect": -0.024363881785238024, + "n_obs": 84.0, + "p_value": 0.79357582275096, + "se": 0.09306614604301798, + "t_stat": -0.26179102521314573 + }, + "-4": { + "ci_hi": 0.06957272135423231, + "ci_lo": -0.34423198704465807, + "effect": -0.13732963284521288, + "n_obs": 84.0, + "p_value": 0.1928757534767246, + "se": 0.10534331572120142, + "t_stat": -1.3036387919349863 + }, + "-5": { + "ci_hi": 0.15677504775855503, + "ci_lo": -0.4306558981578147, + "effect": -0.13694042519962982, + "n_obs": 43.0, + "p_value": 0.36019499313404013, + "se": 0.14954378803351012, + "t_stat": -0.9157212546263966 + }, + "-6": { + "ci_hi": 0.07789021576946761, + "ci_lo": -0.47058081586467804, + "effect": -0.19634530004760523, + "n_obs": 43.0, + "p_value": 0.16019426463331468, + "se": 0.1396256636927232, + "t_stat": -1.406226440432226 + }, + "0": { + "ci_hi": 2.244194579906093, + "ci_lo": 2.0759929420176784, + "effect": 2.145940363587726, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.05530889329763085, + "t_stat": 38.79919187751398 + }, + "1": { + "ci_hi": 2.3576847763043283, + "ci_lo": 2.1606791601654245, + "effect": 2.242441654342141, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.060823150057492385, + "t_stat": 36.86822619713873 + }, + "2": { + "ci_hi": 2.7879575301476263, + "ci_lo": 2.474642715050153, + "effect": 2.598268853733904, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09662278671372519, + "t_stat": 26.89084989270778 + }, + "3": { + "ci_hi": 2.8626691734943193, + "ci_lo": 2.598801947853138, + "effect": 2.7664303765231946, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09451615379064816, + "t_stat": 29.269392221046104 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.5409735465284364, + "ci_lo": 2.378212505064488, + "df_used": null, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.05575674613997778, + "t_stat": 44.13550260439243 + }, + "6": { + "ci_hi": 2.3603208389178514, + "ci_lo": 2.027066634034936, + "df_used": null, + "effect": 2.1511282275930954, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.09332024816422878, + "t_stat": 23.05103415292523 + } + }, + "overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4498889778888646, + "ci_lo": 2.291396781980644, + "p_value": 0.05, + "se": 0.04875538870512177, + "t_stat": 48.288139922865156 + }, + "reference_event_times": null + }, + "pretrends_plain_overall": { + "att": 2.3543070317865995, + "ci_hi": 2.4498889778888646, + "ci_lo": 2.291396781980644, + "p_value": 0.05, + "se": 0.04875538870512177, + "t_stat": 48.288139922865156 + }, + "survey_tsl_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.220784930763723, + "ci_lo": 1.9227633105976678, + "effect": 2.0361973460553426, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.08990414546895434, + "t_stat": 22.64853678808928 + }, + "1": { + "ci_hi": 2.232753678345635, + "ci_lo": 2.005171985889536, + "effect": 2.1281777081775473, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06525565374864616, + "t_stat": 32.61292448888691 + }, + "2": { + "ci_hi": 2.7159073842435433, + "ci_lo": 2.2524410941180917, + "effect": 2.4900024320750482, + "n_obs": 46.0, + "p_value": 0.05, + "se": 0.13297834548487006, + "t_stat": 18.72487150442366 + }, + "3": { + "ci_hi": 2.7088409830699383, + "ci_lo": 2.151233032203804, + "effect": 2.4352726594598915, + "n_obs": 46.0, + "p_value": 0.05, + "se": 0.16212690788794695, + "t_stat": 15.02078027136011 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.3334490124802647, + "ci_lo": 2.1252162505371825, + "df_used": null, + "effect": 2.2443534854455107, + "n_obs": 184.0, + "p_value": 0.05, + "se": 0.06379681143793288, + "t_stat": 35.17971251006822 + }, + "6": { + "ci_hi": 2.3556685282151895, + "ci_lo": 1.987448116972624, + "df_used": null, + "effect": 2.1379018964851286, + "n_obs": 76.0, + "p_value": 0.05, + "se": 0.10055826903197541, + "t_stat": 21.260329131215663 + } + }, + "overall": { + "att": 2.2086986136463955, + "ci_hi": 2.323535613491389, + "ci_lo": 2.120735084887383, + "p_value": 0.05, + "se": 0.05917362183752092, + "t_stat": 37.32572969271757 + }, + "reference_event_times": null + }, + "survey_tsl_plain_overall": { + "att": 2.2086986136463955, + "ci_hi": 2.323535613491389, + "ci_lo": 2.120735084887383, + "p_value": 0.05, + "se": 0.05917362183752092, + "t_stat": 37.32572969271757 + } + }, + "TwoStageDiD": { + "default_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.215861652531097, + "ci_lo": 2.047714596195685, + "effect": 2.1459403635877266, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.055304727578956686, + "t_stat": 38.80211434951994 + }, + "1": { + "ci_hi": 2.321940670314704, + "ci_lo": 2.1294141287480315, + "effect": 2.2424416543421417, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.061290033810934993, + "t_stat": 36.5873783209116 + }, + "2": { + "ci_hi": 2.7218949924181612, + "ci_lo": 2.408580177320143, + "effect": 2.5982688537339036, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.0966227867138525, + "t_stat": 26.89084989267235 + }, + "3": { + "ci_hi": 2.9340588051934278, + "ci_lo": 2.6701915795511035, + "effect": 2.7664303765231955, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09451615379091943, + "t_stat": 29.269392220962107 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.543491523882159, + "ci_lo": 2.3807304824183935, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.05575674613993602, + "t_stat": 44.13550260442548 + }, + "6": { + "ci_hi": 2.2751898211508865, + "ci_lo": 1.9419356162672947, + "effect": 2.151128227593096, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.09332024816436411, + "t_stat": 23.051034152891805 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4137285528561563, + "ci_lo": 2.2737514743334115, + "p_value": 0.05, + "se": 0.04883129244003266, + "t_stat": 48.21308046838631 + }, + "reference_event_times": null + }, + "default_plain_overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4137285528561563, + "ci_lo": 2.2737514743334115, + "p_value": 0.05, + "se": 0.04883129244003266, + "t_stat": 48.21308046838631 + }, + "pretrends_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "-2": { + "ci_hi": 0.04085242646287475, + "ci_lo": -0.09948469861005711, + "effect": -0.029856983716645812, + "n_obs": 84.0, + "p_value": 0.42105263157894735, + "se": 0.04090615773293422, + "t_stat": -0.729889712731623 + }, + "-3": { + "ci_hi": 0.07969999083423256, + "ci_lo": -0.03999085796309322, + "effect": 0.034596864567728876, + "n_obs": 84.0, + "p_value": 0.631578947368421, + "se": 0.036877661987001153, + "t_stat": 0.9381523313469214 + }, + "-4": { + "ci_hi": 0.063284196345179, + "ci_lo": -0.09643479922002418, + "effect": -0.002521727613600901, + "n_obs": 84.0, + "p_value": 0.8421052631578947, + "se": 0.04654795670678167, + "t_stat": -0.054174829401985446 + }, + "-5": { + "ci_hi": 0.07196494227817381, + "ci_lo": -0.11758544269811656, + "effect": -0.034152641587387955, + "n_obs": 43.0, + "p_value": 0.8421052631578947, + "se": 0.05419133038898293, + "t_stat": -0.6302233464696627 + }, + "-6": { + "ci_hi": 0.07262749167373701, + "ci_lo": -0.10048039306864671, + "effect": -0.03367413800267828, + "n_obs": 43.0, + "p_value": 0.631578947368421, + "se": 0.05355222200691118, + "t_stat": -0.6288093517078052 + }, + "0": { + "ci_hi": 2.215861652531097, + "ci_lo": 2.047714596195685, + "effect": 2.1459403635877266, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.055304727578956686, + "t_stat": 38.80211434951994 + }, + "1": { + "ci_hi": 2.321940670314704, + "ci_lo": 2.1294141287480315, + "effect": 2.2424416543421417, + "n_obs": 84.0, + "p_value": 0.05, + "se": 0.06129003381093498, + "t_stat": 36.587378320911604 + }, + "2": { + "ci_hi": 2.7218949924181612, + "ci_lo": 2.408580177320143, + "effect": 2.5982688537339036, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.0966227867138525, + "t_stat": 26.89084989267235 + }, + "3": { + "ci_hi": 2.9340588051934278, + "ci_lo": 2.6701915795511035, + "effect": 2.7664303765231955, + "n_obs": 41.0, + "p_value": 0.05, + "se": 0.09451615379091943, + "t_stat": 29.269392220962107 + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.543491523882159, + "ci_lo": 2.3807304824183935, + "effect": 2.4608520144734367, + "n_obs": 164.0, + "p_value": 0.05, + "se": 0.05575674613993602, + "t_stat": 44.13550260442548 + }, + "6": { + "ci_hi": 2.2751898211508865, + "ci_lo": 1.9419356162672947, + "effect": 2.151128227593096, + "n_obs": 86.0, + "p_value": 0.05, + "se": 0.09332024816436411, + "t_stat": 23.051034152891805 + } + }, + "overall": { + "att": 2.3543070317865986, + "ci_hi": 2.4137285528561563, + "ci_lo": 2.2737514743334115, + "p_value": 0.05, + "se": 0.04883129244003266, + "t_stat": 48.21308046838631 + }, + "reference_event_times": null + }, + "prop5_all": { + "cband_crit_value": null, + "es": { + "-1": { + "ci_hi": 0.0, + "ci_lo": 0.0, + "effect": 0.0, + "n_obs": 0.0, + "p_value": null, + "se": 0.0, + "t_stat": null + }, + "0": { + "ci_hi": 2.071166212018816, + "ci_lo": 1.7061026470953098, + "effect": 1.9034726388790313, + "n_obs": 36.0, + "p_value": 0.05, + "se": 0.10828021901063675, + "t_stat": 17.579135471567955 + }, + "1": { + "ci_hi": 2.4653739293382753, + "ci_lo": 2.162460266870133, + "effect": 2.3023388848323068, + "n_obs": 36.0, + "p_value": 0.05, + "se": 0.0937291628451595, + "t_stat": 24.563741048618652 + }, + "2": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + }, + "3": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 36.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "event_study_df": null, + "event_study_vcov": null, + "event_study_vcov_index": null, + "group": { + "4": { + "ci_hi": 2.2241133871066556, + "ci_lo": 1.965041675328376, + "effect": 2.1029057618556686, + "n_obs": 72.0, + "p_value": 0.05, + "se": 0.07944228111318641, + "t_stat": 26.47086327820228 + }, + "6": { + "ci_hi": null, + "ci_lo": null, + "effect": null, + "n_obs": 0.0, + "p_value": null, + "se": null, + "t_stat": null + } + }, + "overall": { + "att": 2.1029057618556677, + "ci_hi": 2.2241133871066547, + "ci_lo": 1.9650416753283753, + "p_value": 0.05, + "se": 0.07944228111318632, + "t_stat": 26.470863278202295 + }, + "reference_event_times": null + }, + "prop5_plain_overall": { + "att": 2.1029057618556677, + "ci_hi": 2.2241133871066547, + "ci_lo": 1.9650416753283753, + "p_value": 0.05, + "se": 0.07944228111318632, + "t_stat": 26.470863278202295 + } + } + } + }, + "provenance": { + "backends": [ + "numpy", + "rust" + ], + "captured_at_head": "8724fecd98eca407e862c0c0adacb212bc964f16", + "captured_with": "tests/_capture_v4_aggregate_oracles.py on the UNMODIFIED tree, once per DIFF_DIFF_BACKEND (python -> 'numpy', rust -> 'rust'); the analytic sections of the two runs agreed to rtol 1e-12 / atol 1e-13 and are stored once; the analytic arms ContinuousDiD/covariate_eventstudy (corrected: covariates=['x1']), ContinuousDiD/anticipation1_eventstudy, TwoStageDiD/always_treated_survey_psu_all, TwoStageDiD/covariates_all and ImputationDiD/covariates_all were captured from a detached checkout of the same commit with PYTHONPATH pointing at it (python backend; analytic arms are backend-independent)", + "diff_diff_version": "3.13.0", + "numpy": "2.5.4" + } +} diff --git a/tests/helpers/legacy_surface.py b/tests/helpers/legacy_surface.py new file mode 100644 index 000000000..9926c3966 --- /dev/null +++ b/tests/helpers/legacy_surface.py @@ -0,0 +1,179 @@ +"""Test helpers bridging the post-fit aggregation containers and the 3.x dict shapes. + +Since the 4.0 cut (PR 3) the fit-time ``fit(aggregate=...)`` route is gone, so a +test that used to read ``results.event_study_effects[h]["effect"]`` after a +fit-time aggregation reads the post-fit container instead:: + + es = results.aggregate("event_study") # EventStudyResults + grp = results.aggregate("group") # AggregationResult + +Three helpers keep dict-iteration tests short and let the few tests whose +SUBJECT is the native (legacy-pickle) route build a faithful stand-in: + +``es_dict(results, balance_e=None)`` + ``aggregate("event_study")`` as the legacy ``event_study_effects`` mapping + ``{event_time: {"effect", "se", "t_stat", "p_value", "conf_int", }}``. + The count key follows the container's ``n_kind`` (``"groups"`` -> ``n_groups``, + ``"obs"`` -> ``n_obs``, ...); a NaN count becomes 0 (the legacy zero-count + reference sentinel); when the container has NO ``n_kind`` (ContinuousDiD) no + count key is emitted - ``_from_relative_dict`` rejects a zero-count row with a + finite nonzero effect. ``cband_conf_int`` is added where the band is finite. +``group_dict(results)`` + ``aggregate("group")`` as the legacy ``group_effects`` mapping (label keys; + ``df_used`` from the ``df`` column). +``legacy_standin(results, balance_e=None)`` + A COPY of ``results`` whose legacy fields hold the container's surface + (``event_study_effects`` + ``event_study_vcov``/``_index`` + the surface's + ``reference_event_times`` + ``cband_crit_value`` + ``event_study_df``), i.e. + what a 3.x ``fit(aggregate="event_study", balance_e=...)`` stored. Use it ONLY + where a test exercises the native route that live 4.0 fits no longer + populate (plotter / HonestDiD / PreTrendsPower raw-field branches, + ``build_event_study_surface(native)`` fixtures). + +``load_oracles()`` / ``boot_oracles()`` load ``tests/fixtures/v4_aggregate_oracles.json`` +(see ``tests/fixtures/README.md``); ``boot_oracles`` returns the active weight +backend's section or skips the test when that backend was never captured. +""" + +from __future__ import annotations + +import dataclasses +import json +import math +import os +from functools import lru_cache +from typing import Any, Dict, Optional + +import numpy as np +import pytest + +_COUNT_KEY_BY_N_KIND = { + "groups": "n_groups", + "obs": "n_obs", + "units": "n_units", + "cells": "n_cells", + "switcher_cells": "n_switcher_cells", + "clusters": "n_clusters", +} + +_FIXTURE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fixtures") + + +def _label(x: Any) -> Any: + """Container labels back to the legacy dict-key type (int where integral).""" + if isinstance(x, (np.integer, int)): + return int(x) + if isinstance(x, (np.floating, float)): + f = float(x) + return int(f) if f.is_integer() else f + return x + + +def _count_key(n_kind: Optional[str]) -> Optional[str]: + if n_kind is None: + return None + return _COUNT_KEY_BY_N_KIND.get(n_kind, f"n_{n_kind}") + + +def es_dict(results: Any, *, balance_e: Optional[int] = None) -> Dict[Any, Dict[str, Any]]: + es = results.aggregate("event_study", balance_e=balance_e) + return es_dict_from_container(es) + + +def es_dict_from_container(es: Any) -> Dict[Any, Dict[str, Any]]: + """The legacy ``event_study_effects`` shape of an ``EventStudyResults``.""" + key = _count_key(es.n_kind) + out: Dict[Any, Dict[str, Any]] = {} + for i, e in enumerate(es.event_time): + row: Dict[str, Any] = { + "effect": float(es.att[i]), + "se": float(es.se[i]), + "t_stat": float(es.t_stat[i]), + "p_value": float(es.p_value[i]), + "conf_int": (float(es.conf_int_lower[i]), float(es.conf_int_upper[i])), + } + if key is not None: + n = float(es.n[i]) + row[key] = 0 if math.isnan(n) else (int(n) if float(n).is_integer() else n) + if es.cband_lower is not None and es.cband_upper is not None: + lo, hi = float(es.cband_lower[i]), float(es.cband_upper[i]) + if math.isfinite(lo) and math.isfinite(hi): + row["cband_conf_int"] = (lo, hi) + out[_label(e)] = row + return out + + +def group_dict(results: Any) -> Dict[Any, Dict[str, Any]]: + """``aggregate("group")`` as the legacy ``group_effects`` mapping.""" + grp = results.aggregate("group") + key = _count_key(grp.n_kind) + out: Dict[Any, Dict[str, Any]] = {} + for i, g in enumerate(grp.label): + row: Dict[str, Any] = { + "effect": float(grp.att[i]), + "se": float(grp.se[i]), + "t_stat": float(grp.t_stat[i]), + "p_value": float(grp.p_value[i]), + "conf_int": (float(grp.conf_int_lower[i]), float(grp.conf_int_upper[i])), + } + if key is not None: + n = float(grp.n[i]) + row[key] = 0 if math.isnan(n) else (int(n) if float(n).is_integer() else n) + df = float(grp.df[i]) + row["df_used"] = None if math.isnan(df) else df + out[_label(g)] = row + return out + + +def legacy_standin(results: Any, *, balance_e: Optional[int] = None) -> Any: + """A copy of ``results`` carrying the container's surface in the 3.x fit-time fields.""" + es = results.aggregate("event_study", balance_e=balance_e) + names = {f.name for f in dataclasses.fields(results)} + updates: Dict[str, Any] = {} + if "event_study_effects" in names: + updates["event_study_effects"] = es_dict_from_container(es) + if "event_study_vcov" in names: + updates["event_study_vcov"] = None if es.vcov is None else np.asarray(es.vcov).copy() + if "event_study_vcov_index" in names: + # The 3.x field is a plain list of event-time labels (consumers call + # ``.index(t)`` on it), not an array. + updates["event_study_vcov_index"] = ( + None if es.vcov_index is None else [_label(x) for x in es.vcov_index] + ) + if "reference_event_times" in names: + updates["reference_event_times"] = es.reference_event_times + if "cband_crit_value" in names: + updates["cband_crit_value"] = es.cband_crit_value + if "event_study_df" in names: + df = es.df + if df is None: + updates["event_study_df"] = None + else: + arr = np.asarray(df, dtype=float).ravel() + finite = arr[np.isfinite(arr)] + updates["event_study_df"] = ( + float(finite[0]) if finite.size and np.all(finite == finite[0]) else None + ) + return dataclasses.replace(results, **updates) + + +@lru_cache(maxsize=1) +def load_oracles() -> Dict[str, Any]: + with open(os.path.join(_FIXTURE, "v4_aggregate_oracles.json")) as fh: + return json.load(fh) + + +def boot_oracles() -> Dict[str, Any]: + """The bootstrapped oracles for the ACTIVE weight backend, or skip.""" + from diff_diff.bootstrap_chunking import effective_weight_backend + + backend = effective_weight_backend() + boot = load_oracles()["boot"] + if backend not in boot: + pytest.skip(f"no bootstrapped aggregation oracle captured for weight backend {backend!r}") + return boot[backend] + + +def oracle_value(x: Any) -> float: + """JSON ``null`` back to NaN.""" + return float("nan") if x is None else float(x) diff --git a/tests/test_aggregate_contract.py b/tests/test_aggregate_contract.py index 700672237..0d2c23432 100644 --- a/tests/test_aggregate_contract.py +++ b/tests/test_aggregate_contract.py @@ -2,31 +2,351 @@ The ``test_ref`` for every aggregate-postfit ledger row this file pins: M-020/M-023/M-021/M-022 (the CS / EfficientDiD / Imputation / TwoStage -``fit(aggregate=)`` shims), M-024/M-026 (the Stacked / dCDH shims + view -relays), M-025 (the ContinuousDiD shim + MIXED view/recompute aggregate() - -'simple'/'dose' views, 'event_study' pruned-IF-payload kit), -M-117/M-120/M-118/M-119 (``balance_e`` moves onto ``aggregate()``), -M-027/M-139 (the HAD fit + pretest-workflow mode-selector shims with -panel-shape inference, and the two PURE-VIEW results classes) -and M-122 (``AggregationResult``). - -The headline gate is NUMERICAL INERTNESS: for every supported type, -``fit(aggregate=T)`` and ``fit(); .aggregate(T)`` must agree to 1e-14. The -refactor that enabled post-fit aggregation touched the influence-function -path, so drift here means a regression, not a design change. +fit-time ``aggregate=`` removals - 4.0 PR 3), M-024/M-026 (the Stacked / dCDH +removals + view relays), M-025 (the ContinuousDiD removal + MIXED +view/relay/recompute aggregate() - 'simple'/'dose' views, 'event_study' +pruned-IF-payload kit on analytic fits, RELAY of the fit-time percentile +surface on bootstrapped fits), M-117/M-120/M-118/M-119 (``balance_e`` lives on +``aggregate()`` only), M-027/M-139 (the HAD fit + pretest-workflow +mode-selector removals with panel-shape inference, and the two PURE-VIEW +results classes) and M-122 (``AggregationResult``). + +The headline gate is ORACLE PARITY: the deprecated fit-time route that used to +serve as this file's numerical reference is gone, so every aggregation surface +is pinned against ``tests/fixtures/v4_aggregate_oracles.json`` - captured on the +unmodified tree by ``tests/_capture_v4_aggregate_oracles.py`` while that route +still existed (see ``tests/fixtures/README.md``). Analytic surfaces must match +at ``rtol=1e-12, atol=1e-13``; bootstrapped surfaces (the CS/EfficientDiD +replays, the Imputation/TwoStage/ContinuousDiD relays and the plain-fit overall +quintets) match the backend-matched oracle at the replay-parity tolerances +(``rtol=1e-13, atol=1e-13`` on att/se/t/CI, ``atol=2/n_bootstrap`` on the +count-statistic p-values). Drift here means a regression, not a design change. + +Since 4.0 the fit-time keywords are gone outright: ``fit(aggregate=...)`` / +``fit(balance_e=...)`` raise Python's native ``TypeError`` (pinned per +estimator below), and bootstrapped Imputation/TwoStage/ContinuousDiD fits +materialize their event-study (+ group) surfaces at fit - FAIL-SOFT - so +``aggregate()`` can relay them (the fail-soft path is pinned by failure +injection). """ import copy +import dataclasses +import inspect +import math import pickle import warnings import numpy as np import pandas as pd import pytest +from legacy_surface import boot_oracles, load_oracles, oracle_value from diff_diff import CallawaySantAnna, StaggeredTripleDifference from diff_diff.aggregation import AGGREGATION_SCHEMA, AggregationResult -from diff_diff.results_base import EventStudyResults +from diff_diff.results_base import EventStudyResults, build_event_study_surface +from diff_diff.utils import safe_inference +from tests.conftest import assert_nan_inference + +# --------------------------------------------------------------------------- # +# Removal-pin helper (shared wording with tests/test_v4_rename_mechanical.py) +# --------------------------------------------------------------------------- # + + +def _unexpected(old: str) -> str: + return rf"unexpected keyword argument '{old}'" + + +# --------------------------------------------------------------------------- # +# Oracle comparison helpers (tests/fixtures/v4_aggregate_oracles.json) +# --------------------------------------------------------------------------- # + +#: Analytic surfaces: the kit recompute re-executes the fit-time arithmetic, +#: so only BLAS summation-order noise separates it from the oracle. +ANALYTIC_TOL = dict(rtol=1e-12, atol=1e-13) +#: Bootstrapped surfaces (replays + relays): the replayed weight stream is +#: bit-identical and only GEMM tile reassociation differs (~1 ULP), with one +#: order of headroom for quantile interpolation. p-values are COUNT statistics +#: (a draw within a ULP of the point estimate can flip one count), hence the +#: additional ``atol=2/n_bootstrap`` applied by the callers. +BOOT_TOL = dict(rtol=1e-13, atol=1e-13) + + +def _oracle_key(label): + """The oracle's stringified label for a container label (int / float).""" + if isinstance(label, str): + return label + f = float(label) + return str(int(f)) if f.is_integer() else repr(f) + + +def _oracle_row(rows, label): + """Oracle row for a container label, tolerating the int/float spellings.""" + key = _oracle_key(label) + if key in rows: + return rows[key] + alt = repr(float(label)) + if alt in rows: + return rows[alt] + raise AssertionError(f"label {label!r} ({key}) has no oracle row; oracle keys: {sorted(rows)}") + + +def _close(got, want, *, rtol, atol, what): + g, w = float(got), oracle_value(want) + if math.isnan(w) or math.isnan(g): + assert math.isnan(w) and math.isnan(g), f"{what}: got {g}, oracle {w}" + return + np.testing.assert_allclose(g, w, rtol=rtol, atol=atol, err_msg=what) + + +def _assert_es_matches_oracle(es, orow, *, rtol, atol, p_atol=None, name=""): + """An ``EventStudyResults`` against an oracle ES bundle. + + Non-reference rows are compared column-for-column (att/se/t/p/CI and the + count where the oracle row carries one); the container's reference rows + (normalized to att=0 / NaN inference) are skipped but their oracle entry + (the legacy zero-count sentinel, or a genuine estimate the container + anchors) must exist. The joint vcov, its index, the scalar df and the + sup-t band are compared wherever the oracle bundle recorded them. + """ + rows = orow["es"] + assert rows is not None, f"{name}: oracle stored no event-study surface" + p_atol = atol if p_atol is None else p_atol + ref = np.asarray(es.is_reference, dtype=bool) + labels = list(es.event_time) + non_ref = {_oracle_key(e) for e, r in zip(labels, ref) if not r} + ref_keys = {_oracle_key(e) for e, r in zip(labels, ref) if r} + assert set(rows) - ref_keys == non_ref, ( + f"{name}: event-time set drifted: container {sorted(non_ref)} vs oracle " + f"{sorted(set(rows) - ref_keys)}" + ) + compared = 0 + for i, e in enumerate(labels): + r = _oracle_row(rows, e) + if ref[i]: + continue + tag = f"{name}/es[{e}]" + _close(es.att[i], r["effect"], rtol=rtol, atol=atol, what=f"{tag}.att") + _close(es.se[i], r["se"], rtol=rtol, atol=atol, what=f"{tag}.se") + _close(es.t_stat[i], r["t_stat"], rtol=rtol, atol=atol, what=f"{tag}.t_stat") + _close(es.p_value[i], r["p_value"], rtol=rtol, atol=p_atol, what=f"{tag}.p_value") + _close(es.conf_int_lower[i], r["ci_lo"], rtol=rtol, atol=atol, what=f"{tag}.ci_lo") + _close(es.conf_int_upper[i], r["ci_hi"], rtol=rtol, atol=atol, what=f"{tag}.ci_hi") + for count_key in ("n_obs", "n_groups", "n_units"): + if count_key in r: + _close(es.n[i], r[count_key], rtol=0, atol=0, what=f"{tag}.{count_key}") + if "cband_lo" in r: + assert es.cband_lower is not None and es.cband_upper is not None, tag + _close(es.cband_lower[i], r["cband_lo"], rtol=rtol, atol=atol, what=f"{tag}.cband_lo") + _close(es.cband_upper[i], r["cband_hi"], rtol=rtol, atol=atol, what=f"{tag}.cband_hi") + compared += 1 + assert compared > 0, f"{name}: no non-reference event times compared" + if orow.get("event_study_vcov") is not None: + assert es.vcov is not None, f"{name}: oracle carried a joint vcov, container has none" + assert [float(x) for x in es.vcov_index] == [ + float(x) for x in orow["event_study_vcov_index"] + ], f"{name}: vcov index drifted" + want = np.array( + [[oracle_value(x) for x in row] for row in orow["event_study_vcov"]], dtype=float + ) + np.testing.assert_allclose( + np.asarray(es.vcov, dtype=float), want, rtol=rtol, atol=atol, equal_nan=True + ) + if orow.get("event_study_df") is not None: + df_col = np.asarray(es.df, dtype=float) + finite = df_col[np.isfinite(df_col)] + assert finite.size > 0, f"{name}: oracle df {orow['event_study_df']} but no finite df row" + assert np.all(finite == float(orow["event_study_df"])), (name, finite) + if orow.get("cband_crit_value") is not None: + assert es.cband_crit_value is not None, name + _close( + es.cband_crit_value, + orow["cband_crit_value"], + rtol=rtol, + atol=atol, + what=f"{name}.cband_crit_value", + ) + return es + + +def _assert_group_matches_oracle(agg, orow, *, rtol, atol, p_atol=None, name=""): + rows = orow["group"] + assert rows is not None, f"{name}: oracle stored no group surface" + p_atol = atol if p_atol is None else p_atol + labels = list(agg.label) + assert len(labels) == len(rows), f"{name}: group label set drifted: {labels} vs {sorted(rows)}" + for i, g in enumerate(labels): + r = _oracle_row(rows, g) + tag = f"{name}/group[{g}]" + _close(agg.att[i], r["effect"], rtol=rtol, atol=atol, what=f"{tag}.att") + _close(agg.se[i], r["se"], rtol=rtol, atol=atol, what=f"{tag}.se") + _close(agg.t_stat[i], r["t_stat"], rtol=rtol, atol=atol, what=f"{tag}.t_stat") + _close(agg.p_value[i], r["p_value"], rtol=rtol, atol=p_atol, what=f"{tag}.p_value") + _close(agg.conf_int_lower[i], r["ci_lo"], rtol=rtol, atol=atol, what=f"{tag}.ci_lo") + _close(agg.conf_int_upper[i], r["ci_hi"], rtol=rtol, atol=atol, what=f"{tag}.ci_hi") + for count_key in ("n_obs", "n_groups", "n_units"): + if count_key in r: + _close(agg.n[i], r[count_key], rtol=0, atol=0, what=f"{tag}.{count_key}") + if "df_used" in r: + # df is per-row PROVENANCE: the legacy row's df_used (None -> NaN) + # - except that the container NaNs df wherever p is non-finite + # (AggregationResult.__post_init__; the legacy dict kept e.g. the + # replicate-undefined 0 sentinel beside NaN inference). + if np.isfinite(agg.p_value[i]): + _close(agg.df[i], r["df_used"], rtol=0, atol=0, what=f"{tag}.df") + else: + assert np.isnan(agg.df[i]), f"{tag}.df must be NaN beside non-finite p" + return agg + + +def _assert_overall_matches_oracle(res, o, *, rtol, atol, p_atol=None, name=""): + """The stored overall quintet against an oracle ``overall`` record.""" + p_atol = atol if p_atol is None else p_atol + _close(res.overall_att, o["att"], rtol=rtol, atol=atol, what=f"{name}.overall_att") + _close(res.overall_se, o["se"], rtol=rtol, atol=atol, what=f"{name}.overall_se") + _close(res.overall_t_stat, o["t_stat"], rtol=rtol, atol=atol, what=f"{name}.overall_t") + _close(res.overall_p_value, o["p_value"], rtol=rtol, atol=p_atol, what=f"{name}.overall_p") + ci = res.overall_conf_int + _close(ci[0], o["ci_lo"], rtol=rtol, atol=atol, what=f"{name}.overall_ci_lo") + _close(ci[1], o["ci_hi"], rtol=rtol, atol=atol, what=f"{name}.overall_ci_hi") + + +def _assert_simple_relays_stored_overall(res): + """(ii) aggregate('simple') is a view of the stored overall quintet.""" + s = res.aggregate("simple") + for got, want in ( + (s.att[0], res.overall_att), + (s.se[0], res.overall_se), + (s.t_stat[0], res.overall_t_stat), + (s.p_value[0], res.overall_p_value), + (s.conf_int_lower[0], res.overall_conf_int[0]), + (s.conf_int_upper[0], res.overall_conf_int[1]), + ): + assert (float(got) == float(want)) or (np.isnan(got) and np.isnan(want)) + return s + + +def _assert_analytic_inference_contract(surface, *, alpha): + """(iv) structural SE/p contract on an ANALYTIC surface. + + Every row is NaN-CONSISTENT (se/t/p/CI NaN together - a finite effect with + NaN inference is valid; a NaN effect with n > 0 is valid too) and, where + se AND the per-row df are finite, p/t are exactly ``safe_inference(att, + se, df)``. Reference rows are skipped (normalized by the container). + """ + ref = ( + np.asarray(surface.is_reference, dtype=bool) + if hasattr(surface, "is_reference") + else np.zeros(len(surface.att), dtype=bool) + ) + df_col = np.asarray(surface.df, dtype=float) + checked = 0 + for i in range(len(surface.att)): + if ref[i]: + continue + se = float(surface.se[i]) + att = float(surface.att[i]) + ci = (float(surface.conf_int_lower[i]), float(surface.conf_int_upper[i])) + if not (np.isfinite(se) and se > 0): + assert_nan_inference( + { + "se": se, + "t_stat": float(surface.t_stat[i]), + "p_value": float(surface.p_value[i]), + "conf_int": ci, + } + ) + continue + if not np.isfinite(att): + # A NaN effect beside a finite se: the inference triple is NaN too. + assert np.isnan(surface.t_stat[i]) and np.isnan(surface.p_value[i]) + assert np.isnan(ci[0]) and np.isnan(ci[1]) + continue + assert np.isfinite(surface.p_value[i]) and 0.0 <= float(surface.p_value[i]) <= 1.0 + assert ci[0] <= att <= ci[1] + if np.isfinite(df_col[i]): + t, p, ci_want = safe_inference(att, se, alpha=alpha, df=float(df_col[i])) + np.testing.assert_allclose(t, surface.t_stat[i], rtol=1e-12) + np.testing.assert_allclose(p, surface.p_value[i], rtol=1e-12) + np.testing.assert_allclose(ci_want, ci, rtol=1e-12) + checked += 1 + return checked + + +def _assert_bootstrap_p_values_in_unit_interval(surface): + """(iv) on BOOTSTRAP arms: percentile p-values are counts in [0, 1]; no + ``safe_inference`` identity holds (and none is asserted).""" + p = np.asarray(surface.p_value, dtype=float) + finite = p[np.isfinite(p)] + assert finite.size > 0 + assert np.all((finite >= 0.0) & (finite <= 1.0)) + + +def _assert_att_from_cells(res, es, *, bucket=False, retained=None, name=""): + """(iii) CS/EfficientDiD point estimates from the PUBLIC ``group_time_effects``: + ``att[e]`` is the cohort-size (``n_treated``) weighted mean of the finite + cells at horizon ``e`` (``t - g == e``; ``int(t - g) == e`` when + ``bucket``), over the ``retained`` cohorts when a balance_e filter applies. + Non-survey arms only (survey arms weight by survey mass).""" + gte = res.group_time_effects + ref = np.asarray(es.is_reference, dtype=bool) + compared = 0 + for i, e in enumerate(es.event_time): + if ref[i]: + continue + cells = [ + (float(c["effect"]), float(c["n_treated"])) + for (g, t), c in gte.items() + if (retained is None or g in retained) + and ((int(t - g) == int(e)) if bucket else (t - g == e)) + and np.isfinite(c["effect"]) + ] + if not cells: + assert np.isnan(es.att[i]), (name, e) + continue + w = np.array([c[1] for c in cells]) + eff = np.array([c[0] for c in cells]) + np.testing.assert_allclose( + es.att[i], float(np.sum(w / w.sum() * eff)), rtol=1e-12, err_msg=f"{name}/att[{e}]" + ) + compared += 1 + assert compared > 0, name + + +def _assert_post_att_is_tau_mean(res, es, *, name=""): + """(iii) Imputation/TwoStage point estimates from the PUBLIC + ``treatment_effects``: at POST horizons ``e >= 0`` the event-study att is + the plain mean of ``tau_hat`` over ``rel_time == e`` (non-survey arms; + leads come from the lead regression and are oracle-only).""" + te = res.treatment_effects + compared = 0 + for i, e in enumerate(es.event_time): + if e < 0 or bool(es.is_reference[i]): + continue + sel = te.loc[te["rel_time"] == e, "tau_hat"] + finite = sel[np.isfinite(sel)] + if finite.empty or not np.isfinite(es.att[i]): + continue + np.testing.assert_allclose( + es.att[i], float(finite.mean()), rtol=1e-12, err_msg=f"{name}/att[{e}]" + ) + compared += 1 + assert compared > 0, name + + +def _frame_equal(a, b): + """Two container frames are identical (NaN-aware, dtype-tolerant).""" + assert list(a.columns) == list(b.columns) + assert a.shape == b.shape + for col in a.columns: + x, y = a[col].to_numpy(), b[col].to_numpy() + if x.dtype.kind in "fc" and y.dtype.kind in "fc": + np.testing.assert_allclose( + x.astype(float), y.astype(float), rtol=0, atol=0, equal_nan=True, err_msg=col + ) + else: + assert list(x) == list(y), col + FIT_KW = dict(outcome="y", unit="unit", time="time", first_treat="first_treat") @@ -61,81 +381,83 @@ def panel(): @pytest.fixture(scope="module") def fitted(panel): - """A plain fit - no aggregate= argument, so no deprecation warning.""" + """A plain fit - the only route since 4.0 (no fit-time aggregation keywords).""" return CallawaySantAnna().fit(panel, **FIT_KW) -@pytest.fixture(scope="module") -def fit_time(panel): - """The DEPRECATED fit-time aggregation, for the inertness comparison.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - return CallawaySantAnna().fit(panel, aggregate="all", **FIT_KW) +def _cs_oracle(arm): + return load_oracles()["analytic"]["CallawaySantAnna"][arm] # --------------------------------------------------------------------------- # -# Numerical inertness (the headline gate) +# Oracle parity (the headline gate) - CallawaySantAnna, default panel # --------------------------------------------------------------------------- # -class TestInertness: - def test_simple_matches_fit_time(self, fitted, fit_time): - got = fitted.aggregate("simple") - assert np.allclose(got.att[0], fit_time.overall_att, rtol=1e-14, atol=1e-14) - assert np.allclose(got.se[0], fit_time.overall_se, rtol=1e-14, atol=1e-14) - assert np.allclose(got.p_value[0], fit_time.overall_p_value, rtol=1e-14, atol=1e-14) - - def test_group_matches_fit_time(self, fitted, fit_time): - frame = fitted.aggregate("group").to_dataframe() - assert len(frame) == len(fit_time.group_effects) - for _, row in frame.iterrows(): - native = fit_time.group_effects[row["label"]] - for col, key in ( - ("att", "effect"), - ("se", "se"), - ("t_stat", "t_stat"), - ("p_value", "p_value"), - ): - assert np.allclose( - row[col], native[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"group[{row['label']}].{col} drifted" - - def test_event_study_matches_fit_time(self, fitted, fit_time): - frame = fitted.aggregate("event_study").to_dataframe() - compared = 0 - for _, row in frame.iterrows(): - if bool(row["is_reference"]): - continue - native = fit_time.event_study_effects[row["event_time"]] - for col, key in ( - ("att", "effect"), - ("se", "se"), - ("t_stat", "t_stat"), - ("p_value", "p_value"), - ): - assert np.allclose( - row[col], native[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"event_study[{row['event_time']}].{col} drifted" - compared += 1 - assert compared > 0, "no non-reference event times compared" +class TestOracleParity: + def test_simple_relays_stored_overall_and_matches_oracle(self, fitted): + _assert_simple_relays_stored_overall(fitted) + _assert_overall_matches_oracle( + fitted, _cs_oracle("default_all")["overall"], **ANALYTIC_TOL, name="cs" + ) - @pytest.mark.parametrize("balance_e", [0, 1, 2]) - def test_balance_e_matches_fit_time(self, panel, fitted, balance_e): - """M-117: balance_e on aggregate() reproduces fit(balance_e=).""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna().fit( - panel, aggregate="event_study", balance_e=balance_e, **FIT_KW - ) - frame = fitted.aggregate("event_study", balance_e=balance_e).to_dataframe() - non_ref = frame[~frame["is_reference"].astype(bool)] - assert len(non_ref) == len( - [e for e in native.event_study_effects if e in set(non_ref["event_time"])] + def test_group_matches_oracle(self, fitted): + _assert_group_matches_oracle( + fitted.aggregate("group"), _cs_oracle("default_all"), **ANALYTIC_TOL, name="cs" + ) + + def test_event_study_matches_oracle(self, fitted): + _assert_es_matches_oracle( + fitted.aggregate("event_study"), _cs_oracle("default_all"), **ANALYTIC_TOL, name="cs" + ) + + def test_balance_e1_matches_oracle(self, fitted): + """M-117: balance_e on aggregate() reproduces the pre-removal + fit(balance_e=1) surface.""" + _assert_es_matches_oracle( + fitted.aggregate("event_study", balance_e=1), + _cs_oracle("default_balance_e1"), + **ANALYTIC_TOL, + name="cs/be1", ) - for _, row in non_ref.iterrows(): - got = native.event_study_effects[row["event_time"]] - assert np.allclose(row["att"], got["effect"], rtol=1e-14, atol=1e-14, equal_nan=True) - assert np.allclose(row["se"], got["se"], rtol=1e-14, atol=1e-14, equal_nan=True) + + @pytest.mark.parametrize("balance_e", [0, 1, 2]) + def test_balance_e_retains_cohorts_at_the_exact_anchor(self, fitted, balance_e): + """(v) CS semantics AS IMPLEMENTED: a cohort is retained iff it has a + FINITE effect at the EXACT anchor ``t - g == balance_e``; EVERY horizon + of a retained cohort stays (no lower bound on event_time) and the + per-horizon att is the cohort-size weighted mean over retained + cohorts only.""" + es = fitted.aggregate("event_study", balance_e=balance_e) + gte = fitted.group_time_effects + retained = { + g for (g, t), c in gte.items() if t - g == balance_e and np.isfinite(c["effect"]) + } + assert retained, "fixture reaches no cohort at the anchor" + horizons = sorted({int(t - g) for (g, t) in gte if g in retained}) + assert sorted(int(e) for e in es.event_time) == horizons + assert min(horizons) < 0 # pre-treatment horizons of retained cohorts stay + _assert_att_from_cells(fitted, es, retained=retained, name=f"cs/be{balance_e}") + if balance_e == 2: + # Discriminating arm: the last cohort never reaches e=2 and drops out. + assert max(gte, key=lambda k: k[0])[0] not in retained + + def test_point_estimates_from_public_cells(self, fitted): + """(iii) att[e] is reconstructible from group_time_effects alone.""" + _assert_att_from_cells(fitted, fitted.aggregate("event_study"), name="cs") + + def test_analytic_inference_contract(self, fitted): + """(iv) NaN-consistency on every row; p/t == safe_inference where se + and df are finite (the plain panel has no df, so the identity leg is + exercised by the survey arm below).""" + for level in ("event_study", "group", "simple"): + _assert_analytic_inference_contract(fitted.aggregate(level), alpha=fitted.alpha) + + def test_legacy_fields_none_after_analytic_fit(self, fitted): + """4.0 PR 3: CS never materializes a fit-time surface any more.""" + assert fitted.event_study_effects is None + assert fitted.group_effects is None + assert fitted.event_study_vcov is None and fitted.event_study_df is None # --------------------------------------------------------------------------- # @@ -238,15 +560,20 @@ def test_no_raw_unit_identifiers_are_retained(self): assert np.isfinite(np.asarray(res.aggregate(level).att)).any() def test_identifier_minimization_is_numerically_inert(self, panel, fitted): - """Canonical codes must not perturb any aggregate: compared at - atol=rtol=0, since substituting positions for labels should not change - a single floating-point operation.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna().fit(panel, aggregate="all", **FIT_KW) - got = fitted.aggregate("simple") - assert got.att[0] == native.overall_att - assert got.se[0] == native.overall_se + """Canonical codes must not perturb any aggregate: a fit on the same + panel with STRING unit identifiers is compared at atol=rtol=0, since + substituting positions for labels should not change a single + floating-point operation.""" + labelled = panel.copy() + labelled["unit"] = labelled["unit"].map(lambda u: f"unit-{u:03d}@example.invalid") + res = CallawaySantAnna().fit(labelled, **FIT_KW) + got, want = res.aggregate("simple"), fitted.aggregate("simple") + assert got.att[0] == want.att[0] + assert got.se[0] == want.se[0] + _frame_equal( + res.aggregate("event_study").to_dataframe(), + fitted.aggregate("event_study").to_dataframe(), + ) # --------------------------------------------------------------------------- # @@ -347,79 +674,30 @@ def _boot_fit(data, *, seed=42, n_bootstrap=50, fit_kwargs=None, **ctor): ) -def _boot_fit_time(data, *, seed=42, n_bootstrap=50, aggregate="all", fit_kwargs=None, **ctor): - """The parity REFERENCE: the deprecated fit-time aggregation route. +_CS_NBOOT = 50 - The reference is the NATIVE fit-time surface (`event_study_effects` / - `group_effects` dict entries + `cband_crit_value`), never a second - aggregate() call — the kit is attached unconditionally at the end of - fit(), so a fit-time-aggregated result's own aggregate() would ALSO - replay, and replay-vs-replay proves nothing. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return CallawaySantAnna(n_bootstrap=n_bootstrap, seed=seed, **ctor).fit( - data, aggregate=aggregate, **FIT_KW, **(fit_kwargs or {}) - ) +def _cs_boot_oracle(arm): + """The backend-matched bootstrapped CS oracle (skips when uncaptured).""" + return boot_oracles()["CallawaySantAnna"][arm] -def _assert_es_replay_parity(es, ref, n_bootstrap=50): - """Replayed EventStudyResults vs the native fit-time bootstrap surface. - Tolerance note (reconciling with this file's 1e-14 analytical-parity - convention): the replayed weight stream is bit-identical, and only the - fused GEMM's tile-boundary/column-count reassociation differs between - the fit-time and replay passes — ~1 ULP relative — so 1e-13 is the same - contract with one order of headroom for quantile-interpolation - arithmetic; any real desynchronization is O(1), not O(1e-13). p-values - are COUNT statistics (a draw within a ULP of the point estimate can - flip one count), hence the additional 2/n_bootstrap atol. - """ - ref_es = ref.event_study_effects - p_atol = 2.0 / n_bootstrap - for i, e in enumerate(es.event_time): - if bool(es.is_reference[i]): - continue - r = ref_es[int(e)] - assert float(es.att[i]) == float(r["effect"]) # same analytical point path - np.testing.assert_allclose(es.se[i], r["se"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(es.t_stat[i], r["t_stat"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(es.p_value[i], r["p_value"], rtol=1e-13, atol=p_atol) - np.testing.assert_allclose( - [es.conf_int_lower[i], es.conf_int_upper[i]], - list(r["conf_int"]), - rtol=1e-13, - atol=1e-13, - ) - cb = r.get("cband_conf_int") - if cb is not None: - np.testing.assert_allclose( - [es.cband_lower[i], es.cband_upper[i]], list(cb), rtol=1e-13, atol=1e-13 - ) - # Percentile provenance: no analytical df, and the joint covariance is - # the fit-time one of the same bootstrap draws (never the analytical one). - assert ref.event_study_vcov is not None - assert list(es.vcov_index) == list(ref.event_study_vcov_index) - np.testing.assert_allclose(es.vcov, ref.event_study_vcov, rtol=1e-13, atol=1e-13) +def _assert_es_replay_parity(es, orow, n_bootstrap=_CS_NBOOT): + """Replayed EventStudyResults vs the pre-removal fit-time bootstrap surface + (oracle). Tolerances per ``BOOT_TOL``; the percentile provenance pins - + no analytical df, the joint covariance of the same draws - ride the + oracle bundle (vcov / vcov_index / cband_crit_value).""" + _assert_es_matches_oracle(es, orow, **BOOT_TOL, p_atol=2.0 / n_bootstrap, name="cs/boot") + assert es.vcov is not None assert np.all(np.isnan(np.asarray(es.df, dtype=float))) - if ref.cband_crit_value is not None: - np.testing.assert_allclose(es.cband_crit_value, ref.cband_crit_value, rtol=1e-13) - - -def _assert_group_replay_parity(agg, ref, n_bootstrap=50): - gdf = agg.to_dataframe() - p_atol = 2.0 / n_bootstrap - for _, row in gdf.iterrows(): - r = ref.group_effects[row["label"]] - assert float(row["att"]) == float(r["effect"]) - if np.isnan(r["se"]): - assert np.isnan(row["se"]) - continue - np.testing.assert_allclose(row["se"], r["se"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["t_stat"], r["t_stat"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["p_value"], r["p_value"], rtol=1e-13, atol=p_atol) - # df cleared: percentile inference never used the analytical df. - assert np.isnan(row["df"]) + return es + + +def _assert_group_replay_parity(agg, orow, n_bootstrap=_CS_NBOOT): + _assert_group_matches_oracle(agg, orow, **BOOT_TOL, p_atol=2.0 / n_bootstrap, name="cs/boot") + # df cleared: percentile inference never used the analytical df. + assert np.all(np.isnan(np.asarray(agg.df, dtype=float))) + return agg class TestBootstrapReplay: @@ -435,30 +713,85 @@ class TestBootstrapReplay: by the convention, not pinned individually. """ - def test_event_study_parity_with_fit_time(self, panel): - ref = _boot_fit_time(panel, aggregate="event_study") + def test_event_study_matches_oracle(self, panel): plain = _boot_fit(panel) with warnings.catch_warnings(): warnings.simplefilter("ignore") es = plain.aggregate("event_study") assert isinstance(es, EventStudyResults) - _assert_es_replay_parity(es, ref) + _assert_es_replay_parity(es, _cs_boot_oracle("default_all")) + # (vi) the joint covariance is the covariance of the same draws. + non_ref = ~np.asarray(es.is_reference, dtype=bool) + np.testing.assert_allclose( + np.diag(np.asarray(es.vcov)), np.asarray(es.se)[non_ref] ** 2, rtol=1e-12 + ) + # The CS multiplier bootstrap always carries the sup-t band (cband= + # only changes how the per-horizon interval is formed - pinned by the + # cband_all oracle below), so the band is present on every replay. + assert es.cband_lower is not None and es.cband_upper is not None - def test_group_parity_with_fit_time(self, panel): - ref = _boot_fit_time(panel, aggregate="group") + def test_group_matches_oracle(self, panel): plain = _boot_fit(panel) with warnings.catch_warnings(): warnings.simplefilter("ignore") agg = plain.aggregate("group") - _assert_group_replay_parity(agg, ref) + _assert_group_replay_parity(agg, _cs_boot_oracle("default_all")) + + def test_plain_overall_matches_oracle(self, panel): + """The headline bootstrap of a PLAIN fit is untouched by the removal.""" + plain = _boot_fit(panel) + _assert_overall_matches_oracle( + plain, + _cs_boot_oracle("default_plain_overall"), + **BOOT_TOL, + p_atol=2.0 / _CS_NBOOT, + name="cs/boot", + ) + # ... and equals the overall of the pre-removal fit(aggregate="all"). + _assert_overall_matches_oracle( + plain, _cs_boot_oracle("default_all")["overall"], **BOOT_TOL, p_atol=2.0 / _CS_NBOOT + ) - def test_balance_e_parity_with_fit_time(self, panel): - ref = _boot_fit_time(panel, aggregate="event_study", fit_kwargs={"balance_e": 1}) + def test_cband_replay_matches_oracle(self, panel): + """(vi) cband=True: the sup-t band and its critical value replay + (the backend-matched cband_all oracle pins the cband=True numbers).""" + plain = _boot_fit(panel, cband=True) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + es = plain.aggregate("event_study") + _assert_es_replay_parity(es, _cs_boot_oracle("cband_all")) + assert es.cband_lower is not None and es.cband_upper is not None + assert es.cband_crit_value is not None + + def test_balance_e_replay_retains_anchor_cohorts(self, panel, fitted): + """(v) on the replay route: balance_e keeps the analytical anchor rule + (same labels and point estimates as the analytic container) while + the inference is percentile (df NaN) and deterministic.""" plain = _boot_fit(panel) with warnings.catch_warnings(): warnings.simplefilter("ignore") es = plain.aggregate("event_study", balance_e=1) - _assert_es_replay_parity(es, ref) + again = _boot_fit(panel).aggregate("event_study", balance_e=1) + ana = fitted.aggregate("event_study", balance_e=1) + assert list(es.event_time) == list(ana.event_time) + np.testing.assert_array_equal(np.asarray(es.att), np.asarray(ana.att)) + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + np.testing.assert_array_equal(np.asarray(es.se), np.asarray(again.se)) + _assert_bootstrap_p_values_in_unit_interval(es) + + def test_same_seed_fits_replay_identically(self, panel): + """(vi) determinism: two same-seed fits replay the same surface.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + a = _boot_fit(panel).aggregate("event_study").to_dataframe() + b = _boot_fit(panel).aggregate("event_study").to_dataframe() + _frame_equal(a, b) + + def test_legacy_fields_none_after_bootstrapped_fit(self, panel): + """CS stores NO fit-time surface after ANY fit (the replay serves).""" + plain = _boot_fit(panel) + assert plain.event_study_effects is None and plain.group_effects is None + assert plain.event_study_vcov is None and plain.event_study_df is None def test_seedless_fit_replays_and_is_idempotent(self, panel): """seed=None fits replay too — the spec captures the actual RNG @@ -516,8 +849,6 @@ def test_backend_mismatch_fails_closed(self, panel): """A different weight backend regenerates different draws from the same RNG state — the replay must refuse, deterministically under either installed backend.""" - import dataclasses - from diff_diff.bootstrap_chunking import effective_weight_backend plain = _boot_fit(panel) @@ -544,9 +875,9 @@ def test_no_post_treatment_cells_group_returns_zero_rows(self): """A fit whose every treated cohort's onset lies beyond the observed panel has NO post-treatment cells: overall inference is NaN, only pre-treatment cells exist, and the group aggregation is empty. The - bootstrapped group path must return the supported zero-row result on - BOTH routes (they share `_run_multiplier_bootstrap`, whose group - stats block would otherwise np.column_stack an empty list).""" + bootstrapped group path must return the supported zero-row result + (`_run_multiplier_bootstrap`'s group stats block would otherwise + np.column_stack an empty list).""" rng = np.random.default_rng(0) rows = [] for u in range(30): @@ -568,24 +899,44 @@ def test_no_post_treatment_cells_group_returns_zero_rows(self): agg = plain.aggregate("group") assert len(agg.to_dataframe()) == 0 assert np.isnan(plain.overall_att) # unchanged by the replay - # The deprecated fit-time route rides the same helper — it must - # survive too (the crash was reachable there pre-replay). - ref = _boot_fit_time(data, n_bootstrap=25, seed=1, aggregate="group") - assert ref.group_effects in (None, {}) + assert plain.group_effects is None # nothing is stored at fit any more class TestBootstrapReplayDesigns: - """Replay parity on the engine branches the plain panel never reaches.""" + """Replay parity on the engine branches the plain panel never reaches. + + Arms with a captured oracle pin the numbers; the others (fpc, RCS, + unbalanced) pin the replay contract - percentile provenance (NaN df, + joint vcov), p in [0, 1] and same-seed determinism.""" + + @staticmethod + def _assert_replay_contract(plain, levels=("event_study", "group")): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + first = {lv: plain.aggregate(lv) for lv in levels} + again = {lv: plain.aggregate(lv) for lv in levels} + for lv, surface in first.items(): + assert np.all(np.isnan(np.asarray(surface.df, dtype=float))), lv + _assert_bootstrap_p_values_in_unit_interval(surface) + np.testing.assert_array_equal(np.asarray(surface.se), np.asarray(again[lv].se)) + if "event_study" in first: + es = first["event_study"] + assert es.vcov is not None + non_ref = ~np.asarray(es.is_reference, dtype=bool) + np.testing.assert_allclose( + np.diag(np.asarray(es.vcov)), np.asarray(es.se)[non_ref] ** 2, rtol=1e-12 + ) + return first def test_bare_cluster_psu_expansion(self, panel): d = panel.copy() d["psu"] = d["unit"] % 7 - ref = _boot_fit_time(d, cluster="psu") plain = _boot_fit(d, cluster="psu") + orow = _cs_boot_oracle("psu_cluster_all") with warnings.catch_warnings(): warnings.simplefilter("ignore") - _assert_es_replay_parity(plain.aggregate("event_study"), ref) - _assert_group_replay_parity(plain.aggregate("group"), ref) + _assert_es_replay_parity(plain.aggregate("event_study"), orow) + _assert_group_replay_parity(plain.aggregate("group"), orow) def test_stratified_survey_is_portable_and_matches(self, panel): from diff_diff import SurveyDesign @@ -597,18 +948,18 @@ def test_stratified_survey_is_portable_and_matches(self, panel): d["psu"] = d["unit"] % 7 d["stratum"] = d["unit"] % 2 sd = SurveyDesign(weights="w", strata="stratum", psu="psu", nest=True) - ref = _boot_fit_time(d, fit_kwargs={"survey_design": sd}) plain = _boot_fit(d, fit_kwargs={"survey_design": sd}) # The stratified survey generator draws through NumPy regardless of # the installed backend — provably backend-independent, stamped # "portable" so the artifact replays anywhere. assert plain._aggregation_kit.bootstrap.backend == "portable" + orow = _cs_boot_oracle("stratified_survey_all") with warnings.catch_warnings(): warnings.simplefilter("ignore") - _assert_es_replay_parity(plain.aggregate("event_study"), ref) - _assert_group_replay_parity(plain.aggregate("group"), ref) + _assert_es_replay_parity(plain.aggregate("event_study"), orow) + _assert_group_replay_parity(plain.aggregate("group"), orow) - def test_fpc_survey_matches(self, panel): + def test_fpc_survey_replay_contract(self, panel): from diff_diff import SurveyDesign d = panel.copy() @@ -618,28 +969,22 @@ def test_fpc_survey_matches(self, panel): d["psu"] = d["unit"] % 7 d["fpc"] = 100 # non-census: the fpc_scale branch sd = SurveyDesign(weights="w", psu="psu", fpc="fpc") - ref = _boot_fit_time(d, fit_kwargs={"survey_design": sd}) plain = _boot_fit(d, fit_kwargs={"survey_design": sd}) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - _assert_es_replay_parity(plain.aggregate("event_study"), ref) + es = self._assert_replay_contract(plain, levels=("event_study",))["event_study"] + assert es.df_survey == plain.survey_metadata.df_survey - def test_repeated_cross_sections_match(self): + def test_repeated_cross_sections_replay_contract(self): data = _rcs() - ref = _boot_fit_time(data, panel=False) plain = _boot_fit(data, panel=False) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - _assert_es_replay_parity(plain.aggregate("event_study"), ref) - _assert_group_replay_parity(plain.aggregate("group"), ref) + surfaces = self._assert_replay_contract(plain) + # Same analytical point path as the analytic RCS fit. + ana = CallawaySantAnna(panel=False).fit(data, **FIT_KW).aggregate("event_study") + np.testing.assert_array_equal(np.asarray(surfaces["event_study"].att), np.asarray(ana.att)) - def test_unbalanced_panel_matches(self, panel): + def test_unbalanced_panel_replay_contract(self, panel): thinned = panel.drop(panel.index[::13]).reset_index(drop=True) - ref = _boot_fit_time(thinned, allow_unbalanced_panel=True) plain = _boot_fit(thinned, allow_unbalanced_panel=True) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - _assert_es_replay_parity(plain.aggregate("event_study"), ref) + self._assert_replay_contract(plain, levels=("event_study",)) def test_single_psu_nan_surfaces_and_warning(self, panel): from diff_diff import SurveyDesign @@ -769,29 +1114,57 @@ def test_non_estimable_row_keeps_point_estimate(self): # --------------------------------------------------------------------------- # -# Deprecation shim (M-020 / M-117) +# Fit-time keyword removal (M-020 / M-117) # --------------------------------------------------------------------------- # -class TestFitShim: +class TestFitRemoval: + """M-020 / M-117 removed: the fit-time keywords are gone outright.""" + def test_plain_fit_does_not_warn(self, panel): - """The sentinel default is what makes this true - a bare None default - could not tell 'not passed' from 'passed None'.""" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") CallawaySantAnna().fit(panel, **FIT_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - @pytest.mark.parametrize("kwargs", [{"aggregate": "group"}, {"balance_e": 1}]) - def test_deprecated_args_warn(self, panel, kwargs): - with pytest.warns(FutureWarning, match="aggregate"): - CallawaySantAnna().fit(panel, **kwargs, **FIT_KW) + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, panel, kw): + with pytest.raises(TypeError, match=_unexpected(kw)): + CallawaySantAnna().fit(panel, **{kw: None}, **FIT_KW) + value = "group" if kw == "aggregate" else 1 + with pytest.raises(TypeError, match=_unexpected(kw)): + CallawaySantAnna().fit(panel, **{kw: value}, **FIT_KW) + + def test_signature_has_no_fit_time_aggregation_parameters(self): + params = inspect.signature(CallawaySantAnna.fit).parameters + assert "aggregate" not in params and "balance_e" not in params + assert list(params) == [ + "self", + "data", + "outcome", + "unit", + "time", + "first_treat", + "covariates", + "survey_design", + ] + + def test_seventh_positional_binds_to_survey_design(self): + """No vacated slot: a SurveyDesign in the 7th positional position (after + covariates) is the survey design, not a silently-ignored aggregate.""" + from diff_diff import SurveyDesign - def test_deprecated_path_still_populates_legacy_surface(self, fit_time): - """Downstream consumers (honest_did, pretrends, - build_event_study_surface) read these off the results object.""" - assert fit_time.event_study_effects is not None - assert fit_time.group_effects is not None + d = _survey_panel() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = CallawaySantAnna().fit( + d, "y", "unit", "time", "first_treat", None, _survey_design() + ) + kw = CallawaySantAnna().fit(d, survey_design=_survey_design(), **FIT_KW) + assert res.survey_metadata is not None + assert res.survey_metadata.df_survey == kw.survey_metadata.df_survey + assert res.overall_att == kw.overall_att + assert isinstance(_survey_design(), SurveyDesign) def _rcs(seed=17, n_per_cell=6, n_periods=6): @@ -817,124 +1190,111 @@ def _rcs(seed=17, n_per_cell=6, n_periods=6): return pd.DataFrame(rows) -class TestInertnessAcrossDesigns: - """The inertness gate on designs the balanced-panel fixture does not reach. +class TestOracleParityAcrossDesigns: + """The oracle gate on designs the balanced-panel fixture does not reach. The refactor made the aggregators pure by threading bookkeeping that the panel path and the repeated-cross-section path populate differently (RCS makes several kit keys observation-length rather than unit-length), and - ``anticipation`` shifts which ``(g, t)`` cells are eligible - so both need - their own round-trip, not just the panel. + ``anticipation`` shifts which ``(g, t)`` cells are eligible - so each + design arm carries its own oracle, captured from the pre-removal fit-time + route on exactly these panels. """ @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_repeated_cross_sections_match_fit_time(self, level): - data = _rcs() - post = CallawaySantAnna(panel=False).fit(data, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(panel=False).fit(data, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) + def test_repeated_cross_sections_match_oracle(self, level): + post = CallawaySantAnna(panel=False).fit(_rcs(), **FIT_KW) + _assert_level_matches_oracle(post, _cs_oracle("rcs_all"), level, "cs/rcs") @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_anticipation_matches_fit_time(self, panel, level): + def test_anticipation_matches_oracle(self, panel, level): post = CallawaySantAnna(anticipation=1).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(anticipation=1).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) + _assert_level_matches_oracle(post, _cs_oracle("anticipation1_all"), level, "cs/antic") @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_universal_base_period_matches_fit_time(self, panel, level): + def test_universal_base_period_matches_oracle(self, panel, level): """REGISTRY gives universal bases their own reference-cell and VCV-index semantics (a zero reference cell per cohort), which the default 'varying' fixture never exercises.""" - kw = dict(base_period="universal") - post = CallawaySantAnna(**kw).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(**kw).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) + post = CallawaySantAnna(base_period="universal").fit(panel, **FIT_KW) + _assert_level_matches_oracle(post, _cs_oracle("universal_all"), level, "cs/universal") @pytest.mark.parametrize("level", ["simple", "group", "event_study", "total"]) - def test_not_yet_treated_with_anticipation_matches_fit_time(self, panel, level): + def test_not_yet_treated_with_anticipation_matches_oracle(self, panel, level): """The not-yet-treated control group interacts with anticipation in picking each (g, t) comparison set - a different code path from the never-treated default.""" - kw = dict(control_group="not_yet_treated", anticipation=1) - post = CallawaySantAnna(**kw).fit(panel, **FIT_KW) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - native = CallawaySantAnna(**kw).fit(panel, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) + post = CallawaySantAnna(control_group="not_yet_treated", anticipation=1).fit( + panel, **FIT_KW + ) + _assert_level_matches_oracle(post, _cs_oracle("nyt_anticipation1_all"), level, "cs/nyt") @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_unbalanced_panel_matches_fit_time(self, panel, level): + def test_unbalanced_panel_matches_oracle(self, panel, level): """allow_unbalanced_panel changes which units survive into the influence vectors, so the retained bookkeeping must agree with what the fit actually used.""" thinned = panel.drop(panel.index[::13]).reset_index(drop=True) - kw = dict(allow_unbalanced_panel=True) with warnings.catch_warnings(): warnings.simplefilter("ignore") - post = CallawaySantAnna(**kw).fit(thinned, **FIT_KW) - native = CallawaySantAnna(**kw).fit(thinned, aggregate="all", **FIT_KW) - _assert_level_matches(post, native, level) + post = CallawaySantAnna(allow_unbalanced_panel=True).fit(thinned, **FIT_KW) + _assert_level_matches_oracle(post, _cs_oracle("unbalanced_all"), level, "cs/unbalanced") @pytest.mark.parametrize("level", ["simple", "group", "event_study"]) - def test_survey_numbers_match_fit_time(self, survey_fit, level): + def test_survey_numbers_match_oracle(self, survey_fit, level): """Survey parity on the ESTIMATES, not just the df metadata.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - native = CallawaySantAnna().fit( - _survey_panel(), - survey_design=_survey_design(), - aggregate="all", - **FIT_KW, + _assert_level_matches_oracle(survey_fit, _cs_oracle("survey_all"), level, "cs/survey") + + def test_survey_inference_contract_with_finite_df(self, survey_fit): + """(iv) the identity leg: on the explicit-survey arm every row carries + the finite survey df and p/t/CI are exactly safe_inference(att, se, df).""" + for level in ("event_study", "group", "simple"): + checked = _assert_analytic_inference_contract( + survey_fit.aggregate(level), alpha=survey_fit.alpha ) - _assert_level_matches(survey_fit, native, level) + assert checked > 0, level + + @pytest.mark.parametrize( + "ctor", + [ + {"anticipation": 1}, + {"base_period": "universal"}, + {"control_group": "not_yet_treated", "anticipation": 1}, + ], + ) + def test_point_estimates_from_public_cells_across_designs(self, panel, ctor): + """(iii) on the non-survey design arms.""" + post = CallawaySantAnna(**ctor).fit(panel, **FIT_KW) + _assert_att_from_cells(post, post.aggregate("event_study"), name=str(ctor)) -def _assert_level_matches(post, native, level): - """Compare a post-fit aggregation against the fit-time surface at 1e-14.""" +def _assert_level_matches_oracle(post, orow, level, name=""): + """Compare a post-fit aggregation against the pre-removal oracle arm.""" if level == "total": # Identity form (total == n x overall on the SAME fit) AND the # independent-mass form (n pinned against a frame-derived oracle from - # the OTHER fit's public cells) - the identity alone passes for ANY C. + # the fit's PUBLIC cells) - the identity alone passes for ANY C. got = post.aggregate("total") - antic = getattr(native, "anticipation", 0) + antic = getattr(post, "anticipation", 0) n_exp = sum( d["n_treated"] - for (g, t), d in native.group_time_effects.items() + for (g, t), d in post.group_time_effects.items() if t >= g - antic and np.isfinite(d["effect"]) ) assert got.n[0] == float(n_exp) - assert np.allclose(got.att[0], got.n[0] * native.overall_att, rtol=1e-12) - assert np.allclose(got.se[0], got.n[0] * native.overall_se, rtol=1e-12) - assert np.allclose(got.t_stat[0], native.overall_t_stat, rtol=1e-14, atol=0, equal_nan=True) + o = orow["overall"] + assert np.allclose(got.att[0], got.n[0] * oracle_value(o["att"]), rtol=1e-12) + assert np.allclose(got.se[0], got.n[0] * oracle_value(o["se"]), rtol=1e-12) + _close(got.t_stat[0], o["t_stat"], **ANALYTIC_TOL, what=f"{name}/total.t") return if level == "simple": - got = post.aggregate("simple") - assert np.allclose(got.att[0], native.overall_att, rtol=1e-14, atol=1e-14) - assert np.allclose(got.se[0], native.overall_se, rtol=1e-14, atol=1e-14) + _assert_simple_relays_stored_overall(post) + _assert_overall_matches_oracle(post, orow["overall"], **ANALYTIC_TOL, name=name) return - - frame = post.aggregate(level).to_dataframe() if level == "group": - expected, key_col = native.group_effects, "label" - else: - expected, key_col = native.event_study_effects, "event_time" - compared = 0 - for _, row in frame.iterrows(): - if level == "event_study" and bool(row["is_reference"]): - continue - ref = expected[row[key_col]] - for col, key in (("att", "effect"), ("se", "se"), ("p_value", "p_value")): - assert np.allclose( - row[col], ref[key], rtol=1e-14, atol=1e-14, equal_nan=True - ), f"{level}[{row[key_col]}].{col} drifted" - compared += 1 - assert compared > 0, f"no {level} rows compared" + _assert_group_matches_oracle(post.aggregate("group"), orow, **ANALYTIC_TOL, name=name) + return + _assert_es_matches_oracle(post.aggregate("event_study"), orow, **ANALYTIC_TOL, name=name) def _survey_panel(): @@ -1001,6 +1361,8 @@ def test_bootstrap_clears_group_df_provenance(self, survey): the retained analytical df described inference that no longer exists. Leaving it finite would claim a t-reference governed percentile-bootstrap numbers - the same false provenance the event-study path already clears. + Since 4.0 the CS surface is the REPLAY container (nothing is stored on + the results), so the pin reads the replayed df column. """ from diff_diff import SurveyDesign @@ -1031,13 +1393,15 @@ def test_bootstrap_clears_group_df_provenance(self, survey): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = CallawaySantAnna(n_bootstrap=50, seed=1, **ctor).fit( - pd.DataFrame(rows), aggregate="all", **fit_extra, **FIT_KW + pd.DataFrame(rows), **fit_extra, **FIT_KW ) - assert res.group_effects, "fixture produced no group rows" - for g, eff in res.group_effects.items(): - assert eff.get("df_used") is None, f"group {g} kept analytical df on a bootstrap fit" + grp = res.aggregate("group") + es = res.aggregate("event_study") + assert res.group_effects is None and res.event_study_df is None # nothing stored + assert len(grp.label) > 0, "fixture produced no group rows" + assert np.all(np.isnan(np.asarray(grp.df, dtype=float))) # The event-study path's equivalent clearing must not have regressed. - assert res.event_study_df is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) @pytest.mark.parametrize("survey", [False, True]) def test_sddd_bootstrap_clears_group_df_provenance(self, survey): @@ -1263,7 +1627,7 @@ def test_staggered_triple_diff_overall_att_es_still_works(): # --------------------------------------------------------------------------- # -# dCDH (row M-026): fit(aggregate=) shim + the VIEW-based aggregate() +# dCDH (row M-026): fit(aggregate=) removal pin + the VIEW-based aggregate() # --------------------------------------------------------------------------- # DCDH_KW = dict(outcome="outcome", unit="unit", time="period", treatment="treat") @@ -1314,7 +1678,10 @@ def dcdh_fitted(dcdh_panel): return _fit_dcdh(dcdh_panel) -class TestDcdhShim: +class TestDcdhRemoval: + """M-026 removed: dCDH's fit-time ``aggregate=`` (which never computed + anything) is gone; the 6th positional slot is ``L_max``.""" + def test_plain_fit_does_not_warn(self, dcdh_panel): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille @@ -1323,18 +1690,25 @@ def test_plain_fit_does_not_warn(self, dcdh_panel): ChaisemartinDHaultfoeuille().fit(dcdh_panel, **DCDH_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, dcdh_panel): + @pytest.mark.parametrize("value", [None, "event_study"]) + def test_aggregate_keyword_is_a_type_error(self, dcdh_panel, value): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate=None, **DCDH_KW) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate=value, **DCDH_KW) - def test_non_none_value_warns_then_raises(self, dcdh_panel): + def test_signature_has_no_aggregate_and_sixth_positional_is_l_max(self, dcdh_panel): from diff_diff.chaisemartin_dhaultfoeuille import ChaisemartinDHaultfoeuille - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"results\.aggregate"): - ChaisemartinDHaultfoeuille().fit(dcdh_panel, aggregate="event_study", **DCDH_KW) + params = list(inspect.signature(ChaisemartinDHaultfoeuille.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[6] == "L_max" + res = _fit_dcdh(dcdh_panel) # noqa: F841 - plain fit still works + positional = ChaisemartinDHaultfoeuille().fit( + dcdh_panel, "outcome", "unit", "period", "treat", 2 + ) + assert list(positional.aggregate("simple").target) == ["delta"] + assert 2 in positional.aggregate("event_study").event_time.tolist() class TestDcdhAggregate: @@ -1528,7 +1902,7 @@ def test_mixin_hooks_are_not_dataclass_fields(self): # --------------------------------------------------------------------------- # -# StackedDiD (row M-024): fit(aggregate=) shim + the VIEW-based aggregate() +# StackedDiD (row M-024): fit(aggregate=) removal pin + the VIEW-based aggregate() # --------------------------------------------------------------------------- # STACKED_KW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") @@ -1563,7 +1937,10 @@ def stacked_fitted(stacked_panel): return _fit_stacked(stacked_panel) -class TestStackedShim: +class TestStackedRemoval: + """M-024 removed: the inert fit-time ``aggregate=`` is gone (the ES surface + is always materialized); the 6th positional slot is ``population``.""" + def test_plain_fit_does_not_warn(self, stacked_panel): from diff_diff import StackedDiD @@ -1572,35 +1949,36 @@ def test_plain_fit_does_not_warn(self, stacked_panel): StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, **STACKED_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, stacked_panel): + @pytest.mark.parametrize("value", [None, "event_study", "group"]) + def test_aggregate_keyword_is_a_type_error(self, stacked_panel, value): from diff_diff import StackedDiD - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, aggregate=None, **STACKED_KW) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + StackedDiD(kappa_pre=2, kappa_post=2).fit(stacked_panel, aggregate=value, **STACKED_KW) - def test_deprecated_value_warns_and_still_works(self, stacked_panel, stacked_fitted): - # CS-style warn-and-still-work (the param genuinely worked here, - # unlike dCDH's raise): the deprecated path returns an object whose - # surface equals a plain fit's - the surface is always computed. + def test_signature_has_no_aggregate_and_sixth_positional_is_population( + self, stacked_panel, stacked_fitted + ): from diff_diff import StackedDiD - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - res = StackedDiD(kappa_pre=2, kappa_post=2).fit( - stacked_panel, aggregate="event_study", **STACKED_KW + params = list(inspect.signature(StackedDiD.fit).parameters) + assert "aggregate" not in params + assert params[6] == "population" + panel = stacked_panel.copy() + panel["pop"] = 100.0 + (panel["unit"] % 7) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = StackedDiD(kappa_pre=2, kappa_post=2, weighting="population").fit( + panel, "outcome", "unit", "period", "first_treat", "pop" ) - assert res.overall_att == stacked_fitted.overall_att - assert res.event_study_effects is not None - assert sorted(res.event_study_effects) == sorted(stacked_fitted.event_study_effects) - np.testing.assert_array_equal(res.event_study_vcov, stacked_fitted.event_study_vcov) - - def test_group_warns_then_raises_educational_error(self, stacked_panel): - from diff_diff import StackedDiD - - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="not supported by StackedDiD"): - StackedDiD(kappa_pre=2, kappa_post=2).fit( - stacked_panel, aggregate="group", **STACKED_KW - ) + keyword = StackedDiD(kappa_pre=2, kappa_post=2, weighting="population").fit( + panel, population="pop", **STACKED_KW + ) + assert positional.overall_att == keyword.overall_att + assert positional.overall_se == keyword.overall_se + # The surface is always computed - with or without a population column. + assert positional.event_study_effects is not None + assert stacked_fitted.event_study_effects is not None class TestStackedAggregate: @@ -1847,7 +2225,7 @@ def test_unsupported_types_fail_closed(self, stacked_fitted, bad): # --------------------------------------------------------------------------- # -# EfficientDiD (rows M-023/M-120): fit(aggregate=/balance_e=) shim + the +# EfficientDiD (rows M-023/M-120): fit(aggregate=/balance_e=) removal pins + the # lazy-KIT recompute aggregate() (the CallawaySantAnna class) # --------------------------------------------------------------------------- # @@ -1926,13 +2304,13 @@ def efficient_fitted(efficient_panel): return _fit_efficient(efficient_panel) -@pytest.fixture(scope="module") -def efficient_fit_time(efficient_panel): - """Deprecated fit-time aggregate="all" - the inertness reference.""" - return _fit_efficient(efficient_panel, aggregate="all") +def _ed_oracle(arm): + return load_oracles()["analytic"]["EfficientDiD"][arm] + +class TestEfficientRemoval: + """M-023 / M-120 removed.""" -class TestEfficientShim: def test_plain_fit_does_not_warn(self, efficient_panel): from diff_diff import EfficientDiD @@ -1941,95 +2319,60 @@ def test_plain_fit_does_not_warn(self, efficient_panel): EfficientDiD().fit(efficient_panel, **EFFICIENT_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, efficient_panel): - from diff_diff import EfficientDiD - - with pytest.warns(FutureWarning, match=r"EfficientDiD\.fit\(aggregate=\)"): - EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, aggregate=None) - - def test_balance_e_kwarg_warns_alone(self, efficient_panel): + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, efficient_panel, kw): from diff_diff import EfficientDiD - with pytest.warns(FutureWarning, match=r"EfficientDiD\.fit\(balance_e=\)"): - EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, balance_e=None) + with pytest.raises(TypeError, match=_unexpected(kw)): + EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, **{kw: None}) + value = "event_study" if kw == "aggregate" else 0 + with pytest.raises(TypeError, match=_unexpected(kw)): + EfficientDiD().fit(efficient_panel, **EFFICIENT_KW, **{kw: value}) - def test_joint_supply_warns_once_naming_both(self, efficient_panel): + def test_joint_supply_names_the_first_unexpected_keyword(self, efficient_panel): from diff_diff import EfficientDiD - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") + with pytest.raises(TypeError, match="unexpected keyword argument"): EfficientDiD().fit( efficient_panel, **EFFICIENT_KW, aggregate="event_study", balance_e=0 ) - fw = [w for w in caught if issubclass(w.category, FutureWarning)] - assert len(fw) == 1 - msg = str(fw[0].message) - assert "aggregate=" in msg and "balance_e=" in msg - - def test_unknown_string_still_acts_like_none(self, efficient_panel): - # The legacy path performs NO value validation - an unknown string - # behaves exactly like a plain fit (documented unchanged behavior; - # the post-fit mixin is what fails closed on unknown types). - res = _fit_efficient(efficient_panel, aggregate="nonsense") - plain = _fit_efficient(efficient_panel) - assert res.event_study_effects is None and res.group_effects is None - assert res.overall_att == plain.overall_att - def test_warn_and_still_work(self, efficient_panel, efficient_fitted, efficient_fit_time): - # The deprecated path still populates the fit-time surfaces AND - # equals the plain fit's post-fit recompute. - assert efficient_fit_time.event_study_effects is not None - assert efficient_fit_time.group_effects is not None - es = efficient_fitted.aggregate("event_study") - for e, row in efficient_fit_time.event_study_effects.items(): - i = list(es.event_time).index(e) - np.testing.assert_allclose(row["effect"], es.att[i], rtol=1e-14) + def test_signature_and_seventh_positional_is_survey_design(self): + from diff_diff import EfficientDiD + + params = list(inspect.signature(EfficientDiD.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[7] == "survey_design" + d, _ = _efficient_survey_panel() + sd = _efficient_survey_design() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = EfficientDiD().fit(d, "outcome", "unit", "period", "first_treat", None, sd) + keyword = EfficientDiD().fit(d, survey_design=sd, **EFFICIENT_KW) + assert positional.survey_metadata is not None + assert positional.overall_se == keyword.overall_se class TestEfficientAggregate: - def _assert_es_matches_fit_time(self, res, fit_time, balance_e=None, skip_reference=False): - es = res.aggregate("event_study", balance_e=balance_e) - native = fit_time.event_study_effects - ref_marked = set() - if skip_reference: - ref_marked = {int(e) for e, m in zip(es.event_time, es.is_reference) if m} - assert sorted(int(e) for e in es.event_time) == sorted(native) - for e, row in native.items(): - i = list(es.event_time).index(e) - if int(e) in ref_marked: - # The container's reference normalization NaNs se/t/p/CI/n - # and forces att 0.0 on the marked row - compare the NATIVE - # dict instead (its anchor must be an exact mechanical zero). - assert row["effect"] == 0.0 and row["se"] == 0.0 - assert es.att[i] == 0.0 - assert np.isnan(es.se[i]) and np.isnan(es.n[i]) - continue - np.testing.assert_allclose(row["effect"], es.att[i], rtol=1e-14) - np.testing.assert_allclose(row["se"], es.se[i], rtol=1e-14) - if np.isfinite(row["p_value"]): - np.testing.assert_allclose(row["p_value"], es.p_value[i], rtol=1e-14) - return es - - def test_event_study_inertness(self, efficient_fitted, efficient_fit_time): - self._assert_es_matches_fit_time(efficient_fitted, efficient_fit_time) + def test_event_study_matches_oracle(self, efficient_fitted): + _assert_es_matches_oracle( + efficient_fitted.aggregate("event_study"), + _ed_oracle("default_all"), + **ANALYTIC_TOL, + name="edid", + ) - def test_group_inertness(self, efficient_fitted, efficient_fit_time): + def test_group_matches_oracle(self, efficient_fitted): grp = efficient_fitted.aggregate("group") - for i, g in enumerate(grp.label): - row = efficient_fit_time.group_effects[g] - np.testing.assert_allclose(row["effect"], grp.att[i], rtol=1e-14) - np.testing.assert_allclose(row["se"], grp.se[i], rtol=1e-14) + _assert_group_matches_oracle(grp, _ed_oracle("default_all"), **ANALYTIC_TOL, name="edid") assert grp.n_kind == "cells" assert grp.weight is None def test_simple_relay_bit_exact(self, efficient_fitted): - simple = efficient_fitted.aggregate("simple") - assert simple.att[0] == efficient_fitted.overall_att - assert simple.se[0] == efficient_fitted.overall_se - assert simple.t_stat[0] == efficient_fitted.overall_t_stat - assert simple.p_value[0] == efficient_fitted.overall_p_value - assert simple.conf_int_lower[0] == efficient_fitted.overall_conf_int[0] - assert simple.conf_int_upper[0] == efficient_fitted.overall_conf_int[1] + simple = _assert_simple_relays_stored_overall(efficient_fitted) + _assert_overall_matches_oracle( + efficient_fitted, _ed_oracle("default_all")["overall"], **ANALYTIC_TOL, name="edid" + ) assert simple.target[0] == "att" # Disjoint treated+control total (last_cohort trimming reassigns # BEFORE the counts, so a true total exists - unlike StackedDiD). @@ -2040,26 +2383,71 @@ def test_simple_relay_bit_exact(self, efficient_fitted): # Plain fit: the kit's post-overall df snapshot is None -> NaN df. assert np.isnan(simple.df[0]) + def test_balance_e1_matches_oracle(self, efficient_fitted): + _assert_es_matches_oracle( + efficient_fitted.aggregate("event_study", balance_e=1), + _ed_oracle("default_balance_e1"), + **ANALYTIC_TOL, + name="edid/be1", + ) + @pytest.mark.parametrize("balance_e", [0, 1, 2]) - def test_balance_e_inertness(self, efficient_panel, efficient_fitted, balance_e): - fit_time = _fit_efficient(efficient_panel, aggregate="event_study", balance_e=balance_e) - self._assert_es_matches_fit_time(efficient_fitted, fit_time, balance_e=balance_e) + def test_balance_e_retains_cohorts_present_in_the_anchor_bucket( + self, efficient_fitted, balance_e + ): + """(v) EfficientDiD semantics AS IMPLEMENTED: a cohort is retained iff + it has a finite cell in the truncation BUCKET ``int(t - g) == + balance_e``; every bucket of a retained cohort stays (no lower bound) + and the per-bucket att pools the retained cohorts' cells.""" + es = efficient_fitted.aggregate("event_study", balance_e=balance_e) + gte = efficient_fitted.group_time_effects + retained = { + g for (g, t), c in gte.items() if int(t - g) == balance_e and np.isfinite(c["effect"]) + } + assert retained + buckets = sorted({int(t - g) for (g, t) in gte if g in retained}) + assert sorted(int(e) for e in es.event_time) == buckets + assert min(buckets) < 0 + _assert_att_from_cells( + efficient_fitted, es, bucket=True, retained=retained, name=f"edid/be{balance_e}" + ) + if balance_e == 2: + assert max(gte, key=lambda k: k[0])[0] not in retained - def test_cluster_inertness(self): - d = _efficient_clustered_panel() - plain = _fit_efficient(d, est_kw={"cluster": "cl"}) - fit_time = _fit_efficient(d, est_kw={"cluster": "cl"}, aggregate="all") - self._assert_es_matches_fit_time(plain, fit_time) - grp = plain.aggregate("group") - for i, g in enumerate(grp.label): - np.testing.assert_allclose(fit_time.group_effects[g]["se"], grp.se[i], rtol=1e-14) + def test_point_estimates_from_public_cells(self, efficient_fitted): + """(iii) bucketed cohort-size weighting from group_time_effects.""" + _assert_att_from_cells( + efficient_fitted, efficient_fitted.aggregate("event_study"), bucket=True, name="edid" + ) + + def test_analytic_inference_contract(self, efficient_fitted): + """(iv) on the plain panel (no df) and the group/simple levels.""" + for level in ("event_study", "group", "simple"): + _assert_analytic_inference_contract( + efficient_fitted.aggregate(level), alpha=efficient_fitted.alpha + ) - def test_survey_tsl_inertness_and_df(self): + def test_cluster_matches_oracle(self): + plain = _fit_efficient(_efficient_clustered_panel(), est_kw={"cluster": "cl"}) + orow = _ed_oracle("clustered_all") + _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="edid/cluster" + ) + _assert_group_matches_oracle( + plain.aggregate("group"), orow, **ANALYTIC_TOL, name="edid/cluster" + ) + + def test_survey_tsl_matches_oracle_and_df(self): d, _ = _efficient_survey_panel() sd = _efficient_survey_design() plain = _fit_efficient(d, survey_design=sd) - fit_time = _fit_efficient(d, survey_design=sd, aggregate="all") - es = self._assert_es_matches_fit_time(plain, fit_time) + orow = _ed_oracle("survey_weights_all") + es = _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="edid/tsl" + ) + _assert_group_matches_oracle( + plain.aggregate("group"), orow, **ANALYTIC_TOL, name="edid/tsl" + ) # TSL survey: the post-overall snapshot is the finite survey df and # is provenance-exact on every row. assert es.df_survey == plain._aggregation_kit.bookkeeping["df_survey"] @@ -2068,8 +2456,6 @@ def test_survey_tsl_inertness_and_df(self): grp = plain.aggregate("group") # Non-circular oracle: safe_inference(att, se, df=row.df) reproduces # each stored group row's inference (a stale df would mismatch p). - from diff_diff.utils import safe_inference - for i in range(len(grp.label)): t, p, ci = safe_inference( float(grp.att[i]), @@ -2079,30 +2465,21 @@ def test_survey_tsl_inertness_and_df(self): ) np.testing.assert_allclose(t, grp.t_stat[i], rtol=1e-12) np.testing.assert_allclose(p, grp.p_value[i], rtol=1e-12) + assert _assert_analytic_inference_contract(es, alpha=plain.alpha) > 0 - def test_replicate_inertness_healthy_and_dropped(self): - from diff_diff.utils import safe_inference - + def test_replicate_df_contract_healthy_and_dropped(self): for degenerate, expected_df in ((None, 7.0), ("dropped", 5.0)): d, rep_cols = _efficient_survey_panel(replicate=True, degenerate=degenerate) sd = _efficient_survey_design(rep_cols) plain = _fit_efficient(d, survey_design=sd) - fit_time = _fit_efficient(d, survey_design=sd, aggregate="all") - # Inertness against the SAME object's stored fit-time surface. - self._assert_es_matches_fit_time(plain, fit_time) + es = plain.aggregate("event_study") grp = plain.aggregate("group") - # Cross-FIT df contrast: all group rows share n_valid - 1. + # Cross-FIT df contrast: all rows share n_valid - 1. assert set(np.unique(grp.df[np.isfinite(grp.df)])) <= {expected_df} - for i in range(len(grp.label)): - if not np.isfinite(grp.df[i]): - continue - t, p, ci = safe_inference( - float(grp.att[i]), - float(grp.se[i]), - alpha=plain.alpha, - df=float(grp.df[i]), - ) - np.testing.assert_allclose(p, grp.p_value[i], rtol=1e-12) + es_df = np.asarray(es.df, dtype=float) + assert set(np.unique(es_df[np.isfinite(es_df)])) <= {expected_df} + assert _assert_analytic_inference_contract(grp, alpha=plain.alpha) > 0 + assert _assert_analytic_inference_contract(es, alpha=plain.alpha) > 0 def test_replicate_undefined_df_degenerate(self): # n_valid <= 1: the working df degenerates to None mid-fit. The @@ -2110,31 +2487,40 @@ def test_replicate_undefined_df_degenerate(self): # df_survey resolves to the 0.0 replicate-undefined sentinel (the # shared resolver ladder), NOT the raw survey_metadata value the # is-not-None guard leaves finite - this arm is the discriminator - # for the carrier's metadata copy. + # for the carrier's metadata copy. The per-row df column is NaN + # (the 0 sentinel NaNs the inference triple). d, rep_cols = _efficient_survey_panel(replicate=True, degenerate="undefined") sd = _efficient_survey_design(rep_cols) plain = _fit_efficient(d, survey_design=sd) assert plain._aggregation_kit.bookkeeping["df_survey"] is None es = plain.aggregate("event_study") assert es.df_survey == 0.0 - fit_time = _fit_efficient(d, survey_design=sd, aggregate="all") - self._assert_es_matches_fit_time(plain, fit_time) + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + _assert_analytic_inference_contract(es, alpha=plain.alpha) - def test_pt_post_inertness_and_reference(self, efficient_panel): + def test_pt_post_reference_anchor(self, efficient_panel): plain = _fit_efficient(efficient_panel, est_kw={"pt_assumption": "post"}) - fit_time = _fit_efficient( - efficient_panel, est_kw={"pt_assumption": "post"}, aggregate="all" - ) - es = self._assert_es_matches_fit_time(plain, fit_time, skip_reference=True) + es = plain.aggregate("event_study") # Exactly one is_reference row, at the materialized mechanical anchor. marked = [int(e) for e, m in zip(es.event_time, es.is_reference) if m] assert marked == [-1] - # The NATIVE anchor is an exact mechanical zero BEFORE marking (the - # explicit-reference branch has no value check; post_init would - # silently rewrite a nonzero anchor, so the native pin is the guard). - assert fit_time.event_study_effects[-1]["effect"] == 0.0 - assert fit_time.event_study_effects[-1]["se"] == 0.0 - assert fit_time.event_study_effects[-1]["n_groups"] > 0 + i = list(es.event_time).index(-1) + assert es.att[i] == 0.0 and np.isnan(es.se[i]) and np.isnan(es.n[i]) + # The PUBLIC anchor cells are exact mechanical zeros BEFORE marking + # (the explicit-reference branch has no value check; post_init would + # silently rewrite a nonzero anchor, so the cell pin is the guard). + anchors = [c for (g, t), c in plain.group_time_effects.items() if t == g - 1] + assert anchors and all(c["effect"] == 0.0 and c["se"] == 0.0 for c in anchors) + _assert_att_from_cells(plain, es, bucket=True, name="edid/pt_post") + + def test_legacy_fields_none_after_every_fit(self, efficient_panel, efficient_fitted): + """4.0 PR 3: EfficientDiD never stores a fit-time surface (analytic OR + bootstrapped) - the kit recompute / replay serves both.""" + assert efficient_fitted.event_study_effects is None + assert efficient_fitted.group_effects is None + boot = _fit_efficient(efficient_panel, est_kw={"n_bootstrap": 20, "seed": 1}) + assert boot.event_study_effects is None and boot.group_effects is None + assert boot.event_study_df is None def test_pt_post_absent_anchor_not_synthesized(self): # Single cohort baselined at the panel's first period: the anchor @@ -2249,14 +2635,15 @@ def test_bootstrap_survey_simple_relay_df_nan(self): assert res.survey_metadata is not None _assert_bootstrap_simple_relay(res) - def test_bootstrap_fit_time_group_rows_clear_df_used(self, efficient_panel): - res = _fit_efficient( - efficient_panel, - est_kw={"n_bootstrap": 20, "seed": 1}, - aggregate="group", - ) - for row in res.group_effects.values(): - assert row.get("df_used") is None + def test_bootstrap_group_rows_df_is_nan(self, efficient_panel): + """Percentile inference publishes no analytical df: the replayed + group rows carry a NaN df column (nothing is stored at fit).""" + res = _fit_efficient(efficient_panel, est_kw={"n_bootstrap": 20, "seed": 1}) + assert res.group_effects is None + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + grp = res.aggregate("group") + assert np.all(np.isnan(np.asarray(grp.df, dtype=float))) def test_weights_rejected(self, efficient_fitted): with pytest.raises(ValueError, match="does not accept a weights selector"): @@ -2341,9 +2728,8 @@ def test_zero_row_balance_e_surface(self, efficient_fitted): # EfficientDiD bootstrap REPLAY (the CS BootstrapReplaySpec mechanism): # post-fit aggregate('event_study'/'group') on bootstrapped fits replays the # fit-time multiplier bootstrap from the kit-retained RNG state. The parity -# REFERENCE is always the NATIVE fit-time surface (the kit attaches -# unconditionally, so a fit-time-aggregated result's own aggregate() would -# ALSO replay - replay-vs-replay proves nothing). +# REFERENCE is the pre-removal fit-time surface captured in the oracle file +# (backend-matched: the seeded multiplier stream differs per weight backend). # Tolerances: se/ci/t at 1e-13 (the replayed draws are bit-identical; only # GEMM tile-boundary reassociation differs - ~1 ULP, with headroom for # quantile interpolation); percentile p-values are COUNT statistics, so a @@ -2359,60 +2745,42 @@ def _efficient_boot_fit(data, **fit_kw): return _fit_efficient(data, est_kw={"n_bootstrap": _EDID_NBOOT, "seed": 42, **est_kw}, **fit_kw) -def _efficient_boot_fit_time(data, *, aggregate="all", **fit_kw): - est_kw = fit_kw.pop("est_kw", {}) - return _fit_efficient( - data, - est_kw={"n_bootstrap": _EDID_NBOOT, "seed": 42, **est_kw}, - aggregate=aggregate, - **fit_kw, - ) +def _ed_boot_oracle(arm): + return boot_oracles()["EfficientDiD"][arm] -def _assert_edid_es_replay_parity(es, fit_time, n_boot=_EDID_NBOOT): - df = es.to_dataframe() - assert es.vcov is None - assert np.all(np.isnan(df["df"].to_numpy(dtype=float))) - assert len(df) == len(fit_time.event_study_effects) - p_atol = 2.0 / n_boot - for _, row in df.iterrows(): - ref = fit_time.event_study_effects[int(row["event_time"])] - assert row["att"] == ref["effect"] - np.testing.assert_allclose(row["se"], ref["se"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["t_stat"], ref["t_stat"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["p_value"], ref["p_value"], rtol=1e-13, atol=p_atol) - np.testing.assert_allclose( - [row["conf_int_lower"], row["conf_int_upper"]], - list(ref["conf_int"]), - rtol=1e-13, - atol=1e-13, - ) +def _assert_edid_es_replay_parity(es, orow, n_boot=_EDID_NBOOT): + # EfficientDiD's replay container carries no joint vcov and no sup-t band + # (the kit hard-codes cband=False); percentile inference publishes no df. + assert es.vcov is None and es.vcov_index is None + assert es.cband_lower is None and es.cband_upper is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + return _assert_es_matches_oracle(es, orow, **BOOT_TOL, p_atol=2.0 / n_boot, name="edid/boot") -def _assert_edid_group_replay_parity(grp, fit_time, n_boot=_EDID_NBOOT): - df = grp.to_dataframe() - assert np.all(np.isnan(df["df"].to_numpy(dtype=float))) - assert len(df) == len(fit_time.group_effects) - p_atol = 2.0 / n_boot - for _, row in df.iterrows(): - ref = fit_time.group_effects[float(row["label"])] - assert row["att"] == ref["effect"] - np.testing.assert_allclose(row["se"], ref["se"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["t_stat"], ref["t_stat"], rtol=1e-13, atol=1e-13) - np.testing.assert_allclose(row["p_value"], ref["p_value"], rtol=1e-13, atol=p_atol) - np.testing.assert_allclose( - [row["conf_int_lower"], row["conf_int_upper"]], - list(ref["conf_int"]), - rtol=1e-13, - atol=1e-13, - ) +def _assert_edid_group_replay_parity(grp, orow, n_boot=_EDID_NBOOT): + assert np.all(np.isnan(np.asarray(grp.df, dtype=float))) + return _assert_group_matches_oracle( + grp, orow, **BOOT_TOL, p_atol=2.0 / n_boot, name="edid/boot" + ) + + +def _assert_edid_replay_contract(res, levels=("event_study", "group")): + """Structural replay pins for arms without a captured oracle: NaN df, + no vcov, p in [0, 1], same-fit idempotency.""" + first = {lv: res.aggregate(lv) for lv in levels} + again = {lv: res.aggregate(lv) for lv in levels} + for lv, surface in first.items(): + assert np.all(np.isnan(np.asarray(surface.df, dtype=float))), lv + _assert_bootstrap_p_values_in_unit_interval(surface) + np.testing.assert_array_equal(np.asarray(surface.se), np.asarray(again[lv].se)) + if "event_study" in first: + assert first["event_study"].vcov is None + return first def _efficient_fractional_panel(cohorts=(3.0, 2.25), seed=0, n_units=90): """0.5-spaced panel; cohorts may sit ON the grid or OFF it (e.g. 2.25).""" - import numpy as np - import pandas as pd - rng = np.random.default_rng(seed) periods = np.arange(1.0, 5.0, 0.5) per_cohort = n_units // (len(cohorts) + 2) @@ -2427,20 +2795,48 @@ def _efficient_fractional_panel(cohorts=(3.0, 2.25), seed=0, n_units=90): class TestEfficientBootstrapReplay: - def test_event_study_parity_with_fit_time(self, efficient_panel): + def test_event_study_matches_oracle(self, efficient_panel): res = _efficient_boot_fit(efficient_panel) - ftime = _efficient_boot_fit_time(efficient_panel) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) + _assert_edid_es_replay_parity(res.aggregate("event_study"), _ed_boot_oracle("default_all")) - def test_group_parity_with_fit_time(self, efficient_panel): + def test_group_matches_oracle(self, efficient_panel): res = _efficient_boot_fit(efficient_panel) - ftime = _efficient_boot_fit_time(efficient_panel) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) + _assert_edid_group_replay_parity(res.aggregate("group"), _ed_boot_oracle("default_all")) + + def test_plain_overall_matches_oracle(self, efficient_panel): + res = _efficient_boot_fit(efficient_panel) + _assert_overall_matches_oracle( + res, + _ed_boot_oracle("default_plain_overall"), + **BOOT_TOL, + p_atol=2.0 / _EDID_NBOOT, + name="edid/boot", + ) + _assert_overall_matches_oracle( + res, _ed_boot_oracle("default_all")["overall"], **BOOT_TOL, p_atol=2.0 / _EDID_NBOOT + ) - def test_balance_e_parity_with_fit_time(self, efficient_panel): + def test_same_seed_fits_replay_identically(self, efficient_panel): + """(vi) determinism across two same-seed fits; nothing stored at fit.""" + a = _efficient_boot_fit(efficient_panel) + b = _efficient_boot_fit(efficient_panel) + assert a.event_study_effects is None and a.group_effects is None + _frame_equal( + a.aggregate("event_study").to_dataframe(), b.aggregate("event_study").to_dataframe() + ) + _frame_equal(a.aggregate("group").to_dataframe(), b.aggregate("group").to_dataframe()) + + def test_balance_e_replay_keeps_the_analytic_anchor_rule( + self, efficient_panel, efficient_fitted + ): res = _efficient_boot_fit(efficient_panel) - ftime = _efficient_boot_fit_time(efficient_panel, aggregate="event_study", balance_e=1) - _assert_edid_es_replay_parity(res.aggregate("event_study", balance_e=1), ftime) + es = res.aggregate("event_study", balance_e=1) + ana = efficient_fitted.aggregate("event_study", balance_e=1) + assert list(es.event_time) == list(ana.event_time) + np.testing.assert_array_equal(np.asarray(es.att), np.asarray(ana.att)) + np.testing.assert_array_equal(np.asarray(es.n), np.asarray(ana.n)) + _assert_edid_replay_contract(res, levels=("event_study",)) + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) def test_balance_e_empty_anchor_replay_warns_zero_rows(self, efficient_panel): # The replay re-emits the fit-time anchor warning and returns the @@ -2451,33 +2847,36 @@ def test_balance_e_empty_anchor_replay_warns_zero_rows(self, efficient_panel): es = res.aggregate("event_study", balance_e=99) assert len(es.event_time) == 0 - def test_pt_post_parity_with_fit_time(self, efficient_panel): + def test_pt_post_reference_row_survives_replay(self, efficient_panel): # PT-Post: the bootstrap prep DOES key the finite effect=0.0 anchor # at e=-1 and the override NaNs its inference (zero-SE draws); the - # is_reference marking is label-based and survives. Parity holds - # row-for-row, the anchor included. + # is_reference marking is label-based and survives. res = _efficient_boot_fit(efficient_panel, est_kw={"pt_assumption": "post"}) - ftime = _efficient_boot_fit_time(efficient_panel, est_kw={"pt_assumption": "post"}) es = res.aggregate("event_study") df = es.to_dataframe() anchor = df[df["is_reference"]] assert len(anchor) == 1 assert float(anchor["att"].iloc[0]) == 0.0 assert np.isnan(float(anchor["se"].iloc[0])) - # Non-reference rows hit full parity; the reference row's fit-time - # DICT entry is also NaN'd by the override (same applier), so the - # container/dict split is exercised on the fractional fixture below. - _assert_edid_es_replay_parity(es, ftime) + _assert_edid_replay_contract(res, levels=("event_study",)) + non_ref = df[~df["is_reference"]] + assert np.all(np.isfinite(non_ref["se"].to_numpy(dtype=float))) - def test_covariates_parity_with_fit_time(self, efficient_panel): + def test_covariates_replay_contract(self, efficient_panel): d = efficient_panel.copy() rng = np.random.default_rng(3) xmap = {u: rng.normal() for u in d["unit"].unique()} d["x1"] = d["unit"].map(xmap) res = _efficient_boot_fit(d, covariates=["x1"]) - ftime = _efficient_boot_fit_time(d, covariates=["x1"]) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) + surfaces = _assert_edid_replay_contract(res) + # Same analytical point path as the analytic covariate fit. + ana = _fit_efficient(d, covariates=["x1"]) + np.testing.assert_array_equal( + np.asarray(surfaces["event_study"].att), np.asarray(ana.aggregate("event_study").att) + ) + np.testing.assert_array_equal( + np.asarray(surfaces["group"].att), np.asarray(ana.aggregate("group").att) + ) def test_seedless_fit_replays_and_is_idempotent(self, efficient_panel): res = _fit_efficient(efficient_panel, est_kw={"n_bootstrap": _EDID_NBOOT}) @@ -2499,8 +2898,6 @@ def test_set_params_and_mutation_immunity(self, efficient_panel): np.testing.assert_array_equal(before["se"].to_numpy(), after["se"].to_numpy()) def test_pickle_round_trip_replays(self, efficient_panel): - import pickle - res = _efficient_boot_fit(efficient_panel) before = res.aggregate("group").to_dataframe() res2 = pickle.loads(pickle.dumps(res)) @@ -2523,8 +2920,6 @@ def test_legacy_kit_without_spec_fails_closed(self, efficient_panel): res.aggregate(level) def test_backend_mismatch_fails_closed(self, efficient_panel): - import dataclasses as dc - from diff_diff.bootstrap_chunking import effective_weight_backend res = _efficient_boot_fit(efficient_panel) @@ -2532,13 +2927,13 @@ def test_backend_mismatch_fails_closed(self, efficient_panel): current = effective_weight_backend() other = "numpy" if current == "rust" else "rust" assert kit.bootstrap.backend == current # plain fits stamp the live backend - object.__setattr__(kit, "bootstrap", dc.replace(kit.bootstrap, backend=other)) + object.__setattr__(kit, "bootstrap", dataclasses.replace(kit.bootstrap, backend=other)) for level in ("event_study", "group"): with pytest.raises(NotImplementedError, match="weight backend"): res.aggregate(level) # None (unknown) also fails closed - a permissive default on a # safety discriminator would disarm the guard. - object.__setattr__(kit, "bootstrap", dc.replace(kit.bootstrap, backend=None)) + object.__setattr__(kit, "bootstrap", dataclasses.replace(kit.bootstrap, backend=None)) with pytest.raises(NotImplementedError, match="weight backend"): res.aggregate("event_study") @@ -2549,21 +2944,22 @@ def test_low_bootstrap_warning_re_emitted_on_replay(self, efficient_panel): class TestEfficientBootstrapReplayDesigns: - def test_cluster_parity(self): + def test_cluster_matches_oracle(self): d = _efficient_clustered_panel() res = _efficient_boot_fit(d, est_kw={"cluster": "cl"}) - ftime = _efficient_boot_fit_time(d, est_kw={"cluster": "cl"}) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) + orow = _ed_boot_oracle("clustered_all") + _assert_edid_es_replay_parity(res.aggregate("event_study"), orow) + _assert_edid_group_replay_parity(res.aggregate("group"), orow) - def test_weights_only_survey_parity(self): + def test_weights_only_survey_matches_oracle(self): # Weights-only design: unit weight PATH + survey EIF scaling. d, _ = _efficient_survey_panel() sd = _efficient_survey_design() res = _efficient_boot_fit(d, survey_design=sd) - ftime = _efficient_boot_fit_time(d, survey_design=sd) assert res._aggregation_kit.bootstrap.backend != "portable" - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) + orow = _ed_boot_oracle("survey_weights_all") + _assert_edid_es_replay_parity(res.aggregate("event_study"), orow) + _assert_edid_group_replay_parity(res.aggregate("group"), orow) @staticmethod def _psu_survey_frame(strata=False, fpc=None): @@ -2582,45 +2978,37 @@ def _psu_survey_frame(strata=False, fpc=None): kw["fpc"] = "fpc_col" return d, SurveyDesign(**kw) - def test_stratified_survey_portable_stamp_and_parity(self): + def test_stratified_survey_portable_stamp_and_matches_oracle(self): d, sd = self._psu_survey_frame(strata=True) res = _efficient_boot_fit(d, survey_design=sd) assert res._aggregation_kit.bootstrap.backend == "portable" - ftime = _efficient_boot_fit_time(d, survey_design=sd) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) + orow = _ed_boot_oracle("stratified_psu_survey_all") + _assert_edid_es_replay_parity(res.aggregate("event_study"), orow) + _assert_edid_group_replay_parity(res.aggregate("group"), orow) - def test_fpc_parity(self): + def test_fpc_matches_oracle(self): # Non-census FPC (population 10x the PSU count): fpc_scale on top of # the backend-dependent generator. d, sd = self._psu_survey_frame(fpc=200.0) res = _efficient_boot_fit(d, survey_design=sd) assert res._aggregation_kit.bootstrap.backend != "portable" - ftime = _efficient_boot_fit_time(d, survey_design=sd) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) + orow = _ed_boot_oracle("fpc_survey_all") + _assert_edid_es_replay_parity(res.aggregate("event_study"), orow) + _assert_edid_group_replay_parity(res.aggregate("group"), orow) def test_census_fpc_portable(self): # Census FPC (fpc == n_psu): every weight block is zeroed, so every # bootstrap distribution is EXACTLY CONSTANT at the original effect # - the discarded draws' backend is irrelevant (stamped portable), # and the constant-distribution guard must NaN ALL inference fields - # on BOTH routes (a tiny-positive roundoff np.std at a non-zero - # constant level must never leak a huge finite t - CI review P0). + # (a tiny-positive roundoff np.std at a non-zero constant level must + # never leak a huge finite t - CI review P0). d, sd = self._psu_survey_frame(fpc=20.0) # 120 units // 6 = 20 PSUs with warnings.catch_warnings(): warnings.simplefilter("ignore") res = _efficient_boot_fit(d, survey_design=sd) - ftime = _efficient_boot_fit_time(d, survey_design=sd) assert res._aggregation_kit.bootstrap.backend == "portable" - # Fit-time surfaces: full-NaN inference beside finite effects. - for surface in (ftime.event_study_effects, ftime.group_effects): - assert surface - for row in surface.values(): - assert np.isfinite(row["effect"]) - assert np.isnan(row["se"]) and np.isnan(row["t_stat"]) - assert np.isnan(row["p_value"]) - assert np.isnan(row["conf_int"][0]) and np.isnan(row["conf_int"][1]) - # Replayed surfaces reproduce the same degenerate state. + assert res.event_study_effects is None and res.group_effects is None with warnings.catch_warnings(): warnings.simplefilter("ignore") es_df = res.aggregate("event_study").to_dataframe() @@ -2658,72 +3046,115 @@ def test_single_psu_nan_surfaces_and_warning(self): grp = res.aggregate("group").to_dataframe() assert np.all(np.isnan(grp["se"].to_numpy(dtype=float))) - def test_anticipation_parity(self, efficient_panel): + def test_anticipation_wiring(self, efficient_panel): # Pins the replay host's anticipation wiring (shifts the engine's - # post-treatment mask and the group prep's inclusion rule); a - # defaulted host attribute would pass every anticipation=0 arm. + # post-treatment mask and the group prep's inclusion rule): the + # replayed surface carries the analytic anticipation=1 container's + # labels / point estimates / anticipation, which an anticipation=0 + # host could not reproduce. res = _efficient_boot_fit(efficient_panel, est_kw={"anticipation": 1}) - ftime = _efficient_boot_fit_time(efficient_panel, est_kw={"anticipation": 1}) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) + ana = _fit_efficient(efficient_panel, est_kw={"anticipation": 1}) + surfaces = _assert_edid_replay_contract(res) + es, ana_es = surfaces["event_study"], ana.aggregate("event_study") + assert es.anticipation == ana_es.anticipation == 1 + assert list(es.event_time) == list(ana_es.event_time) + np.testing.assert_array_equal(np.asarray(es.att), np.asarray(ana_es.att)) + np.testing.assert_array_equal( + np.asarray(surfaces["group"].att), np.asarray(ana.aggregate("group").att) + ) + # ...and differs from the anticipation=0 replay on the same draws. + base = _efficient_boot_fit(efficient_panel).aggregate("event_study") + assert list(base.event_time) != list(es.event_time) or not np.array_equal( + np.asarray(base.att), np.asarray(es.att) + ) - def test_alpha_and_mammen_parity(self, efficient_panel): + def test_alpha_and_mammen_wiring(self, efficient_panel): # Pins the host/spec wiring of alpha and weight_type - a host that # hard-codes the defaults passes every other arm. - kw = {"alpha": 0.10, "bootstrap_weights": "mammen"} - res = _efficient_boot_fit(efficient_panel, est_kw=kw) - ftime = _efficient_boot_fit_time(efficient_panel, est_kw=kw) - _assert_edid_es_replay_parity(res.aggregate("event_study"), ftime) - _assert_edid_group_replay_parity(res.aggregate("group"), ftime) - - + res = _efficient_boot_fit( + efficient_panel, est_kw={"alpha": 0.10, "bootstrap_weights": "mammen"} + ) + surfaces = _assert_edid_replay_contract(res) + es = surfaces["event_study"] + assert es.alpha == 0.10 and surfaces["group"].alpha == 0.10 + # alpha wiring: the 90% interval sits inside the 95% one on the SAME + # mammen draws (same seed); weight-type wiring: mammen != rademacher. + wide = _efficient_boot_fit( + efficient_panel, est_kw={"alpha": 0.05, "bootstrap_weights": "mammen"} + ).aggregate("event_study") + rade = _efficient_boot_fit(efficient_panel).aggregate("event_study") + non_ref = ~np.asarray(es.is_reference, dtype=bool) + np.testing.assert_array_equal(np.asarray(es.se)[non_ref], np.asarray(wide.se)[non_ref]) + assert np.all( + np.asarray(es.conf_int_lower)[non_ref] >= np.asarray(wide.conf_int_lower)[non_ref] + ) + assert np.all( + np.asarray(es.conf_int_upper)[non_ref] <= np.asarray(wide.conf_int_upper)[non_ref] + ) + assert not np.allclose(np.asarray(es.se)[non_ref], np.asarray(rade.se)[non_ref]) + + class TestEfficientFractionalPeriods: """Decision-10 pins: int(t - g) truncation-bucketing on fractional panels. - The published fit-time key set is int-bucketed by the ANALYTICAL - aggregator regardless of the bootstrap prep's keying, so the - discriminating surfaces are (a) the prep's own key set and (b) the - off-grid-onset arm, where pre-fix NO raw key intersected the analytical - buckets and the fit-time rows kept analytical inference. + The container's key set is int-bucketed by the ANALYTICAL aggregator and + the bootstrap prep keys the same buckets, so the discriminating surfaces + are (a) the replay's own key set and (b) the off-grid-onset arm, where + pre-fix NO raw key intersected the analytical buckets and the rows kept + analytical inference. """ - def test_prep_keys_match_analytical_buckets_and_warns(self): - from diff_diff import EfficientDiD - + def test_replay_keys_are_analytical_buckets_and_warns(self): d = _efficient_fractional_panel() - # _fit_efficient suppresses warnings, so fit directly to pin the - # fractional-truncation warning alongside the prep-key assertion. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = _efficient_boot_fit(d) + # _fit_efficient suppresses warnings, so aggregate directly to pin the + # fractional-truncation warning alongside the key-set assertion. with pytest.warns(UserWarning, match="bucketed by int"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - res = EfficientDiD(n_bootstrap=_EDID_NBOOT, seed=42).fit( - d, aggregate="all", **EFFICIENT_KW - ) - assert set(res.bootstrap_results.event_study_ses) >= set(res.event_study_effects) - assert all(isinstance(e, int) for e in res.bootstrap_results.event_study_ses) + es = res.aggregate("event_study") + labels = np.asarray(es.event_time, dtype=float) + assert np.all(labels == np.round(labels)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ana = _fit_efficient(d).aggregate("event_study") + assert list(es.event_time) == list(ana.event_time) def test_offgrid_rows_carry_percentile_inference(self): # EVERY treated cohort off-grid: raw t-g is never an integer, so - # pre-fix no override would land and the fit-time rows would keep - # ANALYTICAL inference - the end-to-end discriminator. + # pre-fix no override would land and the rows would keep ANALYTICAL + # inference - the end-to-end discriminator. d = _efficient_fractional_panel(cohorts=(2.25, 3.75)) with warnings.catch_warnings(): warnings.simplefilter("ignore") - boot = _efficient_boot_fit_time(d) - ana = _fit_efficient(d, aggregate="all") - for e, row in boot.event_study_effects.items(): - assert row["se"] != ana.event_study_effects[e]["se"] - - def test_replay_parity_on_fractional_panel(self): + boot = _efficient_boot_fit(d).aggregate("event_study") + ana = _fit_efficient(d).aggregate("event_study") + assert list(boot.event_time) == list(ana.event_time) + non_ref = ~np.asarray(boot.is_reference, dtype=bool) + for b_se, a_se in zip(np.asarray(boot.se)[non_ref], np.asarray(ana.se)[non_ref]): + assert b_se != a_se + + def test_replay_contract_on_fractional_panel(self): d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") res = _efficient_boot_fit(d) - ftime = _efficient_boot_fit_time(d) + again = _efficient_boot_fit(d) + es = _assert_edid_replay_contract(res, levels=("event_study",))["event_study"] + np.testing.assert_array_equal( + np.asarray(es.se), np.asarray(again.aggregate("event_study").se) + ) + + def test_fractional_balance_e1_matches_oracles(self): + d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") - es = res.aggregate("event_study") - _assert_edid_es_replay_parity(es, ftime) + ana = _fit_efficient(d).aggregate("event_study", balance_e=1) + boot = _efficient_boot_fit(d).aggregate("event_study", balance_e=1) + _assert_es_matches_oracle( + ana, _ed_oracle("fractional_balance_e1"), **ANALYTIC_TOL, name="edid/frac/be1" + ) + _assert_edid_es_replay_parity(boot, _ed_boot_oracle("fractional_balance_e1_es")) def test_n_groups_counts_distinct_cohorts(self): # Fractional buckets pool multiple cells per cohort; n_groups must @@ -2731,22 +3162,24 @@ def test_n_groups_counts_distinct_cohorts(self): d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") - res = _fit_efficient(d, aggregate="event_study") - for e, row in res.event_study_effects.items(): - assert row["n_groups"] <= 2, (e, row["n_groups"]) + es = _fit_efficient(d).aggregate("event_study") + for e, n in zip(es.event_time, es.n): + if np.isfinite(n): + assert n <= 2, (e, n) - def test_integer_panel_n_groups_regression(self, efficient_fit_time): + def test_integer_panel_n_groups_regression(self, efficient_fitted): # Identity claim: on integer panels one cell per cohort per bucket, # so distinct-cohort counting equals the old cell count. Pin the # exact values on the standard 2-cohort fixture (cohorts 4 and 6 on # an 8-period panel: both cohorts share buckets -1..1 given cohort # 6's horizons span -5..1, cohort 4's -3..3). + es = efficient_fitted.aggregate("event_study") + gte = efficient_fitted.group_time_effects expected = { - e: len({g for (g, t) in efficient_fit_time.group_time_effects if int(t - g) == e}) - for e in efficient_fit_time.event_study_effects + int(e): len({g for (g, t) in gte if int(t - g) == int(e)}) for e in es.event_time } - for e, row in efficient_fit_time.event_study_effects.items(): - assert row["n_groups"] == expected[e] + for e, n in zip(es.event_time, es.n): + assert n == expected[int(e)] assert max(expected.values()) == 2 # both cohorts pool somewhere def test_integer_panel_never_warns(self, efficient_panel): @@ -2757,18 +3190,29 @@ def test_integer_panel_never_warns(self, efficient_panel): assert not any("bucketed by int" in str(w.message) for w in caught) def test_fractional_balance_e_offgrid_anchor(self): - # Cohort 2.25 reaches the integer anchor bucket 1 only via raw - # horizons 1.25/1.75 - the :403 anchor-filter pin: the bootstrap - # balanced cohort set must match the analytical one. + # (v) Cohort 2.25 reaches the integer anchor bucket 1 only via raw + # horizons 1.25/1.75 - the bucket rule keeps it (the exact-anchor CS + # rule would not), both cohorts are anchored, and the replay's + # balanced cohort set matches the analytical one. d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") - boot = _efficient_boot_fit_time(d, aggregate="event_study", balance_e=1) - ana = _fit_efficient(d, aggregate="event_study", balance_e=1) - assert set(boot.bootstrap_results.event_study_ses) >= set(ana.event_study_effects) - assert ana.event_study_effects[1]["n_groups"] == 2 # both cohorts anchored - for e, row in boot.event_study_effects.items(): - assert row["se"] != ana.event_study_effects[e]["se"] or np.isnan(row["se"]) + plain = _fit_efficient(d) + ana = plain.aggregate("event_study", balance_e=1) + boot = _efficient_boot_fit(d).aggregate("event_study", balance_e=1) + gte = plain.group_time_effects + retained = {g for (g, t), c in gte.items() if int(t - g) == 1 and np.isfinite(c["effect"])} + assert 2.25 in retained and 3.0 in retained + assert not any(t - g == 1 for (g, t) in gte if g == 2.25) # never at the EXACT anchor + i = list(ana.event_time).index(1) + assert ana.n[i] == 2 # both cohorts anchored + assert sorted(int(e) for e in ana.event_time) == sorted( + {int(t - g) for (g, t) in gte if g in retained} + ) + assert list(boot.event_time) == list(ana.event_time) + non_ref = ~np.asarray(boot.is_reference, dtype=bool) + for b_se, a_se in zip(np.asarray(boot.se)[non_ref], np.asarray(ana.se)[non_ref]): + assert b_se != a_se or np.isnan(b_se) def test_bucket_pooled_att_cell_mass_weighting(self): # Hand-computed oracle for the REGISTRY Note's weighting clause: a @@ -2778,46 +3222,49 @@ def test_bucket_pooled_att_cell_mass_weighting(self): d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") - res = _fit_efficient(d, aggregate="event_study") + res = _fit_efficient(d) + es = res.aggregate("event_study") kit = res._aggregation_kit fracs = kit.bookkeeping["cohort_fractions"] gt = kit.bookkeeping["group_time_effects"] - for e, row in res.event_study_effects.items(): + for e, att, is_ref in zip(es.event_time, es.att, es.is_reference): + if is_ref: + continue cells = [ (d_["effect"], fracs.get(g, 0.0)) for (g, t), d_ in gt.items() - if int(t - g) == e and np.isfinite(d_["effect"]) + if int(t - g) == int(e) and np.isfinite(d_["effect"]) ] w = np.array([c[1] for c in cells]) effs = np.array([c[0] for c in cells]) w = w / w.sum() if w.sum() > 0 else np.ones(len(w)) / len(w) - np.testing.assert_allclose(row["effect"], float(np.sum(w * effs)), rtol=1e-12) + np.testing.assert_allclose(att, float(np.sum(w * effs)), rtol=1e-12) def test_fractional_pt_post_reference_collision(self): - # Note clause (iii), PER-ROUTE oracles: reference normalization is - # CONTAINER-only. The bucket at -1-anticipation pools a genuine - # fractional pre-treatment estimate with the mechanical zero anchor; - # the container publishes it as the reference (att=0, NaN - # inference), while the fit-time DICT keeps the pooled effect with - # percentile inference. Parity asserted on NON-reference rows only. + # Note clause (iii): reference normalization is CONTAINER-only. The + # bucket at -1-anticipation pools a genuine fractional pre-treatment + # estimate with the mechanical zero anchor; the container publishes + # it as the reference (att=0, NaN inference) although the PUBLIC + # cells pool to a non-zero estimate there. d = _efficient_fractional_panel() with warnings.catch_warnings(): warnings.simplefilter("ignore") res = _efficient_boot_fit(d, est_kw={"pt_assumption": "post"}) - ftime = _efficient_boot_fit_time(d, est_kw={"pt_assumption": "post"}) es = res.aggregate("event_study") df = es.to_dataframe() anchor = df[df["is_reference"]] assert len(anchor) == 1 and int(anchor["event_time"].iloc[0]) == -1 assert float(anchor["att"].iloc[0]) == 0.0 assert np.isnan(float(anchor["se"].iloc[0])) - # The fit-time DICT entry for the same bucket is NOT rewritten. - dict_row = ftime.event_study_effects[-1] - assert dict_row["effect"] != 0.0 or not np.isnan(dict_row["se"]) - for _, row in df[~df["is_reference"]].iterrows(): - ref = ftime.event_study_effects[int(row["event_time"])] - assert row["att"] == ref["effect"] - np.testing.assert_allclose(row["se"], ref["se"], rtol=1e-13, atol=1e-13) + pooled = [ + c["effect"] + for (g, t), c in res.group_time_effects.items() + if int(t - g) == -1 and np.isfinite(c["effect"]) + ] + assert len(pooled) > 1 and any(v != 0.0 for v in pooled) + non_ref = df[~df["is_reference"]] + assert np.all(np.isfinite(non_ref["se"].to_numpy(dtype=float))) + _assert_bootstrap_p_values_in_unit_interval(es) def test_hausman_pretest_fractional_warns(self): d = _efficient_fractional_panel() @@ -2853,8 +3300,9 @@ def test_hausman_pretest_emits_no_future_warning(self, efficient_panel): # --------------------------------------------------------------------------- # -# ImputationDiD (rows M-021/M-118): fit(aggregate=/balance_e=) shim + the -# PANEL-BACKED recompute aggregate() (kit refs = the _fit_data objects) +# ImputationDiD (rows M-021/M-118): fit(aggregate=/balance_e=) removal pins + the +# PANEL-BACKED recompute aggregate() (kit refs = the _fit_data objects) on +# analytic fits; bootstrapped fits RELAY the fit-time percentile surfaces # --------------------------------------------------------------------------- # IMPUTATION_KW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") @@ -2924,51 +3372,6 @@ def _fit_imputation(data, *, est_kw=None, **fit_kw): return ImputationDiD(**(est_kw or {})).fit(data, **IMPUTATION_KW, **fit_kw) -def _assert_es_container_matches_fit_time(container, fit_time_results, name=""): - """Post-fit EventStudyResults must equal the fit-time-built surface - column-for-column at 1e-14 (incl. reference marking and df provenance).""" - from diff_diff.results_base import build_event_study_surface - - ref = build_event_study_surface(fit_time_results) - da, db = container.to_dataframe(), ref.to_dataframe() - assert list(da.columns) == list(db.columns) - for c in da.columns: - x, y = da[c].to_numpy(), db[c].to_numpy() - if x.dtype.kind in "fc": - np.testing.assert_allclose( - x, y, rtol=0, atol=1e-14, equal_nan=True, err_msg=f"{name}/{c}" - ) - else: - assert (x == y).all(), (name, c) - for attr in ("df_survey", "anticipation", "alpha"): - va, vb = getattr(container, attr), getattr(ref, attr) - same = va == vb or (va is None and vb is None) - try: - same = same or (np.isnan(va) and np.isnan(vb)) - except TypeError: - pass - assert same, (name, attr, va, vb) - - -def _assert_group_matches_fit_time(agg, fit_time_results, name=""): - for i, g in enumerate(agg.label): - row = fit_time_results.group_effects[g] - for field_, key in ( - ("att", "effect"), - ("se", "se"), - ("t_stat", "t_stat"), - ("p_value", "p_value"), - ): - np.testing.assert_allclose( - getattr(agg, field_)[i], - row[key], - rtol=0, - atol=1e-14, - equal_nan=True, - err_msg=f"{name}/{g}/{field_}", - ) - - @pytest.fixture(scope="module") def imputation_panel(): return _imputation_panel() @@ -2980,13 +3383,64 @@ def imputation_fitted(imputation_panel): return _fit_imputation(imputation_panel) -@pytest.fixture(scope="module") -def imputation_fit_time(imputation_panel): - """Deprecated fit-time aggregate="all" - the analytical inertness reference.""" - return _fit_imputation(imputation_panel, aggregate="all") +def _im_oracle(arm): + return load_oracles()["analytic"]["ImputationDiD"][arm] + + +def _im_boot_oracle(arm): + return boot_oracles()["ImputationDiD"][arm] -class TestImputationShim: +_IM_BOOT_KW = {"n_bootstrap": 19, "seed": 1} +_IM_P_ATOL = 2.0 / _IM_BOOT_KW["n_bootstrap"] + + +def _assert_relay_matches_stored_surfaces(res, es, grp, *, has_vcov_fields=True): + """The relay IS the stored fit-time surface: ``aggregate('event_study')`` + equals ``build_event_study_surface(res)`` frame-for-frame and the stored + legacy dict entries row-for-row; ``aggregate('group')`` equals the stored + ``group_effects``; percentile provenance (NaN df, no joint vcov).""" + _frame_equal(es.to_dataframe(), build_event_study_surface(res).to_dataframe()) + assert es.vcov is None and es.vcov_index is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + stored = res.event_study_effects + ref = np.asarray(es.is_reference, dtype=bool) + for i, e in enumerate(es.event_time): + row = stored[int(e)] + if ref[i]: + assert row["effect"] == 0.0 and row.get("n_obs", 0) == 0 + continue + for col, key in ( + ("att", "effect"), + ("se", "se"), + ("t_stat", "t_stat"), + ("p_value", "p_value"), + ): + got, want = float(getattr(es, col)[i]), float(row[key]) + assert got == want or (np.isnan(got) and np.isnan(want)), (e, col) + assert (float(es.conf_int_lower[i]), float(es.conf_int_upper[i])) == tuple( + float(x) for x in row["conf_int"] + ) or np.isnan(row["conf_int"][0]) + assert np.all(np.isnan(np.asarray(grp.df, dtype=float))) + for i, g in enumerate(grp.label): + row = res.group_effects[g] + for col, key in ( + ("att", "effect"), + ("se", "se"), + ("t_stat", "t_stat"), + ("p_value", "p_value"), + ): + got, want = float(getattr(grp, col)[i]), float(row[key]) + assert got == want or (np.isnan(got) and np.isnan(want)), (g, col) + assert row.get("df_used") is None + if has_vcov_fields: + assert res.event_study_vcov is None and res.event_study_vcov_index is None + assert res.event_study_df is None + + +class TestImputationRemoval: + """M-021 / M-118 removed.""" + def test_plain_fit_does_not_warn(self, imputation_panel): from diff_diff import ImputationDiD @@ -2995,166 +3449,201 @@ def test_plain_fit_does_not_warn(self, imputation_panel): ImputationDiD().fit(imputation_panel, **IMPUTATION_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, imputation_panel): + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, imputation_panel, kw): from diff_diff import ImputationDiD - with pytest.warns(FutureWarning, match=r"ImputationDiD\.fit\(aggregate=\)"): - ImputationDiD().fit(imputation_panel, **IMPUTATION_KW, aggregate=None) + with pytest.raises(TypeError, match=_unexpected(kw)): + ImputationDiD().fit(imputation_panel, **IMPUTATION_KW, **{kw: None}) + value = "event_study" if kw == "aggregate" else 0 + with pytest.raises(TypeError, match=_unexpected(kw)): + ImputationDiD().fit(imputation_panel, **IMPUTATION_KW, **{kw: value}) - def test_balance_e_kwarg_warns_alone(self, imputation_panel): + def test_signature_and_seventh_positional_is_survey_design(self): from diff_diff import ImputationDiD - with pytest.warns(FutureWarning, match=r"ImputationDiD\.fit\(balance_e=\)"): - ImputationDiD().fit(imputation_panel, **IMPUTATION_KW, balance_e=None) - - def test_joint_supply_warns_once_naming_both(self, imputation_panel): - from diff_diff import ImputationDiD - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - ImputationDiD().fit( - imputation_panel, **IMPUTATION_KW, aggregate="event_study", balance_e=0 + params = list(inspect.signature(ImputationDiD.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[7] == "survey_design" + d, _ = _imputation_survey_panel() + sd = _imputation_survey_design() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = ImputationDiD().fit( + d, "outcome", "unit", "period", "first_treat", None, sd ) - fw = [w for w in caught if issubclass(w.category, FutureWarning)] - assert len(fw) == 1 - msg = str(fw[0].message) - assert "aggregate=" in msg and "balance_e=" in msg - - def test_unknown_string_still_acts_like_none(self, imputation_panel): - res = _fit_imputation(imputation_panel, aggregate="nonsense") - plain = _fit_imputation(imputation_panel) - assert res.event_study_effects is None and res.group_effects is None - assert res.overall_att == plain.overall_att - - def test_warn_and_still_work(self, imputation_fitted, imputation_fit_time): - assert imputation_fit_time.event_study_effects is not None - assert imputation_fit_time.group_effects is not None - es = imputation_fitted.aggregate("event_study") - for e, row in imputation_fit_time.event_study_effects.items(): - i = list(es.event_time).index(e) - if np.isfinite(row["effect"]) and not es.is_reference[i]: - np.testing.assert_allclose(row["effect"], es.att[i], rtol=1e-14) + keyword = ImputationDiD().fit(d, survey_design=sd, **IMPUTATION_KW) + assert positional.survey_metadata is not None + assert positional.overall_se == keyword.overall_se class TestImputationAggregate: - def test_event_study_inert(self, imputation_fitted, imputation_fit_time): + def test_event_study_matches_oracle(self, imputation_fitted): es = imputation_fitted.aggregate("event_study") - _assert_es_container_matches_fit_time(es, imputation_fit_time, "imp/es") + _assert_es_matches_oracle(es, _im_oracle("default_all"), **ANALYTIC_TOL, name="imp/es") - @pytest.mark.parametrize("balance_e", [0, 1, 2]) - def test_balance_e_inert(self, imputation_panel, imputation_fitted, balance_e): - ref = _fit_imputation(imputation_panel, aggregate="event_study", balance_e=balance_e) - es = imputation_fitted.aggregate("event_study", balance_e=balance_e) - _assert_es_container_matches_fit_time(es, ref, f"imp/es/be{balance_e}") - - def test_group_inert(self, imputation_fitted, imputation_fit_time): - _assert_group_matches_fit_time( - imputation_fitted.aggregate("group"), imputation_fit_time, "imp/gr" + def test_balance_e1_matches_oracle(self, imputation_fitted): + es = imputation_fitted.aggregate("event_study", balance_e=1) + _assert_es_matches_oracle( + es, _im_oracle("default_balance_e1"), **ANALYTIC_TOL, name="imp/es/be1" + ) + + def test_group_matches_oracle(self, imputation_fitted): + _assert_group_matches_oracle( + imputation_fitted.aggregate("group"), + _im_oracle("default_all"), + **ANALYTIC_TOL, + name="imp/gr", ) def test_simple_relay_bit_exact(self, imputation_fitted): - sm = imputation_fitted.aggregate("simple") - assert sm.att[0] == imputation_fitted.overall_att - assert sm.se[0] == imputation_fitted.overall_se - assert sm.t_stat[0] == imputation_fitted.overall_t_stat - assert sm.p_value[0] == imputation_fitted.overall_p_value + sm = _assert_simple_relays_stored_overall(imputation_fitted) + _assert_overall_matches_oracle( + imputation_fitted, _im_oracle("default_all")["overall"], **ANALYTIC_TOL, name="imp" + ) assert sm.n_kind == "obs" assert sm.n[0] == imputation_fitted.n_treated_obs assert np.isnan(sm.to_dataframe()["df"].to_numpy()).all() # non-survey fit - def test_cluster_arm_inert(self): - d = _imputation_clustered_panel() - ref = _fit_imputation(d, est_kw={"cluster": "cl"}, aggregate="all") - plain = _fit_imputation(d, est_kw={"cluster": "cl"}) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "imp/cluster") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "imp/cluster") + def test_post_point_estimates_from_public_tau(self, imputation_fitted): + """(iii) att[e >= 0] is the plain mean of treatment_effects.tau_hat.""" + _assert_post_att_is_tau_mean( + imputation_fitted, imputation_fitted.aggregate("event_study"), name="imp" + ) + + def test_analytic_inference_contract(self, imputation_fitted): + """(iv) NaN-consistency everywhere (the zero-count reference row is + normalized by the container; the plain panel carries no df).""" + for level in ("event_study", "group", "simple"): + _assert_analytic_inference_contract( + imputation_fitted.aggregate(level), alpha=imputation_fitted.alpha + ) + + def test_cluster_arm_matches_oracle(self): + plain = _fit_imputation(_imputation_clustered_panel(), est_kw={"cluster": "cl"}) + orow = _im_oracle("clustered_all") + _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="imp/cluster" + ) + _assert_group_matches_oracle( + plain.aggregate("group"), orow, **ANALYTIC_TOL, name="imp/cluster" + ) - def test_covariate_arm_inert(self): + def test_covariate_arm_point_path(self): # Time-varying covariate: the kit must carry delta_hat / - # kept_cov_mask / the covariate columns; an omission escapes every - # covariate-free arm (local-review P2). + # kept_cov_mask / the covariate columns; the post-fit point path must + # reproduce the PUBLIC imputed tau_hat means, which an omission of + # the covariate adjustment could not (local-review P2). d = _imputation_panel(seed=23).copy() rng = np.random.default_rng(23) d["x1"] = rng.normal(size=len(d)) + 0.1 * d["period"] - ref = _fit_imputation(d, aggregate="all", covariates=["x1"]) plain = _fit_imputation(d, covariates=["x1"]) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "imp/cov") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "imp/cov") + es = plain.aggregate("event_study") + _assert_post_att_is_tau_mean(plain, es, name="imp/cov") + _assert_analytic_inference_contract(es, alpha=plain.alpha) + grp = plain.aggregate("group") + orow = _im_oracle("covariates_all") + _assert_es_matches_oracle(es, orow, **ANALYTIC_TOL, name="imp/cov") + _assert_group_matches_oracle(grp, orow, **ANALYTIC_TOL, name="imp/cov") + te = plain.treatment_effects.merge( + d[["unit", "first_treat"]].drop_duplicates("unit"), on="unit", how="left" + ) + for i, g in enumerate(grp.label): + sel = te.loc[(te["first_treat"] == g) & (te["rel_time"] >= 0), "tau_hat"] + np.testing.assert_allclose(grp.att[i], float(sel[np.isfinite(sel)].mean()), rtol=1e-12) - def test_survey_tsl_arm_inert(self): + def test_survey_tsl_arm_matches_oracle(self): d, _ = _imputation_survey_panel() sd = _imputation_survey_design() - ref = _fit_imputation(d, aggregate="all", survey_design=sd) plain = _fit_imputation(d, survey_design=sd) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "imp/tsl") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "imp/tsl") + orow = _im_oracle("survey_tsl_all") + es = _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="imp/tsl" + ) + _assert_group_matches_oracle(plain.aggregate("group"), orow, **ANALYTIC_TOL, name="imp/tsl") sm = plain.aggregate("simple") df_col = sm.to_dataframe()["df"].to_numpy() assert np.isfinite(df_col).all() # survey df threads the simple row + # (iv) identity leg: finite survey df on every estimable row. + assert _assert_analytic_inference_contract(es, alpha=plain.alpha) > 0 + assert _assert_analytic_inference_contract(plain.aggregate("group"), alpha=plain.alpha) > 0 - @pytest.mark.parametrize("degenerate", [None, "dropped", "undefined"]) + @pytest.mark.parametrize("degenerate", [None, "dropped", "undefined", "cohort_zero"]) def test_replicate_arms_level_matched(self, degenerate): - # LEVEL-MATCHED references: aggregate(L) reproduces fit(aggregate=L), - # not fit(aggregate='all') (the joint replicate stack couples rows). + # LEVEL-MATCHED oracles: aggregate(L) reproduces the pre-removal + # fit(aggregate=L), not fit(aggregate='all') (the joint replicate + # stack couples rows). d, rep_cols = _imputation_survey_panel(replicate=True, degenerate=degenerate) sd = _imputation_survey_design(rep_cols) - ref_es = _fit_imputation(d, aggregate="event_study", survey_design=sd) - ref_gr = _fit_imputation(d, aggregate="group", survey_design=sd) plain = _fit_imputation(d, survey_design=sd) - _assert_es_container_matches_fit_time( - plain.aggregate("event_study"), ref_es, f"imp/rep/{degenerate}" + tag = degenerate or "none" + _assert_es_matches_oracle( + plain.aggregate("event_study"), + _im_oracle(f"replicate_{tag}_event_study"), + **ANALYTIC_TOL, + name=f"imp/rep/{tag}", + ) + _assert_group_matches_oracle( + plain.aggregate("group"), + _im_oracle(f"replicate_{tag}_group"), + **ANALYTIC_TOL, + name=f"imp/rep/{tag}", ) - _assert_group_matches_fit_time(plain.aggregate("group"), ref_gr, f"imp/rep/{degenerate}") sm = plain.aggregate("simple") np.testing.assert_allclose(sm.se[0], plain.overall_se, rtol=0, atol=0, equal_nan=True) + # (iv) the degenerate="undefined" arm's df=0 sentinel NaNs t/p beside + # finite effects - a valid NaN-consistent state. + _assert_analytic_inference_contract(plain.aggregate("event_study"), alpha=plain.alpha) + _assert_analytic_inference_contract(plain.aggregate("group"), alpha=plain.alpha) def test_replicate_undefined_df_sentinel(self): # n_valid <= 1: the working df degenerates; the ES container's - # df_survey resolves to the 0.0 replicate-undefined sentinel on - # BOTH routes (the metadata-copy discriminator). + # df_survey resolves to the 0.0 replicate-undefined sentinel (the + # metadata-copy discriminator) and the per-row df column is NaN. d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="undefined") sd = _imputation_survey_design(rep_cols) plain = _fit_imputation(d, survey_design=sd) es = plain.aggregate("event_study") - ref = _fit_imputation(d, aggregate="event_study", survey_design=sd) - from diff_diff.results_base import build_event_study_surface - - assert es.df_survey == build_event_study_surface(ref).df_survey == 0.0 + assert es.df_survey == 0.0 + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + assert _im_oracle("replicate_undefined_event_study")["event_study_df"] is None def test_replicate_overall_row_migration_delta(self): # The documented migration consequence: the deprecated fit(aggregate=) # coupled the OVERALL row to the joint replicate stack. On the # cohort-zero design (one replicate NaNs one cohort's group target - # while overall stays finite) the joint [overall, groups] stack drops - # a replicate the [overall]-only stack keeps -> plain-fit overall_se - # differs from fit(aggregate='group') overall_se; each surface is - # self-consistent, and post-fit aggregate('group') level-matches the - # deprecated fit(aggregate='group') rows exactly. + # while overall stays finite) the joint [overall, groups] stack + # dropped a replicate the [overall]-only stack keeps -> the plain + # fit's overall_se differs from the pre-removal fit(aggregate='group') + # overall_se (oracle); post-fit aggregate('group') level-matches the + # oracle rows exactly. d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="cohort_zero") sd = _imputation_survey_design(rep_cols) plain = _fit_imputation(d, survey_design=sd) - ref_gr = _fit_imputation(d, aggregate="group", survey_design=sd) - assert plain.overall_se != ref_gr.overall_se - _assert_group_matches_fit_time(plain.aggregate("group"), ref_gr, "imp/delta") - # healthy design: no coupling -> equality - dh, rep_h = _imputation_survey_panel(replicate=True) - sdh = _imputation_survey_design(rep_h) + orow = _im_oracle("replicate_cohort_zero_group") + assert plain.overall_se != oracle_value(orow["overall"]["se"]) + _assert_group_matches_oracle( + plain.aggregate("group"), orow, **ANALYTIC_TOL, name="imp/delta" + ) # Healthy design: no replicate-drop coupling, so the two stacks # agree - but NOT bit-identically: the [overall] vs # [overall, groups] layouts route the replicate-variance matmul - # through different BLAS kernel shapes (~1 ULP reassociation; - # bit-identical on Accelerate, not on OpenBLAS-ARM/Windows). + # through different BLAS kernel shapes (~1 ULP reassociation). # A REAL coupling delta is O(se) itself, far above this band. + dh, rep_h = _imputation_survey_panel(replicate=True) + healthy = _fit_imputation(dh, survey_design=_imputation_survey_design(rep_h)) np.testing.assert_allclose( - _fit_imputation(dh, survey_design=sdh).overall_se, - _fit_imputation(dh, aggregate="group", survey_design=sdh).overall_se, + healthy.overall_se, + oracle_value(_im_oracle("replicate_none_group")["overall"]["se"]), rtol=1e-12, ) - def test_pretrends_arm_inert(self, imputation_panel): - ref = _fit_imputation(imputation_panel, est_kw={"pretrends": True}, aggregate="event_study") + def test_pretrends_arm_matches_oracle(self, imputation_panel): plain = _fit_imputation(imputation_panel, est_kw={"pretrends": True}) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "imp/pretrends") + es = plain.aggregate("event_study") + _assert_es_matches_oracle(es, _im_oracle("pretrends_all"), **ANALYTIC_TOL, name="imp/pre") + assert (np.asarray(es.event_time) < -1).any() # leads present + _assert_post_att_is_tau_mean(plain, es, name="imp/pre") # leads are oracle-only def test_isolation_under_public_field_mutation(self, imputation_panel): res = _fit_imputation(imputation_panel) @@ -3192,14 +3681,213 @@ def test_repeated_and_order_independent(self, imputation_fitted): b = imputation_fitted.aggregate("group").to_dataframe() pd.testing.assert_frame_equal(a, b) - def test_bootstrap_recompute_levels_fail_closed(self, imputation_panel): - res = _fit_imputation(imputation_panel, est_kw={"n_bootstrap": 19, "seed": 1}) + # ---------------- bootstrapped fits: the RELAY (4.0 PR 3) ---------------- + + def test_bootstrap_relays_fit_time_surfaces(self, imputation_panel): + res = _fit_imputation(imputation_panel, est_kw=_IM_BOOT_KW) + assert res.bootstrap_results is not None + es, grp = res.aggregate("event_study"), res.aggregate("group") + _assert_relay_matches_stored_surfaces(res, es, grp, has_vcov_fields=False) + orow = _im_boot_oracle("default_all") + _assert_es_matches_oracle(es, orow, **BOOT_TOL, p_atol=_IM_P_ATOL, name="imp/boot") + _assert_group_matches_oracle(grp, orow, **BOOT_TOL, p_atol=_IM_P_ATOL, name="imp/boot") + _assert_overall_matches_oracle( + res, _im_boot_oracle("default_plain_overall"), **BOOT_TOL, p_atol=_IM_P_ATOL + ) + _assert_overall_matches_oracle(res, orow["overall"], **BOOT_TOL, p_atol=_IM_P_ATOL) + _assert_bootstrap_p_values_in_unit_interval(es) + with pytest.raises(NotImplementedError, match="balance_e"): + res.aggregate("event_study", balance_e=1) + _assert_bootstrap_simple_relay(res) + + def test_bootstrap_pretrends_relay_matches_oracle(self, imputation_panel): + res = _fit_imputation(imputation_panel, est_kw={**_IM_BOOT_KW, "pretrends": True}) + es = res.aggregate("event_study") + _assert_es_matches_oracle( + es, _im_boot_oracle("pretrends_all"), **BOOT_TOL, p_atol=_IM_P_ATOL, name="imp/boot/pre" + ) + # Lead rows ride the lead regression (analytical) but the relay still + # publishes NO df (identical to the pre-removal bootstrapped surface). + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + _assert_overall_matches_oracle( + res, _im_boot_oracle("pretrends_plain_overall"), **BOOT_TOL, p_atol=_IM_P_ATOL + ) + + def test_bootstrap_survey_tsl_relay_matches_oracle(self): + d, _ = _imputation_survey_panel() + res = _fit_imputation(d, est_kw=_IM_BOOT_KW, survey_design=_imputation_survey_design()) + orow = _im_boot_oracle("survey_tsl_all") + es = res.aggregate("event_study") + _assert_es_matches_oracle(es, orow, **BOOT_TOL, p_atol=_IM_P_ATOL, name="imp/boot/tsl") + _assert_group_matches_oracle( + res.aggregate("group"), orow, **BOOT_TOL, p_atol=_IM_P_ATOL, name="imp/boot/tsl" + ) + _assert_overall_matches_oracle( + res, _im_boot_oracle("survey_tsl_plain_overall"), **BOOT_TOL, p_atol=_IM_P_ATOL + ) + assert es.df_survey == res.survey_metadata.df_survey + + def test_legacy_fields_populated_iff_bootstrapped(self, imputation_panel, imputation_fitted): + assert imputation_fitted.event_study_effects is None + assert imputation_fitted.group_effects is None + boot = _fit_imputation(imputation_panel, est_kw=_IM_BOOT_KW) + assert boot.event_study_effects and boot.group_effects + assert boot.event_study_df is None + + def test_pre_4_0_pickle_without_surface_raises(self, imputation_panel): + """A 3.x bootstrapped plain fit stored no surface: the relay refuses + with the refit message instead of recomputing analytically.""" + res = _fit_imputation(imputation_panel, est_kw=_IM_BOOT_KW) + legacy = dataclasses.replace(res, event_study_effects=None, group_effects=None) + with pytest.raises(ValueError, match="carries no stored event_study surface"): + legacy.aggregate("event_study") + with pytest.raises(ValueError, match="carries no stored group surface"): + legacy.aggregate("group") + _assert_bootstrap_simple_relay(legacy) # the scalar relays still work + + def test_relay_is_a_view_of_the_public_field(self, imputation_panel): + """The relay payload reads the MUTABLE public field by definition + (StackedDiD precedent); only alpha/anticipation/survey metadata come + from kit snapshots.""" + res = _fit_imputation(imputation_panel, est_kw=_IM_BOOT_KW) + res.event_study_effects[0]["effect"] = 123.0 + es = res.aggregate("event_study") + assert float(es.att[list(es.event_time).index(0)]) == 123.0 + object.__setattr__(res, "alpha", 0.5) + assert res.aggregate("event_study").alpha == 0.05 + + # ---------------- fail-soft materialization (failure injection) ---------------- + + @staticmethod + def _boot_est(**extra): + from diff_diff import ImputationDiD + + return ImputationDiD(**_IM_BOOT_KW, **extra) + + def _assert_fail_soft_outcome(self, res, panel_kw): + """Surfaces dropped, bootstrap intact: the overall quintet is the + backend-matched PLAIN-fit oracle and the relay refuses.""" + assert res.event_study_effects is None and res.group_effects is None + assert res.bootstrap_results is not None + _assert_overall_matches_oracle( + res, _im_boot_oracle("default_plain_overall"), **BOOT_TOL, p_atol=_IM_P_ATOL + ) for level in ("event_study", "group"): - with pytest.raises(NotImplementedError, match="bootstrap") as exc: + with pytest.raises(ValueError, match="could not be materialized"): res.aggregate(level) - assert "aggregate('simple') and, where supported, aggregate('total') relay" in str( - exc.value + _assert_bootstrap_simple_relay(res) + + def test_failsoft_a_surface_hook_failure(self, imputation_panel, monkeypatch): + from diff_diff import ImputationDiD + + def boom(self, *a, **k): + raise RuntimeError("injected surface failure") + + monkeypatch.setattr(ImputationDiD, "_aggregate_event_study", boom) + with pytest.warns(UserWarning, match="could not be materialized") as rec: + res = self._boot_est().fit(imputation_panel, **IMPUTATION_KW) + assert any("injected surface failure" in str(w.message) for w in rec) + self._assert_fail_soft_outcome(res, IMPUTATION_KW) + + def test_failsoft_b_psi_stage_failure_retries_overall_only(self, imputation_panel, monkeypatch): + from diff_diff import ImputationDiD + + orig = ImputationDiD._precompute_bootstrap_psi + calls = [] + + def flaky(self, *a, **k): + calls.append(k.get("event_study_effects") is not None) + if k.get("event_study_effects") is not None: + raise RuntimeError("injected psi failure") + return orig(self, *a, **k) + + monkeypatch.setattr(ImputationDiD, "_precompute_bootstrap_psi", flaky) + with pytest.warns(UserWarning, match="could not be materialized"): + res = self._boot_est().fit(imputation_panel, **IMPUTATION_KW) + assert calls == [True, False] # attempt 1 with surfaces, ONE retry without + self._assert_fail_soft_outcome(res, IMPUTATION_KW) + + def test_failsoft_b2_total_psi_failure_falls_back_analytical( + self, imputation_panel, monkeypatch + ): + from diff_diff import ImputationDiD + + def boom(self, *a, **k): + raise RuntimeError("injected total psi failure") + + monkeypatch.setattr(ImputationDiD, "_precompute_bootstrap_psi", boom) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = self._boot_est().fit(imputation_panel, **IMPUTATION_KW) + msgs = [str(w.message) for w in caught] + assert any("Bootstrap pre-computation failed" in m for m in msgs) # today's fallback + assert not any("could not be materialized" in m for m in msgs) # no "unavailable" claim + assert res.bootstrap_results is None + assert res.event_study_effects is None and res.group_effects is None # storage rule + analytic = _fit_imputation(imputation_panel) + assert res.overall_se == analytic.overall_se # analytical overall + # ...and aggregate() RECOMPUTES analytically (no raise). + _frame_equal( + res.aggregate("event_study").to_dataframe(), + analytic.aggregate("event_study").to_dataframe(), + ) + _frame_equal( + res.aggregate("group").to_dataframe(), analytic.aggregate("group").to_dataframe() + ) + + def test_failsoft_b2_run_bootstrap_failure_on_retry_propagates( + self, imputation_panel, monkeypatch + ): + from diff_diff import ImputationDiD + + def boom(self, *a, **k): + raise RuntimeError("injected engine failure") + + monkeypatch.setattr(ImputationDiD, "_run_bootstrap", boom) + with pytest.raises(RuntimeError, match="injected engine failure"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + self._boot_est().fit(imputation_panel, **IMPUTATION_KW) + + def test_failsoft_b3_n_valid_zero_bootstrapped_fit_recomputes(self): + """Storage-rule corner: every treated cell unidentified (no untreated + observations in any post period) -> n_valid == 0 skips the bootstrap + stage, the surfaces are None and aggregate() recomputes analytically.""" + rng = np.random.default_rng(0) + rows = [ + {"unit": u, "period": t, "first_treat": 4, "outcome": rng.normal() + 0.1 * t} + for u in range(30) + for t in range(1, 7) + ] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = self._boot_est().fit(pd.DataFrame(rows), **IMPUTATION_KW) + assert np.isnan(res.overall_att) + assert res.bootstrap_results is None + assert res.event_study_effects is None and res.group_effects is None + assert not any("could not be materialized" in str(w.message) for w in caught) + es = res.aggregate("event_study") # recompute, no raise + assert len(es.event_time) > 0 + + def test_failsoft_c_real_trigger_lead_collinear_covariate(self, imputation_panel): + """The real trigger: pretrends=True + rank_deficient_action='error' + + a covariate equal to the lead-1 indicator makes the lead regression + raise ValueError (which the engine's (IndexError, LinAlgError) catch + does not cover). The plain fit SUCCEEDS with the fail-soft warning + (the pre-removal fit(aggregate='event_study') aborted).""" + from diff_diff import ImputationDiD + + d = imputation_panel.copy() + d["lead1"] = ((d["first_treat"] > 0) & (d["period"] == d["first_treat"] - 1)).astype(float) + with pytest.warns(UserWarning, match="could not be materialized"): + res = ImputationDiD(pretrends=True, rank_deficient_action="error", **_IM_BOOT_KW).fit( + d, covariates=["lead1"], **IMPUTATION_KW ) + assert res.bootstrap_results is not None + assert res.event_study_effects is None and res.group_effects is None + assert np.isfinite(res.overall_att) and np.isfinite(res.overall_se) + with pytest.raises(ValueError, match="could not be materialized"): + res.aggregate("event_study") def test_bootstrap_simple_relays_stored_quintet(self, imputation_panel): res = _fit_imputation(imputation_panel, est_kw={"n_bootstrap": 19, "seed": 1}) @@ -3272,14 +3960,15 @@ def test_group_df_used_relay(self): assert np.isnan(plain2.aggregate("group").to_dataframe()["df"].to_numpy()).all() def test_bootstrap_group_rows_clear_df_used(self, imputation_panel): - # Fit-time bootstrap override must never publish an analytical df - # beside percentile inference (public group_effects row dicts). - res = _fit_imputation( - imputation_panel, est_kw={"n_bootstrap": 19, "seed": 1}, aggregate="group" - ) + # The fit-time bootstrap override must never publish an analytical df + # beside percentile inference - on the STORED group_effects rows of a + # bootstrapped plain fit (populated since 4.0) and on the relay. + res = _fit_imputation(imputation_panel, est_kw=_IM_BOOT_KW) + assert res.group_effects for row in res.group_effects.values(): if np.isfinite(row["effect"]): assert row.get("df_used") is None + assert np.all(np.isnan(np.asarray(res.aggregate("group").df, dtype=float))) def test_empty_balance_window(self, imputation_fitted): with pytest.warns(UserWarning, match="no horizons"): @@ -3289,8 +3978,9 @@ def test_empty_balance_window(self, imputation_fitted): # --------------------------------------------------------------------------- # -# TwoStageDiD (rows M-022/M-119): fit(aggregate=/balance_e=) shim + the -# PANEL-BACKED recompute aggregate() (column-subset working-frame kit) +# TwoStageDiD (rows M-022/M-119): fit(aggregate=/balance_e=) removal pins + the +# PANEL-BACKED recompute aggregate() (column-subset working-frame kit) on +# analytic fits; bootstrapped fits RELAY the fit-time percentile surfaces # --------------------------------------------------------------------------- # TWOSTAGE_KW = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") @@ -3378,12 +4068,21 @@ def twostage_fitted(twostage_panel): return _fit_twostage(twostage_panel) -@pytest.fixture(scope="module") -def twostage_fit_time(twostage_panel): - return _fit_twostage(twostage_panel, aggregate="all") +def _ts_oracle(arm): + return load_oracles()["analytic"]["TwoStageDiD"][arm] + + +def _ts_boot_oracle(arm): + return boot_oracles()["TwoStageDiD"][arm] -class TestTwoStageShim: +_TS_BOOT_KW = {"n_bootstrap": 19, "seed": 1} +_TS_P_ATOL = 2.0 / _TS_BOOT_KW["n_bootstrap"] + + +class TestTwoStageRemoval: + """M-022 / M-119 removed.""" + def test_plain_fit_does_not_warn(self, twostage_panel): from diff_diff import TwoStageDiD @@ -3392,79 +4091,84 @@ def test_plain_fit_does_not_warn(self, twostage_panel): TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, twostage_panel): - from diff_diff import TwoStageDiD - - with pytest.warns(FutureWarning, match=r"TwoStageDiD\.fit\(aggregate=\)"): - TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW, aggregate=None) - - def test_balance_e_kwarg_warns_alone(self, twostage_panel): + @pytest.mark.parametrize("kw", ["aggregate", "balance_e"]) + def test_removed_keyword_is_a_type_error(self, twostage_panel, kw): from diff_diff import TwoStageDiD - with pytest.warns(FutureWarning, match=r"TwoStageDiD\.fit\(balance_e=\)"): - TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW, balance_e=None) + with pytest.raises(TypeError, match=_unexpected(kw)): + TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW, **{kw: None}) + value = "event_study" if kw == "aggregate" else 0 + with pytest.raises(TypeError, match=_unexpected(kw)): + TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW, **{kw: value}) - def test_joint_supply_warns_once_naming_both(self, twostage_panel): + def test_signature_and_seventh_positional_is_survey_design(self): from diff_diff import TwoStageDiD - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - TwoStageDiD().fit(twostage_panel, **TWOSTAGE_KW, aggregate="event_study", balance_e=0) - fw = [w for w in caught if issubclass(w.category, FutureWarning)] - assert len(fw) == 1 - msg = str(fw[0].message) - assert "aggregate=" in msg and "balance_e=" in msg - - def test_unknown_string_still_acts_like_none(self, twostage_panel): - res = _fit_twostage(twostage_panel, aggregate="nonsense") - plain = _fit_twostage(twostage_panel) - assert res.event_study_effects is None and res.group_effects is None - assert res.overall_att == plain.overall_att - - def test_warn_and_still_work(self, twostage_fitted, twostage_fit_time): - assert twostage_fit_time.event_study_effects is not None - assert twostage_fit_time.group_effects is not None - es = twostage_fitted.aggregate("event_study") - for e, row in twostage_fit_time.event_study_effects.items(): - i = list(es.event_time).index(e) - if np.isfinite(row["effect"]) and not es.is_reference[i]: - np.testing.assert_allclose(row["effect"], es.att[i], rtol=1e-14) + params = list(inspect.signature(TwoStageDiD.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[7] == "survey_design" + d, _ = _twostage_survey_panel() + sd = _twostage_survey_design() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = TwoStageDiD().fit(d, "outcome", "unit", "period", "first_treat", None, sd) + keyword = TwoStageDiD().fit(d, survey_design=sd, **TWOSTAGE_KW) + assert positional.survey_metadata is not None + assert positional.overall_se == keyword.overall_se class TestTwoStageAggregate: - def test_event_study_inert(self, twostage_fitted, twostage_fit_time): + def test_event_study_matches_oracle(self, twostage_fitted): es = twostage_fitted.aggregate("event_study") - _assert_es_container_matches_fit_time(es, twostage_fit_time, "ts/es") + _assert_es_matches_oracle(es, _ts_oracle("default_all"), **ANALYTIC_TOL, name="ts/es") - @pytest.mark.parametrize("balance_e", [0, 1, 2]) - def test_balance_e_inert(self, twostage_panel, twostage_fitted, balance_e): - ref = _fit_twostage(twostage_panel, aggregate="event_study", balance_e=balance_e) - es = twostage_fitted.aggregate("event_study", balance_e=balance_e) - _assert_es_container_matches_fit_time(es, ref, f"ts/es/be{balance_e}") - - def test_group_inert(self, twostage_fitted, twostage_fit_time): - _assert_group_matches_fit_time( - twostage_fitted.aggregate("group"), twostage_fit_time, "ts/gr" + def test_balance_e1_matches_oracle(self, twostage_fitted): + es = twostage_fitted.aggregate("event_study", balance_e=1) + _assert_es_matches_oracle( + es, _ts_oracle("default_balance_e1"), **ANALYTIC_TOL, name="ts/es/be1" + ) + + def test_group_matches_oracle(self, twostage_fitted): + _assert_group_matches_oracle( + twostage_fitted.aggregate("group"), _ts_oracle("default_all"), **ANALYTIC_TOL, name="ts" ) def test_simple_relay_bit_exact(self, twostage_fitted): - sm = twostage_fitted.aggregate("simple") - assert sm.att[0] == twostage_fitted.overall_att - assert sm.se[0] == twostage_fitted.overall_se + sm = _assert_simple_relays_stored_overall(twostage_fitted) + _assert_overall_matches_oracle( + twostage_fitted, _ts_oracle("default_all")["overall"], **ANALYTIC_TOL, name="ts" + ) assert sm.n_kind == "obs" assert sm.n[0] == twostage_fitted.n_treated_obs assert np.isnan(sm.to_dataframe()["df"].to_numpy()).all() - def test_m092_vcov_parity_analytical(self, twostage_fitted, twostage_panel): - # The post-fit container threads the recomputed joint GMM vcov + - # index + df exactly as the level-matched fit-time container does. - from diff_diff.results_base import build_event_study_surface + def test_post_point_estimates_from_public_tau(self, twostage_fitted): + """(iii) att[e >= 0] is the plain mean of treatment_effects.tau_hat + (the Stage-2 OLS on horizon dummies IS that mean).""" + _assert_post_att_is_tau_mean( + twostage_fitted, twostage_fitted.aggregate("event_study"), name="ts" + ) + + def test_analytic_inference_contract(self, twostage_fitted): + for level in ("event_study", "group", "simple"): + _assert_analytic_inference_contract( + twostage_fitted.aggregate(level), alpha=twostage_fitted.alpha + ) - ref = build_event_study_surface(_fit_twostage(twostage_panel, aggregate="event_study")) + def test_m092_vcov_matches_oracle(self, twostage_fitted): + # The post-fit container threads the recomputed joint GMM vcov + + # index exactly as the pre-removal fit-time container did. es = twostage_fitted.aggregate("event_study") - assert es.vcov is not None and ref.vcov is not None - np.testing.assert_allclose(es.vcov, ref.vcov, rtol=0, atol=1e-14, equal_nan=True) - assert list(es.vcov_index) == list(ref.vcov_index) + orow = _ts_oracle("default_all") + assert es.vcov is not None and orow["event_study_vcov"] is not None + assert [float(x) for x in es.vcov_index] == orow["event_study_vcov_index"] + want = np.array([[oracle_value(x) for x in r] for r in orow["event_study_vcov"]]) + np.testing.assert_allclose(np.asarray(es.vcov), want, **ANALYTIC_TOL, equal_nan=True) + # The ES diagonal is the per-horizon variance. + non_ref = ~np.asarray(es.is_reference, dtype=bool) + np.testing.assert_allclose( + np.diag(np.asarray(es.vcov)), np.asarray(es.se)[non_ref] ** 2, rtol=1e-12 + ) def test_m092_vcov_cleared_on_replicate(self): d, rep_cols = _twostage_survey_panel(replicate=True) @@ -3472,97 +4176,146 @@ def test_m092_vcov_cleared_on_replicate(self): plain = _fit_twostage(d, survey_design=sd) es = plain.aggregate("event_study") assert es.vcov is None and es.vcov_index is None - # df provenance still threads (level-matched replayed value) - ref = _fit_twostage(d, aggregate="event_study", survey_design=sd) - from diff_diff.results_base import build_event_study_surface - - ref_surface = build_event_study_surface(ref) - assert (es.df_survey == ref_surface.df_survey) or ( - es.df_survey is None and ref_surface.df_survey is None - ) + # df provenance still threads (the level-matched replayed value). + orow = _ts_oracle("replicate_none_event_study") + assert orow["event_study_vcov"] is None + assert es.df_survey == float(orow["event_study_df"]) - def test_cluster_arm_inert(self): + def test_cluster_arm_point_path(self): d = _twostage_panel(seed=11).copy() d["cl"] = (d["unit"] // 3).astype(int) - ref = _fit_twostage(d, est_kw={"cluster": "cl"}, aggregate="all") plain = _fit_twostage(d, est_kw={"cluster": "cl"}) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "ts/cl") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "ts/cl") - - def test_cluster_naming_unit_column_inert(self, twostage_panel): + es = plain.aggregate("event_study") + _assert_post_att_is_tau_mean(plain, es, name="ts/cl") + _assert_analytic_inference_contract(es, alpha=plain.alpha) + _assert_analytic_inference_contract(plain.aggregate("group"), alpha=plain.alpha) + # Clustering changes the variance, never the point path. + unclustered = _fit_twostage(d).aggregate("event_study") + np.testing.assert_array_equal(np.asarray(es.att), np.asarray(unclustered.att)) + assert not np.array_equal(np.asarray(es.se), np.asarray(unclustered.se)) + + def test_cluster_naming_unit_column_inert(self, twostage_panel, twostage_fitted): # cluster= legally names the unit column - the kit's column-subset # dedup must keep the frame single-labeled (a duplicated column - # would break df[unit].map in the moved Stage-1 helpers). - ref = _fit_twostage(twostage_panel, est_kw={"cluster": "unit"}, aggregate="all") + # would break df[unit].map in the moved Stage-1 helpers). The + # default fit clusters by unit too, so the surfaces are identical. plain = _fit_twostage(twostage_panel, est_kw={"cluster": "unit"}) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "ts/cl-unit") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "ts/cl-unit") + _frame_equal( + plain.aggregate("event_study").to_dataframe(), + twostage_fitted.aggregate("event_study").to_dataframe(), + ) + _frame_equal( + plain.aggregate("group").to_dataframe(), + twostage_fitted.aggregate("group").to_dataframe(), + ) - def test_covariate_arm_inert(self): + def test_covariate_arm_point_path(self): d = _twostage_panel(seed=23).copy() rng = np.random.default_rng(23) d["x1"] = rng.normal(size=len(d)) + 0.1 * d["period"] - ref = _fit_twostage(d, aggregate="all", covariates=["x1"]) plain = _fit_twostage(d, covariates=["x1"]) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "ts/cov") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "ts/cov") - - def test_prop5_arm_inert(self): + es = plain.aggregate("event_study") + _assert_post_att_is_tau_mean(plain, es, name="ts/cov") + _assert_analytic_inference_contract(es, alpha=plain.alpha) + orow = _ts_oracle("covariates_all") + _assert_es_matches_oracle(es, orow, **ANALYTIC_TOL, name="ts/cov") + _assert_group_matches_oracle(plain.aggregate("group"), orow, **ANALYTIC_TOL, name="ts/cov") + # The covariate adjustment moves the imputed tau_hat (and so the att). + bare = _fit_twostage(d).aggregate("event_study") + assert not np.array_equal(np.asarray(es.att), np.asarray(bare.att)) + + def test_prop5_arm_matches_oracle(self): d = _twostage_prop5_panel() - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - ref = _fit_twostage(d, aggregate="event_study") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") plain = _fit_twostage(d) es = plain.aggregate("event_study") - _assert_es_container_matches_fit_time(es, ref, "ts/prop5") + assert any("Proposition 5" in str(w.message) for w in caught) + _assert_es_matches_oracle(es, _ts_oracle("prop5_all"), **ANALYTIC_TOL, name="ts/prop5") + # (iv) a NaN effect with n > 0 is valid (Prop-5 unidentified horizons). + frame = es.to_dataframe() + nan_rows = frame[~np.isfinite(frame["att"]) & ~frame["is_reference"]] + assert len(nan_rows) > 0 and (nan_rows["n"] > 0).all() + _assert_analytic_inference_contract(es, alpha=plain.alpha) - def test_survey_tsl_arm_inert(self): + def test_survey_tsl_arm_matches_oracle(self): + d, _ = _twostage_survey_panel() + plain = _fit_twostage(d, survey_design=_twostage_survey_design()) + orow = _ts_oracle("survey_tsl_all") + _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="ts/tsl" + ) + _assert_group_matches_oracle(plain.aggregate("group"), orow, **ANALYTIC_TOL, name="ts/tsl") + + def test_survey_psu_arm_matches_oracle_and_df(self): d, _ = _twostage_survey_panel() sd = _twostage_survey_design(psu=True) - ref = _fit_twostage(d, aggregate="all", survey_design=sd) plain = _fit_twostage(d, survey_design=sd) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "ts/tsl") - _assert_group_matches_fit_time(plain.aggregate("group"), ref, "ts/tsl") + orow = _ts_oracle("survey_psu_all") + es = _assert_es_matches_oracle( + plain.aggregate("event_study"), orow, **ANALYTIC_TOL, name="ts/psu" + ) gr = plain.aggregate("group") + _assert_group_matches_oracle(gr, orow, **ANALYTIC_TOL, name="ts/psu") df_col = gr.to_dataframe()["df"].to_numpy() finite_p = np.isfinite(gr.p_value) assert np.isfinite(df_col[finite_p]).all() # scalar survey-df broadcast + assert _assert_analytic_inference_contract(es, alpha=plain.alpha) > 0 + assert _assert_analytic_inference_contract(gr, alpha=plain.alpha) > 0 - def test_always_treated_pad_arm_inert(self): + def test_always_treated_pad_arm_matches_oracle(self): # The only arm where score_pad_mask/cluster_ids_full are non-None: # an implementation storing None unconditionally passes every other - # arm but breaks inertness here. + # arm but breaks parity here. d = _twostage_always_treated_panel() - sd = _twostage_survey_design(psu=True) with warnings.catch_warnings(): warnings.simplefilter("ignore") - ref = _fit_twostage(d, aggregate="all", survey_design=sd) - plain = _fit_twostage(d, survey_design=sd) + plain = _fit_twostage(d, survey_design=_twostage_survey_design()) es = plain.aggregate("event_study") gr = plain.aggregate("group") - _assert_es_container_matches_fit_time(es, ref, "ts/pad") - _assert_group_matches_fit_time(gr, ref, "ts/pad") - - @pytest.mark.parametrize("degenerate", [None, "dropped", "undefined"]) + orow = _ts_oracle("always_treated_survey_all") + _assert_es_matches_oracle(es, orow, **ANALYTIC_TOL, name="ts/pad") + _assert_group_matches_oracle(gr, orow, **ANALYTIC_TOL, name="ts/pad") + # The PSU design is the shape the 3.x test used (score_pad_mask + + # cluster_ids_full both non-None at once). + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + plain_psu = _fit_twostage(d, survey_design=_twostage_survey_design(psu=True)) + es_psu = plain_psu.aggregate("event_study") + gr_psu = plain_psu.aggregate("group") + orow_psu = _ts_oracle("always_treated_survey_psu_all") + _assert_es_matches_oracle(es_psu, orow_psu, **ANALYTIC_TOL, name="ts/pad-psu") + _assert_group_matches_oracle(gr_psu, orow_psu, **ANALYTIC_TOL, name="ts/pad-psu") + + @pytest.mark.parametrize("degenerate", [None, "dropped", "undefined", "cohort_zero"]) def test_replicate_arms_level_matched(self, degenerate): d, rep_cols = _twostage_survey_panel(replicate=True, degenerate=degenerate) sd = _twostage_survey_design(rep_cols) - ref_es = _fit_twostage(d, aggregate="event_study", survey_design=sd) - ref_gr = _fit_twostage(d, aggregate="group", survey_design=sd) plain = _fit_twostage(d, survey_design=sd) - _assert_es_container_matches_fit_time( - plain.aggregate("event_study"), ref_es, f"ts/rep/{degenerate}" + tag = degenerate or "none" + _assert_es_matches_oracle( + plain.aggregate("event_study"), + _ts_oracle(f"replicate_{tag}_event_study"), + **ANALYTIC_TOL, + name=f"ts/rep/{tag}", + ) + _assert_group_matches_oracle( + plain.aggregate("group"), + _ts_oracle(f"replicate_{tag}_group"), + **ANALYTIC_TOL, + name=f"ts/rep/{tag}", ) - _assert_group_matches_fit_time(plain.aggregate("group"), ref_gr, f"ts/rep/{degenerate}") + _assert_analytic_inference_contract(plain.aggregate("event_study"), alpha=plain.alpha) + _assert_analytic_inference_contract(plain.aggregate("group"), alpha=plain.alpha) def test_all_prop5_horizons_survive_early_return(self): # Local-review P1 (pre-existing corner in the verbatim-moved code, # fixed in this PR): when EVERY non-reference horizon is # Proposition-5-unidentified, est_horizons empties and the early # return used to DROP the built prop5 rows - real treated horizons - # reported as absent instead of unidentified. Both the fit-time - # and post-fit surfaces must keep them as all-NaN rows with - # n_obs > 0, plus the consolidated Prop-5 warning. + # reported as absent instead of unidentified. The surface must keep + # them as all-NaN rows with n_obs > 0, plus the consolidated Prop-5 + # warning. rng = np.random.default_rng(5) rows = [] for u in range(12): # cohort 2: pre at t=1, post only at h >= h_bar @@ -3574,43 +4327,40 @@ def test_all_prop5_horizons_survive_early_return(self): d = pd.DataFrame(rows, columns=["unit", "period", "outcome", "first_treat"]) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - ref = _fit_twostage(d.copy(), aggregate="event_study") plain = _fit_twostage(d.copy()) es = plain.aggregate("event_study") assert any("Proposition 5" in str(w.message) for w in caught) - for surface_dict in (ref.event_study_effects,): - prop5 = {h: r for h, r in surface_dict.items() if h != -1} - assert prop5, "Prop-5 rows must survive the early return" - for h, r in prop5.items(): - assert h >= 6 and r["n_obs"] > 0 - assert np.isnan(r["effect"]) and np.isnan(r["se"]) df_ = es.to_dataframe() - prop5_rows = df_[(df_["event_time"] >= 6)] - assert len(prop5_rows) > 0 - assert np.isnan(prop5_rows["att"]).all() + prop5_rows = df_[~df_["is_reference"]] + assert len(prop5_rows) > 0, "Prop-5 rows must survive the early return" + assert (prop5_rows["event_time"] >= 6).all() + assert np.isnan(prop5_rows["att"]).all() and np.isnan(prop5_rows["se"]).all() assert (prop5_rows["n"] > 0).all() - _assert_es_container_matches_fit_time(es, ref, "ts/all-prop5") + _assert_analytic_inference_contract(es, alpha=plain.alpha) def test_replicate_overall_row_migration_delta(self): d, rep_cols = _twostage_survey_panel(replicate=True, degenerate="cohort_zero") sd = _twostage_survey_design(rep_cols) plain = _fit_twostage(d, survey_design=sd) - ref_gr = _fit_twostage(d, aggregate="group", survey_design=sd) - assert plain.overall_se != ref_gr.overall_se - _assert_group_matches_fit_time(plain.aggregate("group"), ref_gr, "ts/delta") + orow = _ts_oracle("replicate_cohort_zero_group") + assert plain.overall_se != oracle_value(orow["overall"]["se"]) + _assert_group_matches_oracle( + plain.aggregate("group"), orow, **ANALYTIC_TOL, name="ts/delta" + ) dh, rep_h = _twostage_survey_panel(replicate=True) - sdh = _twostage_survey_design(rep_h) + healthy = _fit_twostage(dh, survey_design=_twostage_survey_design(rep_h)) # Same BLAS-shape caveat as the Imputation twin above. np.testing.assert_allclose( - _fit_twostage(dh, survey_design=sdh).overall_se, - _fit_twostage(dh, aggregate="group", survey_design=sdh).overall_se, + healthy.overall_se, + oracle_value(_ts_oracle("replicate_none_group")["overall"]["se"]), rtol=1e-12, ) - def test_pretrends_arm_inert(self, twostage_panel): - ref = _fit_twostage(twostage_panel, est_kw={"pretrends": True}, aggregate="event_study") + def test_pretrends_arm_matches_oracle(self, twostage_panel): plain = _fit_twostage(twostage_panel, est_kw={"pretrends": True}) - _assert_es_container_matches_fit_time(plain.aggregate("event_study"), ref, "ts/pretrends") + es = plain.aggregate("event_study") + _assert_es_matches_oracle(es, _ts_oracle("pretrends_all"), **ANALYTIC_TOL, name="ts/pre") + _assert_post_att_is_tau_mean(plain, es, name="ts/pre") def test_isolation_under_public_field_mutation(self, twostage_panel): res = _fit_twostage(twostage_panel) @@ -3645,14 +4395,196 @@ def test_repeated_and_order_independent(self, twostage_fitted): b = twostage_fitted.aggregate("group").to_dataframe() pd.testing.assert_frame_equal(a, b) - def test_bootstrap_recompute_levels_fail_closed(self, twostage_panel): - res = _fit_twostage(twostage_panel, est_kw={"n_bootstrap": 19, "seed": 1}) + # ---------------- bootstrapped fits: the RELAY (4.0 PR 3) ---------------- + + def test_bootstrap_relays_fit_time_surfaces(self, twostage_panel): + res = _fit_twostage(twostage_panel, est_kw=_TS_BOOT_KW) + assert res.bootstrap_results is not None + es, grp = res.aggregate("event_study"), res.aggregate("group") + _assert_relay_matches_stored_surfaces(res, es, grp) + orow = _ts_boot_oracle("default_all") + _assert_es_matches_oracle(es, orow, **BOOT_TOL, p_atol=_TS_P_ATOL, name="ts/boot") + _assert_group_matches_oracle(grp, orow, **BOOT_TOL, p_atol=_TS_P_ATOL, name="ts/boot") + _assert_overall_matches_oracle( + res, _ts_boot_oracle("default_plain_overall"), **BOOT_TOL, p_atol=_TS_P_ATOL + ) + _assert_overall_matches_oracle(res, orow["overall"], **BOOT_TOL, p_atol=_TS_P_ATOL) + _assert_bootstrap_p_values_in_unit_interval(es) + with pytest.raises(NotImplementedError, match="balance_e"): + res.aggregate("event_study", balance_e=1) + _assert_bootstrap_simple_relay(res) + + def test_bootstrap_pretrends_relay_matches_oracle(self, twostage_panel): + res = _fit_twostage(twostage_panel, est_kw={**_TS_BOOT_KW, "pretrends": True}) + es = res.aggregate("event_study") + _assert_es_matches_oracle( + es, _ts_boot_oracle("pretrends_all"), **BOOT_TOL, p_atol=_TS_P_ATOL, name="ts/boot/pre" + ) + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + + def test_bootstrap_prop5_relay_matches_oracle_and_aggregator_warnings_fire(self): + """The Prop-5 panel: the relay matches the oracle (NaN rows kept with + n > 0) AND the ES aggregator's own fit-time UserWarnings now fire on + the bootstrapped PLAIN fit (documented 4.0 PR 3 change).""" + from diff_diff import TwoStageDiD + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = TwoStageDiD(**_TS_BOOT_KW).fit(_twostage_prop5_panel(), **TWOSTAGE_KW) + msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] + assert any("Proposition 5" in m for m in msgs) + assert not any("could not be materialized" in m for m in msgs) + assert res.bootstrap_results is not None and res.event_study_effects + orow = _ts_boot_oracle("prop5_all") + _assert_es_matches_oracle( + res.aggregate("event_study"), orow, **BOOT_TOL, p_atol=_TS_P_ATOL, name="ts/boot/p5" + ) + _assert_group_matches_oracle( + res.aggregate("group"), orow, **BOOT_TOL, p_atol=_TS_P_ATOL, name="ts/boot/p5" + ) + _assert_overall_matches_oracle( + res, _ts_boot_oracle("prop5_plain_overall"), **BOOT_TOL, p_atol=_TS_P_ATOL + ) + + def test_legacy_fields_populated_iff_bootstrapped(self, twostage_panel, twostage_fitted): + assert twostage_fitted.event_study_effects is None + assert twostage_fitted.group_effects is None + boot = _fit_twostage(twostage_panel, est_kw=_TS_BOOT_KW) + assert boot.event_study_effects and boot.group_effects + assert boot.event_study_vcov is None and boot.event_study_vcov_index is None + assert boot.event_study_df is None + + def test_pre_4_0_pickle_without_surface_raises(self, twostage_panel): + res = _fit_twostage(twostage_panel, est_kw=_TS_BOOT_KW) + legacy = dataclasses.replace(res, event_study_effects=None, group_effects=None) + with pytest.raises(ValueError, match="carries no stored event_study surface"): + legacy.aggregate("event_study") + with pytest.raises(ValueError, match="carries no stored group surface"): + legacy.aggregate("group") + _assert_bootstrap_simple_relay(legacy) + + # ---------------- fail-soft materialization (failure injection) ---------------- + + @staticmethod + def _boot_est(**extra): + from diff_diff import TwoStageDiD + + return TwoStageDiD(**_TS_BOOT_KW, **extra) + + def _assert_fail_soft_outcome(self, res): + assert res.event_study_effects is None and res.group_effects is None + assert res.event_study_vcov is None and res.event_study_vcov_index is None + assert res.bootstrap_results is not None + _assert_overall_matches_oracle( + res, _ts_boot_oracle("default_plain_overall"), **BOOT_TOL, p_atol=_TS_P_ATOL + ) for level in ("event_study", "group"): - with pytest.raises(NotImplementedError, match="bootstrap") as exc: + with pytest.raises(ValueError, match="could not be materialized"): res.aggregate(level) - assert "aggregate('simple') and, where supported, aggregate('total') relay" in str( - exc.value - ) + _assert_bootstrap_simple_relay(res) + + def test_failsoft_a_surface_hook_failure(self, twostage_panel, monkeypatch): + from diff_diff import TwoStageDiD + + def boom(self, *a, **k): + raise RuntimeError("injected surface failure") + + monkeypatch.setattr(TwoStageDiD, "_stage2_event_study", boom) + with pytest.warns(UserWarning, match="could not be materialized") as rec: + res = self._boot_est().fit(twostage_panel, **TWOSTAGE_KW) + assert any("injected surface failure" in str(w.message) for w in rec) + self._assert_fail_soft_outcome(res) + + def test_failsoft_b_engine_failure_with_surfaces_retries_overall_only( + self, twostage_panel, monkeypatch + ): + from diff_diff import TwoStageDiD + + orig = TwoStageDiD._run_bootstrap + calls = [] + + def flaky(self, *a, **k): + calls.append(k.get("original_event_study") is not None) + if k.get("original_event_study") is not None: + raise RuntimeError("injected engine failure") + return orig(self, *a, **k) + + monkeypatch.setattr(TwoStageDiD, "_run_bootstrap", flaky) + with pytest.warns(UserWarning, match="could not be materialized"): + res = self._boot_est().fit(twostage_panel, **TWOSTAGE_KW) + assert calls == [True, False] + self._assert_fail_soft_outcome(res) + + def test_failsoft_b1_lsmr_unconverged_none_return_retries(self, twostage_panel, monkeypatch): + """(b') the None-return path: `_compute_cluster_S_scores` hits + `_LSMRUnconvergedError` only on the multi-column ES/group designs + (the single-column static call succeeds), so attempt 1 RETURNS None + with surfaces present -> dropped, retried overall-only.""" + from diff_diff import TwoStageDiD + from diff_diff.two_stage import _LSMRUnconvergedError + + orig = TwoStageDiD._compute_cluster_S_scores + widths = [] + + def selective(self, *a, **k): + x2 = k["X_2"] + widths.append(int(x2.shape[1])) + if x2.shape[1] > 1: + raise _LSMRUnconvergedError("injected LSMR non-convergence") + return orig(self, *a, **k) + + monkeypatch.setattr(TwoStageDiD, "_compute_cluster_S_scores", selective) + with pytest.warns(UserWarning, match="could not be materialized") as rec: + res = self._boot_est().fit(twostage_panel, **TWOSTAGE_KW) + assert any("returned no results" in str(w.message) for w in rec) + assert widths[0] == 1 and max(widths) > 1 and widths[-1] == 1 # static, ES, retry static + self._assert_fail_soft_outcome(res) + + def test_failsoft_b2_total_engine_failure_falls_back_analytical( + self, twostage_panel, monkeypatch + ): + from diff_diff import TwoStageDiD + + def boom(self, *a, **k): + raise RuntimeError("injected total engine failure") + + monkeypatch.setattr(TwoStageDiD, "_run_bootstrap", boom) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = self._boot_est().fit(twostage_panel, **TWOSTAGE_KW) + msgs = [str(w.message) for w in caught] + assert any("Bootstrap failed" in m for m in msgs) # today's catch-all + assert not any("could not be materialized" in m for m in msgs) + assert res.bootstrap_results is None + assert res.event_study_effects is None and res.group_effects is None + analytic = _fit_twostage(twostage_panel) + assert res.overall_se == analytic.overall_se + _frame_equal( + res.aggregate("event_study").to_dataframe(), + analytic.aggregate("event_study").to_dataframe(), + ) + _frame_equal( + res.aggregate("group").to_dataframe(), analytic.aggregate("group").to_dataframe() + ) + + def test_failsoft_b2_none_return_on_both_attempts_falls_back_analytical( + self, twostage_panel, monkeypatch + ): + from diff_diff import TwoStageDiD + + monkeypatch.setattr(TwoStageDiD, "_run_bootstrap", lambda self, *a, **k: None) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = self._boot_est().fit(twostage_panel, **TWOSTAGE_KW) + assert not any("could not be materialized" in str(w.message) for w in caught) + assert res.bootstrap_results is None + assert res.event_study_effects is None and res.group_effects is None + analytic = _fit_twostage(twostage_panel) + assert res.overall_se == analytic.overall_se + _frame_equal( + res.aggregate("event_study").to_dataframe(), + analytic.aggregate("event_study").to_dataframe(), + ) def test_bootstrap_simple_relays_stored_quintet(self, twostage_panel): res = _fit_twostage(twostage_panel, est_kw={"n_bootstrap": 19, "seed": 1}) @@ -3704,10 +4636,11 @@ def test_empty_balance_window(self, twostage_fitted): # --------------------------------------------------------------------------- # -# ContinuousDiD (row M-025): fit(aggregate=) shim + the MIXED view/recompute -# aggregate() - 'simple'/'dose' are pure VIEWS over stored fields (permitted -# on bootstrap fits), 'event_study' is a pruned-IF-payload kit recompute -# (bootstrap fails closed; replicate designs supported, no refit replay) +# ContinuousDiD (row M-025): fit(aggregate=) removal pin + the MIXED +# view/relay/recompute aggregate() - 'simple'/'dose' are pure VIEWS over stored +# fields (permitted on bootstrap fits), 'event_study' is a pruned-IF-payload kit +# recompute on analytic fits and a RELAY of the fit-time percentile surface on +# bootstrapped fits (replicate designs supported analytically, no refit replay) # --------------------------------------------------------------------------- # CONT_KW = dict( @@ -3847,18 +4780,41 @@ def cont_fitted(cont_panel): return _fit_cont(cont_panel) -@pytest.fixture(scope="module") -def cont_fit_time(cont_panel): - """Deprecated fit-time aggregate='eventstudy' - the inertness reference.""" - return _fit_cont(cont_panel, aggregate="eventstudy") - - @pytest.fixture(scope="module") def cont_bootstrap(cont_panel): return _fit_cont(cont_panel, est_kw=dict(n_bootstrap=30, seed=3)) -class TestContinuousShim: +_CO_BOOT_KW = dict(n_bootstrap=30, seed=3) +_CO_P_ATOL = 2.0 / _CO_BOOT_KW["n_bootstrap"] + + +def _co_oracle(arm): + return load_oracles()["analytic"]["ContinuousDiD"][arm] + + +def _co_boot_oracle(arm): + return boot_oracles()["ContinuousDiD"][arm] + + +def _assert_cont_overall_matches_oracle(res, orow, *, p_atol=_CO_P_ATOL): + """ContinuousDiD overall ATT quintet + the ACRT scalars the capture kept.""" + o = orow["overall"] + _close(res.overall_att, o["att"], **BOOT_TOL, what="cont.overall_att") + _close(res.overall_att_se, o["se"], **BOOT_TOL, what="cont.overall_att_se") + _close(res.overall_att_t_stat, o["t_stat"], **BOOT_TOL, what="cont.overall_att_t") + _close(res.overall_att_p_value, o["p_value"], rtol=1e-13, atol=p_atol, what="cont.overall_p") + _close(res.overall_att_conf_int[0], o["ci_lo"], **BOOT_TOL, what="cont.ci_lo") + _close(res.overall_att_conf_int[1], o["ci_hi"], **BOOT_TOL, what="cont.ci_hi") + _close(res.overall_acrt, orow["overall_acrt"], **BOOT_TOL, what="cont.acrt") + _close(res.overall_acrt_se, orow["overall_acrt_se"], **BOOT_TOL, what="cont.acrt_se") + + +class TestContinuousRemoval: + """M-025 removed: ``fit(aggregate=)`` - including the ``"eventstudy"`` + spelling and the always-inert ``"dose"`` - is a TypeError; the 7th + positional slot is ``survey_design``.""" + def test_plain_fit_does_not_warn(self, cont_panel): from diff_diff import ContinuousDiD @@ -3867,52 +4823,48 @@ def test_plain_fit_does_not_warn(self, cont_panel): ContinuousDiD().fit(cont_panel, **CONT_KW) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_aggregate_kwarg_warns_even_at_none(self, cont_panel): - from diff_diff import ContinuousDiD - - with pytest.warns(FutureWarning, match=r"ContinuousDiD\.fit\(aggregate=\)"): - ContinuousDiD().fit(cont_panel, **CONT_KW, aggregate=None) - - def test_dose_value_warns_and_is_inert(self, cont_panel, cont_fitted): - """aggregate='dose' was always a fit-time no-op - the deprecated - call warns (the message says so) and produces the identical fit.""" + @pytest.mark.parametrize("value", [None, "dose", "eventstudy", "event_study"]) + def test_aggregate_keyword_is_a_type_error(self, cont_panel, value): from diff_diff import ContinuousDiD - with pytest.warns(FutureWarning, match="already[\\s\\n ]*redundant"): - res = ContinuousDiD().fit(cont_panel, **CONT_KW, aggregate="dose") - assert res.overall_att == cont_fitted.overall_att - assert res.overall_acrt_se == cont_fitted.overall_acrt_se - np.testing.assert_array_equal( - res.dose_response_att.effects, cont_fitted.dose_response_att.effects - ) - assert res.event_study_effects is None + with pytest.raises(TypeError, match=_unexpected("aggregate")): + ContinuousDiD().fit(cont_panel, **CONT_KW, aggregate=value) - def test_warn_and_still_work(self, cont_fit_time): - """The deprecated 'eventstudy' value still computes the fit-time - surface (legacy routing unchanged until 4.0).""" - assert cont_fit_time.event_study_effects is not None - assert any(np.isfinite(v["se"]) for v in cont_fit_time.event_study_effects.values()) - - def test_invalid_value_warns_then_raises(self, cont_panel): - """The PRE-EXISTING fit-time value validation survives the shim - (a delta vs the EfficientDiD/Imputation no-validation shims): - unknown strings still raise ValueError - after the warning.""" + def test_signature_and_seventh_positional_is_survey_design(self): from diff_diff import ContinuousDiD - with pytest.warns(FutureWarning): - with pytest.raises(ValueError, match="Invalid aggregate"): - ContinuousDiD().fit(cont_panel, **CONT_KW, aggregate="event_study") + params = list(inspect.signature(ContinuousDiD.fit).parameters) + assert "aggregate" not in params and "balance_e" not in params + assert params[7] == "survey_design" + d, _ = _cont_survey_panel() + sd = _cont_survey_design() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + positional = ContinuousDiD().fit( + d, "outcome", "unit", "period", "first_treat", "dose", sd + ) + keyword = ContinuousDiD().fit(d, survey_design=sd, **CONT_KW) + assert positional.survey_metadata is not None + assert positional.overall_att_se == keyword.overall_att_se class TestContinuousAggregate: - # ---------------- event_study: kit-recompute inertness ---------------- + # ---------------- event_study: kit-recompute oracle parity ---------------- - def test_event_study_inert(self, cont_fitted, cont_fit_time): + def test_event_study_matches_oracle(self, cont_fitted): es = cont_fitted.aggregate("event_study") - _assert_es_container_matches_fit_time(es, cont_fit_time, "cont/es") + _assert_es_matches_oracle(es, _co_oracle("default_eventstudy"), **ANALYTIC_TOL, name="cont") # n_kind is a container ATTRIBUTE (not an EVENT_STUDY_SCHEMA # column): ContinuousDiD's ES dict carries no count key. assert es.n_kind is None + _assert_analytic_inference_contract(es, alpha=cont_fitted.alpha) + assert cont_fitted.event_study_effects is None # analytic: nothing stored + + _ORACLE_ARMS = { + "survey_tsl": "survey_tsl_eventstudy", + "covariates": "covariate_eventstudy", + "anticipation": "anticipation1_eventstudy", + } @pytest.mark.parametrize( "arm", @@ -3931,7 +4883,10 @@ def test_event_study_inert(self, cont_fitted, cont_fit_time): "nondefault_config", ], ) - def test_event_study_inert_across_designs(self, arm): + def test_event_study_across_designs(self, arm): + """Arms with a captured oracle pin the numbers; the others pin the + structural contract ((iv) NaN-consistency / safe_inference identity + where se and df are finite) and same-data idempotency.""" est_kw, fit_kw = {}, {} if arm == "multi_cohort": data = _cont_panel(seed=43, cohort_periods=[3, 4, 5]) @@ -3969,9 +4924,21 @@ def test_event_study_inert_across_designs(self, arm): data = _cont_panel(seed=59) est_kw = dict(alpha=0.10, base_period="universal") plain = _fit_cont(data, est_kw=est_kw, **fit_kw) - ref = _fit_cont(data, est_kw=est_kw, aggregate="eventstudy", **fit_kw) es = plain.aggregate("event_study") - _assert_es_container_matches_fit_time(es, ref, f"cont/{arm}") + if arm in self._ORACLE_ARMS: + _assert_es_matches_oracle( + es, _co_oracle(self._ORACLE_ARMS[arm]), **ANALYTIC_TOL, name=f"cont/{arm}" + ) + checked = _assert_analytic_inference_contract(es, alpha=plain.alpha) + if arm in ("survey_tsl", "survey_zero_dose_drop", "replicate_healthy", "replicate_dropped"): + assert checked > 0, arm # finite survey df -> the identity leg ran + _frame_equal(es.to_dataframe(), plain.aggregate("event_study").to_dataframe()) + assert plain.event_study_effects is None + if arm == "covariates": + # The covariate adjustment must move the point path off the + # plain fit's. + bare = _fit_cont(data).aggregate("event_study") + assert not np.array_equal(np.asarray(es.att), np.asarray(bare.att)) def test_nondefault_provenance_discriminates_defaults(self): """alpha=0.10 / base_period='universal' must SURFACE - a kit that @@ -3985,15 +4952,18 @@ def test_nondefault_provenance_discriminates_defaults(self): assert s.alpha == 0.10 def test_empty_post_gt_all_levels(self): - """Fit-faithful empty-post_gt quirk: ES rows keep NaN inference on - BOTH routes; the views relay the stored all-NaN fields.""" + """Fit-faithful empty-post_gt quirk: ES rows keep NaN inference (the + oracle pins the pre-period point estimates); the views relay the + stored all-NaN fields.""" data = _cont_empty_post_panel() plain = _fit_cont(data) - ref = _fit_cont(data, aggregate="eventstudy") es = plain.aggregate("event_study") - _assert_es_container_matches_fit_time(es, ref, "cont/empty_post") + _assert_es_matches_oracle( + es, _co_oracle("empty_post_eventstudy"), **ANALYTIC_TOL, name="cont/empty_post" + ) frame = es.to_dataframe() assert not np.isfinite(frame["se"]).any() + _assert_analytic_inference_contract(es, alpha=plain.alpha) s = plain.aggregate("simple") assert np.isnan(s.att).all() and np.isnan(s.se).all() d = plain.aggregate("dose") @@ -4149,11 +5119,36 @@ def test_mixed_type_labels_preserve_producer_order(self): assert list(frame["target"]) == ["att", "att", "acrt", "acrt"] assert "target" in agg.summary() - # ---------------- bootstrap gating + kit shape ---------------- + # ---------------- bootstrap: the RELAY (4.0 PR 3) + kit shape ---------------- + + def test_bootstrap_event_study_relays(self, cont_bootstrap): + assert cont_bootstrap.event_study_effects # materialized at fit + assert cont_bootstrap.event_study_df is None + es = cont_bootstrap.aggregate("event_study") + _frame_equal(es.to_dataframe(), build_event_study_surface(cont_bootstrap).to_dataframe()) + assert es.vcov is None and es.vcov_index is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + stored = cont_bootstrap.event_study_effects + for i, e in enumerate(es.event_time): + row = stored[int(e)] + assert float(es.att[i]) == float(row["effect"]) + assert float(es.se[i]) == float(row["se"]) + assert float(es.p_value[i]) == float(row["p_value"]) + _assert_es_matches_oracle( + es, + _co_boot_oracle("default_eventstudy"), + **BOOT_TOL, + p_atol=_CO_P_ATOL, + name="cont/boot", + ) + _assert_bootstrap_p_values_in_unit_interval(es) + assert es.n_kind is None - def test_bootstrap_event_study_fails_closed(self, cont_bootstrap): - with pytest.raises(NotImplementedError, match="bootstrap"): - cont_bootstrap.aggregate("event_study") + def test_bootstrap_plain_overall_matches_oracle(self, cont_bootstrap): + """The headline bootstrap (overall ATT + ACRT) is untouched by the + always-on surface materialization.""" + _assert_cont_overall_matches_oracle(cont_bootstrap, _co_boot_oracle("default_plain")) + _assert_cont_overall_matches_oracle(cont_bootstrap, _co_boot_oracle("default_eventstudy")) def test_bootstrap_views_still_work(self, cont_bootstrap): assert cont_bootstrap.aggregate("simple") is not None @@ -4170,6 +5165,88 @@ def test_bootstrap_kit_is_scalars_only(self, cont_bootstrap): assert bk["resolved_survey"] is None assert bk["n_bootstrap"] == 30 + def test_pre_4_0_pickle_without_surface_raises(self, cont_bootstrap): + legacy = dataclasses.replace(cont_bootstrap, event_study_effects=None) + with pytest.raises(ValueError, match="carries no stored event_study surface"): + legacy.aggregate("event_study") + assert legacy.aggregate("simple") is not None # views still work + + def test_bootstrap_without_post_cells_stores_no_surface(self): + """Storage-rule corner: no post-treatment cells -> the bootstrap never + runs, the surface is None and the relay raises the absent-surface + error (pre-4.0 this raised NotImplementedError from the gate).""" + from diff_diff import ContinuousDiD + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + res = ContinuousDiD(**_CO_BOOT_KW).fit(_cont_empty_post_panel(), **CONT_KW) + assert any("No post-treatment" in str(w.message) for w in caught) + assert not any("could not be materialized" in str(w.message) for w in caught) + assert np.isnan(res.overall_att) + assert res.event_study_effects is None + with pytest.raises(ValueError, match="carries no stored event_study surface"): + res.aggregate("event_study") + + # ---------------- fail-soft materialization (failure injection) ---------------- + + def _assert_cont_fail_soft_outcome(self, res, cont_bootstrap): + assert res.event_study_effects is None + _assert_cont_overall_matches_oracle(res, _co_boot_oracle("default_plain")) + # The dose curves equal the plain bootstrapped fit's (same seed -> same draws). + for name in ("dose_response_att", "dose_response_acrt"): + got, want = getattr(res, name), getattr(cont_bootstrap, name) + np.testing.assert_array_equal(np.asarray(got.effects), np.asarray(want.effects)) + np.testing.assert_array_equal(np.asarray(got.se), np.asarray(want.se)) + np.testing.assert_array_equal(np.asarray(got.p_value), np.asarray(want.p_value)) + with pytest.raises(ValueError, match="could not be materialized"): + res.aggregate("event_study") + assert res.aggregate("simple") is not None + + def test_failsoft_a_surface_hook_failure(self, cont_panel, cont_bootstrap, monkeypatch): + from diff_diff import ContinuousDiD + + def boom(self, *a, **k): + raise RuntimeError("injected surface failure") + + monkeypatch.setattr(ContinuousDiD, "_aggregate_event_study", boom) + with pytest.warns(UserWarning, match="could not be materialized") as rec: + res = ContinuousDiD(**_CO_BOOT_KW).fit(cont_panel, **CONT_KW) + assert any("injected surface failure" in str(w.message) for w in rec) + self._assert_cont_fail_soft_outcome(res, cont_bootstrap) + + def test_failsoft_b_engine_failure_with_surface_retries( + self, cont_panel, cont_bootstrap, monkeypatch + ): + from diff_diff import ContinuousDiD + + orig = ContinuousDiD._run_bootstrap + calls = [] + + def flaky(self, *a, **k): + es = a[12] if len(a) > 12 else k.get("event_study_effects") + calls.append(es is not None) + if es is not None: + raise RuntimeError("injected engine failure") + return orig(self, *a, **k) + + monkeypatch.setattr(ContinuousDiD, "_run_bootstrap", flaky) + with pytest.warns(UserWarning, match="could not be materialized"): + res = ContinuousDiD(**_CO_BOOT_KW).fit(cont_panel, **CONT_KW) + assert calls == [True, False] + self._assert_cont_fail_soft_outcome(res, cont_bootstrap) + + def test_failsoft_retry_failure_propagates(self, cont_panel, monkeypatch): + from diff_diff import ContinuousDiD + + def boom(self, *a, **k): + raise RuntimeError("injected total engine failure") + + monkeypatch.setattr(ContinuousDiD, "_run_bootstrap", boom) + with pytest.raises(RuntimeError, match="injected total engine failure"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + ContinuousDiD(**_CO_BOOT_KW).fit(cont_panel, **CONT_KW) + # ---------------- isolation (ES route only - views are views) ---------------- def test_es_isolation_from_public_field_mutation(self, cont_panel): @@ -4187,12 +5264,13 @@ def test_es_isolation_from_public_field_mutation(self, cont_panel): pd.testing.assert_frame_equal(baseline, again) def test_bootstrap_gate_reads_kit_not_field(self, cont_panel): - """The sharpest isolation arm: mutating res.n_bootstrap = 0 must - NOT bypass the fail-closed gate (it reads the kit).""" - res = _fit_cont(cont_panel, est_kw=dict(n_bootstrap=30, seed=3)) + """The sharpest isolation arm: mutating res.n_bootstrap = 0 must NOT + flip the gate (it reads the kit) - the relay still serves the stored + percentile surface instead of attempting a scalars-only recompute.""" + res = _fit_cont(cont_panel, est_kw=_CO_BOOT_KW) + before = res.aggregate("event_study").to_dataframe() res.n_bootstrap = 0 - with pytest.raises(NotImplementedError, match="bootstrap"): - res.aggregate("event_study") + _frame_equal(res.aggregate("event_study").to_dataframe(), before) def test_repeated_calls_idempotent(self, cont_fitted): a = cont_fitted.aggregate("event_study").to_dataframe() @@ -4267,7 +5345,7 @@ def _walk(obj): # --------------------------------------------------------------------------- # # HeterogeneousAdoptionDiD (rows M-027/M-139): the fit + workflow MODE-SELECTOR -# shims (panel-shape inference) and the two PURE-VIEW results classes. +# removals (panel-shape inference only) and the two PURE-VIEW results classes. # --------------------------------------------------------------------------- # HAD_KW = dict(outcome="outcome", dose="dose", time="period", unit="unit") @@ -4356,7 +5434,11 @@ def had_fitted_multi(had_panel_multi): return _fit_had(had_panel_multi, first_treat="ft") -class TestHadShim: +class TestHadRemoval: + """M-027 / M-139 removed: the mode selector is gone from fit() and the + workflow; panel-shape inference (``_infer_aggregate_mode``) is the only + rule. The ``HADPretestReport.aggregate`` FIELD survives.""" + def test_plain_fit_2p_infers_overall_without_warning(self, had_panel_2p): from diff_diff import HeterogeneousAdoptionDiD, HeterogeneousAdoptionDiDResults @@ -4381,42 +5463,37 @@ def test_plain_fit_multi_infers_event_study_without_warning(self, had_panel_mult assert isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - @pytest.mark.parametrize("mode", ["overall", "event_study"]) - def test_supplied_mode_warns_and_still_works(self, had_panel_2p, had_panel_multi, mode): - from diff_diff import HeterogeneousAdoptionDiD - - data = had_panel_2p if mode == "overall" else had_panel_multi - kw = {} if mode == "overall" else {"first_treat": "ft"} - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - HeterogeneousAdoptionDiD().fit(data, **HAD_KW, aggregate=mode, **kw) - - def test_aggregate_none_warns_then_raises(self, had_panel_2p): - # Unlike ContinuousDiD, None is NOT in HAD's _VALID_AGGREGATES: an - # explicit None is a supplied INVALID value - a third distinct - # behavior beside omitted (infers) and supplied-valid (legacy). + @pytest.mark.parametrize("value", [None, "overall", "event_study", "bogus"]) + def test_aggregate_keyword_is_a_type_error(self, had_panel_2p, had_panel_multi, value): from diff_diff import HeterogeneousAdoptionDiD - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate=None"): - HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate=None) - - def test_invalid_value_warns_then_raises(self, had_panel_2p): - from diff_diff import HeterogeneousAdoptionDiD - - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate='bogus'"): - HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate="bogus") + with pytest.raises(TypeError, match=_unexpected("aggregate")): + HeterogeneousAdoptionDiD().fit(had_panel_2p, **HAD_KW, aggregate=value) + with pytest.raises(TypeError, match=_unexpected("aggregate")): + HeterogeneousAdoptionDiD().fit( + had_panel_multi, **HAD_KW, first_treat="ft", aggregate=value + ) - def test_supplied_overall_on_multi_warns_then_shape_error(self, had_panel_multi): + def test_signature_and_eighth_positional_is_a_type_error(self, had_panel_2p): + """fit() takes 7 positionals (data..first_treat); cband and the rest + are keyword-only, so no vacated slot can swallow a stray mode.""" from diff_diff import HeterogeneousAdoptionDiD - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="exactly two time periods"): - HeterogeneousAdoptionDiD().fit( - had_panel_multi, **HAD_KW, aggregate="overall", first_treat="ft" - ) + params = inspect.signature(HeterogeneousAdoptionDiD.fit).parameters + assert "aggregate" not in params + assert [n for n, p in params.items() if p.kind is p.POSITIONAL_OR_KEYWORD] == [ + "self", + "data", + "outcome", + "dose", + "time", + "unit", + "first_treat", + ] + with pytest.raises(TypeError, match="positional argument"): + HeterogeneousAdoptionDiD().fit( + had_panel_2p, "outcome", "dose", "period", "unit", None, "overall" + ) def test_sentinel_post_filter_shape_error_no_warning(self): # Sentinel-reachable shape error: T>2 raw panel infers event_study, @@ -4442,59 +5519,30 @@ def test_missing_time_column_still_value_error(self, had_panel_2p): had_panel_2p, outcome="outcome", dose="dose", time="missing", unit="unit" ) - @pytest.mark.parametrize("shape", ["2p", "multi"]) - def test_inference_equivalence_bit_identical(self, had_panel_2p, had_panel_multi, shape): - """Plain fit ≡ fit(aggregate=) field-for-field per shape.""" - if shape == "2p": - plain = _fit_had(had_panel_2p) - legacy = _fit_had(had_panel_2p, aggregate="overall") - else: - plain = _fit_had(had_panel_multi, first_treat="ft") - legacy = _fit_had(had_panel_multi, first_treat="ft", aggregate="event_study") - import dataclasses - - assert type(plain) is type(legacy) - for f in dataclasses.fields(plain): - a, b = getattr(plain, f.name), getattr(legacy, f.name) - if isinstance(a, np.ndarray): - np.testing.assert_array_equal(a, b) - elif isinstance(a, float): - assert (a == b) or (np.isnan(a) and np.isnan(b)), f.name - elif isinstance(a, (list, tuple)) and a and isinstance(a[0], float): - np.testing.assert_array_equal(np.asarray(a), np.asarray(b)) - elif f.name in ( - "bandwidth_diagnostics", - "bias_corrected_fit", - "survey_metadata", - "filter_info", - ): - assert (a is None) == (b is None), f.name - else: - assert a == b, f.name - def test_workflow_plain_call_infers_without_warning(self, had_panel_2p): from diff_diff import did_had_pretest_workflow with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") rep = did_had_pretest_workflow(had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3) - assert rep.aggregate == "overall" + assert rep.aggregate == "overall" # the REPORT field survives assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] - def test_workflow_supplied_mode_warns(self, had_panel_2p): + @pytest.mark.parametrize("value", ["overall", "junk"]) + def test_workflow_aggregate_keyword_is_a_type_error(self, had_panel_2p, value): from diff_diff import did_had_pretest_workflow - with pytest.warns(FutureWarning, match=r"workflow\(aggregate=\) is deprecated"): + with pytest.raises(TypeError, match=_unexpected("aggregate")): did_had_pretest_workflow( - had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3, aggregate="overall" + had_panel_2p, **HAD_KW, n_bootstrap=99, seed=3, aggregate=value ) - def test_workflow_invalid_value_warns_then_raises(self, had_panel_2p): + def test_workflow_signature_has_no_aggregate(self): from diff_diff import did_had_pretest_workflow - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="aggregate must be one of"): - did_had_pretest_workflow(had_panel_2p, **HAD_KW, aggregate="junk") + params = inspect.signature(did_had_pretest_workflow).parameters + assert "aggregate" not in params + assert list(params)[:6] == ["data", "outcome", "dose", "time", "unit", "first_treat"] class TestHadAggregate: @@ -5380,31 +6428,27 @@ def test_degenerate_inference_passes_through(self): # --------------------------------------------------------------------------- -from diff_diff.results_base import build_event_study_surface # noqa: E402 - - class TestPerRowDfProvenanceCompletion: """Per-row ES df threading for the four former M-092 holes (EfficientDiD, ImputationDiD, ContinuousDiD, HAD) and the documented two-channel contract: the scalar ``df_survey`` (the fit's resolved scalar inference df) persists on bootstrapped surfaces, while the per-row ``df`` column is the inference-provenance channel and is cleared under percentile - bootstrap (M-027).""" + bootstrap (M-027). Since 4.0 the container is the only surface on + analytic fits and bootstrapped Imputation/ContinuousDiD fits RELAY their + stored percentile surface.""" # ---- EfficientDiD ------------------------------------------------- def test_edid_es_per_row_df_survey_tsl_oracle(self): - from diff_diff.utils import safe_inference - d, _ = _efficient_survey_panel() sd = _efficient_survey_design() plain = _fit_efficient(d, survey_design=sd) expected = plain._aggregation_kit.bookkeeping["df_survey"] - # Modern fits build no fit-time ES surface -> attr None (absent- - # surface gate); the deprecated fit-time route carries the value. + # Modern fits build no fit-time ES surface -> attr None; the oracle + # recorded the same scalar on the pre-removal fit-time route. assert plain.event_study_df is None - fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") - assert fit_time.event_study_df == expected + assert expected == _ed_oracle("survey_weights_all")["event_study_df"] es = plain.aggregate("event_study") finite = np.isfinite(np.asarray(es.df, dtype=float)) assert finite.any() @@ -5419,49 +6463,46 @@ def test_edid_es_per_row_df_survey_tsl_oracle(self): np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) def test_edid_es_per_row_df_replicate_arms(self): - from diff_diff.utils import safe_inference - for degenerate, expected_df in ((None, 7.0), ("dropped", 5.0)): d, rep_cols = _efficient_survey_panel(replicate=True, degenerate=degenerate) sd = _efficient_survey_design(rep_cols) plain = _fit_efficient(d, survey_design=sd) - fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") - assert fit_time.event_study_df == expected_df + assert plain.event_study_df is None es = plain.aggregate("event_study") df_col = np.asarray(es.df, dtype=float) finite = np.isfinite(df_col) + assert finite.any() assert set(np.unique(df_col[finite])) <= {expected_df} + assert es.df_survey == expected_df for i in np.flatnonzero(finite): t, p, _ = safe_inference( float(es.att[i]), float(es.se[i]), alpha=plain.alpha, df=float(es.df[i]) ) np.testing.assert_allclose(p, es.p_value[i], rtol=1e-12) - def test_edid_replicate_undefined_zero_sentinel_is_none(self): + def test_edid_replicate_undefined_zero_sentinel(self): # 0-sentinel normalization: the replicate-undefined df publishes the - # ATTRIBUTE as None, never 0 (the sentinel stays representable only - # via survey_metadata). + # per-row column as NaN and the scalar df_survey as the 0.0 sentinel + # (representable only via survey_metadata; the attribute stays None). d, rep_cols = _efficient_survey_panel(replicate=True, degenerate="undefined") sd = _efficient_survey_design(rep_cols) - fit_time = _fit_efficient(d, survey_design=sd, aggregate="event_study") - assert fit_time.event_study_df is None + plain = _fit_efficient(d, survey_design=sd) + assert plain.event_study_df is None + es = plain.aggregate("event_study") + assert es.df_survey == 0.0 + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) def test_edid_bootstrap_clears_per_row_df_scalar_persists(self): d, _ = _efficient_survey_panel() sd = _efficient_survey_design() boot = _efficient_boot_fit(d, survey_design=sd) - # Fit-time clearing: percentile inference used no df. - assert boot.event_study_df is None + # Nothing is stored at fit; percentile inference used no df. + assert boot.event_study_df is None and boot.event_study_effects is None es = boot.aggregate("event_study") assert np.all(np.isnan(np.asarray(es.df, dtype=float))) # Row B: the scalar df_survey persists beside percentile inference. assert es.df_survey is not None and np.isfinite(es.df_survey) assert es.df_survey == boot.survey_metadata.df_survey - # Fit-time (deprecated route) surface behaves identically. - fit_time = _efficient_boot_fit_time(d, aggregate="event_study", survey_design=sd) - surface = build_event_study_surface(fit_time) - assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) - assert surface.df_survey == fit_time.survey_metadata.df_survey def test_edid_absent_es_surface_gate(self, efficient_panel): plain = _fit_efficient(efficient_panel) @@ -5471,16 +6512,11 @@ def test_edid_absent_es_surface_gate(self, efficient_panel): # ---- ImputationDiD ------------------------------------------------ def test_imputation_es_per_row_df_covers_leads(self): - from diff_diff.utils import safe_inference - d, _ = _imputation_survey_panel() sd = _imputation_survey_design() plain = _fit_imputation(d, survey_design=sd, est_kw={"pretrends": True}) expected = plain.survey_metadata.df_survey - fit_time = _fit_imputation( - d, survey_design=sd, aggregate="event_study", est_kw={"pretrends": True} - ) - assert fit_time.event_study_df == expected + assert plain.event_study_df is None # analytic fits store no surface es = plain.aggregate("event_study") df_col = np.asarray(es.df, dtype=float) ev = np.asarray(es.event_time) @@ -5488,6 +6524,7 @@ def test_imputation_es_per_row_df_covers_leads(self): # Leads included: at least one pre-period row carries the finite df. assert finite.any() and (ev[finite] < 0).any() assert set(np.unique(df_col[finite])) == {float(expected)} + assert es.df_survey == expected for i in np.flatnonzero(finite): t, p, _ = safe_inference( float(es.att[i]), float(es.se[i]), alpha=plain.alpha, df=float(es.df[i]) @@ -5506,20 +6543,36 @@ def test_imputation_replicate_replay_df_is_level_matched(self): finite = np.isfinite(df_col) assert finite.any() assert set(np.unique(df_col[finite])) == {float(es.df_survey)} + assert es.df_survey == _im_oracle("replicate_cohort_zero_event_study")["event_study_df"] - def test_imputation_replicate_undefined_zero_sentinel_is_none(self): + def test_imputation_replicate_undefined_zero_sentinel(self): d, rep_cols = _imputation_survey_panel(replicate=True, degenerate="undefined") sd = _imputation_survey_design(rep_cols) - fit_time = _fit_imputation(d, survey_design=sd, aggregate="event_study") - assert fit_time.event_study_df is None + plain = _fit_imputation(d, survey_design=sd) + assert plain.event_study_df is None + es = plain.aggregate("event_study") + assert es.df_survey == 0.0 + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) - def test_imputation_bootstrap_clears_at_fit_and_replay_fails_closed(self): + def test_imputation_bootstrap_clears_at_fit_and_relays(self): + """The bootstrapped Imputation fit stores its percentile surface with + a cleared df and the relay publishes a NaN df column beside the + persisted survey scalar (Row B) - and matches the oracle.""" d, _ = _imputation_survey_panel() sd = _imputation_survey_design() - boot = _fit_imputation(d, survey_design=sd, est_kw={"n_bootstrap": 30, "seed": 1}) + boot = _fit_imputation(d, survey_design=sd, est_kw=_IM_BOOT_KW) assert boot.event_study_df is None - with pytest.raises(NotImplementedError): - boot.aggregate("event_study") + assert boot.event_study_effects + es = boot.aggregate("event_study") + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) + assert es.df_survey == boot.survey_metadata.df_survey + _assert_es_matches_oracle( + es, + _im_boot_oracle("survey_tsl_all"), + **BOOT_TOL, + p_atol=_IM_P_ATOL, + name="imp/boot/tsl", + ) # ---- ContinuousDiD ------------------------------------------------ @@ -5534,15 +6587,11 @@ def test_cont_es_per_row_df_threads_survey_df(self): finite = np.isfinite(df_col) assert finite.any() assert set(np.unique(df_col[finite])) == {float(expected)} - # Route parity: the fit-time surface carries the same column. - fit_time = _fit_cont(d, survey_design=sd, aggregate="eventstudy") - surface = build_event_study_surface(fit_time) - np.testing.assert_array_equal( - np.asarray(surface.df, dtype=float), np.asarray(es.df, dtype=float) - ) + # The pre-removal fit-time surface carried the same scalar. + assert float(expected) == _co_oracle("survey_tsl_eventstudy")["event_study_df"] def test_cont_bootstrap_fit_clears_df_scalar_is_unit_level(self): - # Bootstrapped fit: _survey_df stays None -> attr None; the fit-time + # Bootstrapped fit: _survey_df stays None -> attr None; the relayed # surface's per-row df is all-NaN while the df_survey scalar reports # the (now unit-level) design df - the Row A container pin on an # implicit-PSU design. @@ -5550,14 +6599,11 @@ def test_cont_bootstrap_fit_clears_df_scalar_is_unit_level(self): from diff_diff import SurveyDesign sd = SurveyDesign(weights="w") # implicit-PSU - boot = _fit_cont( - d, - survey_design=sd, - aggregate="eventstudy", - est_kw={"n_bootstrap": 20, "seed": 3}, - ) + boot = _fit_cont(d, survey_design=sd, est_kw={"n_bootstrap": 20, "seed": 3}) assert boot.event_study_df is None - surface = build_event_study_surface(boot) + assert boot.event_study_effects # materialized at fit, relayed below + surface = boot.aggregate("event_study") + _frame_equal(surface.to_dataframe(), build_event_study_surface(boot).to_dataframe()) assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) # Unit-level scalar: n_units - 1 (implicit PSU = unit), not obs-level. n_units = boot.survey_metadata.n_psu @@ -5572,7 +6618,6 @@ def test_cont_absent_es_surface_gate(self, cont_panel): def test_had_es_per_row_df_survey(self): from diff_diff import SurveyDesign - from diff_diff.utils import safe_inference d = _had_panel_multi() rng = np.random.default_rng(7) @@ -5606,11 +6651,8 @@ def test_cs_bootstrapped_scalar_persists_per_row_cleared(self): d = _survey_panel() sd = _survey_design() boot = _boot_fit(d, fit_kwargs={"survey_design": sd}) + assert boot.event_study_effects is None # nothing stored; the replay serves es = boot.aggregate("event_study") assert np.all(np.isnan(np.asarray(es.df, dtype=float))) assert es.df_survey is not None and np.isfinite(es.df_survey) assert es.df_survey == boot.survey_metadata.df_survey - fit_time = _boot_fit_time(d, aggregate="event_study", fit_kwargs={"survey_design": sd}) - surface = build_event_study_surface(fit_time) - assert np.all(np.isnan(np.asarray(surface.df, dtype=float))) - assert surface.df_survey == fit_time.survey_metadata.df_survey diff --git a/tests/test_bootstrap_chunking.py b/tests/test_bootstrap_chunking.py index 634ec0f26..f562097b2 100644 --- a/tests/test_bootstrap_chunking.py +++ b/tests/test_bootstrap_chunking.py @@ -329,7 +329,9 @@ def _panel(): return pd.DataFrame({"unit": units, "period": periods, "y": y, "first_treat": ft}) def _fit(self): - return CallawaySantAnna( + # 4.0: the fit-time surface is gone; the post-fit aggregate() REPLAYS the + # bootstrap, so it runs here - inside whatever monkeypatch the caller set. + res = CallawaySantAnna( control_group="never_treated", estimation_method="dr", cluster="unit", @@ -341,20 +343,22 @@ def _fit(self): unit="unit", time="period", first_treat="first_treat", - aggregate="all", ) + return _with_aggregates(res) def test_tiny_chunks_match_single_chunk(self, monkeypatch): # Default path: this small panel fits in a single block. - base = self._fit() + base, base_es, base_grp = self._fit() # Force many tiny blocks on every weight path (unit + survey). monkeypatch.setattr("diff_diff.bootstrap_chunking.compute_block_size", lambda *a, **k: 7) monkeypatch.setattr("diff_diff.staggered_bootstrap.compute_block_size", lambda *a, **k: 7) - tiny = self._fit() + tiny, tiny_es, tiny_grp = self._fit() # Continuous bootstrap statistics match to within BLAS reassociation. assert tiny.overall_se == pytest.approx(base.overall_se, rel=1e-10, abs=1e-12) - assert tiny.cband_crit_value == pytest.approx(base.cband_crit_value, rel=1e-10, abs=1e-12) + assert tiny_es.cband_crit_value == pytest.approx( + base_es.cband_crit_value, rel=1e-10, abs=1e-12 + ) # p-values are discrete proportions over draws; a borderline draw can # flip under reassociation, shifting a p-value by O(1/n_bootstrap). assert tiny.overall_p_value == pytest.approx(base.overall_p_value, abs=0.02) @@ -366,9 +370,9 @@ def test_tiny_chunks_match_single_chunk(self, monkeypatch): np.testing.assert_allclose(t["p_value"].to_numpy(), b["p_value"].to_numpy(), atol=0.02) # Event-study and group aggregate effects/SEs/CIs also match under chunking. - for level in ("event_study", "group"): - bl = base.to_dataframe(level=level).reset_index(drop=True) - tl = tiny.to_dataframe(level=level).reset_index(drop=True) + for level, bc, tc in (("event_study", base_es, tiny_es), ("group", base_grp, tiny_grp)): + bl = bc.to_dataframe().reset_index(drop=True) + tl = tc.to_dataframe().reset_index(drop=True) num_cols = [c for c in bl.columns if bl[c].dtype.kind in "fi" and c != "p_value"] assert num_cols, f"no numeric columns to compare for level={level}" for col in num_cols: @@ -392,7 +396,6 @@ def fit(): unit="unit", time="period", first_treat="first_treat", - aggregate="simple", ) base = fit() @@ -423,7 +426,7 @@ def _clustered_panel(): ) def _fit_clustered(self): - return CallawaySantAnna( + res = CallawaySantAnna( control_group="never_treated", estimation_method="dr", cluster="state", @@ -435,15 +438,15 @@ def _fit_clustered(self): unit="unit", time="period", first_treat="first_treat", - aggregate="all", ) + return _with_aggregates(res) def test_nonidentity_cluster_chunks_match_single(self, monkeypatch): # Exercises the non-identity PSU fan-out expansion under tiny chunks. - base = self._fit_clustered() + base, _, _ = self._fit_clustered() monkeypatch.setattr("diff_diff.bootstrap_chunking.compute_block_size", lambda *a, **k: 7) monkeypatch.setattr("diff_diff.staggered_bootstrap.compute_block_size", lambda *a, **k: 7) - tiny = self._fit_clustered() + tiny, _, _ = self._fit_clustered() assert tiny.overall_se == pytest.approx(base.overall_se, rel=1e-10, abs=1e-12) b = base.to_dataframe().sort_values(["group", "time"]).reset_index(drop=True) @@ -472,20 +475,22 @@ def _force_one_column_tiles(monkeypatch): def test_unit_path_tiles_match_single_tile(self, monkeypatch): fit = TestCSBootstrapChunkInvariance()._fit - base = fit() + base, base_es, base_grp = fit() self._force_one_column_tiles(monkeypatch) - tiled = fit() + tiled, tiled_es, tiled_grp = fit() assert tiled.overall_se == pytest.approx(base.overall_se, rel=1e-10, abs=1e-12) - assert tiled.cband_crit_value == pytest.approx(base.cband_crit_value, rel=1e-10, abs=1e-12) + assert tiled_es.cband_crit_value == pytest.approx( + base_es.cband_crit_value, rel=1e-10, abs=1e-12 + ) assert tiled.overall_p_value == pytest.approx(base.overall_p_value, abs=0.02) b = base.to_dataframe().sort_values(["group", "time"]).reset_index(drop=True) t = tiled.to_dataframe().sort_values(["group", "time"]).reset_index(drop=True) for col in ["se", "conf_int_lower", "conf_int_upper"]: np.testing.assert_allclose(t[col].to_numpy(), b[col].to_numpy(), rtol=1e-10, atol=1e-12) - for level in ("event_study", "group"): - bl = base.to_dataframe(level=level).reset_index(drop=True) - tl = tiled.to_dataframe(level=level).reset_index(drop=True) + for bc, tc in ((base_es, tiled_es), (base_grp, tiled_grp)): + bl = bc.to_dataframe().reset_index(drop=True) + tl = tc.to_dataframe().reset_index(drop=True) num_cols = [c for c in bl.columns if bl[c].dtype.kind in "fi" and c != "p_value"] for col in num_cols: np.testing.assert_allclose( @@ -495,9 +500,9 @@ def test_unit_path_tiles_match_single_tile(self, monkeypatch): def test_cluster_path_tiles_match_single_tile(self, monkeypatch): # Non-identity PSU fan-out: the per-pass expansion copy must replay too. fit = TestCSBootstrapChunkInvariance()._fit_clustered - base = fit() + base, _, _ = fit() self._force_one_column_tiles(monkeypatch) - tiled = fit() + tiled, _, _ = fit() assert tiled.overall_se == pytest.approx(base.overall_se, rel=1e-10, abs=1e-12) b = base.to_dataframe().sort_values(["group", "time"]).reset_index(drop=True) @@ -539,7 +544,7 @@ def _fit_survey(self, stratified): ) with warnings.catch_warnings(): warnings.simplefilter("ignore") - return CallawaySantAnna( + res = CallawaySantAnna( control_group="never_treated", estimation_method="dr", n_bootstrap=200, @@ -550,9 +555,12 @@ def _fit_survey(self, stratified): unit="unit", time="period", first_treat="first_treat", - aggregate="all", survey_design=design, ) + # Replays the bootstrap under the caller's chunking monkeypatch. + res.aggregate("event_study") + res.aggregate("group") + return res @pytest.mark.parametrize("stratified", [False, True], ids=["psu", "stratified"]) def test_survey_path_tiles_match_single_tile(self, monkeypatch, stratified): @@ -586,7 +594,7 @@ def test_rcs_path_tiles_match_single_tile(self, monkeypatch): def fit(): with warnings.catch_warnings(): warnings.simplefilter("ignore") - return CallawaySantAnna( + res = CallawaySantAnna( estimation_method="reg", panel=False, n_bootstrap=200, seed=42 ).fit( self._rcs_frame(), @@ -594,8 +602,10 @@ def fit(): "unit", "period", "first_treat", - aggregate="all", ) + res.aggregate("event_study") + res.aggregate("group") + return res base = fit() self._force_one_column_tiles(monkeypatch) @@ -608,6 +618,16 @@ def fit(): np.testing.assert_allclose(t[col].to_numpy(), b[col].to_numpy(), rtol=1e-10, atol=1e-12) +def _with_aggregates(res): + """``(results, aggregate("event_study"), aggregate("group"))`` for a bootstrapped fit. + + Since 4.0 the fit-time surface is gone and each post-fit ``aggregate()`` call + REPLAYS the multiplier bootstrap, so the containers are materialized right + after the fit - inside the caller's chunk/tile monkeypatch context. + """ + return res, res.aggregate("event_study"), res.aggregate("group") + + def _design(psu=None, strata=None, fpc=None, weights=None, lonely_psu="adjust"): """Minimal duck-typed ResolvedSurveyDesign for the survey weight generators.""" return SimpleNamespace(psu=psu, strata=strata, fpc=fpc, weights=weights, lonely_psu=lonely_psu) @@ -697,27 +717,27 @@ def _panel(): def _fit(self, cluster=None, survey_design=None): with warnings.catch_warnings(): warnings.simplefilter("ignore") - return EfficientDiD(n_bootstrap=200, seed=42, cluster=cluster).fit( + res = EfficientDiD(n_bootstrap=200, seed=42, cluster=cluster).fit( self._panel(), "y", "unit", "period", "first_treat", - aggregate="all", survey_design=survey_design, ) + # 4.0: EfficientDiD stores no fit-time surface; the post-fit + # containers replay the bootstrap under the caller's monkeypatch. + assert res.event_study_effects is None and res.group_effects is None + return _with_aggregates(res) @staticmethod - def _ses(r): + def _ses(fitted): # Flatten every bootstrap SE (overall + group_time + event_study + group) # into one vector, ordered by sorted keys, for an nan-safe comparison. + r, es_c, grp_c = fitted gt = [r.group_time_effects[k]["se"] for k in sorted(r.group_time_effects)] - es = ( - [r.event_study_effects[k]["se"] for k in sorted(r.event_study_effects)] - if r.event_study_effects - else [] - ) - gp = [r.group_effects[k]["se"] for k in sorted(r.group_effects)] if r.group_effects else [] + es = [float(x) for x in es_c.se] + gp = [float(x) for x in grp_c.se] return np.array([r.overall_se, *gt, *es, *gp], dtype=float) def _run(self, monkeypatch, **fit_kwargs): @@ -837,7 +857,6 @@ def _fit(self): "dose", "period", "unit", - aggregate="event_study", survey_design=SurveyDesign(weights="w"), ) diff --git a/tests/test_br_dr_canonical_datasets.py b/tests/test_br_dr_canonical_datasets.py index 74bd05c2f..bcabdd35a 100644 --- a/tests/test_br_dr_canonical_datasets.py +++ b/tests/test_br_dr_canonical_datasets.py @@ -149,7 +149,6 @@ def test_negative_att_robust_sensitivity_clean_pretrends(self, mpdta_panel): unit="countyreal", time="year", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport( cs, @@ -202,7 +201,6 @@ def test_cs_positive_att_clear_violation_fragile_sensitivity(self, castle_panel) unit="state", time="year", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport( cs, @@ -248,7 +246,6 @@ def test_treatment_label_proper_noun_preserved(self, castle_panel): unit="state", time="year", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport( cs, @@ -274,7 +271,6 @@ def test_breakdown_m_zero_uses_smallest_m_evaluated_wording(self, castle_panel): unit="state", time="year", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport( cs, @@ -318,7 +314,6 @@ def test_sa_agrees_with_cs_on_direction_and_pt(self, castle_panel): unit="state", time="year", first_treat="first_treat", - aggregate="event_study", ) sa = SunAbraham().fit( castle_panel, diff --git a/tests/test_business_report.py b/tests/test_business_report.py index b899a84a4..6119125bf 100644 --- a/tests/test_business_report.py +++ b/tests/test_business_report.py @@ -26,6 +26,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import legacy_standin import diff_diff as dd from diff_diff import ( @@ -95,13 +96,14 @@ def cs_fit(): # base_period='universal' so DR's sensitivity check can run without # hitting the round-5 methodology-critical skip (Rambachan-Roth bounds # are not interpretable on consecutive-comparison pre-periods). + # 4.0: no fit-time surface - the report layer derives the event study + # via results.aggregate("event_study"). cs = CallawaySantAnna(base_period="universal").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) return cs, sdf @@ -1455,7 +1457,6 @@ def test_cs_fit_persists_anticipation(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) assert getattr(cs, "anticipation", None) == 1 br = BusinessReport(cs, auto_diagnostics=False) @@ -2283,7 +2284,6 @@ def test_cs_not_yet_treated_still_reinterprets(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport(cs, auto_diagnostics=False) sample = br.to_dict()["sample"] @@ -2609,8 +2609,8 @@ def test_full_vcov_path_no_downgrade_on_real_cs_fit(self, cs_fit): # Step 4 linear MDV is in Roth's γ units, a slope; the level-scale # comparable is mdv * max(|violation_weights|)). assert block["covariance_source"] == "full_pre_period_vcov", ( - "cs_fit is analytical CS with event_study_vcov populated — " - "PR-B routing must report full_pre_period_vcov" + "cs_fit is analytical CS whose derived event-study container carries " + "the full vcov — PR-B routing must report full_pre_period_vcov" ) # max_abs_pre_violation = mdv * max(|t - t_ref|) = 0.1337 * 3 ≈ 0.401 assert block.get("max_abs_pre_violation") is not None @@ -2633,7 +2633,7 @@ def test_full_vcov_path_no_downgrade_on_real_cs_fit(self, cs_fit): # compute_pretrends_power output's persisted field, locking that # provenance is recorded at fit time and consumed at the report # layer (not re-inferred from the source-fit type). - pp = compute_pretrends_power(fit, alpha=0.05, target_power=0.80) + pp = compute_pretrends_power(fit.aggregate("event_study"), alpha=0.05, target_power=0.80) assert pp.covariance_source == "full_pre_period_vcov" # Positive prose contract on the rendered surfaces. @@ -2690,7 +2690,6 @@ def test_not_yet_treated_fit_does_not_render_misleading_control_count(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) br = BusinessReport(cs, auto_diagnostics=False) sample = br.to_dict()["sample"] @@ -5062,19 +5061,12 @@ def test_plain_cs_fit_business_report_pt_verdict(self, cs_plain_fit_br): # The provenance key crosses the _lift_pre_trends whitelist. assert pt["pre_period_source"] == "aggregate_event_study" - def test_raw_route_business_report_pre_period_source_is_none(self): - # Raw-field (fit-time kwarg) route: BR still emits the key, as None. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sdf = generate_staggered_data(n_units=100, n_periods=6, treatment_effect=1.5, seed=7) - cs = CallawaySantAnna(base_period="universal").fit( - sdf, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="event_study", - ) + def test_raw_route_business_report_pre_period_source_is_none(self, cs_plain_fit_br): + # Raw-field route (the 3.x fit-time shape, hand-populated from the + # container - a legacy pickle today): BR still emits the key, as None. + cs, _ = cs_plain_fit_br + cs = legacy_standin(cs) + assert cs.event_study_effects is not None d = BusinessReport(cs, outcome_label="revenue").to_dict() pt = d["pre_trends"] assert pt["status"] == "computed" diff --git a/tests/test_chaisemartin_dhaultfoeuille.py b/tests/test_chaisemartin_dhaultfoeuille.py index 0d886dff4..ddc05147f 100644 --- a/tests/test_chaisemartin_dhaultfoeuille.py +++ b/tests/test_chaisemartin_dhaultfoeuille.py @@ -242,32 +242,19 @@ def data(self): def _est(self): return ChaisemartinDHaultfoeuille() - def test_aggregate_simple_raises_value_error(self, data): - # M-026: fit(aggregate=) is deprecated and never computed anything; - # a non-None value warns then raises ValueError pointing at the - # post-fit results.aggregate() route. - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="results.aggregate"): - self._est().fit( - data, - outcome="outcome", - unit="group", - time="period", - treatment="treatment", - aggregate="simple", - ) - - def test_aggregate_event_study_raises_value_error(self, data): - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="results.aggregate"): - self._est().fit( - data, - outcome="outcome", - unit="group", - time="period", - treatment="treatment", - aggregate="event_study", - ) + def test_fit_time_aggregate_kwarg_removed(self, data): + # 4.0 (M-026): fit(aggregate=) never computed anything and is gone; + # supplying it is a native TypeError. Aggregations are post-fit + # results.aggregate(...). + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + self._est().fit( + data, + outcome="outcome", + unit="group", + time="period", + treatment="treatment", + aggregate="event_study", + ) def test_L_max_validation(self, data): """L_max is now a Phase 2 feature: positive int or None accepted, diff --git a/tests/test_continuous_did.py b/tests/test_continuous_did.py index f4d177864..cd4b59eeb 100644 --- a/tests/test_continuous_did.py +++ b/tests/test_continuous_did.py @@ -20,13 +20,6 @@ from diff_diff.continuous_did_results import ContinuousDiDResults from diff_diff.prep_dgp import generate_continuous_did_data -# M-025: sites deliberately kept on the deprecated fit-time aggregate= -# (bootstrap event studies - the aggregated bootstrap surface is -# fit-time-only until replay ships - and legacy-surface pins) run under -# this module-scoped suppression; the shim behavior itself is pinned in -# tests/test_aggregate_contract.py::TestContinuousShim. -pytestmark = pytest.mark.filterwarnings(r"ignore:ContinuousDiD\.fit\(aggregate=\):FutureWarning") - # ============================================================================= # B-Spline Basis Tests # ============================================================================= @@ -372,8 +365,9 @@ def test_unbalanced_panel_same_count_different_periods(self): with pytest.raises(ValueError, match="[Uu]nbalanced"): est.fit(data, "outcome", "unit", "period", "first_treat", "dose") - def test_invalid_aggregate_raises(self): - """Invalid aggregate value should raise ValueError.""" + def test_fit_time_aggregate_kwarg_removed(self): + """4.0: the fit-time ``aggregate=`` keyword is gone (native TypeError); + the event study is a post-fit ``results.aggregate("event_study")``.""" data = pd.DataFrame( { "unit": [1, 1, 2, 2], @@ -384,19 +378,16 @@ def test_invalid_aggregate_raises(self): } ) est = ContinuousDiD() - # M-025: the shim warns on ANY supplied value BEFORE the surviving - # value validation raises - both must fire, in that order. - with pytest.warns(FutureWarning, match=r"ContinuousDiD\.fit\(aggregate=\)"): - with pytest.raises(ValueError, match="Invalid aggregate"): - est.fit( - data, - "outcome", - "unit", - "period", - "first_treat", - "dose", - aggregate="event_study", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + data, + "outcome", + "unit", + "period", + "first_treat", + "dose", + aggregate="event_study", + ) def test_no_never_treated_error(self): data = pd.DataFrame( @@ -507,10 +498,19 @@ def test_to_dataframe_group_time(self, results): assert "time" in df.columns assert "att_glob" in df.columns - def test_to_dataframe_event_study_error(self, results): - """Should error if event study not computed.""" - with pytest.raises(ValueError, match="[Ee]vent study"): - results.to_dataframe(level="event_study") + def test_to_dataframe_event_study_bootstrapped_populated(self, results): + """A bootstrapped fit materializes the event-study surface at fit + time (4.0: always, no fit-time kwarg), so the legacy frame is + available and agrees row-for-row with the relayed container; the + analytic-fit error is pinned in test_event_study_to_dataframe.""" + assert results.event_study_effects is not None + df = results.to_dataframe(level="event_study") + assert "relative_period" in df.columns + assert "att_glob" in df.columns + es = results.aggregate("event_study") + assert len(df) == len(es.event_time) + np.testing.assert_array_equal(df["relative_period"].to_numpy(), es.event_time) + np.testing.assert_allclose(df["att_glob"].to_numpy(), es.att, rtol=1e-14, atol=0) def test_to_dataframe_invalid_level(self, results): with pytest.raises(ValueError, match="Unknown level"): @@ -584,20 +584,16 @@ def test_event_study_computed(self): noise_sd=0.5, ) est = ContinuousDiD(n_bootstrap=49, seed=42) - results = est.fit( - data, - "outcome", - "unit", - "period", - "first_treat", - "dose", - aggregate="eventstudy", - ) + results = est.fit(data, "outcome", "unit", "period", "first_treat", "dose") + # bootstrapped fits materialize the event-study surface at fit time + # and the post-fit container relays it assert results.event_study_effects is not None + es = results.aggregate("event_study") # Should have pre and post relative periods rel_periods = sorted(results.event_study_effects.keys()) assert min(rel_periods) < 0 # Pre-treatment assert max(rel_periods) >= 0 # Post-treatment + assert sorted(int(e) for e in es.event_time) == rel_periods def test_event_study_to_dataframe(self): data = generate_continuous_did_data( @@ -608,18 +604,15 @@ def test_event_study_to_dataframe(self): noise_sd=0.5, ) est = ContinuousDiD() - results = est.fit( - data, - "outcome", - "unit", - "period", - "first_treat", - "dose", - aggregate="eventstudy", - ) - df = results.to_dataframe(level="event_study") - assert "relative_period" in df.columns - assert "att_glob" in df.columns + results = est.fit(data, "outcome", "unit", "period", "first_treat", "dose") + # analytic fits leave the legacy fit-time field None; the surface is + # the post-fit container + assert results.event_study_effects is None + with pytest.raises(ValueError, match=r"aggregate\('event_study'\)"): + results.to_dataframe(level="event_study") + df = results.aggregate("event_study").to_dataframe() + assert "event_time" in df.columns + assert "att" in df.columns def test_event_study_not_yet_treated(self): """Event study with control_group='not_yet_treated' and analytic SE.""" @@ -672,15 +665,8 @@ def test_event_study_not_yet_treated_bootstrap(self, ci_params): n_bootstrap=n_boot, seed=42, ) - results = est.fit( - data, - "outcome", - "unit", - "period", - "first_treat", - "dose", - aggregate="eventstudy", - ) + results = est.fit(data, "outcome", "unit", "period", "first_treat", "dose") + # bootstrapped fits populate the surface at fit time assert results.event_study_effects is not None rel_periods = sorted(results.event_study_effects.keys()) assert min(rel_periods) < 0 # Pre-treatment @@ -1766,8 +1752,9 @@ def test_covariate_metadata_on_results(self): assert "Covariates" in res.summary() def test_covariate_eventstudy_and_bootstrap(self): - """Covariate paths compose with aggregate='eventstudy' and n_bootstrap>0 - (reg + dr), with finite inference and bootstrap SE near analytical.""" + """Covariate paths compose with the post-fit event study and + n_bootstrap>0 (reg + dr), with finite inference and bootstrap SE near + analytical.""" data = _cov_data(n_units=200) for method in ("reg", "dr"): es = ContinuousDiD(estimation_method=method).fit( @@ -1777,10 +1764,10 @@ def test_covariate_eventstudy_and_bootstrap(self): "period", "first_treat", "dose", - aggregate="eventstudy", covariates=["x1"], ) assert np.isfinite(es.overall_att) and np.isfinite(es.overall_att_se) + assert len(es.aggregate("event_study").event_time) > 0 ana = ContinuousDiD(estimation_method=method).fit( data, "outcome", diff --git a/tests/test_csdid_ported.py b/tests/test_csdid_ported.py index 43baa853e..b3fe9b771 100644 --- a/tests/test_csdid_ported.py +++ b/tests/test_csdid_ported.py @@ -18,6 +18,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff import CallawaySantAnna from tests.helpers.csdid_dgp import build_csdid_sim_data, build_two_group_sim_data @@ -188,16 +189,20 @@ def test_two_period_all_aggregations(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="all", ) assert ( abs(results.overall_att - 1.0) < 0.5 ), f"Simple ATT = {results.overall_att:.3f}, expected ≈ 1.0" + # 4.0: the fit stores no aggregation surface; both levels are post-fit + assert results.event_study_effects is None and results.group_effects is None # Event study: post-treatment effect ≈ 1 - if results.event_study_effects: - post_effects = {e: v for e, v in results.event_study_effects.items() if e >= 0} - for e, eff in post_effects.items(): - assert abs(eff["effect"] - 1.0) < 0.5, f"Event study e={e}: {eff['effect']:.3f}" + post_effects = {e: v for e, v in es_dict(results).items() if e >= 0} + assert post_effects + for e, eff in post_effects.items(): + assert abs(eff["effect"] - 1.0) < 0.5, f"Event study e={e}: {eff['effect']:.3f}" + # Group: the single cohort's effect ≈ 1 + for g, eff in group_dict(results).items(): + assert abs(eff["effect"] - 1.0) < 0.5, f"Group g={g}: {eff['effect']:.3f}" def test_dynamic_effects_dose_response(self, ci_params): """Ported from R did::test-att_gt.R: 'aggregations' (dynamic) @@ -221,14 +226,14 @@ def test_dynamic_effects_dose_response(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) - assert results.event_study_effects is not None + es_all = es_dict(results) + assert es_all # Post-treatment event study effects should be positive and increasing. # R: panel=FALSE gives att(e=2) ≈ 2. Panel=TRUE may give ≈ 3 # (different aggregation weights). Check monotonic increase. post_effects = sorted( - [(e, v["effect"]) for e, v in results.event_study_effects.items() if e >= 0], + [(e, v["effect"]) for e, v in es_all.items() if e >= 0], key=lambda x: x[0], ) if len(post_effects) >= 2: @@ -262,11 +267,11 @@ def test_aggregation_group(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None + grp = group_dict(results) + assert grp # At least one group should have a sizeable effect - any_large = any(abs(v["effect"]) > 1.0 for v in results.group_effects.values()) + any_large = any(abs(v["effect"]) > 1.0 for v in grp.values()) assert any_large, "Expected group-specific heterogeneity" def test_aggregation_balance_e(self, ci_params): @@ -293,11 +298,9 @@ def test_aggregation_balance_e(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", - balance_e=1, ) - assert results.event_study_effects is not None - es = results.event_study_effects + es = es_dict(results, balance_e=1) + assert es if 0 in es and 1 in es: diff = es[1]["effect"] - es[0]["effect"] assert abs(diff - 1.0) < 0.5, f"balance_e consecutive diff = {diff:.3f}, expected ≈ 1.0" @@ -333,12 +336,12 @@ def test_non_consecutive_time_periods(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) - assert results.event_study_effects is not None + es_all = es_dict(results) + assert es_all # R: agg_dynamic$att.egt[egt==2] ≈ 3 - if 2 in results.event_study_effects: - att_e2 = results.event_study_effects[2]["effect"] + if 2 in es_all: + att_e2 = es_all[2]["effect"] assert abs(att_e2 - 3.0) < 1.0, f"Non-consecutive e=2: {att_e2:.3f}, expected ≈ 3" def test_anticipation_with_and_without(self, ci_params): @@ -369,10 +372,10 @@ def test_anticipation_with_and_without(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) - if results.event_study_effects and 2 in results.event_study_effects: - att_e2 = results.event_study_effects[2]["effect"] + es_all = es_dict(results) + if 2 in es_all: + att_e2 = es_all[2]["effect"] assert abs(att_e2 - 2.0) < 0.5, f"Anticipation=1 e=2: {att_e2:.3f}, expected ≈ 2" # Without anticipation (incorrectly ignoring it) @@ -384,10 +387,10 @@ def test_anticipation_with_and_without(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) - if results_no.event_study_effects and 2 in results_no.event_study_effects: - att_e2_no = results_no.event_study_effects[2]["effect"] + es_no = es_dict(results_no) + if 2 in es_no: + att_e2_no = es_no[2]["effect"] assert abs(att_e2_no - 3.0) < 0.5, f"No anticipation e=2: {att_e2_no:.3f}, expected ≈ 3" def test_varying_vs_universal_pretreatment(self, ci_params): @@ -417,8 +420,8 @@ def test_varying_vs_universal_pretreatment(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) + es_v = es_dict(res_v) # Universal base period cs_u = CallawaySantAnna(estimation_method="dr", base_period="universal") @@ -429,15 +432,15 @@ def test_varying_vs_universal_pretreatment(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) + es_u = es_dict(res_u) # R: varying at e=-3 ≈ 1, universal at e=-3 ≈ -2 - if res_v.event_study_effects and -3 in res_v.event_study_effects: - att_v = res_v.event_study_effects[-3]["effect"] + if -3 in es_v: + att_v = es_v[-3]["effect"] assert abs(att_v - 1.0) < 0.5, f"Varying e=-3: {att_v:.3f}, expected ≈ 1" - if res_u.event_study_effects and -3 in res_u.event_study_effects: - att_u = res_u.event_study_effects[-3]["effect"] + if -3 in es_u: + att_u = es_u[-3]["effect"] assert abs(att_u - (-2.0)) < 0.5, f"Universal e=-3: {att_u:.3f}, expected ≈ -2" def test_small_groups_warning(self): @@ -581,8 +584,8 @@ def test_significance_level_and_cbands(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) + es05 = res05.aggregate("event_study") cs01 = CallawaySantAnna( estimation_method="dr", @@ -598,17 +601,12 @@ def test_significance_level_and_cbands(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) + es01 = res01.aggregate("event_study") # 5% CI should be narrower than (or equal to) 1% CI - if ( - res05.event_study_effects - and res01.event_study_effects - and res05.cband_crit_value - and res01.cband_crit_value - ): - assert res05.cband_crit_value <= res01.cband_crit_value + 0.01 + assert es05.cband_crit_value is not None and es01.cband_crit_value is not None + assert es05.cband_crit_value <= es01.cband_crit_value + 0.01 def test_some_units_treated_first_period(self): """Ported from R did::test-att_gt.R: 'some units treated in first period' @@ -712,18 +710,19 @@ def test_fewer_time_periods_than_groups(self, ci_params): time="period", first_treat="first_treat", covariates=["X"], - aggregate="all", ) # Dynamic aggregation should have no NaN ATTs - if results.event_study_effects: - for e, eff in results.event_study_effects.items(): - assert np.isfinite(eff["effect"]), f"NaN in event_study at e={e}" + es_all = es_dict(results) + assert es_all + for e, eff in es_all.items(): + assert np.isfinite(eff["effect"]), f"NaN in event_study at e={e}" # Group aggregation should have no NaN ATTs - if results.group_effects: - for g, eff in results.group_effects.items(): - assert np.isfinite(eff["effect"]), f"NaN in group_effects at g={g}" + grp = group_dict(results) + assert grp + for g, eff in grp.items(): + assert np.isfinite(eff["effect"]), f"NaN in group_effects at g={g}" def test_zero_pretreatment_outcomes(self): """Ported from R did::test-user_bug_fixes.R: '0 pre-treatment estimates...' @@ -1088,18 +1087,16 @@ def test_golden_dynamic_effects(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) r_dyn = scenario["results"]["dynamic"] - if results.event_study_effects: - for i, e in enumerate(r_dyn["egt"]): - e = int(e) - if e in results.event_study_effects: - py_att = results.event_study_effects[e]["effect"] - r_att = r_dyn["att"][i] - assert ( - abs(py_att - r_att) < 0.05 - ), f"Dynamic e={e}: Py={py_att:.4f}, R={r_att:.4f}" + es_all = es_dict(results) + assert es_all + for i, e in enumerate(r_dyn["egt"]): + e = int(e) + if e in es_all: + py_att = es_all[e]["effect"] + r_att = r_dyn["att"][i] + assert abs(py_att - r_att) < 0.05, f"Dynamic e={e}: Py={py_att:.4f}, R={r_att:.4f}" def test_golden_non_consecutive_periods(self, golden_values): """Non-consecutive periods {1,2,5,7} match R at the per-cell level. @@ -1125,7 +1122,6 @@ def test_golden_non_consecutive_periods(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) r_gt = scenario["results"]["group_time"] for i, (g, t) in enumerate(zip(r_gt["group"], r_gt["time"])): @@ -1161,18 +1157,18 @@ def test_golden_anticipation(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) r_dyn = scenario["results"]["dynamic"] - if results.event_study_effects: - for i, e in enumerate(r_dyn["egt"]): - e = int(e) - if e in results.event_study_effects: - py_att = results.event_study_effects[e]["effect"] - r_att = r_dyn["att"][i] - assert ( - abs(py_att - r_att) < 0.1 - ), f"Anticipation e={e}: Py={py_att:.4f}, R={r_att:.4f}" + es_all = es_dict(results) + assert es_all + for i, e in enumerate(r_dyn["egt"]): + e = int(e) + if e in es_all: + py_att = es_all[e]["effect"] + r_att = r_dyn["att"][i] + assert ( + abs(py_att - r_att) < 0.1 + ), f"Anticipation e={e}: Py={py_att:.4f}, R={r_att:.4f}" def test_golden_varying_vs_universal(self, golden_values): """Both base period modes match R.""" @@ -1265,7 +1261,6 @@ def test_golden_fewer_periods(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="all", ) r_gt = scenario["results"]["group_time"] for i, (g, t) in enumerate(zip(r_gt["group"], r_gt["time"])): @@ -1335,7 +1330,6 @@ def test_golden_simple_aggregation_se(self, golden_values): unit="unit", time="period", first_treat="first_treat", - aggregate="simple", ) r_simple = scenario["results"]["simple"] assert ( @@ -1375,14 +1369,14 @@ def test_golden_event_study_aggregation_se(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) + es_all = es_dict(results) blk = scenario["results"][f"{bp}_dynamic"] n_checked = 0 for e, r_att, r_se in zip(blk["egt"], blk["att"], blk["se"]): if r_att == "NA" or r_se == "NA": continue - es = results.event_study_effects.get(int(e)) + es = es_all.get(int(e)) assert es is not None, f"{bp}: missing event time e={e}" assert ( abs(es["effect"] - float(r_att)) < 2e-3 @@ -1457,19 +1451,18 @@ def test_golden_reg_aggregation_se(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="all", ) r = scenario["results"] assert abs(results.overall_att - r["simple"]["att"]) < 1e-8 assert abs(results.overall_se - r["simple"]["se"]) < 1e-8 g_att = np.atleast_1d(r["group"]["att"])[0] g_se = np.atleast_1d(r["group"]["se"])[0] - grp = results.group_effects[2] + grp = group_dict(results)[2] assert abs(grp["effect"] - g_att) < 1e-8 assert abs(grp["se"] - g_se) < 1e-8 d_att = np.atleast_1d(r["dynamic"]["att"])[0] d_se = np.atleast_1d(r["dynamic"]["se"])[0] - es0 = results.event_study_effects[0] + es0 = es_dict(results)[0] assert abs(es0["effect"] - d_att) < 1e-8 assert abs(es0["se"] - d_se) < 1e-8 @@ -1484,11 +1477,11 @@ def test_golden_reg_aggregation_se(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="event_study", ) r_dyn = scenario["results"]["dynamic"] + es_all = es_dict(results) for i, e in enumerate(r_dyn["egt"]): - es = results.event_study_effects.get(int(e)) + es = es_all.get(int(e)) assert es is not None, f"missing event time e={e}" assert ( abs(es["effect"] - r_dyn["att"][i]) < 1e-8 @@ -1525,7 +1518,6 @@ def test_golden_ipw_aggregation_se_vs_r_did_251(self, golden_values): time="period", first_treat="first_treat", covariates=["X"], - aggregate="all", ) # R did 2.5.1: aggte(type="simple", bstrap=FALSE, cband=FALSE) assert abs(results.overall_att - r_results["simple"]["att"]) < 1e-6 @@ -1533,15 +1525,17 @@ def test_golden_ipw_aggregation_se_vs_r_did_251(self, golden_values): # aggte(type="dynamic"): (att.egt, se.egt) per event time r_dyn = r_results["dynamic"] assert len(r_dyn["egt"]) > 0 + es_all = es_dict(results) for e, r_att, r_se in zip(r_dyn["egt"], r_dyn["att"], r_dyn["se"]): - es = results.event_study_effects[int(e)] + es = es_all[int(e)] assert abs(es["effect"] - r_att) < 1e-6, f"ipw dyn ATT(e={e})" assert abs(es["se"] - r_se) < 1e-6, f"ipw dyn SE(e={e})" # aggte(type="group"): (att.egt, se.egt) per cohort r_grp = r_results["group"] assert len(r_grp["egt"]) > 0 + grp_all = group_dict(results) for g, r_att, r_se in zip(r_grp["egt"], r_grp["att"], r_grp["se"]): - grp = results.group_effects[int(g)] + grp = grp_all[int(g)] assert abs(grp["effect"] - r_att) < 1e-6, f"ipw group ATT(g={g})" assert abs(grp["se"] - r_se) < 1e-6, f"ipw group SE(g={g})" @@ -1807,9 +1801,8 @@ def test_universal_gapped_event_study_reference_dilution_matches_r(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - es = res.event_study_effects + es = es_dict(res) gt = res.group_time_effects real = gt[(5, 3)]["effect"] # cohort 5 estimated cell at e=-2 real_se = gt[(5, 3)]["se"] @@ -1865,7 +1858,6 @@ def test_universal_reference_weight_ignores_skipped_cells(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) gt = res.group_time_effects # (7,1) is skipped; (7,5) reference still carries a positive weight. @@ -1874,9 +1866,7 @@ def test_universal_reference_weight_ignores_skipped_cells(self): # e=-2 is still diluted to half the real cell (weight not zeroed by the # skipped cell). Bootstrap SE differs from analytical but the POINT is # the same diluted estimand. - assert res.event_study_effects[-2]["effect"] == pytest.approx( - 0.5 * gt[(5, 3)]["effect"], rel=1e-6 - ) + assert es_dict(res)[-2]["effect"] == pytest.approx(0.5 * gt[(5, 3)]["effect"], rel=1e-6) def test_universal_reference_materialized_for_all_skipped_cohort(self): """A cohort whose non-reference cells are ALL non-estimable still gets its @@ -1911,8 +1901,10 @@ def test_universal_reference_materialized_for_all_skipped_cohort(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + # the post-fit event study (bootstrap replay on the n_boot=199 arm) + # must also be computable on this all-skipped-cohort panel + assert es_dict(res) gt = res.group_time_effects # Every non-reference cohort-7 cell is skipped, yet its base (7,5) # reference is materialized with the fixed cohort mass (40), not @@ -1983,7 +1975,6 @@ def _fit(self, golden): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) def test_cells_match_r(self, unbalanced_golden): @@ -2009,11 +2000,12 @@ def test_event_study_matches_r(self, unbalanced_golden): """ with pytest.warns(UserWarning): res = self._fit(unbalanced_golden) + py_es = es_dict(res) es = unbalanced_golden["event_study"] n_units = unbalanced_golden["meta"]["n_units"] fac = np.sqrt(n_units / (n_units - 1)) for i, e in enumerate(es["egt"]): - v = res.event_study_effects.get(int(e)) + v = py_es.get(int(e)) assert v is not None, f"missing event time {e}" assert v["effect"] == pytest.approx(es["att"][i], abs=1e-9) assert v["se"] == pytest.approx(es["se"][i] * fac, abs=1e-8) @@ -2041,17 +2033,15 @@ def test_flag_inert_on_balanced_panel(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) r0 = CallawaySantAnna(estimation_method="reg").fit(bal, **kw) r1 = CallawaySantAnna(estimation_method="reg", allow_unbalanced_panel=True).fit(bal, **kw) - for e in r0.event_study_effects: - assert r1.event_study_effects[e]["effect"] == pytest.approx( - r0.event_study_effects[e]["effect"], abs=1e-13 - ) - assert r1.event_study_effects[e]["se"] == pytest.approx( - r0.event_study_effects[e]["se"], abs=1e-13 - ) + es0 = es_dict(r0) + es1 = es_dict(r1) + assert es0 and set(es0) == set(es1) + for e in es0: + assert es1[e]["effect"] == pytest.approx(es0[e]["effect"], abs=1e-13) + assert es1[e]["se"] == pytest.approx(es0[e]["se"], abs=1e-13) def test_unbalanced_default_path_warns(self, unbalanced_golden): """Without the flag, an unbalanced panel warns (no-silent-failures).""" @@ -2062,7 +2052,6 @@ def test_unbalanced_default_path_warns(self, unbalanced_golden): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) def test_user_cluster_honored(self, unbalanced_golden): @@ -2082,11 +2071,11 @@ def test_user_cluster_honored(self, unbalanced_golden): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + py_es = es_dict(res) es = unbalanced_golden["event_study"] for i, e in enumerate(es["egt"]): - v = res.event_study_effects.get(int(e)) + v = py_es.get(int(e)) if v is not None: assert v["effect"] == pytest.approx(es["att"][i], abs=1e-9) @@ -2146,14 +2135,14 @@ def test_group_effects_match_r(self, unbalanced_golden): unit="unit", time="period", first_treat="first_treat", - aggregate="group", ) + grp = group_dict(res) g = unbalanced_golden["group"] n_units = unbalanced_golden["meta"]["n_units"] fac = np.sqrt(n_units / (n_units - 1)) - assert res.group_effects is not None + assert grp for i, gval in enumerate(g["egt"]): - eff = res.group_effects.get(gval) or res.group_effects.get(float(gval)) + eff = grp.get(gval) or grp.get(float(gval)) assert eff is not None, f"missing group {gval}" assert eff["effect"] == pytest.approx(g["att"][i], abs=1e-9) assert eff["se"] == pytest.approx(g["se"][i] * fac, abs=1e-8) @@ -2168,7 +2157,6 @@ def test_bootstrap_event_study_uses_unit_weights(self, unbalanced_golden): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) df = _unbal_df(unbalanced_golden) with pytest.warns(UserWarning): @@ -2183,10 +2171,11 @@ def test_bootstrap_event_study_uses_unit_weights(self, unbalanced_golden): n_bootstrap=200, seed=11, ).fit(df, **kw) - for e in a.event_study_effects: - assert b.event_study_effects[e]["effect"] == pytest.approx( - a.event_study_effects[e]["effect"], abs=1e-12 - ) + es_a = es_dict(a) + es_b = es_dict(b) # bootstrap replay + assert es_a and set(es_a) == set(es_b) + for e in es_a: + assert es_b[e]["effect"] == pytest.approx(es_a[e]["effect"], abs=1e-12) def test_result_metadata_reflects_rc_routing(self, unbalanced_golden): """The result records the flag AND whether RC-on-panel actually engaged, @@ -2296,7 +2285,6 @@ def test_nan_outcome_triggers_rc_routing(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) def mk(): @@ -2317,8 +2305,11 @@ def mk(): if v.get("is_reference"): continue assert v["effect"] == pytest.approx(ref.group_time_effects[k]["effect"], abs=1e-12) - for e, v in res.event_study_effects.items(): - assert v["effect"] == pytest.approx(ref.event_study_effects[e]["effect"], abs=1e-12) + es_res = es_dict(res) + es_ref = es_dict(ref) + assert es_res and set(es_res) == set(es_ref) + for e, v in es_res.items(): + assert v["effect"] == pytest.approx(es_ref[e]["effect"], abs=1e-12) assert res.overall_att == pytest.approx(ref.overall_att, abs=1e-12) def test_complete_case_rectangular_stays_inert(self): diff --git a/tests/test_diagnostic_report.py b/tests/test_diagnostic_report.py index 9698eb78e..8f73f7ffd 100644 --- a/tests/test_diagnostic_report.py +++ b/tests/test_diagnostic_report.py @@ -22,6 +22,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import legacy_standin import diff_diff as dd from diff_diff import ( @@ -108,17 +109,29 @@ def cs_fit(): # methodology-critical reason (Rambachan-Roth bounds are not valid for # interpretation on consecutive-comparison pre-periods). See the # round-5 CI review on PR #318. + # 4.0: no fit-time surface - DR derives the event study via + # results.aggregate("event_study") (the default route). cs = CallawaySantAnna(base_period="universal").fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) return cs, sdf +@pytest.fixture(scope="module") +def cs_raw_fit(cs_fit): + """The RAW-FIELD route: ``cs_fit`` with the 3.x fit-time fields populated by + hand from the container (the legacy-pickle shape). DR reads the raw field + FIRST, so this is the precedence-route fixture.""" + cs, sdf = cs_fit + raw = legacy_standin(cs) + assert raw.event_study_effects is not None + return raw, sdf + + @pytest.fixture(scope="module") def edid_fit(): warnings.filterwarnings("ignore") @@ -387,11 +400,14 @@ def test_precomputed_pretrends_power_parity_with_default_path(self, cs_fit): fit, data = cs_fit - # Precompute the power result from the same fit. The compute function - # populates ``original_results`` on the output so DR's precomputed - # adapter can inspect the source fit's event_study_vcov. - pp = compute_pretrends_power(fit, alpha=0.05, target_power=0.80, violation_type="linear") - assert getattr(pp, "original_results", None) is fit + # Precompute the power result from the same fit's event-study + # container (4.0: the consumer takes the post-fit container). The + # compute function records ``covariance_source`` and populates + # ``original_results`` on the output for DR's precomputed adapter. + es = fit.aggregate("event_study") + pp = compute_pretrends_power(es, alpha=0.05, target_power=0.80, violation_type="linear") + assert getattr(pp, "original_results", None) is es + assert pp.covariance_source == "full_pre_period_vcov" dr_default = DiagnosticReport(fit, data=data).to_dict() dr_precomputed = DiagnosticReport( @@ -3083,14 +3099,16 @@ def test_two_stage_nan_vcov_row_excluded_from_pt_family(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - balance_e=4, ) + # The balance_e-restricted surface reaches DR through the RAW + # field (DR derives without balance_e): a legacy stand-in carrying + # aggregate("event_study", balance_e=4) in the 3.x fields. + res = legacy_standin(res, balance_e=4) # Path-B precondition: -6 is an estimated column with NaN inference # and a NaN-filled VCV row; finite pre-periods {-4, -3, -2} remain. assert res.event_study_effects[-6]["n_obs"] == 0 assert not np.isfinite(res.event_study_effects[-6]["se"]) - idx = res.event_study_vcov_index + idx = [int(h) for h in res.event_study_vcov_index] assert -6 in idx and -4 in idx pos = idx.index(-6) assert np.isnan(res.event_study_vcov[pos, :]).all() @@ -3290,10 +3308,10 @@ def test_two_stage_singular_vcov_downgrades_to_bonferroni(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - V = res.event_study_vcov - idx = res.event_study_vcov_index + es = res.aggregate("event_study") + V = es.vcov + idx = es.vcov_index.tolist() pre_idx = [i for i, h in enumerate(idx) if h < -1 and np.isfinite(V[i, i])] sub = V[np.ix_(pre_idx, pre_idx)] sub = 0.5 * (sub + sub.T) @@ -3485,11 +3503,13 @@ def cs_bootstrap_plain_fit(): @pytest.fixture(scope="module") def imp_pretrends_fits(): - """(raw-kwarg fit, plain fit) ImputationDiD pair, both pretrends=True. + """(raw-field stand-in, plain fit) ImputationDiD pair, both pretrends=True. ``pretrends=True`` is REQUIRED for estimated pre-period horizons to exist at all — under the default ``pretrends=False`` the derived - surface carries only the reference lead. + surface carries only the reference lead. Since 4.0 an analytic fit + stores no surface, so the raw-field route is a legacy stand-in with the + container's surface hand-set in the 3.x fields. """ from diff_diff import ImputationDiD @@ -3497,22 +3517,24 @@ def imp_pretrends_fits(): kw = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") with warnings.catch_warnings(): warnings.simplefilter("ignore") - raw = ImputationDiD(pretrends=True).fit(sdf, aggregate="event_study", **kw) plain = ImputationDiD(pretrends=True).fit(sdf, **kw) + assert plain.event_study_effects is None + raw = legacy_standin(plain) return raw, plain, sdf @pytest.fixture(scope="module") def ts_pretrends_fits(): - """(raw-kwarg fit, plain fit) TwoStageDiD pair, both pretrends=True.""" + """(raw-field stand-in, plain fit) TwoStageDiD pair, both pretrends=True.""" from diff_diff import TwoStageDiD sdf = generate_staggered_data(n_units=100, n_periods=6, treatment_effect=1.5, seed=7) kw = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") with warnings.catch_warnings(): warnings.simplefilter("ignore") - raw = TwoStageDiD(pretrends=True).fit(sdf, aggregate="event_study", **kw) plain = TwoStageDiD(pretrends=True).fit(sdf, **kw) + assert plain.event_study_effects is None + raw = legacy_standin(plain) return raw, plain, sdf @@ -3530,9 +3552,9 @@ def test_plain_cs_fit_runs_event_study_gated_checks(self, cs_plain_fit): for check in ("parallel_trends", "pretrends_power", "sensitivity", "heterogeneity"): assert d[check]["status"] == "ran", (check, d[check]) - def test_plain_cs_pt_parity_with_kwarg_route(self, cs_plain_fit, cs_fit): + def test_plain_cs_pt_parity_with_raw_field_route(self, cs_plain_fit, cs_raw_fit): plain, _ = cs_plain_fit - raw, _ = cs_fit + raw, _ = cs_raw_fit d_plain = DiagnosticReport(plain).run_all().to_dict()["parallel_trends"] d_raw = DiagnosticReport(raw).run_all().to_dict()["parallel_trends"] assert d_plain["method"] == "joint_wald_event_study" @@ -3552,9 +3574,9 @@ def test_plain_cs_pt_parity_with_kwarg_route(self, cs_plain_fit, cs_fit): np.testing.assert_allclose(a["coef"], b["coef"], rtol=1e-12, atol=1e-12) np.testing.assert_allclose(a["se"], b["se"], rtol=1e-12, atol=1e-12) - def test_plain_cs_pretrends_power_parity(self, cs_plain_fit, cs_fit): + def test_plain_cs_pretrends_power_parity(self, cs_plain_fit, cs_raw_fit): plain, _ = cs_plain_fit - raw, _ = cs_fit + raw, _ = cs_raw_fit d_plain = DiagnosticReport(plain).run_all().to_dict()["pretrends_power"] d_raw = DiagnosticReport(raw).run_all().to_dict()["pretrends_power"] assert d_plain["status"] == d_raw["status"] == "ran" @@ -3571,9 +3593,9 @@ def test_plain_cs_pretrends_power_parity(self, cs_plain_fit, cs_fit): atol=1e-3, ) - def test_plain_cs_sensitivity_parity(self, cs_plain_fit, cs_fit): + def test_plain_cs_sensitivity_parity(self, cs_plain_fit, cs_raw_fit): plain, _ = cs_plain_fit - raw, _ = cs_fit + raw, _ = cs_raw_fit d_plain = DiagnosticReport(plain).run_all().to_dict()["sensitivity"] d_raw = DiagnosticReport(raw).run_all().to_dict()["sensitivity"] assert d_plain["status"] == d_raw["status"] == "ran" @@ -3586,9 +3608,9 @@ def test_plain_cs_sensitivity_parity(self, cs_plain_fit, cs_fit): np.testing.assert_allclose(a["ci_lower"], b["ci_lower"], rtol=1e-12, atol=1e-12) np.testing.assert_allclose(a["ci_upper"], b["ci_upper"], rtol=1e-12, atol=1e-12) - def test_derived_route_emits_pre_period_source(self, cs_plain_fit, cs_fit): + def test_derived_route_emits_pre_period_source(self, cs_plain_fit, cs_raw_fit): plain, _ = cs_plain_fit - raw, _ = cs_fit + raw, _ = cs_raw_fit d_plain = DiagnosticReport(plain).run_all().to_dict() d_raw = DiagnosticReport(raw).run_all().to_dict() assert d_plain["parallel_trends"]["pre_period_source"] == "aggregate_event_study" @@ -3901,9 +3923,9 @@ def test_continuous_heterogeneity_numeric_parity(self): ) with warnings.catch_warnings(): warnings.simplefilter("ignore") - # ContinuousDiD's fit-time spelling has no underscore. - raw = ContinuousDiD().fit(cdf, aggregate="eventstudy", **kw) plain = ContinuousDiD().fit(cdf, **kw) + assert plain.event_study_effects is None + raw = legacy_standin(plain) self._assert_heterogeneity_parity(raw, plain) def test_pre_period_source_on_inconclusive_derived_return(self, cs_plain_fit): @@ -3932,23 +3954,28 @@ def test_anticipation_boundary_respected_on_derived_route(self): assert all(p["period"] < -1 for p in d["per_period"]) def test_requested_but_empty_raw_field_is_authoritative(self): - # Round-4 dual-review P0 pin: a fit whose fit-time aggregate=/ - # balance_e= produced the REQUESTED-but-empty {} sentinel must - # behave exactly as today — no silent re-derivation (which would - # drop the fit-time balance_e and report effects over cohorts the - # fit deliberately excluded). + # Round-4 dual-review P0 pin: a results object whose raw field holds + # the REQUESTED-but-empty {} sentinel (what a 3.x fit-time + # balance_e=4 stored on this panel - a legacy pickle today) must + # behave exactly as before — no silent re-derivation (which would + # drop the restriction and report effects over cohorts the surface + # deliberately excluded). + import dataclasses + with warnings.catch_warnings(): warnings.simplefilter("ignore") sdf = generate_staggered_data(n_units=100, n_periods=6, treatment_effect=1.5, seed=7) - e = EfficientDiD().fit( + plain = EfficientDiD().fit( sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", - balance_e=4, ) + # balance_e=4 retains no cohort on this 6-period panel. + assert len(plain.aggregate("event_study", balance_e=4).event_time) == 0 + assert plain.event_study_effects is None + e = dataclasses.replace(plain, event_study_effects={}) assert e.event_study_effects == {} calls = {"n": 0} orig = type(e).aggregate @@ -3983,8 +4010,8 @@ def test_no_mutation_after_run_all(self, cs_plain_fit): DiagnosticReport(cs).run_all() assert cs.event_study_effects is None - def test_raw_precedence_never_calls_aggregate(self, cs_fit): - cs, _ = cs_fit + def test_raw_precedence_never_calls_aggregate(self, cs_raw_fit): + cs, _ = cs_raw_fit def boom(self, *args, **kwargs): raise AssertionError("aggregate() must not be called when the raw field is present") @@ -4030,12 +4057,10 @@ def test_survey_backed_raw_vs_derived_pt_parity(self): ) with warnings.catch_warnings(): warnings.simplefilter("ignore") - cs_raw = CallawaySantAnna(base_period="universal").fit( - sdf, aggregate="event_study", **kw - ) cs_plain = CallawaySantAnna(base_period="universal").fit(sdf, **kw) - ts_raw = TwoStageDiD(pretrends=True).fit(sdf, aggregate="event_study", **kw) + cs_raw = legacy_standin(cs_plain) ts_plain = TwoStageDiD(pretrends=True).fit(sdf, **kw) + ts_raw = legacy_standin(ts_plain) for raw, plain in ((cs_raw, cs_plain), (ts_raw, ts_plain)): d_raw = DiagnosticReport(raw).run_all().to_dict()["parallel_trends"] d_plain = DiagnosticReport(plain).run_all().to_dict()["parallel_trends"] @@ -4175,22 +4200,79 @@ def warning_aggregate(self, *args, **kwargs): assert any("single-copy caveat" in w for w in d[check].get("warnings", [])) assert sum("single-copy caveat" in w for w in d["warnings"]) == 1 - def test_bootstrap_failure_heterogeneity_skips_with_context(self): - # Derived heterogeneity producers on a bootstrapped fit: the runner - # section fails closed with the derivation context and publishes no - # numeric heterogeneity fields. - from diff_diff import ImputationDiD + @staticmethod + def _bootstrapped_relay_fits(): + from diff_diff import ImputationDiD, TwoStageDiD sdf = generate_staggered_data(n_units=100, n_periods=6, treatment_effect=1.5, seed=7) + kw = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat") with warnings.catch_warnings(): warnings.simplefilter("ignore") - imp = ImputationDiD(pretrends=True, n_bootstrap=19, seed=1).fit( - sdf, outcome="outcome", unit="unit", time="period", first_treat="first_treat" - ) - d = DiagnosticReport(imp).run_all().to_dict() + imp = ImputationDiD(pretrends=True, n_bootstrap=19, seed=1).fit(sdf, **kw) + ts = TwoStageDiD(pretrends=True, n_bootstrap=19, seed=1).fit(sdf, **kw) + return imp, ts + + def test_bootstrap_relay_heterogeneity_runs_from_group_effects(self): + # 4.0: a bootstrapped plain ImputationDiD / TwoStageDiD fit STORES the + # percentile event-study AND group surfaces (aggregate() relays them), + # so DR heterogeneity RUNS - sourced from the stored ``group_effects`` + # (``_collect_effect_scalars`` reads group_effects first) - and + # publishes numeric fields that match the relayed group container. + for res in self._bootstrapped_relay_fits(): + assert res.bootstrap_results is not None + assert res.event_study_effects is not None + assert res.group_effects is not None + het = DiagnosticReport(res).run_all().to_dict()["heterogeneity"] + assert het["status"] == "ran", het + assert het["source"] == "group_effects" + grp = res.aggregate("group") + finite = grp.att[np.isfinite(grp.att)] + assert het["n_effects"] == finite.size > 0 + np.testing.assert_allclose(het["mean"], float(np.mean(finite)), rtol=1e-12) + np.testing.assert_allclose(het["min"], float(np.min(finite)), rtol=1e-12) + np.testing.assert_allclose(het["max"], float(np.max(finite)), rtol=1e-12) + assert het["sign_consistent"] == bool(np.all(finite >= 0) or np.all(finite <= 0)) + + def test_bootstrap_relay_parallel_trends_runs_on_relayed_surface(self): + # The percentile event-study surface (pretrends=True leads) the + # bootstrapped fit STORES - and aggregate("event_study") relays - + # feeds DR's parallel-trends check: it RUNS. DR reads the stored raw + # field first, so this is the raw-field route (no derived-route + # provenance key), and the field equals the relayed container. + for res in self._bootstrapped_relay_fits(): + es = res.aggregate("event_study") + assert es.vcov is None # percentile surface: no covariance + assert ((es.event_time < -1) & np.isfinite(es.p_value)).any() + times = es.event_time.tolist() + for e, row in res.event_study_effects.items(): + assert row["effect"] == es.att[times.index(e)] + pt = DiagnosticReport(res).run_all().to_dict()["parallel_trends"] + assert pt["status"] == "ran", pt + assert "pre_period_source" not in pt + # No covariance -> the per-row Bonferroni path, never a joint Wald. + assert pt["method"] == "bonferroni" + assert np.isfinite(pt["joint_p_value"]) + assert {p["period"] for p in pt["per_period"]} == { + int(e) for e, p in zip(es.event_time, es.p_value) if e < -1 and np.isfinite(p) + } + + def test_bootstrap_absent_surface_heterogeneity_skips_with_context(self): + # A bootstrapped fit WITHOUT the stored surfaces (a pre-4.0 pickle, + # or a fit whose fail-soft materialization dropped them) fails + # closed: BOTH raw fields cleared (the collector reads group_effects + # first), the derivation hits the relay's absent-surface ValueError, + # and the section publishes the context and no numeric fields. + import dataclasses + + imp, _ = self._bootstrapped_relay_fits() + stripped = dataclasses.replace(imp, event_study_effects=None, group_effects=None) + with pytest.raises(ValueError, match="no stored event_study surface"): + stripped.aggregate("event_study") + d = DiagnosticReport(stripped).run_all().to_dict() het = d["heterogeneity"] assert het["status"] == "skipped" assert "aggregate('event_study')" in het["reason"] - assert "NotImplementedError" in het["reason"] + assert "ValueError" in het["reason"] + assert "no stored event_study surface" in het["reason"] assert "n_effects" not in het assert "mean" not in het diff --git a/tests/test_did_had_parity.py b/tests/test_did_had_parity.py index caeb216d0..6bf69bef5 100644 --- a/tests/test_did_had_parity.py +++ b/tests/test_did_had_parity.py @@ -257,7 +257,7 @@ def test_point_se_ci_parity( py = _python_fit(panels[dgp_name], effects, placebo, trends_lin) # `overall_e1` returns a scalar HeterogeneousAdoptionDiDResults - # (aggregate="overall" path); all others return the array + # (two-period overall path); all others return the array # HeterogeneousAdoptionDiDEventStudyResults. Build a uniform # (rowname, py_att, py_se, py_ci_lo, py_ci_hi) iterator that # works on both. diff --git a/tests/test_efficient_did.py b/tests/test_efficient_did.py index 09bc61b74..44a64991b 100644 --- a/tests/test_efficient_did.py +++ b/tests/test_efficient_did.py @@ -14,6 +14,7 @@ import pandas as pd import pytest from edid_dgp import make_compustat_dgp +from legacy_surface import es_dict, group_dict from diff_diff import CallawaySantAnna, EDiD, EfficientDiD from diff_diff._backend import HAS_RUST_BACKEND @@ -345,8 +346,12 @@ def test_summary_and_dataframe(self): def test_to_dataframe_raises_without_aggregation(self): df = _make_simple_panel() result = EfficientDiD().fit(df, "y", "unit", "time", "first_treat") - with pytest.raises(ValueError, match="Event study effects not computed"): + # 4.0: the legacy fit-time field is never populated; the error + # steers to the post-fit container route. + with pytest.raises(ValueError, match=r"not stored on 4.0 fits.*aggregate\('event_study'\)"): result.to_dataframe("event_study") + with pytest.raises(ValueError, match=r"not stored on 4.0 fits.*aggregate\('group'\)"): + result.to_dataframe("group") def test_repr(self): df = _make_simple_panel() @@ -645,11 +650,8 @@ def _cov_df(n_units=200, n_periods=9, seed=42): @staticmethod def _surfaces(res): gt = {k: (v["effect"], v["se"]) for k, v in res.group_time_effects.items()} - es = ( - {k: (v["effect"], v["se"]) for k, v in res.event_study_effects.items()} - if res.event_study_effects - else {} - ) + # post-fit container (the fit-time surface is gone since 4.0) + es = {k: (v["effect"], v["se"]) for k, v in es_dict(res).items()} return gt, es, (res.overall_att, res.overall_se) @classmethod @@ -737,7 +739,7 @@ def dense_reference( return out df = self._cov_df() - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_fused = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -757,7 +759,7 @@ def test_tile_forced_twin(self, monkeypatch): import diff_diff.efficient_did_covariates as cov_mod df = self._cov_df(n_units=150) - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_single = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -789,9 +791,7 @@ def test_tile_forced_twin_survey_weights(self, monkeypatch): n_units = df["unit"].nunique() pw = np.exp(rng.normal(0, 0.4, n_units)) df = df.merge(pd.DataFrame({"unit": sorted(df["unit"].unique()), "pw": pw}), on="unit") - fit_kwargs = dict( - covariates=["x1", "x2"], aggregate="all", survey_design=SurveyDesign(weights="pw") - ) + fit_kwargs = dict(covariates=["x1", "x2"], survey_design=SurveyDesign(weights="pw")) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_single = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -809,7 +809,7 @@ def test_table_build_count_proportional_to_tiles(self, monkeypatch): n_units = 40 df = self._cov_df(n_units=n_units) - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) calls = {"n": 0} orig_build = cov_mod._build_group_kcov_table @@ -864,7 +864,6 @@ def counting_kwm(*args, **kwargs): "time", "first_treat", covariates=["x1", "x2"], - aggregate="all", ) assert calls["tables"] == 0 assert calls["kernels"] == 0 @@ -877,7 +876,7 @@ def test_mid_size_tile_twin_with_chunked_products(self, monkeypatch): import diff_diff.efficient_did_covariates as cov_mod df = self._cov_df(n_units=150) - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_single = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -893,7 +892,7 @@ def test_one_ulp_stability_conditional(self): per-cell and event-study ATTs and SEs by <= 1e-6 relative under the default ridge (the legacy pseudoinverse path moves ~1e-4).""" df = self._cov_df() - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_base = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -909,12 +908,12 @@ def test_one_ulp_stability_nocov(self): df = _make_staggered_panel(n_per_group=80, n_control=100) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r_base = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", aggregate="all") + r_base = EfficientDiD().fit(df, "y", "unit", "time", "first_treat") df_ulp = df.copy() df_ulp["y"] = np.nextafter(df_ulp["y"].to_numpy(), np.inf) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r_ulp = EfficientDiD().fit(df_ulp, "y", "unit", "time", "first_treat", aggregate="all") + r_ulp = EfficientDiD().fit(df_ulp, "y", "unit", "time", "first_treat") self._assert_close_surfaces(r_ulp, r_base, rtol=1e-6, atol=1e-9) @@ -979,7 +978,6 @@ def spy(a_stack, ridge): "time", "first_treat", covariates=["x1", "x2"], - aggregate="all", ) assert calls["n"] > 0, "rust kernel never dispatched on the conditional fit" @@ -993,7 +991,7 @@ def test_backend_ab_conditional_fit(self, monkeypatch): import diff_diff.efficient_did_covariates as cov_mod df = _make_covariate_panel(n_units=150, n_periods=9, seed=42) - fit_kwargs = dict(covariates=["x1", "x2"], aggregate="all") + fit_kwargs = dict(covariates=["x1", "x2"]) with warnings.catch_warnings(): warnings.simplefilter("ignore") r_rust = EfficientDiD().fit(df, "y", "unit", "time", "first_treat", **fit_kwargs) @@ -1463,7 +1461,7 @@ def test_clustered_se_at_least_as_large(self): assert result_unclustered.overall_se > 0 def test_clustered_aggregate_event_study(self): - """Clustered SE with aggregate='event_study' should produce finite results.""" + """Clustered SE: the post-fit event-study aggregation should produce finite results.""" df = self._make_clustered_panel(n_clusters=60, units_per_cluster=3) result = EfficientDiD(cluster="cluster_id").fit(df, "y", "unit", "time", "first_treat") es = result.aggregate("event_study") @@ -1471,7 +1469,7 @@ def test_clustered_aggregate_event_study(self): assert np.isfinite(es.se[~es.is_reference]).all() def test_clustered_aggregate_all(self): - """Clustered SE with aggregate='all' should produce finite results.""" + """Clustered SE: both post-fit aggregations should produce finite results.""" df = self._make_clustered_panel(n_clusters=60, units_per_cluster=3) result = EfficientDiD(cluster="cluster_id").fit(df, "y", "unit", "time", "first_treat") es = result.aggregate("event_study") @@ -1560,21 +1558,19 @@ def test_cluster_plus_survey_raises(self): ) def test_clustered_bootstrap_aggregate_all(self, ci_params): - """Clustered bootstrap with aggregate='all' should produce finite results.""" + """Clustered bootstrap: the post-fit aggregations (which REPLAY the + fit-time bootstrap on n_bootstrap > 0) produce finite SEs.""" n_boot = ci_params.bootstrap(99) df = self._make_clustered_panel(n_clusters=60, units_per_cluster=3) - # Deprecated fit-time kwarg kept as the parity REFERENCE: post-fit - # aggregate() now REPLAYS the fit-time bootstrap on n_bootstrap > 0. - with pytest.warns(FutureWarning): - result = EfficientDiD(cluster="cluster_id", n_bootstrap=n_boot, seed=42).fit( - df, "y", "unit", "time", "first_treat", aggregate="all" - ) - assert result.event_study_effects is not None - assert result.group_effects is not None - for e, d in result.event_study_effects.items(): - assert np.isfinite(d["se"]) - for g, d in result.group_effects.items(): - assert np.isfinite(d["se"]) + result = EfficientDiD(cluster="cluster_id", n_bootstrap=n_boot, seed=42).fit( + df, "y", "unit", "time", "first_treat" + ) + es = result.aggregate("event_study") + grp = result.aggregate("group") + assert len(es.event_time) > 0 + assert len(grp.label) > 0 + assert np.isfinite(es.se).all() + assert np.isfinite(grp.se).all() class TestSmallCohortWarning: @@ -1770,7 +1766,7 @@ def test_last_cohort_with_anticipation_trims_at_last_g_minus_anticipation(self): assert 7 not in result_a1.groups def test_last_cohort_aggregate_event_study(self): - """last_cohort with aggregate='event_study' should produce finite results.""" + """last_cohort: the post-fit event-study aggregation should produce finite results.""" df = _make_staggered_panel( n_per_group=60, n_control=0, @@ -1786,7 +1782,7 @@ def test_last_cohort_aggregate_event_study(self): assert np.isfinite(es.att).all() def test_last_cohort_aggregate_all(self): - """last_cohort with aggregate='all' should produce finite results.""" + """last_cohort: both post-fit aggregations should produce finite results.""" df = _make_staggered_panel( n_per_group=60, n_control=0, @@ -1837,22 +1833,14 @@ def test_balance_e_with_bootstrap(self, ci_params): """Bootstrap balance_e should produce finite SEs.""" n_boot = ci_params.bootstrap(99) df = _make_staggered_panel(n_per_group=80, n_control=80, groups=(3, 5)) - # Deprecated fit-time kwargs kept as the parity REFERENCE: post-fit - # aggregate() now REPLAYS the fit-time bootstrap on n_bootstrap > 0. - with pytest.warns(FutureWarning): - result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( - df, - "y", - "unit", - "time", - "first_treat", - aggregate="event_study", - balance_e=0, - ) - assert result.event_study_effects is not None - for e, d in result.event_study_effects.items(): - if np.isfinite(d["effect"]): - assert np.isfinite(d["se"]) + result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( + df, "y", "unit", "time", "first_treat" + ) + # post-fit aggregate() REPLAYS the fit-time bootstrap on n_bootstrap > 0 + es = result.aggregate("event_study", balance_e=0) + assert len(es.event_time) > 0 + finite = np.isfinite(es.att) + assert np.isfinite(es.se[finite]).all() def test_balance_e_nan_anchor_filters_group(self): """When a group has NaN at the anchor horizon, bootstrap should @@ -1916,17 +1904,18 @@ def test_bootstrap_se_finite(self, ci_params): def test_bootstrap_with_aggregation(self, ci_params): n_boot = ci_params.bootstrap(99) df = _make_simple_panel() - # Deprecated fit-time kwarg kept as the parity REFERENCE: post-fit - # aggregate() now REPLAYS the fit-time bootstrap on n_bootstrap > 0. - with pytest.warns(FutureWarning): - result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( - df, "y", "unit", "time", "first_treat", aggregate="all" - ) + result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( + df, "y", "unit", "time", "first_treat" + ) assert result.bootstrap_results is not None - if result.event_study_effects: - for e, d in result.event_study_effects.items(): - if np.isfinite(d["effect"]): - assert np.isfinite(d["se"]) + # the legacy fit-time fields are None on EVERY 4.0 EDiD fit; the + # post-fit aggregate() REPLAYS the fit-time bootstrap + assert result.event_study_effects is None + assert result.group_effects is None + es = result.aggregate("event_study") + assert len(es.event_time) > 0 + finite = np.isfinite(es.att) + assert np.isfinite(es.se[finite]).all() def test_bootstrap_override_t_is_effect_over_se(self, ci_params): # Committed pin for the t-recompute semantics of the shared @@ -1937,11 +1926,10 @@ def test_bootstrap_override_t_is_effect_over_se(self, ci_params): # draw values - and catches a misaligned or altered t routine. n_boot = ci_params.bootstrap(99) df = _make_simple_panel() - with pytest.warns(FutureWarning): - result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( - df, "y", "unit", "time", "first_treat", aggregate="all" - ) - rows = list(result.event_study_effects.values()) + list(result.group_effects.values()) + result = EfficientDiD(n_bootstrap=n_boot, seed=42).fit( + df, "y", "unit", "time", "first_treat" + ) + rows = list(es_dict(result).values()) + list(group_dict(result).values()) assert rows for d in rows: se = float(d["se"]) @@ -2664,21 +2652,17 @@ def test_bootstrap_with_covariates_smoke(self): def test_covariates_pt_all_bootstrap(self): """PT-All + bootstrap + covariates end-to-end.""" df = _make_covariate_panel(n_units=300) - # Deprecated fit-time kwarg kept as the parity REFERENCE: post-fit - # aggregate() now REPLAYS the fit-time bootstrap on n_bootstrap > 0. - with pytest.warns(FutureWarning): - result = EfficientDiD(pt_assumption="all", n_bootstrap=99, seed=42).fit( - df, - "y", - "unit", - "time", - "first_treat", - covariates=["x1"], - aggregate="all", - ) + result = EfficientDiD(pt_assumption="all", n_bootstrap=99, seed=42).fit( + df, + "y", + "unit", + "time", + "first_treat", + covariates=["x1"], + ) assert result.bootstrap_results is not None - assert result.event_study_effects is not None - assert result.group_effects is not None + assert result.aggregate("event_study") is not None + assert result.aggregate("group") is not None assert np.isfinite(result.overall_att) assert result.overall_se > 0 @@ -2974,10 +2958,11 @@ class TestEfficientDiDVcovType: stay None under unclustered fits. 7-surface matrix: - 1. Default preserved bit-equally across all 4 aggregate modes - 2. Cluster path preserved bit-equally across the same aggregate grid - 3. TSL-survey path preserved bit-equally across the same aggregate grid - 4. Replicate-survey path preserved bit-equally across the same aggregate grid + 1. Default preserved bit-equally + 2. Cluster path preserved bit-equally + 3. TSL-survey path preserved bit-equally + 4. Replicate-survey path preserved bit-equally (incl. the post-fit + event-study / group containers) 5. Bootstrap × cluster + bootstrap × survey bit-equal 6. set_params(vcov_type=bad) eager revalidation 7. Bootstrap n_psu<2 NaN propagation (defensive fix regression) @@ -2986,10 +2971,9 @@ class TestEfficientDiDVcovType: ``cluster + replicate_weights`` rejection, and a DR-path bit-equality test. """ - # ---- Surface 1: default bit-equal across aggregation modes ------------ + # ---- Surface 1: default bit-equal ------------------------------------ - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_default_hc1_bit_equal_baseline(self, aggregate): + def test_default_hc1_bit_equal_baseline(self): data = _efficient_clustered_panel() common = dict( data=data, @@ -2997,21 +2981,15 @@ def test_default_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) - # Subject is bit equality, not the deprecated fit-time shim - keep - # all four aggregate arms and silence the FutureWarning. - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD().fit(**common) - r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) + r_default = EfficientDiD().fit(**common) + r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se # ---- Surface 2: cluster path bit-equal -------------------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_cluster_hc1_bit_equal_baseline(self, aggregate): + def test_cluster_hc1_bit_equal_baseline(self): data = _efficient_clustered_panel() common = dict( data=data, @@ -3019,19 +2997,15 @@ def test_cluster_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD(cluster="state").fit(**common) - r_explicit = EfficientDiD(cluster="state", vcov_type="hc1").fit(**common) + r_default = EfficientDiD(cluster="state").fit(**common) + r_explicit = EfficientDiD(cluster="state", vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se # ---- Surface 3: TSL-survey path bit-equal ----------------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_survey_tsl_hc1_bit_equal_baseline(self, aggregate): + def test_survey_tsl_hc1_bit_equal_baseline(self): data = _efficient_survey_panel() design = SurveyDesign(weights="weight", psu="psu", strata="stratum", weight_type="pweight") common = dict( @@ -3040,20 +3014,16 @@ def test_survey_tsl_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, survey_design=design, ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD().fit(**common) - r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) + r_default = EfficientDiD().fit(**common) + r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se # ---- Surface 4: replicate-survey path bit-equal ----------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_survey_replicate_hc1_bit_equal_baseline(self, aggregate): + def test_survey_replicate_hc1_bit_equal_baseline(self): data, rep_cols = _efficient_replicate_panel() design = SurveyDesign( weights="weight", @@ -3068,29 +3038,21 @@ def test_survey_replicate_hc1_bit_equal_baseline(self, aggregate): time="time", first_treat="first_treat", survey_design=design, - aggregate=aggregate, ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD().fit(**common) - r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) + r_default = EfficientDiD().fit(**common) + r_explicit = EfficientDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se # Per-horizon / per-group SE override branches must also agree under - # the replicate-weight variance path. - if aggregate in ("event_study", "all"): - assert r_default.event_study_effects is not None - assert r_explicit.event_study_effects is not None - for h in r_default.event_study_effects: - assert ( - r_default.event_study_effects[h]["se"] - == r_explicit.event_study_effects[h]["se"] - ) - if aggregate in ("group", "all"): - assert r_default.group_effects is not None - assert r_explicit.group_effects is not None - for g in r_default.group_effects: - assert r_default.group_effects[g]["se"] == r_explicit.group_effects[g]["se"] + # the replicate-weight variance path (post-fit containers). + es_d, es_e = r_default.aggregate("event_study"), r_explicit.aggregate("event_study") + assert len(es_d.event_time) > 0 + np.testing.assert_array_equal(es_d.event_time, es_e.event_time) + np.testing.assert_array_equal(es_d.se, es_e.se) + g_d, g_e = r_default.aggregate("group"), r_explicit.aggregate("group") + assert len(g_d.label) > 0 + np.testing.assert_array_equal(g_d.label, g_e.label) + np.testing.assert_array_equal(g_d.se, g_e.se) # ---- Surface 5: bootstrap × cluster / × survey bit-equal -------------- @@ -3103,30 +3065,31 @@ def test_bootstrap_cluster_hc1_bit_equal(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD(cluster="state", n_bootstrap=n_boot, seed=11).fit(**common) - r_explicit = EfficientDiD( - cluster="state", n_bootstrap=n_boot, seed=11, vcov_type="hc1" - ).fit(**common) + r_default = EfficientDiD(cluster="state", n_bootstrap=n_boot, seed=11).fit(**common) + r_explicit = EfficientDiD( + cluster="state", n_bootstrap=n_boot, seed=11, vcov_type="hc1" + ).fit(**common) assert r_default.bootstrap_results is not None assert r_explicit.bootstrap_results is not None assert ( r_default.bootstrap_results.overall_att_se == r_explicit.bootstrap_results.overall_att_se ) - # Per-horizon / per-group bootstrap SE override branches at - # efficient_did.py:1090-1115 must also agree. - assert r_default.bootstrap_results.event_study_ses is not None - assert r_explicit.bootstrap_results.event_study_ses is not None - for h, se in r_default.bootstrap_results.event_study_ses.items(): - assert se == r_explicit.bootstrap_results.event_study_ses[h] - assert r_default.bootstrap_results.group_effect_ses is not None - assert r_explicit.bootstrap_results.group_effect_ses is not None - for g, se in r_default.bootstrap_results.group_effect_ses.items(): - assert se == r_explicit.bootstrap_results.group_effect_ses[g] + # A plain 4.0 EDiD fit computes no aggregated bootstrap SEs; the + # post-fit aggregate() REPLAYS the fit-time bootstrap, so the + # per-horizon / per-group override branches are compared on the + # replayed containers. + assert r_default.bootstrap_results.event_study_ses is None + assert r_default.bootstrap_results.group_effect_ses is None + es_d, es_e = r_default.aggregate("event_study"), r_explicit.aggregate("event_study") + assert len(es_d.event_time) > 0 + np.testing.assert_array_equal(es_d.event_time, es_e.event_time) + np.testing.assert_array_equal(es_d.se, es_e.se) + g_d, g_e = r_default.aggregate("group"), r_explicit.aggregate("group") + assert len(g_d.label) > 0 + np.testing.assert_array_equal(g_d.label, g_e.label) + np.testing.assert_array_equal(g_d.se, g_e.se) def test_bootstrap_survey_hc1_bit_equal(self, ci_params): data = _efficient_survey_panel() @@ -3139,12 +3102,9 @@ def test_bootstrap_survey_hc1_bit_equal(self, ci_params): time="time", first_treat="first_treat", survey_design=design, - aggregate="all", ) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - r_default = EfficientDiD(n_bootstrap=n_boot, seed=23).fit(**common) - r_explicit = EfficientDiD(n_bootstrap=n_boot, seed=23, vcov_type="hc1").fit(**common) + r_default = EfficientDiD(n_bootstrap=n_boot, seed=23).fit(**common) + r_explicit = EfficientDiD(n_bootstrap=n_boot, seed=23, vcov_type="hc1").fit(**common) assert r_default.bootstrap_results is not None assert r_explicit.bootstrap_results is not None assert ( diff --git a/tests/test_efficient_did_validation.py b/tests/test_efficient_did_validation.py index 07909bed2..c1410f298 100644 --- a/tests/test_efficient_did_validation.py +++ b/tests/test_efficient_did_validation.py @@ -8,13 +8,13 @@ "Efficient Difference-in-Differences and Event Study Estimators." """ -import warnings from pathlib import Path import numpy as np import pandas as pd import pytest from edid_dgp import make_compustat_dgp, true_es_avg, true_overall_att +from legacy_surface import es_dict from diff_diff import CallawaySantAnna, EfficientDiD @@ -90,25 +90,9 @@ def _assert_close(actual, expected, label, se=None, se_frac=0.05): def _compute_es_avg(result): """Compute ES_avg (Eq 2.3): uniform average over post-treatment horizons.""" - if result.event_study_effects is None: - raise ValueError("No event study effects; use aggregate='all'") - es = {int(e): d["effect"] for e, d in result.event_study_effects.items() if int(e) >= 0} - return np.mean(list(es.values())) - - -def _attach_post_fit_event_study(res): - """Recompute the event-study surface post-fit and hand it to the - mapping-consuming helpers/assertions in this module (``_compute_es_avg`` - and the dict-iterating tests). Post-fit ``aggregate()`` recomputes from - the retained kit and relays the same numbers as the deprecated fit-time - ``aggregate=`` surface. - """ - es = res.aggregate("event_study") - res.event_study_effects = { - int(e): {"effect": float(a), "se": float(s)} - for e, a, s in zip(es.event_time, es.att, es.se) - } - return res + es = result.aggregate("event_study") + post = np.asarray(es.event_time, dtype=int) >= 0 + return float(np.mean(np.asarray(es.att, dtype=float)[post])) _TRUE_ES_AVG_COMPUSTAT = true_es_avg() @@ -133,7 +117,6 @@ def _run_mc_simulation(n_sims, rho, seed=1000, also_cs=False): time="time", first_treat="first_treat", ) - _attach_post_fit_event_study(res) edid_estimates.append(_compute_es_avg(res)) edid_overall_att.append(res.overall_att) edid_overall_se.append(res.overall_se) @@ -141,18 +124,13 @@ def _run_mc_simulation(n_sims, rho, seed=1000, also_cs=False): if also_cs: cs = CallawaySantAnna(control_group="never_treated") - # CS fit-time aggregate= is deprecated too; migrating CS is out - # of scope here, so silence its shim warning. - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - cs_res = cs.fit( - data, - outcome="y", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - ) + cs_res = cs.fit( + data, + outcome="y", + unit="unit", + time="time", + first_treat="first_treat", + ) cs_estimates_list.append(_compute_es_avg(cs_res)) return { @@ -188,7 +166,7 @@ def edid_hrs_result(hrs_data): time="time", first_treat="first_treat", ) - return _attach_post_fit_event_study(res) + return res class TestHRSReplication: @@ -225,9 +203,13 @@ def test_group_time_effects_match_table6(self, edid_hrs_result): _assert_close(info["effect"], expected_effect, f"ATT({g},{t})", se=se) def test_event_study_effects_match_table6(self, edid_hrs_result): + # EDiD fits never populate the fit-time field; the post-fit container + # (via the legacy dict shape) carries the event study. + assert edid_hrs_result.event_study_effects is None + es = es_dict(edid_hrs_result) for e, (expected_effect, se) in TABLE6_ES.items(): found = False - for rel_time, info in edid_hrs_result.event_study_effects.items(): + for rel_time, info in es.items(): if int(rel_time) == e: _assert_close(info["effect"], expected_effect, f"ES({e})", se=se) found = True diff --git a/tests/test_event_study_consumers.py b/tests/test_event_study_consumers.py index 15f9fcb83..e155a0e1d 100644 --- a/tests/test_event_study_consumers.py +++ b/tests/test_event_study_consumers.py @@ -28,6 +28,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import legacy_standin from diff_diff import CallawaySantAnna, compute_honest_did, compute_pretrends_power from diff_diff.pretrends import PreTrendsPower @@ -60,12 +61,14 @@ def _panel(seed=11, n_units=80, n_periods=8): def _fit_cs(data, **cs_kw): - # The deprecated fit-time aggregate= populates the NATIVE surface the - # route-parity tests compare against; the container side re-aggregates - # from the kit either way. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return CallawaySantAnna(**cs_kw).fit(data, aggregate="event_study", **FIT_KW) + # 4.0: no CS fit stores an event-study surface. The route-parity tests + # compare the consumers' NATIVE (raw-field, legacy-pickle) branch against + # the container route on the same fit, so the native side is a stand-in + # carrying the container's surface in the 3.x fields (dict + vcov + + # vcov_index + provenance); the container side re-aggregates from the kit. + res = CallawaySantAnna(**cs_kw).fit(data, **FIT_KW) + assert res.event_study_effects is None + return legacy_standin(res) @pytest.fixture(scope="module") @@ -194,9 +197,7 @@ def test_survey_df_threads(self, panel): sd = SurveyDesign(weights="survey_weights", strata="strata", psu="psu") with warnings.catch_warnings(): warnings.simplefilter("ignore") - res = CallawaySantAnna().fit( - _panel(), survey_design=sd, aggregate="event_study", **FIT_KW - ) + res = legacy_standin(CallawaySantAnna().fit(_panel(), survey_design=sd, **FIT_KW)) h_native, h_container = _assert_honest_parity(res) assert h_container.df_survey is not None assert np.isfinite(float(h_container.df_survey)) @@ -220,8 +221,17 @@ def test_zero_se_rows_dropped_on_both_routes(self, panel): # and assert the same drop (the row disappears from the retained # pre set rather than entering beta with sigma 0). res = _fit_cs(panel, base_period="universal") + # The 3.x field type is ``list`` (CallawaySantAnnaResults annotates + # ``event_study_vcov_index: Optional[list]``) and the native branch's + # subsetting path calls ``.index()`` on it; the stand-in stores an + # ndarray, so restore the native type here. + import dataclasses + from diff_diff.honest_did import _extract_event_study_params + res = dataclasses.replace( + res, event_study_vcov_index=[int(t) for t in res.event_study_vcov_index] + ) pre_key = min(k for k in res.event_study_effects if k < -1) res.event_study_effects[pre_key] = dict( res.event_study_effects[pre_key], se=0.0, p_value=np.nan @@ -393,11 +403,16 @@ def _fit_gapped(periods, cohorts, **fit_kw): "y": ufe + 0.3 * t + 2.0 * post + rng.randn() * 0.5, } ) + # The native (raw-field) side is a legacy stand-in carrying the + # container's surface; ``balance_e=`` restricts that surface the way + # the 3.x fit-time ``balance_e`` did. + balance_e = fit_kw.pop("balance_e", None) with warnings.catch_warnings(): warnings.simplefilter("ignore") - return CallawaySantAnna(n_bootstrap=0, base_period="universal").fit( - pd.DataFrame(rows), aggregate="event_study", **fit_kw, **FIT_KW + res = CallawaySantAnna(n_bootstrap=0, base_period="universal").fit( + pd.DataFrame(rows), **fit_kw, **FIT_KW ) + return legacy_standin(res, balance_e=balance_e) def test_overlap_layout_provenance(self): res = self._fit_gapped((1, 2, 3, 5), (2, 3, 5)) @@ -512,13 +527,16 @@ def test_missing_container_provenance_warns(self): with pytest.warns(UserWarning, match="no reference_event_times provenance"): compute_pretrends_power(surface, M=0.1) - def test_fit_time_balance_e_provenance_matches_surface(self): - # The deprecated fit-time aggregate="event_study" + balance_e - # stores a RESTRICTED surface: its provenance must describe the - # retained cohorts too, or the native route would reject a fit - # whose equivalent post-fit container is accepted (route parity). + def test_native_balance_e_provenance_matches_surface(self): + # A balance_e-RESTRICTED native surface (the 3.x fit-time shape, here a + # legacy stand-in) carries provenance describing the retained cohorts + # only, so the native route accepts a surface whose equivalent post-fit + # container is accepted (route parity). The unrestricted FIT keeps the + # fit-wide tuple. res = self._fit_gapped((1, 2, 3, 4, 6), (3, 6), balance_e=1) assert tuple(int(e) for e in res.reference_event_times) == (-1,) + unrestricted = self._fit_gapped((1, 2, 3, 4, 6), (3, 6)) + assert tuple(int(e) for e in unrestricted.reference_event_times) == (-2, -1) with warnings.catch_warnings(): warnings.simplefilter("ignore") p = compute_pretrends_power(res, M=0.1) diff --git a/tests/test_event_study_surface.py b/tests/test_event_study_surface.py index fc816f5eb..cb5688561 100644 --- a/tests/test_event_study_surface.py +++ b/tests/test_event_study_surface.py @@ -19,6 +19,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import legacy_standin from diff_diff.results_base import ( EVENT_STUDY_SCHEMA, @@ -263,7 +264,17 @@ class _Fake: @pytest.fixture(scope="module") def surfaces(): - """Build every producer's surface once; return {label: (native, surface)}.""" + """Build every producer's surface once; return {label: (native, surface)}. + + Since 4.0 no CallawaySantAnna / EfficientDiD fit, and no ANALYTIC + ImputationDiD / TwoStageDiD / ContinuousDiD fit, stores a fit-time + event-study dict, so for those producers the "native" side is a legacy + stand-in carrying ``aggregate("event_study")`` in the 3.x fields (the + legacy-pickle shape ``build_event_study_surface`` still accepts). The + BOOTSTRAPPED ImputationDiD / TwoStageDiD / ContinuousDiD fits populate the + dict themselves (the percentile surface the relay serves) and enter as + genuine native producers. + """ out = {} from diff_diff import ( @@ -303,8 +314,9 @@ def surfaces(): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + assert cs.event_study_effects is None # 4.0: never populated at fit + cs = legacy_standin(cs) out["CallawaySantAnna"] = (cs, build_event_study_surface(cs)) # 2. SunAbraham (always-on) @@ -322,10 +334,23 @@ def surfaces(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + assert imp.event_study_effects is None # analytic fit: no surface stored + imp = legacy_standin(imp) out["ImputationDiD"] = (imp, build_event_study_surface(imp)) + # 3b. Bootstrapped ImputationDiD: a GENUINE native producer (the fit + # stores the percentile surface the relay serves). + imp_boot = ImputationDiD(n_bootstrap=49, seed=42).fit( + imp_data, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) + assert imp_boot.event_study_effects is not None + out["ImputationDiD_bootstrap"] = (imp_boot, build_event_study_surface(imp_boot)) + # 4. TwoStageDiD (same panel shape) ts = TwoStageDiD().fit( imp_data, @@ -333,10 +358,22 @@ def surfaces(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + assert ts.event_study_effects is None + ts = legacy_standin(ts) out["TwoStageDiD"] = (ts, build_event_study_surface(ts)) + # 4b. Bootstrapped TwoStageDiD: genuine native producer. + ts_boot = TwoStageDiD(n_bootstrap=49, seed=42).fit( + imp_data, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) + assert ts_boot.event_study_effects is not None + out["TwoStageDiD_bootstrap"] = (ts_boot, build_event_study_surface(ts_boot)) + # 5. StackedDiD st_data = dgp_staggered( n_units=120, @@ -379,15 +416,24 @@ def surfaces(): n_units=200, n_periods=5, cohort_periods=[2, 4], seed=42, noise_sd=0.5 ) cd = ContinuousDiD(control_group="not_yet_treated", n_bootstrap=0).fit( - cd_data, "outcome", "unit", "period", "first_treat", "dose", aggregate="eventstudy" + cd_data, "outcome", "unit", "period", "first_treat", "dose" ) + assert cd.event_study_effects is None + cd = legacy_standin(cd) out["ContinuousDiD"] = (cd, build_event_study_surface(cd)) + # 7b. Bootstrapped ContinuousDiD: genuine native producer. + cd_boot = ContinuousDiD(control_group="not_yet_treated", n_bootstrap=49, seed=42).fit( + cd_data, "outcome", "unit", "period", "first_treat", "dose" + ) + assert cd_boot.event_study_effects is not None + out["ContinuousDiD_bootstrap"] = (cd_boot, build_event_study_surface(cd_boot)) + # 8. EfficientDiD ed_data = _efficient_panel() - ed = EfficientDiD().fit( - ed_data, "y", "unit", "time", "first_treat", aggregate="event_study" - ) + ed = EfficientDiD().fit(ed_data, "y", "unit", "time", "first_treat") + assert ed.event_study_effects is None + ed = legacy_standin(ed) out["EfficientDiD"] = (ed, build_event_study_surface(ed)) # 9. WooldridgeDiD (post-fit aggregate) @@ -455,10 +501,13 @@ def surfaces(): "CallawaySantAnna", "SunAbraham", "ImputationDiD", + "ImputationDiD_bootstrap", "TwoStageDiD", + "TwoStageDiD_bootstrap", "StackedDiD", "SpilloverDiD", "ContinuousDiD", + "ContinuousDiD_bootstrap", "EfficientDiD", "WooldridgeDiD", "StaggeredTripleDifference", @@ -789,13 +838,14 @@ def test_cs_universal_gapped_multiple_references(): ) with warnings.catch_warnings(): warnings.simplefilter("ignore") - cs = CallawaySantAnna(n_bootstrap=0, base_period="universal").fit( - pd.DataFrame(rows), - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="event_study", + cs = legacy_standin( + CallawaySantAnna(n_bootstrap=0, base_period="universal").fit( + pd.DataFrame(rows), + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) ) native_refs = sorted(e for e, r in cs.event_study_effects.items() if r.get("n_groups") == 0) assert len(native_refs) >= 2 # the case only matters when >1 @@ -1030,7 +1080,8 @@ def test_absent_surface_raises(): from diff_diff.prep import generate_staggered_data as prep_staggered data = prep_staggered(n_units=60, n_periods=6, cohort_periods=[3], treatment_effect=2.0, seed=1) - # aggregate defaults to "simple": no event_study_effects populated. + # 4.0: no CallawaySantAnna fit populates event_study_effects; the builder + # distinguishes that ABSENT surface (raise) from an empty one. cs = CallawaySantAnna(n_bootstrap=0).fit( data, outcome="outcome", unit="unit", time="period", first_treat="first_treat" ) @@ -1298,7 +1349,7 @@ def test_two_stage_vcov_alignment(surfaces): idx = surface.vcov_index.tolist() assert set(idx).issubset(set(surface.event_time.tolist())) assert -1 not in idx # ref_period never a Stage-2 column - assert idx == native.event_study_vcov_index + assert idx == np.asarray(native.event_study_vcov_index).tolist() ses = {h: d["se"] for h, d in native.event_study_effects.items()} diag = np.sqrt(np.maximum(np.diag(surface.vcov), 0.0)) for i, h in enumerate(idx): @@ -1308,6 +1359,19 @@ def test_two_stage_vcov_alignment(surfaces): assert np.isnan(diag[i]) # rank-guard NaN row == NaN marginal SE +@pytest.mark.parametrize( + "label", ["ImputationDiD_bootstrap", "TwoStageDiD_bootstrap", "ContinuousDiD_bootstrap"] +) +def test_bootstrap_relay_native_surface_has_no_vcov(label, surfaces): + # The bootstrapped adopters store the percentile surface; its SEs are + # not the diagonal of any stored matrix, so the surface carries no vcov + # and the relay container is the same surface. + native, surface = surfaces[label] + assert surface.vcov is None and surface.vcov_index is None + relay = native.aggregate("event_study") + pd.testing.assert_frame_equal(relay.to_dataframe(), surface.to_dataframe()) + + def test_two_stage_prop5_horizons_excluded_from_vcov_index(): # No never-treated units -> late horizons are Proposition-5 unidentified # (NaN effect, n_obs > 0). They live in the effects dict but were never @@ -1330,18 +1394,17 @@ def test_two_stage_prop5_horizons_excluded_from_vcov_index(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + surface = res.aggregate("event_study") prop5 = [ - h - for h, d in res.event_study_effects.items() - if d["n_obs"] > 0 and not np.isfinite(d["effect"]) + int(h) + for h, n, att in zip(surface.event_time, surface.n, surface.att) + if n > 0 and not np.isfinite(att) ] assert prop5, "fixture must produce Proposition-5 horizons" - assert res.event_study_vcov_index is not None - assert not set(prop5) & set(res.event_study_vcov_index) - surface = build_event_study_surface(res) assert surface.vcov is not None + assert surface.vcov_index is not None + assert not set(prop5) & set(surface.vcov_index.tolist()) assert set(surface.vcov_index.tolist()).issubset(set(surface.event_time.tolist())) @@ -1434,14 +1497,14 @@ def test_two_stage_survey_df_threaded(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", survey_design=design, ) - assert res.event_study_df is not None and res.event_study_df > 0 - surface = build_event_study_surface(res) + surface = res.aggregate("event_study") + assert surface.df_survey is not None and surface.df_survey > 0 + assert surface.df_survey == float(res.survey_metadata.df_survey) finite_p = np.isfinite(surface.p_value) assert finite_p.any() - assert set(surface.df[finite_p].tolist()) == {float(res.event_study_df)} + assert set(surface.df[finite_p].tolist()) == {float(surface.df_survey)} def test_stacked_replicate_weight_vcov_diag_matches_ses(): @@ -1511,13 +1574,12 @@ def test_cs_bare_cluster_df_fallback(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + surface = res.aggregate("event_study") # Bare-cluster synthesize path: the ES rows' safe_inference used G-1, - # recorded on BOTH channels (primary event_study_df; df_inference keeps - # its narrow HonestDiD contract). - assert res.event_study_df == float(res.df_inference) == 7.0 - surface = build_event_study_surface(res) + # recorded on BOTH channels (the container's per-row df + df_survey; + # df_inference keeps its narrow HonestDiD contract). + assert surface.df_survey == float(res.df_inference) == 7.0 finite_p = np.isfinite(surface.p_value) assert set(surface.df[finite_p].tolist()) == {7.0} @@ -1533,16 +1595,14 @@ def test_cs_bare_cluster_bootstrap_df_is_nan(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - # Bootstrap overrode the stored ES p/CIs with percentile values that + surface = res.aggregate("event_study") + # The bootstrap replay overrides the ES p/CIs with percentile values that # never used the analytical df: the surface must show NO df even though # df_inference (the HonestDiD contract field) still carries G-1 on the - # container. Bootstrap p-values are finite, so only the gated + # results object. Bootstrap p-values are finite, so only the gated # df_inference fallback - not the NaN-p mask - prevents the leak. assert res.df_inference == 7 - assert res.event_study_df is None - surface = build_event_study_surface(res) assert np.isfinite(surface.p_value).any() assert np.isnan(surface.df).all() @@ -1562,18 +1622,19 @@ def test_cs_survey_min_df_on_surface(): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", survey_design=design, ) + surface = res.aggregate("event_study") # Explicit-survey fits: df_inference stays None (PR #487 narrow # HonestDiD contract - channel separation), while the surface carries # the ONE df every ES row's safe_inference_batch actually applied. assert res.df_inference is None - assert res.event_study_df is not None and res.event_study_df > 0 - surface = build_event_study_surface(res) finite_p = np.isfinite(surface.p_value) assert finite_p.any() - assert set(surface.df[finite_p].tolist()) == {float(res.event_study_df)} + applied = set(surface.df[finite_p].tolist()) + assert len(applied) == 1 + (df_applied,) = applied + assert np.isfinite(df_applied) and df_applied > 0 def test_mpd_hc2_bm_per_period_df(): diff --git a/tests/test_guides.py b/tests/test_guides.py index 240af2c7a..20c427f38 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -296,6 +296,30 @@ def test_module_docstring_mentions_helper(): # --------------------------------------------------------------------------- # llms-full.txt — HeterogeneousAdoptionDiD coverage (Phase 5) # --------------------------------------------------------------------------- +def _assert_no_fit_time_aggregation_lines(fit_block: str, label: str) -> None: + """4.0 (PR 3): no documented fit() signature carries a fit-time + ``aggregate`` / ``balance_e`` line - aggregation is post-fit only.""" + offending = [ + line + for line in fit_block.splitlines() + if line.strip().startswith(("aggregate", "balance_e")) + ] + assert ( + offending == [] + ), f"{label}: fit-time aggregation keyword(s) still documented: {offending}" + + +def test_llms_full_callaway_fit_block_has_no_fit_time_aggregate(): + # M-020 / M-117 (removed at 4.0, PR 3): the CallawaySantAnna signature block + # lost its `aggregate: str = None` / `balance_e: int = None` lines. + text = get_llm_guide("full") + start = text.index("### CallawaySantAnna") + section = text[start : text.index("\n### ", start + 1)] + fit_start = section.index(".fit(") + fit_block = section[fit_start : section.index("\n)", fit_start)] + _assert_no_fit_time_aggregation_lines(fit_block, "CallawaySantAnna") + + class TestLLMsFullHADCoverage: """Lock the HAD section additions to llms-full.txt against deletion or framing drift. Phase 5 surfaces the agent-facing API contract for @@ -929,20 +953,14 @@ def test_llms_full_stacked_fit_documents_covariates(self): "does not document it." ) - def test_llms_full_stacked_fit_aggregate_line_documents_shim(self): - # M-024: the documented fit signature must carry the sentinel - # default + deprecation marker (the dCDH precedent's form), not - # the stale `aggregate: str = None` - no other pin covers this - # line, so it could silently go stale. + def test_llms_full_stacked_fit_block_has_no_fit_time_aggregate(self): + # M-024 (removed at 4.0, PR 3): the documented fit signature carries + # no `aggregate` line any more - aggregation is post-fit only. section = self._stacked_section() fit_start = section.index("stacked.fit(") fit_block = section[fit_start : section.index("\n)", fit_start)] - agg_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("aggregate") - ) - assert "NOT_SUPPLIED" in agg_line - assert "DEPRECATED (M-024)" in agg_line - assert "results.aggregate()" in agg_line + _assert_no_fit_time_aggregation_lines(fit_block, "StackedDiD") + assert "NOT_SUPPLIED" not in fit_block # balance= must be documented somewhere in the section (constructor param) assert "balance" in section @@ -1009,21 +1027,12 @@ def _efficient_section(self): nxt = text.index("\n### ", start + 1) return text[start:nxt] - def test_llms_full_efficient_fit_aggregate_line_documents_shim(self): - section = self._efficient_section() - fit_start = section.index("edid.fit(") + def test_llms_full_efficient_fit_block_has_no_fit_time_aggregate(self): + # M-023 / M-120 (removed at 4.0, PR 3). + section = self._section("### EfficientDiD") + fit_start = section.index(".fit(") fit_block = section[fit_start : section.index("\n)", fit_start)] - agg_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("aggregate") - ) - assert "NOT_SUPPLIED" in agg_line - assert "DEPRECATED (M-023)" in agg_line - assert "results.aggregate()" in agg_line - bal_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("balance_e") - ) - assert "NOT_SUPPLIED" in bal_line - assert "DEPRECATED (M-120)" in bal_line + _assert_no_fit_time_aggregation_lines(fit_block, "EfficientDiD") def _section(self, header): text = get_llm_guide("full") @@ -1031,41 +1040,28 @@ def _section(self, header): nxt = text.index("\n### ", start + 1) return text[start:nxt] - def _assert_shim_lines(self, section, fit_call, agg_row, bal_row=None): - """``bal_row=None`` skips the balance_e-line lookup - required for - estimators without a balance_e param (ContinuousDiD, M-025), where - the unconditional ``next(...)`` would raise StopIteration.""" + def _assert_no_shim_lines(self, section, fit_call): fit_start = section.index(fit_call) fit_block = section[fit_start : section.index("\n)", fit_start)] - agg_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("aggregate") - ) - assert "NOT_SUPPLIED" in agg_line - assert f"DEPRECATED ({agg_row})" in agg_line - assert "results.aggregate()" in agg_line - if bal_row is not None: - bal_line = next( - line for line in fit_block.splitlines() if line.strip().startswith("balance_e") - ) - assert "NOT_SUPPLIED" in bal_line - assert f"DEPRECATED ({bal_row})" in bal_line + _assert_no_fit_time_aggregation_lines(fit_block, fit_call) + assert "NOT_SUPPLIED" not in fit_block - def test_llms_full_imputation_fit_aggregate_line_documents_shim(self): + def test_llms_full_imputation_fit_block_has_no_fit_time_aggregate(self): # The M-021/M-118 twin of the EfficientDiD pin above - no other # test covers the ImputationDiD signature block's shim comments. - self._assert_shim_lines(self._section("### ImputationDiD"), "imp.fit(", "M-021", "M-118") + self._assert_no_shim_lines(self._section("### ImputationDiD"), "imp.fit(") - def test_llms_full_two_stage_fit_aggregate_line_documents_shim(self): - self._assert_shim_lines(self._section("### TwoStageDiD"), ".fit(", "M-022", "M-119") + def test_llms_full_two_stage_fit_block_has_no_fit_time_aggregate(self): + self._assert_no_shim_lines(self._section("### TwoStageDiD"), ".fit(") - def test_llms_full_continuous_fit_aggregate_line_documents_shim(self): + def test_llms_full_continuous_fit_block_has_no_fit_time_aggregate(self): # M-025: no balance_e twin exists - bal_row stays None. - self._assert_shim_lines(self._section("### ContinuousDiD"), "cdid.fit(", "M-025") + self._assert_no_shim_lines(self._section("### ContinuousDiD"), "cdid.fit(") - def test_llms_full_had_fit_aggregate_line_documents_shim(self): + def test_llms_full_had_fit_block_has_no_fit_time_aggregate(self): # M-027: the mode-selector shim (panel-shape inference successor); # no balance_e twin exists - bal_row stays None. - self._assert_shim_lines(self._section("### HeterogeneousAdoptionDiD"), "had.fit(", "M-027") + self._assert_no_shim_lines(self._section("### HeterogeneousAdoptionDiD"), "had.fit(") def test_llms_full_had_examples_do_not_teach_mode_kwarg(self): # M-027/M-139 regression guard (local review R1 P2): outside the @@ -1090,7 +1086,6 @@ def test_llms_full_had_examples_do_not_teach_mode_kwarg(self): or "aggregate='event_study'" in line or 'aggregate="event_study"' in line ) - and "DEPRECATED" not in line ] assert offending == [], offending diff --git a/tests/test_had.py b/tests/test_had.py index d85464745..d941233cf 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -22,8 +22,8 @@ all-NaN inference. 9. sklearn clone round-trip preserves raw ``design="auto"``; fit is idempotent. -10. Scaffolding (``aggregate="event_study"``, ``survey``, ``weights``) - raises ``NotImplementedError`` with phase pointers. +10. Scaffolding: the removed fit-time ``aggregate=`` keyword is a native + ``TypeError``; ``survey`` / ``weights`` positional slots are gone. 11. ``get_params()`` keys match ``__init__`` signature. 12. REGISTRY ticks tested indirectly via parity with the paper rules. """ @@ -565,39 +565,16 @@ def test_unbalanced_panel_raises(self): with pytest.raises(ValueError, match=r"[Uu]nbalanced|[Bb]alanced"): est.fit(panel, "outcome", "dose", "period", "unit") - def test_three_periods_supplied_overall_raises(self): - # M-027: a PLAIN 3-period fit now infers the event-study mode (the - # error->works behavior delta, pinned in test_aggregate_contract); - # the overall estimator's two-period requirement still binds on the - # supplied legacy path. + def test_fit_time_aggregate_kwarg_is_gone(self): + # 4.0: the mode follows the panel shape (M-027 inference; a plain + # 3-period fit selects the event study, pinned in + # test_aggregate_contract). The removed fit-time ``aggregate=`` + # keyword is a native TypeError - there is no supplied-mode path. d, dy = _dgp_continuous_at_zero(200, seed=0) - panel2 = _make_panel(d, dy) - panel3 = pd.concat([panel2, panel2.assign(period=3)]) - est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - est.fit(panel3, "outcome", "dose", "period", "unit", aggregate="overall") - - def test_three_periods_with_first_treat_supplied_overall_raises(self): - # See test_three_periods_supplied_overall_raises: the plain-fit - # routing changed with M-027; the supplied legacy path keeps the - # overall estimator's two-period requirement. - d, dy = _dgp_continuous_at_zero(200, seed=0) - panel2 = _make_panel(d, dy) - panel3 = pd.concat([panel2, panel2.assign(period=3)]) - panel3["ft"] = 2 # arbitrary first_treat + panel = _make_panel(d, dy) est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - est.fit( - panel3, - "outcome", - "dose", - "period", - "unit", - first_treat="ft", - aggregate="overall", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit(panel, "outcome", "dose", "period", "unit", aggregate="overall") def test_single_period_raises(self): d, _ = _dgp_continuous_at_zero(200, seed=0) @@ -900,38 +877,23 @@ def test_set_params_rollback_on_invalid_alpha(self): class TestScaffoldingRejections: - def test_aggregate_event_study_on_two_period_panel_raises(self): - """Event-study mode requires T > 2 (Phase 2b). Reaching it on a T=2 - panel now takes the deprecated supplied path (a plain T=2 fit infers - the overall mode, M-027); the shape error still binds there.""" + def test_aggregate_kwarg_removed_raises_type_error(self): + """4.0: the mode is inferred from the panel shape (a T=2 panel is the + overall estimator, T > 2 the event study); the fit-time + ``aggregate=`` keyword no longer exists, so supplying it (any value) + is a native TypeError.""" d, dy = _dgp_continuous_at_zero(200, seed=0) panel = _make_panel(d, dy) est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="more than two"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - ) - - def test_aggregate_invalid_raises(self): - d, dy = _dgp_continuous_at_zero(200, seed=0) - panel = _make_panel(d, dy) - est = HeterogeneousAdoptionDiD() - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="garbage", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + panel, + "outcome", + "dose", + "period", + "unit", + aggregate="event_study", + ) def test_survey_design_bad_type_raises(self): """survey_design= must be a SurveyDesign-like object with a @@ -2196,11 +2158,10 @@ def _fit_es(est, *args, **kwargs) -> HeterogeneousAdoptionDiDEventStudyResults: The public ``fit()`` is annotated to return a union over the single-period ``HeterogeneousAdoptionDiDResults`` and the multi- period ``HeterogeneousAdoptionDiDEventStudyResults``, matching its - runtime polymorphism on ``aggregate``. When ``aggregate="event_study"`` - is requested, this helper narrows the union to the event-study - branch for the test body via ``typing.cast``. + runtime polymorphism on the panel shape (T > 2 selects the event + study). This helper narrows the union to the event-study branch for + the test body via ``typing.cast``. """ - kwargs.setdefault("aggregate", "event_study") result = est.fit(*args, **kwargs) return cast(HeterogeneousAdoptionDiDEventStudyResults, result) @@ -2210,8 +2171,8 @@ class TestFitReturnAnnotation: The annotation MUST be a union over the single-period and event-study result classes — narrowing one branch out silently would make the - static type contract diverge from the runtime polymorphism on - ``aggregate``. + static type contract diverge from the runtime polymorphism on the + panel shape. """ def test_fit_return_annotation_is_union_of_result_classes(self): @@ -2545,40 +2506,39 @@ def test_continuous_at_zero_per_horizon_matches_phase_2a(self): assert np.isnan(overall_result.att) -class TestEventStudyAggregateMatrix: - """2x2 period/aggregate matrix: reciprocal rejections.""" +class TestEventStudyModeInference: + """The mode follows the panel shape: T=2 -> overall result, T>2 -> + event-study result; the fit-time ``aggregate=`` keyword is gone.""" - def test_T2_event_study_raises(self): + def test_T2_returns_overall_result(self): d = np.linspace(0.0, 1.0, 100) dy = 0.3 * d + 0.01 * np.random.default_rng(0).standard_normal(100) panel = _make_panel(d, dy) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="more than two"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="event_study" - ) + res = HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit" + ) + assert isinstance(res, HeterogeneousAdoptionDiDResults) + assert not isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) - def test_T_gt_2_overall_raises(self): + def test_T_gt_2_returns_event_study_result(self): rng = np.random.default_rng(0) G = 100 d = rng.uniform(0.0, 1.0, G) d[0] = 0.0 panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=1) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="exactly two time periods"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="overall" - ) + res = HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit" + ) + assert isinstance(res, HeterogeneousAdoptionDiDEventStudyResults) - def test_invalid_aggregate_raises(self): + def test_aggregate_kwarg_is_type_error(self): d = np.linspace(0.0, 1.0, 100) dy = 0.3 * d panel = _make_panel(d, dy) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="Invalid aggregate"): - HeterogeneousAdoptionDiD(design="auto").fit( - panel, "outcome", "dose", "period", "unit", aggregate="garbage" - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + HeterogeneousAdoptionDiD(design="auto").fit( + panel, "outcome", "dose", "period", "unit", aggregate="overall" + ) class TestEventStudyPlacebos: @@ -5431,8 +5391,8 @@ def test_mass_point_default_vcov_event_study_cband_rejected(self): def test_survey_event_study_continuous_end_to_end(self): """Review R6 P3: estimator-level - ``fit(aggregate='event_study', survey_design=SurveyDesign(...))`` - integration lock for the continuous path. Verifies + multi-period ``fit(survey_design=SurveyDesign(...))`` (event-study + mode) integration lock for the continuous path. Verifies variance_formula, survey_metadata.df_survey (t-inference path), cband_* population, and stratified PSU dispatch through _aggregate_unit_resolved_survey.""" @@ -5480,8 +5440,8 @@ def test_survey_event_study_continuous_end_to_end(self): def test_survey_event_study_mass_point_end_to_end(self): """Review R6 P3: estimator-level - ``fit(design='mass_point', aggregate='event_study', - survey_design=...)`` integration lock. Verifies + multi-period ``fit(design='mass_point', survey_design=...)`` + (event-study mode) integration lock. Verifies variance_formula='survey_binder_tsl_2sls' and that the weighted 2SLS IF flows correctly through per-horizon Binder-TSL + sup-t bootstrap.""" diff --git a/tests/test_had_dual_knob_deprecation.py b/tests/test_had_dual_knob_deprecation.py index 2f84afeb3..5b4240a84 100644 --- a/tests/test_had_dual_knob_deprecation.py +++ b/tests/test_had_dual_knob_deprecation.py @@ -467,7 +467,8 @@ def test_cband_is_keyword_only(self, two_period_panel): df = two_period_panel est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with pytest.raises(TypeError): - # positional through aggregate, then a positional cband -- now + # the removed `aggregate` slot and a positional cband -- fit() + # has 6 positional parameters (data..first_treat) and cband is # keyword-only, so the 8th positional argument is rejected. est.fit(df, "y", "d", "time", "unit", None, "overall", True) r = est.fit(df, "y", "d", "time", "unit", cband=True) @@ -494,7 +495,7 @@ def test_fit_rejects_pre_resolved_design_overall(self, two_period_panel): ) def test_fit_rejects_pre_resolved_design_event_study(self, event_study_continuous_panel): - """PR #376 R8 P1: same TypeError on aggregate='event_study'.""" + """PR #376 R8 P1: same TypeError on the multi-period (event-study) path.""" df = event_study_continuous_panel est = HeterogeneousAdoptionDiD(design="continuous_at_zero", n_bootstrap=99, seed=0) with pytest.raises(TypeError, match=r"`survey_design=` accepts a SurveyDesign"): @@ -629,7 +630,7 @@ def test_survey_design_kwarg_smoke(self, mass_point_panel): class TestHADFitEventStudySurveyDesign: - """PR #376 R2 P1: cover aggregate='event_study' + cband=True + survey_design=.""" + """PR #376 R2 P1: cover the multi-period (event-study) path + cband=True + survey_design=.""" def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): df = event_study_continuous_panel @@ -651,8 +652,8 @@ def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): class TestDidHadPretestWorkflowEventStudySurveyDesign: - """PR #376 R2 P1: cover did_had_pretest_workflow(aggregate='event_study', - survey_design=...).""" + """PR #376 R2 P1: cover did_had_pretest_workflow on a multi-period panel + (event-study battery) with survey_design=.""" def test_survey_design_kwarg_smoke(self, event_study_panel): df = event_study_panel diff --git a/tests/test_had_pretests.py b/tests/test_had_pretests.py index 90f04671e..f8a92fcf1 100644 --- a/tests/test_had_pretests.py +++ b/tests/test_had_pretests.py @@ -2333,10 +2333,9 @@ def test_overall_aggregate_unchanged(self): def test_overall_battery_rejects_multi_period_on_supplied_path(self): """Registry/docstring contract: the overall battery is two-period only. - A PLAIN multi-period workflow call now selects the event-study - battery (M-139 panel-shape inference); the two-period requirement - still binds when the legacy aggregate='overall' override is - supplied, so the registry text and the validator stay in lock-step. + A PLAIN multi-period workflow call selects the event-study battery + (M-139 panel-shape inference); since 4.0 there is no mode override - + the removed ``aggregate=`` keyword is a native TypeError. """ df = _make_multi_period_panel( G=40, @@ -2344,11 +2343,10 @@ def test_overall_battery_rejects_multi_period_on_supplied_path(self): first_treat_period=1999, seed=313, ) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match=r"exactly two time periods"): - did_had_pretest_workflow( - df, "y", "d", "period", "unit", n_bootstrap=199, seed=0, aggregate="overall" - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + did_had_pretest_workflow( + df, "y", "d", "period", "unit", n_bootstrap=199, seed=0, aggregate="overall" + ) def test_event_study_linear_dgp_all_pass(self): df = self._linear_panel(seed=101) @@ -2457,20 +2455,6 @@ def shifted_dose_fn(rng_, G): if report.qug.reject: assert report.verdict.startswith("support infimum rejected") - def test_invalid_aggregate_raises(self): - df = self._linear_panel(seed=102) - with pytest.warns(FutureWarning, match="aggregate"): - with pytest.raises(ValueError, match="aggregate must be one of"): - did_had_pretest_workflow( - df, - "y", - "d", - "period", - "unit", - aggregate="bogus", - n_bootstrap=199, - ) - def test_single_pre_period_yields_pretrends_skipped(self): """If t_pre_list has only the base pre-period, no earlier placebos exist -> pretrends_joint is None and verdict flags the skip.""" @@ -2789,7 +2773,8 @@ def test_joint_homogeneity_test_uses_chronological_rank(self): assert np.isfinite(result.p_value) def test_workflow_event_study_ordered_categorical(self): - """did_had_pretest_workflow(aggregate="event_study") must pick + """did_had_pretest_workflow on a multi-period panel (event-study + battery) must pick up BOTH earlier pre-periods ("q1", "q2") from an ordered- categorical panel where lexical order would silently drop one of them. Regression against the `earlier_pre` raw-< fix.""" diff --git a/tests/test_honest_did.py b/tests/test_honest_did.py index 9e5e4442c..ea071429a 100644 --- a/tests/test_honest_did.py +++ b/tests/test_honest_did.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import legacy_standin from diff_diff import MultiPeriodDiD from diff_diff.honest_did import ( @@ -862,12 +863,13 @@ def test_callaway_santanna_universal_base_period(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", - ) + ).aggregate("event_study") # Verify reference period exists with NaN SE - assert -1 in results.event_study_effects - assert np.isnan(results.event_study_effects[-1]["se"]) + times = results.event_time.tolist() + assert -1 in times + assert bool(results.is_reference[times.index(-1)]) + assert np.isnan(results.se[times.index(-1)]) # HonestDiD should work without errors (reference period filtered out) honest = HonestDiD(method="relative_magnitude", M=1.0) @@ -890,33 +892,41 @@ def test_max_pre_violation_excludes_reference_period(self): # Generate data with universal base period data = generate_staggered_data(n_units=200, n_periods=10, seed=42) cs = CallawaySantAnna(base_period="universal") - results = cs.fit( + fit = cs.fit( data, outcome="outcome", unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + es = fit.aggregate("event_study") - # Verify reference period exists with n_groups=0 - assert -1 in results.event_study_effects - assert results.event_study_effects[-1]["n_groups"] == 0 + # Verify the reference period exists and is marked on the container + times = es.event_time.tolist() + assert -1 in times + assert bool(es.is_reference[times.index(-1)]) + assert np.isnan(es.n[times.index(-1)]) # The max pre-violation calculation should exclude the reference period honest = HonestDiD(method="relative_magnitude", M=1.0) - # Get pre_periods excluding reference (n_groups=0) + # Get pre_periods excluding the reference row real_pre_periods = [ - t - for t in results.event_study_effects - if t < 0 and results.event_study_effects[t].get("n_groups", 1) > 0 + int(t) for t, ref in zip(es.event_time, es.is_reference) if t < 0 and not ref ] + # ``_estimate_max_pre_violation`` reads the CS raw ``event_study_effects`` + # dict (the 3.x fit-time shape, now the legacy-pickle route): feed it a + # stand-in carrying the container's surface. Its reference row is the + # zero-count sentinel the method excludes. + results = legacy_standin(fit) + assert results.event_study_effects[-1]["n_groups"] == 0 + # If there are real pre-periods, max_violation should be > 0 # (based on actual pre-period effects, not the reference period's effect=0) if real_pre_periods: max_violation = honest._estimate_max_pre_violation(results, real_pre_periods) + assert max_violation == max(abs(es.att[times.index(t)]) for t in real_pre_periods) # Max violation should reflect actual pre-period coefficients, not 0 # The actual effects are non-zero due to sampling variation assert max_violation > 0, "max_pre_violation should be > 0 when real pre-periods exist" @@ -1246,25 +1256,29 @@ def test_df_survey_extracted_from_cs_results(self): "period", "first_treat", survey_design=sd, - aggregate="event_study", ) + es = cs_result.aggregate("event_study") honest = HonestDiD(method="smoothness", M=0.0) - h_result = honest.fit(cs_result) + h_result = honest.fit(es) - # df_survey should be propagated + # df_survey should be propagated through the container's df_survey assert h_result.df_survey is not None assert h_result.df_survey > 0 - assert h_result.survey_metadata is not None - # Single-source oracle: the CS branch resolves via the shared helper - # (aggregation.resolve_inference_df) - value-equal, float-typed. + assert h_result.df_survey == es.df_survey + # The container carries no survey-metadata OBJECT (documented + # divergence from the raw-field route); df_survey is its replacement. + assert h_result.survey_metadata is None + # Single-source oracle: the container's df_survey resolves via the + # shared helper (aggregation.resolve_inference_df) - value-equal, + # float-typed. from diff_diff.aggregation import resolve_inference_df assert h_result.df_survey == resolve_inference_df(cs_result) assert isinstance(h_result.df_survey, float) def test_event_study_vcov_computed(self): - """CallawaySantAnna event_study_vcov is computed and used by HonestDiD.""" + """The CallawaySantAnna event-study container carries the full vcov HonestDiD uses.""" from diff_diff import CallawaySantAnna, generate_staggered_data data = generate_staggered_data(n_units=100, n_periods=6, seed=42) @@ -1274,23 +1288,32 @@ def test_event_study_vcov_computed(self): "unit", "period", "first_treat", - aggregate="event_study", ) + # 4.0 invariant: no CS fit stores the fit-time surface or its vcov. + assert cs_result.event_study_effects is None + assert cs_result.event_study_vcov is None + assert cs_result.event_study_vcov_index is None - # VCV should be computed - assert cs_result.event_study_vcov is not None - n_effects = len( - [ - e - for e, d in cs_result.event_study_effects.items() - if d.get("n_groups", 1) > 0 and np.isfinite(d.get("se", np.nan)) - ] - ) - assert cs_result.event_study_vcov.shape == (n_effects, n_effects) + es = cs_result.aggregate("event_study") + # VCV should be computed on the container + assert es.vcov is not None + estimated = ~es.is_reference & np.isfinite(es.se) + n_effects = int(estimated.sum()) + assert es.vcov.shape == (n_effects, n_effects) + np.testing.assert_array_equal(es.vcov_index, es.event_time[estimated]) - # Diagonal should be positive + # Diagonal should be positive and equal the stored SEs squared for i in range(n_effects): - assert cs_result.event_study_vcov[i, i] > 0 + assert es.vcov[i, i] > 0 + np.testing.assert_allclose(np.diag(es.vcov), es.se[estimated] ** 2, rtol=1e-12) + + # HonestDiD consumes it without the diagonal fallback + import warnings as _w + + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + HonestDiD(method="smoothness", M=0.0).fit(es) + assert not [w for w in caught if "covariance" in str(w.message)] def test_survey_df_widens_bounds(self): """Survey df (t-distribution) should give wider CIs than normal.""" @@ -1319,13 +1342,12 @@ def test_survey_df_widens_bounds(self): "period", "first_treat", survey_design=sd, - aggregate="event_study", ) # Use RM method (naive FLCI) which honors df via _get_critical_value. # The optimal FLCI (smoothness) uses folded normal which doesn't use df. honest = HonestDiD(method="relative_magnitude", M=1.0) - h_result = honest.fit(cs_result) + h_result = honest.fit(cs_result.aggregate("event_study")) # With df=8, t critical value (~2.31) > z critical value (1.96) # So CI width should be wider than 2*1.96*SE @@ -1346,17 +1368,16 @@ def test_no_survey_gives_none_df(self): "unit", "period", "first_treat", - aggregate="event_study", ) honest = HonestDiD(method="smoothness", M=0.0) - h_result = honest.fit(cs_result) + h_result = honest.fit(cs_result.aggregate("event_study")) assert h_result.df_survey is None assert h_result.survey_metadata is None def test_replicate_weight_uses_diagonal_fallback(self): - """Replicate-weight designs should NOT produce full event_study_vcov.""" + """Replicate-weight designs should NOT produce a full event-study vcov.""" from diff_diff import CallawaySantAnna, SurveyDesign, generate_staggered_data data = generate_staggered_data(n_units=100, n_periods=5, seed=42) @@ -1386,15 +1407,16 @@ def test_replicate_weight_uses_diagonal_fallback(self): "period", "first_treat", survey_design=sd, - aggregate="event_study", ) - # event_study_vcov should be None (diagonal fallback for replicate designs) - assert cs_result.event_study_vcov is None + # The container's vcov should be None (diagonal fallback for replicate designs) + es = cs_result.aggregate("event_study") + assert es.vcov is None and es.vcov_index is None + assert np.isfinite(es.se[~es.is_reference]).any() def test_bootstrap_fit_carries_bootstrap_vcov(self): - """Bootstrap CS results carry the covariance of the bootstrap draws - (never the analytical event_study_vcov): its diagonal is the + """A bootstrapped CS event-study container carries the covariance of + the bootstrap draws (never the analytical vcov): its diagonal is the bootstrap SEs squared, and HonestDiD uses it without the diagonal fallback.""" from diff_diff import CallawaySantAnna, generate_staggered_data @@ -1406,13 +1428,14 @@ def test_bootstrap_fit_carries_bootstrap_vcov(self): "unit", "period", "first_treat", - aggregate="event_study", ) + es = cs_result.aggregate("event_study") - vcov = cs_result.event_study_vcov - index = cs_result.event_study_vcov_index + vcov = es.vcov + index = es.vcov_index assert vcov is not None and vcov.shape == (len(index), len(index)) - ses = np.array([cs_result.event_study_effects[e]["se"] for e in index]) + times = es.event_time.tolist() + ses = np.array([es.se[times.index(e)] for e in index]) np.testing.assert_allclose(np.diag(vcov), ses**2, rtol=1e-12, atol=0.0) honest = HonestDiD(method="relative_magnitude", M=1.0) @@ -1420,16 +1443,22 @@ def test_bootstrap_fit_carries_bootstrap_vcov(self): with _w.catch_warnings(record=True) as caught: _w.simplefilter("always") - h_result = honest.fit(cs_result) - diag_warnings = [w for w in caught if "diagonal covariance" in str(w.message)] - assert not diag_warnings + h_result = honest.fit(es) + fallback_warnings = [ + w + for w in caught + if "diagonal covariance" in str(w.message) or "no full covariance" in str(w.message) + ] + assert not fallback_warnings assert np.isfinite(h_result.original_se) assert h_result.original_se > 0 def test_bootstrap_vcov_same_on_native_and_container_routes(self): - """On a bootstrapped fit HonestDiD reads the bootstrap covariance on - both input routes: the native results object and the replayed - aggregate('event_study') container give the same sigma and bounds.""" + """On a bootstrapped fit the replayed aggregate('event_study') + container's vcov diagonal is the stored bootstrap SEs squared, and + HonestDiD reads that covariance identically on the container route + and on the native (raw-field, legacy-pickle) route - here a stand-in + carrying the same surface in the 3.x fields.""" import warnings as _w from diff_diff import CallawaySantAnna, generate_staggered_data @@ -1443,15 +1472,20 @@ def test_bootstrap_vcov_same_on_native_and_container_routes(self): "unit", "period", "first_treat", - aggregate="event_study", ) es = cs_result.aggregate("event_study") - native = _extract_event_study_params(cs_result) + standin = legacy_standin(cs_result) + native = _extract_event_study_params(standin) container = _extract_event_study_params(es) - h_native = HonestDiD(method="smoothness", M=0.0).fit(cs_result) + h_native = HonestDiD(method="smoothness", M=0.0).fit(standin) h_container = HonestDiD(method="smoothness", M=0.0).fit(es) - np.testing.assert_array_equal(native[1], cs_result.event_study_vcov) + # The fit itself stores no surface; the container's vcov is the + # bootstrap-draw covariance whose diagonal is se**2. + assert cs_result.event_study_vcov is None + in_vcov = np.isin(es.event_time, es.vcov_index) + np.testing.assert_allclose(np.diag(es.vcov), es.se[in_vcov] ** 2, rtol=1e-12, atol=0.0) + np.testing.assert_array_equal(native[1], standin.event_study_vcov) np.testing.assert_array_equal(container[0], native[0]) np.testing.assert_allclose(container[1], native[1], rtol=1e-12, atol=0.0) np.testing.assert_allclose(h_container.original_se, h_native.original_se, rtol=1e-12) @@ -1472,17 +1506,18 @@ def test_bootstrap_fit_without_vcov_falls_back_to_diagonal(self): def fit(n_bootstrap): with _w.catch_warnings(): _w.simplefilter("ignore") - return CallawaySantAnna( + res = CallawaySantAnna( n_bootstrap=n_bootstrap, seed=42, base_period="universal" - ).fit(data, "outcome", "unit", "period", "first_treat", aggregate="event_study") - - cs_result = fit(len(fit(49).event_study_vcov_index)) - with _w.catch_warnings(): - _w.simplefilter("ignore") - es = cs_result.aggregate("event_study") - assert cs_result.event_study_vcov is None and es.vcov is None + ).fit(data, "outcome", "unit", "period", "first_treat") + return res, res.aggregate("event_study") + + n_horizons = len(fit(49)[1].vcov_index) + cs_result, es = fit(n_horizons) + assert es.vcov is None and es.vcov_index is None + # Native (raw-field) route on the same surface: a stand-in with the + # dict but no vcov takes the diagonal fallback too. with pytest.warns(UserWarning, match="diagonal covariance"): - HonestDiD(method="relative_magnitude", M=1.0).fit(cs_result) + HonestDiD(method="relative_magnitude", M=1.0).fit(legacy_standin(cs_result)) with pytest.warns(UserWarning, match="no full covariance"): HonestDiD(method="relative_magnitude", M=1.0).fit(es) @@ -1503,15 +1538,16 @@ def test_singular_vcov_few_psus_raises_on_both_routes(self, n_bootstrap): _w.simplefilter("ignore") cs_result = CallawaySantAnna( n_bootstrap=n_bootstrap, seed=1, base_period="universal", cluster="psu" - ).fit(data, "outcome", "unit", "period", "first_treat", aggregate="event_study") + ).fit(data, "outcome", "unit", "period", "first_treat") es = cs_result.aggregate("event_study") - vcov = cs_result.event_study_vcov + standin = legacy_standin(cs_result) + vcov = standin.event_study_vcov assert vcov is not None and vcov.shape[0] > 7 # Precondition: both PSU-built matrices are genuinely rank-deficient. assert np.linalg.matrix_rank(vcov) < vcov.shape[0] assert np.linalg.matrix_rank(es.vcov) < es.vcov.shape[0] with pytest.raises(ValueError, match="singular"): - HonestDiD(method="relative_magnitude", M=1.0).fit(cs_result) + HonestDiD(method="relative_magnitude", M=1.0).fit(standin) with pytest.raises(ValueError, match="singular"): HonestDiD(method="relative_magnitude", M=1.0).fit(es) diff --git a/tests/test_imputation.py b/tests/test_imputation.py index be907ad59..60e8398ef 100644 --- a/tests/test_imputation.py +++ b/tests/test_imputation.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff.imputation import ( ImputationBootstrapResults, @@ -15,21 +16,6 @@ ) from diff_diff.survey import SurveyDesign -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # ============================================================================= # Shared test data generation # ============================================================================= @@ -172,7 +158,7 @@ def test_aggregate_simple(self): assert results.group_effects is None def test_aggregate_event_study(self): - """Test event study aggregation.""" + """Post-fit event-study aggregation on an analytical fit.""" data = generate_test_data() est = ImputationDiD() results = est.fit( @@ -181,20 +167,27 @@ def test_aggregate_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + # Analytical fits store no fit-time surface; the route is post-fit. + assert results.event_study_effects is None assert results.group_effects is None - - for h, eff in results.event_study_effects.items(): - assert "effect" in eff - assert "se" in eff - assert "t_stat" in eff - assert "p_value" in eff - assert "conf_int" in eff - assert "n_obs" in eff + es = results.aggregate("event_study") + assert len(es.event_time) > 0 + assert es.n_kind == "obs" + frame = es.to_dataframe() + for col in ( + "event_time", + "att", + "se", + "t_stat", + "p_value", + "conf_int_lower", + "conf_int_upper", + "n", + "is_reference", + ): + assert col in frame.columns def test_aggregate_group(self): """Test group aggregation.""" @@ -206,20 +199,17 @@ def test_aggregate_group(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None - assert len(results.group_effects) == 3 # 3 cohorts + grp = results.aggregate("group") + assert len(grp.label) == 3 # 3 cohorts assert results.event_study_effects is None - - for g, eff in results.group_effects.items(): - assert "effect" in eff - assert "se" in eff - assert eff["se"] > 0 + assert results.group_effects is None + assert np.all(np.isfinite(grp.att)) + assert np.all(grp.se > 0) def test_aggregate_all(self): - """Test 'all' aggregation computes both event study and group.""" + """Both post-fit levels are available from one analytical fit.""" data = generate_test_data() est = ImputationDiD() results = est.fit( @@ -228,11 +218,10 @@ def test_aggregate_all(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) - assert results.event_study_effects is not None - assert results.group_effects is not None + assert len(results.aggregate("event_study").event_time) > 0 + assert len(results.aggregate("group").label) > 0 def test_covariates(self): """Test estimation with covariates.""" @@ -265,7 +254,6 @@ def test_anticipation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) est1 = ImputationDiD(anticipation=1) @@ -275,15 +263,16 @@ def test_anticipation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # With anticipation=1, more obs are treated, fewer untreated assert results1.n_treated_obs > results0.n_treated_obs # Reference period changes - ref0 = [h for h, e in results0.event_study_effects.items() if e.get("n_obs", 1) == 0] - ref1 = [h for h, e in results1.event_study_effects.items() if e.get("n_obs", 1) == 0] + es0 = results0.aggregate("event_study") + es1 = results1.aggregate("event_study") + ref0 = es0.event_time[es0.is_reference].tolist() + ref1 = es1.event_time[es1.is_reference].tolist() assert -1 in ref0 assert -2 in ref1 @@ -292,26 +281,18 @@ def test_balance_e(self): data = generate_test_data() est = ImputationDiD() - results_unbal = est.fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - ) - results_bal = est.fit( + results = est.fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - balance_e=2, ) + es_unbal = results.aggregate("event_study") + es_bal = results.aggregate("event_study", balance_e=2) # Balanced should have same or fewer horizons - assert len(results_bal.event_study_effects) <= len(results_unbal.event_study_effects) + 5 + assert len(es_bal.event_time) <= len(es_unbal.event_time) + 5 def test_horizon_max(self): """Test horizon_max caps event study horizons.""" @@ -324,11 +305,10 @@ def test_horizon_max(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h in results.event_study_effects: - if results.event_study_effects[h].get("n_obs", 0) > 0: + for h, eff in es_dict(results).items(): + if eff["n_obs"] > 0: assert abs(h) <= 3 def test_summary(self): @@ -341,14 +321,16 @@ def test_summary(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) summary = results.summary() assert "Imputation DiD" in summary assert "ATT" in summary - assert "Event Study" in summary - assert "Group" in summary + # Analytical fits carry no fit-time surface: the per-level tables + # live on the post-fit containers' own summaries. + assert "Event Study" not in summary + assert "Event-Study Effects" in results.aggregate("event_study").summary() + assert "aggregate(type='group')" in results.aggregate("group").summary() def test_to_dataframe_observation(self): """Test to_dataframe at observation level.""" @@ -377,12 +359,11 @@ def test_to_dataframe_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - df = results.to_dataframe("event_study") - assert "relative_period" in df.columns - assert "effect" in df.columns + df = results.aggregate("event_study").to_dataframe() + assert "event_time" in df.columns + assert "att" in df.columns assert "se" in df.columns def test_to_dataframe_group(self): @@ -395,11 +376,10 @@ def test_to_dataframe_group(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - df = results.to_dataframe("group") - assert "group" in df.columns + df = results.aggregate("group").to_dataframe() + assert "label" in df.columns assert len(df) == 3 def test_to_dataframe_errors(self): @@ -417,7 +397,7 @@ def test_to_dataframe_errors(self): with pytest.raises(ValueError, match="Unknown level"): results.to_dataframe("invalid") - with pytest.raises(ValueError, match="Event study effects not computed"): + with pytest.raises(ValueError, match="Event study effects not stored on this fit"): results.to_dataframe("event_study") def test_get_params(self): @@ -592,33 +572,25 @@ def test_balance_e_checks_pre_treatment_periods(self): data = pd.DataFrame(rows) est = ImputationDiD(horizon_max=1) - # balance_e=2, horizon_max=1: required = {-2,-1,0,1} - # Cohort B missing -2 => should be dropped - results_bal2 = est.fit( + results = est.fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - balance_e=2, ) + # balance_e=2, horizon_max=1: required = {-2,-1,0,1} + # Cohort B missing -2 => should be dropped + es_bal2 = es_dict(results, balance_e=2) + # balance_e=1, horizon_max=1: required = {-1,0,1} # Both cohorts have -1 => both kept - results_bal1 = est.fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - balance_e=1, - ) + es_bal1 = es_dict(results, balance_e=1) # Cohort B dropped at balance_e=2 => fewer obs at horizon 0 - n_obs_bal2_h0 = results_bal2.event_study_effects[0]["n_obs"] - n_obs_bal1_h0 = results_bal1.event_study_effects[0]["n_obs"] + n_obs_bal2_h0 = es_bal2[0]["n_obs"] + n_obs_bal1_h0 = es_bal1[0]["n_obs"] assert n_obs_bal2_h0 < n_obs_bal1_h0, ( f"balance_e=2 should drop cohort B (missing rel_time=-2), " f"got n_obs={n_obs_bal2_h0} vs {n_obs_bal1_h0}" @@ -748,11 +720,10 @@ def test_se_positive_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h, eff in results.event_study_effects.items(): - if eff.get("n_obs", 0) > 0 and np.isfinite(eff["se"]): + for h, eff in es_dict(results).items(): + if eff["n_obs"] > 0 and np.isfinite(eff["se"]): assert eff["se"] > 0 def test_aux_partition_cohort_horizon(self): @@ -975,7 +946,8 @@ def test_bootstrap_different_seeds(self, ci_params): assert r1.overall_se != r2.overall_se def test_bootstrap_event_study(self, ci_params): - """Test bootstrap with event study aggregation.""" + """A bootstrapped fit materializes the percentile event-study surface + at fit and ``aggregate('event_study')`` relays it.""" data = generate_test_data() n_boot = ci_params.bootstrap(50) est = ImputationDiD(n_bootstrap=n_boot, seed=42) @@ -985,15 +957,23 @@ def test_bootstrap_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) br = results.bootstrap_results assert br.event_study_ses is not None assert len(br.event_study_ses) > 0 + assert results.event_study_effects is not None + relayed = es_dict(results) + assert set(relayed) == set(results.event_study_effects) + for h, eff in results.event_study_effects.items(): + if eff["n_obs"] == 0: + continue # reference row: the container reports NaN inference + assert relayed[h]["effect"] == eff["effect"] + assert relayed[h]["se"] == eff["se"] def test_bootstrap_group(self, ci_params): - """Test bootstrap with group aggregation.""" + """A bootstrapped fit materializes the percentile group surface at + fit and ``aggregate('group')`` relays it.""" data = generate_test_data() n_boot = ci_params.bootstrap(50) est = ImputationDiD(n_bootstrap=n_boot, seed=42) @@ -1003,62 +983,48 @@ def test_bootstrap_group(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) br = results.bootstrap_results assert br.group_ses is not None assert len(br.group_ses) == 3 - - def test_bootstrap_balance_e_consistency(self, ci_params): - """Test bootstrap event study respects balance_e filtering.""" + assert results.group_effects is not None + relayed = group_dict(results) + assert set(relayed) == set(results.group_effects) + for g, eff in results.group_effects.items(): + assert relayed[g]["effect"] == eff["effect"] + assert relayed[g]["se"] == eff["se"] + + def test_bootstrap_balance_e_rejected_post_fit(self, ci_params): + """The fit-time percentile surface is computed with balance_e=None + and the per-target draws are not retained, so ``balance_e`` on a + bootstrapped fit is rejected post-fit (the unrestricted relay stays + available).""" data = generate_test_data(n_units=150, seed=42) n_boot = ci_params.bootstrap(50) - # Run WITH balance_e - est_bal = ImputationDiD(n_bootstrap=n_boot, seed=42) - results_bal = est_bal.fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - balance_e=2, - ) - - # Run WITHOUT balance_e - est_nobal = ImputationDiD(n_bootstrap=n_boot, seed=42) - results_nobal = est_nobal.fit( + est = ImputationDiD(n_bootstrap=n_boot, seed=42) + results = est.fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results_bal.bootstrap_results is not None - assert results_bal.bootstrap_results.event_study_ses is not None + assert results.bootstrap_results is not None + assert results.bootstrap_results.event_study_ses is not None - # Verify SEs are finite - for h in results_bal.event_study_effects: - eff = results_bal.event_study_effects[h] - if eff.get("n_obs", 0) > 0 and np.isfinite(eff["effect"]): - if h in results_bal.bootstrap_results.event_study_ses: - assert np.isfinite(results_bal.bootstrap_results.event_study_ses[h]) + with pytest.raises(NotImplementedError, match="balance_e"): + results.aggregate("event_study", balance_e=2) - # Verify balance_e changed bootstrap SEs at some horizon - if results_nobal.bootstrap_results is not None: - bal_ses = results_bal.bootstrap_results.event_study_ses - nobal_ses = results_nobal.bootstrap_results.event_study_ses - shared_h = set(bal_ses.keys()) & set(nobal_ses.keys()) - any_different = any( - not np.isclose(bal_ses[h], nobal_ses[h], rtol=0.05) - for h in shared_h - if np.isfinite(bal_ses[h]) and np.isfinite(nobal_ses[h]) - ) - assert any_different, "balance_e should change bootstrap SEs for at least one horizon" + # The unrestricted relay still works and its SEs are the bootstrap SEs. + relayed = es_dict(results) + for h, eff in relayed.items(): + if eff["n_obs"] > 0 and np.isfinite(eff["effect"]): + if h in results.bootstrap_results.event_study_ses: + assert np.isfinite(results.bootstrap_results.event_study_ses[h]) + assert eff["se"] == results.bootstrap_results.event_study_ses[h] def test_bootstrap_p_value_significance(self, ci_params): """Test bootstrap p-value for significant effect.""" @@ -1173,7 +1139,6 @@ def test_bootstrap_weights_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) br = results.bootstrap_results @@ -1195,7 +1160,6 @@ def test_bootstrap_weights_group(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) br = results.bootstrap_results @@ -1424,8 +1388,8 @@ def test_no_never_treated(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + effects = es_dict(results) # Should still estimate assert results.overall_att is not None @@ -1433,15 +1397,13 @@ def test_no_never_treated(self): # Proposition 5: long-run horizons should be NaN prop5_nans = [ - h - for h, eff in results.event_study_effects.items() - if np.isnan(eff["effect"]) and eff.get("n_obs", 0) > 0 + h for h, eff in effects.items() if np.isnan(eff["effect"]) and eff["n_obs"] > 0 ] assert len(prop5_nans) > 0, "Should have Prop 5 NaN horizons" # Check all inference fields are NaN for Prop 5 horizons for h in prop5_nans: - eff = results.event_study_effects[h] + eff = effects[h] assert np.isnan(eff["se"]) assert np.isnan(eff["t_stat"]) assert np.isnan(eff["p_value"]) @@ -1602,12 +1564,12 @@ def test_nan_propagation_all_nan_horizon(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + effects = es_dict(results) # Check that NaN horizons have all-NaN inference - for h, eff in results.event_study_effects.items(): - if eff.get("n_obs", 0) > 0 and np.isnan(eff["effect"]): + for h, eff in effects.items(): + if eff["n_obs"] > 0 and np.isnan(eff["effect"]): assert np.isnan(eff["se"]) assert np.isnan(eff["t_stat"]) assert np.isnan(eff["p_value"]) @@ -1652,19 +1614,14 @@ def test_rank_condition_missing_untreated_period(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) rank_warnings = [x for x in w if "Rank condition" in str(x.message)] assert len(rank_warnings) > 0, "Should warn about rank condition violation" + effects = es_dict(results) # Affected horizons should have NaN effects (periods with no untreated units) - if results.event_study_effects: - nan_effects = [ - h - for h, d in results.event_study_effects.items() - if np.isnan(d["effect"]) and d.get("n_obs", 1) > 0 - ] - assert len(nan_effects) > 0, "Some horizons should have NaN effects" + nan_effects = [h for h, d in effects.items() if np.isnan(d["effect"]) and d["n_obs"] > 0] + assert len(nan_effects) > 0, "Some horizons should have NaN effects" def test_rank_condition_error_mode(self): """Test error raised when rank condition fails with action='error'.""" @@ -1754,17 +1711,17 @@ def test_plot_reference_with_anticipation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Reference should be at -2 (= -1 - anticipation) - assert -2 in results.event_study_effects - assert results.event_study_effects[-2]["n_obs"] == 0 # reference marker + es = results.aggregate("event_study") + assert es.event_time[es.is_reference].tolist() == [-2] + assert es_dict(results)[-2]["n_obs"] == 0 # legacy zero-count marker - # Test that plot_event_study auto-detects it + # Test that plot_event_study auto-detects it on the container pytest.importorskip("matplotlib") from diff_diff import plot_event_study - fig = plot_event_study(results) + fig = plot_event_study(es) assert fig is not None def test_overall_se_with_partial_nan_tau_hat(self): @@ -2085,28 +2042,23 @@ def test_event_study_empty_after_filtering(self): # All cohorts fail the balanced check, so all horizons have n_h=0. est = ImputationDiD() + results = est.fit( + data, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - results = est.fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - balance_e=100, - ) + effects = es_dict(results, balance_e=100) empty_warnings = [x for x in w if "no horizons with observations" in str(x.message)] assert len(empty_warnings) >= 1, "Expected warning about empty event study" # Only reference period should remain ref_period = -1 - assert ref_period in results.event_study_effects - real_effects = { - h: v - for h, v in results.event_study_effects.items() - if h != ref_period and v.get("n_obs", 0) > 0 - } + assert ref_period in effects + real_effects = {h: v for h, v in effects.items() if h != ref_period and v["n_obs"] > 0} assert len(real_effects) == 0 def test_balanced_cohort_mask_requires_negative_horizons(self): @@ -2391,8 +2343,17 @@ class TestImputationDiDVcovType: # ---- Surface 1: default bit-equal across aggregation modes ------------ - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_default_hc1_bit_equal_baseline(self, aggregate): + @staticmethod + def _assert_levels_bit_equal(r_default, r_explicit): + """Post-fit event-study and group containers agree bit-for-bit.""" + for level in ("event_study", "group"): + pd.testing.assert_frame_equal( + r_default.aggregate(level).to_dataframe(), + r_explicit.aggregate(level).to_dataframe(), + check_exact=True, + ) + + def test_default_hc1_bit_equal_baseline(self): data = generate_test_data(seed=53, n_units=80, n_periods=8) common = dict( data=data, @@ -2400,17 +2361,16 @@ def test_default_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) r_default = ImputationDiD().fit(**common) r_explicit = ImputationDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se + self._assert_levels_bit_equal(r_default, r_explicit) # ---- Surface 2: cluster path bit-equal -------------------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_cluster_hc1_bit_equal_baseline(self, aggregate): + def test_cluster_hc1_bit_equal_baseline(self): data = _imputation_clustered_panel() common = dict( data=data, @@ -2418,17 +2378,16 @@ def test_cluster_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) r_default = ImputationDiD(cluster="state").fit(**common) r_explicit = ImputationDiD(cluster="state", vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se + self._assert_levels_bit_equal(r_default, r_explicit) # ---- Surface 3: TSL-survey path bit-equal ----------------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_survey_tsl_hc1_bit_equal_baseline(self, aggregate): + def test_survey_tsl_hc1_bit_equal_baseline(self): data = _imputation_survey_panel() design = SurveyDesign( weights="weight", @@ -2442,18 +2401,18 @@ def test_survey_tsl_hc1_bit_equal_baseline(self, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, survey_design=design, ) r_default = ImputationDiD().fit(**common) r_explicit = ImputationDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se + self._assert_levels_bit_equal(r_default, r_explicit) # ---- Surface 4: replicate-survey path bit-equal ----------------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_survey_replicate_hc1_bit_equal_baseline(self, aggregate): + @pytest.mark.parametrize("level", ["event_study", "group"]) + def test_survey_replicate_hc1_bit_equal_baseline(self, level): data, rep_cols = _imputation_replicate_panel() design = SurveyDesign( weights="weight", @@ -2468,32 +2427,36 @@ def test_survey_replicate_hc1_bit_equal_baseline(self, aggregate): time="time", first_treat="first_treat", survey_design=design, - aggregate=aggregate, ) r_default = ImputationDiD().fit(**common) r_explicit = ImputationDiD(vcov_type="hc1").fit(**common) assert r_default.overall_att == r_explicit.overall_att assert r_default.overall_se == r_explicit.overall_se # Per-horizon / per-group SE override branches must also agree under - # the replicate-weight variance path. - if aggregate == "event_study": - assert r_default.event_study_effects is not None - assert r_explicit.event_study_effects is not None - for h in r_default.event_study_effects: - assert ( - r_default.event_study_effects[h]["se"] - == r_explicit.event_study_effects[h]["se"] - ) - if aggregate == "group": - assert r_default.group_effects is not None - assert r_explicit.group_effects is not None - for g in r_default.group_effects: - assert r_default.group_effects[g]["se"] == r_explicit.group_effects[g]["se"] + # the replicate-weight variance path (post-fit replicate replay). + a_default = r_default.aggregate(level) + a_explicit = r_explicit.aggregate(level) + assert len(a_default.se) > 0 + assert np.array_equal(a_default.se, a_explicit.se, equal_nan=True) + assert np.array_equal(a_default.att, a_explicit.att, equal_nan=True) # ---- Surface 5: bootstrap × cluster / × survey bit-equal -------------- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_bootstrap_cluster_hc1_bit_equal(self, ci_params, aggregate): + @staticmethod + def _assert_bootstrap_surfaces_bit_equal(r_default, r_explicit): + """Per-horizon / per-group bootstrap SE override branches at + imputation_aggregation.py::_replicate_override_aggregates must agree + (bootstrapped fits always materialize both surfaces).""" + assert r_default.bootstrap_results.event_study_ses is not None + assert r_explicit.bootstrap_results.event_study_ses is not None + for h, se in r_default.bootstrap_results.event_study_ses.items(): + assert se == r_explicit.bootstrap_results.event_study_ses[h] + assert r_default.bootstrap_results.group_ses is not None + assert r_explicit.bootstrap_results.group_ses is not None + for g, se in r_default.bootstrap_results.group_ses.items(): + assert se == r_explicit.bootstrap_results.group_ses[g] + + def test_bootstrap_cluster_hc1_bit_equal(self, ci_params): data = _imputation_clustered_panel() n_boot = ci_params.bootstrap(199) common = dict( @@ -2502,7 +2465,6 @@ def test_bootstrap_cluster_hc1_bit_equal(self, ci_params, aggregate): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) r_default = ImputationDiD(cluster="state", n_bootstrap=n_boot, seed=11).fit(**common) r_explicit = ImputationDiD( @@ -2514,21 +2476,9 @@ def test_bootstrap_cluster_hc1_bit_equal(self, ci_params, aggregate): r_default.bootstrap_results.overall_att_se == r_explicit.bootstrap_results.overall_att_se ) - # Per-horizon / per-group bootstrap SE override branches at - # imputation_aggregation.py::_replicate_override_aggregates must also agree. - if aggregate == "event_study": - assert r_default.bootstrap_results.event_study_ses is not None - assert r_explicit.bootstrap_results.event_study_ses is not None - for h, se in r_default.bootstrap_results.event_study_ses.items(): - assert se == r_explicit.bootstrap_results.event_study_ses[h] - if aggregate == "group": - assert r_default.bootstrap_results.group_ses is not None - assert r_explicit.bootstrap_results.group_ses is not None - for g, se in r_default.bootstrap_results.group_ses.items(): - assert se == r_explicit.bootstrap_results.group_ses[g] - - @pytest.mark.parametrize("aggregate", [None, "event_study", "group"]) - def test_bootstrap_survey_hc1_bit_equal(self, ci_params, aggregate): + self._assert_bootstrap_surfaces_bit_equal(r_default, r_explicit) + + def test_bootstrap_survey_hc1_bit_equal(self, ci_params): data = _imputation_survey_panel() design = SurveyDesign( weights="weight", @@ -2544,7 +2494,6 @@ def test_bootstrap_survey_hc1_bit_equal(self, ci_params, aggregate): time="time", first_treat="first_treat", survey_design=design, - aggregate=aggregate, ) r_default = ImputationDiD(n_bootstrap=n_boot, seed=23).fit(**common) r_explicit = ImputationDiD(n_bootstrap=n_boot, seed=23, vcov_type="hc1").fit(**common) @@ -2554,16 +2503,7 @@ def test_bootstrap_survey_hc1_bit_equal(self, ci_params, aggregate): r_default.bootstrap_results.overall_att_se == r_explicit.bootstrap_results.overall_att_se ) - if aggregate == "event_study": - assert r_default.bootstrap_results.event_study_ses is not None - assert r_explicit.bootstrap_results.event_study_ses is not None - for h, se in r_default.bootstrap_results.event_study_ses.items(): - assert se == r_explicit.bootstrap_results.event_study_ses[h] - if aggregate == "group": - assert r_default.bootstrap_results.group_ses is not None - assert r_explicit.bootstrap_results.group_ses is not None - for g, se in r_default.bootstrap_results.group_ses.items(): - assert se == r_explicit.bootstrap_results.group_ses[g] + self._assert_bootstrap_surfaces_bit_equal(r_default, r_explicit) # ---- Surface 6: eager transactional validation (BaseEstimator) -------- @@ -2687,7 +2627,6 @@ def test_pretrends_hc1_bit_equal_with_cluster(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) r_default = ImputationDiD(cluster="state", pretrends=True).fit(**common) r_explicit = ImputationDiD(cluster="state", pretrends=True, vcov_type="hc1").fit(**common) @@ -2702,18 +2641,19 @@ def test_pretrends_hc1_bit_equal_with_cluster(self): pt_explicit = r_explicit.pretrend_test() assert np.isclose(pt_default["f_stat"], pt_explicit["f_stat"], rtol=0, atol=1e-12) assert np.isclose(pt_default["p_value"], pt_explicit["p_value"], rtol=0, atol=1e-12) - # Event-study SE (computed during fit() via Theorem 3 machinery on + # Event-study SE (post-fit recompute via Theorem 3 machinery on # within-transformed residuals; pretrends=True path includes the # pre-period horizons). Sub-ULP differences come from BLAS # non-associativity in the shared MAP demean engine # (`demean_by_groups`) across distinct estimator instances and are # not methodological divergence under the narrow vcov_type contract. - assert r_default.event_study_effects is not None - assert r_explicit.event_study_effects is not None - for h in r_default.event_study_effects: + es_default = es_dict(r_default) + es_explicit = es_dict(r_explicit) + assert set(es_default) == set(es_explicit) + for h in es_default: assert np.isclose( - r_default.event_study_effects[h]["se"], - r_explicit.event_study_effects[h]["se"], + es_default[h]["se"], + es_explicit[h]["se"], rtol=0, atol=1e-12, equal_nan=True, @@ -2983,17 +2923,16 @@ def test_main_fit_zero_weight_treated_unit_covariates(self): first_treat="first_treat", covariates=["x1", "x2"], survey_design=SurveyDesign(weights="w"), - aggregate="group", ) + groups = group_dict(r) from tests.conftest import assert_nan_inference assert np.isfinite(r.overall_att) - assert r.group_effects is not None # Cohort 2 contains only the zero-weight unit: NaN across ALL fields. - assert_nan_inference(r.group_effects[2]) - assert np.isnan(r.group_effects[2]["effect"]) + assert_nan_inference(groups[2]) + assert np.isnan(groups[2]["effect"]) # Cohort 3 is unaffected. - assert np.isfinite(r.group_effects[3]["effect"]) + assert np.isfinite(groups[3]["effect"]) class TestLeadSnapAbsorbed: @@ -3034,10 +2973,10 @@ def test_spanned_lead_snaps_to_nan_with_cause_warning(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None - eff = res.event_study_effects + # The lead regression runs on the post-fit route; its warnings + # are recorded in the same block. + eff = es_dict(res) # The spanned lead is deterministically NaN — the FULL inference # tuple (review P3: assert every field, not just effect/se). assert np.isnan(eff[-2]["effect"]) and np.isnan(eff[-2]["se"]) @@ -3074,12 +3013,9 @@ def test_identified_leads_unchanged_no_snap_warning(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None - finite_leads = [ - h for h, e in res.event_study_effects.items() if h < -1 and np.isfinite(e["effect"]) - ] + effects = es_dict(res) + finite_leads = [h for h, e in effects.items() if h < -1 and np.isfinite(e["effect"])] assert len(finite_leads) >= 4 assert not any("collinear with the absorbed fixed effects" in str(x.message) for x in w) @@ -3261,7 +3197,6 @@ def _panel(seed=13): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) @staticmethod @@ -3277,8 +3212,9 @@ def _z_p(t_stat): return 2 * stats.norm.sf(abs(t_stat)) def _lead(self, res): - h = min(k for k in res.event_study_effects if k < -1) - return h, res.event_study_effects[h] + effects = es_dict(res) + h = min(k for k in effects if k < -1) + return h, effects[h] def test_leads_are_t_residual_not_z(self): res = ImputationDiD(pretrends=True).fit(self._panel(), **self._kw) @@ -3292,20 +3228,21 @@ def test_cluster_matches_g_minus_1_on_leads_only(self): r0 = ImputationDiD(pretrends=True).fit(data, **self._kw) rc = ImputationDiD(pretrends=True, df_convention="cluster").fit(data, **self._kw) h, e0 = self._lead(r0) - ec = rc.event_study_effects[h] + es0, esc = es_dict(r0), es_dict(rc) + ec = esc[h] G = data["unit"].nunique() assert ec["effect"] == e0["effect"] and ec["se"] == e0["se"] assert ec["p_value"] == pytest.approx(self._t_p(ec["t_stat"], G - 1), rel=1e-12) # post rows are BJS (knob-independent) - hp = min(k for k in r0.event_study_effects if k >= 0) - assert r0.event_study_effects[hp]["p_value"] == rc.event_study_effects[hp]["p_value"] + hp = min(k for k in es0 if k >= 0) + assert es0[hp]["p_value"] == esc[hp]["p_value"] def test_normal_reproduces_pre39_z_on_leads(self): data = self._panel() r0 = ImputationDiD(pretrends=True).fit(data, **self._kw) rn = ImputationDiD(pretrends=True, df_convention="normal").fit(data, **self._kw) h, e0 = self._lead(r0) - en = rn.event_study_effects[h] + en = es_dict(rn)[h] assert en["effect"] == e0["effect"] and en["se"] == e0["se"] assert en["p_value"] == pytest.approx(self._z_p(en["t_stat"]), rel=1e-14) @@ -3336,51 +3273,48 @@ def test_survey_df_precedence_on_leads(self): } h, e0 = self._lead(fits["residual"]) for conv in ("cluster", "normal"): - e = fits[conv].event_study_effects[h] + e = es_dict(fits[conv])[h] assert e["p_value"] == e0["p_value"] assert e["conf_int"] == e0["conf_int"] def test_inert_config_warns_on_explicit_nondefault(self): - """M-127 REACHABILITY predicate (revised with the M-021 post-fit - migration): warn iff the per-lead inference is unreachable on every - route for this fit config — pretrends=False always warns; - pretrends=True analytical fits never warn (post-fit - results.aggregate('event_study') reaches the leads regardless of - the deprecated fit-time aggregate value); pretrends=True with - n_bootstrap>0 and aggregate unset warns (no ES surface is built - and post-fit aggregate() fails closed); pretrends=True with the - deprecated fit-time ES supplied never warns, bootstrap included - (fit-time leads use analytical inference).""" + """M-127 REACHABILITY predicate (4.0, fit-time aggregate= removed): + the per-lead inference is reachable iff ``pretrends and not + replicate`` - analytical fits recompute the leads in + results.aggregate('event_study'), bootstrapped fits materialize the + lead surface at fit and relay it; under a replicate-weight design + the post-fit route fails closed. So warn iff pretrends=False, or + pretrends=True with a replicate-weight design.""" data = self._panel() base = dict(outcome="outcome", unit="unit", time="time", first_treat="first_treat") with pytest.warns(UserWarning, match="affects only the pretrends"): ImputationDiD(df_convention="cluster").fit(data, **base) - # pretrends=True + deprecated aggregate='group': the knob is NO - # LONGER inert (post-fit ES reaches the leads) — no inert-config - # warning (the FutureWarning from aggregate= is separate). + # pretrends=True, analytical: reachable post-fit -> no warning. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - ImputationDiD(pretrends=True, df_convention="cluster").fit( - data, aggregate="group", **base - ) + ImputationDiD(pretrends=True, df_convention="cluster").fit(data, **base) assert not any("affects only the pretrends" in str(w.message) for w in caught) + # pretrends=True + bootstrap: the lead surface is materialized at + # fit (analytical lead inference) and relayed -> no warning. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") - ImputationDiD(pretrends=True, df_convention="cluster").fit(data, **self._kw) - assert not any("affects only the pretrends" in str(w.message) for w in caught) - # pretrends=True + bootstrap, aggregate unset: unreachable → warns. - with pytest.warns(UserWarning, match="affects only the pretrends"): ImputationDiD(pretrends=True, df_convention="cluster", n_bootstrap=9, seed=1).fit( data, **base ) - # pretrends=True + bootstrap + deprecated fit-time ES: reachable - # (analytical lead inference rides the fit-time surface) → no warn. - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - ImputationDiD(pretrends=True, df_convention="cluster", n_bootstrap=9, seed=1).fit( - data, aggregate="event_study", **base - ) assert not any("affects only the pretrends" in str(w.message) for w in caught) + # pretrends=True + replicate-weight design: the post-fit lead route + # fails closed (no per-replicate lead refits) -> unreachable -> warns. + rep_data, rep_cols = _imputation_replicate_panel() + design = SurveyDesign( + weights="weight", + replicate_weights=rep_cols, + replicate_method="JK1", + weight_type="pweight", + ) + with pytest.warns(UserWarning, match="affects only the pretrends"): + ImputationDiD(pretrends=True, df_convention="cluster").fit( + rep_data, survey_design=design, **base + ) def test_validation_and_transactional_set_params(self): with pytest.raises(ValueError, match="df_convention"): diff --git a/tests/test_imputation_loo_stata_parity.py b/tests/test_imputation_loo_stata_parity.py index 20abebb27..3026a2558 100644 --- a/tests/test_imputation_loo_stata_parity.py +++ b/tests/test_imputation_loo_stata_parity.py @@ -74,25 +74,11 @@ import pandas as pd import pytest +from legacy_surface import es_dict from numpy.testing import assert_allclose from diff_diff import ImputationDiD -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - _DATA = Path(__file__).parent.parent / "benchmarks" / "data" STATA_GOLDEN_PATH = _DATA / "didimputation_loo_stata_golden.json" R_GOLDEN_PATH = _DATA / "didimputation_golden.json" @@ -144,10 +130,10 @@ def r_golden() -> dict: def library_fit() -> dict: """Fit the LOO and non-LOO paths once each; return per-horizon + overall + warnings. - ImputationDiD exposes ``res.event_study_effects[h]["effect"]`` / ``["se"]`` (a - dict-of-dicts, NOT a ``.event_study`` DataFrame) and scalar ``res.overall_att`` / - ``res.overall_se``. ``aggregate="event_study"`` also computes the overall - unconditionally, so one fit per variance mode suffices. + The fit stores scalar ``res.overall_att`` / ``res.overall_se``; the per-horizon + surface is the post-fit ``res.aggregate("event_study")`` container, read here + through the legacy dict shape (``es_dict``: ``{h: {"effect", "se", ...}}``). + The LOO warnings of both stages (fit + event-study recompute) are recorded. """ _skip_if_missing() panel = pd.read_csv(PANEL_PATH) @@ -159,8 +145,8 @@ def library_fit() -> dict: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + loo_es = es_dict(loo) loo_msgs = [str(w.message) for w in caught] nonloo = ImputationDiD(leave_one_out=False).fit( panel, @@ -168,17 +154,17 @@ def library_fit() -> dict: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + nonloo_es = es_dict(nonloo) - def by_h(res): - return {h: res.event_study_effects[h] for h in HORIZONS} + def by_h(es): + return {h: es[h] for h in HORIZONS} return { "loo_overall": (float(loo.overall_att), float(loo.overall_se)), - "loo_es": {h: (float(v["effect"]), float(v["se"])) for h, v in by_h(loo).items()}, + "loo_es": {h: (float(v["effect"]), float(v["se"])) for h, v in by_h(loo_es).items()}, "nonloo_overall_se": float(nonloo.overall_se), - "nonloo_es_se": {h: float(v["se"]) for h, v in by_h(nonloo).items()}, + "nonloo_es_se": {h: float(v["se"]) for h, v in by_h(nonloo_es).items()}, "loo_warnings": loo_msgs, } @@ -194,11 +180,9 @@ def _require_block(golden: dict, key: str) -> dict: def _fit_partition(panel, aux_partition: str, leave_one_out: bool): - """One event-study fit; returns (results, [warning messages]). The event_study - aggregate populates the per-horizon surfaces gates 6/8 read. The DEPRECATED - fit-time ``aggregate=`` route is deliberate, matching this module's convention - (see the M-021/M-022 pytestmark comment above: parity tests pin the fit-time - surface shape-for-shape; the shim warning is filtered by message).""" + """One fit plus its post-fit event-study recompute; returns (results, + per-horizon legacy dict, [warning messages]). The event-study surface is the + per-horizon surface gates 6/8 read; warnings from both stages are recorded.""" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") res = ImputationDiD(leave_one_out=leave_one_out, aux_partition=aux_partition).fit( @@ -207,10 +191,10 @@ def _fit_partition(panel, aux_partition: str, leave_one_out: bool): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None - return res, [str(w.message) for w in caught] + es = es_dict(res) + assert len(es) > 0 + return res, es, [str(w.message) for w in caught] @pytest.fixture(scope="module") @@ -223,11 +207,9 @@ def library_fit_variants() -> dict: out: dict = {"loo_warnings": []} for part in ("cohort", "horizon", "cohort_horizon"): for loo in (True, False): - res, msgs = _fit_partition(panel, part, loo) + res, es, msgs = _fit_partition(panel, part, loo) if loo: out["loo_warnings"] += msgs - es = res.event_study_effects - assert es is not None mode = "loo" if loo else "nonloo" out[f"{part}_{mode}"] = { "att": float(res.overall_att), diff --git a/tests/test_lpdid.py b/tests/test_lpdid.py index d69daa10c..47f1a1aed 100644 --- a/tests/test_lpdid.py +++ b/tests/test_lpdid.py @@ -25,6 +25,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from _dgp_utils import make_lpdid_panel # noqa: E402 +from legacy_surface import es_dict # noqa: E402 from diff_diff import ( # noqa: E402 CallawaySantAnna, @@ -722,19 +723,11 @@ def test_ce2_reweighted_equals_callaway_santanna(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + cs_es = es_dict(cs) for h in range(0, 4): - assert _event_coef(lp, h) == pytest.approx( - cs.event_study_effects[h]["effect"], abs=1e-9 - ) + assert _event_coef(lp, h) == pytest.approx(cs_es[h]["effect"], abs=1e-9) - # The BJS cross-check reads `bjs.event_study_effects` (the native fit-time - # dict) so it keeps the deprecated `fit(aggregate=)` route (rows M-021); - # scoped by message to the ImputationDiD shim only. - @pytest.mark.filterwarnings( - r"ignore:ImputationDiD\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" - ) def test_ce3_pmd_single_cohort_equals_bjs_imputation(self): # PMD LP-DiD (k=t-1, "max") with a SINGLE treated cohort == BJS # imputation [paper Section 3.4, footnotes 10-11; only single-cohort @@ -750,12 +743,10 @@ def test_ce3_pmd_single_cohort_equals_bjs_imputation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + bjs_es = es_dict(bjs) for h in range(0, 4): - assert _event_coef(lp, h) == pytest.approx( - bjs.event_study_effects[h]["effect"], abs=1e-6 - ) + assert _event_coef(lp, h) == pytest.approx(bjs_es[h]["effect"], abs=1e-6) # CE-4 (variance-weighted LP-DiD == stacked regression) is intentionally # omitted: the paper's equivalence is to the Cengiz et al. (2019) stacking @@ -850,12 +841,10 @@ def test_reweight_matches_cs_on_unbalanced(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + cs_es = es_dict(cs) for h in range(0, 4): - assert _event_coef(lp, h) == pytest.approx( - cs.event_study_effects[h]["effect"], abs=1e-9 - ) + assert _event_coef(lp, h) == pytest.approx(cs_es[h]["effect"], abs=1e-9) def test_ra_nan_consistent_on_uncontrolled_event_time(self): # An event time with treated units but no clean control is unidentified: diff --git a/tests/test_methodology_callaway.py b/tests/test_methodology_callaway.py index 34655bbf9..864bfdc7c 100644 --- a/tests/test_methodology_callaway.py +++ b/tests/test_methodology_callaway.py @@ -20,6 +20,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff import CallawaySantAnna, SurveyDesign from diff_diff.bootstrap_utils import ( @@ -258,16 +259,16 @@ def test_base_period_universal_includes_reference_period(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + # 4.0: the fit stores no event study; it is computed post-fit + assert results.event_study_effects is None + es = es_dict(results) + # Reference period e=-1 should exist with effect=0 - assert results.event_study_effects is not None, "Event study effects should be computed" - assert ( - -1 in results.event_study_effects - ), "Universal base period should include e=-1 in event study" + assert -1 in es, "Universal base period should include e=-1 in event study" - ref = results.event_study_effects[-1] + ref = es[-1] assert ref["effect"] == 0.0, "Reference period effect should be 0" assert np.isnan(ref["se"]), "Reference period SE should be NaN" assert ref["n_groups"] == 0, "Reference period n_groups should be 0" @@ -339,7 +340,7 @@ class TestDRNoCovariateSEUniformity: fed aggregation.""" @staticmethod - def _fit(data, method, aggregate=None): + def _fit(data, method): cs = CallawaySantAnna(estimation_method=method, n_bootstrap=0) return cs.fit( data, @@ -347,7 +348,6 @@ def _fit(data, method, aggregate=None): unit="unit", time="period", first_treat="first_treat", - aggregate=aggregate, ) def test_dr_reg_per_cell_se_identical(self): @@ -406,16 +406,18 @@ def test_dr_no_cov_aggregated_se_matches_reg(self): """Overall and event-study SEs are identical between dr and reg (both consume the same per-cell IF) — guards against an accidental IF change.""" data = generate_staggered_data(n_units=120, n_periods=6, never_treated_frac=0.3, seed=11) - reg = self._fit(data, "reg", aggregate="event_study") - dr = self._fit(data, "dr", aggregate="event_study") + reg = self._fit(data, "reg") + dr = self._fit(data, "dr") np.testing.assert_allclose(dr.overall_att, reg.overall_att, rtol=0, atol=1e-12) np.testing.assert_allclose(dr.overall_se, reg.overall_se, rtol=0, atol=1e-12) - assert reg.event_study_effects is not None and dr.event_study_effects is not None - assert set(reg.event_study_effects) == set(dr.event_study_effects) - for e, reg_es in reg.event_study_effects.items(): - dr_es = dr.event_study_effects[e] + reg_es_all = es_dict(reg) + dr_es_all = es_dict(dr) + assert reg_es_all and dr_es_all + assert set(reg_es_all) == set(dr_es_all) + for e, reg_es in reg_es_all.items(): + dr_es = dr_es_all[e] if np.isnan(reg_es["se"]): assert np.isnan(dr_es["se"]) else: @@ -434,8 +436,8 @@ def test_ipw_no_cov_per_cell_identical_to_reg(self): branch has diverged from the diff-in-means contract and the REGISTRY note must be revisited.""" data = generate_staggered_data(n_units=120, n_periods=6, never_treated_frac=0.3, seed=7) - reg = self._fit(data, "reg", aggregate="event_study") - ipw = self._fit(data, "ipw", aggregate="event_study") + reg = self._fit(data, "reg") + ipw = self._fit(data, "ipw") assert set(reg.group_time_effects) == set(ipw.group_time_effects) compared = 0 @@ -1063,14 +1065,13 @@ def test_event_study_aggregation_by_relative_time(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + es = results.aggregate("event_study") + assert len(es.event_time) > 0 # Should have both pre and post treatment periods - rel_times = list(results.event_study_effects.keys()) + rel_times = list(es.event_time) assert any(e < 0 for e in rel_times), "Should have pre-treatment periods" assert any(e >= 0 for e in rel_times), "Should have post-treatment periods" @@ -1091,17 +1092,16 @@ def test_group_aggregation_by_cohort(self): unit="unit", time="period", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None - assert len(results.group_effects) > 0 + grp = group_dict(results) + assert len(grp) > 0 # Should have effects for each cohort - assert 4 in results.group_effects or 6 in results.group_effects + assert 4 in grp or 6 in grp def test_all_aggregation_computes_everything(self): - """Test aggregate='all' computes event_study and group effects.""" + """Both post-fit aggregation levels are available on one fit.""" data = generate_staggered_data( n_units=100, n_periods=8, cohort_periods=[4], treatment_effect=2.0, seed=42 ) @@ -1113,11 +1113,10 @@ def test_all_aggregation_computes_everything(self): unit="unit", time="period", first_treat="first_treat", - aggregate="all", ) - assert results.event_study_effects is not None - assert results.group_effects is not None + assert len(results.aggregate("event_study").event_time) > 0 + assert len(results.aggregate("group").label) > 0 class TestGetSetParams: @@ -1211,7 +1210,7 @@ def test_results_to_dataframe_group_time(self): assert len(df) == len(results.group_time_effects) def test_results_to_dataframe_event_study(self): - """Test to_dataframe with level='event_study'.""" + """The post-fit event study container converts to a DataFrame.""" data = generate_staggered_data( n_units=50, n_periods=6, cohort_periods=[3], treatment_effect=2.0, seed=42 ) @@ -1223,13 +1222,12 @@ def test_results_to_dataframe_event_study(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - df = results.to_dataframe(level="event_study") + df = results.aggregate("event_study").to_dataframe() - assert "relative_period" in df.columns - assert "effect" in df.columns + assert "event_time" in df.columns + assert "att" in df.columns def test_results_significance_properties(self): """Test is_significant and significance_stars properties.""" @@ -1282,12 +1280,12 @@ def test_event_study_analytical_se_includes_wif(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None + es = es_dict(results) + assert es - for e, eff_data in results.event_study_effects.items(): + for e, eff_data in es.items(): se_with_wif = eff_data["se"] if np.isfinite(se_with_wif) and se_with_wif > 0: # WIF-adjusted SE should be positive @@ -1310,10 +1308,10 @@ def test_event_study_bootstrap_consistent_with_analytical(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + es_analytical = es_dict(results_analytical) - # Bootstrap SEs + # Bootstrap SEs (post-fit aggregation replays the bootstrap) cs_boot = CallawaySantAnna(n_bootstrap=n_boot, seed=42, cband=False) results_boot = cs_boot.fit( data, @@ -1321,19 +1319,19 @@ def test_event_study_bootstrap_consistent_with_analytical(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + es_boot = es_dict(results_boot) - assert results_analytical.event_study_effects is not None - assert results_boot.event_study_effects is not None + assert es_analytical + assert es_boot threshold = 0.40 if n_boot < 100 else 0.20 n_compared = 0 - for e in results_analytical.event_study_effects: - se_a = results_analytical.event_study_effects[e]["se"] - if e not in results_boot.event_study_effects: + for e in es_analytical: + se_a = es_analytical[e]["se"] + if e not in es_boot: continue - se_b = results_boot.event_study_effects[e]["se"] + se_b = es_boot[e]["se"] if np.isfinite(se_a) and se_a > 0 and np.isfinite(se_b) and se_b > 0: rel_diff = abs(se_a - se_b) / se_a assert rel_diff < threshold, ( @@ -1390,12 +1388,12 @@ def test_single_group_event_time_wif_effect(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None + es = es_dict(results) + assert es # With a single cohort, all event times have only one group - for e, eff_data in results.event_study_effects.items(): + for e, eff_data in es.items(): if eff_data["n_groups"] == 1: # SE should still be finite and positive assert np.isfinite(eff_data["se"]), f"Single-group SE for e={e} should be finite" @@ -1449,10 +1447,10 @@ def test_unbalanced_panel_bootstrap_uses_global_n(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + es_a = es_dict(results_a) - # Bootstrap SEs (the fix under test) + # Bootstrap SEs (the fix under test; post-fit aggregation replays it) cs_boot = CallawaySantAnna(n_bootstrap=n_boot, seed=42, cband=False) results_b = cs_boot.fit( data_unbalanced, @@ -1460,19 +1458,19 @@ def test_unbalanced_panel_bootstrap_uses_global_n(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + es_b = es_dict(results_b) - assert results_a.event_study_effects is not None - assert results_b.event_study_effects is not None + assert es_a + assert es_b threshold = 0.40 if n_boot < 100 else 0.25 n_compared = 0 - for e in results_a.event_study_effects: - se_a = results_a.event_study_effects[e]["se"] - if e not in results_b.event_study_effects: + for e in es_a: + se_a = es_a[e]["se"] + if e not in es_b: continue - se_b = results_b.event_study_effects[e]["se"] + se_b = es_b[e]["se"] if np.isfinite(se_a) and se_a > 0 and np.isfinite(se_b) and se_b > 0: rel_diff = abs(se_a - se_b) / se_a assert rel_diff < threshold, ( @@ -1504,14 +1502,16 @@ def test_cband_crit_value_exceeds_pointwise(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) + # 4.0: the fit stores no band; the post-fit event study carries it + assert results.cband_crit_value is None + es = results.aggregate("event_study") assert ( - results.cband_crit_value is not None + es.cband_crit_value is not None ), "cband_crit_value should be set when cband=True and n_bootstrap > 0" - assert results.cband_crit_value > 1.96, ( - f"Simultaneous critical value ({results.cband_crit_value:.3f}) " + assert es.cband_crit_value > 1.96, ( + f"Simultaneous critical value ({es.cband_crit_value:.3f}) " "should exceed pointwise z_0.025=1.96" ) @@ -1529,11 +1529,12 @@ def test_cband_confidence_intervals_wider(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - for e, eff_data in results.event_study_effects.items(): + es = es_dict(results) + assert es + assert any("cband_conf_int" in eff_data for eff_data in es.values()) + for e, eff_data in es.items(): if "cband_conf_int" in eff_data: pw_ci = eff_data["conf_int"] cb_ci = eff_data["cband_conf_int"] @@ -1559,10 +1560,9 @@ def test_cband_false_disables_simultaneous_ci(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.cband_crit_value is None + assert results.aggregate("event_study").cband_crit_value is None def test_cband_requires_bootstrap(self): """When n_bootstrap=0, cband has no effect (remains None).""" @@ -1577,10 +1577,9 @@ def test_cband_requires_bootstrap(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - assert results.cband_crit_value is None + assert results.aggregate("event_study").cband_crit_value is None def test_cband_validity_threshold(self): """When >50% of sup-t bootstrap draws are non-finite, cband_crit_value is None.""" @@ -1606,18 +1605,20 @@ def mock_max(a, axis=None): result[:n_nan] = np.nan return result + results = cs.fit( + data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + # The sup-t band is computed by the post-fit bootstrap replay, so the + # patch and the warning capture wrap the aggregation, not the fit. with unittest.mock.patch("diff_diff.staggered_bootstrap.np.max", side_effect=mock_max): with pytest.warns(RuntimeWarning, match="Too few valid sup-t"): - results = cs.fit( - data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="event_study", - ) + es = results.aggregate("event_study") - assert results.cband_crit_value is None + assert es.cband_crit_value is None def test_cband_in_get_params(self): """Test that cband parameter appears in get_params.""" @@ -1640,12 +1641,12 @@ def test_cband_to_dataframe_columns(self, ci_params): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - df = results.to_dataframe(level="event_study") + df = results.aggregate("event_study").to_dataframe() assert "cband_lower" in df.columns assert "cband_upper" in df.columns + assert np.isfinite(df["cband_lower"]).any() class TestMPDTARComparison: @@ -1886,10 +1887,10 @@ def test_mpdta_event_study_ses_match_r(self, require_r, tmp_path): unit="countyreal", time="year", first_treat="first_treat", - aggregate="event_study", ) - assert py_results.event_study_effects is not None, "Python event_study_effects is None" + py_es = es_dict(py_results) + assert py_es, "Python event study is empty" r_es = r_results["event_study"] r_event_times = r_es["event_time"] @@ -1908,11 +1909,11 @@ def test_mpdta_event_study_ses_match_r(self, require_r, tmp_path): if r_se is None or np.isnan(r_se) or r_se == 0: continue - if e not in py_results.event_study_effects: + if e not in py_es: unmatched_r.append(e) continue - py_eff = py_results.event_study_effects[e] + py_eff = py_es[e] py_att = py_eff["effect"] py_se = py_eff["se"] @@ -1960,10 +1961,9 @@ def test_mpdta_cband_crit_value_vs_r(self, require_r, tmp_path): unit="countyreal", time="year", first_treat="first_treat", - aggregate="event_study", ) - py_crit = py_results.cband_crit_value + py_crit = py_results.aggregate("event_study").cband_crit_value # Both should be finite and > 1.96 (exceed pointwise z-value) assert np.isfinite(r_crit), f"R crit value not finite: {r_crit}" diff --git a/tests/test_methodology_had.py b/tests/test_methodology_had.py index b9824451a..0557eecf4 100644 --- a/tests/test_methodology_had.py +++ b/tests/test_methodology_had.py @@ -153,7 +153,7 @@ def _make_two_period_panel( def _fit_overall(panel: pd.DataFrame, **kwargs) -> HeterogeneousAdoptionDiDResults: - """Fit HAD with `aggregate="overall"` and return the result.""" + """Fit HAD on a two-period panel (overall mode) and return the result.""" est = HeterogeneousAdoptionDiD(**kwargs) with warnings.catch_warnings(): # The Design 1 family (mass_point / continuous_near_d_lower) emits diff --git a/tests/test_methodology_imputation.py b/tests/test_methodology_imputation.py index f74355d35..15aa3246a 100644 --- a/tests/test_methodology_imputation.py +++ b/tests/test_methodology_imputation.py @@ -49,24 +49,10 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff import ImputationDiD -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # ============================================================================= # Module-level R-fixture availability + per-class seed decorrelation # ============================================================================= @@ -184,12 +170,11 @@ def test_recovers_heterogeneous_event_study(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + effects = es_dict(res) for h, expected in tau_by_h.items(): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["effect"] + assert h in effects, f"missing horizon {h}" + got = effects[h]["effect"] assert abs(got - expected) < 0.06, f"h={h}: {got:.4f} vs {expected}" def test_step1_uses_untreated_only(self) -> None: @@ -248,10 +233,8 @@ def test_event_study_ses_finite(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None - for h, eff in res.event_study_effects.items(): + for h, eff in es_dict(res).items(): # Skip the normalized reference period (effect=se=0 by construction). if h >= 0 and np.isfinite(eff["effect"]): assert np.isfinite(eff["se"]) and eff["se"] > 0, f"h={h}" @@ -406,38 +389,45 @@ def test_cache_reuse_matches_fresh_build(self) -> None: @pytest.mark.parametrize("survey", [False, True]) def test_projection_built_once_per_fit(self, survey: bool) -> None: - """The cache collapses the (1 + #horizons + #groups) per-target projection - builds -- and, with bootstrap, the analytical + precompute builds -- to a - single ``_build_untreated_projection`` per ``fit()``. Holds for the survey - path too (survey_weights is excluded from the cache key).""" - from diff_diff.imputation import ImputationDiD as _Cls + """The call-local cache collapses the per-target projection builds to a + single ``_build_untreated_projection`` per ``fit()`` and per post-fit + ``aggregate()`` call (the event-study call spans #horizons targets, the + group call #groups targets). Holds for the survey path too + (survey_weights is excluded from the cache key).""" + from diff_diff.imputation_aggregation import _ImputationAggregationMixin as _Mixin panel = self._cov_panel() base = dict(outcome="outcome", unit="unit", time="time", first_treat="first_treat") - kw: Dict[str, Any] = dict(covariates=["x1", "x2"], aggregate="all") + kw: Dict[str, Any] = dict(covariates=["x1", "x2"]) if survey: from diff_diff import SurveyDesign kw["survey_design"] = SurveyDesign(weights="weight") - orig = _Cls._build_untreated_projection + # Patch the mixin: the fit (ImputationDiD) and the post-fit kit + # aggregator (_ImputationKitAggregator) both inherit the builder. + orig = _Mixin._build_untreated_projection calls = {"n": 0} def counting(self, *a, **k): calls["n"] += 1 return orig(self, *a, **k) - _Cls._build_untreated_projection = counting # type: ignore[method-assign] + _Mixin._build_untreated_projection = counting # type: ignore[method-assign] try: with warnings.catch_warnings(): warnings.simplefilter("ignore") res = ImputationDiD().fit(panel, **base, **kw) + assert calls["n"] == 1, f"expected 1 projection build, got {calls['n']}" + es = res.aggregate("event_study") + assert calls["n"] == 2, f"event-study aggregate: {calls['n']} builds" + grp = res.aggregate("group") + assert calls["n"] == 3, f"group aggregate: {calls['n']} builds" finally: - _Cls._build_untreated_projection = orig # type: ignore[method-assign] + _Mixin._build_untreated_projection = orig # type: ignore[method-assign] # Multiple aggregation targets exist (so the naive count would be > 1). - assert res.event_study_effects and res.group_effects - assert calls["n"] == 1, f"expected 1 projection build, got {calls['n']}" + assert len(es.event_time) > 1 and len(grp.label) > 1 def test_projection_built_once_per_fit_with_bootstrap(self) -> None: """With ``n_bootstrap > 0`` the analytical aggregation AND the bootstrap @@ -461,7 +451,7 @@ def counting(self, *a, **k): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = ImputationDiD(n_bootstrap=50, seed=99).fit( - panel, **base, covariates=["x1", "x2"], aggregate="all" + panel, **base, covariates=["x1", "x2"] ) finally: _Cls._build_untreated_projection = orig # type: ignore[method-assign] @@ -478,12 +468,13 @@ def test_fit_idempotent_cache_no_leak(self) -> None: panel = self._cov_panel() base = dict(outcome="outcome", unit="unit", time="time", first_treat="first_treat") est = ImputationDiD() - r1 = est.fit(panel, **base, covariates=["x1", "x2"], aggregate="all") - r2 = est.fit(panel, **base, covariates=["x1", "x2"], aggregate="all") + r1 = est.fit(panel, **base, covariates=["x1", "x2"]) + r2 = est.fit(panel, **base, covariates=["x1", "x2"]) assert r1.overall_se == r2.overall_se - assert r1.event_study_effects is not None and r2.event_study_effects is not None - for h in r1.event_study_effects: - assert r1.event_study_effects[h]["se"] == r2.event_study_effects[h]["se"] + es1, es2 = r1.aggregate("event_study"), r2.aggregate("event_study") + assert len(es1.se) > 0 + assert np.array_equal(es1.event_time, es2.event_time) + assert np.array_equal(es1.se, es2.se, equal_nan=True) # ============================================================================= @@ -861,38 +852,38 @@ def _assert_inference_consistent(effects): assert n_finite > 0 def test_loo_aggregate_all_analytical(self): - """aggregate="all": LOO routes through the event-study AND group - aggregators (not just overall). ATT unchanged vs non-LOO, overall LOO SE - >= non-LOO, and both aggregation surfaces have consistent finite inference.""" + """LOO routes through the post-fit event-study AND group aggregators + (not just overall). ATT unchanged vs non-LOO, overall LOO SE >= non-LOO, + and both aggregation surfaces have consistent finite inference.""" rng = np.random.default_rng(_BASE_SEED_EQ8 + 19) panel = _make_staggered_panel( rng, cohorts=[3, 4], n_per_cohort=40, n_periods=6, tau_constant=1.0 ) - r0 = ImputationDiD(leave_one_out=False).fit(panel, aggregate="all", **self._COMMON) - r1 = ImputationDiD(leave_one_out=True).fit(panel, aggregate="all", **self._COMMON) + r0 = ImputationDiD(leave_one_out=False).fit(panel, **self._COMMON) + r1 = ImputationDiD(leave_one_out=True).fit(panel, **self._COMMON) np.testing.assert_allclose(r1.overall_att, r0.overall_att, rtol=0, atol=1e-12) assert r1.overall_se > r0.overall_se - self._assert_inference_consistent(r1.event_study_effects) - self._assert_inference_consistent(r1.group_effects) + self._assert_inference_consistent(es_dict(r1)) + self._assert_inference_consistent(group_dict(r1)) def test_loo_aggregate_all_bootstrap(self): - """aggregate="all" under the multiplier bootstrap: event-study and group - inference fields are populated and consistent.""" + """Under the multiplier bootstrap the event-study and group surfaces + are materialized at fit: populated, consistent, and relayed post-fit.""" rng = np.random.default_rng(_BASE_SEED_EQ8 + 20) panel = _make_staggered_panel(rng, cohorts=[3, 4], n_per_cohort=30, n_periods=6) with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = ImputationDiD(leave_one_out=True, n_bootstrap=99, seed=3).fit( - panel, aggregate="all", **self._COMMON - ) + r = ImputationDiD(leave_one_out=True, n_bootstrap=99, seed=3).fit(panel, **self._COMMON) assert np.isfinite(r.overall_se) self._assert_inference_consistent(r.event_study_effects) self._assert_inference_consistent(r.group_effects) + self._assert_inference_consistent(es_dict(r)) + self._assert_inference_consistent(group_dict(r)) def test_loo_aggregate_all_analytical_survey(self): - """aggregate="all" with an analytical survey design (weights + PSU): the - LOO rescale composes through the survey TSL variance with consistent - inference on every surface.""" + """Analytical survey design (weights + PSU): the LOO rescale composes + through the survey TSL variance with consistent inference on every + post-fit surface.""" from diff_diff.survey import SurveyDesign rng = np.random.default_rng(_BASE_SEED_EQ8 + 21) @@ -902,12 +893,11 @@ def test_loo_aggregate_all_analytical_survey(self): design = SurveyDesign(weights="weight", psu="psu") with warnings.catch_warnings(): warnings.simplefilter("ignore") - r = ImputationDiD(leave_one_out=True).fit( - panel, aggregate="all", survey_design=design, **self._COMMON - ) + r = ImputationDiD(leave_one_out=True).fit(panel, survey_design=design, **self._COMMON) + effects, groups = es_dict(r), group_dict(r) assert np.isfinite(r.overall_se) - self._assert_inference_consistent(r.event_study_effects) - self._assert_inference_consistent(r.group_effects) + self._assert_inference_consistent(effects) + self._assert_inference_consistent(groups) @pytest.mark.slow def test_loo_coverage_geq_nominal(self): @@ -971,11 +961,10 @@ def test_horizons_at_or_above_hbar_are_nan_with_warning(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + effects = es_dict(res) h_bar = 2 - for h, eff in res.event_study_effects.items(): + for h, eff in effects.items(): if h >= h_bar: assert np.isnan(eff["effect"]), f"h={h} >= H_bar should be NaN" @@ -996,13 +985,9 @@ def test_never_treated_present_identifies_all_horizons(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None # With never-treated controls, post horizons are identified (finite). - assert any( - np.isfinite(eff["effect"]) and h >= 2 for h, eff in res.event_study_effects.items() - ) + assert any(np.isfinite(eff["effect"]) and h >= 2 for h, eff in es_dict(res).items()) # ============================================================================= @@ -1151,15 +1136,14 @@ def test_event_study_atts_match_r(self, golden: dict, panel: pd.DataFrame) -> No unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + effects = es_dict(res) es = golden["event_study"] assert len(es["horizons"]) > 0 for h, att in zip(es["horizons"], es["att"]): # Every golden horizon must be present and finite -- no silent skips. - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["effect"] + assert h in effects, f"missing horizon {h}" + got = effects[h]["effect"] assert np.isfinite(got), f"non-finite ATT at h={h}" assert got == pytest.approx(att, abs=1e-6), f"h={h}" @@ -1173,15 +1157,14 @@ def test_event_study_ses_match_r(self, golden: dict, panel: pd.DataFrame) -> Non unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + effects = es_dict(res) es = golden["event_study"] assert len(es["horizons"]) > 0 for h, se in zip(es["horizons"], es["se"]): # Every golden horizon must be present and finite -- no silent skips. - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["se"] + assert h in effects, f"missing horizon {h}" + got = effects[h]["se"] assert np.isfinite(got), f"non-finite SE at h={h}" assert got == pytest.approx(se, abs=1e-7), f"h={h}" @@ -1239,13 +1222,12 @@ def test_overall_se_matches_r(self, golden: dict, covariate_panel: pd.DataFrame) assert res.overall_se == pytest.approx(golden["covariate"]["overall"]["se"], abs=1e-7) def test_event_study_atts_match_r(self, golden: dict, covariate_panel: pd.DataFrame) -> None: - res = self._fit(covariate_panel, aggregate="event_study") - assert res.event_study_effects is not None + effects = es_dict(self._fit(covariate_panel)) es = golden["covariate"]["event_study"] assert len(es["horizons"]) > 0 for h, att in zip(es["horizons"], es["att"]): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["effect"] + assert h in effects, f"missing horizon {h}" + got = effects[h]["effect"] assert np.isfinite(got), f"non-finite ATT at h={h}" assert got == pytest.approx(att, abs=1e-6), f"h={h}" @@ -1253,11 +1235,10 @@ def test_event_study_ses_match_r(self, golden: dict, covariate_panel: pd.DataFra """Per-horizon covariate-path SEs match R (the variance machinery under a covariate-augmented first stage) — ~2e-10 observed on the reference platform, asserted at abs=1e-7 for cross-platform robustness.""" - res = self._fit(covariate_panel, aggregate="event_study") - assert res.event_study_effects is not None + effects = es_dict(self._fit(covariate_panel)) es = golden["covariate"]["event_study"] for h, se in zip(es["horizons"], es["se"]): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["se"] + assert h in effects, f"missing horizon {h}" + got = effects[h]["se"] assert np.isfinite(got), f"non-finite SE at h={h}" assert got == pytest.approx(se, abs=1e-7), f"h={h}" diff --git a/tests/test_methodology_two_stage.py b/tests/test_methodology_two_stage.py index 4c9c1a4f6..7d09d198d 100644 --- a/tests/test_methodology_two_stage.py +++ b/tests/test_methodology_two_stage.py @@ -53,24 +53,10 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff import ImputationDiD, TwoStageDiD -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # ============================================================================= # Module-level R-fixture availability + per-class seed decorrelation # ============================================================================= @@ -181,12 +167,11 @@ def test_recovers_heterogeneous_event_study_eq6(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + es = es_dict(res) for h, expected in tau_by_h.items(): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["effect"] + assert h in es, f"missing horizon {h}" + got = es[h]["effect"] assert abs(got - expected) < 0.06, f"h={h}: {got:.4f} vs {expected}" def test_stage1_uses_untreated_only(self) -> None: @@ -318,11 +303,11 @@ def test_event_study_ses_finite(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None - for h, eff in res.event_study_effects.items(): - # Skip the normalized reference period (effect=se=0 by construction). + es = es_dict(res) + assert len(es) > 0 + for h, eff in es.items(): + # Skip the normalized reference period (att=0, inference NaN). if h >= 0 and np.isfinite(eff["effect"]): assert np.isfinite(eff["se"]) and eff["se"] > 0, f"h={h}" @@ -395,16 +380,15 @@ def test_pretrends_event_study_ses_finite(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - ) - assert res.event_study_effects is not None - # At least one pre-period lead is present and has a finite SE. - leads = [h for h in res.event_study_effects if h < 0] - assert leads, "pretrends=True should expose pre-period leads" - for h in leads: - eff = res.event_study_effects[h] - if np.isfinite(eff["effect"]): - assert np.isfinite(eff["se"]), f"lead h={h} SE non-finite" + ) + es = res.aggregate("event_study") + # At least one pre-period lead is present and has a finite SE + # (the reference period's inference is NaN by container contract). + leads = (es.event_time < 0) & ~es.is_reference + assert leads.any(), "pretrends=True should expose pre-period leads" + for h, att, se in zip(es.event_time[leads], es.att[leads], es.se[leads]): + if np.isfinite(att): + assert np.isfinite(se), f"lead h={h} SE non-finite" # Overall ATT is unchanged by requesting pre-period leads. assert res.overall_att == pytest.approx(base.overall_att, abs=1e-8) @@ -616,25 +600,20 @@ def test_no_never_treated_horizons_nan(self) -> None: sigma=0.1, include_never_treated=False, ) + res = TwoStageDiD().fit( + panel, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) with pytest.warns(UserWarning, match="identified"): - res = TwoStageDiD().fit( - panel, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - ) - assert res.event_study_effects is not None + es = es_dict(res) h_bar = 2 - flagged = [ - h - for h, eff in res.event_study_effects.items() - if h >= h_bar and np.isnan(eff["effect"]) - ] + flagged = [h for h, eff in es.items() if h >= h_bar and np.isnan(eff["effect"])] assert flagged, "expected NaN horizons at or above h_bar" for h in flagged: - assert res.event_study_effects[h]["n_obs"] > 0, f"h={h} should keep n_obs>0" + assert es[h]["n_obs"] > 0, f"h={h} should keep n_obs>0" def test_balance_e_no_qualifying_cohorts_warns(self) -> None: """balance_e larger than any cohort's available pre-window -> no cohort @@ -643,24 +622,18 @@ def test_balance_e_no_qualifying_cohorts_warns(self) -> None: panel = _make_staggered_panel( rng, cohorts=[2, 3], n_per_cohort=60, n_periods=5, tau_constant=1.0, sigma=0.1 ) + res = TwoStageDiD().fit( + panel, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) with pytest.warns(UserWarning, match="balance_e"): - res = TwoStageDiD().fit( - panel, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - balance_e=10, # impossible pre-window - ) - assert res.event_study_effects is not None + es = es_dict(res, balance_e=10) # impossible pre-window # Only the reference period survives. - finite_effects = [ - h for h, eff in res.event_study_effects.items() if eff.get("n_obs", 0) > 0 - ] - assert finite_effects == [] or all( - res.event_study_effects[h]["effect"] == 0.0 for h in res.event_study_effects - ) + finite_effects = [h for h, eff in es.items() if eff.get("n_obs", 0) > 0] + assert finite_effects == [] or all(es[h]["effect"] == 0.0 for h in es) def test_zero_obs_cohort_nan(self) -> None: """A cohort whose treated obs ALL fall at an unidentified period -> its @@ -705,17 +678,16 @@ def test_zero_obs_cohort_nan(self) -> None: unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - assert res.group_effects is not None + grp = group_dict(res) # Cohort 4's only treated obs is the unidentified t=4 -> NaN, n_obs=0. - assert 4 in res.group_effects - c4 = res.group_effects[4] + assert 4 in grp + c4 = grp[4] assert c4["n_obs"] == 0, f"cohort 4 should have n_obs=0, got {c4['n_obs']}" assert np.isnan(c4["effect"]), "cohort 4 effect must be NaN" assert np.isnan(c4["se"]) and np.isnan(c4["t_stat"]) and np.isnan(c4["p_value"]) # Cohort 3 keeps an identified t=3 effect. - assert 3 in res.group_effects and res.group_effects[3]["n_obs"] > 0 + assert 3 in grp and grp[3]["n_obs"] > 0 # ============================================================================= @@ -805,14 +777,13 @@ def test_event_study_atts_match_r(self, golden: dict, panel: pd.DataFrame) -> No unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + got_es = es_dict(res) es = golden["event_study"] assert len(es["horizons"]) > 0 for h, att in zip(es["horizons"], es["att"]): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["effect"] + assert h in got_es, f"missing horizon {h}" + got = got_es[h]["effect"] assert np.isfinite(got), f"non-finite ATT at h={h}" assert got == pytest.approx(att, abs=1e-6), f"h={h}" @@ -823,13 +794,12 @@ def test_event_study_ses_match_r(self, golden: dict, panel: pd.DataFrame) -> Non unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_effects is not None + got_es = es_dict(res) es = golden["event_study"] assert len(es["horizons"]) > 0 for h, se in zip(es["horizons"], es["se"]): - assert h in res.event_study_effects, f"missing horizon {h}" - got = res.event_study_effects[h]["se"] + assert h in got_es, f"missing horizon {h}" + got = got_es[h]["se"] assert np.isfinite(got), f"non-finite SE at h={h}" assert got == pytest.approx(se, abs=1e-7), f"h={h}" diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index e185ba34c..5b823d710 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -574,10 +574,10 @@ def _build_rowed_index(): **{f"{cls}.groups": _CS_COHORT for cls in _CS_GROUPS_CLASSES}, "GroupTimeEffect.group": _CS_COHORT, "HADPretestReport.aggregate": ( - "honest OUTPUT metadata, not the deprecated param (M-139 kills only " - "did_had_pretest_workflow's aggregate= INPUT): the field records " - "which pretest battery the workflow RAN, and the overall/event_study " - "modes survive 4.0 - only the routing param dies" + "honest OUTPUT metadata, not the removed param (M-139 removed only " + "did_had_pretest_workflow's aggregate= INPUT at 4.0): the field " + "records which pretest battery the workflow RAN, and the " + "overall/event_study modes survive - only the routing param died" ), "plot_group_effects[groups]": _CS_COHORT + " - cohort selector on the plotting surface", "DMLDiDResults.overall_att": ( @@ -1261,14 +1261,35 @@ def _token_family_code_refs(tok): # the docs/guides attr lane. Each entry below was verified clean of # stale fit-time `aggregate=` teaching before allowlisting (the # migrate-first rule; stale sites were migrated in the same diff that - # added these entries). Dated phase-closure records in the paper-review - # doc describe the API as-shipped at closure time (the released-record - # convention) and are not regenerated for a deprecation sweep. + # added these entries - the last of them with the 4.0 PR 3 removal of + # the fit-time keyword on all eight estimators and the workflow). Dated + # phase-closure records in the paper-review doc describe the API + # as-shipped at closure time (the released-record convention) and are + # not regenerated for a deprecation sweep. ("aggregate", "diff_diff/guides/llms-autonomous.txt"): ( "canonical post-fit results.aggregate() vocabulary; the HAD " "mode-kwarg teachings were migrated to panel-shape wording with " "M-027/M-139" ), + ("aggregate", "diff_diff/guides/llms.txt"): ( + "canonical post-fit results.aggregate() vocabulary; the fit-time " + "aggregate= teaching for the eight adopters was removed with the " + "4.0 PR 3 sweep (M-020..M-027, M-117..M-120, M-139); the only " + "surviving fit-time aggregate= is TripleDifference's canonical " + "staggered-mode keyword (M-140/M-141)" + ), + ("aggregate", "diff_diff/guides/llms-full.txt"): ( + "canonical post-fit results.aggregate() vocabulary and the " + "signature blocks WITHOUT the removed fit-time lines (4.0 PR 3 " + "sweep; pinned by tests/test_guides.py's " + "*_fit_block_has_no_fit_time_aggregate tests); the only surviving " + "fit-time aggregate= is TripleDifference's (M-140/M-141)" + ), + ("aggregate", "diff_diff/guides/llms-practitioner.txt"): ( + "canonical post-fit results.aggregate() vocabulary; the bootstrapped " + "ImputationDiD/TwoStageDiD fit-time route was replaced by the " + "4.0 PR 3 relay wording" + ), ("aggregate", "docs/methodology/REGISTRY.md"): ( "the aggregate-postfit register Notes (M-020..M-027) teach the " "canonical successor method; HAD-section fit-time mode mentions " @@ -1282,9 +1303,9 @@ def _token_family_code_refs(tok): "consumes" ), ("aggregate", "docs/methodology/REPORTING.md"): ( - "canonical post-fit aggregate() mention only (zero fit-time " - "kwarg sites; the fit-time-population clause was reworded with " - "M-027)" + "canonical post-fit aggregate() mention only (zero fit-time kwarg " + "sites since the 4.0 PR 3 sweep removed the deprecated-route " + "clauses; the fit-time-population clause was reworded with M-027)" ), ("aggregate", "docs/methodology/papers/dechaisemartin-2026-review.md"): ( "dated phase-closure records describing the API as-shipped at " @@ -1298,7 +1319,8 @@ def _token_family_code_refs(tok): "with M-027)" ), ("aggregate", "docs/methodology/variance-conventions.md"): ( - "canonical post-fit aggregate() vocabulary only (zero fit-time " "kwarg sites)" + "canonical post-fit aggregate() vocabulary only (zero fit-time kwarg " + "sites since the 4.0 PR 3 sweep removed the deprecated-route clause)" ), **{ ( diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index b67861a94..aa1638ca6 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -644,7 +644,11 @@ def test_aggregation_step_bootstrap_branch(self): steps = self._agg_step(output) assert len(steps) == 1 assert "BOOTSTRAPPED" in steps[0]["why"] - assert "aggregate='all'" in steps[0]["code"] + # 4.0 (PR 3): bootstrapped fits RELAY the surfaces computed at fit; the + # deprecated fit-time aggregate='all' route is gone from the guidance. + assert "relay" in steps[0]["why"] + assert "aggregate='all'" not in steps[0]["code"] + assert "results.aggregate('event_study')" in steps[0]["code"] def test_aggregation_step_name_is_non_steps_key(self): from diff_diff.practitioner import STEPS @@ -683,9 +687,8 @@ class TestContinuousAggregationStep: ``aggregate('group')`` (not in ContinuousDiD's supported set), simulates bootstrap via a ``bootstrap_results`` field (ContinuousDiDResults keys on ``n_bootstrap`` and has no such field), - and expects an ``aggregate='all'`` fallback (never a valid - ContinuousDiD value). ContinuousDiD's step is a single unconditional - step whose wording carries the bootstrap carve-out. + and never had an ``aggregate='all'`` fallback. ContinuousDiD's step is a + single unconditional step whose wording names the bootstrapped-fit relay. """ @staticmethod @@ -706,8 +709,9 @@ def test_aggregation_step_present_with_all_routes(self): assert "results.aggregate('event_study')" in steps[0]["code"] assert "results.aggregate('dose')" in steps[0]["code"] assert "results.aggregate('simple')" in steps[0]["code"] - # The bootstrap carve-out is carried in the wording. - assert "n_bootstrap=0" in steps[0]["why"] + # 4.0 (PR 3): bootstrapped fits relay the surface computed at fit. + assert "relays" in steps[0]["why"] + assert "n_bootstrap=0" not in steps[0]["why"] def test_aggregation_step_name_is_non_steps_key(self): from diff_diff.practitioner import STEPS diff --git a/tests/test_pretrends.py b/tests/test_pretrends.py index f7cf74df2..ebeff0d26 100644 --- a/tests/test_pretrends.py +++ b/tests/test_pretrends.py @@ -885,16 +885,17 @@ def test_callaway_santanna_universal_base_period(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", ) - # Verify reference period exists with NaN SE - assert -1 in results.event_study_effects - assert np.isnan(results.event_study_effects[-1]["se"]) + # Verify the reference period exists with NaN SE on the post-fit container + es = results.aggregate("event_study") + es_times = list(es.event_time) + assert -1 in es_times + assert np.isnan(es.se[es_times.index(-1)]) # PreTrendsPower should work without errors (reference period filtered out) pt = PreTrendsPower() - power_results = pt.fit(results) + power_results = pt.fit(es) # Should have valid (non-NaN) results assert np.isfinite(power_results.power) diff --git a/tests/test_pretrends_event_study.py b/tests/test_pretrends_event_study.py index 4f90c18b6..a5c7acd6d 100644 --- a/tests/test_pretrends_event_study.py +++ b/tests/test_pretrends_event_study.py @@ -7,26 +7,11 @@ import numpy as np import pandas as pd -import pytest +from legacy_surface import es_dict from diff_diff.imputation import ImputationDiD from diff_diff.two_stage import TwoStageDiD -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - def generate_test_data( n_units: int = 100, @@ -95,11 +80,9 @@ def test_pretrends_includes_negative_horizons(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - horizons = sorted(results.event_study_effects.keys()) + horizons = sorted(es_dict(results)) negative = [h for h in horizons if h < 0] positive = [h for h in horizons if h >= 0] assert len(negative) > 1, "Should have pre-period horizons" @@ -115,11 +98,10 @@ def test_pretrends_coefficients_near_zero(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) ref_period = -1 - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if h >= 0 or h == ref_period: continue assert np.isfinite(eff["effect"]), f"h={h}: effect not finite" @@ -137,11 +119,10 @@ def test_pretrends_se_finite_positive(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) ref_period = -1 - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if h == ref_period: continue if eff["n_obs"] == 0: @@ -159,11 +140,11 @@ def test_reference_period_correct(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert -1 in results.event_study_effects - ref = results.event_study_effects[-1] + effects = es_dict(results) + assert -1 in effects + ref = effects[-1] assert ref["effect"] == 0.0 assert ref["n_obs"] == 0 @@ -177,7 +158,6 @@ def test_backward_compatibility(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) results_false = ImputationDiD(pretrends=False).fit( data, @@ -185,14 +165,11 @@ def test_backward_compatibility(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) assert results_default.overall_att == results_false.overall_att assert results_default.overall_se == results_false.overall_se - assert set(results_default.event_study_effects.keys()) == set( - results_false.event_study_effects.keys() - ) + assert set(es_dict(results_default)) == set(es_dict(results_false)) def test_post_treatment_invariance(self): """Post-treatment effects identical with pretrends=True vs False.""" @@ -204,7 +181,6 @@ def test_post_treatment_invariance(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) results_on = ImputationDiD(pretrends=True).fit( data, @@ -212,7 +188,6 @@ def test_post_treatment_invariance(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Overall ATT unchanged @@ -220,10 +195,11 @@ def test_post_treatment_invariance(self): assert results_on.overall_se == results_off.overall_se # Post-treatment event study effects unchanged - for h in results_off.event_study_effects: - assert h in results_on.event_study_effects - eff_off = results_off.event_study_effects[h] - eff_on = results_on.event_study_effects[h] + es_off, es_on = es_dict(results_off), es_dict(results_on) + for h in es_off: + assert h in es_on + eff_off = es_off[h] + eff_on = es_on[h] np.testing.assert_allclose(eff_off["effect"], eff_on["effect"], rtol=1e-10) np.testing.assert_allclose(eff_off["se"], eff_on["se"], rtol=1e-10) @@ -237,10 +213,9 @@ def test_horizon_max_interaction(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h in results.event_study_effects: + for h in es_dict(results): assert abs(h) <= 2, f"h={h} exceeds horizon_max=2" def test_anticipation_interaction(self): @@ -253,11 +228,11 @@ def test_anticipation_interaction(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert -2 in results.event_study_effects - ref = results.event_study_effects[-2] + effects = es_dict(results) + assert -2 in effects + ref = effects[-2] assert ref["effect"] == 0.0 assert ref["n_obs"] == 0 @@ -292,9 +267,8 @@ def test_no_pretreatment_obs_graceful(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None + assert results.aggregate("event_study") is not None # ============================================================================= @@ -315,11 +289,9 @@ def test_pretrends_includes_negative_horizons(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - horizons = sorted(results.event_study_effects.keys()) + horizons = sorted(es_dict(results)) negative = [h for h in horizons if h < 0] positive = [h for h in horizons if h >= 0] assert len(negative) > 1, "Should have pre-period horizons" @@ -335,11 +307,10 @@ def test_pretrends_coefficients_near_zero(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) ref_period = -1 - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if h >= 0 or h == ref_period: continue assert np.isfinite(eff["effect"]), f"h={h}: effect not finite" @@ -357,11 +328,10 @@ def test_pretrends_se_finite_positive(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) ref_period = -1 - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if h == ref_period: continue if eff["n_obs"] == 0: @@ -379,7 +349,6 @@ def test_post_treatment_invariance(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) results_on = TwoStageDiD(pretrends=True).fit( data, @@ -387,7 +356,6 @@ def test_post_treatment_invariance(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Overall ATT unchanged @@ -395,10 +363,11 @@ def test_post_treatment_invariance(self): assert results_on.overall_se == results_off.overall_se # Post-treatment event study effects unchanged - for h in results_off.event_study_effects: - assert h in results_on.event_study_effects - eff_off = results_off.event_study_effects[h] - eff_on = results_on.event_study_effects[h] + es_off, es_on = es_dict(results_off), es_dict(results_on) + for h in es_off: + assert h in es_on + eff_off = es_off[h] + eff_on = es_on[h] np.testing.assert_allclose(eff_off["effect"], eff_on["effect"], rtol=1e-10) np.testing.assert_allclose(eff_off["se"], eff_on["se"], rtol=1e-10) @@ -419,10 +388,9 @@ def test_horizon_max_interaction(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h in results.event_study_effects: + for h in es_dict(results): assert abs(h) <= 2, f"h={h} exceeds horizon_max=2" def test_anticipation_interaction(self): @@ -435,11 +403,11 @@ def test_anticipation_interaction(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert -2 in results.event_study_effects - ref = results.event_study_effects[-2] + effects = es_dict(results) + assert -2 in effects + ref = effects[-2] assert ref["effect"] == 0.0 assert ref["n_obs"] == 0 @@ -463,7 +431,6 @@ def test_imputation_pretrends_match_pretrend_test(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Get pretrend_test lead coefficients (called on results object) @@ -471,9 +438,10 @@ def test_imputation_pretrends_match_pretrend_test(self): lead_coefs = pt["lead_coefficients"] # Every lead coefficient should match the event study pre-period effect + effects = es_dict(results) for h, coef in lead_coefs.items(): - assert h in results.event_study_effects, f"h={h}: lead coefficient not in event study" - es_effect = results.event_study_effects[h]["effect"] + assert h in effects, f"h={h}: lead coefficient not in event study" + es_effect = effects[h]["effect"] np.testing.assert_allclose( es_effect, coef, @@ -537,11 +505,10 @@ def test_imputation_survey_weighted_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", survey_design=sd, ) - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if eff["n_obs"] == 0: continue assert np.isfinite(eff["se"]), f"h={h}: SE not finite" @@ -563,12 +530,9 @@ def test_imputation_pretrends_with_covariates(self): time="time", first_treat="first_treat", covariates=["x1"], - aggregate="event_study", ) - negative = { - h: v for h, v in results.event_study_effects.items() if h < -1 and v["n_obs"] > 0 - } + negative = {h: v for h, v in es_dict(results).items() if h < -1 and v["n_obs"] > 0} assert len(negative) > 0, "Should have pre-period horizons" for h, eff in negative.items(): assert np.isfinite(eff["se"]), f"h={h}: SE not finite with covariates" @@ -589,7 +553,6 @@ def test_imputation_pretrends_bootstrap_post_only(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # With bootstrap @@ -599,15 +562,16 @@ def test_imputation_pretrends_bootstrap_post_only(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - # Pre-period effects and SEs should be identical (bootstrap doesn't touch them) - for h in results_no_boot.event_study_effects: - if h >= 0 or results_no_boot.event_study_effects[h]["n_obs"] == 0: + # Pre-period effects and SEs should be identical (bootstrap doesn't + # touch them): the analytical recompute vs the relayed fit-time surface. + es_no_boot, es_boot = es_dict(results_no_boot), es_dict(results_boot) + for h in es_no_boot: + if h >= 0 or es_no_boot[h]["n_obs"] == 0: continue - eff_nb = results_no_boot.event_study_effects[h] - eff_b = results_boot.event_study_effects[h] + eff_nb = es_no_boot[h] + eff_b = es_boot[h] np.testing.assert_allclose( eff_nb["effect"], eff_b["effect"], @@ -636,7 +600,6 @@ def test_nondefault_index_analytical(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Permuted integer index @@ -649,7 +612,6 @@ def test_nondefault_index_analytical(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Gapped/nonconsecutive index @@ -661,16 +623,18 @@ def test_nondefault_index_analytical(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # All pre-period effects and SEs must match - for h in results_default.event_study_effects: - if results_default.event_study_effects[h]["n_obs"] == 0: + es_default = es_dict(results_default) + es_perm = es_dict(results_perm) + es_gap = es_dict(results_gap) + for h in es_default: + if es_default[h]["n_obs"] == 0: continue - eff_d = results_default.event_study_effects[h] - eff_p = results_perm.event_study_effects[h] - eff_g = results_gap.event_study_effects[h] + eff_d = es_default[h] + eff_p = es_perm[h] + eff_g = es_gap[h] np.testing.assert_allclose( eff_d["effect"], eff_p["effect"], @@ -709,7 +673,6 @@ def test_nondefault_index_bootstrap(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) rng = np.random.default_rng(42) @@ -721,14 +684,14 @@ def test_nondefault_index_bootstrap(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h in results_default.event_study_effects: - if results_default.event_study_effects[h]["n_obs"] == 0: + es_default, es_perm = es_dict(results_default), es_dict(results_perm) + for h in es_default: + if es_default[h]["n_obs"] == 0: continue - eff_d = results_default.event_study_effects[h] - eff_p = results_perm.event_study_effects[h] + eff_d = es_default[h] + eff_p = es_perm[h] np.testing.assert_allclose( eff_d["effect"], eff_p["effect"], @@ -759,41 +722,32 @@ def test_imputation_pretrends_survey_accepted(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", survey_design=sd, ) - assert result.event_study_effects is not None + assert result.aggregate("event_study") is not None assert np.isfinite(result.overall_att) def test_imputation_pretrends_balance_e(self): """balance_e restricts pre-period lead regression to balanced cohorts.""" data = generate_test_data(seed=42) - results_no_be = ImputationDiD(pretrends=True).fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - ) - results_be = ImputationDiD(pretrends=True).fit( + results = ImputationDiD(pretrends=True).fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - balance_e=1, ) + es_no_be = results.aggregate("event_study") + es_be = results.aggregate("event_study", balance_e=1) # Both should have pre-period horizons - neg_no_be = [h for h in results_no_be.event_study_effects if h < -1] + neg_no_be = [h for h in es_no_be.event_time.tolist() if h < -1] assert len(neg_no_be) > 0 # balance_e restricts cohorts; pre-period coefficients may be NaN # if the restricted sample is rank-deficient after demeaning # The key invariant: the method runs without error - assert results_be.event_study_effects is not None + assert es_be is not None def test_two_stage_pretrends_bootstrap(self): """TwoStageDiD pretrends=True with bootstrap produces finite pre-period SEs.""" @@ -805,12 +759,9 @@ def test_two_stage_pretrends_bootstrap(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - negative = { - h: v for h, v in results.event_study_effects.items() if h < -1 and v["n_obs"] > 0 - } + negative = {h: v for h, v in es_dict(results).items() if h < -1 and v["n_obs"] > 0} assert len(negative) > 0, "Should have pre-period horizons" for h, eff in negative.items(): assert np.isfinite(eff["se"]), f"h={h}: SE not finite" diff --git a/tests/test_replicate_weight_expansion.py b/tests/test_replicate_weight_expansion.py index 4880ba71f..24adb7d9a 100644 --- a/tests/test_replicate_weight_expansion.py +++ b/tests/test_replicate_weight_expansion.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, group_dict from diff_diff import ( DifferenceInDifferences, @@ -17,21 +18,6 @@ ) from diff_diff.survey import SurveyDesign -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -414,15 +400,14 @@ def test_imputation_event_study_replicate(self): "unit", "time", "first_treat", - aggregate="event_study", survey_design=sd, ) assert np.isfinite(result.overall_se) and result.overall_se > 0 - assert result.event_study_effects is not None + es = es_dict(result) # At least some identified periods should have finite SE finite_ses = [ e - for e, eff in result.event_study_effects.items() + for e, eff in es.items() if np.isfinite(eff["effect"]) and np.isfinite(eff["se"]) and eff["se"] > 0 ] assert len(finite_ses) > 0, "No event-study periods have finite replicate SE" @@ -438,11 +423,11 @@ def test_imputation_group_replicate(self): "unit", "time", "first_treat", - aggregate="group", survey_design=sd, ) - assert result.group_effects is not None - for g, eff in result.group_effects.items(): + grp = group_dict(result) + assert len(grp) > 0 + for g, eff in grp.items(): assert np.isfinite(eff["se"]) and eff["se"] > 0, f"group {g}: SE not finite" def test_imputation_bootstrap_rejected(self): @@ -491,11 +476,9 @@ def test_two_stage_event_study_replicate(self): "unit", "time", "first_treat", - aggregate="event_study", survey_design=sd, ) - assert result.event_study_effects is not None - non_ref = {e: eff for e, eff in result.event_study_effects.items() if eff["effect"] != 0.0} + non_ref = {e: eff for e, eff in es_dict(result).items() if eff["effect"] != 0.0} assert len(non_ref) > 0, "No non-reference event-study effects" for e, eff in non_ref.items(): assert np.isfinite(eff["se"]) and eff["se"] > 0, f"period {e}: SE not finite" @@ -608,7 +591,6 @@ def test_two_stage_always_treated_event_study_and_group_replicate(self): "unit", "time", "first_treat", - aggregate="all", survey_design=sd, ) # Event-study surface: at least one non-reference horizon must have @@ -616,10 +598,9 @@ def test_two_stage_always_treated_event_study_and_group_replicate(self): # override path at `two_stage.py` updates SE + t_stat + p_value + # conf_int separately for each non-reference horizon via the same # _refit_ts callback, so all four fields must be locked). - assert result.event_study_effects is not None non_ref_es = { e: eff - for e, eff in result.event_study_effects.items() + for e, eff in es_dict(result).items() if eff["effect"] != 0.0 and np.isfinite(eff["effect"]) } assert len(non_ref_es) > 0, "no non-reference event-study effects" @@ -636,9 +617,8 @@ def test_two_stage_always_treated_event_study_and_group_replicate(self): ), f"event-study horizon {e}: replicate conf_int bounds must be finite" # Group surface: at least one cohort with finite replicate SE / # p_value / conf_int (same override path applies to group effects). - assert result.group_effects is not None finite_groups = { - g: eff for g, eff in result.group_effects.items() if np.isfinite(eff["effect"]) + g: eff for g, eff in group_dict(result).items() if np.isfinite(eff["effect"]) } assert len(finite_groups) > 0, "no finite group effects" for g, eff in finite_groups.items(): diff --git a/tests/test_se_accuracy.py b/tests/test_se_accuracy.py index 85dc82484..3dc6a8c73 100644 --- a/tests/test_se_accuracy.py +++ b/tests/test_se_accuracy.py @@ -126,7 +126,6 @@ def cs_results(self, benchmark_data) -> Tuple: unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) elapsed = time.perf_counter() - start @@ -195,7 +194,6 @@ def test_aggregation_se_formula(self, benchmark_data): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) # Aggregated SE should be positive diff --git a/tests/test_stacked_did.py b/tests/test_stacked_did.py index 017ffbc0f..07f349a56 100644 --- a/tests/test_stacked_did.py +++ b/tests/test_stacked_did.py @@ -105,33 +105,33 @@ def test_event_study(self, staggered_data): if h in results.event_study_effects: assert results.event_study_effects[h]["n_obs"] > 0 - def test_group_aggregate_raises(self, staggered_data): - """aggregate='group' warns (M-024 shim) then raises ValueError.""" + def test_fit_time_aggregate_kwarg_removed(self, staggered_data): + """4.0: the fit-time ``aggregate=`` keyword is gone (native TypeError); + the event study is always computed.""" est = StackedDiD(kappa_pre=2, kappa_post=2) - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="group.*not supported"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="group", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + aggregate="group", + ) - def test_all_aggregate_raises(self, staggered_data): - """aggregate='all' warns (M-024 shim) then raises ValueError.""" + def test_group_aggregation_unsupported(self, staggered_data): + """StackedDiD exposes no group aggregation: the post-fit + ``aggregate("group")`` raises the mixin's unsupported-type error.""" est = StackedDiD(kappa_pre=2, kappa_post=2) - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="all.*not supported"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="all", - ) + results = est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + with pytest.raises(ValueError, match="Unsupported aggregation type"): + results.aggregate("group") def test_simple_att(self, staggered_data): """Plain fit: overall ATT plus the always-computed surface (M-024).""" @@ -898,18 +898,27 @@ def test_invalid_cluster(self): StackedDiD(cluster="invalid") def test_invalid_aggregate(self, staggered_data): - """Warns (M-024 shim) then raises on invalid aggregate parameter.""" + """The fit-time ``aggregate=`` keyword is gone (4.0): any value is a + native TypeError; an unknown post-fit aggregation type raises.""" est = StackedDiD() - with pytest.warns(FutureWarning, match=r"fit\(aggregate=\) is deprecated"): - with pytest.raises(ValueError, match="aggregate"): - est.fit( - staggered_data, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="invalid", - ) + with pytest.raises(TypeError, match="unexpected keyword argument 'aggregate'"): + est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + aggregate="invalid", + ) + results = est.fit( + staggered_data, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + with pytest.raises(ValueError, match="Unsupported aggregation type"): + results.aggregate("invalid") def test_population_required_for_population_weighting(self, staggered_data): """Raises when population col not specified with weighting='population'.""" diff --git a/tests/test_staggered.py b/tests/test_staggered.py index a4e85d8b1..9a88f8171 100644 --- a/tests/test_staggered.py +++ b/tests/test_staggered.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, es_dict_from_container, group_dict from diff_diff import CallawaySantAnna, CallawaySantAnnaResults from diff_diff.prep import generate_staggered_data as _generate_staggered_data @@ -118,7 +119,7 @@ def test_never_treated_inf_encoding(self): ), f"ATT differs: inf={results_inf.overall_att}, zero={results_zero.overall_att}" def test_event_study_aggregation(self): - """Test event study aggregation.""" + """Test post-fit event study aggregation.""" data = generate_staggered_data() cs = CallawaySantAnna() @@ -128,14 +129,16 @@ def test_event_study_aggregation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + # 4.0: fit never populates the fit-time surface; aggregation is post-fit + assert results.event_study_effects is None + assert results.group_effects is None + es = results.aggregate("event_study") + assert len(es.event_time) > 0 # Check that relative periods are present - rel_periods = list(results.event_study_effects.keys()) + rel_periods = list(es.event_time) assert any(p >= 0 for p in rel_periods) # Post-treatment def test_group_aggregation(self): @@ -149,14 +152,13 @@ def test_group_aggregation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None - assert len(results.group_effects) > 0 + grp = results.aggregate("group") + assert len(grp.label) > 0 def test_all_aggregation(self): - """Test computing all aggregations.""" + """Test computing both post-fit aggregation levels on one fit.""" data = generate_staggered_data() cs = CallawaySantAnna() @@ -166,11 +168,10 @@ def test_all_aggregation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) - assert results.event_study_effects is not None - assert results.group_effects is not None + assert len(results.aggregate("event_study").event_time) > 0 + assert len(results.aggregate("group").label) > 0 def test_control_group_options(self): """Test different control group options.""" @@ -221,7 +222,6 @@ def test_summary_output(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) summary = results.summary() @@ -241,7 +241,6 @@ def test_to_dataframe(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) # Group-time DataFrame @@ -250,13 +249,15 @@ def test_to_dataframe(self): assert "time" in df_gt.columns assert "effect" in df_gt.columns - # Event study DataFrame - df_es = results.to_dataframe(level="event_study") - assert "relative_period" in df_es.columns + # Event study DataFrame (post-fit container) + df_es = results.aggregate("event_study").to_dataframe() + assert "event_time" in df_es.columns + assert "att" in df_es.columns - # Group DataFrame - df_g = results.to_dataframe(level="group") - assert "group" in df_g.columns + # Group DataFrame (post-fit container) + df_g = results.aggregate("group").to_dataframe() + assert "label" in df_g.columns + assert "att" in df_g.columns def test_get_set_params(self): """Test sklearn-compatible parameter access.""" @@ -336,7 +337,7 @@ def test_event_study_not_computed_error(self): data, outcome="outcome", unit="unit", time="time", first_treat="first_treat" ) - with pytest.raises(ValueError, match="Event study effects not computed"): + with pytest.raises(ValueError, match="not stored on 4.0 fits"): results.to_dataframe(level="event_study") @@ -537,11 +538,9 @@ def test_event_study_with_covariates(self): time="time", first_treat="first_treat", covariates=["x1", "x2"], - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + assert len(results.aggregate("event_study").event_time) > 0 def test_missing_covariate_error(self): """Test error when covariate column is missing.""" @@ -1118,8 +1117,10 @@ def mock_solve_ols(*args, **kwargs): time="time", first_treat="first_treat", covariates=["x1", "x1_dup"], - aggregate="all", ) + # post-fit aggregation replays the bootstrap (low-n warnings again) + es = es_dict(results) + grp = group_dict(results) # NaN cell should be preserved in group_time_effects nan_cells = [ @@ -1140,18 +1141,18 @@ def mock_solve_ols(*args, **kwargs): assert all(np.isfinite(x) for x in results.overall_conf_int), "overall CI should be finite" # Event study: valid relative times should have finite bootstrap inference - if results.event_study_effects: - for e, data_es in results.event_study_effects.items(): - if np.isfinite(data_es["effect"]): - assert np.isfinite(data_es["se"]), f"ES e={e} se should be finite" - assert np.isfinite(data_es["p_value"]), f"ES e={e} p_value should be finite" + assert es + for e, data_es in es.items(): + if np.isfinite(data_es["effect"]): + assert np.isfinite(data_es["se"]), f"ES e={e} se should be finite" + assert np.isfinite(data_es["p_value"]), f"ES e={e} p_value should be finite" # Group effects: valid groups should have finite bootstrap inference - if results.group_effects: - for g, data_ge in results.group_effects.items(): - if np.isfinite(data_ge["effect"]): - assert np.isfinite(data_ge["se"]), f"Group {g} se should be finite" - assert np.isfinite(data_ge["p_value"]), f"Group {g} p_value should be finite" + assert grp + for g, data_ge in grp.items(): + if np.isfinite(data_ge["effect"]): + assert np.isfinite(data_ge["se"]), f"Group {g} se should be finite" + assert np.isfinite(data_ge["p_value"]), f"Group {g} p_value should be finite" def test_balance_e_excludes_nan_anchor_cohort(self, ci_params): """balance_e must exclude cohorts whose anchor-horizon effect is NaN.""" @@ -1196,9 +1197,9 @@ def mock_solve_ols(*args, **kwargs): time="time", first_treat="first_treat", covariates=["x1", "x1_dup"], - aggregate="event_study", - balance_e=0, ) + # balanced post-fit aggregation (replays the bootstrap) + es = es_dict(results, balance_e=0) # Confirm the anchor cell is NaN and is specifically the anchor (t - g == 0) assert np.isnan( @@ -1209,21 +1210,16 @@ def mock_solve_ols(*args, **kwargs): # Cohort g=3 should be excluded from ALL event-study horizons # Only g=5 and g=8 should contribute (<=2 because not all balanced # cohorts have cells at extreme horizons) - for e, es_data in results.event_study_effects.items(): + for e, es_data in es.items(): assert es_data["n_groups"] <= 2, ( f"Event time e={e} has n_groups={es_data['n_groups']}, " "expected <=2 (cohort g=3 should be excluded due to NaN anchor)" ) - # Analytical effects and SEs should be finite for all horizons - for e, es_data in results.event_study_effects.items(): - assert np.isfinite(es_data["effect"]), f"e={e}: analytical effect should be finite" - assert np.isfinite(es_data["se"]), f"e={e}: analytical SE should be finite" - - # Bootstrap SEs should also be finite - if results.bootstrap_results and results.bootstrap_results.event_study_ses: - for e, se in results.bootstrap_results.event_study_ses.items(): - assert np.isfinite(se), f"e={e}: bootstrap SE should be finite" + # Effects and (bootstrap percentile) SEs should be finite for all horizons + for e, es_data in es.items(): + assert np.isfinite(es_data["effect"]), f"e={e}: effect should be finite" + assert np.isfinite(es_data["se"]), f"e={e}: bootstrap SE should be finite" @pytest.mark.parametrize("action", ["silent", "warn"]) def test_reg_underdetermined_control_cell_no_crash(self, action): @@ -1525,9 +1521,8 @@ def test_event_study_omits_all_nonestimable_relative_time(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - es = results.event_study_effects + es = es_dict(results) # Relative time e=2 contains only (g=2, t=4), which is non-estimable (no # not-yet-treated controls at t=4) -> the whole bucket must be omitted. assert 2 not in es, "all-non-estimable relative time must be omitted, not a NaN row" @@ -2335,8 +2330,10 @@ def test_bootstrap_single_unit_cohort_handles_gracefully(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) + # both post-fit aggregation levels (bootstrap replay) must not crash either + results.aggregate("event_study") + results.aggregate("group") assert results is not None assert results.overall_att is not None @@ -2395,16 +2392,15 @@ def test_bootstrap_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) assert results.bootstrap_results is not None - assert results.bootstrap_results.event_study_ses is not None - assert results.bootstrap_results.event_study_cis is not None - assert results.bootstrap_results.event_study_p_values is not None + # the post-fit aggregation replays the bootstrap: percentile inference + es = es_dict(results) + assert es # Check event study effects have bootstrap SEs - for e, effect in results.event_study_effects.items(): + for e, effect in es.items(): assert effect["se"] > 0 assert effect["conf_int"][0] < effect["conf_int"][1] @@ -2420,16 +2416,14 @@ def test_bootstrap_group_aggregation(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) assert results.bootstrap_results is not None - assert results.bootstrap_results.group_effect_ses is not None - assert results.bootstrap_results.group_effect_cis is not None - assert results.bootstrap_results.group_effect_p_values is not None + grp = group_dict(results) + assert grp # Check group effects have bootstrap SEs - for g, effect in results.group_effects.items(): + for g, effect in grp.items(): assert effect["se"] > 0 assert effect["conf_int"][0] < effect["conf_int"][1] @@ -2445,12 +2439,13 @@ def test_bootstrap_all_aggregations(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) assert results.bootstrap_results is not None - assert results.bootstrap_results.event_study_ses is not None - assert results.bootstrap_results.group_effect_ses is not None + es = results.aggregate("event_study") + grp = results.aggregate("group") + assert np.all(np.isfinite(es.se)) and np.all(es.se > 0) + assert np.all(np.isfinite(grp.se)) and np.all(grp.se > 0) def test_bootstrap_reproducibility(self, ci_params): """Test that bootstrap is reproducible with same seed.""" @@ -2630,16 +2625,14 @@ def test_bootstrap_with_balanced_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - balance_e=0, # Balance at treatment time ) assert results.bootstrap_results is not None - assert results.bootstrap_results.event_study_ses is not None - assert results.event_study_effects is not None + es = es_dict(results, balance_e=0) # Balance at treatment time + assert es # Check that event study effects have valid bootstrap SEs - for e, effect in results.event_study_effects.items(): + for e, effect in es.items(): assert effect["se"] > 0 assert effect["conf_int"][0] < effect["conf_int"][1] @@ -2670,12 +2663,12 @@ def test_bootstrap_event_study_vcov_is_draw_covariance(self, base_period, antici unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - effects = results.event_study_effects - vcov = results.event_study_vcov - index = results.event_study_vcov_index + es = results.aggregate("event_study") + effects = es_dict_from_container(es) + vcov = es.vcov + index = list(es.vcov_index) assert index == [ e for e in sorted(effects) @@ -2701,21 +2694,21 @@ def test_bootstrap_event_study_vcov_needs_more_draws_than_horizons(self): def fit(n_bootstrap): with warnings.catch_warnings(): warnings.simplefilter("ignore") - return CallawaySantAnna(n_bootstrap=n_bootstrap, seed=7).fit( + res = CallawaySantAnna(n_bootstrap=n_bootstrap, seed=7).fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + return res.aggregate("event_study") - n_horizons = len(fit(50).event_study_vcov_index) + n_horizons = len(fit(50).vcov_index) at_limit = fit(n_horizons) - assert at_limit.event_study_vcov is None and at_limit.event_study_vcov_index is None - assert all(np.isfinite(d["se"]) for d in at_limit.event_study_effects.values()) + assert at_limit.vcov is None and at_limit.vcov_index is None + assert np.all(np.isfinite(at_limit.se)) above_limit = fit(n_horizons + 1) - assert above_limit.event_study_vcov.shape == (n_horizons, n_horizons) + assert above_limit.vcov.shape == (n_horizons, n_horizons) # ============================================================================= @@ -2827,14 +2820,13 @@ def test_single_cohort_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + es = es_dict(results) + assert len(es) > 0 # Event study should have multiple relative periods - rel_periods = sorted(results.event_study_effects.keys()) + rel_periods = sorted(es.keys()) assert len(rel_periods) >= 2, f"Expected multiple periods, got {rel_periods}" # With single cohort, all effects are for the same group @@ -2842,7 +2834,7 @@ def test_single_cohort_event_study(self): post_periods = [e for e in rel_periods if e >= 0] if post_periods: # At least some post-periods should show positive effect - post_effects = [results.event_study_effects[e]["effect"] for e in post_periods] + post_effects = [es[e]["effect"] for e in post_periods] assert any( e > 0.5 for e in post_effects ), f"Expected positive post-period effects, got {post_effects}" @@ -3097,8 +3089,8 @@ def test_event_study_analytical_se(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + es_analytical = es_dict(results_analytical) # Bootstrap cs_bootstrap = CallawaySantAnna(n_bootstrap=n_boot, seed=42) @@ -3108,20 +3100,20 @@ def test_event_study_analytical_se(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + es_bootstrap = es_dict(results_bootstrap) # Event study effects should exist - assert results_analytical.event_study_effects is not None - assert results_bootstrap.event_study_effects is not None + assert es_analytical + assert es_bootstrap # Check each event time SE is similar (wider tolerance when # min_n cap reduces bootstrap iterations in pure Python mode) threshold = 0.40 if n_boot < 100 else 0.20 - for e in results_analytical.event_study_effects: - if e in results_bootstrap.event_study_effects: - se_analytical = results_analytical.event_study_effects[e]["se"] - se_bootstrap = results_bootstrap.event_study_effects[e]["se"] + for e in es_analytical: + if e in es_bootstrap: + se_analytical = es_analytical[e]["se"] + se_bootstrap = es_bootstrap[e]["se"] if se_bootstrap > 0: rel_diff = abs(se_analytical - se_bootstrap) / se_bootstrap @@ -3239,11 +3231,9 @@ def test_non_standard_names_with_event_study(self): unit="id", time="period", first_treat="cohort", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + assert len(results.aggregate("event_study").event_time) > 0 def test_non_standard_names_with_covariates(self): """Test non-standard column names with covariate adjustment.""" @@ -3436,13 +3426,12 @@ def test_event_study_negative_periods(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None + es = results.aggregate("event_study") # Should have negative relative periods - rel_periods = list(results.event_study_effects.keys()) + rel_periods = list(es.event_time) negative_periods = [e for e in rel_periods if e < 0] assert ( len(negative_periods) > 0 @@ -3880,21 +3869,22 @@ def test_group_effects_anticipation_boundary(self): data, outcome="outcome", unit="unit", time="time", first_treat="first_treat" ) - # Check group effects exist - if results.group_effects is not None and g in results.group_effects: - # The group effect for g should aggregate periods t >= g - 2 - # Verify by checking which group-time effects exist - gt_for_group = [(gg, t) for (gg, t) in results.group_time_effects.keys() if gg == g] + # Check group effects exist (post-fit group aggregation) + grp = group_dict(results) + assert g in grp, f"expected a group effect for cohort {g}, got {sorted(grp)}" + # The group effect for g should aggregate periods t >= g - 2 + # Verify by checking which group-time effects exist + gt_for_group = [(gg, t) for (gg, t) in results.group_time_effects.keys() if gg == g] - # There should be effects at t = g - anticipation = g - 2 - # (if the data has that period) - # Note: the anticipation period t = g - 2 may or may not be - # present depending on base_period, so it is not asserted here; - # post-treatment periods (t >= g - anticipation) should exist. + # There should be effects at t = g - anticipation = g - 2 + # (if the data has that period) + # Note: the anticipation period t = g - 2 may or may not be + # present depending on base_period, so it is not asserted here; + # post-treatment periods (t >= g - anticipation) should exist. - # Verify post-treatment periods t >= g are included - post_treatment = [t for (gg, t) in gt_for_group if t >= g] - assert len(post_treatment) > 0, "Should have post-treatment effects" + # Verify post-treatment periods t >= g are included + post_treatment = [t for (gg, t) in gt_for_group if t >= g] + assert len(post_treatment) > 0, "Should have post-treatment effects" def test_not_yet_treated_with_anticipation_excludes_anticipation_window(self): """Not-yet-treated controls must exclude cohorts in the anticipation window. @@ -4000,25 +3990,21 @@ def test_per_effect_tstat_consistency(self, ci_params): f"expected {expected}, got {t_stat}" ) - # Check event study effects if present - if results.event_study_effects is not None: - for e, effect_data in results.event_study_effects.items(): - se = effect_data["se"] - t_stat = effect_data["t_stat"] + # Check event study effects (post-fit aggregation, bootstrap replay) + for e, effect_data in es_dict(results).items(): + se = effect_data["se"] + t_stat = effect_data["t_stat"] - if not np.isfinite(se) or se == 0: - assert np.isnan( - t_stat - ), f"event study t_stat for e={e} should be NaN when SE={se}" + if not np.isfinite(se) or se == 0: + assert np.isnan(t_stat), f"event study t_stat for e={e} should be NaN when SE={se}" - # Check group effects if present - if results.group_effects is not None: - for g, effect_data in results.group_effects.items(): - se = effect_data["se"] - t_stat = effect_data["t_stat"] + # Check group effects (post-fit aggregation, bootstrap replay) + for g, effect_data in group_dict(results).items(): + se = effect_data["se"] + t_stat = effect_data["t_stat"] - if not np.isfinite(se) or se == 0: - assert np.isnan(t_stat), f"group t_stat for g={g} should be NaN when SE={se}" + if not np.isfinite(se) or se == 0: + assert np.isnan(t_stat), f"group t_stat for g={g} should be NaN when SE={se}" def test_aggregated_tstat_nan_when_se_zero(self): """Aggregated t_stat (event-study and group) is NaN when SE is zero or non-finite. @@ -4043,7 +4029,6 @@ def test_aggregated_tstat_nan_when_se_zero(self): df = pd.DataFrame(data) - # Fit with event study aggregation to get event_study_effects cs = CallawaySantAnna(n_bootstrap=0) results = cs.fit( df, @@ -4051,13 +4036,16 @@ def test_aggregated_tstat_nan_when_se_zero(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", # Get both event study and group effects ) + # Both post-fit aggregation levels + es = es_dict(results) + grp = group_dict(results) + assert es and grp # Check that t_stat computation follows the correct pattern: # t_stat = effect / se if np.isfinite(se) and se > 0 else np.nan - if results.event_study_effects: - for e, data in results.event_study_effects.items(): + if es: + for e, data in es.items(): se = data["se"] t_stat = data["t_stat"] effect = data["effect"] @@ -4074,8 +4062,8 @@ def test_aggregated_tstat_nan_when_se_zero(self): f"got {t_stat}" ) - if results.group_effects: - for g, data in results.group_effects.items(): + if grp: + for g, data in grp.items(): se = data["se"] t_stat = data["t_stat"] effect = data["effect"] @@ -4102,17 +4090,15 @@ def test_event_study_universal_includes_reference_period(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None, "event_study_effects should not be None" + es = es_dict(results) # Reference period should be included - assert -1 in results.event_study_effects, ( - f"Reference period e=-1 should be in event_study_effects, " - f"got periods: {list(results.event_study_effects.keys())}" - ) - ref = results.event_study_effects[-1] + assert ( + -1 in es + ), f"Reference period e=-1 should be in the event study, got periods: {list(es)}" + ref = es[-1] # Effect is 0 by construction (normalization) assert ref["effect"] == 0.0, f"Reference period effect should be 0.0, got {ref['effect']}" @@ -4140,17 +4126,17 @@ def test_event_study_varying_excludes_reference_period(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None, "event_study_effects should not be None" + es = es_dict(results) + assert es # Varying mode: no single reference period, e=-1 computed normally or excluded # The key is we don't artificially add a 0-effect entry - if -1 in results.event_study_effects: + if -1 in es: # If it exists, it should be an actual computed effect, not 0.0 with n_groups=0 assert ( - results.event_study_effects[-1]["n_groups"] > 0 + es[-1]["n_groups"] > 0 ), "Varying mode should not artificially add e=-1 with n_groups=0" def test_event_study_universal_with_anticipation(self): @@ -4164,17 +4150,16 @@ def test_event_study_universal_with_anticipation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None, "event_study_effects should not be None" + es = es_dict(results) # With anticipation=1, reference is e=-2 - assert -2 in results.event_study_effects, ( - f"With anticipation=1, reference period e=-2 should be in event_study_effects, " - f"got periods: {list(results.event_study_effects.keys())}" + assert -2 in es, ( + f"With anticipation=1, reference period e=-2 should be in the event study, " + f"got periods: {list(es)}" ) - ref = results.event_study_effects[-2] + ref = es[-2] assert ref["effect"] == 0.0, f"Reference period effect should be 0.0, got {ref['effect']}" # Inference fields are NaN - normalization constraint assert np.isnan(ref["se"]), f"Reference period SE should be NaN, got {ref['se']}" @@ -4209,7 +4194,6 @@ def test_event_study_universal_no_effects_raises_error(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) @@ -5841,7 +5825,6 @@ def test_bare_cluster_honest_did_uses_df_inference(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # Sanity: the CS fit populated df_inference but not survey_metadata @@ -5854,7 +5837,7 @@ def test_bare_cluster_honest_did_uses_df_inference(self): # Run HonestDiD; assert it threads df_inference into the returned df_survey honest = HonestDiD(method="relative_magnitude", M=1.0) - honest_res = honest.fit(cs_res) + honest_res = honest.fit(cs_res.aggregate("event_study")) assert honest_res.df_survey is not None, ( "HonestDiD must preserve the cluster df from CS's df_inference. " @@ -6125,8 +6108,9 @@ def test_bare_cluster_bootstrap_propagates_nan_when_g_less_than_2(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + # post-fit event study (replays the degenerate bootstrap) + es = es_dict(res) # Overall bootstrap inference must be NaN-consistent assert not np.isfinite(res.overall_se), ( @@ -6154,17 +6138,17 @@ def test_bare_cluster_bootstrap_propagates_nan_when_g_less_than_2(self): assert np.isnan(eff["p_value"]) assert np.isnan(eff["conf_int"][0]) and np.isnan(eff["conf_int"][1]) - # Requested aggregate (event-study) must also be NaN-consistent + # The event-study aggregation must also be NaN-consistent # for any aggregated horizon whose underlying cells are NaN - if res.event_study_effects: - for h, ev in res.event_study_effects.items(): - if not np.isfinite(ev["se"]): - assert np.isnan(ev["t_stat"]) - assert np.isnan(ev["p_value"]) - assert np.isnan(ev["conf_int"][0]) and np.isnan(ev["conf_int"][1]) + assert es + for h, ev in es.items(): + if not np.isfinite(ev["se"]): + assert np.isnan(ev["t_stat"]) + assert np.isnan(ev["p_value"]) + assert np.isnan(ev["conf_int"][0]) and np.isnan(ev["conf_int"][1]) def test_grouped_aggregate_se_changes_with_cluster(self): - """The ``aggregate="group"`` aggregation path + """The ``aggregate("group")`` aggregation path (``_aggregate_by_group`` at ``staggered_aggregation.py:782-860``) has its own SE computation independent of overall + event-study. Asserts grouped SEs differ between cluster=None and cluster="state" @@ -6182,7 +6166,6 @@ def test_grouped_aggregate_se_changes_with_cluster(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) cs_cluster = CallawaySantAnna(cluster="state") @@ -6192,7 +6175,6 @@ def test_grouped_aggregate_se_changes_with_cluster(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) cs_explicit = CallawaySantAnna() @@ -6202,37 +6184,37 @@ def test_grouped_aggregate_se_changes_with_cluster(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", survey_design=SurveyDesign(psu="state"), ) - assert res_unit.group_effects is not None - assert res_cluster.group_effects is not None - assert res_explicit.group_effects is not None + grp_unit = group_dict(res_unit) + grp_cluster = group_dict(res_cluster) + grp_explicit = group_dict(res_explicit) + assert grp_unit and grp_cluster and grp_explicit # Grouped SEs must differ under cluster vs unit-level (at least # one group) - common_groups = set(res_unit.group_effects.keys()) & set(res_cluster.group_effects.keys()) + common_groups = set(grp_unit) & set(grp_cluster) assert common_groups, "expected overlapping groups" diffs = [] for g in common_groups: - se_unit = res_unit.group_effects[g]["se"] - se_cluster = res_cluster.group_effects[g]["se"] + se_unit = grp_unit[g]["se"] + se_cluster = grp_cluster[g]["se"] if np.isfinite(se_unit) and np.isfinite(se_cluster): diffs.append(abs(se_unit - se_cluster)) max_diff = max(diffs) if diffs else 0.0 assert max_diff > 1e-6, ( f"Grouped SEs did not change with cluster= (max diff: " - f"{max_diff:.6g}). aggregate='group' may not be routing " + f"{max_diff:.6g}). aggregate('group') may not be routing " "through the cluster-aware IF aggregation." ) # Bare cluster vs explicit SurveyDesign must agree on grouped surface - common = set(res_cluster.group_effects.keys()) & set(res_explicit.group_effects.keys()) + common = set(grp_cluster) & set(grp_explicit) for g in common: - se_bare = res_cluster.group_effects[g]["se"] - se_explicit = res_explicit.group_effects[g]["se"] + se_bare = grp_cluster[g]["se"] + se_explicit = grp_explicit[g]["se"] if np.isfinite(se_bare) and np.isfinite(se_explicit): assert se_bare == pytest.approx(se_explicit, rel=1e-10, abs=1e-12), ( f"Grouped SE divergence at g={g}: bare cluster=state " diff --git a/tests/test_staggered_aggregation.py b/tests/test_staggered_aggregation.py index a5c96230a..97377d366 100644 --- a/tests/test_staggered_aggregation.py +++ b/tests/test_staggered_aggregation.py @@ -308,7 +308,25 @@ def __init__(self, monkeypatch, atol=1e-9): def wrapper(mixin_self, *args, **kwargs): new_psi, new_units = orig(mixin_self, *args, **kwargs) - old_psi, _ = _frozen_combined_influence_function(*args, **kwargs) + frozen_args = list(args) + precomputed = frozen_args[7] if len(frozen_args) > 7 else kwargs.get("precomputed") + if ( + frozen_args[5] is None + and precomputed is not None + and precomputed.get("is_panel", True) + and precomputed.get("survey_weights") is None + ): + # Post-fit ``aggregate()`` relays through the retained kit with + # ``df=None`` (the kit keeps no DataFrame); the fast path reads + # the cohort sizes off ``precomputed["unit_cohorts"]``. The + # frozen dense copy counts them off a per-unit frame, so hand + # it the equivalent one (one row per kit unit, canonical ids). + cohorts = np.asarray(precomputed["unit_cohorts"]) + frozen_args[5] = pd.DataFrame( + {"unit": np.arange(len(cohorts)), "first_treat": cohorts} + ) + frozen_args[6] = "unit" + old_psi, _ = _frozen_combined_influence_function(*frozen_args, **kwargs) assert new_psi.shape == old_psi.shape np.testing.assert_allclose( new_psi, @@ -330,9 +348,10 @@ def wrapper(mixin_self, *args, **kwargs): class TestCombinedIFFrozenParity: """Old-vs-new psi_total parity across data families and aggregation - targets. aggregate='all' exercises the multi-cohort overall target, - every per-horizon keeper subset (incl. single-keeper horizons), and - single-cohort group targets in one fit.""" + targets. The plain fit exercises the multi-cohort overall target; the + post-fit ``aggregate("event_study")`` / ``aggregate("group")`` calls + exercise every per-horizon keeper subset (incl. single-keeper horizons) + and the single-cohort group targets.""" def _fit_cs(self, df, covs=None, survey=False, rcs=False, boot=0): cs = CallawaySantAnna( @@ -341,16 +360,17 @@ def _fit_cs(self, df, covs=None, survey=False, rcs=False, boot=0): panel=not rcs, seed=7, ) - cs.fit( + res = cs.fit( df, outcome="outcome", unit="unit", time="time", first_treat="first_treat", covariates=covs, - aggregate="all", survey_design=SurveyDesign(weights="pw") if survey else None, ) + res.aggregate("event_study") + res.aggregate("group") return cs def test_plain_panel(self, monkeypatch): @@ -530,14 +550,15 @@ def spy( monkeypatch.setattr(CallawaySantAnnaAggregationMixin, "_combined_if_fast", spy) cs = CallawaySantAnna(estimation_method="dr", seed=7) - cs.fit( + res = cs.fit( _make_panel(), outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) + res.aggregate("event_study") + res.aggregate("group") # every aggregation target routed through the fast path, none fell # back to the general (DataFrame-scanning) branch assert calls["resolved"] > 0 @@ -563,15 +584,16 @@ def test_rcs_fast_path_taken(self, monkeypatch): calls = self._spy(monkeypatch) cs = CallawaySantAnna(estimation_method="dr", panel=False, seed=7) with pytest.warns(UserWarning, match="stationary"): - cs.fit( + res = cs.fit( _make_rcs(), outcome="outcome", unit="unit", time="time", first_treat="first_treat", covariates=["x1", "x2"], - aggregate="all", ) + res.aggregate("event_study") + res.aggregate("group") assert calls["resolved"] > 0 assert calls["fallback"] == 0 @@ -581,16 +603,17 @@ def test_survey_fast_path_taken(self, monkeypatch): optimization for the whole survey family.""" calls = self._spy(monkeypatch) cs = CallawaySantAnna(estimation_method="dr", seed=7) - cs.fit( + res = cs.fit( _make_panel(n_cov=2, survey=True), outcome="outcome", unit="unit", time="time", first_treat="first_treat", covariates=["x1", "x2"], - aggregate="all", survey_design=SurveyDesign(weights="pw"), ) + res.aggregate("event_study") + res.aggregate("group") assert calls["resolved"] > 0 assert calls["fallback"] == 0 @@ -600,14 +623,16 @@ def test_bootstrap_prep_fast_path_taken(self, monkeypatch): calls = self._spy(monkeypatch) cs = CallawaySantAnna(estimation_method="dr", n_bootstrap=19, seed=7) with pytest.warns(UserWarning, match="n_bootstrap"): - cs.fit( + res = cs.fit( _make_panel(), outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) + # the post-fit aggregation replays the bootstrap + res.aggregate("event_study") + res.aggregate("group") assert calls["resolved"] > 0 assert calls["fallback"] == 0 diff --git a/tests/test_staggered_rc.py b/tests/test_staggered_rc.py index dd71e5fd5..cb67a474e 100644 --- a/tests/test_staggered_rc.py +++ b/tests/test_staggered_rc.py @@ -130,26 +130,27 @@ def test_positive_effect(self, rc_data): class TestAggregation: - """Aggregation types work with RCS.""" + """Post-fit aggregation levels work with RCS.""" - @pytest.mark.parametrize("agg", ["simple", "event_study", "group"]) - def test_aggregation(self, rc_data, agg): + def test_aggregation(self, rc_data): result = CallawaySantAnna(estimation_method="reg", panel=False).fit( rc_data, "outcome", "unit", "period", "first_treat", - aggregate=agg, ) assert np.isfinite(result.overall_att) - if agg == "event_study": - assert result.event_study_effects is not None - for e, info in result.event_study_effects.items(): - if info["n_groups"] > 0: - assert np.isfinite(info["effect"]) - if agg == "group": - assert result.group_effects is not None + # the fit never populates the fit-time surface; aggregation is post-fit + assert result.event_study_effects is None + assert result.group_effects is None + es = result.aggregate("event_study") + for att, n in zip(es.att, es.n): + if np.isfinite(n) and n > 0: + assert np.isfinite(att) + grp = result.aggregate("group") + assert len(grp.label) > 0 + assert np.all(np.isfinite(grp.att)) # ============================================================================= diff --git a/tests/test_sun_abraham.py b/tests/test_sun_abraham.py index c236a237b..c6fbb4470 100644 --- a/tests/test_sun_abraham.py +++ b/tests/test_sun_abraham.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict from scipy import stats from diff_diff.sun_abraham import SunAbraham, SunAbrahamResults @@ -558,8 +559,8 @@ def test_pre_period_difference_expected_between_cs_sa(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + cs_varying_es = es_dict(cs_varying_results) # Callaway-Sant'Anna with universal base (all compare to g-1) cs_universal = CallawaySantAnna(base_period="universal") @@ -569,13 +570,13 @@ def test_pre_period_difference_expected_between_cs_sa(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + cs_universal_es = es_dict(cs_universal_results) # Find common event times sa_times = set(sa_results.event_study_effects.keys()) - cs_varying_times = set(cs_varying_results.event_study_effects.keys()) - cs_universal_times = set(cs_universal_results.event_study_effects.keys()) + cs_varying_times = set(cs_varying_es.keys()) + cs_universal_times = set(cs_universal_es.keys()) common_times = sa_times & cs_varying_times & cs_universal_times # Separate pre and post periods @@ -585,14 +586,14 @@ def test_pre_period_difference_expected_between_cs_sa(self): # Post-treatment effects should match across all methods for t in post_times: sa_eff = sa_results.event_study_effects[t]["effect"] - cs_vary_eff = cs_varying_results.event_study_effects[t]["effect"] - cs_univ_eff = cs_universal_results.event_study_effects[t]["effect"] + cs_vary_eff = cs_varying_es[t]["effect"] + cs_univ_eff = cs_universal_es[t]["effect"] # All three should be similar for post-treatment max_se = max( sa_results.event_study_effects[t]["se"], - cs_varying_results.event_study_effects[t]["se"], - cs_universal_results.event_study_effects[t]["se"], + cs_varying_es[t]["se"], + cs_universal_es[t]["se"], ) assert abs(sa_eff - cs_vary_eff) < 3 * max_se, ( f"Post-period t={t}: SA and CS(varying) differ too much: " @@ -614,8 +615,8 @@ def test_pre_period_difference_expected_between_cs_sa(self): total_diff_universal = 0.0 for t in pre_times: sa_eff = sa_results.event_study_effects[t]["effect"] - cs_vary_eff = cs_varying_results.event_study_effects[t]["effect"] - cs_univ_eff = cs_universal_results.event_study_effects[t]["effect"] + cs_vary_eff = cs_varying_es[t]["effect"] + cs_univ_eff = cs_universal_es[t]["effect"] total_diff_varying += abs(sa_eff - cs_vary_eff) total_diff_universal += abs(sa_eff - cs_univ_eff) diff --git a/tests/test_survey_phase3.py b/tests/test_survey_phase3.py index 8aad88eab..8618febd7 100644 --- a/tests/test_survey_phase3.py +++ b/tests/test_survey_phase3.py @@ -1375,11 +1375,9 @@ def test_efficient_did_weights_only_se(self, staggered_survey_data): assert r.overall_se > 0 assert r.overall_se > 0.01 # Not artificially tiny - @pytest.mark.filterwarnings(r"ignore:ContinuousDiD\.fit\(aggregate=\):FutureWarning") def test_continuous_did_eventstudy_survey(self, continuous_survey_data): - """The deprecated fit-time aggregate=eventstudy still works with a - survey design (M-025 legacy routing; the post-fit successor's - survey arms are pinned in tests/test_aggregate_contract.py).""" + """The post-fit event-study aggregation works with a survey design + (the survey arms are pinned in tests/test_aggregate_contract.py).""" from diff_diff import ContinuousDiD sd = SurveyDesign(weights="weight", strata="stratum") @@ -1390,14 +1388,15 @@ def test_continuous_did_eventstudy_survey(self, continuous_survey_data): "time", "first_treat", "dose", - aggregate="eventstudy", survey_design=sd, ) - assert result.event_study_effects is not None + assert result.event_study_effects is None # analytic fit: post-fit route only assert result.survey_metadata is not None - for e, eff in result.event_study_effects.items(): - if np.isfinite(eff["effect"]): - assert np.isfinite(eff["se"]), f"Non-finite SE for e={e}" + es = result.aggregate("event_study") + assert len(es.event_time) > 0 + for e, att, se, ref in zip(es.event_time, es.att, es.se, es.is_reference): + if not ref and np.isfinite(att): + assert np.isfinite(se), f"Non-finite SE for e={e}" def test_within_unit_varying_weights_rejected(self): """Time-varying survey weights within units should be rejected.""" diff --git a/tests/test_survey_phase4.py b/tests/test_survey_phase4.py index d209e1513..58b63c5dc 100644 --- a/tests/test_survey_phase4.py +++ b/tests/test_survey_phase4.py @@ -18,21 +18,6 @@ ) from diff_diff.linalg import solve_logit -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # ============================================================================= # Shared Fixtures # ============================================================================= @@ -343,14 +328,13 @@ def test_event_study_with_survey(self, staggered_survey_data, survey_design_weig "unit", "period", "first_treat", - aggregate="event_study", survey_design=survey_design_weights_only, ) - assert result.event_study_effects is not None - assert len(result.event_study_effects) > 0 - for h, eff in result.event_study_effects.items(): - assert np.isfinite(eff["effect"]) - assert np.isfinite(eff["se"]) + assert result.event_study_effects is None # analytic fit: post-fit route only + es = result.aggregate("event_study") + assert len(es.event_time) > 0 + assert np.all(np.isfinite(es.att)) + assert np.all(np.isfinite(es.se[~es.is_reference])) def test_bootstrap_survey_supported(self, staggered_survey_data, survey_design_weights_only): """Bootstrap + survey should produce finite SE via PSU-level multiplier bootstrap.""" @@ -453,35 +437,32 @@ def test_se_scale_invariance_with_covariates(self, staggered_survey_data): ), f"SE not scale-invariant (covariates): {r1.overall_se} vs {r2.overall_se}" def test_aggregate_group_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='group' works with survey design.""" + """Post-fit group aggregation works with survey design.""" result = ImputationDiD().fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="group", survey_design=survey_design_weights_only, ) - assert result.group_effects is not None - assert len(result.group_effects) > 0 - for g, eff in result.group_effects.items(): - assert np.isfinite(eff["effect"]) + grp = result.aggregate("group") + assert len(grp.label) > 0 + assert np.all(np.isfinite(grp.att)) def test_aggregate_all_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='all' works with survey design.""" + """Post-fit event-study + group aggregation work with survey design.""" result = ImputationDiD().fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="all", survey_design=survey_design_weights_only, ) assert np.isfinite(result.overall_att) - assert result.event_study_effects is not None - assert result.group_effects is not None + assert len(result.aggregate("event_study").event_time) > 0 + assert len(result.aggregate("group").label) > 0 def test_aweight_raises(self, staggered_survey_data): """aweight survey design should raise ValueError.""" @@ -643,33 +624,31 @@ def test_summary_includes_survey(self, staggered_survey_data, survey_design_weig assert "pweight" in summary def test_aggregate_group_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='group' works with survey design.""" + """Post-fit group aggregation works with survey design.""" result = TwoStageDiD().fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="group", survey_design=survey_design_weights_only, ) - assert result.group_effects is not None - assert len(result.group_effects) > 0 + grp = result.aggregate("group") + assert len(grp.label) > 0 def test_aggregate_all_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='all' works with survey design.""" + """Post-fit event-study + group aggregation work with survey design.""" result = TwoStageDiD().fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="all", survey_design=survey_design_weights_only, ) assert np.isfinite(result.overall_att) - assert result.event_study_effects is not None - assert result.group_effects is not None + assert len(result.aggregate("event_study").event_time) > 0 + assert len(result.aggregate("group").label) > 0 def test_always_treated_with_survey(self, staggered_survey_data): """TwoStageDiD with survey + always-treated units should not crash.""" @@ -838,21 +817,20 @@ def test_strata_psu_fpc_supported(self, staggered_survey_data): assert result.survey_metadata is not None def test_aggregate_group_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='group' works with weights-only survey design.""" + """Post-fit group aggregation works with weights-only survey design.""" result = CallawaySantAnna(estimation_method="reg").fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="group", survey_design=survey_design_weights_only, ) - assert result.group_effects is not None - assert len(result.group_effects) > 0 + grp = result.aggregate("group") + assert len(grp.label) > 0 def test_aggregate_group_full_design(self, staggered_survey_data): - """aggregate='group' with full design uses design-based SEs.""" + """Post-fit group aggregation with full design uses design-based SEs.""" sd_full = SurveyDesign(weights="weight", strata="stratum", psu="psu") result = CallawaySantAnna(estimation_method="reg").fit( staggered_survey_data, @@ -860,29 +838,26 @@ def test_aggregate_group_full_design(self, staggered_survey_data): "unit", "period", "first_treat", - aggregate="group", survey_design=sd_full, ) - assert result.group_effects is not None - assert len(result.group_effects) > 0 - for g, info in result.group_effects.items(): - assert np.isfinite(info["effect"]) - assert np.isfinite(info["se"]) + grp = result.aggregate("group") + assert len(grp.label) > 0 + assert np.all(np.isfinite(grp.att)) + assert np.all(np.isfinite(grp.se)) def test_aggregate_all_with_survey(self, staggered_survey_data, survey_design_weights_only): - """aggregate='all' works with weights-only survey design.""" + """Post-fit event-study + group aggregation work with weights-only survey design.""" result = CallawaySantAnna(estimation_method="reg").fit( staggered_survey_data, "outcome", "unit", "period", "first_treat", - aggregate="all", survey_design=survey_design_weights_only, ) assert np.isfinite(result.overall_att) - assert result.event_study_effects is not None - assert result.group_effects is not None + assert len(result.aggregate("event_study").event_time) > 0 + assert len(result.aggregate("group").label) > 0 def test_bootstrap_survey_supported(self, staggered_survey_data, survey_design_weights_only): """Bootstrap + survey now works via PSU-level multiplier bootstrap.""" @@ -963,7 +938,6 @@ def test_reg_covariates_rank_deficient_survey(self, staggered_survey_data): "period", "first_treat", covariates=["x1", "x2"], - aggregate="simple", survey_design=sd, ) assert np.isfinite(result.overall_att) @@ -984,7 +958,6 @@ def test_reg_covariates_survey_se_scale_invariance(self, staggered_survey_data): "period", "first_treat", covariates=["x1"], - aggregate="simple", survey_design=sd1, ) r2 = est.fit( @@ -994,7 +967,6 @@ def test_reg_covariates_survey_se_scale_invariance(self, staggered_survey_data): "period", "first_treat", covariates=["x1"], - aggregate="simple", survey_design=sd2, ) assert np.isclose( @@ -1045,7 +1017,6 @@ def test_aggregation_with_survey(self, staggered_survey_data, survey_design_weig "unit", "period", "first_treat", - aggregate="event_study", ) r_w = CallawaySantAnna(estimation_method="reg").fit( staggered_survey_data, @@ -1053,14 +1024,13 @@ def test_aggregation_with_survey(self, staggered_survey_data, survey_design_weig "unit", "period", "first_treat", - aggregate="event_study", survey_design=survey_design_weights_only, ) # Event study ATTs should differ with non-uniform weights assert r_unw.overall_att != r_w.overall_att - # Event study effects should exist - assert r_w.event_study_effects is not None - assert len(r_w.event_study_effects) > 0 + # Event study effects should exist on the post-fit route + es_w = r_w.aggregate("event_study") + assert len(es_w.event_time) > 0 def test_summary_includes_survey(self, staggered_survey_data, survey_design_weights_only): """Summary output should include survey design section.""" @@ -1302,7 +1272,6 @@ def test_unbalanced_panel_scale_invariance(self, staggered_survey_data): "unit", "period", "first_treat", - aggregate="simple", survey_design=sd1, ) r2 = CallawaySantAnna(estimation_method="reg").fit( @@ -1311,7 +1280,6 @@ def test_unbalanced_panel_scale_invariance(self, staggered_survey_data): "unit", "period", "first_treat", - aggregate="simple", survey_design=sd2, ) assert np.isclose( @@ -1337,7 +1305,6 @@ def test_se_scale_invariance_all_methods(self, staggered_survey_data): unit="unit", time="period", first_treat="first_treat", - aggregate="simple", survey_design=sd1, ) r2 = est.fit( @@ -1346,7 +1313,6 @@ def test_se_scale_invariance_all_methods(self, staggered_survey_data): unit="unit", time="period", first_treat="first_treat", - aggregate="simple", survey_design=sd2, ) assert np.isclose( @@ -1392,7 +1358,6 @@ def test_strata_psu_fpc_works_inference(self, staggered_survey_data): "unit", "period", "first_treat", - aggregate="simple", survey_design=sd_full, ) assert np.isfinite(result.overall_att) diff --git a/tests/test_survey_phase7a.py b/tests/test_survey_phase7a.py index 3c71efc8a..2df4e6a9b 100644 --- a/tests/test_survey_phase7a.py +++ b/tests/test_survey_phase7a.py @@ -82,13 +82,16 @@ def test_event_study(self, staggered_data_with_covariates, survey_weights_only, "first_treat", covariates=["x1"], survey_design=survey_weights_only, - aggregate="event_study", ) - assert result.event_study_effects is not None - for e, info in result.event_study_effects.items(): - if info["n_groups"] > 0: - assert np.isfinite(info["effect"]) - assert np.isfinite(info["se"]) + # 4.0: the fit-time surface is gone - CS leaves the legacy field None + # on every fit and the post-fit container carries the event study. + assert result.event_study_effects is None + es = result.aggregate("event_study") + assert len(es.event_time) > 0 + for i in range(len(es.event_time)): + if es.n[i] > 0: + assert np.isfinite(es.att[i]) + assert np.isfinite(es.se[i]) @pytest.mark.parametrize("method", ["ipw", "dr"]) def test_full_design(self, staggered_data_with_covariates, survey_full_design, method): @@ -263,13 +266,14 @@ def test_aggregation(self, staggered_data_with_covariates, survey_weights_only, "first_treat", covariates=["x1"], survey_design=survey_weights_only, - aggregate=agg, ) assert np.isfinite(result.overall_att) - if agg == "event_study": - assert result.event_study_effects is not None - if agg == "group": - assert result.group_effects is not None + agg_result = result.aggregate(agg) + if agg == "simple": + assert float(agg_result.att[0]) == pytest.approx(result.overall_att) + else: + assert len(agg_result.att) > 0 + assert np.isfinite(agg_result.att).any() # ============================================================================= diff --git a/tests/test_survey_phase8.py b/tests/test_survey_phase8.py index a27b15f16..541649924 100644 --- a/tests/test_survey_phase8.py +++ b/tests/test_survey_phase8.py @@ -11,21 +11,6 @@ compute_replicate_vcov, ) -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -386,7 +371,7 @@ def test_imputation_fpc_reduces_se(self, staggered_data): assert result_fpc.overall_se <= result_no_fpc.overall_se + 1e-10 def test_imputation_fpc_event_study(self, staggered_data): - """FPC works with aggregate='event_study'.""" + """FPC works with the post-fit event-study aggregation.""" from diff_diff import ImputationDiD data = staggered_data @@ -406,14 +391,14 @@ def test_imputation_fpc_event_study(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) - assert result.event_study_effects is not None - for h, eff in result.event_study_effects.items(): - assert np.isfinite(eff["se"]), f"Non-finite SE at horizon {h}" + es = result.aggregate("event_study") + for h, se, ref in zip(es.event_time, es.se, es.is_reference): + if not ref: + assert np.isfinite(se), f"Non-finite SE at horizon {h}" def test_imputation_fpc_group(self, staggered_data): - """FPC works with aggregate='group'.""" + """FPC works with the post-fit group aggregation.""" from diff_diff import ImputationDiD data = staggered_data @@ -433,11 +418,10 @@ def test_imputation_fpc_group(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="group", ) - assert result.group_effects is not None - for g, eff in result.group_effects.items(): - assert np.isfinite(eff["se"]), f"Non-finite SE for group {g}" + grp = result.aggregate("group") + for g, se in zip(grp.label, grp.se): + assert np.isfinite(se), f"Non-finite SE for group {g}" class TestTwoStageDiDFPC: @@ -504,7 +488,7 @@ def test_two_stage_fpc_reduces_se(self, staggered_data): assert result_fpc.overall_se <= result_no_fpc.overall_se + 1e-10 def test_two_stage_fpc_event_study(self, staggered_data): - """FPC works with aggregate='event_study'.""" + """FPC works with the post-fit event-study aggregation.""" from diff_diff import TwoStageDiD data = staggered_data @@ -524,14 +508,14 @@ def test_two_stage_fpc_event_study(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) - assert result.event_study_effects is not None - for h, eff in result.event_study_effects.items(): - assert np.isfinite(eff["se"]), f"Non-finite SE at horizon {h}" + es = result.aggregate("event_study") + for h, se, ref in zip(es.event_time, es.se, es.is_reference): + if not ref: + assert np.isfinite(se), f"Non-finite SE at horizon {h}" def test_two_stage_fpc_group(self, staggered_data): - """FPC works with aggregate='group'.""" + """FPC works with the post-fit group aggregation.""" from diff_diff import TwoStageDiD data = staggered_data @@ -551,11 +535,10 @@ def test_two_stage_fpc_group(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="group", ) - assert result.group_effects is not None - for g, eff in result.group_effects.items(): - assert np.isfinite(eff["se"]), f"Non-finite SE for group {g}" + grp = result.aggregate("group") + for g, se in zip(grp.label, grp.se): + assert np.isfinite(se), f"Non-finite SE for group {g}" class TestUnstratifiedFPC: @@ -619,11 +602,11 @@ def test_two_stage_unstratified_fpc_event_study(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) - assert result.event_study_effects is not None - for h, eff in result.event_study_effects.items(): - assert np.isfinite(eff["se"]), f"Non-finite SE at horizon {h}" + es = result.aggregate("event_study") + for h, se, ref in zip(es.event_time, es.se, es.is_reference): + if not ref: + assert np.isfinite(se), f"Non-finite SE at horizon {h}" class TestSinglePSUUnstratifiedFPC: @@ -1247,11 +1230,11 @@ def test_pretrends_survey_no_raise(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) - assert result.event_study_effects is not None + assert result.event_study_effects is None # analytic fit: post-fit route only + es = result.aggregate("event_study") # Should have negative-horizon (pre-period) effects - pre_horizons = [h for h in result.event_study_effects if h < 0] + pre_horizons = [h for h in es.event_time if h < 0] assert len(pre_horizons) > 0 def test_pretrends_survey_finite_se(self, staggered_data): @@ -1268,12 +1251,12 @@ def test_pretrends_survey_finite_se(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) - for h, eff in result.event_study_effects.items(): + es = result.aggregate("event_study") + for h, se in zip(es.event_time, es.se): if h < -1: # Skip reference period (h=-1) - assert np.isfinite(eff["se"]), f"Non-finite SE at pre-horizon {h}" - assert eff["se"] > 0, f"Zero SE at pre-horizon {h}" + assert np.isfinite(se), f"Non-finite SE at pre-horizon {h}" + assert se > 0, f"Zero SE at pre-horizon {h}" def test_pretrend_test_survey_no_raise(self, staggered_data): """pretrend_test() works with survey design.""" @@ -1289,7 +1272,6 @@ def test_pretrend_test_survey_no_raise(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) pt = est._pretrend_test() assert "f_stat" in pt @@ -1314,11 +1296,11 @@ def test_pretrends_survey_with_covariates(self, staggered_data): first_treat="first_treat", covariates=["x1"], survey_design=sd, - aggregate="event_study", ) - pre = [h for h in result.event_study_effects if h < -1] - for h in pre: - assert np.isfinite(result.event_study_effects[h]["se"]) + es = result.aggregate("event_study") + pre = es.event_time < -1 + assert pre.any() + assert np.all(np.isfinite(es.se[pre])) def test_pretrends_survey_with_bootstrap(self, staggered_data): """Survey pretrends + bootstrap: bootstrap doesn't overwrite pre-period SEs.""" @@ -1334,12 +1316,19 @@ def test_pretrends_survey_with_bootstrap(self, staggered_data): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) + # Bootstrapped fits materialize the surface at fit time. + assert result.event_study_effects is not None pre = [h for h in result.event_study_effects if h < -1] + assert pre for h in pre: # Pre-period SEs come from lead regression, not bootstrap assert np.isfinite(result.event_study_effects[h]["se"]) + # The relay reproduces the stored surface. + es = result.aggregate("event_study") + for h in pre: + i = list(es.event_time).index(h) + assert es.se[i] == result.event_study_effects[h]["se"] def test_pretrends_survey_always_treated_psu(self): """Survey pretrends with a PSU/stratum that has no untreated obs.""" @@ -1385,17 +1374,17 @@ def test_pretrends_survey_always_treated_psu(self): time="time", first_treat="first_treat", survey_design=sd, - aggregate="event_study", ) # Should produce finite results even though stratum 0 has # mostly treated PSUs (sparse untreated domain) assert np.isfinite(result.overall_att) - pre = [h for h in result.event_study_effects if h < -1] - for h in pre: - assert np.isfinite(result.event_study_effects[h]["se"]) + es = result.aggregate("event_study") + pre = es.event_time < -1 + assert pre.any() + assert np.all(np.isfinite(es.se[pre])) def test_pretrends_replicate_raises(self, staggered_data): - """pretrends=True + replicate-weight survey raises NotImplementedError.""" + """pretrends=True + replicate-weight survey: aggregate('event_study') raises.""" from diff_diff import ImputationDiD data = staggered_data @@ -1419,16 +1408,19 @@ def test_pretrends_replicate_raises(self, staggered_data): replicate_method="BRR", ) est = ImputationDiD(pretrends=True, horizon_max=3) + # The fit itself succeeds; the lead regression's per-replicate refits + # are rejected post-fit by the event-study aggregation. + result = est.fit( + data, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + survey_design=sd, + ) + assert np.isfinite(result.overall_att) with pytest.raises(NotImplementedError, match="replicate"): - est.fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - survey_design=sd, - aggregate="event_study", - ) + result.aggregate("event_study") def test_pretrend_test_replicate_raises(self, staggered_data): """pretrend_test() with replicate-weight survey raises NotImplementedError.""" diff --git a/tests/test_survey_r_crossvalidation.py b/tests/test_survey_r_crossvalidation.py index e735faab0..42932192e 100644 --- a/tests/test_survey_r_crossvalidation.py +++ b/tests/test_survey_r_crossvalidation.py @@ -279,7 +279,6 @@ def _fit_scenario(self, r_results, key): "period", "first_treat", covariates=covariates, - aggregate="simple", survey_design=sd, ) return result, r @@ -362,7 +361,6 @@ def test_design_se_differs_from_naive(self, r_results, key): "period", "first_treat", covariates=covariates, - aggregate="simple", survey_design=sd, ) @@ -380,7 +378,6 @@ def test_design_se_differs_from_naive(self, r_results, key): "period", "first_treat", covariates=covariates, - aggregate="simple", ) assert result_survey.overall_se != result_naive.overall_se, ( diff --git a/tests/test_two_stage.py b/tests/test_two_stage.py index 863d971fa..e236ab96e 100644 --- a/tests/test_two_stage.py +++ b/tests/test_two_stage.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict, es_dict_from_container, group_dict from diff_diff.two_stage import ( TwoStageBootstrapResults, @@ -14,21 +15,6 @@ TwoStageDiDResults, ) -# --------------------------------------------------------------------------- -# Rows M-021/M-022 (+ M-118/M-119): ImputationDiD / TwoStageDiD -# ``fit(aggregate=, balance_e=)`` is deprecated (3.9, removed 4.0) and warns on -# ANY supplied value. The deprecated fit-time route is kept DELIBERATELY here: -# these tests pin FIT-TIME surface behaviour (bit-equality grids, bootstrap -# aggregation, R/Stata parity, replicate overrides, native effect dicts) that -# the post-fit ``results.aggregate(...)`` container route does not reproduce -# shape-for-shape. The shim warning is therefore filtered BY MESSAGE, scoped to -# these two estimators only - every other FutureWarning (including the other -# estimators' aggregate() shims) still surfaces. -# --------------------------------------------------------------------------- -pytestmark = pytest.mark.filterwarnings( - r"ignore:(ImputationDiD|TwoStageDiD)\.fit\((aggregate=|balance_e=|aggregate= / balance_e=)\):FutureWarning" -) - # ============================================================================= # Shared test data generation # ============================================================================= @@ -171,19 +157,18 @@ def test_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert results.event_study_effects is not None - assert len(results.event_study_effects) > 0 + es = es_dict(results) + assert len(es) > 0 # Check reference period is present ref_period = -1 - assert ref_period in results.event_study_effects - assert results.event_study_effects[ref_period]["effect"] == 0.0 + assert ref_period in es + assert es[ref_period]["effect"] == 0.0 # Post-treatment effects should be positive (treatment_effect=2.0) - post_effects = {h: e for h, e in results.event_study_effects.items() if h >= 0} + post_effects = {h: e for h, e in es.items() if h >= 0} assert len(post_effects) > 0 for h, eff in post_effects.items(): assert eff["effect"] > 0, f"Post-treatment effect at h={h} should be positive" @@ -200,19 +185,19 @@ def test_group_effects(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None + grp = group_dict(results) # Should have 3 groups (cohorts 3, 5, 7) - assert len(results.group_effects) == 3 - for g, eff in results.group_effects.items(): + assert len(grp) == 3 + for g, eff in grp.items(): assert eff["effect"] > 0 assert eff["se"] > 0 assert np.isfinite(eff["t_stat"]) def test_all_aggregation(self): - """Test aggregate='all' produces both event study and group effects.""" + """An analytic fit stores NO fit-time surfaces; both event-study and + group effects come from the post-fit aggregate() route.""" data = generate_test_data() results = TwoStageDiD().fit( data, @@ -220,11 +205,17 @@ def test_all_aggregation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) - assert results.event_study_effects is not None - assert results.group_effects is not None + assert results.event_study_effects is None + assert results.group_effects is None + es = results.aggregate("event_study") + grp = results.aggregate("group") + assert len(es.event_time) > 0 + assert len(grp.label) == 3 + # Post-fit aggregation does not populate the fit-time fields. + assert results.event_study_effects is None + assert results.group_effects is None def test_summary_text(self): """Test that summary produces expected header text.""" @@ -239,7 +230,8 @@ def test_summary_text(self): assert "Overall Average Treatment Effect" in text def test_to_dataframe_event_study(self): - """Test to_dataframe with event_study level.""" + """Event-study frame comes from the aggregate() container; the + results-level to_dataframe steers there on an analytic fit.""" data = generate_test_data() results = TwoStageDiD().fit( data, @@ -247,18 +239,20 @@ def test_to_dataframe_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - df = results.to_dataframe("event_study") + with pytest.raises(ValueError, match=r"aggregate\('event_study'\)"): + results.to_dataframe("event_study") + df = results.aggregate("event_study").to_dataframe() assert isinstance(df, pd.DataFrame) - assert "relative_period" in df.columns - assert "effect" in df.columns + assert "event_time" in df.columns + assert "att" in df.columns assert "se" in df.columns assert len(df) > 0 def test_to_dataframe_group(self): - """Test to_dataframe with group level.""" + """Group frame comes from the aggregate() container; the results-level + to_dataframe steers there on an analytic fit.""" data = generate_test_data() results = TwoStageDiD().fit( data, @@ -266,12 +260,13 @@ def test_to_dataframe_group(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - df = results.to_dataframe("group") + with pytest.raises(ValueError, match=r"aggregate\('group'\)"): + results.to_dataframe("group") + df = results.aggregate("group").to_dataframe() assert isinstance(df, pd.DataFrame) - assert "group" in df.columns + assert "label" in df.columns assert len(df) == 3 def test_to_dataframe_observation(self): @@ -300,13 +295,14 @@ def test_to_dataframe_invalid_level(self): results.to_dataframe("invalid") def test_to_dataframe_no_event_study(self): - """Test to_dataframe raises when event study not computed.""" + """to_dataframe raises on an analytic fit (no fit-time event-study + surface) and steers to the post-fit aggregate() route.""" data = generate_test_data() results = TwoStageDiD().fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat" ) - with pytest.raises(ValueError, match="Event study effects not computed"): + with pytest.raises(ValueError, match="not stored on this fit"): results.to_dataframe("event_study") def test_repr(self): @@ -396,7 +392,6 @@ def test_event_study_effects_match_imputation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) imp_results = ImputationDiD().fit( data, @@ -404,18 +399,19 @@ def test_event_study_effects_match_imputation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + ts_es = es_dict(ts_results) + imp_es = es_dict(imp_results) # Both should have the same horizons - ts_horizons = set(ts_results.event_study_effects.keys()) - imp_horizons = set(imp_results.event_study_effects.keys()) + ts_horizons = set(ts_es.keys()) + imp_horizons = set(imp_es.keys()) assert ts_horizons == imp_horizons # Point estimates should match for h in ts_horizons: - ts_eff = ts_results.event_study_effects[h]["effect"] - imp_eff = imp_results.event_study_effects[h]["effect"] + ts_eff = ts_es[h]["effect"] + imp_eff = imp_es[h]["effect"] if np.isfinite(ts_eff) and np.isfinite(imp_eff): assert ( abs(ts_eff - imp_eff) < 1e-8 @@ -433,7 +429,6 @@ def test_group_effects_match_imputation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) imp_results = ImputationDiD().fit( data, @@ -441,14 +436,15 @@ def test_group_effects_match_imputation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) + ts_grp = group_dict(ts_results) + imp_grp = group_dict(imp_results) - assert set(ts_results.group_effects.keys()) == set(imp_results.group_effects.keys()) + assert set(ts_grp.keys()) == set(imp_grp.keys()) - for g in ts_results.group_effects: - ts_eff = ts_results.group_effects[g]["effect"] - imp_eff = imp_results.group_effects[g]["effect"] + for g in ts_grp: + ts_eff = ts_grp[g]["effect"] + imp_eff = imp_grp[g]["effect"] assert abs(ts_eff - imp_eff) < 1e-8 def test_ses_differ_from_imputation(self): @@ -496,10 +492,9 @@ def test_event_study_se_positive(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - for h, eff in results.event_study_effects.items(): + for h, eff in es_dict(results).items(): if eff.get("n_obs", 0) > 0: assert eff["se"] > 0, f"SE at h={h} should be positive" assert np.isfinite(eff["se"]) @@ -903,13 +898,13 @@ def test_nan_propagation(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + es = es_dict(results) # Proposition 5 horizons should have NaN inference fields - assert results.event_study_effects, "Event study should be computed" + assert es, "Event study should be computed" nan_horizons_found = 0 - for h, eff in results.event_study_effects.items(): + for h, eff in es.items(): if np.isnan(eff["effect"]): nan_horizons_found += 1 assert_nan_inference( @@ -986,12 +981,12 @@ def test_horizon_max(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) # All horizons should have |h| <= 2 - for h in results.event_study_effects: - if results.event_study_effects[h].get("n_obs", 0) > 0: + es = es_dict(results) + for h in es: + if es[h].get("n_obs", 0) > 0: assert abs(h) <= 2 def test_always_treated_warning_lists_unit_ids(self): @@ -1046,24 +1041,23 @@ def test_balance_e_empty_cohorts_warns(self): """Unreasonably large balance_e should warn when no cohorts qualify.""" data = generate_test_data() + results = TwoStageDiD().fit( + data, + outcome="outcome", + unit="unit", + time="time", + first_treat="first_treat", + ) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - results = TwoStageDiD().fit( - data, - outcome="outcome", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - balance_e=100, # No cohort can satisfy this - ) + es = results.aggregate("event_study", balance_e=100) # No cohort can satisfy this balance_warns = [x for x in w if "No cohorts satisfy" in str(x.message)] assert len(balance_warns) > 0 # Event study should contain only the reference period - assert len(results.event_study_effects) == 1 - ref_key = list(results.event_study_effects.keys())[0] - assert results.event_study_effects[ref_key]["n_obs"] == 0 + assert len(es.event_time) == 1 + assert bool(es.is_reference[0]) + assert es_dict_from_container(es)[int(es.event_time[0])]["n_obs"] == 0 def test_proposition_5_nan_for_long_run_horizons(self): """Prop 5 horizons have n_obs > 0 but NaN inference (unidentified).""" @@ -1082,10 +1076,8 @@ def test_proposition_5_nan_for_long_run_horizons(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - - assert results.event_study_effects is not None + es = es_dict(results) # Check Prop 5 warning was emitted prop5_warnings = [x for x in w if "not identified without never-treated" in str(x.message)] @@ -1093,13 +1085,13 @@ def test_proposition_5_nan_for_long_run_horizons(self): # Horizons 0-3 should have observations and finite effects for h in range(0, 4): - eff = results.event_study_effects[h] + eff = es[h] assert eff["n_obs"] > 0, f"Horizon {h} should have observations" assert np.isfinite(eff["effect"]), f"Horizon {h} effect should be finite" # Horizons 4, 5, 6: Prop 5 — n_obs > 0 but NaN inference for h in [4, 5, 6]: - eff = results.event_study_effects[h] + eff = es[h] assert eff["n_obs"] > 0, f"Horizon {h} should have n_obs > 0 (Prop 5)" assert np.isnan(eff["effect"]), f"Horizon {h} effect should be NaN" assert np.isnan(eff["se"]), f"Horizon {h} SE should be NaN" @@ -1120,19 +1112,18 @@ def test_group_effects_nan_for_all_nan_cohort(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) - assert results.group_effects is not None + grp = group_dict(results) # Cohorts 3 and 5 should have valid effects for g in [3, 5]: - eff = results.group_effects[g] + eff = grp[g] assert eff["n_obs"] > 0, f"Cohort {g} should have observations" assert np.isfinite(eff["effect"]), f"Cohort {g} effect should be finite" # Cohort 7: all treated obs have NaN y_tilde -> zero obs -> NaN - eff_7 = results.group_effects[7] + eff_7 = grp[7] assert eff_7["n_obs"] == 0, "Cohort 7 should have 0 observations" assert np.isnan(eff_7["effect"]), "Cohort 7 effect should be NaN" assert np.isnan(eff_7["se"]), "Cohort 7 SE should be NaN" @@ -1225,7 +1216,6 @@ def test_horizon_max_limits_horizons(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) results_limited = TwoStageDiD(horizon_max=2).fit( data, @@ -1233,11 +1223,10 @@ def test_horizon_max_limits_horizons(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - full_horizons = set(results_full.event_study_effects.keys()) - limited_horizons = set(results_limited.event_study_effects.keys()) + full_horizons = set(results_full.aggregate("event_study").event_time.tolist()) + limited_horizons = set(results_limited.aggregate("event_study").event_time.tolist()) assert len(limited_horizons) <= len(full_horizons) @@ -1303,13 +1292,19 @@ def test_bootstrap_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) assert results.bootstrap_results is not None assert results.bootstrap_results.event_study_ses is not None for h, se in results.bootstrap_results.event_study_ses.items(): assert se > 0 + # Bootstrapped fits materialize the percentile surface at fit time and + # aggregate() relays it. + assert results.event_study_effects is not None + es = results.aggregate("event_study") + for h, se in zip(es.event_time, es.se): + if h in results.bootstrap_results.event_study_ses: + assert se == results.event_study_effects[h]["se"] def test_bootstrap_weights_mammen(self, ci_params): """Bootstrap with mammen weights should produce valid results.""" @@ -1349,7 +1344,6 @@ def test_bootstrap_weights_event_study(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) br = results.bootstrap_results @@ -1370,7 +1364,6 @@ def test_bootstrap_weights_group(self, ci_params): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) br = results.bootstrap_results @@ -1502,8 +1495,8 @@ def test_item3_always_treated_no_survey_note_without_weights(self): survey_notes = [x for x in w if "survey weights" in str(x.message)] assert len(survey_notes) == 0, f"Unexpected survey note: {survey_notes}" - def test_item2_nan_ytilde_event_study(self): - """Item 2: y_tilde warning fires for aggregate='event_study'.""" + def test_item2_nan_ytilde_fit(self): + """Item 2: y_tilde warning fires at fit (the overall Stage-2 regression).""" data = generate_test_data(n_units=50, n_periods=10, never_treated_frac=0.0, seed=42) ts = TwoStageDiD() with pytest.warns(UserWarning, match="non-finite imputed outcomes"): @@ -1513,22 +1506,24 @@ def test_item2_nan_ytilde_event_study(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - def test_item2_nan_ytilde_group(self): - """Item 2: y_tilde warning fires for aggregate='group'.""" + @pytest.mark.parametrize("level", ["event_study", "group"]) + def test_item2_nan_ytilde_aggregate(self, level): + """Item 2: the post-fit event-study / group aggregations re-run the + Stage-2 regression on the kit and warn about non-finite y_tilde too.""" data = generate_test_data(n_units=50, n_periods=10, never_treated_frac=0.0, seed=42) - ts = TwoStageDiD() - with pytest.warns(UserWarning, match="non-finite imputed outcomes"): - ts.fit( + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = TwoStageDiD().fit( data, outcome="outcome", unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) + with pytest.warns(UserWarning, match="non-finite imputed outcomes"): + res.aggregate(level) def test_iterative_fe_warns_on_nonconvergence(self): """Silent-failure audit axis B: _iterative_fe must warn when max_iter exhausts.""" @@ -1715,11 +1710,8 @@ def drop_last(A, **kwargs): return drop_last - @pytest.mark.parametrize( - "aggregate,attr", - [("event_study", "event_study_effects"), ("group", "group_effects")], - ) - def test_dropped_coefficient_propagates_nan_inference(self, aggregate, attr): + @pytest.mark.parametrize("aggregate", ["event_study", "group"]) + def test_dropped_coefficient_propagates_nan_inference(self, aggregate): """A dropped (unidentified) Stage-2 coefficient must report NaN se / t_stat / p_value / conf_int at the ESTIMATOR level (not the zero-filled se=0) — the per-coefficient propagation the rank-guard enables (CI codex P2 test-depth). @@ -1740,9 +1732,20 @@ def test_dropped_coefficient_propagates_nan_inference(self, aggregate, attr): unit="unit", time="time", first_treat="first_treat", - aggregate=aggregate, ) - effects = getattr(res, attr) + # The analytical Stage-2 aggregation recomputes post-fit, so the + # rank-guard patch must still be live when aggregate() runs. + if aggregate == "event_study": + es = res.aggregate("event_study") + # The reference row's inference is NaN by container + # contract - exclude it so nan_k reflects dropped coordinates. + effects = { + k: e + for (k, e), ref in zip(es_dict_from_container(es).items(), es.is_reference) + if not ref + } + else: + effects = group_dict(res) nan_k = [k for k, e in effects.items() if np.isnan(e["se"])] fin_k = [k for k, e in effects.items() if np.isfinite(e["se"]) and e["se"] > 0] assert nan_k, ( @@ -1777,9 +1780,10 @@ def test_bootstrap_dropped_coefficient_propagates_nan_inference(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) + # Bootstrapped fits materialize the percentile surface at fit time. es = res.event_study_effects + assert es is not None nan_h = [h for h, e in es.items() if np.isnan(e["se"])] fin_h = [h for h, e in es.items() if np.isfinite(e["se"]) and e["se"] > 0] assert nan_h, ( @@ -2203,23 +2207,45 @@ def _fit_ts(est, data, **kw): def _assert_results_bit_equal(r0, r1): - """Assert two TwoStageDiDResults are numerically identical (NaN-aware).""" + """Assert two TwoStageDiDResults are numerically identical (NaN-aware). + + Bootstrapped fits materialize the percentile ``event_study_effects`` / + ``group_effects`` at fit time, so those legacy fields are compared + directly; analytic fits store no fit-time surface, so the post-fit + ``aggregate()`` containers are compared instead. + """ def _eq(a, b): return a == b or (np.isnan(a) and np.isnan(b)) assert _eq(r0.overall_att, r1.overall_att) assert _eq(r0.overall_se, r1.overall_se) + assert (r0.bootstrap_results is None) == (r1.bootstrap_results is None) + if r0.bootstrap_results is not None: + for attr in ("event_study_effects", "group_effects"): + e0 = getattr(r0, attr) + e1 = getattr(r1, attr) + assert e0 is not None and e1 is not None, f"bootstrapped fit left {attr} None" + assert set(e0) == set(e1) + for k in e0: + for f in ("effect", "se"): + assert _eq(e0[k][f], e1[k][f]), f"{attr}[{k}][{f}] differs" + return for attr in ("event_study_effects", "group_effects"): - e0 = getattr(r0, attr) - e1 = getattr(r1, attr) - if e0 is None: - assert e1 is None - continue - assert set(e0) == set(e1) - for k in e0: - for f in ("effect", "se"): - assert _eq(e0[k][f], e1[k][f]), f"{attr}[{k}][{f}] differs" + assert getattr(r0, attr) is None and getattr(r1, attr) is None + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + pairs = [ + (level, r0.aggregate(level), r1.aggregate(level)) for level in ("event_study", "group") + ] + for level, c0, c1 in pairs: + labels = "event_time" if level == "event_study" else "label" + np.testing.assert_array_equal(getattr(c0, labels), getattr(c1, labels)) + for f in ("att", "se"): + # Exact (NaN-aware) equality: vcov_type="hc1" is a pure no-op. + np.testing.assert_array_equal( + getattr(c0, f), getattr(c1, f), err_msg=f"{level}.{f} differs" + ) def _assert_full_bootstrap_nan(r): @@ -2280,7 +2306,7 @@ def test_default_vcov_type(self): def test_results_carry_vcov_metadata(self): data = generate_test_data(n_units=80, seed=1) - r = _fit_ts(TwoStageDiD(), data, aggregate="all") + r = _fit_ts(TwoStageDiD(), data) assert r.vcov_type == "hc1" assert r.cluster_name == "unit" assert r.n_clusters == 80 @@ -2362,41 +2388,37 @@ def test_cluster_name_suppressed_under_replicate_survey(self): # ---- bit-equality regression guards (vcov_type='hc1' is a pure no-op) ---- - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_default_path_bit_equal(self, aggregate): + def test_default_path_bit_equal(self): data = generate_test_data(n_units=80, seed=10) - r0 = _fit_ts(TwoStageDiD(), data, aggregate=aggregate) - r1 = _fit_ts(TwoStageDiD(vcov_type="hc1"), data, aggregate=aggregate) + r0 = _fit_ts(TwoStageDiD(), data) + r1 = _fit_ts(TwoStageDiD(vcov_type="hc1"), data) _assert_results_bit_equal(r0, r1) - @pytest.mark.parametrize("aggregate", [None, "event_study", "group", "all"]) - def test_cluster_path_bit_equal(self, aggregate): + def test_cluster_path_bit_equal(self): data = generate_test_data(n_units=80, seed=11) data["st"] = data["unit"] % 7 - r0 = _fit_ts(TwoStageDiD(cluster="st"), data, aggregate=aggregate) - r1 = _fit_ts(TwoStageDiD(cluster="st", vcov_type="hc1"), data, aggregate=aggregate) + r0 = _fit_ts(TwoStageDiD(cluster="st"), data) + r1 = _fit_ts(TwoStageDiD(cluster="st", vcov_type="hc1"), data) _assert_results_bit_equal(r0, r1) def test_tsl_survey_path_bit_equal(self): data, _ = _add_survey_cols(generate_test_data(n_units=80, seed=12)) design = SurveyDesign(weights="w") - r0 = _fit_ts(TwoStageDiD(), data, survey_design=design, aggregate="all") - r1 = _fit_ts(TwoStageDiD(vcov_type="hc1"), data, survey_design=design, aggregate="all") + r0 = _fit_ts(TwoStageDiD(), data, survey_design=design) + r1 = _fit_ts(TwoStageDiD(vcov_type="hc1"), data, survey_design=design) _assert_results_bit_equal(r0, r1) def test_replicate_survey_path_bit_equal(self): data, rep_cols = _add_survey_cols(generate_test_data(n_units=80, seed=13)) design = SurveyDesign(weights="w", replicate_weights=rep_cols, replicate_method="JK1") - r0 = _fit_ts(TwoStageDiD(), data, survey_design=design, aggregate="event_study") - r1 = _fit_ts( - TwoStageDiD(vcov_type="hc1"), data, survey_design=design, aggregate="event_study" - ) + r0 = _fit_ts(TwoStageDiD(), data, survey_design=design) + r1 = _fit_ts(TwoStageDiD(vcov_type="hc1"), data, survey_design=design) _assert_results_bit_equal(r0, r1) def test_bootstrap_path_bit_equal(self): data = generate_test_data(n_units=80, seed=14) - r0 = _fit_ts(TwoStageDiD(n_bootstrap=199, seed=99), data, aggregate="all") - r1 = _fit_ts(TwoStageDiD(n_bootstrap=199, seed=99, vcov_type="hc1"), data, aggregate="all") + r0 = _fit_ts(TwoStageDiD(n_bootstrap=199, seed=99), data) + r1 = _fit_ts(TwoStageDiD(n_bootstrap=199, seed=99, vcov_type="hc1"), data) _assert_results_bit_equal(r0, r1) b0, b1 = r0.bootstrap_results, r1.bootstrap_results assert b0.overall_att_se == b1.overall_att_se @@ -2434,7 +2456,6 @@ def test_bootstrap_single_cluster_returns_nan(self): unit="unit", time="time", first_treat="first_treat", - aggregate="all", ) _assert_full_bootstrap_nan(r) @@ -2451,7 +2472,6 @@ def test_bootstrap_single_psu_survey_returns_nan(self): time="time", first_treat="first_treat", survey_design=design, - aggregate="event_study", ) _assert_full_bootstrap_nan(r) @@ -2621,7 +2641,7 @@ def _panel(seed=7): ) return pd.DataFrame(rows) - def test_analytical_fit_persists_vcov_diag_matches_ses(self): + def test_analytical_aggregate_vcov_diag_matches_ses(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = TwoStageDiD().fit( @@ -2630,16 +2650,22 @@ def test_analytical_fit_persists_vcov_diag_matches_ses(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - assert res.event_study_vcov is not None - assert res.event_study_vcov_index is not None - ses = {h: d["se"] for h, d in res.event_study_effects.items()} - diag = np.sqrt(np.maximum(np.diag(res.event_study_vcov), 0.0)) - for i, h in enumerate(res.event_study_vcov_index): + es = res.aggregate("event_study") + # Analytic fits store no fit-time surface; the GMM matrix travels on + # the container. + assert res.event_study_vcov is None + assert res.event_study_vcov_index is None + assert es.vcov is not None + assert es.vcov_index is not None + ses = dict(zip(es.event_time.tolist(), es.se.tolist())) + diag = np.sqrt(np.maximum(np.diag(es.vcov), 0.0)) + for i, h in enumerate(es.vcov_index.tolist()): np.testing.assert_allclose(diag[i], ses[h], rtol=1e-14) # Non-survey GMM inference is normal-theory: no df provenance. assert res.event_study_df is None + assert es.df_survey is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) def test_bootstrap_clears_vcov_and_df(self): # Bootstrap replaces the stored ES se/p/CI with percentile values: @@ -2653,12 +2679,18 @@ def test_bootstrap_clears_vcov_and_df(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + es = res.aggregate("event_study") assert res.bootstrap_results is not None + assert res.event_study_effects is not None assert res.event_study_vcov is None assert res.event_study_vcov_index is None assert res.event_study_df is None + # The relay carries neither matrix nor df. + assert es.vcov is None + assert es.vcov_index is None + assert es.df_survey is None + assert np.all(np.isnan(np.asarray(es.df, dtype=float))) def test_replicate_weight_survey_clears_vcov_and_threads_final_df(self): # Replicate-weight designs: reported ES SEs come from the replicate @@ -2676,21 +2708,18 @@ def test_replicate_weight_survey_clears_vcov_and_threads_final_df(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", survey_design=design, ) - assert res.event_study_vcov is None - assert res.event_study_vcov_index is None - assert res.event_study_df is not None and res.event_study_df > 0 - assert res.event_study_df == float(res.survey_metadata.df_survey) - from diff_diff.results_base import build_event_study_surface - - surface = build_event_study_surface(res) + surface = res.aggregate("event_study") + assert surface.vcov is None + assert surface.vcov_index is None + assert surface.df_survey is not None and surface.df_survey > 0 + assert surface.df_survey == float(res.survey_metadata.df_survey) finite_p = np.isfinite(surface.p_value) assert finite_p.any() - assert set(surface.df[finite_p].tolist()) == {float(res.event_study_df)} + assert set(np.asarray(surface.df)[finite_p].tolist()) == {float(surface.df_survey)} - def test_group_only_aggregate_has_no_es_vcov(self): + def test_analytic_fit_leaves_fit_time_surfaces_none(self): with warnings.catch_warnings(): warnings.simplefilter("ignore") res = TwoStageDiD().fit( @@ -2699,9 +2728,12 @@ def test_group_only_aggregate_has_no_es_vcov(self): unit="unit", time="time", first_treat="first_treat", - aggregate="group", ) + res.aggregate("group") + res.aggregate("event_study") + # Post-fit aggregation is a pure read: nothing is written back. assert res.event_study_effects is None + assert res.group_effects is None assert res.event_study_vcov is None assert res.event_study_vcov_index is None assert res.event_study_df is None diff --git a/tests/test_v4_rename_mechanical.py b/tests/test_v4_rename_mechanical.py index f939c3fe6..9bc82f5e9 100644 --- a/tests/test_v4_rename_mechanical.py +++ b/tests/test_v4_rename_mechanical.py @@ -35,6 +35,7 @@ deprecated_kwarg_message, require_arg, resolve_renamed_kwarg, + warn_deprecated_kwarg, ) @@ -76,6 +77,14 @@ def test_message_template(self): "Cls.fit(old=) is deprecated and will be removed in 4.0; " "use new= instead." ) + def test_warn_deprecated_kwarg_emits_future_warning(self): + # Retained 5.0-window helper (spec section 8 rule 11): its last library + # caller (dCDH fit(aggregate=), M-026) was removed at 4.0, so this pin + # keeps the helper itself under test. + with pytest.warns(FutureWarning, match=r"Cls\.fit\(old=\) is deprecated") as rec: + warn_deprecated_kwarg("Cls.fit", "old", "use new= instead", stacklevel=2) + assert str(rec[0].message) == deprecated_kwarg_message("Cls.fit", "old", "use new= instead") + def test_resolve_neither_returns_default(self): with warnings.catch_warnings(): warnings.simplefilter("error") diff --git a/tests/test_variance_conventions.py b/tests/test_variance_conventions.py index d4fdd79ed..cdc690470 100644 --- a/tests/test_variance_conventions.py +++ b/tests/test_variance_conventions.py @@ -385,14 +385,11 @@ def snapshot(self): ), dict( key="imputation_pretrends_event_study", - fit=lambda df: diff_diff.ImputationDiD(pretrends=True).fit( - df, - outcome="y", - unit="unit", - time="time", - first_treat="first_treat", - aggregate="event_study", - ), + # The pretrends lead regression runs on the post-fit event-study + # route (4.0: no fit-time aggregate=), so the row spies fit + aggregate. + fit=lambda df: diff_diff.ImputationDiD(pretrends=True) + .fit(df, outcome="y", unit="unit", time="time", first_treat="first_treat") + .aggregate("event_study"), cr1_k=None, # contract row: shared CR1 REACHED; exact k is config-detail # Pinned since 3.9 (M-127): the three lead calls carry the knob- # resolved residual df (157 = n_untreated - k_kept - absorbed rank diff --git a/tests/test_visualization.py b/tests/test_visualization.py index dc23fd697..7d0c97de3 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd import pytest +from legacy_surface import es_dict_from_container, legacy_standin from diff_diff import ( CallawaySantAnna, @@ -97,8 +98,8 @@ def multi_period_results(self): ) @pytest.fixture - def cs_results(self): - """Fixture for CallawaySantAnna results with event study.""" + def cs_fit(self): + """A plain CallawaySantAnna fit (4.0: no fit-time event-study surface).""" data = generate_staggered_data() cs = CallawaySantAnna() return cs.fit( @@ -107,9 +108,13 @@ def cs_results(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) + @pytest.fixture + def cs_results(self, cs_fit): + """The post-fit event-study container - what plot_event_study consumes.""" + return cs_fit.aggregate("event_study") + def test_plot_from_multi_period_results(self, multi_period_results): """Test plotting from MultiPeriodDiD results.""" pytest.importorskip("matplotlib") @@ -118,12 +123,52 @@ def test_plot_from_multi_period_results(self, multi_period_results): assert ax is not None def test_plot_from_cs_results(self, cs_results): - """Test plotting from CallawaySantAnna results.""" + """Test plotting from the CallawaySantAnna event-study container.""" pytest.importorskip("matplotlib") ax = plot_event_study(cs_results, show=False) assert ax is not None + def test_cs_results_object_has_no_fit_time_surface(self, cs_fit): + """4.0 invariant: a CS results object carries no event-study surface. + + Every CallawaySantAnna fit leaves the legacy field None; plotting the + results object itself fails closed and points at the container. + """ + assert cs_fit.event_study_effects is None + with pytest.raises(TypeError, match=r"aggregate\(\s*'event_study'\)"): + plot_event_study(cs_fit, show=False) + + def test_plot_from_legacy_raw_field_results(self, cs_fit): + """Raw-field branch pin: a results object whose ``event_study_effects`` + dict is populated by hand (the legacy-pickle shape) still plots, and + the plotted effects equal the container's. + """ + pytest.importorskip("matplotlib") + import matplotlib.pyplot as plt + + from diff_diff.visualization import _extract_plot_data + + standin = legacy_standin(cs_fit) + assert standin.event_study_effects is not None + es = cs_fit.aggregate("event_study") + + effects, se, periods, _, _, ref, inferred, *_ = _extract_plot_data( + standin, None, None, None, None + ) + assert sorted(periods) == sorted(es.event_time.tolist()) + for i, e in enumerate(es.event_time.tolist()): + assert effects[e] == pytest.approx(float(es.att[i]), nan_ok=True) + assert se[e] == pytest.approx(float(es.se[i]), nan_ok=True) + # Varying-base CS marks no reference row on the container; the raw-field + # branch keeps its legacy ``-1`` fallback for that case. + assert ref == (es.reference_period if es.reference_period is not None else -1) + assert inferred is True + + ax = plot_event_study(standin, show=False) + assert ax is not None + plt.close() + def test_plot_from_dataframe(self): """Test plotting from DataFrame.""" pytest.importorskip("matplotlib") @@ -331,8 +376,7 @@ def test_plot_cs_universal_base_period(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", - ) + ).aggregate("event_study") # Should not raise even with NaN SE in reference period ax = plot_event_study(results, show=False) @@ -362,12 +406,13 @@ def test_plot_cs_with_anticipation(self): unit="unit", time="period", first_treat="first_treat", - aggregate="event_study", - ) + ).aggregate("event_study") # Reference period should be at e=-2 (not e=-1) with anticipation=1 - assert -2 in results.event_study_effects - assert results.event_study_effects[-2]["n_groups"] == 0 + times = results.event_time.tolist() + assert -2 in times + assert bool(results.is_reference[times.index(-2)]) + assert results.reference_period == -2 ax = plot_event_study(results, show=False) assert ax is not None @@ -538,7 +583,7 @@ def test_plot_cs_results_no_auto_normalization(self, cs_results): # Get original effects from results (before any normalization) original_effects = { period: effect_data["effect"] - for period, effect_data in results.event_study_effects.items() + for period, effect_data in es_dict_from_container(results).items() } # Plot WITHOUT explicitly specifying reference_period @@ -586,7 +631,7 @@ def test_plot_cs_results_explicit_reference_normalizes(self, cs_results): # Get original effects from results original_effects = { period: effect_data["effect"] - for period, effect_data in results.event_study_effects.items() + for period, effect_data in es_dict_from_container(results).items() } # Choose reference period (typically -1) @@ -658,8 +703,7 @@ def cs_cband_results(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", - ) + ).aggregate("event_study") def test_plot_uses_cband_cis_by_default(self, cs_cband_results): """Test that cband CIs are used by default when available.""" @@ -668,16 +712,36 @@ def test_plot_uses_cband_cis_by_default(self, cs_cband_results): from diff_diff.visualization import _extract_plot_data + # The container carries the simultaneous band. + assert cs_cband_results.cband_lower is not None + assert cs_cband_results.cband_upper is not None + assert np.isfinite(cs_cband_results.cband_crit_value) + finite = np.isfinite(cs_cband_results.cband_lower) & np.isfinite( + cs_cband_results.cband_upper + ) + assert finite.any() + # The band is at least as wide as the pointwise interval on every finite row. + assert np.all( + cs_cband_results.cband_lower[finite] <= cs_cband_results.conf_int_lower[finite] + 1e-12 + ) + assert np.all( + cs_cband_results.cband_upper[finite] >= cs_cband_results.conf_int_upper[finite] - 1e-12 + ) + # Verify plot succeeds ax = plot_event_study(cs_cband_results, show=False) assert ax is not None - # Verify cband CIs are extracted + # Verify cband CIs are extracted and equal the container's band _, _, _, _, _, _, _, ci_lower_override, ci_upper_override, _, _, _ = _extract_plot_data( cs_cband_results, None, None, None, None ) assert ci_lower_override is not None assert ci_upper_override is not None + for i, e in enumerate(cs_cband_results.event_time.tolist()): + if finite[i]: + assert ci_lower_override[e] == float(cs_cband_results.cband_lower[i]) + assert ci_upper_override[e] == float(cs_cband_results.cband_upper[i]) plt.close() @@ -737,11 +801,12 @@ def test_full_workflow_callaway_santanna(self): unit="unit", time="time", first_treat="first_treat", - aggregate="event_study", ) - # Plot - ax = plot_event_study(results, title="Staggered DiD Event Study", show=False) + # Plot the post-fit container + ax = plot_event_study( + results.aggregate("event_study"), title="Staggered DiD Event Study", show=False + ) assert ax is not None plt.close() @@ -760,24 +825,28 @@ class TestEfficientDiDPlotReference: def test_pt_post_anticipation_reference_shifts(self): pytest.importorskip("matplotlib") - import warnings from diff_diff import EfficientDiD from diff_diff.prep_dgp import generate_staggered_data from diff_diff.visualization._event_study import _extract_plot_data d = generate_staggered_data(n_units=80, n_periods=8, cohort_periods=[4, 6], seed=9) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - res = EfficientDiD(pt_assumption="post", anticipation=1).fit( - d, - outcome="outcome", - unit="unit", - time="period", - first_treat="first_treat", - aggregate="event_study", - ) - assert res.reference_period == -2 - extracted = _extract_plot_data(res, None, None, None, None) + res = EfficientDiD(pt_assumption="post", anticipation=1).fit( + d, + outcome="outcome", + unit="unit", + time="period", + first_treat="first_treat", + ) + # 4.0: EfficientDiD stores no fit-time surface, so the MEMBERSHIP-GATED + # property is None on the results object itself ... + assert res.event_study_effects is None + assert res.reference_period is None + # ... and reads -2 on a surface-bearing stand-in (the legacy-pickle shape), + # while the container carries the anchor as its single is_reference row. + assert legacy_standin(res).reference_period == -2 + es = res.aggregate("event_study") + assert es.reference_period == -2 + extracted = _extract_plot_data(es, None, None, None, None) assert extracted[5] == -2 # reference_period slot assert extracted[6] is True # inferred, not caller-supplied