diff-diff 1.1.0__tar.gz → 1.2.0__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 (36) hide show
  1. {diff_diff-1.1.0 → diff_diff-1.2.0}/PKG-INFO +161 -1
  2. {diff_diff-1.1.0 → diff_diff-1.2.0}/README.md +160 -0
  3. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/__init__.py +16 -1
  4. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/diagnostics.py +59 -8
  5. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/estimators.py +53 -15
  6. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/prep.py +1 -1
  7. diff_diff-1.2.0/diff_diff/pretrends.py +1067 -0
  8. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/staggered.py +2 -2
  9. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/synthetic_did.py +28 -4
  10. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/twfe.py +2 -15
  11. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/utils.py +1 -1
  12. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/visualization.py +236 -0
  13. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff.egg-info/PKG-INFO +161 -1
  14. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff.egg-info/SOURCES.txt +2 -0
  15. {diff_diff-1.1.0 → diff_diff-1.2.0}/pyproject.toml +1 -1
  16. diff_diff-1.2.0/tests/test_pretrends.py +813 -0
  17. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/bacon.py +0 -0
  18. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/honest_did.py +0 -0
  19. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/power.py +0 -0
  20. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/results.py +0 -0
  21. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff/sun_abraham.py +0 -0
  22. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff.egg-info/dependency_links.txt +0 -0
  23. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff.egg-info/requires.txt +0 -0
  24. {diff_diff-1.1.0 → diff_diff-1.2.0}/diff_diff.egg-info/top_level.txt +0 -0
  25. {diff_diff-1.1.0 → diff_diff-1.2.0}/setup.cfg +0 -0
  26. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_bacon.py +0 -0
  27. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_diagnostics.py +0 -0
  28. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_estimators.py +0 -0
  29. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_honest_did.py +0 -0
  30. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_power.py +0 -0
  31. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_prep.py +0 -0
  32. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_staggered.py +0 -0
  33. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_sun_abraham.py +0 -0
  34. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_utils.py +0 -0
  35. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_visualization.py +0 -0
  36. {diff_diff-1.1.0 → diff_diff-1.2.0}/tests/test_wild_bootstrap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: A library for Difference-in-Differences causal inference analysis
5
5
  Author: diff-diff contributors
6
6
  License-Expression: MIT
@@ -112,6 +112,7 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
112
112
  - **Goodman-Bacon decomposition**: Diagnose TWFE bias by decomposing into 2x2 comparisons
113
113
  - **Placebo tests**: Comprehensive diagnostics including fake timing, fake group, permutation, and leave-one-out tests
114
114
  - **Honest DiD sensitivity analysis**: Rambachan-Roth (2023) bounds and breakdown analysis for parallel trends violations
115
+ - **Pre-trends power analysis**: Roth (2022) minimum detectable violation (MDV) and power curves for pre-trends tests
115
116
  - **Power analysis**: MDE, sample size, and power calculations for study design; simulation-based power for any estimator
116
117
  - **Data prep utilities**: Helper functions for common data preparation tasks
117
118
 
@@ -1256,6 +1257,90 @@ plot_sensitivity(sensitivity, title="Sensitivity to Parallel Trends Violations")
1256
1257
  plot_honest_event_study(event_results, honest_results)
