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.
Files changed (92) hide show
  1. {diff_diff-3.5.0 → diff_diff-3.5.1}/PKG-INFO +2 -2
  2. {diff_diff-3.5.0 → diff_diff-3.5.1}/README.md +1 -1
  3. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/__init__.py +1 -1
  4. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/business_report.py +3 -0
  5. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/diagnostic_report.py +135 -4
  6. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did.py +96 -32
  7. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_covariates.py +238 -62
  8. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_results.py +5 -2
  9. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/estimators.py +70 -3
  10. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-full.txt +12 -9
  11. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms.txt +1 -1
  12. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/had.py +77 -0
  13. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/practitioner.py +36 -0
  14. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/synthetic_control.py +715 -23
  15. diff_diff-3.5.1/diff_diff/synthetic_control_results.py +1403 -0
  16. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/twfe.py +39 -1
  17. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/utils.py +151 -1
  18. {diff_diff-3.5.0 → diff_diff-3.5.1}/pyproject.toml +1 -1
  19. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/Cargo.lock +5 -5
  20. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/Cargo.toml +1 -1
  21. diff_diff-3.5.0/diff_diff/synthetic_control_results.py +0 -729
  22. {diff_diff-3.5.0 → diff_diff-3.5.1}/LICENSE +0 -0
  23. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_backend.py +0 -0
  24. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_guides_api.py +0 -0
  25. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_nprobust_port.py +0 -0
  26. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/_reporting_helpers.py +0 -0
  27. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/agent_workflow.py +0 -0
  28. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/bacon.py +0 -0
  29. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/bootstrap_utils.py +0 -0
  30. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
  31. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  32. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  33. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/conley.py +0 -0
  34. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did.py +0 -0
  35. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did_bspline.py +0 -0
  36. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/continuous_did_results.py +0 -0
  37. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/datasets.py +0 -0
  38. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/diagnostics.py +0 -0
  39. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_bootstrap.py +0 -0
  40. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/efficient_did_weights.py +0 -0
  41. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/__init__.py +0 -0
  42. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-autonomous.txt +0 -0
  43. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/guides/llms-practitioner.txt +0 -0
  44. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/had_pretests.py +0 -0
  45. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/honest_did.py +0 -0
  46. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation.py +0 -0
  47. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation_bootstrap.py +0 -0
  48. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/imputation_results.py +0 -0
  49. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/linalg.py +0 -0
  50. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/local_linear.py +0 -0
  51. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/power.py +0 -0
  52. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/prep.py +0 -0
  53. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/prep_dgp.py +0 -0
  54. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/pretrends.py +0 -0
  55. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/profile.py +0 -0
  56. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/results.py +0 -0
  57. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/spillover.py +0 -0
  58. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/stacked_did.py +0 -0
  59. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/stacked_did_results.py +0 -0
  60. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered.py +0 -0
  61. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_aggregation.py +0 -0
  62. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_bootstrap.py +0 -0
  63. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_results.py +0 -0
  64. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_triple_diff.py +0 -0
  65. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/staggered_triple_diff_results.py +0 -0
  66. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/sun_abraham.py +0 -0
  67. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/survey.py +0 -0
  68. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/synthetic_did.py +0 -0
  69. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/triple_diff.py +0 -0
  70. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop.py +0 -0
  71. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_global.py +0 -0
  72. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_local.py +0 -0
  73. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/trop_results.py +0 -0
  74. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage.py +0 -0
  75. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage_bootstrap.py +0 -0
  76. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/two_stage_results.py +0 -0
  77. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/__init__.py +0 -0
  78. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_common.py +0 -0
  79. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_continuous.py +0 -0
  80. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_diagnostic.py +0 -0
  81. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_event_study.py +0 -0
  82. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_power.py +0 -0
  83. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_staggered.py +0 -0
  84. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/visualization/_synthetic.py +0 -0
  85. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/wooldridge.py +0 -0
  86. {diff_diff-3.5.0 → diff_diff-3.5.1}/diff_diff/wooldridge_results.py +0 -0
  87. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/build.rs +0 -0
  88. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/bootstrap.rs +0 -0
  89. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/lib.rs +0 -0
  90. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/linalg.rs +0 -0
  91. {diff_diff-3.5.0 → diff_diff-3.5.1}/rust/src/trop.rs +0 -0
  92. {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.0
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`.
@@ -298,7 +298,7 @@ ETWFE = WooldridgeDiD
298
298
  DCDH = ChaisemartinDHaultfoeuille
299
299
  HAD = HeterogeneousAdoptionDiD
300
300
 
301
- __version__ = "3.5.0"
301
+ __version__ = "3.5.1"
302
302
  __all__ = [
303
303
  # Estimators
304
304
  "DifferenceInDifferences",
@@ -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 in-space placebo
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.) Only the in-space placebo is exposed;
2255
- in-time placebo and leave-one-out are ADH 2015 (not implemented).
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 OLS outcome regression, sieve propensity ratios, and
8
- kernel-smoothed conditional Omega*(X) (see class docstring for caveats).
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), OLS outcome regression, sieve-estimated
151
- inverse propensities (algorithm step 4), and kernel-smoothed
152
- conditional Omega*(X) with per-unit efficient weights (Eq 3.12).
153
- The DR property ensures consistency if either the OLS outcome model
154
- or the sieve propensity ratio is correctly specified. The OLS
155
- working model for outcome regressions does not generically guarantee
156
- the semiparametric efficiency bound (see REGISTRY.md).
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 sieve ratio estimation. None = auto
203
- (``min(floor(n_gp^{1/5}), 5)``). Only used with covariates.
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 for sieve degree selection: ``"aic"`` or ``"bic"``.
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
- V = cov_post - cov_all
1840
-
1841
- if not np.all(np.isfinite(V)):
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(