diff-diff 3.6.0__tar.gz → 3.6.2__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 (98) hide show
  1. {diff_diff-3.6.0 → diff_diff-3.6.2}/PKG-INFO +4 -4
  2. {diff_diff-3.6.0 → diff_diff-3.6.2}/README.md +3 -3
  3. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/__init__.py +1 -1
  4. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/_backend.py +12 -0
  5. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/_nprobust_port.py +13 -0
  6. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/_reporting_helpers.py +20 -0
  7. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/bacon.py +15 -0
  8. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/business_report.py +13 -4
  9. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille.py +13 -6
  10. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_results.py +5 -3
  11. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/continuous_did.py +46 -81
  12. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/diagnostic_report.py +181 -7
  13. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/efficient_did.py +4 -23
  14. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/efficient_did_covariates.py +33 -10
  15. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/estimators.py +112 -52
  16. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/guides/llms-autonomous.txt +15 -6
  17. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/guides/llms-full.txt +15 -11
  18. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/guides/llms-practitioner.txt +6 -3
  19. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/guides/llms.txt +5 -5
  20. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/had.py +89 -44
  21. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/linalg.py +21 -4
  22. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/lpdid.py +461 -59
  23. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/lpdid_results.py +43 -2
  24. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/results.py +5 -3
  25. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/stacked_did.py +4 -0
  26. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered.py +185 -33
  27. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered_aggregation.py +16 -7
  28. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered_results.py +9 -0
  29. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/sun_abraham.py +16 -14
  30. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/survey.py +65 -2
  31. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/synthetic_control.py +198 -21
  32. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/synthetic_control_results.py +717 -72
  33. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/trop.py +153 -22
  34. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/trop_global.py +17 -1
  35. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/trop_local.py +179 -19
  36. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/trop_results.py +33 -2
  37. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/twfe.py +29 -35
  38. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/utils.py +568 -95
  39. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/wooldridge.py +21 -1
  40. {diff_diff-3.6.0 → diff_diff-3.6.2}/pyproject.toml +1 -1
  41. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/Cargo.lock +8 -8
  42. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/Cargo.toml +5 -1
  43. diff_diff-3.6.2/rust/src/alloc_profile.rs +70 -0
  44. diff_diff-3.6.2/rust/src/demean.rs +333 -0
  45. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/src/lib.rs +17 -1
  46. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/src/linalg.rs +70 -48
  47. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/src/trop.rs +212 -87
  48. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/src/weights.rs +227 -61
  49. {diff_diff-3.6.0 → diff_diff-3.6.2}/LICENSE +0 -0
  50. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/_guides_api.py +0 -0
  51. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/agent_workflow.py +0 -0
  52. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/balancing.py +0 -0
  53. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/bootstrap_chunking.py +0 -0
  54. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/bootstrap_utils.py +0 -0
  55. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  56. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/conformal.py +0 -0
  57. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/conley.py +0 -0
  58. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/continuous_did_bspline.py +0 -0
  59. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/continuous_did_results.py +0 -0
  60. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/datasets.py +0 -0
  61. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/diagnostics.py +0 -0
  62. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/efficient_did_bootstrap.py +0 -0
  63. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/efficient_did_results.py +0 -0
  64. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/efficient_did_weights.py +0 -0
  65. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/guides/__init__.py +0 -0
  66. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/had_pretests.py +0 -0
  67. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/honest_did.py +0 -0
  68. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/imputation.py +0 -0
  69. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/imputation_bootstrap.py +0 -0
  70. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/imputation_results.py +0 -0
  71. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/local_linear.py +0 -0
  72. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/power.py +0 -0
  73. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/practitioner.py +0 -0
  74. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/prep.py +0 -0
  75. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/prep_dgp.py +0 -0
  76. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/pretrends.py +0 -0
  77. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/profile.py +0 -0
  78. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/spillover.py +0 -0
  79. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/stacked_did_results.py +0 -0
  80. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered_bootstrap.py +0 -0
  81. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff.py +0 -0
  82. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff_results.py +0 -0
  83. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/synthetic_did.py +0 -0
  84. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/triple_diff.py +0 -0
  85. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/two_stage.py +0 -0
  86. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/two_stage_bootstrap.py +0 -0
  87. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/two_stage_results.py +0 -0
  88. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/__init__.py +0 -0
  89. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_common.py +0 -0
  90. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_continuous.py +0 -0
  91. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_diagnostic.py +0 -0
  92. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_event_study.py +0 -0
  93. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_power.py +0 -0
  94. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_staggered.py +0 -0
  95. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/visualization/_synthetic.py +0 -0
  96. {diff_diff-3.6.0 → diff_diff-3.6.2}/diff_diff/wooldridge_results.py +0 -0
  97. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/build.rs +0 -0
  98. {diff_diff-3.6.0 → diff_diff-3.6.2}/rust/src/bootstrap.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 3.6.0
