diff-diff 1.1.0__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diff_diff-1.1.0 → diff_diff-1.2.1}/PKG-INFO +161 -1
- {diff_diff-1.1.0 → diff_diff-1.2.1}/README.md +160 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/__init__.py +16 -1
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/diagnostics.py +59 -8
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/estimators.py +53 -15
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/prep.py +1 -1
- diff_diff-1.2.1/diff_diff/pretrends.py +1067 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/staggered.py +51 -31
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/synthetic_did.py +28 -4
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/twfe.py +2 -15
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/utils.py +1 -1
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/visualization.py +236 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff.egg-info/PKG-INFO +161 -1
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff.egg-info/SOURCES.txt +2 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/pyproject.toml +1 -1
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_estimators.py +518 -0
- diff_diff-1.2.1/tests/test_pretrends.py +813 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_staggered.py +231 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_wild_bootstrap.py +181 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/bacon.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/honest_did.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/power.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/results.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff.egg-info/dependency_links.txt +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff.egg-info/requires.txt +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/diff_diff.egg-info/top_level.txt +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/setup.cfg +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_bacon.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_diagnostics.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_honest_did.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_power.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_prep.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_sun_abraham.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_utils.py +0 -0
- {diff_diff-1.1.0 → diff_diff-1.2.1}/tests/test_visualization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diff-diff
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
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
|
|
101
|
+
__version__ = "1.2.1"
|
|
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(
|
|
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(
|
|
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}"] = {
|
|
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"] = {
|
|
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"] = {
|
|
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
|
-
|
|
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}"
|