1257
1258
  ```
1258
1259
 
1260
+ ### Pre-Trends Power Analysis (Roth 2022)
1261
+
1262
+ A passing pre-trends test doesn't mean parallel trends holds—it may just mean the test has low power. **Pre-Trends Power Analysis** (Roth 2022) answers: "What violations could my pre-trends test have detected?"
1263
+
1264
+ ```python
1265
+ from diff_diff import PreTrendsPower, MultiPeriodDiD
1266
+
1267
+ # First, fit an event study
1268
+ did = MultiPeriodDiD()
1269
+ event_results = did.fit(
1270
+ data,
1271
+ outcome='outcome',
1272
+ treatment='treated',
1273
+ time='period',
1274
+ post_periods=[5, 6, 7, 8, 9]
1275
+ )
1276
+
1277
+ # Analyze pre-trends test power
1278
+ pt = PreTrendsPower(alpha=0.05, power=0.80)
1279
+ power_results = pt.fit(event_results)
1280
+
1281
+ print(power_results.summary())
1282
+ print(f"Minimum Detectable Violation (MDV): {power_results.mdv:.4f}")
1283
+ print(f"Power to detect violations of size MDV: {power_results.power:.1%}")
1284
+ ```
1285
+
1286
+ **Key concepts:**
1287
+
1288
+ - **Minimum Detectable Violation (MDV)**: Smallest violation magnitude that would be detected with your target power (e.g., 80%). Passing the pre-trends test does NOT rule out violations up to this size.
1289
+ - **Power**: Probability of detecting a violation of given size if it exists.
1290
+ - **Violation types**: Linear trend, constant violation, last-period only, or custom patterns.
1291
+
1292
+ **Power curve visualization:**
1293
+
1294
+ ```python
1295
+ from diff_diff import plot_pretrends_power
1296
+
1297
+ # Generate power curve across violation magnitudes
1298
+ curve = pt.power_curve(event_results)
1299
+
1300
+ # Plot the power curve
1301
+ plot_pretrends_power(curve, title="Pre-Trends Test Power Curve")
1302
+
1303
+ # Or from the curve object directly
1304
+ curve.plot()
1305
+ ```
1306
+
1307
+ **Different violation patterns:**
1308
+
1309
+ ```python
1310
+ # Linear trend violations (default) - most common assumption
1311
+ pt_linear = PreTrendsPower(violation_type='linear')
1312
+
1313
+ # Constant violation in all pre-periods
1314
+ pt_constant = PreTrendsPower(violation_type='constant')
1315
+
1316
+ # Violation only in the last pre-period (sharp break)
1317
+ pt_last = PreTrendsPower(violation_type='last_period')
1318
+
1319
+ # Custom violation pattern
1320
+ custom_weights = np.array([0.1, 0.3, 0.6]) # Increasing violations
1321
+ pt_custom = PreTrendsPower(violation_type='custom', violation_weights=custom_weights)
1322
+ ```
1323
+
1324
+ **Combining with HonestDiD:**
1325
+
1326
+ Pre-trends power analysis and HonestDiD are complementary:
1327
+ 1. **Pre-trends power** tells you what the test could have detected
1328
+ 2. **HonestDiD** tells you how robust your results are to violations
1329
+
1330
+ ```python
1331
+ from diff_diff import HonestDiD, PreTrendsPower
1332
+
1333
+ # If MDV is large relative to your estimated effect, be cautious
1334
+ pt = PreTrendsPower()
1335
+ power_results = pt.fit(event_results)
1336
+ sensitivity = pt.sensitivity_to_honest_did(event_results)
1337
+ print(sensitivity['interpretation'])
1338
+
1339
+ # Use HonestDiD for robust inference
1340
+ honest = HonestDiD(method='relative_magnitude', M=1.0)
1341
+ honest_results = honest.fit(event_results)
1342
+ ```
1343
+
1259
1344
  ### Placebo Tests
1260
1345
 
1261
1346
  Placebo tests help validate the parallel trends assumption by checking whether effects appear where they shouldn't (before treatment or in untreated groups).
@@ -1680,6 +1765,81 @@ HonestDiD(
1680
1765
  | `plot(ax)` | Plot sensitivity analysis |
1681
1766
  | `to_dataframe()` | Convert to pandas DataFrame |
1682
1767
 
1768
+ ### PreTrendsPower
1769
+
1770
+ ```python
1771
+ PreTrendsPower(
1772
+ alpha=0.05, # Significance level for pre-trends test
1773
+ power=0.80, # Target power for MDV calculation
1774
+ violation_type='linear', # 'linear', 'constant', 'last_period', 'custom'
1775
+ violation_weights=None # Custom weights (required if violation_type='custom')
1776
+ )
1777
+ ```
1778
+
1779
+ **fit() Parameters:**
1780
+
1781
+ | Parameter | Type | Description |
1782
+ |-----------|------|-------------|
1783
+ | `results` | MultiPeriodDiDResults | Results from event study |
1784
+ | `M` | float | Specific violation magnitude to evaluate |
1785
+
1786
+ **Methods:**
1787
+
1788
+ | Method | Description |
1789
+ |--------|-------------|
1790
+ | `fit(results, M)` | Compute power analysis for given event study |
1791
+ | `power_at(results, M)` | Compute power for specific violation magnitude |
1792
+ | `power_curve(results, M_grid, n_points)` | Compute power across range of M values |
1793
+ | `sensitivity_to_honest_did(results)` | Compare with HonestDiD analysis |
1794
+
1795
+ ### PreTrendsPowerResults
1796
+
1797
+ **Attributes:**
1798
+
1799
+ | Attribute | Description |
1800
+ |-----------|-------------|
1801
+ | `power` | Power to detect the specified violation |
1802
+ | `mdv` | Minimum detectable violation at target power |
1803
+ | `violation_magnitude` | Violation magnitude (M) tested |
1804
+ | `violation_type` | Type of violation pattern |
1805
+ | `alpha` | Significance level |
1806
+ | `target_power` | Target power level |
1807
+ | `n_pre_periods` | Number of pre-treatment periods |
1808
+ | `test_statistic` | Expected test statistic under violation |
1809
+ | `critical_value` | Critical value for pre-trends test |
1810
+ | `noncentrality` | Non-centrality parameter |
1811
+ | `is_informative` | Heuristic check if test is informative |
1812
+ | `power_adequate` | Whether power meets target |
1813
+
1814
+ **Methods:**
1815
+
1816
+ | Method | Description |
1817
+ |--------|-------------|
1818
+ | `summary()` | Get formatted summary string |
1819
+ | `print_summary()` | Print summary to stdout |
1820
+ | `to_dict()` | Convert to dictionary |
1821
+ | `to_dataframe()` | Convert to pandas DataFrame |
1822
+
1823
+ ### PreTrendsPowerCurve
1824
+
1825
+ **Attributes:**
1826
+
1827
+ | Attribute | Description |
1828
+ |-----------|-------------|
1829
+ | `M_values` | Array of violation magnitudes |
1830
+ | `powers` | Array of power values |
1831
+ | `mdv` | Minimum detectable violation |
1832
+ | `alpha` | Significance level |
1833
+ | `target_power` | Target power level |
1834
+ | `violation_type` | Type of violation pattern |
1835
+
1836
+ **Methods:**
1837
+
1838
+ | Method | Description |
1839
+ |--------|-------------|
1840
+ | `plot(ax, show_mdv, show_target)` | Plot power curve |
1841
+ | `to_dataframe()` | Convert to DataFrame with M and power columns |
1842
+
1683
1843
  ### Data Preparation Functions
1684
1844
 
1685
1845
  #### generate_did_data
@@ -77,6 +77,7 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
77
77
  - **Goodman-Bacon decomposition**: Diagnose TWFE bias by decomposing into 2x2 comparisons
78
78
  - **Placebo tests**: Comprehensive diagnostics including fake timing, fake group, permutation, and leave-one-out tests
79
79
  - **Honest DiD sensitivity analysis**: Rambachan-Roth (2023) bounds and breakdown analysis for parallel trends violations
80
+ - **Pre-trends power analysis**: Roth (2022) minimum detectable violation (MDV) and power curves for pre-trends tests
80
81
  - **Power analysis**: MDE, sample size, and power calculations for study design; simulation-based power for any estimator
81
82
  - **Data prep utilities**: Helper functions for common data preparation tasks
82
83
 
@@ -1221,6 +1222,90 @@ plot_sensitivity(sensitivity, title="Sensitivity to Parallel Trends Violations")
1221
1222
  plot_honest_event_study(event_results, honest_results)
1222
1223
  ```