3
+ Version: 3.6.2
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -155,7 +155,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
155
155
  - [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies
156
156
  - [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - event study design with period-specific treatment effects for dynamic analysis
157
157
  - [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption
158
- - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The only library option for treatments that switch on AND off. Alias `DCDH`.
158
+ - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The most general option for treatments that switch on AND off (see also `LPDiD`/`TROP` `non_absorbing`). Alias `DCDH`.
159
159
  - [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies
160
160
  - [ImputationDiD](https://diff-diff.readthedocs.io/en/stable/api/imputation.html) - Borusyak, Jaravel & Spiess (2024) imputation estimator, most efficient under homogeneous effects
161
161
  - [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html) - Gardner (2022) two-stage estimator with GMM sandwich variance
@@ -170,7 +170,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
170
170
  - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment
171
171
  - [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference) - Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT
172
172
  - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`.
173
- - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing treatment
173
+ - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
174
174
  - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
175
175
 
176
176
  ## Diagnostics & Sensitivity
@@ -198,7 +198,7 @@ No other Python or R DiD package offers design-based variance estimation for mod
198
198
  - Python 3.9 - 3.14
199
199
  - numpy >= 1.20
200
200
  - pandas >= 1.3
201
- - scipy >= 1.7
201
+ - scipy >= 1.10
202
202
 
203
203
  ## Development
204
204
 
@@ -102,7 +102,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
102
102
  - [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies
103
103
  - [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - event study design with period-specific treatment effects for dynamic analysis
104
104
  - [CallawaySantAnna](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Callaway & Sant'Anna (2021) group-time ATT estimator for staggered adoption
105
- - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The only library option for treatments that switch on AND off. Alias `DCDH`.
105
+ - [ChaisemartinDHaultfoeuille](https://diff-diff.readthedocs.io/en/stable/api/chaisemartin_dhaultfoeuille.html) - de Chaisemartin & D'Haultfœuille (2020/2022) for **reversible (non-absorbing) treatments** with multi-horizon event study, normalized effects, cost-benefit delta, sup-t bands, and dynamic placebos. The most general option for treatments that switch on AND off (see also `LPDiD`/`TROP` `non_absorbing`). Alias `DCDH`.
106
106
  - [SunAbraham](https://diff-diff.readthedocs.io/en/stable/api/staggered.html) - Sun & Abraham (2021) interaction-weighted estimator for heterogeneity-robust event studies
107
107
  - [ImputationDiD](https://diff-diff.readthedocs.io/en/stable/api/imputation.html) - Borusyak, Jaravel & Spiess (2024) imputation estimator, most efficient under homogeneous effects
108
108
  - [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html) - Gardner (2022) two-stage estimator with GMM sandwich variance
@@ -117,7 +117,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
117
117
  - [TROP](https://diff-diff.readthedocs.io/en/stable/api/trop.html) - Triply Robust Panel estimator (Athey et al. 2025) with nuclear norm factor adjustment
118
118
  - [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference) - Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT
119
119
  - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`.
120
- - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing treatment
120
+ - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
121
121
  - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
122
122
 
123
123
  ## Diagnostics & Sensitivity
@@ -145,7 +145,7 @@ No other Python or R DiD package offers design-based variance estimation for mod
145
145
  - Python 3.9 - 3.14
146
146
  - numpy >= 1.20
147
147
  - pandas >= 1.3
148
- - scipy >= 1.7
148
+ - scipy >= 1.10
149
149
 
150
150
  ## Development
151
151
 
@@ -301,7 +301,7 @@ ETWFE = WooldridgeDiD
301
301
  DCDH = ChaisemartinDHaultfoeuille
302
302
  HAD = HeterogeneousAdoptionDiD
303
303
 
304
- __version__ = "3.6.0"
304
+ __version__ = "3.6.2"
305
305
  __all__ = [
306
306
  # Estimators
307
307
  "DifferenceInDifferences",
@@ -65,6 +65,14 @@ except ImportError:
65
65
  _rust_sc_weight_fw_weighted_with_convergence = None
66
66
  _rust_backend_info = None
67
67
 
68
+ # FE-absorption MAP demeaning kernel: imported independently so a stale or
69
+ # mixed-version extension missing only this newer symbol degrades to the
70
+ # numpy demeaning engine WITHOUT disabling the older Rust accelerations.
71
+ try:
72
+ from diff_diff._rust_backend import demean_map as _rust_demean_map
73
+ except ImportError:
74
+ _rust_demean_map = None
75
+
68
76
  # Determine final backend based on environment variable and availability
69
77
  if _backend_env == "python":
70
78
  # Force pure Python mode - disable Rust even if available
@@ -73,6 +81,8 @@ if _backend_env == "python":
73
81
  _rust_project_simplex = None
74
82
  _rust_solve_ols = None
75
83
  _rust_compute_robust_vcov = None
84
+ # FE-absorption MAP demeaning kernel
85
+ _rust_demean_map = None
76
86
  # TROP estimator acceleration (local method)
77
87
  _rust_unit_distance_matrix = None
78
88
  _rust_loocv_grid_search = None
@@ -124,6 +134,8 @@ __all__ = [
124
134
  "_rust_project_simplex",
125
135
  "_rust_solve_ols",
126
136
  "_rust_compute_robust_vcov",
137
+ # FE-absorption MAP demeaning kernel
138
+ "_rust_demean_map",
127
139
  # TROP estimator acceleration (local method)
128
140
  "_rust_unit_distance_matrix",
129
141
  "_rust_loocv_grid_search",
@@ -1361,6 +1361,19 @@ def lprobust(
1361
1361
  se_cl = float(np.sqrt((deriv_fact**2) * V_Y_cl[deriv, deriv]))
1362
1362
  se_rb = float(np.sqrt((deriv_fact**2) * V_Y_bc[deriv, deriv]))
1363
1363
 
1364
+ # Cluster-robust variance is unidentified when fewer than two clusters
1365
+ # contribute to the ACTIVE kernel window (``eC = cluster[ind]``): the
1366
+ # between-cluster meat is degenerate, so a finite ``se`` here would report
1367
+ # unidentified clustered inference as if identified. NaN both SEs so any
1368
+ # downstream inference (the ``safe_inference`` gate in
1369
+ # ``bias_corrected_local_linear``; HAD's beta-scale rescale) is NaN-coupled.
1370
+ # Unclustered fits (``eC is None``) are unaffected, and a clustered window
1371
+ # with >= 2 distinct clusters is bit-identical, so the DGP-4 golden parity
1372
+ # is preserved.
1373
+ if eC is not None and len(np.unique(eC)) < 2:
1374
+ se_cl = float("nan")
1375
+ se_rb = float("nan")
1376
+
1364
1377
  # --- Per-observation influence function for the BIAS-CORRECTED point
1365
1378
  # estimate at ``deriv`` (Phase 4.5 survey composition).
1366
1379
  # Aligned with ``V_Y_bc`` (NOT ``V_Y_cl``) so survey-composed variance
@@ -635,6 +635,26 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
635
635
  "reference": "REGISTRY.md Sec. SyntheticControl",
636
636
  }
637
637
 
638
+ if name == "SpilloverDiDResults":
639
+ return {
640
+ "name": "total effect on the treated (Butts spillover-aware ATT)",
641
+ "definition": (
642
+ "The total effect on the treated ``tau_total`` from Butts (2021) "
643
+ "ring-indicator spillover DiD, identified off FAR-AWAY control "
644
+ "observations (``d_it > d_bar``, Assumption 5) rather than any "
645
+ "not-yet-/never-treated pool. The estimator decomposes into the "
646
+ "DIRECT effect on treated units plus per-ring spillover-on-control "
647
+ "effects that relax SUTVA within the treated units' spatial "
648
+ "neighborhood; ``att`` is the headline total effect, while the "
649
+ "per-ring ``spillover_effects`` and (when ``event_study=True``) the "
650
+ "per-event-time direct dynamics are available on the result object "
651
+ "for disaggregated inference."
652
+ ),
653
+ "aggregation": "spillover",
654
+ "headline_attribute": "att",
655
+ "reference": "Butts (2021); REGISTRY.md Sec. SpilloverDiD",
656
+ }
657
+
638
658
  # Default: unrecognized result class. Fall through with a neutral
639
659
  # block — agents / downstream consumers can still dispatch on
640
660
  # ``aggregation="unknown"`` and fall back to generic ATT narration.
@@ -18,6 +18,7 @@ import numpy as np
18
18
  import pandas as pd
19
19
 
20
20
  from diff_diff.results import _format_survey_block
21
+ from diff_diff.utils import pre_demean_norms, snap_absorbed_regressors
21
22
  from diff_diff.utils import within_transform as _within_transform_util
22
23
 
23
24
 
@@ -795,6 +796,7 @@ class BaconDecomposition:
795
796
  ) -> float:
796
797
  """Compute TWFE estimate using within-transformation."""
797
798
  # Apply two-way within transformation (weighted if survey weights provided)
799
+ _pre_norms = pre_demean_norms(df, [treat_col], weights=weights)
798
800
  df_dm = _within_transform_util(
799
801
  df,
800
802
  [outcome, treat_col],
@@ -803,6 +805,19 @@ class BaconDecomposition:
803
805
  suffix="_within",
804
806
  weights=weights,
805
807
  )
808
+ # Snap an FE-spanned treatment to exact zero: the d_var == 0 guard
809
+ # below then returns its deterministic 0.0 (with the cause warning)
810
+ # instead of an arbitrary junk/junk division.
811
+ snap_absorbed_regressors(
812
+ df_dm,
813
+ [treat_col],
814
+ _pre_norms,
815
+ absorbed_desc=f"unit '{unit}' and time '{time}' fixed effects",
816
+ group_vars=[unit, time],
817
+ suffix="_within",
818
+ display_names={treat_col: "treatment"},
819
+ weights=weights,
820
+ )
806
821
 
807
822
  # Extract within-transformed values
808
823
  y_within = df_dm[f"{outcome}_within"].values
@@ -353,12 +353,21 @@ class BusinessReport:
353
353
  """Return a structured multi-section markdown report."""
354
354
  base = _render_full_report(self.to_dict())
355
355
  if self._include_appendix:
356
+ appendix_text = None
356
357
  try:
357
358
  appendix = self._results.summary()
358
- except Exception: # noqa: BLE001
359
- appendix = None
360
- if appendix:
361
- base = base + "\n\n## Technical Appendix\n\n```\n" + str(appendix) + "\n```\n"
359
+ if appendix:
360
+ appendix_text = str(appendix)
361
+ except Exception as exc: # noqa: BLE001
362
+ appendix_error = type(exc).__name__ or "Exception"
363
+ base = (
364
+ base
365
+ + "\n\n## Technical Appendix\n\n"
366
+ + "Technical appendix unavailable: estimator summary rendering failed "
367
+ + f"({appendix_error}).\n"
368
+ )
369
+ if appendix_text:
370
+ base = base + "\n\n## Technical Appendix\n\n```\n" + appendix_text + "\n```\n"
362
371
  return base
363
372
 
364
373
  def export_markdown(self) -> str:
@@ -1,9 +1,14 @@
1
1
  """
2
2
  de Chaisemartin-D'Haultfoeuille (dCDH) estimator for reversible-treatment DiD.
3
3
 
4
- The dCDH estimator is the only modern DiD estimator in the diff-diff library
5
- that handles **non-absorbing (reversible) treatments** — treatment can switch
6
- on AND off over time. All other staggered estimators in the library
4
+ The dCDH estimator is the most general DiD estimator in the diff-diff library
5
+ for **non-absorbing (reversible) treatments** — treatment can switch on AND off
6
+ over time, switcher vs non-switcher comparisons are its primitive object, and it
7
+ allows dynamic (carryover) effects with explicit joiner/leaver (``DID_+`` /
8
+ ``DID_-``) decomposition. ``LPDiD`` (``non_absorbing="first_entry"`` /
9
+ ``"effect_stabilization"``) and ``TROP`` (``non_absorbing=True``, under a
10
+ no-dynamic-effects assumption) also accept non-absorbing treatment under stronger
11
+ assumptions. The remaining staggered estimators in the library
7
12
  (``CallawaySantAnna``, ``SunAbraham``, ``ImputationDiD``, ``TwoStageDiD``,
8
13
  ``EfficientDiD``, ``WooldridgeDiD``) assume treatment is absorbing.
9
14
 
@@ -354,9 +359,11 @@ class ChaisemartinDHaultfoeuille(ChaisemartinDHaultfoeuilleBootstrapMixin):
354
359
  """
355
360
  de Chaisemartin-D'Haultfoeuille (dCDH) estimator.
356
361
 
357
- The only modern DiD estimator in the library that handles **reversible
358
- (non-absorbing) treatments** - treatment may switch on AND off over
359
- time. Computes the contemporaneous-switch DiD ``DID_M`` from the
362
+ The most general library estimator for **reversible (non-absorbing)
363
+ treatments** - treatment may switch on AND off over time, with explicit
364
+ joiner/leaver (``DID_+`` / ``DID_-``) decomposition (``LPDiD`` and ``TROP``
365
+ also support non-absorbing treatment under stronger assumptions; see their
366
+ ``non_absorbing`` parameters). Computes the contemporaneous-switch DiD ``DID_M`` from the
360
367
  AER 2020 paper (equivalently ``DID_1`` at horizon ``l = 1`` of the
361
368
  dynamic companion paper, NBER WP 29873) plus the full multi-horizon
362
369
  event study ``DID_l`` for ``l = 1..L_max`` via the ``L_max`` parameter
@@ -4,9 +4,11 @@ Result containers for the de Chaisemartin-D'Haultfoeuille (dCDH) estimator.
4
4
  This module contains ``ChaisemartinDHaultfoeuilleResults`` and
5
5
  ``DCDHBootstrapResults`` dataclasses produced by the
6
6
  ``ChaisemartinDHaultfoeuille`` (alias ``DCDH``) estimator. The dCDH
7
- estimator is the only modern DiD estimator in the library that handles
8
- non-absorbing (reversible) treatments. Phase 1 ships the contemporaneous-
9
- switch case ``DID_M`` (= ``DID_1`` of the dynamic companion paper).
7
+ estimator is the most general library estimator for non-absorbing
8
+ (reversible) treatments (``LPDiD`` and ``TROP`` also support non-absorbing
9
+ treatment under stronger assumptions; see their ``non_absorbing`` parameters).
10
+ Phase 1 ships the contemporaneous-switch case ``DID_M`` (= ``DID_1`` of the
11
+ dynamic companion paper).
10
12
 
11
13
  References
12
14
  ----------
@@ -31,9 +31,9 @@ from diff_diff.continuous_did_results import (
31
31
  )
32
32
  from diff_diff.linalg import _rank_guarded_inv, solve_ols
33
33
  from diff_diff.survey import (
34
- ResolvedSurveyDesign,
35
34
  _resolve_survey_for_fit,
36
35
  _validate_unit_constant_survey,
36
+ build_unit_first_row_index,
37
37
  compute_survey_vcov,
38
38
  )
39
39
  from diff_diff.utils import safe_inference
@@ -413,8 +413,7 @@ class ContinuousDiD:
413
413
 
414
414
  # Filter out NaN cells (e.g., from zero effective survey mass)
415
415
  gt_results = {
416
- gt: r for gt, r in gt_results.items()
417
- if np.isfinite(r.get("att_glob", np.nan))
416
+ gt: r for gt, r in gt_results.items() if np.isfinite(r.get("att_glob", np.nan))
418
417
  }
419
418
 
420
419
  if len(gt_results) == 0:
@@ -573,9 +572,12 @@ class ContinuousDiD:
573
572
  # Survey df for t-distribution inference (unit-level, not panel-level)
574
573
  _survey_df = analytic.get("df_survey")
575
574
  # Guard: replicate design with undefined df → NaN inference
576
- if (_survey_df is None and resolved_survey is not None
577
- and hasattr(resolved_survey, 'uses_replicate_variance')
578
- and resolved_survey.uses_replicate_variance):
575
+ if (
576
+ _survey_df is None
577
+ and resolved_survey is not None
578
+ and hasattr(resolved_survey, "uses_replicate_variance")
579
+ and resolved_survey.uses_replicate_variance
580
+ ):
579
581
  _survey_df = 0
580
582
 
581
583
  # Recompute survey_metadata from unit-level design so reported
@@ -589,8 +591,7 @@ class ContinuousDiD:
589
591
 
590
592
  # Propagate replicate df override to survey_metadata for display
591
593
  # (but not the df=0 sentinel — keep metadata as None for undefined df)
592
- if (_survey_df is not None and _survey_df != 0
593
- and survey_metadata is not None):
594
+ if _survey_df is not None and _survey_df != 0 and survey_metadata is not None:
594
595
  if survey_metadata.df_survey != _survey_df:
595
596
  survey_metadata.df_survey = _survey_df
596
597
 
@@ -624,30 +625,8 @@ class ContinuousDiD:
624
625
  unit_resolved_es = None
625
626
  if resolved_survey is not None:
626
627
  row_idx = precomp["unit_first_panel_row"]
627
- uw = (
628
- precomp.get("unit_survey_weights")
629
- if precomp.get("unit_survey_weights") is not None
630
- else np.ones(n_units)
631
- )
632
- us = (
633
- resolved_survey.strata[row_idx]
634
- if resolved_survey.strata is not None
635
- else None
636
- )
637
- up = (
638
- resolved_survey.psu[row_idx]
639
- if resolved_survey.psu is not None
640
- else None
641
- )
642
- uf = (
643
- resolved_survey.fpc[row_idx]
644
- if resolved_survey.fpc is not None
645
- else None
646
- )
647
- n_strata_u = len(np.unique(us)) if us is not None else 0
648
- n_psu_u = len(np.unique(up)) if up is not None else 0
649
- unit_resolved_es = resolved_survey.subset_to_units(
650
- row_idx, uw, us, up, uf, n_strata_u, n_psu_u,
628
+ unit_resolved_es = resolved_survey.subset_to_units_by_row_idx(
629
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
651
630
  )
652
631
 
653
632
  for e_val, info_e in event_study_effects.items():
@@ -711,13 +690,21 @@ class ContinuousDiD:
711
690
 
712
691
  # Score-scale: psi = w * if_es (matches TSL bread)
713
692
  psi_es = unit_resolved_es.weights * if_es
714
- variance, _nv = compute_replicate_if_variance(psi_es, unit_resolved_es)
715
- es_se = float(np.sqrt(max(variance, 0.0))) if np.isfinite(variance) else np.nan
693
+ variance, _nv = compute_replicate_if_variance(
694
+ psi_es, unit_resolved_es
695
+ )
696
+ es_se = (
697
+ float(np.sqrt(max(variance, 0.0)))
698
+ if np.isfinite(variance)
699
+ else np.nan
700
+ )
716
701
  else:
717
702
  X_ones_es = np.ones((n_units, 1))
718
703
  tsl_scale_es = float(unit_resolved_es.weights.sum())
719
704
  if_es_tsl = if_es * tsl_scale_es
720
- vcov_es = compute_survey_vcov(X_ones_es, if_es_tsl, unit_resolved_es)
705
+ vcov_es = compute_survey_vcov(
706
+ X_ones_es, if_es_tsl, unit_resolved_es
707
+ )
721
708
  es_se = float(np.sqrt(np.abs(vcov_es[0, 0])))
722
709
  else:
723
710
  es_se = float(np.sqrt(np.sum(if_es**2)))
@@ -831,15 +818,11 @@ class ContinuousDiD:
831
818
  unit_cohorts[i] = unit_first.loc[u, first_treat]
832
819
  dose_vector[i] = unit_first.loc[u, dose]
833
820
 
834
- # Build unit-to-first-panel-row mapping (for subsetting panel-level arrays)
835
- # This maps each unit index to the positional index of its first row in df.
836
- unit_first_panel_row = np.zeros(n_units, dtype=int)
837
- seen_units: set = set()
838
- for pos_idx, (_, row) in enumerate(df.iterrows()):
839
- u = row[unit]
840
- if u not in seen_units:
841
- seen_units.add(u)
842
- unit_first_panel_row[unit_to_idx[u]] = pos_idx
821
+ # Build unit-to-first-panel-row mapping (for subsetting panel-level
822
+ # arrays): the positional index of each unit's first row in df, aligned
823
+ # to ``all_units`` (== ``unit_to_idx`` order since
824
+ # ``unit_to_idx = {u: i for i, u in enumerate(all_units)}``).
825
+ unit_first_panel_row = build_unit_first_row_index(df[unit].values, all_units)
843
826
 
844
827
  # Per-unit survey weights (take first obs per unit from panel data)
845
828
  unit_survey_weights = None
@@ -949,8 +932,10 @@ class ContinuousDiD:
949
932
  # Guard against zero effective mass (e.g., after subpopulation)
950
933
  if np.sum(w_treated) <= 0 or np.sum(w_control) <= 0:
951
934
  return {
952
- "att_glob": np.nan, "acrt_glob": np.nan,
953
- "n_treated": 0, "n_control": 0,
935
+ "att_glob": np.nan,
936
+ "acrt_glob": np.nan,
937
+ "n_treated": 0,
938
+ "n_control": 0,
954
939
  "att_d": np.full(len(dvals), np.nan),
955
940
  "acrt_d": np.full(len(dvals), np.nan),
956
941
  }
@@ -1293,23 +1278,8 @@ class ContinuousDiD:
1293
1278
  # but influence functions are unit-level (n_units). Build a unit-level
1294
1279
  # ResolvedSurveyDesign by subsetting to one obs per unit.
1295
1280
  row_idx = precomp["unit_first_panel_row"]
1296
- unit_weights = precomp.get("unit_survey_weights")
1297
- if unit_weights is None:
1298
- unit_weights = np.ones(n_units)
1299
-
1300
- unit_strata = (
1301
- resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
1302
- )
1303
- unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
1304
- unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
1305
-
1306
- # Count unique strata/PSU in the unit-level subset
1307
- n_strata_unit = len(np.unique(unit_strata)) if unit_strata is not None else 0
1308
- n_psu_unit = len(np.unique(unit_psu)) if unit_psu is not None else 0
1309
-
1310
- unit_resolved = resolved_survey.subset_to_units(
1311
- row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
1312
- n_strata_unit, n_psu_unit,
1281
+ unit_resolved = resolved_survey.subset_to_units_by_row_idx(
1282
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
1313
1283
  )
1314
1284
 
1315
1285
  X_ones = np.ones((n_units, 1))
@@ -1370,7 +1340,11 @@ class ContinuousDiD:
1370
1340
 
1371
1341
  # Return unit-level survey df and resolved design for metadata recomputation
1372
1342
  # Only override with n_valid-based df when replicates were actually dropped
1373
- if resolved_survey is not None and hasattr(resolved_survey, 'uses_replicate_variance') and resolved_survey.uses_replicate_variance:
1343
+ if (
1344
+ resolved_survey is not None
1345
+ and hasattr(resolved_survey, "uses_replicate_variance")
1346
+ and resolved_survey.uses_replicate_variance
1347
+ ):
1374
1348
  if _rep_n_valid < unit_resolved.n_replicates:
1375
1349
  unit_df_survey = _rep_n_valid - 1 if _rep_n_valid > 1 else None
1376
1350
  else:
@@ -1415,7 +1389,11 @@ class ContinuousDiD:
1415
1389
 
1416
1390
  # Reject replicate-weight designs for bootstrap — replicate variance
1417
1391
  # is an analytical alternative to bootstrap, not compatible with it
1418
- if resolved_survey is not None and hasattr(resolved_survey, "uses_replicate_variance") and resolved_survey.uses_replicate_variance:
1392
+ if (
1393
+ resolved_survey is not None
1394
+ and hasattr(resolved_survey, "uses_replicate_variance")
1395
+ and resolved_survey.uses_replicate_variance
1396
+ ):
1419
1397
  raise NotImplementedError(
1420
1398
  "ContinuousDiD bootstrap (n_bootstrap > 0) is not supported "
1421
1399
  "with replicate-weight survey designs. Replicate weights provide "
@@ -1429,22 +1407,9 @@ class ContinuousDiD:
1429
1407
  # Build unit-level ResolvedSurveyDesign for survey-aware bootstrap
1430
1408
  unit_resolved = None
1431
1409
  if resolved_survey is not None:
1432
- from diff_diff.survey import ResolvedSurveyDesign
1433
-
1434
1410
  row_idx = precomp["unit_first_panel_row"]
1435
- unit_weights = precomp.get("unit_survey_weights")
1436
- if unit_weights is None:
1437
- unit_weights = np.ones(n_units)
1438
- unit_strata = (
1439
- resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
1440
- )
1441
- unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
1442
- unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
1443
- n_strata_u = len(np.unique(unit_strata)) if unit_strata is not None else 0
1444
- n_psu_u = len(np.unique(unit_psu)) if unit_psu is not None else 0
1445
- unit_resolved = resolved_survey.subset_to_units(
1446
- row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
1447
- n_strata_u, n_psu_u,
1411
+ unit_resolved = resolved_survey.subset_to_units_by_row_idx(
1412
+ row_idx, unit_weights=precomp.get("unit_survey_weights")
1448
1413
  )
1449
1414
 
1450
1415
  # Generate bootstrap weights — PSU-level when survey design is present
@@ -1682,7 +1647,7 @@ class ContinuousDiD:
1682
1647
  boot_es[e],
1683
1648
  alpha=self.alpha,
1684
1649
  context=f"event study e={e}",
1685
- )
1650
+ )
1686
1651
  es_se[e] = se_e
1687
1652
  es_ci[e] = ci_e
1688
1653
  es_p[e] = p_e