From a464ad88428607d24c3b31d542e154afdb580974 Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 26 Oct 2026 12:47:54 -0400 Subject: [PATCH] docs(v4): PR 5a-ii of the 4.0 cut - migrate the documented readers of the overall_* field names to the canonical quintet Documentation half of cut-sequence item 5's reader migration (the M-050..M-058 storage flip), split from PR 5a because the combined compiled review prompt exceeded the AI review input cap. Every documented read of overall_att / overall_se / overall_t_stat / overall_p_value / overall_conf_int (and ContinuousDiD's overall_att_* spellings) on the nine flipping containers now reads att / se / t_stat / p_value / conf_int: - RTD pages (docs/*.rst, docs/api/*.rst) and the REGISTRY prose that describes a read; - the bundled llms-full / llms-practitioner / llms-autonomous guides (code examples and read-describing prose only - the field TABLES describe the stored surface and flip with the storage in PR 5b); - tutorial code cells in 02, 09, 11-17, 24, 26, 29, 30, 32 (markdown in 14/19/24/32); committed outputs are byte-identical except tutorial 18's echoed practitioner guidance string, which mirrors the PR 5a source change; - the tutorial drift tests that mirror those notebooks (t19, t24, t26, t29, t32), with tutorial 32's ALL_CODE_CELL_HASHES re-pinned to the edited cells; - benchmark scripts (attribute reads only - the R-parity output-dict keys stay) and the carousel generator. No behaviour change: the canonical names already resolve as properties. Bootstrap draw containers (bootstrap_results.overall_att_se) and StaggeredTripleDiffResults readers are untouched by design. Independent of PR 5a (different files, both based on main). --- benchmarks/python/benchmark_callaway.py | 45 +++--- benchmarks/python/benchmark_imputation.py | 30 ++-- benchmarks/python/benchmark_stacked_did.py | 6 +- benchmarks/python/benchmark_sun_abraham.py | 26 +-- benchmarks/python/benchmark_wooldridge.py | 44 ++--- .../speed_review/bench_solve_ols_fastpath.py | 2 +- benchmarks/speed_review/validate_results.py | 73 ++++++--- carousel/generate_v26_pdf.py | 152 +++++++++++------- .../20261026-v4-pr5a-ii-docs-readers.md | 13 ++ diff_diff/guides/llms-autonomous.txt | 6 +- diff_diff/guides/llms-full.txt | 27 ++-- diff_diff/guides/llms-practitioner.txt | 50 +++--- docs/api/chaisemartin_dhaultfoeuille.rst | 6 +- docs/api/continuous_did.rst | 2 +- docs/api/efficient_did.rst | 10 +- docs/api/imputation.rst | 6 +- docs/api/two_stage.rst | 6 +- docs/choosing_estimator.rst | 6 +- docs/methodology/REGISTRY.md | 4 +- docs/practitioner_decision_tree.rst | 6 +- docs/practitioner_getting_started.rst | 2 +- docs/quickstart.rst | 2 +- docs/troubleshooting.rst | 2 +- docs/tutorials/02_staggered_did.ipynb | 20 +-- docs/tutorials/09_real_world_examples.ipynb | 8 +- docs/tutorials/11_imputation_did.ipynb | 10 +- docs/tutorials/12_two_stage_did.ipynb | 8 +- docs/tutorials/13_stacked_did.ipynb | 22 +-- docs/tutorials/14_continuous_did.ipynb | 20 +-- docs/tutorials/15_efficient_did.ipynb | 30 ++-- docs/tutorials/16_survey_did.ipynb | 28 ++-- docs/tutorials/16_wooldridge_etwfe.ipynb | 6 +- .../tutorials/17_brand_awareness_survey.ipynb | 2 +- docs/tutorials/18_geo_experiments.ipynb | 2 +- docs/tutorials/19_dcdh_marketing_pulse.ipynb | 2 +- .../24_staggered_vs_collapsed_power.ipynb | 2 +- .../26_composition_drift_calibration.ipynb | 16 +- docs/tutorials/29_mmm_calibration_pymc.ipynb | 2 +- .../30_mmm_calibration_meridian.ipynb | 2 +- docs/tutorials/32_dml_did.ipynb | 18 +-- tests/test_t19_marketing_pulse_drift.py | 6 +- ..._t24_staggered_vs_collapsed_power_drift.py | 2 +- ...t26_composition_drift_calibration_drift.py | 16 +- tests/test_t29_mmm_calibration_pymc_drift.py | 6 +- tests/test_t32_dml_did_drift.py | 30 ++-- 45 files changed, 449 insertions(+), 335 deletions(-) create mode 100644 changelog.d/20261026-v4-pr5a-ii-docs-readers.md diff --git a/benchmarks/python/benchmark_callaway.py b/benchmarks/python/benchmark_callaway.py index 38ef8536b..16c00d536 100644 --- a/benchmarks/python/benchmark_callaway.py +++ b/benchmarks/python/benchmark_callaway.py @@ -12,6 +12,7 @@ import sys from pathlib import Path + # IMPORTANT: Parse --backend and set environment variable BEFORE importing diff_diff # This ensures the backend configuration is respected by all modules def _get_backend_from_args(): @@ -21,6 +22,7 @@ def _get_backend_from_args(): args, _ = parser.parse_known_args() return args.backend + _requested_backend = _get_backend_from_args() if _requested_backend in ("python", "rust"): os.environ["DIFF_DIFF_BACKEND"] = _requested_backend @@ -44,9 +46,7 @@ def _get_backend_from_args(): def parse_args(): - parser = argparse.ArgumentParser( - description="Benchmark Callaway-Sant'Anna estimator" - ) + parser = argparse.ArgumentParser(description="Benchmark Callaway-Sant'Anna estimator") parser.add_argument("--data", required=True, help="Path to input CSV data") parser.add_argument("--output", required=True, help="Path to output JSON results") parser.add_argument( @@ -62,12 +62,15 @@ def parse_args(): help="Control group definition", ) parser.add_argument( - "--backend", default="auto", choices=["auto", "python", "rust"], - help="Backend to use: auto (default), python (pure Python), rust (Rust backend)" + "--backend", + default="auto", + choices=["auto", "python", "rust"], + help="Backend to use: auto (default), python (pure Python), rust (Rust backend)", ) parser.add_argument( - "--warmup", action="store_true", - help="Run one untimed fit before the timed fit (JIT/cache warm-up)" + "--warmup", + action="store_true", + help="Run one untimed fit before the timed fit (JIT/cache warm-up)", ) return parser.parse_args() @@ -153,20 +156,24 @@ def main(): # run_refresh.py joins these records against R on `event_time`. es_effects = [] 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), - }) + 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 = [] 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), - }) + grp_effects.append( + { + "group": int(row.label), + "att": float(row.att), + "se": float(row.se), + } + ) # Build output output = { @@ -175,8 +182,8 @@ def main(): "method": args.method, "control_group": args.control_group, # Overall ATT - "overall_att": float(results.overall_att), - "overall_se": float(results.overall_se), + "overall_att": float(results.att), + "overall_se": float(results.se), # Group-time effects "group_time_effects": gt_effects, # Event study diff --git a/benchmarks/python/benchmark_imputation.py b/benchmarks/python/benchmark_imputation.py index b71540b4f..9f0a9ad12 100644 --- a/benchmarks/python/benchmark_imputation.py +++ b/benchmarks/python/benchmark_imputation.py @@ -14,6 +14,7 @@ import sys from pathlib import Path + # IMPORTANT: Parse --backend and set environment variable BEFORE importing diff_diff # This ensures the backend configuration is respected by all modules def _get_backend_from_args(): @@ -23,6 +24,7 @@ def _get_backend_from_args(): args, _ = parser.parse_known_args() return args.backend + _requested_backend = _get_backend_from_args() if _requested_backend in ("python", "rust"): os.environ["DIFF_DIFF_BACKEND"] = _requested_backend @@ -38,14 +40,14 @@ def _get_backend_from_args(): def parse_args(): - parser = argparse.ArgumentParser( - description="Benchmark Imputation DiD estimator" - ) + parser = argparse.ArgumentParser(description="Benchmark Imputation DiD estimator") parser.add_argument("--data", required=True, help="Path to input CSV data") parser.add_argument("--output", required=True, help="Path to output JSON results") parser.add_argument( - "--backend", default="auto", choices=["auto", "python", "rust"], - help="Backend to use: auto (default), python (pure Python), rust (Rust backend)" + "--backend", + default="auto", + choices=["auto", "python", "rust"], + help="Backend to use: auto (default), python (pure Python), rust (Rust backend)", ) return parser.parse_args() @@ -98,19 +100,21 @@ def main(): 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), - }) + es_effects.append( + { + "event_time": int(row.event_time), + "att": float(row.att), + "se": float(row.se), + } + ) # Build output output = { "estimator": "diff_diff.ImputationDiD", "backend": actual_backend, # Overall ATT - "overall_att": float(results.overall_att), - "overall_se": float(results.overall_se), + "overall_att": float(results.att), + "overall_se": float(results.se), # Event study "event_study": es_effects, # Timing @@ -135,7 +139,7 @@ def main(): with open(output_path, "w") as f: json.dump(output, f, indent=2) - print(f"Overall ATT: {results.overall_att:.6f} (SE: {results.overall_se:.6f})") + print(f"Overall ATT: {results.att:.6f} (SE: {results.se:.6f})") print(f"Completed in {total_time:.3f} seconds") return output diff --git a/benchmarks/python/benchmark_stacked_did.py b/benchmarks/python/benchmark_stacked_did.py index cd9cd16a7..7baaec413 100644 --- a/benchmarks/python/benchmark_stacked_did.py +++ b/benchmarks/python/benchmark_stacked_did.py @@ -134,8 +134,8 @@ def main(): "estimator": "diff_diff.StackedDiD", "backend": actual_backend, # Overall ATT - "overall_att": float(results.overall_att), - "overall_se": float(results.overall_se), + "overall_att": float(results.att), + "overall_se": float(results.se), # Event study "event_study": es_effects, # Timing @@ -164,7 +164,7 @@ def main(): with open(output_path, "w") as f: json.dump(output, f, indent=2) - print(f"Overall ATT: {results.overall_att:.6f} (SE: {results.overall_se:.6f})") + print(f"Overall ATT: {results.att:.6f} (SE: {results.se:.6f})") if results.event_study_effects: for h, eff in sorted(results.event_study_effects.items()): if eff.get("n_obs", 1) > 0: diff --git a/benchmarks/python/benchmark_sun_abraham.py b/benchmarks/python/benchmark_sun_abraham.py index 87cb45742..5f86a7208 100644 --- a/benchmarks/python/benchmark_sun_abraham.py +++ b/benchmarks/python/benchmark_sun_abraham.py @@ -15,6 +15,7 @@ import sys from pathlib import Path + # IMPORTANT: Parse --backend and set environment variable BEFORE importing diff_diff def _get_backend_from_args(): """Parse --backend argument without importing diff_diff.""" @@ -23,6 +24,7 @@ def _get_backend_from_args(): args, _ = parser.parse_known_args() return args.backend + _requested_backend = _get_backend_from_args() if _requested_backend in ("python", "rust"): os.environ["DIFF_DIFF_BACKEND"] = _requested_backend @@ -43,8 +45,10 @@ def parse_args(): parser.add_argument("--data", required=True, help="Path to input CSV data") parser.add_argument("--output", required=True, help="Path to output JSON results") parser.add_argument( - "--backend", default="auto", choices=["auto", "python", "rust"], - help="Backend to use: auto (default), python (pure Python), rust (Rust backend)" + "--backend", + default="auto", + choices=["auto", "python", "rust"], + help="Backend to use: auto (default), python (pure Python), rust (Rust backend)", ) return parser.parse_args() @@ -78,19 +82,21 @@ def main(): first_treat="first_treat", ) - overall_att = results.overall_att - overall_se = results.overall_se - overall_pvalue = results.overall_p_value + overall_att = results.att + overall_se = results.se + overall_pvalue = results.p_value # Extract event study effects event_study = [] for e in sorted(results.event_study_effects.keys()): eff = results.event_study_effects[e] - event_study.append({ - "event_time": int(e), - "att": float(eff["effect"]), - "se": float(eff["se"]), - }) + event_study.append( + { + "event_time": int(e), + "att": float(eff["effect"]), + "se": float(eff["se"]), + } + ) total_time = timer.elapsed diff --git a/benchmarks/python/benchmark_wooldridge.py b/benchmarks/python/benchmark_wooldridge.py index e38963d47..d1b01425a 100644 --- a/benchmarks/python/benchmark_wooldridge.py +++ b/benchmarks/python/benchmark_wooldridge.py @@ -15,6 +15,7 @@ import sys from pathlib import Path + # IMPORTANT: Parse --backend and set environment variable BEFORE importing diff_diff def _get_backend_from_args(): """Parse --backend argument without importing diff_diff.""" @@ -23,6 +24,7 @@ def _get_backend_from_args(): args, _ = parser.parse_known_args() return args.backend + _requested_backend = _get_backend_from_args() if _requested_backend in ("python", "rust"): os.environ["DIFF_DIFF_BACKEND"] = _requested_backend @@ -38,14 +40,14 @@ def _get_backend_from_args(): def parse_args(): - parser = argparse.ArgumentParser( - description="Benchmark WooldridgeDiD (ETWFE) estimator" - ) + parser = argparse.ArgumentParser(description="Benchmark WooldridgeDiD (ETWFE) estimator") parser.add_argument("--data", required=True, help="Path to input CSV data (mpdta format)") parser.add_argument("--output", required=True, help="Path to output JSON results") parser.add_argument( - "--backend", default="auto", choices=["auto", "python", "rust"], - help="Backend to use: auto (default), python (pure Python), rust (Rust backend)" + "--backend", + default="auto", + choices=["auto", "python", "rust"], + help="Backend to use: auto (default), python (pure Python), rust (Rust backend)", ) return parser.parse_args() @@ -91,22 +93,26 @@ def main(): # Format ATT(g,t) effects gt_effects_out = [] for (g, t), cell in sorted(results.group_time_effects.items()): - gt_effects_out.append({ - "cohort": int(g), - "time": int(t), - "att": float(cell["att"]), - "se": float(cell["se"]), - }) + gt_effects_out.append( + { + "cohort": int(g), + "time": int(t), + "att": float(cell["att"]), + "se": float(cell["se"]), + } + ) # Format event study effects 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["att"]), - "se": float(effect_data["se"]), - }) + es_effects.append( + { + "event_time": int(rel_t), + "att": float(effect_data["att"]), + "se": float(effect_data["se"]), + } + ) output = { "estimator": "diff_diff.WooldridgeDiD", @@ -114,8 +120,8 @@ def main(): "control_group": "not_yet_treated", "backend": actual_backend, # Overall ATT - "overall_att": float(results.overall_att), - "overall_se": float(results.overall_se), + "overall_att": float(results.att), + "overall_se": float(results.se), # Group-time ATT(g,t) "group_time_effects": gt_effects_out, # Event study @@ -140,7 +146,7 @@ def main(): with open(output_path, "w") as f: json.dump(output, f, indent=2) - print(f"Overall ATT: {results.overall_att:.6f} (SE: {results.overall_se:.6f})") + print(f"Overall ATT: {results.att:.6f} (SE: {results.se:.6f})") print(f"Completed in {total_time:.3f} seconds") return output diff --git a/benchmarks/speed_review/bench_solve_ols_fastpath.py b/benchmarks/speed_review/bench_solve_ols_fastpath.py index 1376daca9..c71399492 100644 --- a/benchmarks/speed_review/bench_solve_ols_fastpath.py +++ b/benchmarks/speed_review/bench_solve_ols_fastpath.py @@ -129,7 +129,7 @@ def fit(d): # 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 float(res.att), float(res.se) return df, fit if scenario == "survey_absorb": diff --git a/benchmarks/speed_review/validate_results.py b/benchmarks/speed_review/validate_results.py index 7965efc6e..57b370469 100644 --- a/benchmarks/speed_review/validate_results.py +++ b/benchmarks/speed_review/validate_results.py @@ -71,10 +71,10 @@ def run_estimator(df, estimation_method="reg", covariates=None, control_group="n grp_container = results.aggregate("group") out = { - "overall_att": float(results.overall_att), - "overall_se": float(results.overall_se), - "overall_p_value": float(results.overall_p_value), - "overall_ci": [float(results.overall_conf_int[0]), float(results.overall_conf_int[1])], + "overall_att": float(results.att), + "overall_se": float(results.se), + "overall_p_value": float(results.p_value), + "overall_ci": [float(results.conf_int[0]), float(results.conf_int[1])], } # Group-time effects (sorted for determinism) @@ -137,10 +137,16 @@ def save_baseline(path="benchmarks/speed_review/baseline_results.json"): name = scenario["name"] print(f"Running scenario: {name} ...") df = generate_data(n_covariates=scenario["n_cov"]) - covariates = [f"x{i}" for i in range(1, scenario["n_cov"] + 1)] if scenario["n_cov"] > 0 else None + covariates = ( + [f"x{i}" for i in range(1, scenario["n_cov"] + 1)] if scenario["n_cov"] > 0 else None + ) control_group = scenario.get("control_group", "never_treated") - results = run_estimator(df, estimation_method=scenario["method"], - covariates=covariates, control_group=control_group) + results = run_estimator( + df, + estimation_method=scenario["method"], + covariates=covariates, + control_group=control_group, + ) all_results[name] = results print(f" Overall ATT: {results['overall_att']:.10f}") print(f" N group-time effects: {len(results['group_time_effects'])}") @@ -165,14 +171,20 @@ def check_results(path="benchmarks/speed_review/baseline_results.json", tol=1e-1 baseline = all_baseline[name] df = generate_data(n_covariates=scenario["n_cov"]) - covariates = [f"x{i}" for i in range(1, scenario["n_cov"] + 1)] if scenario["n_cov"] > 0 else None + covariates = ( + [f"x{i}" for i in range(1, scenario["n_cov"] + 1)] if scenario["n_cov"] > 0 else None + ) control_group = scenario.get("control_group", "never_treated") # Use relaxed tolerance for covariate scenarios (Cholesky vs lstsq) scenario_tol = 1e-10 if scenario["n_cov"] > 0 else tol - current = run_estimator(df, estimation_method=scenario["method"], - covariates=covariates, control_group=control_group) + current = run_estimator( + df, + estimation_method=scenario["method"], + covariates=covariates, + control_group=control_group, + ) failures = [] @@ -184,17 +196,27 @@ def compare(label, base_val, cur_val, t): return diff = abs(base_val - cur_val) if diff > t: - failures.append(f" {label}: baseline={base_val:.15e}, current={cur_val:.15e}, diff={diff:.2e}") + failures.append( + f" {label}: baseline={base_val:.15e}, current={cur_val:.15e}, diff={diff:.2e}" + ) - compare(f"{name}/overall_att", baseline["overall_att"], current["overall_att"], scenario_tol) + compare( + f"{name}/overall_att", baseline["overall_att"], current["overall_att"], scenario_tol + ) compare(f"{name}/overall_se", baseline["overall_se"], current["overall_se"], scenario_tol) - compare(f"{name}/overall_p_value", baseline["overall_p_value"], current["overall_p_value"], 0.02) + compare( + f"{name}/overall_p_value", baseline["overall_p_value"], current["overall_p_value"], 0.02 + ) # Compare overall CI values if "overall_ci" in baseline and "overall_ci" in current: for i, label in enumerate(["lower", "upper"]): - compare(f"{name}/overall_ci.{label}", - baseline["overall_ci"][i], current["overall_ci"][i], scenario_tol) + compare( + f"{name}/overall_ci.{label}", + baseline["overall_ci"][i], + current["overall_ci"][i], + scenario_tol, + ) # Group-time SE tolerance: tight for covariate scenarios, relaxed for bootstrap gt_se_tol = 1e-8 if scenario["n_cov"] > 0 else 0.01 @@ -213,7 +235,12 @@ def compare(label, base_val, cur_val, t): compare(f"{name}/gt[{key}].p_value", b["p_value"], c["p_value"], 0.02) if "conf_int" in b and "conf_int" in c: for i, label in enumerate(["lower", "upper"]): - compare(f"{name}/gt[{key}].ci.{label}", b["conf_int"][i], c["conf_int"][i], gt_se_tol) + compare( + f"{name}/gt[{key}].ci.{label}", + b["conf_int"][i], + c["conf_int"][i], + gt_se_tol, + ) # Compare event study effects/SEs if present if "event_study" in baseline and "event_study" in current: @@ -231,7 +258,12 @@ def compare(label, base_val, cur_val, t): compare(f"{name}/es[{e_key}].p_value", b_es["p_value"], c_es["p_value"], 0.02) if "conf_int" in b_es and "conf_int" in c_es: for i, label in enumerate(["lower", "upper"]): - compare(f"{name}/es[{e_key}].ci.{label}", b_es["conf_int"][i], c_es["conf_int"][i], gt_se_tol) + compare( + f"{name}/es[{e_key}].ci.{label}", + b_es["conf_int"][i], + c_es["conf_int"][i], + gt_se_tol, + ) # Compare group effects if present if "group_effects" in baseline and "group_effects" in current: @@ -249,7 +281,12 @@ def compare(label, base_val, cur_val, t): compare(f"{name}/ge[{g_key}].p_value", b_ge["p_value"], c_ge["p_value"], 0.02) if "conf_int" in b_ge and "conf_int" in c_ge: for i, label in enumerate(["lower", "upper"]): - compare(f"{name}/ge[{g_key}].ci.{label}", b_ge["conf_int"][i], c_ge["conf_int"][i], gt_se_tol) + compare( + f"{name}/ge[{g_key}].ci.{label}", + b_ge["conf_int"][i], + c_ge["conf_int"][i], + gt_se_tol, + ) if failures: all_failures.extend(failures) diff --git a/carousel/generate_v26_pdf.py b/carousel/generate_v26_pdf.py index 6f4551353..f0d2c10f7 100644 --- a/carousel/generate_v26_pdf.py +++ b/carousel/generate_v26_pdf.py @@ -7,6 +7,7 @@ from pathlib import Path import matplotlib + matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 import numpy as np # noqa: E402 @@ -73,16 +74,19 @@ def _render_equations(self, latex_lines, fontsize=26): for i, line in enumerate(latex_lines): y_frac = 1.0 - (2 * i + 1) / (2 * n) fig.text( - 0.5, y_frac, line, - fontsize=fontsize, ha="center", va="center", + 0.5, + y_frac, + line, + fontsize=fontsize, + ha="center", + va="center", color=NAVY_HEX, ) fig.patch.set_alpha(0) fd, path = tempfile.mkstemp(suffix=".png") os.close(fd) - fig.savefig(path, dpi=250, bbox_inches="tight", pad_inches=0.06, - transparent=True) + fig.savefig(path, dpi=250, bbox_inches="tight", pad_inches=0.06, transparent=True) plt.close(fig) with PILImage.open(path) as img: @@ -91,8 +95,7 @@ def _render_equations(self, latex_lines, fontsize=26): self._temp_files.append(path) return path, pw, ph - def _place_equation(self, path, pw, ph, box_x, _box_y, box_w, - content_top, content_bottom): + def _place_equation(self, path, pw, ph, box_x, _box_y, box_w, content_top, content_bottom): """Place an equation image centered in a region of a box.""" max_w = box_w * 0.82 aspect = ph / pw @@ -127,8 +130,7 @@ def _render_dose_response_figure(self): fig, ax = plt.subplots(figsize=(8, 4.5)) # Confidence band - ax.fill_between(d, lower, upper, alpha=0.25, color=VIOLET_LIGHT_HEX, - label="95% CI") + ax.fill_between(d, lower, upper, alpha=0.25, color=VIOLET_LIGHT_HEX, label="95% CI") # ATT(d) curve ax.plot(d, att, color=VIOLET_HEX, linewidth=2.5, label="ATT(d)") # Zero line @@ -146,8 +148,7 @@ def _render_dose_response_figure(self): fd, path = tempfile.mkstemp(suffix=".png") os.close(fd) - fig.savefig(path, dpi=200, bbox_inches="tight", pad_inches=0.1, - facecolor="white") + fig.savefig(path, dpi=200, bbox_inches="tight", pad_inches=0.1, facecolor="white") plt.close(fig) with PILImage.open(path) as img: @@ -198,9 +199,7 @@ def add_connector_graphic(self, position="right"): ] for i, (dx, dy) in enumerate(dot_positions): dot_radius = 3 - i * 0.5 - self.ellipse( - dx - dot_radius, dy - dot_radius, dot_radius * 2, dot_radius * 2, "F" - ) + self.ellipse(dx - dot_radius, dy - dot_radius, dot_radius * 2, dot_radius * 2, "F") def light_gradient_background(self): """Draw light gradient background (top #e1f0ff fading to white).""" @@ -316,7 +315,7 @@ def slide_problem(self): self.set_text_color(*RED) bars_bottom = panel_y + n_bars * (bar_h + bar_gap) self.set_xy(left_x, bars_bottom - 2) - self.cell(panel_w, 10, "All collapsed to \"Treated\"", align="C") + self.cell(panel_w, 10, 'All collapsed to "Treated"', align="C") # Right panel: Continuous — bars proportional to dose right_x = margin * 2 + panel_w @@ -355,7 +354,8 @@ def slide_problem(self): self.set_font("Helvetica", "B", 14) self.set_text_color(*VIOLET) self.multi_cell( - callout_w - 16, 10, + callout_w - 16, + 10, "TWFE with a continuous treatment is biased --\n" "negative weights, contamination, and scale dependence.", align="C", @@ -366,12 +366,16 @@ def slide_problem(self): self.centered_text( ann_y, "Binarizing discards the dose-response relationship entirely", - size=13, bold=False, color=GRAY, + size=13, + bold=False, + color=GRAY, ) self.centered_text( ann_y + 16, "You need level effects AND marginal effects", - size=13, bold=False, color=GRAY, + size=13, + bold=False, + color=GRAY, ) self.add_footer() @@ -390,7 +394,8 @@ def slide_solution(self): self.set_font("Helvetica", "I", 15) self.set_text_color(*GRAY) self.cell( - WIDTH, 8, + WIDTH, + 8, "Callaway, Goodman-Bacon & Sant'Anna (2024) | NBER WP 32117", align="C", ) @@ -418,8 +423,11 @@ def slide_solution(self): self.set_fill_color(*VIOLET) self.ellipse( - circle_x - circle_r, circle_y - circle_r, - circle_r * 2, circle_r * 2, "F", + circle_x - circle_r, + circle_y - circle_r, + circle_r * 2, + circle_r * 2, + "F", ) self.set_xy(circle_x - circle_r, circle_y - 6) self.set_font("Helvetica", "B", 18) @@ -451,12 +459,16 @@ def slide_solution(self): # Arrowhead head_size = 5 self.line( - arrow_x - head_size, arrow_bottom - head_size, - arrow_x, arrow_bottom, + arrow_x - head_size, + arrow_bottom - head_size, + arrow_x, + arrow_bottom, ) self.line( - arrow_x + head_size, arrow_bottom - head_size, - arrow_x, arrow_bottom, + arrow_x + head_size, + arrow_bottom - head_size, + arrow_x, + arrow_bottom, ) # Footer text @@ -464,7 +476,9 @@ def slide_solution(self): self.centered_text( footer_y, "From binary to the full dose-response curve.", - size=15, bold=False, color=GRAY, + size=15, + bold=False, + color=GRAY, ) self.add_footer() @@ -477,8 +491,7 @@ def slide_math(self): self.light_gradient_background() self.centered_text(30, "The Math", size=38) - self.centered_text(63, "Three targets, one estimation", size=18, - bold=False, color=GRAY) + self.centered_text(63, "Three targets, one estimation", size=18, bold=False, color=GRAY) margin = 30 box_w = WIDTH - margin * 2 @@ -493,8 +506,7 @@ def slide_math(self): [r"$\mathrm{ATT}(d) = \psi^K(d)'\hat{\beta}$"] ) eq3_path, eq3_pw, eq3_ph = self._render_equations( - [r"$\mathrm{ACRT}(d) = \frac{\partial \psi^K(d)}{\partial d}" - r" \cdot \hat{\beta}$"] + [r"$\mathrm{ACRT}(d) = \frac{\partial \psi^K(d)}{\partial d}" r" \cdot \hat{\beta}$"] ) boxes = [ @@ -551,9 +563,14 @@ def slide_math(self): # Place equation image eq_path, eq_pw, eq_ph = box["eq"] self._place_equation( - eq_path, eq_pw, eq_ph, - margin, y_cursor, box_w, - content_top, content_bottom, + eq_path, + eq_pw, + eq_ph, + margin, + y_cursor, + box_w, + content_top, + content_bottom, ) # Annotation text @@ -569,7 +586,9 @@ def slide_math(self): self.centered_text( y_cursor + 2, "Influence-function SEs + multiplier bootstrap", - size=14, bold=True, color=VIOLET, + size=14, + bold=True, + color=VIOLET, ) self.add_footer() @@ -598,12 +617,16 @@ def slide_dose_response(self): self.centered_text( ann_y, "ATT(d): total impact at each dose level", - size=16, bold=True, color=NAVY, + size=16, + bold=True, + color=NAVY, ) self.centered_text( ann_y + 20, "Confidence bands from influence functions or bootstrap", - size=14, bold=False, color=GRAY, + size=14, + bold=False, + color=GRAY, ) self.add_footer() @@ -695,7 +718,8 @@ def slide_two_questions(self): self.set_font("Helvetica", "B", 15) self.set_text_color(*VIOLET) self.cell( - callout_w, 12, + callout_w, + 12, "Both are functions of d -- not just single numbers.", align="C", ) @@ -704,7 +728,9 @@ def slide_two_questions(self): self.centered_text( callout_y + 34, "Plus global summaries: overall ATT and overall ACRT", - size=14, bold=False, color=GRAY, + size=14, + bold=False, + color=GRAY, ) self.add_footer() @@ -733,7 +759,7 @@ def slide_assumptions(self): "headline": "Parallel trends in untreated outcomes", "identifies": "ATT(d|d) level effects within each dose group", "meaning": "Counterfactual trends are the same across\n" - "all dose groups and the untreated.", + "all dose groups and the untreated.", }, { "badge": "Strong PT", @@ -741,7 +767,7 @@ def slide_assumptions(self): "headline": "No selection into dose based on effects", "identifies": "ATT(d) + ACRT(d) dose-response curves", "meaning": "Units don't choose their dose based on\n" - "how much they would benefit from it.", + "how much they would benefit from it.", }, ] @@ -776,8 +802,7 @@ def slide_assumptions(self): self.set_xy(margin + 12, y + 28) self.set_font("Helvetica", "B", 15) self.set_text_color(*row["badge_color"]) - self.cell(row_w - 24, 10, - "Identifies: " + row["identifies"]) + self.cell(row_w - 24, 10, "Identifies: " + row["identifies"]) # Meaning self.set_xy(margin + 12, y + 43) @@ -797,7 +822,8 @@ def slide_assumptions(self): self.set_font("Helvetica", "", 14) self.set_text_color(*VIOLET) self.multi_cell( - callout_w - 16, 12, + callout_w - 16, + 12, "Note: Under standard PT, the slope of the dose-response\n" "curve does NOT identify the causal marginal effect.", align="C", @@ -831,7 +857,7 @@ def slide_code(self): (" aggregate='dose',", 1.0), (")", 1.0), ("", 0.5), - ("results.overall_att # level effect", 1.0), + ("results.att # level effect", 1.0), ("results.overall_acrt # marginal effect", 1.0), ] line_height = 11 @@ -853,12 +879,16 @@ def slide_code(self): self.centered_text( subtitle_y, "Same fit() API as every other diff-diff estimator.", - size=15, bold=False, color=GRAY, + size=15, + bold=False, + color=GRAY, ) self.centered_text( subtitle_y + 17, "Full walkthrough in Tutorial 14: Continuous DiD", - size=15, bold=False, color=GRAY, + size=15, + bold=False, + color=GRAY, ) self.add_footer() @@ -927,7 +957,9 @@ def slide_use_cases(self): self.centered_text( cards_bottom + 6, "Any setting where treatment intensity varies across units.", - size=15, bold=False, color=GRAY, + size=15, + bold=False, + color=GRAY, ) self.add_footer() @@ -1000,7 +1032,9 @@ def slide_full_toolkit(self): self.centered_text( grid_bottom + 2, "The most complete DiD toolkit in any language.", - size=15, bold=False, color=GRAY, + size=15, + bold=False, + color=GRAY, ) self.add_footer() @@ -1026,23 +1060,31 @@ def slide_cta(self): self.set_text_color(*WHITE) self.cell(box_width, 14, "$ pip install --upgrade diff-diff", align="C") - self.centered_text(200, "github.com/igerber/diff-diff", size=20, - color=MID_BLUE) + self.centered_text(200, "github.com/igerber/diff-diff", size=20, color=MID_BLUE) self.centered_text( - 232, "Full documentation & 14 tutorials included", - size=16, bold=False, color=GRAY, + 232, + "Full documentation & 14 tutorials included", + size=16, + bold=False, + color=GRAY, ) self.centered_text( - 252, "MIT Licensed | Open Source", - size=16, bold=False, color=GRAY, + 252, + "MIT Licensed | Open Source", + size=16, + bold=False, + color=GRAY, ) self.draw_split_logo(278, size=28) self.centered_text( - 298, "Difference-in-Differences for Python", - size=14, bold=False, color=GRAY, + 298, + "Difference-in-Differences for Python", + size=14, + bold=False, + color=GRAY, ) diff --git a/changelog.d/20261026-v4-pr5a-ii-docs-readers.md b/changelog.d/20261026-v4-pr5a-ii-docs-readers.md new file mode 100644 index 000000000..9816fe3b1 --- /dev/null +++ b/changelog.d/20261026-v4-pr5a-ii-docs-readers.md @@ -0,0 +1,13 @@ +### Internal +- 4.0 cut, PR 5a-ii (the documentation half of cut-sequence item 5's reader migration, + split from PR 5a because the combined diff exceeded the AI review input budget): every + documented read of the 3.x `overall_att` / `overall_se` / `overall_t_stat` / + `overall_p_value` / `overall_conf_int` names (and ContinuousDiD's `overall_att_*` + spellings) on the nine `M-050`..`M-058` containers - RTD pages, the bundled + `llms*.txt` guides, tutorial code cells, the tutorial drift tests that mirror them + (tutorial 32's code-cell hash pins re-pinned), benchmark scripts and the carousel + generator - now reads the canonical `att` / `se` / `t_stat` / `p_value` / `conf_int` + quintet. No user-visible behaviour change (the canonical names already resolve); + committed notebook outputs are unchanged except tutorial 18's echoed guidance string. + Bootstrap draw containers (`bootstrap_results.overall_att_se`) and R / golden-JSON + interface keys are untouched. diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 5c15d0c37..986b008d8 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -856,10 +856,10 @@ calibration. `PanelProfile.outcome_shape` exposes the relevant facts: distributional misspecification (Wooldridge 2023). It estimates the overall ATT as an ASF-based outcome-scale difference (per-cell average of `E[exp(η_1)] - E[exp(η_0)]`; see REGISTRY.md - §WooldridgeDiD nonlinear / ASF path). The headline `overall_att` is + §WooldridgeDiD nonlinear / ASF path). The headline `att` is a difference on the outcome scale, NOT a multiplicative ratio; a proportional interpretation can be derived post-hoc as - `overall_att / E[Y_0]` if desired but is not the estimator's + `att / E[Y_0]` if desired but is not the estimator's reported scalar. The choice between linear-OLS DiD and Poisson ETWFE is about which functional form (additive vs. multiplicative) best summarizes the treatment effect on the count outcome, not @@ -1098,7 +1098,7 @@ Reasoning chain: 3. Decision: fit `WooldridgeDiD(method="poisson")` if you want a multiplicative effect summary (with the outcome-scale headline reported as a difference; a percent-change reading can be derived - post-hoc as `overall_att / E[Y_0]`). Fit linear-OLS DiD if the + post-hoc as `att / E[Y_0]`). Fit linear-OLS DiD if the additive ATT is the right summary and counterfactual predictions stay safely within the non-negative support — the cluster-robust SEs are calibrated either way. Document which functional form diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index ff4ad9172..9daf2ada8 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -323,7 +323,7 @@ results = est.fit( results.print_summary() # Decomposition -print(f"DID_M (overall): {results.overall_att:.3f}") +print(f"DID_M (overall): {results.att:.3f}") print(f"DID_+ (joiners): {results.joiners_att:.3f}") print(f"DID_- (leavers): {results.leavers_att:.3f}") print(f"Placebo (DID^pl): {results.placebo_effect:.3f}") @@ -774,7 +774,7 @@ ContinuousDiD( Covariates give conditional parallel trends: each (g,t) cell's control counterfactual becomes a covariate-adjusted prediction. `reg`/`dr` share the ATT(d) *shape* and ACRT(d); `dr` (doubly-robust, -default) differs only in the overall_att/ATT(d) level and SE. `overall_att`+SE match DRDID +default) differs only in the att/ATT(d) level and SE. `att`+SE match DRDID reg_did_panel/drdid_panel. `covariates=` + `survey_design=` is not yet supported. `treatment_type="discrete"` fits a **saturated regression** for a multi-valued dose: one indicator per @@ -1713,17 +1713,18 @@ results = etwfe_pois.fit(data, outcome='y_count', unit='id', time='t', first_tre ## Results Objects -**Flat-alias compatibility note.** Every staggered result class in this -section (those with canonical `overall_*` / `overall_att_*` / `avg_*` -prefixed inference fields) ALSO exposes the unprefixed flat names -`att` / `se` / `conf_int` / `p_value` / `t_stat` as read-only `@property` -aliases over the canonical fields. The canonical prefixed fields remain -the documented and computed surface; the flat aliases are pure -read-throughs for compatibility with external adapters that -`getattr(res, "se", None)`-style query the inference surface (e.g. -`balance.interop.diff_diff.as_balance_diagnostic()`). Tables below list -the canonical names; assume the flat aliases are present on every -staggered class unless explicitly noted otherwise. +**Canonical quintet note.** Every staggered result class in this +section exposes the headline inference under the canonical flat names +`att` / `se` / `conf_int` / `p_value` / `t_stat` (4.0 design spec section +5) - read those. The `overall_*` / `overall_att_*` spellings in the tables +below are the 3.x STORED field names; they resolve to the same values +(the flat names are read-only `@property` aliases over them until the 4.0 +storage flip makes the flat names the native fields, after which the +`overall_*` spellings become `FutureWarning` aliases removed at 5.0). +External adapters that `getattr(res, "se", None)`-style query the +inference surface (e.g. `balance.interop.diff_diff.as_balance_diagnostic()`) +therefore work on every staggered class; assume the flat names are +present unless explicitly noted otherwise. **balance interop.** Meta's `balance` package (>= 0.21) ships the one-way adapter `balance.interop.diff_diff` (`pip install "balance[did]"`, diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index f70b18a81..3bd476506 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -64,31 +64,29 @@ results.se # Standard error results.conf_int # Confidence interval # CallawaySantAnna (post-fit aggregate(); fit() takes no aggregate= keyword): -results.overall_att # Overall ATT -results.overall_se # Standard error +results.att # Overall ATT +results.se # Standard error 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 +results.att # Overall ATT results.event_study_effects # Dynamic effects by relative period results.cohort_effects # Per-cohort effects (via to_dataframe(level='cohort')) # Other staggered (ImputationDiD, TwoStageDiD, etc.): -results.overall_att # Overall ATT -results.overall_se # Standard error - -# Flat-alias compatibility: every staggered class above (canonical -# `overall_*` / `overall_att_*` / `avg_*` prefixed) also exposes the -# unprefixed flat names `att` / `se` / `conf_int` / `p_value` / -# `t_stat` as read-only `@property` aliases over the canonical fields. -# The canonical prefixed names remain the documented and computed -# surface; the flat aliases are pure read-throughs for compatibility -# with adapters that `getattr(res, "se", None)`-style query inference. -results.att # alias of overall_att (or overall_att for ContinuousDiD ATT side) -results.se # alias of overall_se / avg_se (or overall_att_se for ContinuousDiD ATT side) +results.att # Overall ATT +results.se # Standard error + +# Canonical quintet: every estimator result class above exposes the +# headline inference as `att` / `se` / `conf_int` / `p_value` / `t_stat` +# (spec section 5 of the 4.0 design). The staggered containers' 3.x +# `overall_*` / `overall_att_*` spellings still resolve to the same values +# through the 4.0 -> 5.0 deprecation window; read the canonical names. +results.att # headline ATT (ContinuousDiD: the ATT side; ACRT stays overall_acrt) +results.se # its standard error ``` --- @@ -485,7 +483,7 @@ Aggregate treatment effects may mask important variation. # 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.att) # Overall ATT print(results.aggregate('group').to_dataframe()) # Per-cohort ATTs print(results.aggregate('event_study').to_dataframe()) # Dynamic effects # balance_e moves onto aggregate() too: @@ -553,9 +551,9 @@ cs_result = cs.fit(data, outcome='y', unit='id', time='t', first_treat='g') sa_result = sa.fit(data, outcome='y', unit='id', time='t', first_treat='g') bjs_result = bjs.fit(data, outcome='y', unit='id', time='t', first_treat='g') -print(f"CS ATT: {cs_result.overall_att:.4f} (SE: {cs_result.overall_se:.4f})") -print(f"SA ATT: {sa_result.overall_att:.4f} (SE: {sa_result.overall_se:.4f})") -print(f"BJS ATT: {bjs_result.overall_att:.4f} (SE: {bjs_result.overall_se:.4f})") +print(f"CS ATT: {cs_result.att:.4f} (SE: {cs_result.se:.4f})") +print(f"SA ATT: {sa_result.att:.4f} (SE: {sa_result.se:.4f})") +print(f"BJS ATT: {bjs_result.att:.4f} (SE: {bjs_result.se:.4f})") ``` ### Report with and without covariates — REQUIRED @@ -572,8 +570,8 @@ result_no_cov = cs.fit(data, outcome='y', unit='id', time='t', first_treat='g') result_cov = cs.fit(data, outcome='y', unit='id', time='t', first_treat='g', covariates=['x1', 'x2']) -print(f"Without covariates: ATT={result_no_cov.overall_att:.4f} (SE: {result_no_cov.overall_se:.4f})") -print(f"With covariates: ATT={result_cov.overall_att:.4f} (SE: {result_cov.overall_se:.4f})") +print(f"Without covariates: ATT={result_no_cov.att:.4f} (SE: {result_no_cov.se:.4f})") +print(f"With covariates: ATT={result_cov.att:.4f} (SE: {result_cov.se:.4f})") # If estimates differ substantially, covariates matter for identification. # If they are similar, the unconditional PT assumption appears sufficient. ``` @@ -768,15 +766,15 @@ bjs = ImputationDiD(cluster='countyreal') bjs_result = bjs.fit(data, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat') -print(f"CS ATT: {results.overall_att:.4f} (SE: {results.overall_se:.4f})") -print(f"SA ATT: {sa_result.overall_att:.4f} (SE: {sa_result.overall_se:.4f})") -print(f"BJS ATT: {bjs_result.overall_att:.4f} (SE: {bjs_result.overall_se:.4f})") +print(f"CS ATT: {results.att:.4f} (SE: {results.se:.4f})") +print(f"SA ATT: {sa_result.att:.4f} (SE: {sa_result.se:.4f})") +print(f"BJS ATT: {bjs_result.att:.4f} (SE: {bjs_result.se:.4f})") # Step 8 (continued): REQUIRED with/without covariates comparison results_no_cov = cs.fit(data, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat') -print(f"Without covariates: ATT={results_no_cov.overall_att:.4f}") -print(f"With covariates: ATT={results.overall_att:.4f}") +print(f"Without covariates: ATT={results_no_cov.att:.4f}") +print(f"With covariates: ATT={results.att:.4f}") # Context-aware next steps guidance = practitioner_next_steps(results) diff --git a/docs/api/chaisemartin_dhaultfoeuille.rst b/docs/api/chaisemartin_dhaultfoeuille.rst index dc4025cd4..953e3656d 100644 --- a/docs/api/chaisemartin_dhaultfoeuille.rst +++ b/docs/api/chaisemartin_dhaultfoeuille.rst @@ -213,7 +213,7 @@ Basic usage with reversible treatment:: Joiners and leavers views:: - print(f"DID_M (overall): {results.overall_att:.3f}") + print(f"DID_M (overall): {results.att:.3f}") print(f"DID_+ (joiners): {results.joiners_att:.3f}") print(f"DID_- (leavers): {results.leavers_att:.3f}") print(f"Placebo (DID^pl): {results.placebo_effect:.3f}") @@ -240,8 +240,8 @@ Multiplier bootstrap inference:: # inference (not normal-theory recomputations from the bootstrap SE). # The t-stat is computed from the SE in both cases. See REGISTRY.md # `Note (bootstrap inference surface)` for the full contract. - print(f"Top-level p-value (bootstrap): {results.overall_p_value:.4f}") - print(f"Top-level CI (bootstrap): {results.overall_conf_int}") + print(f"Top-level p-value (bootstrap): {results.p_value:.4f}") + print(f"Top-level CI (bootstrap): {results.conf_int}") print(f"bootstrap_results.overall_se: {results.bootstrap_results.overall_se:.3f}") print(f"bootstrap_results.overall_ci: {results.bootstrap_results.overall_ci}") diff --git a/docs/api/continuous_did.rst b/docs/api/continuous_did.rst index 9aed4fa2b..9a5f4a1d1 100644 --- a/docs/api/continuous_did.rst +++ b/docs/api/continuous_did.rst @@ -122,7 +122,7 @@ Accessing dose-response curves (always computed by ``fit()``):: acrt_df = results.dose_response_acrt.to_dataframe() # Overall summary parameters - print(f"Overall ATT: {results.overall_att:.3f} (SE: {results.overall_att_se:.3f})") + print(f"Overall ATT: {results.att:.3f} (SE: {results.se:.3f})") print(f"Overall ACRT: {results.overall_acrt:.3f} (SE: {results.overall_acrt_se:.3f})") # Or as post-fit aggregation tables (views - work on any fit): diff --git a/docs/api/efficient_did.rst b/docs/api/efficient_did.rst index 67cf67da7..737e54ce4 100644 --- a/docs/api/efficient_did.rst +++ b/docs/api/efficient_did.rst @@ -129,17 +129,17 @@ PT-Post mode (matches CS for post-treatment ATT):: edid_post = EfficientDiD(pt_assumption="post") results_post = edid_post.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') - print(f"PT-All ATT: {results.overall_att:.4f} (SE={results.overall_se:.4f})") - print(f"PT-Post ATT: {results_post.overall_att:.4f} (SE={results_post.overall_se:.4f})") + print(f"PT-All ATT: {results.att:.4f} (SE={results.se:.4f})") + print(f"PT-Post ATT: {results_post.att:.4f} (SE={results_post.se:.4f})") Bootstrap inference:: edid_boot = EfficientDiD(pt_assumption="all", n_bootstrap=999, seed=42) results_boot = edid_boot.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') - print(f"Bootstrap SE: {results_boot.overall_se:.4f}") - print(f"Bootstrap CI: [{results_boot.overall_conf_int[0]:.4f}, " - f"{results_boot.overall_conf_int[1]:.4f}]") + print(f"Bootstrap SE: {results_boot.se:.4f}") + print(f"Bootstrap CI: [{results_boot.conf_int[0]:.4f}, " + f"{results_boot.conf_int[1]:.4f}]") Comparison with Other Staggered Estimators ------------------------------------------ diff --git a/docs/api/imputation.rst b/docs/api/imputation.rst index d8bad6259..4e3d74d75 100644 --- a/docs/api/imputation.rst +++ b/docs/api/imputation.rst @@ -122,6 +122,6 @@ Comparison with other estimators:: cs = CallawaySantAnna().fit(data, ...) sa = SunAbraham().fit(data, ...) - print(f"Imputation ATT: {imp.overall_att:.3f} (SE: {imp.overall_se:.3f})") - print(f"CS ATT: {cs.overall_att:.3f} (SE: {cs.overall_se:.3f})") - print(f"SA ATT: {sa.overall_att:.3f} (SE: {sa.overall_se:.3f})") + print(f"Imputation ATT: {imp.att:.3f} (SE: {imp.se:.3f})") + print(f"CS ATT: {cs.att:.3f} (SE: {cs.se:.3f})") + print(f"SA ATT: {sa.att:.3f} (SE: {sa.se:.3f})") diff --git a/docs/api/two_stage.rst b/docs/api/two_stage.rst index fe58f1e70..80f7ad6b7 100644 --- a/docs/api/two_stage.rst +++ b/docs/api/two_stage.rst @@ -121,9 +121,9 @@ Comparison with other estimators:: imp = ImputationDiD().fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat') - print(f"Two-Stage ATT: {ts.overall_att:.3f} (SE: {ts.overall_se:.3f})") - print(f"CS ATT: {cs.overall_att:.3f} (SE: {cs.overall_se:.3f})") - print(f"Imputation ATT:{imp.overall_att:.3f} (SE: {imp.overall_se:.3f})") + print(f"Two-Stage ATT: {ts.att:.3f} (SE: {ts.se:.3f})") + print(f"CS ATT: {cs.att:.3f} (SE: {cs.se:.3f})") + print(f"Imputation ATT:{imp.att:.3f} (SE: {imp.se:.3f})") Estimator Comparison -------------------- diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 65a1d06f6..b8fce0b7d 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -248,7 +248,7 @@ This is the recommended estimator for most applied work with staggered adoption. covariates=['x1', 'x2']) # Overall ATT - print(f"Overall ATT: {results.overall_att:.3f}") + print(f"Overall ATT: {results.att:.3f}") # Event study aggregation (post-fit - no refit needed) es = results.aggregate('event_study') @@ -307,7 +307,7 @@ integration on placebos, and survey support via Taylor-series linearization. ) results.print_summary() - print(f"DID_M (overall): {results.overall_att:.3f}") + print(f"DID_M (overall): {results.att:.3f}") print(f"DID_+ (joiners): {results.joiners_att:.3f}") print(f"DID_- (leavers): {results.leavers_att:.3f}") print(f"Placebo: {results.placebo_effect:.3f}") @@ -387,7 +387,7 @@ Use :class:`~diff_diff.ContinuousDiD` when: dose='dose') # Overall effect and dose-response curve (always computed by fit) - print(f"Overall ATT: {results.overall_att:.3f}") + print(f"Overall ATT: {results.att:.3f}") att_curve = results.dose_response_att.to_dataframe() Universal Rollout / No Untreated Control diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 56905b852..10aa23534 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -1427,7 +1427,7 @@ Cost-benefit aggregate `delta = sum_l w_l * DID_l` (Lemma 4) where `w_l` are non Dynamic placebos `DID^{pl}_l` look backward from each group's reference period, with a dual eligibility condition: `F_g - 1 - l >= 1` AND `F_g - 1 + l <= T_g`. -- **Note (Phase 2 `DID_1` vs Phase 1 `DID_M`):** When `L_max >= 2`, `event_study_effects[1]` uses the per-group `DID_{g,1}` building block (Equation 3 of the dynamic paper) with cohort-based controls, which may differ slightly from the Phase 1 `DID_M` value (Theorem 3 of AER 2020 with period-based stable-control sets). The Phase 1 `DID_M` value remains accessible via `fit(..., L_max=None).overall_att`. The difference arises because the per-group path conditions on baseline treatment `D_{g,1}` when selecting controls, while the per-period path does not. On pure-direction panels (all joiners or all leavers) the two agree; on mixed-direction panels they can differ by O(1%). This is the same period-vs-cohort control-set deviation documented in the Phase 1 Note above, extended to the `l=1` event-study entry. +- **Note (Phase 2 `DID_1` vs Phase 1 `DID_M`):** When `L_max >= 2`, `event_study_effects[1]` uses the per-group `DID_{g,1}` building block (Equation 3 of the dynamic paper) with cohort-based controls, which may differ slightly from the Phase 1 `DID_M` value (Theorem 3 of AER 2020 with period-based stable-control sets). The Phase 1 `DID_M` value remains accessible via `fit(..., L_max=None).att`. The difference arises because the per-group path conditions on baseline treatment `D_{g,1}` when selecting controls, while the per-period path does not. On pure-direction panels (all joiners or all leavers) the two agree; on mixed-direction panels they can differ by O(1%). This is the same period-vs-cohort control-set deviation documented in the Phase 1 Note above, extended to the `l=1` event-study entry. - **Note (Phase 2 equal-cell weighting, deviation from R `DIDmultiplegtDYN`):** The Phase 1 equal-cell weighting contract carries forward to all Phase 2 estimands (`DID_l`, `DID^{pl}_l`, `DID^n_l`, `delta`). Each `(g, t)` cell contributes equally regardless of within-cell observation count. On individual-level inputs with uneven cell sizes, this produces a different estimand than R `DIDmultiplegtDYN` which weights by cell size. The parity tests use one-observation-per-cell generators so parity holds. See the Phase 1 weighting Note above for the full rationale. @@ -1487,7 +1487,7 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param - **Note (cluster contract):** `ChaisemartinDHaultfoeuille` clusters at the group level by default. The analytical SE plug-in operates on per-group influence-function values (one `U^G_g` per group) and, under the cell-period allocator, on their per-cell decomposition `U[g, t]` which telescopes back to `U^G_g` at the PSU-level sum. The multiplier bootstrap generates one weight per group. The user-facing `cluster=` kwarg is not supported: the constructor accepts `cluster=None` (the default and only supported value); passing any non-`None` value raises `NotImplementedError` at construction time (and the same gate fires from `set_params`) — custom user-specified clustering is reserved for a future phase. **Automatic PSU-level clustering under `survey_design`:** the analytical TSL path supports PSU labels that vary across cells of a group (within-cell constancy required); the multiplier bootstrap supports the same regime via the cell-level wild PSU bootstrap documented in the survey + bootstrap contract Note below. Under PSU-within-group-constant regimes (including the default auto-inject `psu=group` and strictly-coarser PSU with within-group constancy), the bootstrap dispatcher routes through the legacy group-level path so the SE is bit-identical to pre-cell-level releases via the identity-map fast path. The matching test for the `cluster=` gate is `test_cluster_parameter_raises_not_implemented` in `tests/test_chaisemartin_dhaultfoeuille.py::TestForwardCompatGates`. -- **Note (bootstrap inference surface):** When `n_bootstrap > 0`, the top-level `results.overall_p_value` / `results.overall_conf_int` (and joiners/leavers analogues) hold **percentile-based bootstrap inference** computed by the multiplier bootstrap, NOT normal-theory recomputations from the bootstrap SE. The t-stat (`overall_t_stat`, etc.) is computed from the SE via `safe_inference()[0]` to satisfy the project's anti-pattern rule (never compute `t = effect / se` inline) — bootstrap does not define an alternative t-stat semantic for percentile bootstrap, so the SE-based t-stat is the natural choice. `event_study_effects[1]`, `summary()`, `to_dataframe()`, `is_significant`, and `significance_stars` all read from these top-level fields and therefore reflect the bootstrap inference automatically. The library precedent for this propagation is `imputation.py:790-805`, `two_stage.py:778-787`, and `efficient_did.py:1009-1013`. The single-period placebo (`L_max=None`) still has NaN bootstrap fields; multi-horizon placebos (`L_max >= 1`) have valid bootstrap SE/CI/p via `placebo_horizon_ses/cis/p_values` on the bootstrap results object. The matching test is `test_bootstrap_p_value_and_ci_propagated_to_top_level` in `tests/test_chaisemartin_dhaultfoeuille.py::TestBootstrap`. +- **Note (bootstrap inference surface):** When `n_bootstrap > 0`, the top-level `results.p_value` / `results.conf_int` (and joiners/leavers analogues) hold **percentile-based bootstrap inference** computed by the multiplier bootstrap, NOT normal-theory recomputations from the bootstrap SE. The t-stat (`overall_t_stat`, etc.) is computed from the SE via `safe_inference()[0]` to satisfy the project's anti-pattern rule (never compute `t = effect / se` inline) — bootstrap does not define an alternative t-stat semantic for percentile bootstrap, so the SE-based t-stat is the natural choice. `event_study_effects[1]`, `summary()`, `to_dataframe()`, `is_significant`, and `significance_stars` all read from these top-level fields and therefore reflect the bootstrap inference automatically. The library precedent for this propagation is `imputation.py:790-805`, `two_stage.py:778-787`, and `efficient_did.py:1009-1013`. The single-period placebo (`L_max=None`) still has NaN bootstrap fields; multi-horizon placebos (`L_max >= 1`) have valid bootstrap SE/CI/p via `placebo_horizon_ses/cis/p_values` on the bootstrap results object. The matching test is `test_bootstrap_p_value_and_ci_propagated_to_top_level` in `tests/test_chaisemartin_dhaultfoeuille.py::TestBootstrap`. - **Note:** Placebo Assumption 11 violations (placebo joiners exist but no 3-period stable_0 controls, or symmetric for leavers/stable_1) trigger zero-retention in the placebo numerator AND emit a consolidated `Placebo (DID_M^pl) Assumption 11 violations` warning from `fit()`, mirroring the main DID path's contract documented above. The zeroed placebo periods retain their switcher counts in the placebo `N_S^pl` denominator, biasing `DID_M^pl` toward zero in the offending direction (matching the placebo paper convention). diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 4b5bac2f5..4415b0296 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -130,7 +130,7 @@ a few months later, and so on. Different markets were treated at different times data, outcome="outcome", unit="unit", time="period", first_treat="first_treat", ) - print(f"Overall campaign lift: {results.overall_att:.1f}") + print(f"Overall campaign lift: {results.att:.1f}") .. note:: @@ -183,7 +183,7 @@ assumption — also handle non-absorbing treatment, under stronger assumptions.) ) results.print_summary() - print(f"Overall lift (DID_M): {results.overall_att:.2f}") + print(f"Overall lift (DID_M): {results.att:.2f}") print(f"Joiners only (DID_+): {results.joiners_att:.2f}") print(f"Leavers only (DID_-): {results.leavers_att:.2f}") @@ -247,7 +247,7 @@ appropriate identification assumptions in place. data, outcome="outcome", unit="unit", time="period", first_treat="first_treat", dose="dose", ) - print(f"Average lift across dose levels: {results.overall_att:.1f}") + print(f"Average lift across dose levels: {results.att:.1f}") .. tip:: diff --git a/docs/practitioner_getting_started.rst b/docs/practitioner_getting_started.rst index 8acc83ef4..21b7356af 100644 --- a/docs/practitioner_getting_started.rst +++ b/docs/practitioner_getting_started.rst @@ -267,7 +267,7 @@ the estimates can be biased. time="period", first_treat="first_treat", ) - print(f"Overall campaign lift: {results.overall_att:.1f}") + print(f"Overall campaign lift: {results.att:.1f}") print(results.summary()) .. note:: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 4e1b275e9..df8f44274 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -202,7 +202,7 @@ When treatment is adopted at different times across units: ) # View aggregated treatment effect - print(f"Overall ATT: {results.overall_att:.3f}") + print(f"Overall ATT: {results.att:.3f}") Parallel Trends Testing ----------------------- diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index ebe037023..90848327c 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -218,7 +218,7 @@ Staggered Adoption Issues time='period', first_treat='first_treat') # Access aggregated results - print(results.overall_att) # Overall ATT + print(results.att) # Overall ATT print(results.aggregate('event_study').to_dataframe()) # Event study Visualization Issues diff --git a/docs/tutorials/02_staggered_did.ipynb b/docs/tutorials/02_staggered_did.ipynb index 0a4fdd7a4..4c96eb441 100644 --- a/docs/tutorials/02_staggered_did.ipynb +++ b/docs/tutorials/02_staggered_did.ipynb @@ -962,9 +962,9 @@ "# Simple aggregation: weighted average across all (g,t)\n", "# This is computed automatically and stored in overall_att/overall_se\n", "print(\"Simple Aggregation (Overall ATT):\")\n", - "print(f\"ATT: {results_cs.overall_att:.4f}\")\n", - "print(f\"SE: {results_cs.overall_se:.4f}\")\n", - "print(f\"95% CI: [{results_cs.overall_conf_int[0]:.4f}, {results_cs.overall_conf_int[1]:.4f}]\")" + "print(f\"ATT: {results_cs.att:.4f}\")\n", + "print(f\"SE: {results_cs.se:.4f}\")\n", + "print(f\"95% CI: [{results_cs.conf_int[0]:.4f}, {results_cs.conf_int[1]:.4f}]\")" ] }, { @@ -1111,7 +1111,7 @@ "# Access bootstrap results\n", "print(\"Bootstrap Inference Results:\")\n", "print(\"=\" * 60)\n", - "print(f\"\\nOverall ATT: {results_boot.overall_att:.4f}\")\n", + "print(f\"\\nOverall ATT: {results_boot.att:.4f}\")\n", "print(f\"Bootstrap SE: {results_boot.bootstrap_results.overall_att_se:.4f}\")\n", "print(f\"Bootstrap 95% CI: [{results_boot.bootstrap_results.overall_att_ci[0]:.4f}, \"\n", " f\"{results_boot.bootstrap_results.overall_att_ci[1]:.4f}]\")\n", @@ -1538,8 +1538,8 @@ "print(\"Comparison of control group specifications:\")\n", "print(f\"{'Control Group':<20} {'ATT':>10} {'SE':>10}\")\n", "print(\"-\" * 40)\n", - "print(f\"{'Never-treated':<20} {results_cs.overall_att:>10.4f} {results_cs.overall_se:>10.4f}\")\n", - "print(f\"{'Not-yet-treated':<20} {results_nyt.overall_att:>10.4f} {results_nyt.overall_se:>10.4f}\")" + "print(f\"{'Never-treated':<20} {results_cs.att:>10.4f} {results_cs.se:>10.4f}\")\n", + "print(f\"{'Not-yet-treated':<20} {results_nyt.att:>10.4f} {results_nyt.se:>10.4f}\")" ] }, { @@ -1586,7 +1586,7 @@ " first_treat=\"first_treat\"\n", ")\n", "\n", - "print(f\"With anticipation=1: ATT = {results_antic.overall_att:.4f}\")" + "print(f\"With anticipation=1: ATT = {results_antic.att:.4f}\")" ] }, { @@ -1638,7 +1638,7 @@ " covariates=[\"size\", \"age\"]\n", ")\n", "\n", - "print(f\"With covariates: ATT = {results_cov.overall_att:.4f} (SE: {results_cov.overall_se:.4f})\")" + "print(f\"With covariates: ATT = {results_cov.att:.4f} (SE: {results_cov.se:.4f})\")" ] }, { @@ -2008,8 +2008,8 @@ "print(\"=\" * 60)\n", "print(f\"{'Estimator':<30} {'Overall ATT':>12} {'SE':>10}\")\n", "print(\"-\" * 60)\n", - "print(f\"{cs_label:<30} {results_cs.overall_att:>12.4f} {results_cs.overall_se:>10.4f}\")\n", - "print(f\"{'Sun-Abraham':<30} {results_sa.overall_att:>12.4f} {results_sa.overall_se:>10.4f}\")\n", + "print(f\"{cs_label:<30} {results_cs.att:>12.4f} {results_cs.se:>10.4f}\")\n", + "print(f\"{'Sun-Abraham':<30} {results_sa.att:>12.4f} {results_sa.se:>10.4f}\")\n", "\n", "# Also fit CS with universal base period for comparison\n", "cs_universal = CallawaySantAnna(control_group=\"never_treated\", base_period=\"universal\")\n", diff --git a/docs/tutorials/09_real_world_examples.ipynb b/docs/tutorials/09_real_world_examples.ipynb index 5f909e3ef..807907d5b 100644 --- a/docs/tutorials/09_real_world_examples.ipynb +++ b/docs/tutorials/09_real_world_examples.ipynb @@ -1325,8 +1325,8 @@ "print(\"=\" * 60)\n", "\n", "# Overall ATT (simple aggregation is computed automatically)\n", - "print(f\"\\nOverall ATT: {results_cs.overall_att:.4f} (SE: {results_cs.overall_se:.4f})\")\n", - "print(f\"95% CI: [{results_cs.overall_conf_int[0]:.4f}, {results_cs.overall_conf_int[1]:.4f}]\")\n", + "print(f\"\\nOverall ATT: {results_cs.att:.4f} (SE: {results_cs.se:.4f})\")\n", + "print(f\"95% CI: [{results_cs.conf_int[0]:.4f}, {results_cs.conf_int[1]:.4f}]\")\n", "\n", "# By cohort - post-fit aggregation replays the fit-time bootstrap on this\n", "# bootstrapped fit (percentile inference; no refit needed):\n", @@ -1566,8 +1566,8 @@ "print(\"=\" * 60)\n", "print(f\"{'Estimator':<25} {'Overall ATT':>15} {'SE':>10}\")\n", "print(\"-\" * 60)\n", - "print(f\"{cs_name:<25} {results_cs.overall_att:>15.4f} {results_cs.overall_se:>10.4f}\")\n", - "print(f\"{sa_name:<25} {results_sa.overall_att:>15.4f} {results_sa.overall_se:>10.4f}\")\n", + "print(f\"{cs_name:<25} {results_cs.att:>15.4f} {results_cs.se:>10.4f}\")\n", + "print(f\"{sa_name:<25} {results_sa.att:>15.4f} {results_sa.se:>10.4f}\")\n", "# the decomposition reports the TWFE point estimate only\n", "print(f\"{twfe_name:<25} {bacon_results.twfe_estimate:>15.4f} {'--':>10}\")" ] diff --git a/docs/tutorials/11_imputation_did.ipynb b/docs/tutorials/11_imputation_did.ipynb index caad2e959..e771c4749 100644 --- a/docs/tutorials/11_imputation_did.ipynb +++ b/docs/tutorials/11_imputation_did.ipynb @@ -150,8 +150,8 @@ "print(\"-\" * 55)\n", "\n", "for name, r in [(\"ImputationDiD\", imp), (\"CallawaySantAnna\", cs), (\"SunAbraham\", sa)]:\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{name:<25} {r.overall_att:>8.3f} {r.overall_se:>8.3f} {ci_width:>10.3f}\")" + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{name:<25} {r.att:>8.3f} {r.se:>8.3f} {ci_width:>10.3f}\")" ] }, { @@ -196,8 +196,8 @@ "est_antic = ImputationDiD(anticipation=1)\n", "results_antic = est_antic.fit(data, outcome='outcome', unit='unit',\n", " time='period', first_treat='first_treat')\n", - "print(f\"ATT (no anticipation): {results.overall_att:.3f}\")\n", - "print(f\"ATT (1-period anticipation): {results_antic.overall_att:.3f}\")" + "print(f\"ATT (no anticipation): {results.att:.3f}\")\n", + "print(f\"ATT (1-period anticipation): {results_antic.att:.3f}\")" ] }, { @@ -220,7 +220,7 @@ " r = ImputationDiD(aux_partition=partition).fit(\n", " data, outcome='outcome', unit='unit',\n", " time='period', first_treat='first_treat')\n", - " print(f\"aux_partition='{partition}': ATT={r.overall_att:.3f}, SE={r.overall_se:.3f}\")" + " print(f\"aux_partition='{partition}': ATT={r.att:.3f}, SE={r.se:.3f}\")" ] }, { diff --git a/docs/tutorials/12_two_stage_did.ipynb b/docs/tutorials/12_two_stage_did.ipynb index d465953b6..86e686d5a 100644 --- a/docs/tutorials/12_two_stage_did.ipynb +++ b/docs/tutorials/12_two_stage_did.ipynb @@ -168,8 +168,8 @@ "print(\"-\" * 55)\n", "\n", "for name, r in [(\"TwoStageDiD\", ts), (\"ImputationDiD\", imp), (\"CallawaySantAnna\", cs)]:\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{name:<25} {r.overall_att:>8.3f} {r.overall_se:>8.3f} {ci_width:>10.3f}\")" + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{name:<25} {r.att:>8.3f} {r.se:>8.3f} {ci_width:>10.3f}\")" ] }, { @@ -214,8 +214,8 @@ "est_antic = TwoStageDiD(anticipation=1)\n", "results_antic = est_antic.fit(data, outcome='outcome', unit='unit',\n", " time='period', first_treat='first_treat')\n", - "print(f\"ATT (no anticipation): {results.overall_att:.3f}\")\n", - "print(f\"ATT (1-period anticipation): {results_antic.overall_att:.3f}\")" + "print(f\"ATT (no anticipation): {results.att:.3f}\")\n", + "print(f\"ATT (1-period anticipation): {results_antic.att:.3f}\")" ] }, { diff --git a/docs/tutorials/13_stacked_did.ipynb b/docs/tutorials/13_stacked_did.ipynb index 690daadae..a24071c4a 100644 --- a/docs/tutorials/13_stacked_did.ipynb +++ b/docs/tutorials/13_stacked_did.ipynb @@ -216,7 +216,7 @@ " window = f\"[{-kp}, {kq}]\"\n", " incl = str(r.groups)\n", " trim = str(r.trimmed_groups) if r.trimmed_groups else \"[]\"\n", - " print(f\"{window:>10} {incl:>12} {trim:>12} {r.overall_att:>10.3f} {r.overall_se:>10.3f}\")\n", + " print(f\"{window:>10} {incl:>12} {trim:>12} {r.att:>10.3f} {r.se:>10.3f}\")\n", " except ValueError as e:\n", " window = f\"[{-kp}, {kq}]\"\n", " print(f\"{window:>10} All cohorts trimmed - window too wide\")" @@ -246,7 +246,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": "# Asymmetric window recovers cohort 7 — no warning this time\nr_asym = StackedDiD(kappa_pre=3, kappa_post=2).fit(\n data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\nprint(f\"Asymmetric [-3, 2]: groups={r_asym.groups}, trimmed={r_asym.trimmed_groups}\")\nprint(f\"\\nAll 3 cohorts included. ATT={r_asym.overall_att:.3f} (SE={r_asym.overall_se:.3f})\")" + "source": "# Asymmetric window recovers cohort 7 — no warning this time\nr_asym = StackedDiD(kappa_pre=3, kappa_post=2).fit(\n data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\nprint(f\"Asymmetric [-3, 2]: groups={r_asym.groups}, trimmed={r_asym.trimmed_groups}\")\nprint(f\"\\nAll 3 cohorts included. ATT={r_asym.att:.3f} (SE={r_asym.se:.3f})\")" }, { "cell_type": "markdown", @@ -286,8 +286,8 @@ " if scheme == 'population':\n", " fit_kwargs['population'] = 'population'\n", " r = StackedDiD(**kwargs).fit(data, **fit_kwargs)\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{scheme:<16} {r.overall_att:>10.3f} {r.overall_se:>10.3f} {ci_width:>12.3f}\")" + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{scheme:<16} {r.att:>10.3f} {r.se:>10.3f} {ci_width:>12.3f}\")" ] }, { @@ -321,7 +321,7 @@ " r = StackedDiD(kappa_pre=2, kappa_post=2, control_group=cc).fit(\n", " data, outcome='outcome', unit='unit', time='period', first_treat='first_treat'\n", " )\n", - " print(f\"{cc:<18} {r.overall_att:>8.3f} {r.overall_se:>8.3f} {r.n_control_units:>12} {len(r.groups):>10}\")" + " print(f\"{cc:<18} {r.att:>8.3f} {r.se:>8.3f} {r.n_control_units:>12} {len(r.groups):>10}\")" ] }, { @@ -358,8 +358,8 @@ "print(\"-\" * 55)\n", "\n", "for name, r in [(\"StackedDiD\", sd_r), (\"CallawaySantAnna\", cs_r), (\"ImputationDiD\", imp_r)]:\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{name:<25} {r.overall_att:>8.3f} {r.overall_se:>8.3f} {ci_width:>10.3f}\")" + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{name:<25} {r.att:>8.3f} {r.se:>8.3f} {ci_width:>10.3f}\")" ] }, { @@ -388,8 +388,8 @@ "results_antic = est_antic.fit(\n", " data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n", "\n", - "print(f\"ATT (no anticipation): {results_no_antic.overall_att:.3f}\")\n", - "print(f\"ATT (1-period anticipation): {results_antic.overall_att:.3f}\")" + "print(f\"ATT (no anticipation): {results_no_antic.att:.3f}\")\n", + "print(f\"ATT (1-period anticipation): {results_antic.att:.3f}\")" ] }, { @@ -419,8 +419,8 @@ "print(\"=\" * 50)\n", "print(f\"{'Cluster Level':<20} {'ATT':>10} {'SE':>10}\")\n", "print(\"-\" * 50)\n", - "print(f\"{'unit':<20} {r_unit.overall_att:>10.3f} {r_unit.overall_se:>10.3f}\")\n", - "print(f\"{'unit_subexp':<20} {r_subexp.overall_att:>10.3f} {r_subexp.overall_se:>10.3f}\")\n", + "print(f\"{'unit':<20} {r_unit.att:>10.3f} {r_unit.se:>10.3f}\")\n", + "print(f\"{'unit_subexp':<20} {r_subexp.att:>10.3f} {r_subexp.se:>10.3f}\")\n", "print()\n", "print(\"Point estimates are identical; SEs differ due to clustering level.\")" ] diff --git a/docs/tutorials/14_continuous_did.ipynb b/docs/tutorials/14_continuous_did.ipynb index 1f79ae3f9..b63c85c1f 100644 --- a/docs/tutorials/14_continuous_did.ipynb +++ b/docs/tutorials/14_continuous_did.ipynb @@ -212,8 +212,8 @@ "true_acrt_glob = 2.0\n", "\n", "print(\"Parameter comparison:\")\n", - "print(f\" ATT_glob — estimated: {results.overall_att:.4f}, true: {true_att_glob:.4f}, \"\n", - " f\"bias: {results.overall_att - true_att_glob:.4f}\")\n", + "print(f\" ATT_glob — estimated: {results.att:.4f}, true: {true_att_glob:.4f}, \"\n", + " f\"bias: {results.att - true_att_glob:.4f}\")\n", "print(f\" ACRT_glob — estimated: {results.overall_acrt:.4f}, true: {true_acrt_glob:.4f}, \"\n", " f\"bias: {results.overall_acrt - true_acrt_glob:.4f}\")" ] @@ -435,7 +435,7 @@ " est_cfg = ContinuousDiD(degree=cfg[\"degree\"], num_knots=cfg[\"num_knots\"], seed=42)\n", " res_cfg = est_cfg.fit(data_log, \"outcome\", \"unit\", \"period\", \"first_treat\", \"dose\")\n", " spline_results[cfg[\"label\"]] = res_cfg\n", - " print(f\"{cfg['label']:<35} {res_cfg.overall_att:>10.4f} {res_cfg.overall_acrt:>11.4f}\")" + " print(f\"{cfg['label']:<35} {res_cfg.att:>10.4f} {res_cfg.overall_acrt:>11.4f}\")" ] }, { @@ -504,7 +504,7 @@ "for cg in [\"never_treated\", \"not_yet_treated\"]:\n", " est_cg = ContinuousDiD(control_group=cg, seed=42)\n", " res_cg = est_cg.fit(data_mc, \"outcome\", \"unit\", \"period\", \"first_treat\", \"dose\")\n", - " print(f\"{cg:<20} {res_cg.overall_att:>10.4f} {res_cg.overall_att_se:>10.4f} \"\n", + " print(f\"{cg:<20} {res_cg.att:>10.4f} {res_cg.se:>10.4f} \"\n", " f\"{res_cg.overall_acrt:>11.4f} {res_cg.overall_acrt_se:>10.4f}\")" ] }, @@ -543,8 +543,8 @@ "print(f\"{'Method':<15} {'ATT_glob':>10} {'SE':>10} {'95% CI':>25}\")\n", "print(\"-\" * 62)\n", "for label, r in [(\"Analytical\", res_ana), (\"Bootstrap\", res_boot)]:\n", - " ci = r.overall_att_conf_int\n", - " print(f\"{label:<15} {r.overall_att:>10.4f} {r.overall_att_se:>10.4f} \"\n", + " ci = r.conf_int\n", + " print(f\"{label:<15} {r.att:>10.4f} {r.se:>10.4f} \"\n", " f\"[{ci[0]:>8.4f}, {ci[1]:>8.4f}]\")\n", "\n", "print()\n", @@ -587,8 +587,8 @@ "\n", "print(f\"{'Estimator':<25} {'Overall ATT':>12} {'SE':>10}\")\n", "print(\"-\" * 49)\n", - "print(f\"{'ContinuousDiD':<25} {results.overall_att:>12.4f} {results.overall_att_se:>10.4f}\")\n", - "print(f\"{'CallawaySantAnna':<25} {results_binary.overall_att:>12.4f} {results_binary.overall_se:>10.4f}\")\n", + "print(f\"{'ContinuousDiD':<25} {results.att:>12.4f} {results.se:>10.4f}\")\n", + "print(f\"{'CallawaySantAnna':<25} {results_binary.att:>12.4f} {results_binary.se:>10.4f}\")\n", "print()\n", "print(\"Binary DiD gives one number. Continuous DiD gives the full dose-response curve\")\n", "print(\"plus marginal effects (ACRT) — without losing any information.\")" @@ -611,8 +611,8 @@ " alpha=0.15, color='blue')\n", "\n", " # Binary ATT (flat line)\n", - " ax.axhline(results_binary.overall_att, color='red', linestyle='--', linewidth=2,\n", - " label=f'Binary DiD: ATT = {results_binary.overall_att:.2f}')\n", + " ax.axhline(results_binary.att, color='red', linestyle='--', linewidth=2,\n", + " label=f'Binary DiD: ATT = {results_binary.att:.2f}')\n", "\n", " # True curve\n", " ax.plot(att.dose_grid, 1.0 + 2.0 * att.dose_grid, 'k:', linewidth=2,\n", diff --git a/docs/tutorials/15_efficient_did.ipynb b/docs/tutorials/15_efficient_did.ipynb index 60df470c2..768e12553 100644 --- a/docs/tutorials/15_efficient_did.ipynb +++ b/docs/tutorials/15_efficient_did.ipynb @@ -195,8 +195,8 @@ "for name, r in [(\"EDiD (PT-All)\", results_all),\n", " (\"EDiD (PT-Post)\", results_post),\n", " (\"CallawaySantAnna\", results_cs)]:\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{name:<25} {r.overall_att:>10.4f} {r.overall_se:>10.4f} {ci_width:>12.4f}\")\n", + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{name:<25} {r.att:>10.4f} {r.se:>10.4f} {ci_width:>12.4f}\")\n", "print()\n", "print(\"PT-Post and CS produce identical post-treatment ATTs.\")" ] @@ -232,8 +232,8 @@ " r_cs = CallawaySantAnna().fit(\n", " sim_data, outcome='outcome', unit='unit', time='period',\n", " first_treat='first_treat')\n", - " se_edid_list.append(r_edid.overall_se)\n", - " se_cs_list.append(r_cs.overall_se)\n", + " se_edid_list.append(r_edid.se)\n", + " se_cs_list.append(r_cs.se)\n", "\n", "se_edid = np.array(se_edid_list)\n", "se_cs = np.array(se_cs_list)\n", @@ -357,10 +357,10 @@ "print(\"=\" * 70)\n", "print(f\"{'Method':<20} {'ATT':>10} {'SE':>10} {'CI Lower':>12} {'CI Upper':>12}\")\n", "print(\"-\" * 70)\n", - "print(f\"{'Analytical':<20} {results.overall_att:>10.4f} {results.overall_se:>10.4f} \"\n", - " f\"{results.overall_conf_int[0]:>12.4f} {results.overall_conf_int[1]:>12.4f}\")\n", - "print(f\"{'Bootstrap (499)':<20} {results_boot.overall_att:>10.4f} {results_boot.overall_se:>10.4f} \"\n", - " f\"{results_boot.overall_conf_int[0]:>12.4f} {results_boot.overall_conf_int[1]:>12.4f}\")\n", + "print(f\"{'Analytical':<20} {results.att:>10.4f} {results.se:>10.4f} \"\n", + " f\"{results.conf_int[0]:>12.4f} {results.conf_int[1]:>12.4f}\")\n", + "print(f\"{'Bootstrap (499)':<20} {results_boot.att:>10.4f} {results_boot.se:>10.4f} \"\n", + " f\"{results_boot.conf_int[0]:>12.4f} {results_boot.conf_int[1]:>12.4f}\")\n", "print()\n", "\n", "# Compare weight distributions\n", @@ -371,8 +371,8 @@ " bootstrap_weights=wt, seed=42).fit(\n", " data, outcome='outcome', unit='unit', time='period',\n", " first_treat='first_treat')\n", - " print(f\"{wt:<15} SE={r.overall_se:.4f} \"\n", - " f\"CI=[{r.overall_conf_int[0]:.4f}, {r.overall_conf_int[1]:.4f}]\")" + " print(f\"{wt:<15} SE={r.se:.4f} \"\n", + " f\"CI=[{r.conf_int[0]:.4f}, {r.conf_int[1]:.4f}]\")" ] }, { @@ -443,8 +443,8 @@ "print(\"=\" * 55)\n", "print(f\"{'Setting':<30} {'ATT':>10} {'SE':>10}\")\n", "print(\"-\" * 55)\n", - "print(f\"{'No anticipation':<30} {r_no_antic.overall_att:>10.4f} {r_no_antic.overall_se:>10.4f}\")\n", - "print(f\"{'1-period anticipation':<30} {r_antic.overall_att:>10.4f} {r_antic.overall_se:>10.4f}\")\n", + "print(f\"{'No anticipation':<30} {r_no_antic.att:>10.4f} {r_no_antic.se:>10.4f}\")\n", + "print(f\"{'1-period anticipation':<30} {r_antic.att:>10.4f} {r_antic.se:>10.4f}\")\n", "print()\n", "print(\"Anticipation shifts the effective treatment boundary forward,\")\n", "print(\"reclassifying the period before treatment as post-treatment.\")" @@ -490,9 +490,9 @@ "for name, r in [(\"EfficientDiD (PT-All)\", edid_r),\n", " (\"CallawaySantAnna\", cs_r),\n", " (\"ImputationDiD\", imp_r)]:\n", - " ci_width = r.overall_conf_int[1] - r.overall_conf_int[0]\n", - " print(f\"{name:<25} {r.overall_att:>10.4f} {r.overall_se:>10.4f} \"\n", - " f\"{r.overall_p_value:>10.4f} {ci_width:>12.4f}\")" + " ci_width = r.conf_int[1] - r.conf_int[0]\n", + " print(f\"{name:<25} {r.att:>10.4f} {r.se:>10.4f} \"\n", + " f\"{r.p_value:>10.4f} {ci_width:>12.4f}\")" ] }, { diff --git a/docs/tutorials/16_survey_did.ipynb b/docs/tutorials/16_survey_did.ipynb index 9651e9076..a64552e9e 100644 --- a/docs/tutorials/16_survey_did.ipynb +++ b/docs/tutorials/16_survey_did.ipynb @@ -427,15 +427,15 @@ "\n", "print(f\"Overall ATT:\")\n", "print(f\" True (population): {true_att:.4f}\")\n", - "print(f\" Naive estimate: {results_naive.overall_att:.4f} (bias: {(results_naive.overall_att - true_att)/true_att:+.1%})\")\n", - "print(f\" Survey estimate: {results_survey.overall_att:.4f} (bias: {(results_survey.overall_att - true_att)/true_att:+.1%})\")\n", + "print(f\" Naive estimate: {results_naive.att:.4f} (bias: {(results_naive.att - true_att)/true_att:+.1%})\")\n", + "print(f\" Survey estimate: {results_survey.att:.4f} (bias: {(results_survey.att - true_att)/true_att:+.1%})\")\n", "\n", "print(f\"\\nStandard errors:\")\n", - "print(f\" Naive SE: {results_naive.overall_se:.4f}\")\n", - "print(f\" Survey SE: {results_survey.overall_se:.4f} ({results_survey.overall_se / results_naive.overall_se:.1f}x larger)\")\n", + "print(f\" Naive SE: {results_naive.se:.4f}\")\n", + "print(f\" Survey SE: {results_survey.se:.4f} ({results_survey.se / results_naive.se:.1f}x larger)\")\n", "\n", - "ci_n = results_naive.overall_conf_int\n", - "ci_s = results_survey.overall_conf_int\n", + "ci_n = results_naive.conf_int\n", + "ci_s = results_survey.conf_int\n", "covers_n = \"YES\" if ci_n[0] <= true_att <= ci_n[1] else \"NO\"\n", "covers_s = \"YES\" if ci_s[0] <= true_att <= ci_s[1] else \"NO\"\n", "print(f\"\\n95% confidence intervals:\")\n", @@ -588,7 +588,7 @@ ], "source": [ "# How much does ignoring survey design understate standard errors?\n", - "se_ratio = results_survey.overall_se / results_naive.overall_se\n", + "se_ratio = results_survey.se / results_naive.se\n", "\n", "truth = df.attrs[\"dgp_truth\"]\n", "\n", @@ -674,8 +674,8 @@ " pvals_s = dict(zip(es_s.event_time, es_s.p_value))\n", "\n", " # CI coverage (using proper critical values from result objects)\n", - " ci_n = res_n.overall_conf_int\n", - " ci_s = res_s.overall_conf_int\n", + " ci_n = res_n.conf_int\n", + " ci_s = res_s.conf_int\n", " if ci_n[0] <= true_att_sim <= ci_n[1]:\n", " covers_naive += 1\n", " if ci_s[0] <= true_att_sim <= ci_s[1]:\n", @@ -1045,7 +1045,7 @@ ")\n", "\n", "print(\"=== CS without survey design ===\")\n", - "print(f\"Overall ATT: {results_cs_naive.overall_att:.4f} (SE: {results_cs_naive.overall_se:.4f})\")" + "print(f\"Overall ATT: {results_cs_naive.att:.4f} (SE: {results_cs_naive.se:.4f})\")" ] }, { @@ -1400,7 +1400,7 @@ " first_treat='first_treat', survey_design=sd_rep,\n", ")\n", "\n", - "print(f\"Overall ATT: {results_rep.overall_att:.4f} (SE: {results_rep.overall_se:.4f})\")\n", + "print(f\"Overall ATT: {results_rep.att:.4f} (SE: {results_rep.se:.4f})\")\n", "print(f\"Survey d.f.: {results_rep.survey_metadata.df_survey}\")\n", "print(f\"Replicate method: {results_rep.survey_metadata.replicate_method}\")\n", "print(f\"Number of replicates: {results_rep.survey_metadata.n_replicates}\")\n", @@ -1490,8 +1490,8 @@ "\n", "print(f\"{'Method':25s} {'ATT':>10s} {'SE':>10s}\")\n", "print(\"-\" * 47)\n", - "print(f\"{'Subpopulation (correct)':25s} {results_sub.overall_att:>10.4f} {results_sub.overall_se:>10.4f}\")\n", - "print(f\"{'Naive subset (incorrect)':25s} {results_naive_sub.overall_att:>10.4f} {results_naive_sub.overall_se:>10.4f}\")\n", + "print(f\"{'Subpopulation (correct)':25s} {results_sub.att:>10.4f} {results_sub.se:>10.4f}\")\n", + "print(f\"{'Naive subset (incorrect)':25s} {results_naive_sub.att:>10.4f} {results_naive_sub.se:>10.4f}\")\n", "print(f\"\\nThe subpopulation approach preserves the full design structure for\")\n", "print(f\"correct variance estimation. Naive subsetting compounds two errors:\")\n", "print(f\"losing design information and ignoring the survey structure entirely.\")" @@ -1724,7 +1724,7 @@ ")\n", "\n", "print(f\"Repeated Cross-Section with Survey Design:\")\n", - "print(f\" Overall ATT: {results_rc.overall_att:.4f} (SE: {results_rc.overall_se:.4f})\")\n", + "print(f\" Overall ATT: {results_rc.att:.4f} (SE: {results_rc.se:.4f})\")\n", "print(f\" Survey d.f.: {results_rc.survey_metadata.df_survey}\")\n", "print(f\"\\nPoint estimates may differ from the panel version because the\")\n", "print(f\"estimator uses cross-sectional DRDID (Sant'Anna & Zhao 2020, Section 4).\")" diff --git a/docs/tutorials/16_wooldridge_etwfe.ipynb b/docs/tutorials/16_wooldridge_etwfe.ipynb index 8ae559b41..857d904e0 100644 --- a/docs/tutorials/16_wooldridge_etwfe.ipynb +++ b/docs/tutorials/16_wooldridge_etwfe.ipynb @@ -1115,9 +1115,9 @@ "print(\"-\" * 60)\n", "\n", "for name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n", - " ci = est_r.overall_conf_int\n", + " ci = est_r.conf_int\n", " print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n", - " name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n", + " name, est_r.att, est_r.se, ci[0], ci[1]\n", " ))" ] }, @@ -1274,7 +1274,7 @@ "for w in caught:\n", " print(f\"WARNING: {w.message}\\n\")\n", "\n", - "print(f\"overall ATT = {res_at.overall_att:.4f} (true effect: 1.5)\")\n", + "print(f\"overall ATT = {res_at.att:.4f} (true effect: 1.5)\")\n", "print(f\"estimated cohorts = {sorted(int(g) for g in res_at.groups)}\")\n", "print(f\"cells = {sorted((int(g), int(t)) for g, t in res_at.group_time_effects)}\")\n" ] diff --git a/docs/tutorials/17_brand_awareness_survey.ipynb b/docs/tutorials/17_brand_awareness_survey.ipynb index 8a40e8abf..fc13892eb 100644 --- a/docs/tutorials/17_brand_awareness_survey.ipynb +++ b/docs/tutorials/17_brand_awareness_survey.ipynb @@ -1058,7 +1058,7 @@ "stag_es = stag_results.aggregate(\"event_study\")\n", "\n", "print(stag_results)\n", - "print(f\"\\nOverall campaign lift: {stag_results.overall_att:.1f} pp\")\n", + "print(f\"\\nOverall campaign lift: {stag_results.att:.1f} pp\")\n", "print(\"\\nEvent study effects (relative to campaign launch):\")\n", "print(stag_es.to_dataframe()[[\"event_time\", \"att\", \"se\", \"t_stat\", \"p_value\",\n", " \"conf_int_lower\", \"conf_int_upper\",\n", diff --git a/docs/tutorials/18_geo_experiments.ipynb b/docs/tutorials/18_geo_experiments.ipynb index 68696f7e1..8b155f859 100644 --- a/docs/tutorials/18_geo_experiments.ipynb +++ b/docs/tutorials/18_geo_experiments.ipynb @@ -1054,7 +1054,7 @@ " >>> from diff_diff import CallawaySantAnna\n", " >>> cs = CallawaySantAnna()\n", " >>> cs_result = cs.fit(data, ...)\n", - " >>> print(f'SDiD ATT: {results.att:.4f}, CS ATT: {cs_result.overall_att:.4f}')\n", + " >>> print(f'SDiD ATT: {results.att:.4f}, CS ATT: {cs_result.att:.4f}')\n", "\n", "============================================================\n", "\n" diff --git a/docs/tutorials/19_dcdh_marketing_pulse.ipynb b/docs/tutorials/19_dcdh_marketing_pulse.ipynb index a9be989e8..2b2c40f3e 100644 --- a/docs/tutorials/19_dcdh_marketing_pulse.ipynb +++ b/docs/tutorials/19_dcdh_marketing_pulse.ipynb @@ -300,7 +300,7 @@ "cell_type": "markdown", "id": "t19-cell-019", "metadata": {}, - "source": "## Communicating Results to Leadership\n\nA stakeholder-ready summary of the analysis above:\n\n> **Headline.** The pulse campaign lifted weekly checkout sessions by approximately **12 sessions per market per week** while the promo was on (95% CI: 11.3 to 12.8). On a baseline of about 110 weekly sessions per market, that's roughly an **11% lift**. *[Source: `results.overall_att` from Fitting dCDH.]*\n>\n> **Sample size and design.** 60 markets observed for 8 weeks (480 market-weeks). Of those, 38 markets started untreated and switched the promo on at some point during the quarter (joiners), and 22 markets started with the promo on and switched it off (leavers). Method: dCDH (de Chaisemartin & D'Haultfoeuille 2020) - diff-diff's most general estimator built for treatment that can switch on AND off in the same panel. *[Source: switcher counts and panel shape from The Data.]*\n>\n> **Validity evidence.** Two checks supported the result. (a) The joiners-vs-leavers split agreed: joiners produced a +12.1 lift, leavers a +11.9 lift, well within sampling uncertainty of each other and of the headline. (b) The multi-horizon placebos at l = -2 and l = -1 both sat on zero with bootstrap CIs comfortably covering it - parallel pre-trends look credible. *[Sources: joiners/leavers from Fitting dCDH, multi-horizon placebos from the event study.]*\n>\n> **What \"+12 sessions per market per week\" means in business terms.** Across 60 markets and the weeks each one had the promo on, that's the per-market-week lift attributable to the campaign. Translate to your own revenue-per-session to compare against campaign spend, then use the per-market lift estimate to project what scaling the promo to additional markets would deliver.\n>\n> **Practical significance caveat.** The 11% lift is statistically significant (bootstrap p < 0.01 at both post-treatment horizons), and the on-impact effect persists at the second horizon - the pulse worked while it was on. Whether 11% justifies the campaign cost is a business judgment, not a statistical one. *[Sources: dynamic horizons from the event study.]*" + "source": "## Communicating Results to Leadership\n\nA stakeholder-ready summary of the analysis above:\n\n> **Headline.** The pulse campaign lifted weekly checkout sessions by approximately **12 sessions per market per week** while the promo was on (95% CI: 11.3 to 12.8). On a baseline of about 110 weekly sessions per market, that's roughly an **11% lift**. *[Source: `results.att` from Fitting dCDH.]*\n>\n> **Sample size and design.** 60 markets observed for 8 weeks (480 market-weeks). Of those, 38 markets started untreated and switched the promo on at some point during the quarter (joiners), and 22 markets started with the promo on and switched it off (leavers). Method: dCDH (de Chaisemartin & D'Haultfoeuille 2020) - diff-diff's most general estimator built for treatment that can switch on AND off in the same panel. *[Source: switcher counts and panel shape from The Data.]*\n>\n> **Validity evidence.** Two checks supported the result. (a) The joiners-vs-leavers split agreed: joiners produced a +12.1 lift, leavers a +11.9 lift, well within sampling uncertainty of each other and of the headline. (b) The multi-horizon placebos at l = -2 and l = -1 both sat on zero with bootstrap CIs comfortably covering it - parallel pre-trends look credible. *[Sources: joiners/leavers from Fitting dCDH, multi-horizon placebos from the event study.]*\n>\n> **What \"+12 sessions per market per week\" means in business terms.** Across 60 markets and the weeks each one had the promo on, that's the per-market-week lift attributable to the campaign. Translate to your own revenue-per-session to compare against campaign spend, then use the per-market lift estimate to project what scaling the promo to additional markets would deliver.\n>\n> **Practical significance caveat.** The 11% lift is statistically significant (bootstrap p < 0.01 at both post-treatment horizons), and the on-impact effect persists at the second horizon - the pulse worked while it was on. Whether 11% justifies the campaign cost is a business judgment, not a statistical one. *[Sources: dynamic horizons from the event study.]*" }, { "cell_type": "markdown", diff --git a/docs/tutorials/24_staggered_vs_collapsed_power.ipynb b/docs/tutorials/24_staggered_vs_collapsed_power.ipynb index 9af99b1c4..e1479b45c 100644 --- a/docs/tutorials/24_staggered_vs_collapsed_power.ipynb +++ b/docs/tutorials/24_staggered_vs_collapsed_power.ipynb @@ -97,7 +97,7 @@ " warnings.simplefilter(\"ignore\")\n", " r = CallawaySantAnna(control_group=\"never_treated\").fit(\n", " panel, outcome=\"outcome\", unit=\"unit\", time=\"period\", first_treat=\"first_treat\")\n", - " return r.overall_att, r.overall_se, r.overall_conf_int, bool(r.overall_p_value < 0.05)\n", + " return r.att, r.se, r.conf_int, bool(r.p_value < 0.05)\n", "\n", "\n", "def estimands(panel, rollout_start):\n", diff --git a/docs/tutorials/26_composition_drift_calibration.ipynb b/docs/tutorials/26_composition_drift_calibration.ipynb index 258987ac3..5f7e6476c 100644 --- a/docs/tutorials/26_composition_drift_calibration.ipynb +++ b/docs/tutorials/26_composition_drift_calibration.ipynb @@ -471,10 +471,10 @@ "\n", "\n", "def show_att(res, label):\n", - " lo, hi = res.overall_conf_int\n", + " lo, hi = res.conf_int\n", " print(\n", - " f\"{label}: ATT = {res.overall_att * 100:+.2f}pp \"\n", - " f\"(SE {res.overall_se * 100:.2f}, 95% CI [{lo * 100:+.2f}, {hi * 100:+.2f}])\"\n", + " f\"{label}: ATT = {res.att * 100:+.2f}pp \"\n", + " f\"(SE {res.se * 100:.2f}, 95% CI [{lo * 100:+.2f}, {hi * 100:+.2f}])\"\n", " )\n" ] }, @@ -1006,9 +1006,9 @@ " base_period=\"universal\",\n", ")\n", "\n", - "assert np.isclose(res_adapter.overall_att, res_raked.overall_att, rtol=1e-12)\n", - "print(f\"native ATT: {res_raked.overall_att * 100:+.4f}pp\")\n", - "print(f\"adapter ATT: {res_adapter.overall_att * 100:+.4f}pp (exact match)\")\n", + "assert np.isclose(res_adapter.att, res_raked.att, rtol=1e-12)\n", + "print(f\"native ATT: {res_raked.att * 100:+.4f}pp\")\n", + "print(f\"adapter ATT: {res_adapter.att * 100:+.4f}pp (exact match)\")\n", "print(f\"provenance attached: {hasattr(res_adapter, '_balance_adjustment')}\")\n" ] }, @@ -1084,8 +1084,8 @@ " design_columns={\"psu\": \"state\"}, **extra,\n", " )\n", " rows.append({\"weights\": wlabel, \"estimator\": name,\n", - " \"att_pp\": round(r.overall_att * 100, 2),\n", - " \"se_pp\": round(r.overall_se * 100, 2)})\n", + " \"att_pp\": round(r.att * 100, 2),\n", + " \"se_pp\": round(r.se * 100, 2)})\n", "\n", "sweep = pd.DataFrame(rows).pivot(index=\"estimator\", columns=\"weights\",\n", " values=\"att_pp\")\n", diff --git a/docs/tutorials/29_mmm_calibration_pymc.ipynb b/docs/tutorials/29_mmm_calibration_pymc.ipynb index bfa2c12cc..2acd1728f 100644 --- a/docs/tutorials/29_mmm_calibration_pymc.ipynb +++ b/docs/tutorials/29_mmm_calibration_pymc.ipynb @@ -289,7 +289,7 @@ "boost_spend_total = BOOST * frame_mass\n", "true_total = BETA_SEARCH * boost_spend_total\n", "\n", - "print(f\"per-geo-week ATT: {res.overall_att:.2f} (se {res.overall_se:.2f}; truth {BETA_SEARCH * BOOST:.0f})\")\n", + "print(f\"per-geo-week ATT: {res.att:.2f} (se {res.se:.2f}; truth {BETA_SEARCH * BOOST:.0f})\")\n", "print(f\"CS total incremental sales: {total.att[0]:,.0f}\")\n", "print(f\"ImputationDiD total (cross-check): {imp_total.att[0]:,.0f}\")\n", "print(f\"simulated truth: {true_total:,.0f}\")" diff --git a/docs/tutorials/30_mmm_calibration_meridian.ipynb b/docs/tutorials/30_mmm_calibration_meridian.ipynb index 927af01d2..df90cbb99 100644 --- a/docs/tutorials/30_mmm_calibration_meridian.ipynb +++ b/docs/tutorials/30_mmm_calibration_meridian.ipynb @@ -291,7 +291,7 @@ "first_launch = min(geo_start.values())\n", "assert panel.loc[panel.search_spend > 0, \"week\"].min() >= first_launch\n", "\n", - "print(f\"per-geo-week ATT: {res.overall_att:.1f} (truth {BETA_SEARCH * LAUNCH_SPEND:.0f})\")\n", + "print(f\"per-geo-week ATT: {res.att:.1f} (truth {BETA_SEARCH * LAUNCH_SPEND:.0f})\")\n", "print(f\"CS total incremental sales: {total.att[0]:,.0f}\")\n", "print(f\"ImputationDiD total (cross-check): {imp_total.att[0]:,.0f}\")\n", "print(f\"simulated truth: {true_total:,.0f}\")\n", diff --git a/docs/tutorials/32_dml_did.ipynb b/docs/tutorials/32_dml_did.ipynb index 8b968a0b9..d079af3e5 100644 --- a/docs/tutorials/32_dml_did.ipynb +++ b/docs/tutorials/32_dml_did.ipynb @@ -308,7 +308,7 @@ " den += w\n", "true_att = num / den\n", "print(f\"DGP-implied overall ATT: {true_att:.4f}\")\n", - "print(f\"DMLDiD (sieve) estimate: {res.overall_att:.4f} +/- {res.overall_se:.4f}\")\n" + "print(f\"DMLDiD (sieve) estimate: {res.att:.4f} +/- {res.se:.4f}\")\n" ] }, { @@ -466,8 +466,8 @@ "]:\n", " r = DMLDiD(outcome_learner=learner, n_folds=5, seed=42,\n", " base_period=\"universal\").fit(df, **fit_kw)\n", - " comparison.append((name, r.overall_att, r.overall_se,\n", - " abs(r.overall_att - true_att) / r.overall_se))\n", + " comparison.append((name, r.att, r.se,\n", + " abs(r.att - true_att) / r.se))\n", "pd.DataFrame(comparison, columns=[\"outcome learner\", \"ATT\", \"SE\", \"|bias| / SE\"])\n" ] }, @@ -1157,7 +1157,7 @@ "source": [ "res_boot = DMLDiD(outcome_learner=\"sieve\", n_folds=5, seed=42,\n", " n_bootstrap=199, cband=True).fit(df, **fit_kw)\n", - "print(f\"analytical ATT {res.overall_att:.4f} | bootstrap ATT {res_boot.overall_att:.4f}\")\n", + "print(f\"analytical ATT {res.att:.4f} | bootstrap ATT {res_boot.att:.4f}\")\n", "res_boot.aggregate(\"event_study\").to_dataframe()[\n", " [\"event_time\", \"att\", \"conf_int_lower\", \"conf_int_upper\",\n", " \"cband_lower\", \"cband_upper\"]]\n" @@ -1237,7 +1237,7 @@ "\n", "res_rcs = DMLDiD(outcome_learner=\"sieve\", n_folds=5, seed=42,\n", " panel=False).fit(rcs, **fit_kw)\n", - "print(f\"RCS ATT: {res_rcs.overall_att:.4f} +/- {res_rcs.overall_se:.4f}\")\n" + "print(f\"RCS ATT: {res_rcs.att:.4f} +/- {res_rcs.se:.4f}\")\n" ] }, { @@ -1290,7 +1290,7 @@ " rcs, **fit_kw,\n", " survey_design=SurveyDesign(weights=\"w\", strata=\"stratum\", psu=\"psu\"),\n", ")\n", - "print(f\"survey ATT: {res_svy.overall_att:.4f} +/- {res_svy.overall_se:.4f}\")\n", + "print(f\"survey ATT: {res_svy.att:.4f} +/- {res_svy.se:.4f}\")\n", "print(f\"design df: {res_svy.survey_metadata.df_survey} \"\n", " f\"({res_svy.survey_metadata.n_psu} PSUs - \"\n", " f\"{res_svy.survey_metadata.n_strata} strata)\")\n" @@ -1343,9 +1343,9 @@ "a = DMLDiD(outcome_learner=\"sieve\", seed=1, base_period=\"universal\").fit(df, **fit_kw)\n", "b = DMLDiD(outcome_learner=\"sieve\", seed=2, base_period=\"universal\").fit(df, **fit_kw)\n", "a2 = DMLDiD(outcome_learner=\"sieve\", seed=1, base_period=\"universal\").fit(df, **fit_kw)\n", - "print(f\"seed=1: {a.overall_att:.6f}\")\n", - "print(f\"seed=2: {b.overall_att:.6f} (different folds -> different estimate)\")\n", - "print(f\"seed=1 again: {a2.overall_att:.6f} (bit-identical)\")\n" + "print(f\"seed=1: {a.att:.6f}\")\n", + "print(f\"seed=2: {b.att:.6f} (different folds -> different estimate)\")\n", + "print(f\"seed=1 again: {a2.att:.6f} (bit-identical)\")\n" ] }, { diff --git a/tests/test_t19_marketing_pulse_drift.py b/tests/test_t19_marketing_pulse_drift.py index ea91b4b79..69aefc500 100644 --- a/tests/test_t19_marketing_pulse_drift.py +++ b/tests/test_t19_marketing_pulse_drift.py @@ -114,12 +114,12 @@ def test_panel_composition(panel): def test_overall_att_close_to_truth(phase1_results): """Section 3 quotes 'about 12 sessions' headline (true effect = 12).""" - assert 11.7 <= phase1_results.overall_att <= 12.4, phase1_results.overall_att + assert 11.7 <= phase1_results.att <= 12.4, phase1_results.att def test_overall_ci_covers_truth(phase1_results): """Section 3 narrative claims the CI covers the true effect of 12.""" - ci_low, ci_high = phase1_results.overall_conf_int + ci_low, ci_high = phase1_results.conf_int assert ci_low <= TREATMENT_EFFECT <= ci_high, (ci_low, ci_high) @@ -127,7 +127,7 @@ def test_overall_ci_endpoints_match_quoted(phase1_results): """Section 3 narrative quotes '95% CI: 11.3 to 12.8'. Pin the one-decimal display exactly so any drift past the displayed rounding fails this test.""" - ci_low, ci_high = phase1_results.overall_conf_int + ci_low, ci_high = phase1_results.conf_int assert round(ci_low, 1) == 11.3, ci_low assert round(ci_high, 1) == 12.8, ci_high diff --git a/tests/test_t24_staggered_vs_collapsed_power_drift.py b/tests/test_t24_staggered_vs_collapsed_power_drift.py index d705705e2..9744ceda8 100644 --- a/tests/test_t24_staggered_vs_collapsed_power_drift.py +++ b/tests/test_t24_staggered_vs_collapsed_power_drift.py @@ -112,7 +112,7 @@ def fit_cs(panel): time="period", first_treat="first_treat", ) - return r.overall_att, r.overall_se, r.overall_conf_int, bool(r.overall_p_value < 0.05) + return r.att, r.se, r.conf_int, bool(r.p_value < 0.05) def estimands(panel, rollout_start): diff --git a/tests/test_t26_composition_drift_calibration_drift.py b/tests/test_t26_composition_drift_calibration_drift.py index bdbf9e6dc..19b354cb9 100644 --- a/tests/test_t26_composition_drift_calibration_drift.py +++ b/tests/test_t26_composition_drift_calibration_drift.py @@ -299,18 +299,18 @@ def test_planted_and_realized_truth(pipeline): def test_scenario_a_design_weights_robust(pipeline): - att = pipeline["A_design"].overall_att * 100 + att = pipeline["A_design"].att * 100 assert -3.5 <= att <= -2.7, f"scenario A design ATT drifted: {att:.2f}pp" def test_scenario_b_design_weights_overstate(pipeline): res = pipeline["B_design"] - att = res.overall_att * 100 + att = res.att * 100 assert -4.6 <= att <= -3.7, f"scenario B design ATT drifted: {att:.2f}pp" # The quoted story: the design-weight CI EXCLUDES the realized truth # (~-2.98pp; -2.95 is the upper edge of the realized-truth band pinned # in test_planted_and_realized_truth). - hi = res.overall_conf_int[1] + hi = res.conf_int[1] assert hi * 100 < -2.95, ( f"design-weight CI upper bound {hi*100:.2f}pp no longer excludes " "the realized truth (~-2.98pp)" @@ -323,8 +323,8 @@ def test_scenario_b_design_weights_overstate(pipeline): def test_national_rake_is_not_a_fix(pipeline): - att_nat = pipeline["B_national"].overall_att * 100 - att_des = pipeline["B_design"].overall_att * 100 + att_nat = pipeline["B_national"].att * 100 + att_des = pipeline["B_design"].att * 100 realized = pipeline["truth"]["realized_att_pp"] assert -4.9 <= att_nat <= -4.0, f"national-rake ATT drifted: {att_nat:.2f}pp" # Quoted lesson: national raking does NOT move the estimate toward truth. @@ -333,8 +333,8 @@ def test_national_rake_is_not_a_fix(pipeline): def test_state_rake_recovers_truth(pipeline): res = pipeline["B_raked"] - att = res.overall_att * 100 - se = res.overall_se * 100 + att = res.att * 100 + se = res.se * 100 realized = pipeline["truth"]["realized_att_pp"] assert -3.6 <= att <= -2.8, f"state-rake ATT drifted: {att:.2f}pp" assert ( @@ -404,7 +404,7 @@ def test_native_adapter_parity(pipeline): control_group="not_yet_treated", base_period="universal", ) - np.testing.assert_allclose(res_adapter.overall_att, pipeline["B_raked"].overall_att, rtol=1e-12) + np.testing.assert_allclose(res_adapter.att, pipeline["B_raked"].att, rtol=1e-12) assert hasattr(res_adapter, "_balance_adjustment") diff --git a/tests/test_t29_mmm_calibration_pymc_drift.py b/tests/test_t29_mmm_calibration_pymc_drift.py index 73daa008e..4a2aa2db7 100644 --- a/tests/test_t29_mmm_calibration_pymc_drift.py +++ b/tests/test_t29_mmm_calibration_pymc_drift.py @@ -202,7 +202,7 @@ def test_total_and_att_match_notebook(self, fitted): out = notebook_output_text(NB) printed_att = _parse_number(out, r"per-geo-week ATT: ([\d.,-]+)") printed_total = _parse_number(out, r"CS total incremental sales:\s+([\d.,-]+)") - np.testing.assert_allclose(printed_att, res.overall_att, rtol=2e-3) + np.testing.assert_allclose(printed_att, res.att, rtol=2e-3) np.testing.assert_allclose(printed_total, total.att[0], rtol=2e-3) mass = int(((panel.first_treat > 0) & (panel.week >= panel.first_treat)).sum()) assert total.n[0] == mass @@ -225,8 +225,8 @@ def test_lift_row_matches_notebook(self, fitted): ) assert float(df.x[0]) == SEARCH_BASE * N_GEOS == 1500.0 assert float(df.delta_x[0]) == BOOST * g_treated == 450.0 - np.testing.assert_allclose(float(df.delta_y[0]), res.overall_att * g_treated, rtol=1e-12) - np.testing.assert_allclose(float(df.sigma[0]), res.overall_se * g_treated, rtol=1e-12) + np.testing.assert_allclose(float(df.delta_y[0]), res.att * g_treated, rtol=1e-12) + np.testing.assert_allclose(float(df.sigma[0]), res.se * g_treated, rtol=1e-12) out = notebook_output_text(NB) printed_roi = _parse_number(out, r"DiD measurement:\s+([\d.,-]+)") np.testing.assert_allclose(printed_roi, df.delta_y[0] / df.delta_x[0], atol=5e-3) diff --git a/tests/test_t32_dml_did_drift.py b/tests/test_t32_dml_did_drift.py index c66b72264..c12ea042e 100644 --- a/tests/test_t32_dml_did_drift.py +++ b/tests/test_t32_dml_did_drift.py @@ -40,17 +40,17 @@ "9405e25ece47ce8b", "68f3f1e50a2a3c60", "128c08960b3dfd45", - "c48f19ab5c5b76ad", + "23196c712d274b23", "d4eed65cbf569b74", - "8ecbf97a1a817414", + "ec20e769aadf5d89", "98695413c281f252", "84d167bbdd9af3c8", "9efefccaf9379ba7", "c3573fc2030e099a", - "bbd6bc001d063ff8", - "ffe1220f7721cb2f", - "6b746ea09fd9e3da", - "ed87cf8c4a4cd42f", + "cc122ea44a2f98e0", + "74e236f960fd5fd6", + "7780f1338bd5eede", + "f192d80ba989b535", "9ee221cd97948c35", ] @@ -324,8 +324,8 @@ def test_truth_and_sieve_fit(self, sieve_fit): num += w * (2.0 + 0.3 * (t - g)) den += w np.testing.assert_allclose(num / den, 2.2388, atol=5e-4) - np.testing.assert_allclose(sieve_fit.overall_att, 2.2804, atol=5e-4) - np.testing.assert_allclose(sieve_fit.overall_se, 0.0438, atol=5e-4) + np.testing.assert_allclose(sieve_fit.att, 2.2804, atol=5e-4) + np.testing.assert_allclose(sieve_fit.se, 0.0438, atol=5e-4) def test_learner_table(self, panel_df): expected = { @@ -338,8 +338,8 @@ def test_learner_table(self, panel_df): r = DMLDiD(outcome_learner=learner, n_folds=5, seed=42, base_period="universal").fit( panel_df, **FIT_KW ) - np.testing.assert_allclose(r.overall_att, att, atol=5e-4, err_msg=name) - np.testing.assert_allclose(r.overall_se, se, atol=5e-4, err_msg=name) + np.testing.assert_allclose(r.att, att, atol=5e-4, err_msg=name) + np.testing.assert_allclose(r.se, se, atol=5e-4, err_msg=name) def test_honest_bounds(self, sieve_fit): honest = compute_honest_did( @@ -365,7 +365,7 @@ def test_bootstrap_supt_bands(self, panel_df, sieve_fit, monkeypatch): ) # bootstrap overrides inference only; point estimate matches the # analytical fit across base_period (post cells share the g-1 base) - np.testing.assert_allclose(r.overall_att, sieve_fit.overall_att, rtol=1e-12) + np.testing.assert_allclose(r.att, sieve_fit.att, rtol=1e-12) es = r.aggregate("event_study").to_dataframe() row0 = es[es["event_time"] == 0].iloc[0] np.testing.assert_allclose(row0["cband_lower"], 1.9245, atol=5e-4) @@ -435,8 +435,8 @@ def test_rcs_fit(self, rcs_df): r = self._fit_expecting_only_a23( DMLDiD(outcome_learner="sieve", n_folds=5, seed=42, panel=False), rcs_df, **FIT_KW ) - np.testing.assert_allclose(r.overall_att, 2.4428, atol=5e-4) - np.testing.assert_allclose(r.overall_se, 0.2373, atol=5e-4) + np.testing.assert_allclose(r.att, 2.4428, atol=5e-4) + np.testing.assert_allclose(r.se, 0.2373, atol=5e-4) def test_survey_fit(self, rcs_df): r = self._fit_expecting_only_a23( @@ -445,8 +445,8 @@ def test_survey_fit(self, rcs_df): **FIT_KW, survey_design=SurveyDesign(weights="w", strata="stratum", psu="psu"), ) - np.testing.assert_allclose(r.overall_att, 2.2886, atol=5e-4) - np.testing.assert_allclose(r.overall_se, 0.1509, atol=5e-4) + np.testing.assert_allclose(r.att, 2.2886, atol=5e-4) + np.testing.assert_allclose(r.se, 0.1509, atol=5e-4) assert r.survey_metadata.df_survey == 16 assert r.survey_metadata.n_psu == 20 assert r.effective_n_folds is None # 20 PSUs >= 5 folds: no reduction