1223
1224
 
1225
+ ### Pre-Trends Power Analysis (Roth 2022)
1226
+
1227
+ A passing pre-trends test doesn't mean parallel trends holds—it may just mean the test has low power. **Pre-Trends Power Analysis** (Roth 2022) answers: "What violations could my pre-trends test have detected?"
1228
+
1229
+ ```python
1230
+ from diff_diff import PreTrendsPower, MultiPeriodDiD
1231
+
1232
+ # First, fit an event study
1233
+ did = MultiPeriodDiD()
1234
+ event_results = did.fit(
1235
+ data,
1236
+ outcome='outcome',
1237
+ treatment='treated',
1238
+ time='period',
1239
+ post_periods=[5, 6, 7, 8, 9]
1240
+ )
1241
+
1242
+ # Analyze pre-trends test power
1243
+ pt = PreTrendsPower(alpha=0.05, power=0.80)
1244
+ power_results = pt.fit(event_results)
1245
+
1246
+ print(power_results.summary())
1247
+ print(f"Minimum Detectable Violation (MDV): {power_results.mdv:.4f}")
1248
+ print(f"Power to detect violations of size MDV: {power_results.power:.1%}")
1249
+ ```
1250
+
1251
+ **Key concepts:**
1252
+
1253
+ - **Minimum Detectable Violation (MDV)**: Smallest violation magnitude that would be detected with your target power (e.g., 80%). Passing the pre-trends test does NOT rule out violations up to this size.
1254
+ - **Power**: Probability of detecting a violation of given size if it exists.
1255
+ - **Violation types**: Linear trend, constant violation, last-period only, or custom patterns.
1256
+
1257
+ **Power curve visualization:**
1258
+
1259
+ ```python
1260
+ from diff_diff import plot_pretrends_power
1261
+
1262
+ # Generate power curve across violation magnitudes
1263
+ curve = pt.power_curve(event_results)
1264
+
1265
+ # Plot the power curve
1266
+ plot_pretrends_power(curve, title="Pre-Trends Test Power Curve")
1267
+
1268
+ # Or from the curve object directly
1269
+ curve.plot()
1270
+ ```
1271
+
1272
+ **Different violation patterns:**
1273
+
1274
+ ```python
1275
+ # Linear trend violations (default) - most common assumption
1276
+ pt_linear = PreTrendsPower(violation_type='linear')
1277
+
1278
+ # Constant violation in all pre-periods
1279
+ pt_constant = PreTrendsPower(violation_type='constant')
1280
+
1281
+ # Violation only in the last pre-period (sharp break)
1282
+ pt_last = PreTrendsPower(violation_type='last_period')
1283
+
1284
+ # Custom violation pattern
1285
+ custom_weights = np.array([0.1, 0.3, 0.6]) # Increasing violations
1286
+ pt_custom = PreTrendsPower(violation_type='custom', violation_weights=custom_weights)
1287
+ ```
1288
+
1289
+ **Combining with HonestDiD:**
1290
+
1291
+ Pre-trends power analysis and HonestDiD are complementary:
1292
+ 1. **Pre-trends power** tells you what the test could have detected
1293
+ 2. **HonestDiD** tells you how robust your results are to violations
1294
+
1295
+ ```python
1296
+ from diff_diff import HonestDiD, PreTrendsPower
1297
+
1298
+ # If MDV is large relative to your estimated effect, be cautious
1299
+ pt = PreTrendsPower()
1300
+ power_results = pt.fit(event_results)
1301
+ sensitivity = pt.sensitivity_to_honest_did(event_results)
1302
+ print(sensitivity['interpretation'])
1303
+
1304
+ # Use HonestDiD for robust inference
1305
+ honest = HonestDiD(method='relative_magnitude', M=1.0)
1306
+ honest_results = honest.fit(event_results)
1307
+ ```
1308
+
1224
1309
  ### Placebo Tests
