diff-diff 3.5.0__tar.gz → 3.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diff_diff-3.5.0 → diff_diff-3.5.1}/PKG-INFO +2 -2
- {diff_diff-3.5.0 → diff_diff-3.5.1}/README.md +1 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/__init__.py +1 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/business_report.py +3 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/diagnostic_report.py +135 -4
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did.py +96 -32
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_covariates.py +238 -62
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_results.py +5 -2
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/estimators.py +70 -3
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-full.txt +12 -9
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms.txt +1 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/had.py +77 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/practitioner.py +36 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/synthetic_control.py +715 -23
- diff_diff-3.5.1/diff_diff/synthetic_control_results.py +1403 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/twfe.py +39 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/utils.py +151 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/pyproject.toml +1 -1
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/Cargo.lock +5 -5
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/Cargo.toml +1 -1
- diff_diff-3.5.0/diff_diff/synthetic_control_results.py +0 -729
- {diff_diff-3.5.0 → diff_diff-3.5.1}/LICENSE +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_backend.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_guides_api.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_nprobust_port.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_reporting_helpers.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/agent_workflow.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/bacon.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/conley.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/datasets.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/diagnostics.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/__init__.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-autonomous.txt +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-practitioner.txt +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/had_pretests.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/honest_did.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/linalg.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/local_linear.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/power.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/prep.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/prep_dgp.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/pretrends.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/profile.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/spillover.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/stacked_did.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_aggregation.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_bootstrap.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_triple_diff.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_triple_diff_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/survey.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/triple_diff.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_global.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_local.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage_bootstrap.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/__init__.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_common.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_continuous.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_diagnostic.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_event_study.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_power.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_staggered.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_synthetic.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/wooldridge.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/wooldridge_results.py +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/build.rs +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/bootstrap.rs +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/lib.rs +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/linalg.rs +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/trop.rs +0 -0
- {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/weights.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diff-diff
|
|
3
|
-
Version: 3.5.
|
|
3
|
+
Version: 3.5.1
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -161,7 +161,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
|
|
|
161
161
|
- [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html) - Gardner (2022) two-stage estimator with GMM sandwich variance
|
|
162
162
|
- [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html) - Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover on near-control units; handles non-staggered and staggered timing; supports survey-design variance under `survey_design=` for HC1 / CR1 (Wave E.1 Binder TSL) and Conley (Wave E.2 panel-aware stratified-Conley sandwich on per-period PSU totals; extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC — `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=<col>`); `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores (Wave E.3, R `svyrecvar(subset())` form)
|
|
163
163
|
- [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - Synthetic DiD combining standard DiD and synthetic control for few treated units
|
|
164
|
-
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/custom V; in-space placebo permutation inference via `in_space_placebo()`)
|
|
164
|
+
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/cv/inverse-variance/custom V; in-space placebo permutation inference via `in_space_placebo()`, plus ADH-2015 `leave_one_out()` + `in_time_placebo()` robustness)
|
|
165
165
|
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
|
|
166
166
|
- [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves
|
|
167
167
|
- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`.
|
|
@@ -108,7 +108,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
|
|
|
108
108
|
- [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html) - Gardner (2022) two-stage estimator with GMM sandwich variance
|
|
109
109
|
- [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html) - Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover on near-control units; handles non-staggered and staggered timing; supports survey-design variance under `survey_design=` for HC1 / CR1 (Wave E.1 Binder TSL) and Conley (Wave E.2 panel-aware stratified-Conley sandwich on per-period PSU totals; extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC — `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=<col>`); `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores (Wave E.3, R `svyrecvar(subset())` form)
|
|
110
110
|
- [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - Synthetic DiD combining standard DiD and synthetic control for few treated units
|
|
111
|
-
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/custom V; in-space placebo permutation inference via `in_space_placebo()`)
|
|
111
|
+
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/cv/inverse-variance/custom V; in-space placebo permutation inference via `in_space_placebo()`, plus ADH-2015 `leave_one_out()` + `in_time_placebo()` robustness)
|
|
112
112
|
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
|
|
113
113
|
- [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves
|
|
114
114
|
- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`.
|
|
@@ -1019,6 +1019,9 @@ def _lift_robustness(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
1019
1019
|
native_block["pre_rmspe"] = native.get("pre_rmspe")
|
|
1020
1020
|
native_block["weight_concentration"] = native.get("weight_concentration")
|
|
1021
1021
|
native_block["in_space_placebo"] = native.get("in_space_placebo")
|
|
1022
|
+
# ADH-2015 robustness diagnostics (opt-in; "not_run" stub until run).
|
|
1023
|
+
native_block["leave_one_out"] = native.get("leave_one_out")
|
|
1024
|
+
native_block["in_time_placebo"] = native.get("in_time_placebo")
|
|
1022
1025
|
return {
|
|
1023
1026
|
"bacon": {
|
|
1024
1027
|
"status": bacon.get("status"),
|
|
@@ -2152,8 +2152,10 @@ class DiagnosticReport:
|
|
|
2152
2152
|
selected ``lambda_*``).
|
|
2153
2153
|
|
|
2154
2154
|
SyntheticControl: pre-treatment fit (``pre_rmspe``), donor-weight
|
|
2155
|
-
concentration, and — when already computed — the
|
|
2156
|
-
permutation p-value (``in_space_placebo``)
|
|
2155
|
+
concentration, and — each surfaced only when already computed — the
|
|
2156
|
+
in-space placebo permutation p-value (``in_space_placebo``), the ADH-2015
|
|
2157
|
+
leave-one-out donor robustness (``leave_one_out``), and the in-time
|
|
2158
|
+
backdating placebo (``in_time_placebo``).
|
|
2157
2159
|
"""
|
|
2158
2160
|
r = self._results
|
|
2159
2161
|
name = type(r).__name__
|
|
@@ -2251,8 +2253,11 @@ class DiagnosticReport:
|
|
|
2251
2253
|
DR never triggers it implicitly, because it refits one synthetic control
|
|
2252
2254
|
per donor (potentially many nested V searches) and the placebo layer is
|
|
2253
2255
|
opt-in by design. (This differs from SDiD's cheaper in-time-placebo sweep,
|
|
2254
|
-
which ``_sdid_native`` runs inline.)
|
|
2255
|
-
|
|
2256
|
+
which ``_sdid_native`` runs inline.) The ADH-2015 §4 diagnostics —
|
|
2257
|
+
leave-one-out donor robustness (``leave_one_out()``) and the in-time
|
|
2258
|
+
(backdating) placebo (``in_time_placebo()``) — are surfaced the same way:
|
|
2259
|
+
opt-in, reported only once the user has run them (each refits the synthetic
|
|
2260
|
+
control one or more times), else a ``status="not_run"`` stub.
|
|
2256
2261
|
"""
|
|
2257
2262
|
out: Dict[str, Any] = {"status": "ran", "estimator": "SyntheticControl"}
|
|
2258
2263
|
out["pre_rmspe"] = _to_python_float(getattr(r, "pre_rmspe", None))
|
|
@@ -2327,6 +2332,132 @@ class DiagnosticReport:
|
|
|
2327
2332
|
"per donor)."
|
|
2328
2333
|
),
|
|
2329
2334
|
}
|
|
2335
|
+
|
|
2336
|
+
# Leave-one-out donor robustness (ADH 2015 §4): opt-in, surfaced once run.
|
|
2337
|
+
if getattr(r, "_loo_df", None) is not None:
|
|
2338
|
+
loo_status = getattr(r, "_loo_status", None)
|
|
2339
|
+
if loo_status == "ran":
|
|
2340
|
+
att_range = getattr(r, "_loo_att_range", None)
|
|
2341
|
+
out["leave_one_out"] = {
|
|
2342
|
+
"status": "ran",
|
|
2343
|
+
# Headline single-donor-dependence metric: the largest baseline-
|
|
2344
|
+
# relative swing (max |delta_att|). Preferred over att_range, which
|
|
2345
|
+
# can look narrow even when every drop shifts the ATT far from the
|
|
2346
|
+
# full-fit baseline in the same direction.
|
|
2347
|
+
"max_abs_delta_att": _to_python_float(
|
|
2348
|
+
getattr(r, "_loo_max_abs_delta_att", None)
|
|
2349
|
+
),
|
|
2350
|
+
"att_range": (
|
|
2351
|
+
[_to_python_float(att_range[0]), _to_python_float(att_range[1])]
|
|
2352
|
+
if att_range is not None
|
|
2353
|
+
else None
|
|
2354
|
+
),
|
|
2355
|
+
"n_failed": _to_python_scalar(getattr(r, "_loo_n_failed", None)),
|
|
2356
|
+
}
|
|
2357
|
+
else:
|
|
2358
|
+
_loo_reasons = {
|
|
2359
|
+
"treated_fit_nonconverged": (
|
|
2360
|
+
"leave_one_out() was run but the treated unit's own SCM fit "
|
|
2361
|
+
"did not converge at fit time, so the baseline ATT is not a "
|
|
2362
|
+
"valid reference for the leave-one-out deltas."
|
|
2363
|
+
),
|
|
2364
|
+
"too_few_donors": (
|
|
2365
|
+
"leave_one_out() was run but fewer than 2 donors are available "
|
|
2366
|
+
"(dropping one must leave a non-empty pool)."
|
|
2367
|
+
),
|
|
2368
|
+
"all_refits_failed": (
|
|
2369
|
+
"leave_one_out() was run but every donor-drop refit failed to "
|
|
2370
|
+
"converge, so no valid leave-one-out estimate was produced "
|
|
2371
|
+
"(see the status='failed' rows); raise n_starts or loosen the "
|
|
2372
|
+
"optimizer tolerances."
|
|
2373
|
+
),
|
|
2374
|
+
}
|
|
2375
|
+
out["leave_one_out"] = {
|
|
2376
|
+
"status": "infeasible",
|
|
2377
|
+
# Machine-readable code so consumers can distinguish a numerical
|
|
2378
|
+
# convergence failure ("all_refits_failed") from structural
|
|
2379
|
+
# infeasibility ("too_few_donors") without parsing `reason`.
|
|
2380
|
+
"reason_code": loo_status,
|
|
2381
|
+
"reason": _loo_reasons.get(
|
|
2382
|
+
loo_status, "leave_one_out() produced no valid refits."
|
|
2383
|
+
),
|
|
2384
|
+
}
|
|
2385
|
+
else:
|
|
2386
|
+
out["leave_one_out"] = {
|
|
2387
|
+
"status": "not_run",
|
|
2388
|
+
"reason": (
|
|
2389
|
+
"Call results.leave_one_out() to run leave-one-out donor "
|
|
2390
|
+
"robustness (opt-in; refits once per reportably-weighted donor)."
|
|
2391
|
+
),
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
# In-time (backdating) placebo (ADH 2015 §4): opt-in, surfaced once run.
|
|
2395
|
+
if getattr(r, "_in_time_df", None) is not None:
|
|
2396
|
+
in_time_status = getattr(r, "_in_time_status", None)
|
|
2397
|
+
if in_time_status == "ran":
|
|
2398
|
+
itp = r._in_time_df
|
|
2399
|
+
ran = itp[itp["status"] == "ran"] if "status" in itp else itp
|
|
2400
|
+
max_abs_att = float(ran["placebo_att"].abs().max()) if len(ran) else None
|
|
2401
|
+
out["in_time_placebo"] = {
|
|
2402
|
+
"status": "ran",
|
|
2403
|
+
# Full coverage breakdown so a partially-usable sweep is not
|
|
2404
|
+
# overstated: n_dates is the requested grid; n_ran are the usable
|
|
2405
|
+
# placebos; n_failed / n_infeasible are the dropped remainder.
|
|
2406
|
+
"n_dates": _to_python_scalar(int(len(itp))),
|
|
2407
|
+
"n_ran": _to_python_scalar(int(len(ran))),
|
|
2408
|
+
"n_failed": _to_python_scalar(getattr(r, "_in_time_n_failed", None)),
|
|
2409
|
+
"n_infeasible": _to_python_scalar(getattr(r, "_in_time_n_infeasible", None)),
|
|
2410
|
+
"max_abs_placebo_att": _to_python_float(max_abs_att),
|
|
2411
|
+
}
|
|
2412
|
+
else:
|
|
2413
|
+
_it_reasons = {
|
|
2414
|
+
"treated_fit_nonconverged": (
|
|
2415
|
+
"in_time_placebo() was run but the treated unit's own SCM fit "
|
|
2416
|
+
"did not converge at fit time."
|
|
2417
|
+
),
|
|
2418
|
+
"too_few_pre_periods": (
|
|
2419
|
+
"in_time_placebo() was run but there are too few pre-treatment "
|
|
2420
|
+
"periods for any feasible placebo date (need >=3)."
|
|
2421
|
+
),
|
|
2422
|
+
"all_dates_infeasible": (
|
|
2423
|
+
"in_time_placebo() was run but every placebo date was "
|
|
2424
|
+
"infeasible (no pre-fake period, all predictors dropped, or "
|
|
2425
|
+
"the supplied custom_v had zero mass on the surviving "
|
|
2426
|
+
"predictors after truncation)."
|
|
2427
|
+
),
|
|
2428
|
+
"all_dates_failed": (
|
|
2429
|
+
"in_time_placebo() was run but every placebo refit failed to "
|
|
2430
|
+
"converge (none was dimensionally infeasible); raise n_starts "
|
|
2431
|
+
"or loosen the optimizer tolerances."
|
|
2432
|
+
),
|
|
2433
|
+
"all_dates_unusable": (
|
|
2434
|
+
"in_time_placebo() was run but no placebo date produced a usable "
|
|
2435
|
+
"result: some refits failed to converge AND some dates were "
|
|
2436
|
+
"dimensionally infeasible (see n_failed / n_infeasible)."
|
|
2437
|
+
),
|
|
2438
|
+
}
|
|
2439
|
+
out["in_time_placebo"] = {
|
|
2440
|
+
"status": "infeasible",
|
|
2441
|
+
# Machine-readable code distinguishing a numerical convergence
|
|
2442
|
+
# failure ("all_dates_failed") from structural infeasibility
|
|
2443
|
+
# ("all_dates_infeasible" / "too_few_pre_periods") or a mix
|
|
2444
|
+
# ("all_dates_unusable"), without parsing `reason`. The n_failed /
|
|
2445
|
+
# n_infeasible counts give the exact breakdown.
|
|
2446
|
+
"reason_code": in_time_status,
|
|
2447
|
+
"n_failed": _to_python_scalar(getattr(r, "_in_time_n_failed", None)),
|
|
2448
|
+
"n_infeasible": _to_python_scalar(getattr(r, "_in_time_n_infeasible", None)),
|
|
2449
|
+
"reason": _it_reasons.get(
|
|
2450
|
+
in_time_status, "in_time_placebo() produced no valid refits."
|
|
2451
|
+
),
|
|
2452
|
+
}
|
|
2453
|
+
else:
|
|
2454
|
+
out["in_time_placebo"] = {
|
|
2455
|
+
"status": "not_run",
|
|
2456
|
+
"reason": (
|
|
2457
|
+
"Call results.in_time_placebo() to run the in-time (backdating) "
|
|
2458
|
+
"placebo (opt-in; refits per backdated date)."
|
|
2459
|
+
),
|
|
2460
|
+
}
|
|
2330
2461
|
return out
|
|
2331
2462
|
|
|
2332
2463
|
# -- Heterogeneity helpers --------------------------------------------
|
|
@@ -4,8 +4,8 @@ Efficient Difference-in-Differences estimator.
|
|
|
4
4
|
Implements the ATT estimator from Chen, Sant'Anna & Xie (2025).
|
|
5
5
|
Without covariates, achieves the semiparametric efficiency bound via
|
|
6
6
|
closed-form within-group covariances. With covariates, uses a doubly
|
|
7
|
-
robust path with
|
|
8
|
-
kernel-smoothed conditional Omega*(X) (see class docstring for
|
|
7
|
+
robust path with sieve outcome regressions, sieve propensity ratios, and
|
|
8
|
+
kernel-smoothed conditional Omega*(X) (see class docstring for details).
|
|
9
9
|
|
|
10
10
|
Under PT-All the model is overidentified and EDiD exploits this for
|
|
11
11
|
tighter inference; under PT-Post it reduces to the standard
|
|
@@ -139,6 +139,69 @@ def _compute_se_from_eif(
|
|
|
139
139
|
return float(np.sqrt(np.mean(eif**2) / n_units))
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def _hausman_quadratic_form(
|
|
143
|
+
delta: np.ndarray,
|
|
144
|
+
cov_post: np.ndarray,
|
|
145
|
+
cov_all: np.ndarray,
|
|
146
|
+
) -> Tuple[float, int, float, int, bool]:
|
|
147
|
+
"""Hausman statistic from the event-study delta and the two ES covariances.
|
|
148
|
+
|
|
149
|
+
Implements the Theorem A.1 test statistic of Chen, Sant'Anna & Xie (2025,
|
|
150
|
+
arXiv:2506.17729v1). The variance-difference matrix is
|
|
151
|
+
|
|
152
|
+
V = aCov(ES_post) - aCov(ES_all) = cov_post - cov_all
|
|
153
|
+
|
|
154
|
+
(restricted minus efficient, PSD under H0 because the efficient estimator has
|
|
155
|
+
the smaller variance), and the statistic is ``H = delta' V^+ delta`` with
|
|
156
|
+
``delta = ES_post - ES_all``. ``V`` is inverted by Moore-Penrose pseudoinverse
|
|
157
|
+
and the number of strictly positive eigenvalues is used as the chi-square
|
|
158
|
+
degrees of freedom -- a finite-sample safeguard for a non-PSD ``V`` that equals
|
|
159
|
+
``|E|`` (the number of post-treatment horizons) when ``V`` is well-conditioned.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
delta : ndarray, shape (|E|,)
|
|
164
|
+
Event-study difference ``ES_post - ES_all`` (restricted minus efficient).
|
|
165
|
+
cov_post, cov_all : ndarray, shape (|E|, |E|)
|
|
166
|
+
Estimator-scale covariances of the restricted (PT-Post) and efficient
|
|
167
|
+
(PT-All) event-study vectors.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
H : float
|
|
172
|
+
The Hausman statistic (``max(delta' V^+ delta, 0)``); NaN if ``V`` is
|
|
173
|
+
non-finite or has no positive eigenvalues.
|
|
174
|
+
effective_rank : int
|
|
175
|
+
Number of positive eigenvalues of ``V`` (the chi-square degrees of freedom).
|
|
176
|
+
p_value : float
|
|
177
|
+
Upper-tail ``chi2(effective_rank)`` p-value; NaN when ``H`` is NaN.
|
|
178
|
+
n_negative : int
|
|
179
|
+
Number of substantially negative eigenvalues of ``V`` (efficiency-reversal
|
|
180
|
+
diagnostic).
|
|
181
|
+
finite_ok : bool
|
|
182
|
+
False when ``V`` contains non-finite entries.
|
|
183
|
+
"""
|
|
184
|
+
from scipy.stats import chi2
|
|
185
|
+
|
|
186
|
+
V = cov_post - cov_all
|
|
187
|
+
if not np.all(np.isfinite(V)):
|
|
188
|
+
return np.nan, 0, np.nan, 0, False
|
|
189
|
+
|
|
190
|
+
eigvals = np.linalg.eigvalsh(V)
|
|
191
|
+
max_eigval = float(np.max(np.abs(eigvals))) if len(eigvals) > 0 else 0.0
|
|
192
|
+
tol = max(1e-10 * max_eigval, 1e-15)
|
|
193
|
+
|
|
194
|
+
n_negative = int(np.sum(eigvals < -tol))
|
|
195
|
+
effective_rank = int(np.sum(eigvals > tol))
|
|
196
|
+
if effective_rank == 0:
|
|
197
|
+
return np.nan, 0, np.nan, n_negative, True
|
|
198
|
+
|
|
199
|
+
V_pinv = np.linalg.pinv(V, rcond=tol / max_eigval if max_eigval > 0 else 1e-10)
|
|
200
|
+
H = max(float(delta @ V_pinv @ delta), 0.0)
|
|
201
|
+
p_value = float(chi2.sf(H, df=effective_rank))
|
|
202
|
+
return H, effective_rank, p_value, n_negative, True
|
|
203
|
+
|
|
204
|
+
|
|
142
205
|
class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
143
206
|
"""Efficient DiD estimator (Chen, Sant'Anna & Xie 2025).
|
|
144
207
|
|
|
@@ -147,13 +210,15 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
147
210
|
means and covariances.
|
|
148
211
|
|
|
149
212
|
With covariates, uses a doubly robust path: sieve-based propensity
|
|
150
|
-
score ratios (Eq 4.1-4.2),
|
|
151
|
-
|
|
152
|
-
conditional Omega*(X) with
|
|
153
|
-
The DR property ensures
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
213
|
+
score ratios (Eq 4.1-4.2), sieve outcome regressions (polynomial
|
|
214
|
+
basis, AIC/BIC order selection), sieve-estimated inverse propensities
|
|
215
|
+
(algorithm step 4), and kernel-smoothed conditional Omega*(X) with
|
|
216
|
+
per-unit efficient weights (Eq 3.12). The DR property ensures
|
|
217
|
+
consistency if either the outcome regression or the sieve propensity
|
|
218
|
+
ratio is correctly specified; because all nuisances are sieves /
|
|
219
|
+
kernel smoothers (the paper's flexible-nuisance specification), the
|
|
220
|
+
covariate path attains the semiparametric efficiency bound under the
|
|
221
|
+
paper's regularity conditions (see REGISTRY.md).
|
|
157
222
|
|
|
158
223
|
Parameters
|
|
159
224
|
----------
|
|
@@ -199,10 +264,21 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
199
264
|
``control_group="last_cohort"``, also trims the pseudo-control
|
|
200
265
|
period set at ``t >= last_g - anticipation`` (see REGISTRY.md).
|
|
201
266
|
sieve_k_max : int or None
|
|
202
|
-
Maximum polynomial degree for
|
|
203
|
-
|
|
267
|
+
Maximum polynomial degree for the covariate-path sieves — the
|
|
268
|
+
propensity-ratio, inverse-propensity, AND outcome-regression fits all
|
|
269
|
+
use it. None = auto (``floor(n_pos^{1/5})`` over each group's
|
|
270
|
+
positive-weight support ``n_pos`` — the raw group size when unweighted —
|
|
271
|
+
a growing sieve with no fixed ceiling, bounded by ``n_basis < n_pos``;
|
|
272
|
+
zero-weight survey rows do not affect order selection). Only
|
|
273
|
+
used with covariates. ``sieve_k_max=1`` forces every covariate-path
|
|
274
|
+
sieve (outcome regression and both propensity sieves) to degree 1: it
|
|
275
|
+
recovers the pre-sieve linear-OLS *outcome regression* but also
|
|
276
|
+
degree-1-constrains the propensity sieves, so it does not reproduce the
|
|
277
|
+
exact pre-sieve estimator.
|
|
204
278
|
sieve_criterion : str, default ``"bic"``
|
|
205
|
-
Information criterion
|
|
279
|
+
Information criterion (``"aic"`` or ``"bic"``) for the order selection
|
|
280
|
+
of all covariate-path sieves (propensity ratio, inverse propensity, and
|
|
281
|
+
outcome regression).
|
|
206
282
|
ratio_clip : float, default 20.0
|
|
207
283
|
Clip sieve propensity ratios to ``[1/ratio_clip, ratio_clip]``.
|
|
208
284
|
kernel_bandwidth : float or None
|
|
@@ -855,6 +931,8 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
855
931
|
never_treated_mask,
|
|
856
932
|
t_col_val,
|
|
857
933
|
tpre_col_val,
|
|
934
|
+
k_max=self.sieve_k_max,
|
|
935
|
+
criterion=self.sieve_criterion,
|
|
858
936
|
unit_weights=unit_level_weights,
|
|
859
937
|
)
|
|
860
938
|
# m_{g', tpre, 1}(X)
|
|
@@ -869,6 +947,8 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
869
947
|
gp_mask_for_reg,
|
|
870
948
|
tpre_col_val,
|
|
871
949
|
effective_p1_col,
|
|
950
|
+
k_max=self.sieve_k_max,
|
|
951
|
+
criterion=self.sieve_criterion,
|
|
872
952
|
unit_weights=unit_level_weights,
|
|
873
953
|
)
|
|
874
954
|
# r_{g, inf}(X) and r_{g, g'}(X) via sieve (Eq 4.1-4.2)
|
|
@@ -1672,8 +1752,6 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
1672
1752
|
-------
|
|
1673
1753
|
HausmanPretestResult
|
|
1674
1754
|
"""
|
|
1675
|
-
from scipy.stats import chi2
|
|
1676
|
-
|
|
1677
1755
|
# Fit under both assumptions (analytical SEs only, no bootstrap)
|
|
1678
1756
|
common_kwargs = dict(
|
|
1679
1757
|
cluster=cluster,
|
|
@@ -1836,22 +1914,16 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
1836
1914
|
cov_all = (eif_all_mat.T @ eif_all_mat) / (n_units**2)
|
|
1837
1915
|
cov_post = (eif_post_mat.T @ eif_post_mat) / (n_units**2)
|
|
1838
1916
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1917
|
+
H, effective_rank, p_value, n_negative, finite_ok = _hausman_quadratic_form(
|
|
1918
|
+
delta, cov_post, cov_all
|
|
1919
|
+
)
|
|
1920
|
+
if not finite_ok:
|
|
1842
1921
|
warnings.warn(
|
|
1843
1922
|
"Hausman covariance matrix contains non-finite values. " "The test is unreliable.",
|
|
1844
1923
|
UserWarning,
|
|
1845
1924
|
stacklevel=2,
|
|
1846
1925
|
)
|
|
1847
1926
|
return _nan_result()
|
|
1848
|
-
|
|
1849
|
-
# Eigendecompose V — check for non-PSD
|
|
1850
|
-
eigvals = np.linalg.eigvalsh(V)
|
|
1851
|
-
max_eigval = np.max(np.abs(eigvals)) if len(eigvals) > 0 else 0.0
|
|
1852
|
-
tol = max(1e-10 * max_eigval, 1e-15)
|
|
1853
|
-
|
|
1854
|
-
n_negative = int(np.sum(eigvals < -tol))
|
|
1855
1927
|
if n_negative > 0:
|
|
1856
1928
|
warnings.warn(
|
|
1857
1929
|
f"Hausman variance-difference matrix V has {n_negative} "
|
|
@@ -1860,16 +1932,8 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
|
|
|
1860
1932
|
UserWarning,
|
|
1861
1933
|
stacklevel=2,
|
|
1862
1934
|
)
|
|
1863
|
-
|
|
1864
|
-
effective_rank = int(np.sum(eigvals > tol))
|
|
1865
1935
|
if effective_rank == 0:
|
|
1866
1936
|
return _nan_result()
|
|
1867
|
-
|
|
1868
|
-
V_pinv = np.linalg.pinv(V, rcond=tol / max_eigval if max_eigval > 0 else 1e-10)
|
|
1869
|
-
H = float(delta @ V_pinv @ delta)
|
|
1870
|
-
H = max(H, 0.0)
|
|
1871
|
-
|
|
1872
|
-
p_value = float(chi2.sf(H, df=effective_rank))
|
|
1873
1937
|
reject = p_value < alpha
|
|
1874
1938
|
|
|
1875
1939
|
es_details = pd.DataFrame(
|