1225
1310
 
1226
1311
  Placebo tests help validate the parallel trends assumption by checking whether effects appear where they shouldn't (before treatment or in untreated groups).
@@ -1645,6 +1730,81 @@ HonestDiD(
1645
1730
  | `plot(ax)` | Plot sensitivity analysis |
1646
1731
  | `to_dataframe()` | Convert to pandas DataFrame |
1647
1732
 
1733
+ ### PreTrendsPower
1734
+
1735
+ ```python
1736
+ PreTrendsPower(
1737
+ alpha=0.05, # Significance level for pre-trends test
1738
+ power=0.80, # Target power for MDV calculation
1739
+ violation_type='linear', # 'linear', 'constant', 'last_period', 'custom'
1740
+ violation_weights=None # Custom weights (required if violation_type='custom')
1741
+ )
1742
+ ```
1743
+
1744
+ **fit() Parameters:**
1745
+
1746
+ | Parameter | Type | Description |
1747
+ |-----------|------|-------------|
1748
+ | `results` | MultiPeriodDiDResults | Results from event study |
1749
+ | `M` | float | Specific violation magnitude to evaluate |
1750
+
1751
+ **Methods:**
1752
+
1753
+ | Method | Description |
1754
+ |--------|-------------|
1755
+ | `fit(results, M)` | Compute power analysis for given event study |
1756
+ | `power_at(results, M)` | Compute power for specific violation magnitude |
1757
+ | `power_curve(results, M_grid, n_points)` | Compute power across range of M values |
1758
+ | `sensitivity_to_honest_did(results)` | Compare with HonestDiD analysis |
1759
+
1760
+ ### PreTrendsPowerResults
1761
+
1762
+ **Attributes:**
1763
+
1764
+ | Attribute | Description |
1765
+ |-----------|-------------|
1766
+ | `power` | Power to detect the specified violation |
1767
+ | `mdv` | Minimum detectable violation at target power |
1768
+ | `violation_magnitude` | Violation magnitude (M) tested |
1769
+ | `violation_type` | Type of violation pattern |
1770
+ | `alpha` | Significance level |
1771
+ | `target_power` | Target power level |
1772
+ | `n_pre_periods` | Number of pre-treatment periods |
1773
+ | `test_statistic` | Expected test statistic under violation |
1774
+ | `critical_value` | Critical value for pre-trends test |
1775
+ | `noncentrality` | Non-centrality parameter |
1776
+ | `is_informative` | Heuristic check if test is informative |
1777
+ | `power_adequate` | Whether power meets target |
1778
+
1779
+ **Methods:**
1780
+
1781
+ | Method | Description |
1782
+ |--------|-------------|
1783
+ | `summary()` | Get formatted summary string |
1784
+ | `print_summary()` | Print summary to stdout |
1785
+ | `to_dict()` | Convert to dictionary |
1786
+ | `to_dataframe()` | Convert to pandas DataFrame |
1787
+
1788
+ ### PreTrendsPowerCurve
1789
+
1790
+ **Attributes:**
1791
+
1792
+ | Attribute | Description |
1793
+ |-----------|-------------|
1794
+ | `M_values` | Array of violation magnitudes |
1795
+ | `powers` | Array of power values |
1796
+ | `mdv` | Minimum detectable violation |
1797
+ | `alpha` | Significance level |
1798
+ | `target_power` | Target power level |
1799
+ | `violation_type` | Type of violation pattern |
1800
+
1801
+ **Methods:**
1802
+
1803
+ | Method | Description |
1804
+ |--------|-------------|
1805
+ | `plot(ax, show_mdv, show_target)` | Plot power curve |
1806
+ | `to_dataframe()` | Convert to DataFrame with M and power columns |
1807
+
1648
1808
  ### Data Preparation Functions
1649
1809
 
1650
1810
  #### generate_did_data
@@ -45,6 +45,13 @@ from diff_diff.power import (
45
45
  compute_sample_size,
46
46
  simulate_power,
47
47
  )
48
+ from diff_diff.pretrends import (
49
+ PreTrendsPower,
50
+ PreTrendsPowerCurve,
51
+ PreTrendsPowerResults,
52
+ compute_mdv,
53
+ compute_pretrends_power,
54
+ )
48
55
  from diff_diff.prep import (
49
56
  aggregate_to_cohorts,
50
57
  balance_panel,
@@ -87,10 +94,11 @@ from diff_diff.visualization import (
87
94
  plot_group_effects,
88
95
  plot_honest_event_study,
89
96
  plot_power_curve,
97
+ plot_pretrends_power,
90
98
  plot_sensitivity,
91
99
  )
92
100
 
93
- __version__ = "1.1.0"
101
+ __version__ = "1.2.0"
94
102
  __all__ = [
95
103
  # Estimators
96
104
  "DifferenceInDifferences",
@@ -164,4 +172,11 @@ __all__ = [
164
172
  "compute_sample_size",
165
173
  "simulate_power",
166
174
  "plot_power_curve",
175
+ # Pre-trends power analysis
176
+ "PreTrendsPower",
177
+ "PreTrendsPowerResults",
178
+ "PreTrendsPowerCurve",
179
+ "compute_pretrends_power",
180
+ "compute_mdv",
181
+ "plot_pretrends_power",
167
182
  ]
@@ -625,11 +625,30 @@ def permutation_test(
625
625
  # Handle edge cases where fitting fails
626
626
  permuted_effects[i] = np.nan
627
627
 
628
- # Remove any NaN values
628
+ # Remove any NaN values and track failure rate
629
629
  valid_effects = permuted_effects[~np.isnan(permuted_effects)]
630
+ n_failed = n_permutations - len(valid_effects)
630
631
 
631
632
  if len(valid_effects) == 0:
632
- raise RuntimeError("All permutations failed - check your data")
633
+ raise RuntimeError(
634
+ f"All {n_permutations} permutations failed. This typically occurs when:\n"
635
+ f" - Treatment/control groups are too small for valid permutation\n"
636
+ f" - Data contains collinearity or singular matrices after permutation\n"
637
+ f" - There are too few observations per time period\n"
638
+ f"Consider checking data quality with validate_did_data() from diff_diff.prep."
639
+ )
640
+
641
+ # Warn if significant number of permutations failed
642
+ if n_failed > 0:
643
+ failure_rate = n_failed / n_permutations
644
+ if failure_rate > 0.1:
645
+ import warnings
646
+ warnings.warn(
647
+ f"{n_failed}/{n_permutations} permutations failed ({failure_rate:.1%}). "
648
+ f"Results based on {len(valid_effects)} successful permutations.",
649
+ UserWarning,
650
+ stacklevel=2
651
+ )
633
652
 
634
653
  # Compute p-value: proportion of |permuted| >= |original|
635
654
  p_value = np.mean(np.abs(valid_effects) >= np.abs(original_att))
@@ -736,11 +755,30 @@ def leave_one_out_test(
736
755
  # Skip units that cause fitting issues
737
756
  loo_effects[u] = np.nan
738
757
 
739
- # Remove NaN values for statistics
758
+ # Remove NaN values for statistics and track failures
740
759
  valid_effects = [v for v in loo_effects.values() if not np.isnan(v)]
760
+ n_total = len(loo_effects)
761
+ n_failed = n_total - len(valid_effects)
741
762
 
742
763
  if len(valid_effects) == 0:
743
- raise RuntimeError("All leave-one-out estimates failed")
764
+ raise RuntimeError(
765
+ f"All {n_total} leave-one-out estimates failed. This typically occurs when:\n"
766
+ f" - Removing any single treated unit causes model fitting to fail\n"
767
+ f" - Very few treated units (need at least 2 for LOO)\n"
768
+ f" - Data has collinearity issues that manifest when units are removed\n"
769
+ f"Consider checking data quality and ensuring sufficient treated units."
770
+ )
771
+
772
+ # Warn if significant number of LOO iterations failed
773
+ if n_failed > 0:
774
+ import warnings
775
+ failed_units = [u for u, v in loo_effects.items() if np.isnan(v)]
776
+ warnings.warn(
777
+ f"{n_failed}/{n_total} leave-one-out estimates failed for units: {failed_units}. "
778
+ f"Results based on {len(valid_effects)} successful iterations.",
779
+ UserWarning,
780
+ stacklevel=2
781
+ )
744
782
 
745
783
  # Statistics of LOO distribution
746
784
  mean_effect = np.mean(valid_effects)
@@ -838,8 +876,13 @@ def run_all_placebo_tests(
838
876
  )
839
877
  results[f"fake_timing_{period}"] = test_result
840
878
  except Exception as e:
841
- # Store error info
842
- results[f"fake_timing_{period}"] = {"error": str(e)}
879
+ # Store structured error info for debugging
880
+ results[f"fake_timing_{period}"] = {
881
+ "error": str(e),
882
+ "error_type": type(e).__name__,
883
+ "test_type": "fake_timing",
884
+ "period": period
885
+ }
843
886
 
844
887
  # Permutation test
845
888
  try:
@@ -856,7 +899,11 @@ def run_all_placebo_tests(
856
899
  )
857
900
  results["permutation"] = perm_result
858
901
  except Exception as e:
859
- results["permutation"] = {"error": str(e)}
902
+ results["permutation"] = {
903
+ "error": str(e),
904
+ "error_type": type(e).__name__,
905
+ "test_type": "permutation"
906
+ }
860
907
 
861
908
  # Leave-one-out test
862
909
  try:
@@ -871,6 +918,10 @@ def run_all_placebo_tests(
871
918
  )
872
919
  results["leave_one_out"] = loo_result
873
920
  except Exception as e:
874
- results["leave_one_out"] = {"error": str(e)}
921
+ results["leave_one_out"] = {
922
+ "error": str(e),
923
+ "error_type": type(e).__name__,
924
+ "test_type": "leave_one_out"
925
+ }
875
926
 
876
927
  return results
@@ -19,6 +19,7 @@ import pandas as pd
19
19
 
20
20
  from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
21
21
  from diff_diff.utils import (
22
+ WildBootstrapResults,
22
23
  compute_confidence_interval,
23
24
  compute_p_value,
24
25
  compute_robust_se,
@@ -279,22 +280,9 @@ class DifferenceInDifferences:
279
280
  if self.inference == "wild_bootstrap" and self.cluster is not None:
280
281
  # Wild cluster bootstrap for few-cluster inference
281
282
  cluster_ids = data[self.cluster].values
282
- bootstrap_results = wild_bootstrap_se(
283
- X, y, residuals, cluster_ids,
284
- coefficient_index=att_idx,
285
- n_bootstrap=self.n_bootstrap,
286
- weight_type=self.bootstrap_weights,
287
- alpha=self.alpha,
288
- seed=self.seed,
289
- return_distribution=False
283
+ se, p_value, conf_int, t_stat, vcov, _ = self._run_wild_bootstrap_inference(
284
+ X, y, residuals, cluster_ids, att_idx
290
285
  )
291
- self._bootstrap_results = bootstrap_results
292
- se = bootstrap_results.se
293
- p_value = bootstrap_results.p_value
294
- conf_int = (bootstrap_results.ci_lower, bootstrap_results.ci_upper)
295
- t_stat = bootstrap_results.t_stat_original
296
- # Also compute vcov for storage (using cluster-robust for consistency)
297
- vcov = compute_robust_se(X, residuals, cluster_ids)
298
286
  elif self.cluster is not None:
299
287
  cluster_ids = data[self.cluster].values
300
288
  vcov = compute_robust_se(X, residuals, cluster_ids)
@@ -408,6 +396,56 @@ class DifferenceInDifferences:
408
396
 
409
397
  return coefficients, residuals, fitted, r_squared
410
398
 
399
+ def _run_wild_bootstrap_inference(
400
+ self,
401
+ X: np.ndarray,
402
+ y: np.ndarray,
403
+ residuals: np.ndarray,
404
+ cluster_ids: np.ndarray,
405
+ coefficient_index: int,
406
+ ) -> Tuple[float, float, Tuple[float, float], float, np.ndarray, WildBootstrapResults]:
407
+ """
408
+ Run wild cluster bootstrap inference.
409
+
410
+ Parameters
411
+ ----------
412
+ X : np.ndarray
413
+ Design matrix.
414
+ y : np.ndarray
415
+ Outcome vector.
416
+ residuals : np.ndarray
417
+ OLS residuals.
418
+ cluster_ids : np.ndarray
419
+ Cluster identifiers for each observation.
420
+ coefficient_index : int
421
+ Index of the coefficient to compute inference for.
422
+
423
+ Returns
424
+ -------
425
+ tuple
426
+ (se, p_value, conf_int, t_stat, vcov, bootstrap_results)
427
+ """
428
+ bootstrap_results = wild_bootstrap_se(
429
+ X, y, residuals, cluster_ids,
430
+ coefficient_index=coefficient_index,
431
+ n_bootstrap=self.n_bootstrap,
432
+ weight_type=self.bootstrap_weights,
433
+ alpha=self.alpha,
434
+ seed=self.seed,
435
+ return_distribution=False
436
+ )
437
+ self._bootstrap_results = bootstrap_results
438
+
439
+ se = bootstrap_results.se
440
+ p_value = bootstrap_results.p_value
441
+ conf_int = (bootstrap_results.ci_lower, bootstrap_results.ci_upper)
442
+ t_stat = bootstrap_results.t_stat_original
443
+
444
+ # Also compute vcov for storage (using cluster-robust for consistency)
445
+ vcov = compute_robust_se(X, residuals, cluster_ids)
446
+
447
+ return se, p_value, conf_int, t_stat, vcov, bootstrap_results
448
+
411
449
  def _parse_formula(
412
450
  self, formula: str, data: pd.DataFrame
413
451
  ) -> Tuple[str, str, str, Optional[List[str]]]:
@@ -601,7 +601,7 @@ def summarize_did_data(
601
601
  if len(time_vals) == 2:
602
602
  pre_val, post_val = time_vals[0], time_vals[1]
603
603
 
604
- def format_label(x):
604
+ def format_label(x: tuple) -> str:
605
605
  treatment_label = 'Treated' if x[0] == 1 else 'Control'
606
606
  time_label = 'Post' if x[1] == post_val else 'Pre'
607
607
  return f"{treatment_label} - {time_label}"