diff-diff 1.2.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.2.0 → diff_diff-1.2.1}/PKG-INFO +1 -1
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/__init__.py +1 -1
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/staggered.py +49 -29
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/PKG-INFO +1 -1
- {diff_diff-1.2.0 → diff_diff-1.2.1}/pyproject.toml +1 -1
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_estimators.py +518 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_staggered.py +231 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_wild_bootstrap.py +181 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/README.md +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/bacon.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/diagnostics.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/estimators.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/honest_did.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/power.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/prep.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/pretrends.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/results.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/twfe.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/utils.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/visualization.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/SOURCES.txt +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/dependency_links.txt +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/requires.txt +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/top_level.txt +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/setup.cfg +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_bacon.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_diagnostics.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_honest_did.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_power.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_prep.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_pretrends.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_sun_abraham.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_utils.py +0 -0
- {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_visualization.py +0 -0
|
@@ -1603,30 +1603,25 @@ class CallawaySantAnna:
|
|
|
1603
1603
|
weights * bootstrap_atts_gt[b, gt_indices]
|
|
1604
1604
|
)
|
|
1605
1605
|
|
|
1606
|
-
# Compute bootstrap statistics
|
|
1607
|
-
# ATT(g,t) statistics
|
|
1606
|
+
# Compute bootstrap statistics for ATT(g,t)
|
|
1608
1607
|
gt_ses = {}
|
|
1609
1608
|
gt_cis = {}
|
|
1610
1609
|
gt_p_values = {}
|
|
1611
1610
|
|
|
1612
1611
|
for j, gt in enumerate(gt_pairs):
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
se = float(np.std(boot_dist, ddof=1))
|
|
1617
|
-
ci = self._compute_percentile_ci(boot_dist, self.alpha)
|
|
1618
|
-
p_value = self._compute_bootstrap_pvalue(original_effect, boot_dist)
|
|
1619
|
-
|
|
1612
|
+
se, ci, p_value = self._compute_effect_bootstrap_stats(
|
|
1613
|
+
original_atts[j], bootstrap_atts_gt[:, j]
|
|
1614
|
+
)
|
|
1620
1615
|
gt_ses[gt] = se
|
|
1621
1616
|
gt_cis[gt] = ci
|
|
1622
1617
|
gt_p_values[gt] = p_value
|
|
1623
1618
|
|
|
1624
|
-
#
|
|
1625
|
-
overall_se =
|
|
1626
|
-
|
|
1627
|
-
|
|
1619
|
+
# Compute bootstrap statistics for overall ATT
|
|
1620
|
+
overall_se, overall_ci, overall_p_value = self._compute_effect_bootstrap_stats(
|
|
1621
|
+
original_overall, bootstrap_overall
|
|
1622
|
+
)
|
|
1628
1623
|
|
|
1629
|
-
#
|
|
1624
|
+
# Compute bootstrap statistics for event study effects
|
|
1630
1625
|
event_study_ses = None
|
|
1631
1626
|
event_study_cis = None
|
|
1632
1627
|
event_study_p_values = None
|
|
@@ -1637,16 +1632,14 @@ class CallawaySantAnna:
|
|
|
1637
1632
|
event_study_p_values = {}
|
|
1638
1633
|
|
|
1639
1634
|
for e in rel_periods:
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
event_study_ses[e] = float(np.std(boot_dist, ddof=1))
|
|
1644
|
-
event_study_cis[e] = self._compute_percentile_ci(boot_dist, self.alpha)
|
|
1645
|
-
event_study_p_values[e] = self._compute_bootstrap_pvalue(
|
|
1646
|
-
original_effect, boot_dist
|
|
1635
|
+
se, ci, p_value = self._compute_effect_bootstrap_stats(
|
|
1636
|
+
event_study_info[e]['effect'], bootstrap_event_study[e]
|
|
1647
1637
|
)
|
|
1638
|
+
event_study_ses[e] = se
|
|
1639
|
+
event_study_cis[e] = ci
|
|
1640
|
+
event_study_p_values[e] = p_value
|
|
1648
1641
|
|
|
1649
|
-
#
|
|
1642
|
+
# Compute bootstrap statistics for group effects
|
|
1650
1643
|
group_effect_ses = None
|
|
1651
1644
|
group_effect_cis = None
|
|
1652
1645
|
group_effect_p_values = None
|
|
@@ -1657,14 +1650,12 @@ class CallawaySantAnna:
|
|
|
1657
1650
|
group_effect_p_values = {}
|
|
1658
1651
|
|
|
1659
1652
|
for g in groups:
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
group_effect_ses[g] = float(np.std(boot_dist, ddof=1))
|
|
1664
|
-
group_effect_cis[g] = self._compute_percentile_ci(boot_dist, self.alpha)
|
|
1665
|
-
group_effect_p_values[g] = self._compute_bootstrap_pvalue(
|
|
1666
|
-
original_effect, boot_dist
|
|
1653
|
+
se, ci, p_value = self._compute_effect_bootstrap_stats(
|
|
1654
|
+
group_agg_info[g]['effect'], bootstrap_group[g]
|
|
1667
1655
|
)
|
|
1656
|
+
group_effect_ses[g] = se
|
|
1657
|
+
group_effect_cis[g] = ci
|
|
1658
|
+
group_effect_p_values[g] = p_value
|
|
1668
1659
|
|
|
1669
1660
|
return CSBootstrapResults(
|
|
1670
1661
|
n_bootstrap=self.n_bootstrap,
|
|
@@ -1817,6 +1808,35 @@ class CallawaySantAnna:
|
|
|
1817
1808
|
|
|
1818
1809
|
return float(p_value)
|
|
1819
1810
|
|
|
1811
|
+
def _compute_effect_bootstrap_stats(
|
|
1812
|
+
self,
|
|
1813
|
+
original_effect: float,
|
|
1814
|
+
boot_dist: np.ndarray,
|
|
1815
|
+
) -> Tuple[float, Tuple[float, float], float]:
|
|
1816
|
+
"""
|
|
1817
|
+
Compute bootstrap statistics for a single effect.
|
|
1818
|
+
|
|
1819
|
+
Parameters
|
|
1820
|
+
----------
|
|
1821
|
+
original_effect : float
|
|
1822
|
+
Original point estimate.
|
|
1823
|
+
boot_dist : np.ndarray
|
|
1824
|
+
Bootstrap distribution of the effect.
|
|
1825
|
+
|
|
1826
|
+
Returns
|
|
1827
|
+
-------
|
|
1828
|
+
se : float
|
|
1829
|
+
Bootstrap standard error.
|
|
1830
|
+
ci : Tuple[float, float]
|
|
1831
|
+
Percentile confidence interval.
|
|
1832
|
+
p_value : float
|
|
1833
|
+
Bootstrap p-value.
|
|
1834
|
+
"""
|
|
1835
|
+
se = float(np.std(boot_dist, ddof=1))
|
|
1836
|
+
ci = self._compute_percentile_ci(boot_dist, self.alpha)
|
|
1837
|
+
p_value = self._compute_bootstrap_pvalue(original_effect, boot_dist)
|
|
1838
|
+
return se, ci, p_value
|
|
1839
|
+
|
|
1820
1840
|
def get_params(self) -> Dict[str, Any]:
|
|
1821
1841
|
"""Get estimator parameters (sklearn-compatible)."""
|
|
1822
1842
|
return {
|
|
@@ -2090,6 +2090,159 @@ class TestSyntheticDiD:
|
|
|
2090
2090
|
assert results1.att == results2.att
|
|
2091
2091
|
assert results1.se == results2.se
|
|
2092
2092
|
|
|
2093
|
+
def test_insufficient_pre_periods_warning(self):
|
|
2094
|
+
"""Test that SDID warns with very few pre-treatment periods."""
|
|
2095
|
+
np.random.seed(42)
|
|
2096
|
+
|
|
2097
|
+
# Create data with only 2 pre-treatment periods
|
|
2098
|
+
n_control = 8
|
|
2099
|
+
n_periods = 4 # 2 pre, 2 post
|
|
2100
|
+
post_periods = [2, 3]
|
|
2101
|
+
|
|
2102
|
+
data = []
|
|
2103
|
+
# Treated unit
|
|
2104
|
+
for t in range(n_periods):
|
|
2105
|
+
y = 10.0 + t * 0.5 + np.random.normal(0, 0.3)
|
|
2106
|
+
if t in post_periods:
|
|
2107
|
+
y += 3.0
|
|
2108
|
+
data.append({
|
|
2109
|
+
"unit": 0,
|
|
2110
|
+
"period": t,
|
|
2111
|
+
"outcome": y,
|
|
2112
|
+
"treated": 1,
|
|
2113
|
+
})
|
|
2114
|
+
|
|
2115
|
+
# Control units
|
|
2116
|
+
for unit in range(1, n_control + 1):
|
|
2117
|
+
for t in range(n_periods):
|
|
2118
|
+
y = 8.0 + unit * 0.2 + t * 0.4 + np.random.normal(0, 0.3)
|
|
2119
|
+
data.append({
|
|
2120
|
+
"unit": unit,
|
|
2121
|
+
"period": t,
|
|
2122
|
+
"outcome": y,
|
|
2123
|
+
"treated": 0,
|
|
2124
|
+
})
|
|
2125
|
+
|
|
2126
|
+
df = pd.DataFrame(data)
|
|
2127
|
+
|
|
2128
|
+
sdid = SyntheticDiD(n_bootstrap=30, seed=42)
|
|
2129
|
+
|
|
2130
|
+
# Should work but may warn about few pre-periods
|
|
2131
|
+
# (Depending on implementation - some may warn, some may not)
|
|
2132
|
+
results = sdid.fit(
|
|
2133
|
+
df,
|
|
2134
|
+
outcome="outcome",
|
|
2135
|
+
treatment="treated",
|
|
2136
|
+
unit="unit",
|
|
2137
|
+
time="period",
|
|
2138
|
+
post_periods=post_periods
|
|
2139
|
+
)
|
|
2140
|
+
|
|
2141
|
+
# Results should still be valid
|
|
2142
|
+
assert np.isfinite(results.att)
|
|
2143
|
+
assert results.se > 0
|
|
2144
|
+
|
|
2145
|
+
def test_single_pre_period_edge_case(self):
|
|
2146
|
+
"""Test SDID with single pre-treatment period (extreme edge case)."""
|
|
2147
|
+
np.random.seed(42)
|
|
2148
|
+
|
|
2149
|
+
n_control = 5
|
|
2150
|
+
n_periods = 3 # 1 pre, 2 post
|
|
2151
|
+
post_periods = [1, 2]
|
|
2152
|
+
|
|
2153
|
+
data = []
|
|
2154
|
+
# Treated unit
|
|
2155
|
+
for t in range(n_periods):
|
|
2156
|
+
y = 10.0 + np.random.normal(0, 0.2)
|
|
2157
|
+
if t in post_periods:
|
|
2158
|
+
y += 2.0
|
|
2159
|
+
data.append({
|
|
2160
|
+
"unit": 0,
|
|
2161
|
+
"period": t,
|
|
2162
|
+
"outcome": y,
|
|
2163
|
+
"treated": 1,
|
|
2164
|
+
})
|
|
2165
|
+
|
|
2166
|
+
# Control units
|
|
2167
|
+
for unit in range(1, n_control + 1):
|
|
2168
|
+
for t in range(n_periods):
|
|
2169
|
+
y = 9.0 + np.random.normal(0, 0.2)
|
|
2170
|
+
data.append({
|
|
2171
|
+
"unit": unit,
|
|
2172
|
+
"period": t,
|
|
2173
|
+
"outcome": y,
|
|
2174
|
+
"treated": 0,
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
df = pd.DataFrame(data)
|
|
2178
|
+
|
|
2179
|
+
sdid = SyntheticDiD(n_bootstrap=30, seed=42)
|
|
2180
|
+
|
|
2181
|
+
# With single pre-period, time weights will be trivially [1.0]
|
|
2182
|
+
results = sdid.fit(
|
|
2183
|
+
df,
|
|
2184
|
+
outcome="outcome",
|
|
2185
|
+
treatment="treated",
|
|
2186
|
+
unit="unit",
|
|
2187
|
+
time="period",
|
|
2188
|
+
post_periods=post_periods
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
# Should still produce results
|
|
2192
|
+
assert np.isfinite(results.att)
|
|
2193
|
+
# Time weights should have single entry
|
|
2194
|
+
assert len(results.time_weights) == 1
|
|
2195
|
+
|
|
2196
|
+
def test_more_pre_periods_than_control_units(self):
|
|
2197
|
+
"""Test SDID when n_pre_periods > n_control_units (underdetermined)."""
|
|
2198
|
+
np.random.seed(42)
|
|
2199
|
+
|
|
2200
|
+
n_control = 3 # Few control units
|
|
2201
|
+
n_periods = 10 # Many periods
|
|
2202
|
+
post_periods = [8, 9] # 8 pre-treatment periods
|
|
2203
|
+
|
|
2204
|
+
data = []
|
|
2205
|
+
# Treated unit
|
|
2206
|
+
for t in range(n_periods):
|
|
2207
|
+
y = 10.0 + t * 0.2 + np.random.normal(0, 0.3)
|
|
2208
|
+
if t in post_periods:
|
|
2209
|
+
y += 2.5
|
|
2210
|
+
data.append({
|
|
2211
|
+
"unit": 0,
|
|
2212
|
+
"period": t,
|
|
2213
|
+
"outcome": y,
|
|
2214
|
+
"treated": 1,
|
|
2215
|
+
})
|
|
2216
|
+
|
|
2217
|
+
# Control units
|
|
2218
|
+
for unit in range(1, n_control + 1):
|
|
2219
|
+
for t in range(n_periods):
|
|
2220
|
+
y = 8.0 + t * 0.15 + np.random.normal(0, 0.3)
|
|
2221
|
+
data.append({
|
|
2222
|
+
"unit": unit,
|
|
2223
|
+
"period": t,
|
|
2224
|
+
"outcome": y,
|
|
2225
|
+
"treated": 0,
|
|
2226
|
+
})
|
|
2227
|
+
|
|
2228
|
+
df = pd.DataFrame(data)
|
|
2229
|
+
|
|
2230
|
+
# Use regularization to help with underdetermined system
|
|
2231
|
+
sdid = SyntheticDiD(lambda_reg=1.0, n_bootstrap=30, seed=42)
|
|
2232
|
+
|
|
2233
|
+
results = sdid.fit(
|
|
2234
|
+
df,
|
|
2235
|
+
outcome="outcome",
|
|
2236
|
+
treatment="treated",
|
|
2237
|
+
unit="unit",
|
|
2238
|
+
time="period",
|
|
2239
|
+
post_periods=post_periods
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
# Should produce valid results with regularization
|
|
2243
|
+
assert np.isfinite(results.att)
|
|
2244
|
+
assert results.se > 0
|
|
2245
|
+
|
|
2093
2246
|
|
|
2094
2247
|
class TestSyntheticWeightsUtils:
|
|
2095
2248
|
"""Tests for synthetic weight utility functions."""
|
|
@@ -2179,3 +2332,368 @@ class TestSyntheticWeightsUtils:
|
|
|
2179
2332
|
# Control: 12 - 10 = 2
|
|
2180
2333
|
# SDID: 5 - 2 = 3
|
|
2181
2334
|
assert abs(tau - 3.0) < 1e-6
|
|
2335
|
+
|
|
2336
|
+
|
|
2337
|
+
# =============================================================================
|
|
2338
|
+
# Edge Case Tests
|
|
2339
|
+
# =============================================================================
|
|
2340
|
+
|
|
2341
|
+
|
|
2342
|
+
class TestUnbalancedPanels:
|
|
2343
|
+
"""Tests for handling unbalanced panels with missing periods."""
|
|
2344
|
+
|
|
2345
|
+
def test_did_with_missing_periods(self):
|
|
2346
|
+
"""Test DifferenceInDifferences handles missing periods gracefully."""
|
|
2347
|
+
# Create unbalanced panel - some units missing some periods
|
|
2348
|
+
np.random.seed(42)
|
|
2349
|
+
data = []
|
|
2350
|
+
|
|
2351
|
+
for unit in range(20):
|
|
2352
|
+
is_treated = unit < 10
|
|
2353
|
+
# Some units missing period 0, some missing period 1
|
|
2354
|
+
periods = [0, 1]
|
|
2355
|
+
if unit % 5 == 0:
|
|
2356
|
+
periods = [1] # Missing pre-period
|
|
2357
|
+
elif unit % 7 == 0:
|
|
2358
|
+
periods = [0] # Missing post-period
|
|
2359
|
+
|
|
2360
|
+
for period in periods:
|
|
2361
|
+
y = 10.0 + unit * 0.1
|
|
2362
|
+
if period == 1:
|
|
2363
|
+
y += 5.0
|
|
2364
|
+
if is_treated and period == 1:
|
|
2365
|
+
y += 3.0
|
|
2366
|
+
y += np.random.normal(0, 1)
|
|
2367
|
+
|
|
2368
|
+
data.append({
|
|
2369
|
+
"unit": unit,
|
|
2370
|
+
"period": period,
|
|
2371
|
+
"treated": int(is_treated),
|
|
2372
|
+
"post": period,
|
|
2373
|
+
"outcome": y,
|
|
2374
|
+
})
|
|
2375
|
+
|
|
2376
|
+
df = pd.DataFrame(data)
|
|
2377
|
+
|
|
2378
|
+
did = DifferenceInDifferences()
|
|
2379
|
+
results = did.fit(
|
|
2380
|
+
df,
|
|
2381
|
+
outcome="outcome",
|
|
2382
|
+
treatment="treated",
|
|
2383
|
+
time="post"
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
# Should still produce valid results
|
|
2387
|
+
assert np.isfinite(results.att)
|
|
2388
|
+
assert results.se > 0
|
|
2389
|
+
assert results.n_obs == len(df)
|
|
2390
|
+
|
|
2391
|
+
def test_twfe_with_unbalanced_panel(self):
|
|
2392
|
+
"""Test TwoWayFixedEffects handles unbalanced panels."""
|
|
2393
|
+
from diff_diff import TwoWayFixedEffects
|
|
2394
|
+
|
|
2395
|
+
np.random.seed(42)
|
|
2396
|
+
data = []
|
|
2397
|
+
|
|
2398
|
+
for unit in range(15):
|
|
2399
|
+
is_treated = unit < 8
|
|
2400
|
+
unit_effect = np.random.normal(0, 2)
|
|
2401
|
+
|
|
2402
|
+
# Create unbalanced panel - varying number of periods per unit
|
|
2403
|
+
if unit < 5:
|
|
2404
|
+
periods = [0, 1, 2, 3] # Full panel
|
|
2405
|
+
elif unit < 10:
|
|
2406
|
+
periods = [0, 1, 3] # Missing period 2
|
|
2407
|
+
else:
|
|
2408
|
+
periods = [1, 2, 3] # Missing period 0
|
|
2409
|
+
|
|
2410
|
+
for period in periods:
|
|
2411
|
+
time_effect = period * 0.5
|
|
2412
|
+
post = 1 if period >= 2 else 0
|
|
2413
|
+
|
|
2414
|
+
y = 10.0 + unit_effect + time_effect
|
|
2415
|
+
if is_treated and post:
|
|
2416
|
+
y += 3.0
|
|
2417
|
+
y += np.random.normal(0, 0.5)
|
|
2418
|
+
|
|
2419
|
+
data.append({
|
|
2420
|
+
"unit": unit,
|
|
2421
|
+
"period": period,
|
|
2422
|
+
"treated": int(is_treated),
|
|
2423
|
+
"post": post,
|
|
2424
|
+
"outcome": y,
|
|
2425
|
+
})
|
|
2426
|
+
|
|
2427
|
+
df = pd.DataFrame(data)
|
|
2428
|
+
|
|
2429
|
+
twfe = TwoWayFixedEffects()
|
|
2430
|
+
results = twfe.fit(
|
|
2431
|
+
df,
|
|
2432
|
+
outcome="outcome",
|
|
2433
|
+
treatment="post",
|
|
2434
|
+
unit="unit",
|
|
2435
|
+
time="period"
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
# Should produce valid results
|
|
2439
|
+
assert np.isfinite(results.att)
|
|
2440
|
+
assert results.se > 0
|
|
2441
|
+
|
|
2442
|
+
def test_multiperiod_with_sparse_data(self):
|
|
2443
|
+
"""Test MultiPeriodDiD with sparse data across periods."""
|
|
2444
|
+
np.random.seed(42)
|
|
2445
|
+
data = []
|
|
2446
|
+
|
|
2447
|
+
n_units = 30
|
|
2448
|
+
for unit in range(n_units):
|
|
2449
|
+
is_treated = unit < n_units // 2
|
|
2450
|
+
|
|
2451
|
+
# Each unit observed in random subset of periods
|
|
2452
|
+
available_periods = np.random.choice([0, 1, 2, 3, 4], size=3, replace=False)
|
|
2453
|
+
available_periods = sorted(available_periods)
|
|
2454
|
+
|
|
2455
|
+
for period in available_periods:
|
|
2456
|
+
y = 10.0 + np.random.normal(0, 1)
|
|
2457
|
+
if period >= 2:
|
|
2458
|
+
y += 2.0 # Time effect
|
|
2459
|
+
if is_treated and period >= 2:
|
|
2460
|
+
y += 3.0 # Treatment effect
|
|
2461
|
+
|
|
2462
|
+
data.append({
|
|
2463
|
+
"unit": unit,
|
|
2464
|
+
"period": period,
|
|
2465
|
+
"treated": int(is_treated),
|
|
2466
|
+
"outcome": y,
|
|
2467
|
+
})
|
|
2468
|
+
|
|
2469
|
+
df = pd.DataFrame(data)
|
|
2470
|
+
|
|
2471
|
+
mp_did = MultiPeriodDiD()
|
|
2472
|
+
results = mp_did.fit(
|
|
2473
|
+
df,
|
|
2474
|
+
outcome="outcome",
|
|
2475
|
+
treatment="treated",
|
|
2476
|
+
time="period",
|
|
2477
|
+
reference_period=1
|
|
2478
|
+
)
|
|
2479
|
+
|
|
2480
|
+
# Should produce valid results
|
|
2481
|
+
assert np.isfinite(results.avg_att)
|
|
2482
|
+
assert len(results.period_effects) > 0
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
class TestSingleTreatedUnit:
|
|
2486
|
+
"""Tests for scenarios with only one treated unit."""
|
|
2487
|
+
|
|
2488
|
+
def test_did_single_treated_unit(self):
|
|
2489
|
+
"""Test DifferenceInDifferences with single treated unit."""
|
|
2490
|
+
np.random.seed(42)
|
|
2491
|
+
data = []
|
|
2492
|
+
|
|
2493
|
+
# 1 treated unit, 10 control units
|
|
2494
|
+
for unit in range(11):
|
|
2495
|
+
is_treated = unit == 0
|
|
2496
|
+
|
|
2497
|
+
for period in [0, 1]:
|
|
2498
|
+
y = 10.0 + np.random.normal(0, 0.5)
|
|
2499
|
+
if period == 1:
|
|
2500
|
+
y += 2.0
|
|
2501
|
+
if is_treated and period == 1:
|
|
2502
|
+
y += 5.0 # Large effect for single unit
|
|
2503
|
+
|
|
2504
|
+
data.append({
|
|
2505
|
+
"unit": unit,
|
|
2506
|
+
"period": period,
|
|
2507
|
+
"treated": int(is_treated),
|
|
2508
|
+
"post": period,
|
|
2509
|
+
"outcome": y,
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
df = pd.DataFrame(data)
|
|
2513
|
+
|
|
2514
|
+
did = DifferenceInDifferences()
|
|
2515
|
+
results = did.fit(
|
|
2516
|
+
df,
|
|
2517
|
+
outcome="outcome",
|
|
2518
|
+
treatment="treated",
|
|
2519
|
+
time="post"
|
|
2520
|
+
)
|
|
2521
|
+
|
|
2522
|
+
# Should produce valid results
|
|
2523
|
+
assert np.isfinite(results.att)
|
|
2524
|
+
assert results.se > 0
|
|
2525
|
+
assert results.n_treated == 2 # 1 unit × 2 periods
|
|
2526
|
+
|
|
2527
|
+
def test_sdid_single_treated_unit(self):
|
|
2528
|
+
"""Test SyntheticDiD with single treated unit (primary use case)."""
|
|
2529
|
+
np.random.seed(42)
|
|
2530
|
+
|
|
2531
|
+
n_control = 10
|
|
2532
|
+
n_periods = 6
|
|
2533
|
+
post_periods = [4, 5]
|
|
2534
|
+
|
|
2535
|
+
data = []
|
|
2536
|
+
|
|
2537
|
+
# Generate treated unit
|
|
2538
|
+
treated_base = 15.0
|
|
2539
|
+
treated_trend = 0.5
|
|
2540
|
+
for t in range(n_periods):
|
|
2541
|
+
y = treated_base + treated_trend * t + np.random.normal(0, 0.3)
|
|
2542
|
+
if t in post_periods:
|
|
2543
|
+
y += 3.0 # Treatment effect
|
|
2544
|
+
data.append({
|
|
2545
|
+
"unit": 0,
|
|
2546
|
+
"period": t,
|
|
2547
|
+
"outcome": y,
|
|
2548
|
+
"treated": 1,
|
|
2549
|
+
})
|
|
2550
|
+
|
|
2551
|
+
# Generate control units
|
|
2552
|
+
for unit in range(1, n_control + 1):
|
|
2553
|
+
unit_base = 10.0 + np.random.normal(0, 2)
|
|
2554
|
+
unit_trend = 0.4 + np.random.normal(0, 0.1)
|
|
2555
|
+
for t in range(n_periods):
|
|
2556
|
+
y = unit_base + unit_trend * t + np.random.normal(0, 0.3)
|
|
2557
|
+
data.append({
|
|
2558
|
+
"unit": unit,
|
|
2559
|
+
"period": t,
|
|
2560
|
+
"outcome": y,
|
|
2561
|
+
"treated": 0,
|
|
2562
|
+
})
|
|
2563
|
+
|
|
2564
|
+
df = pd.DataFrame(data)
|
|
2565
|
+
|
|
2566
|
+
sdid = SyntheticDiD(n_bootstrap=50, seed=42)
|
|
2567
|
+
results = sdid.fit(
|
|
2568
|
+
df,
|
|
2569
|
+
outcome="outcome",
|
|
2570
|
+
treatment="treated",
|
|
2571
|
+
unit="unit",
|
|
2572
|
+
time="period",
|
|
2573
|
+
post_periods=post_periods
|
|
2574
|
+
)
|
|
2575
|
+
|
|
2576
|
+
# SDID is designed for single/few treated units
|
|
2577
|
+
assert np.isfinite(results.att)
|
|
2578
|
+
assert results.se > 0
|
|
2579
|
+
# Effect should be roughly correct
|
|
2580
|
+
assert abs(results.att - 3.0) < 2.0
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
class TestCollinearityDetection:
|
|
2584
|
+
"""Tests for handling perfect or near collinearity."""
|
|
2585
|
+
|
|
2586
|
+
def test_did_with_redundant_covariate_raises_error(self):
|
|
2587
|
+
"""Test DiD raises clear error for perfectly collinear covariates."""
|
|
2588
|
+
np.random.seed(42)
|
|
2589
|
+
data = pd.DataFrame({
|
|
2590
|
+
"outcome": np.random.normal(10, 1, 100),
|
|
2591
|
+
"treated": np.repeat([0, 1], 50),
|
|
2592
|
+
"post": np.tile([0, 1], 50),
|
|
2593
|
+
"x1": np.random.normal(0, 1, 100),
|
|
2594
|
+
})
|
|
2595
|
+
# Add perfectly collinear covariate
|
|
2596
|
+
data["x2"] = data["x1"] * 2 + 3
|
|
2597
|
+
|
|
2598
|
+
did = DifferenceInDifferences()
|
|
2599
|
+
|
|
2600
|
+
# Should raise a clear error about collinearity
|
|
2601
|
+
with pytest.raises(ValueError, match="rank-deficient"):
|
|
2602
|
+
did.fit(
|
|
2603
|
+
data,
|
|
2604
|
+
outcome="outcome",
|
|
2605
|
+
treatment="treated",
|
|
2606
|
+
time="post",
|
|
2607
|
+
covariates=["x1", "x2"]
|
|
2608
|
+
)
|
|
2609
|
+
|
|
2610
|
+
def test_did_with_constant_covariate_raises_error(self):
|
|
2611
|
+
"""Test DiD raises clear error for constant covariates."""
|
|
2612
|
+
np.random.seed(42)
|
|
2613
|
+
data = pd.DataFrame({
|
|
2614
|
+
"outcome": np.random.normal(10, 1, 100),
|
|
2615
|
+
"treated": np.repeat([0, 1], 50),
|
|
2616
|
+
"post": np.tile([0, 1], 50),
|
|
2617
|
+
"constant_x": np.ones(100), # Constant covariate
|
|
2618
|
+
})
|
|
2619
|
+
|
|
2620
|
+
did = DifferenceInDifferences()
|
|
2621
|
+
|
|
2622
|
+
# Constant covariate is collinear with intercept
|
|
2623
|
+
# Should raise clear error
|
|
2624
|
+
with pytest.raises(ValueError, match="rank-deficient"):
|
|
2625
|
+
did.fit(
|
|
2626
|
+
data,
|
|
2627
|
+
outcome="outcome",
|
|
2628
|
+
treatment="treated",
|
|
2629
|
+
time="post",
|
|
2630
|
+
covariates=["constant_x"]
|
|
2631
|
+
)
|
|
2632
|
+
|
|
2633
|
+
def test_did_with_near_collinear_covariates(self):
|
|
2634
|
+
"""Test DiD handles near-collinear covariates (not perfectly collinear)."""
|
|
2635
|
+
np.random.seed(42)
|
|
2636
|
+
data = pd.DataFrame({
|
|
2637
|
+
"outcome": np.random.normal(10, 1, 100),
|
|
2638
|
+
"treated": np.repeat([0, 1], 50),
|
|
2639
|
+
"post": np.tile([0, 1], 50),
|
|
2640
|
+
"x1": np.random.normal(0, 1, 100),
|
|
2641
|
+
})
|
|
2642
|
+
# Add near-collinear covariate (small noise breaks perfect collinearity)
|
|
2643
|
+
data["x2"] = data["x1"] * 2 + 3 + np.random.normal(0, 0.1, 100)
|
|
2644
|
+
|
|
2645
|
+
did = DifferenceInDifferences()
|
|
2646
|
+
|
|
2647
|
+
# Near-collinear should work (not perfectly rank-deficient)
|
|
2648
|
+
results = did.fit(
|
|
2649
|
+
data,
|
|
2650
|
+
outcome="outcome",
|
|
2651
|
+
treatment="treated",
|
|
2652
|
+
time="post",
|
|
2653
|
+
covariates=["x1", "x2"]
|
|
2654
|
+
)
|
|
2655
|
+
|
|
2656
|
+
assert np.isfinite(results.att)
|
|
2657
|
+
|
|
2658
|
+
def test_twfe_with_absorbed_covariate(self):
|
|
2659
|
+
"""Test TWFE handles covariate absorbed by fixed effects."""
|
|
2660
|
+
from diff_diff import TwoWayFixedEffects
|
|
2661
|
+
|
|
2662
|
+
np.random.seed(42)
|
|
2663
|
+
n_units = 20
|
|
2664
|
+
n_periods = 4
|
|
2665
|
+
|
|
2666
|
+
data = []
|
|
2667
|
+
for unit in range(n_units):
|
|
2668
|
+
# Unit-specific covariate (absorbed by unit FE)
|
|
2669
|
+
unit_x = np.random.normal(0, 1)
|
|
2670
|
+
|
|
2671
|
+
for period in range(n_periods):
|
|
2672
|
+
y = 10.0 + unit * 0.5 + period * 0.3 + np.random.normal(0, 0.5)
|
|
2673
|
+
post = 1 if period >= 2 else 0
|
|
2674
|
+
if unit < n_units // 2 and post:
|
|
2675
|
+
y += 2.0
|
|
2676
|
+
|
|
2677
|
+
data.append({
|
|
2678
|
+
"unit": unit,
|
|
2679
|
+
"period": period,
|
|
2680
|
+
"outcome": y,
|
|
2681
|
+
"treated": int(unit < n_units // 2),
|
|
2682
|
+
"post": post,
|
|
2683
|
+
"unit_covariate": unit_x, # Same for all periods within unit
|
|
2684
|
+
})
|
|
2685
|
+
|
|
2686
|
+
df = pd.DataFrame(data)
|
|
2687
|
+
|
|
2688
|
+
twfe = TwoWayFixedEffects()
|
|
2689
|
+
# unit_covariate is absorbed by unit fixed effects
|
|
2690
|
+
results = twfe.fit(
|
|
2691
|
+
df,
|
|
2692
|
+
outcome="outcome",
|
|
2693
|
+
treatment="post",
|
|
2694
|
+
unit="unit",
|
|
2695
|
+
time="period"
|
|
2696
|
+
)
|
|
2697
|
+
|
|
2698
|
+
assert np.isfinite(results.att)
|
|
2699
|
+
assert results.se > 0
|
|
@@ -1125,3 +1125,234 @@ class TestCallawaySantAnnaBootstrap:
|
|
|
1125
1125
|
time='time',
|
|
1126
1126
|
first_treat='first_treat'
|
|
1127
1127
|
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
# =============================================================================
|
|
1131
|
+
# Edge Case Tests: Single Cohort
|
|
1132
|
+
# =============================================================================
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
class TestCallawaySantAnnaSingleCohort:
|
|
1136
|
+
"""Tests for CallawaySantAnna with a single treatment cohort."""
|
|
1137
|
+
|
|
1138
|
+
def test_single_cohort_basic(self):
|
|
1139
|
+
"""Test CS estimator with single treatment cohort."""
|
|
1140
|
+
np.random.seed(42)
|
|
1141
|
+
|
|
1142
|
+
n_units = 60
|
|
1143
|
+
n_periods = 8
|
|
1144
|
+
treatment_period = 4
|
|
1145
|
+
|
|
1146
|
+
# Generate data with single cohort
|
|
1147
|
+
data = []
|
|
1148
|
+
for unit in range(n_units):
|
|
1149
|
+
# 40% never-treated, 60% treated at period 4
|
|
1150
|
+
if unit < int(n_units * 0.4):
|
|
1151
|
+
first_treat = 0 # Never treated
|
|
1152
|
+
else:
|
|
1153
|
+
first_treat = treatment_period # Single cohort
|
|
1154
|
+
|
|
1155
|
+
unit_fe = np.random.normal(0, 2)
|
|
1156
|
+
|
|
1157
|
+
for t in range(n_periods):
|
|
1158
|
+
time_fe = t * 0.3
|
|
1159
|
+
y = 10.0 + unit_fe + time_fe
|
|
1160
|
+
|
|
1161
|
+
# Treatment effect for treated units after treatment
|
|
1162
|
+
if first_treat > 0 and t >= first_treat:
|
|
1163
|
+
y += 2.5
|
|
1164
|
+
|
|
1165
|
+
y += np.random.normal(0, 0.5)
|
|
1166
|
+
|
|
1167
|
+
data.append({
|
|
1168
|
+
'unit': unit,
|
|
1169
|
+
'time': t,
|
|
1170
|
+
'outcome': y,
|
|
1171
|
+
'first_treat': first_treat,
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
df = pd.DataFrame(data)
|
|
1175
|
+
|
|
1176
|
+
cs = CallawaySantAnna()
|
|
1177
|
+
results = cs.fit(
|
|
1178
|
+
df,
|
|
1179
|
+
outcome='outcome',
|
|
1180
|
+
unit='unit',
|
|
1181
|
+
time='time',
|
|
1182
|
+
first_treat='first_treat'
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
# Should produce valid results
|
|
1186
|
+
assert results.overall_att is not None
|
|
1187
|
+
assert np.isfinite(results.overall_att)
|
|
1188
|
+
assert results.overall_se > 0
|
|
1189
|
+
|
|
1190
|
+
# Should have effects for single group only
|
|
1191
|
+
groups = set(g for g, t in results.group_time_effects.keys())
|
|
1192
|
+
assert len(groups) == 1
|
|
1193
|
+
assert treatment_period in groups
|
|
1194
|
+
|
|
1195
|
+
# ATT should be roughly correct
|
|
1196
|
+
assert abs(results.overall_att - 2.5) < 1.5
|
|
1197
|
+
|
|
1198
|
+
def test_single_cohort_event_study(self):
|
|
1199
|
+
"""Test event study aggregation with single cohort."""
|
|
1200
|
+
np.random.seed(42)
|
|
1201
|
+
|
|
1202
|
+
n_units = 80
|
|
1203
|
+
n_periods = 12
|
|
1204
|
+
treatment_period = 6 # Start later to have both pre and post periods
|
|
1205
|
+
|
|
1206
|
+
data = []
|
|
1207
|
+
for unit in range(n_units):
|
|
1208
|
+
if unit < int(n_units * 0.3):
|
|
1209
|
+
first_treat = 0
|
|
1210
|
+
else:
|
|
1211
|
+
first_treat = treatment_period
|
|
1212
|
+
|
|
1213
|
+
unit_fe = np.random.normal(0, 1)
|
|
1214
|
+
|
|
1215
|
+
for t in range(n_periods):
|
|
1216
|
+
y = 10.0 + unit_fe + t * 0.2
|
|
1217
|
+
|
|
1218
|
+
if first_treat > 0 and t >= first_treat:
|
|
1219
|
+
# Dynamic effect: grows over time
|
|
1220
|
+
periods_since = t - first_treat
|
|
1221
|
+
y += 2.0 + 0.3 * periods_since
|
|
1222
|
+
|
|
1223
|
+
y += np.random.normal(0, 0.4)
|
|
1224
|
+
|
|
1225
|
+
data.append({
|
|
1226
|
+
'unit': unit,
|
|
1227
|
+
'time': t,
|
|
1228
|
+
'outcome': y,
|
|
1229
|
+
'first_treat': first_treat,
|
|
1230
|
+
})
|
|
1231
|
+
|
|
1232
|
+
df = pd.DataFrame(data)
|
|
1233
|
+
|
|
1234
|
+
cs = CallawaySantAnna()
|
|
1235
|
+
results = cs.fit(
|
|
1236
|
+
df,
|
|
1237
|
+
outcome='outcome',
|
|
1238
|
+
unit='unit',
|
|
1239
|
+
time='time',
|
|
1240
|
+
first_treat='first_treat',
|
|
1241
|
+
aggregate='event_study'
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
assert results.event_study_effects is not None
|
|
1245
|
+
assert len(results.event_study_effects) > 0
|
|
1246
|
+
|
|
1247
|
+
# Event study should have multiple relative periods
|
|
1248
|
+
rel_periods = sorted(results.event_study_effects.keys())
|
|
1249
|
+
assert len(rel_periods) >= 2, f"Expected multiple periods, got {rel_periods}"
|
|
1250
|
+
|
|
1251
|
+
# With single cohort, all effects are for the same group
|
|
1252
|
+
# Post-treatment effects (e >= 0) should show positive effect
|
|
1253
|
+
post_periods = [e for e in rel_periods if e >= 0]
|
|
1254
|
+
if post_periods:
|
|
1255
|
+
# At least some post-periods should show positive effect
|
|
1256
|
+
post_effects = [results.event_study_effects[e]['effect'] for e in post_periods]
|
|
1257
|
+
assert any(e > 0.5 for e in post_effects), f"Expected positive post-period effects, got {post_effects}"
|
|
1258
|
+
|
|
1259
|
+
def test_single_cohort_with_bootstrap(self):
|
|
1260
|
+
"""Test bootstrap inference with single cohort."""
|
|
1261
|
+
np.random.seed(42)
|
|
1262
|
+
|
|
1263
|
+
n_units = 50
|
|
1264
|
+
n_periods = 6
|
|
1265
|
+
treatment_period = 3
|
|
1266
|
+
|
|
1267
|
+
data = []
|
|
1268
|
+
for unit in range(n_units):
|
|
1269
|
+
if unit < int(n_units * 0.4):
|
|
1270
|
+
first_treat = 0
|
|
1271
|
+
else:
|
|
1272
|
+
first_treat = treatment_period
|
|
1273
|
+
|
|
1274
|
+
for t in range(n_periods):
|
|
1275
|
+
y = 10.0 + np.random.normal(0, 1)
|
|
1276
|
+
if first_treat > 0 and t >= first_treat:
|
|
1277
|
+
y += 3.0
|
|
1278
|
+
|
|
1279
|
+
data.append({
|
|
1280
|
+
'unit': unit,
|
|
1281
|
+
'time': t,
|
|
1282
|
+
'outcome': y,
|
|
1283
|
+
'first_treat': first_treat,
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
df = pd.DataFrame(data)
|
|
1287
|
+
|
|
1288
|
+
cs = CallawaySantAnna(n_bootstrap=99, seed=42)
|
|
1289
|
+
results = cs.fit(
|
|
1290
|
+
df,
|
|
1291
|
+
outcome='outcome',
|
|
1292
|
+
unit='unit',
|
|
1293
|
+
time='time',
|
|
1294
|
+
first_treat='first_treat'
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
assert results.bootstrap_results is not None
|
|
1298
|
+
assert results.bootstrap_results.overall_att_se > 0
|
|
1299
|
+
assert results.bootstrap_results.overall_att_ci[0] < results.bootstrap_results.overall_att_ci[1]
|
|
1300
|
+
|
|
1301
|
+
def test_single_cohort_not_yet_treated_control(self):
|
|
1302
|
+
"""Test single cohort with not_yet_treated control group.
|
|
1303
|
+
|
|
1304
|
+
With a single cohort, not_yet_treated should behave same as
|
|
1305
|
+
never_treated after the treatment period.
|
|
1306
|
+
"""
|
|
1307
|
+
np.random.seed(42)
|
|
1308
|
+
|
|
1309
|
+
n_units = 60
|
|
1310
|
+
n_periods = 8
|
|
1311
|
+
treatment_period = 4
|
|
1312
|
+
|
|
1313
|
+
data = []
|
|
1314
|
+
for unit in range(n_units):
|
|
1315
|
+
if unit < int(n_units * 0.4):
|
|
1316
|
+
first_treat = 0
|
|
1317
|
+
else:
|
|
1318
|
+
first_treat = treatment_period
|
|
1319
|
+
|
|
1320
|
+
for t in range(n_periods):
|
|
1321
|
+
y = 10.0 + np.random.normal(0, 0.5)
|
|
1322
|
+
if first_treat > 0 and t >= first_treat:
|
|
1323
|
+
y += 2.0
|
|
1324
|
+
|
|
1325
|
+
data.append({
|
|
1326
|
+
'unit': unit,
|
|
1327
|
+
'time': t,
|
|
1328
|
+
'outcome': y,
|
|
1329
|
+
'first_treat': first_treat,
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
df = pd.DataFrame(data)
|
|
1333
|
+
|
|
1334
|
+
cs_never = CallawaySantAnna(control_group='never_treated')
|
|
1335
|
+
results_never = cs_never.fit(
|
|
1336
|
+
df,
|
|
1337
|
+
outcome='outcome',
|
|
1338
|
+
unit='unit',
|
|
1339
|
+
time='time',
|
|
1340
|
+
first_treat='first_treat'
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
cs_not_yet = CallawaySantAnna(control_group='not_yet_treated')
|
|
1344
|
+
results_not_yet = cs_not_yet.fit(
|
|
1345
|
+
df,
|
|
1346
|
+
outcome='outcome',
|
|
1347
|
+
unit='unit',
|
|
1348
|
+
time='time',
|
|
1349
|
+
first_treat='first_treat'
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
# Both should produce valid results
|
|
1353
|
+
assert np.isfinite(results_never.overall_att)
|
|
1354
|
+
assert np.isfinite(results_not_yet.overall_att)
|
|
1355
|
+
|
|
1356
|
+
# Results may differ slightly due to different comparison groups
|
|
1357
|
+
# but should be in similar range
|
|
1358
|
+
assert abs(results_never.overall_att - results_not_yet.overall_att) < 1.0
|
|
@@ -621,3 +621,184 @@ class TestWildBootstrapResults:
|
|
|
621
621
|
|
|
622
622
|
captured = capsys.readouterr()
|
|
623
623
|
assert "Wild Cluster Bootstrap Results" in captured.out
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# =============================================================================
|
|
627
|
+
# Edge Case Tests: Few Clusters (< 5)
|
|
628
|
+
# =============================================================================
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class TestFewClustersEdgeCases:
|
|
632
|
+
"""Tests for wild bootstrap behavior with very few clusters."""
|
|
633
|
+
|
|
634
|
+
def test_three_clusters_still_works(self):
|
|
635
|
+
"""Test wild bootstrap works with 3 clusters (minimum viable)."""
|
|
636
|
+
np.random.seed(42)
|
|
637
|
+
|
|
638
|
+
n_clusters = 3
|
|
639
|
+
obs_per_cluster = 40
|
|
640
|
+
|
|
641
|
+
data = []
|
|
642
|
+
for cluster in range(n_clusters):
|
|
643
|
+
is_treated = cluster < 2 # 2 treated, 1 control cluster
|
|
644
|
+
cluster_effect = np.random.normal(0, 2)
|
|
645
|
+
|
|
646
|
+
for obs in range(obs_per_cluster):
|
|
647
|
+
for period in [0, 1]:
|
|
648
|
+
y = 10.0 + cluster_effect
|
|
649
|
+
if period == 1:
|
|
650
|
+
y += 5.0
|
|
651
|
+
if is_treated and period == 1:
|
|
652
|
+
y += 3.0
|
|
653
|
+
y += np.random.normal(0, 1)
|
|
654
|
+
|
|
655
|
+
data.append({
|
|
656
|
+
"cluster": cluster,
|
|
657
|
+
"unit": cluster * obs_per_cluster + obs,
|
|
658
|
+
"period": period,
|
|
659
|
+
"treated": int(is_treated),
|
|
660
|
+
"post": period,
|
|
661
|
+
"outcome": y,
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
df = pd.DataFrame(data)
|
|
665
|
+
|
|
666
|
+
did = DifferenceInDifferences(
|
|
667
|
+
cluster="cluster",
|
|
668
|
+
inference="wild_bootstrap",
|
|
669
|
+
n_bootstrap=99,
|
|
670
|
+
bootstrap_weights="webb", # Webb recommended for few clusters
|
|
671
|
+
seed=42
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Should warn about few clusters but still produce valid results
|
|
675
|
+
with pytest.warns(UserWarning, match="Only 3 clusters"):
|
|
676
|
+
results = did.fit(
|
|
677
|
+
df,
|
|
678
|
+
outcome="outcome",
|
|
679
|
+
treatment="treated",
|
|
680
|
+
time="post"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
assert results.se > 0
|
|
684
|
+
assert results.inference_method == "wild_bootstrap"
|
|
685
|
+
assert results.n_clusters == 3
|
|
686
|
+
|
|
687
|
+
def test_two_clusters_minimum(self):
|
|
688
|
+
"""Test wild bootstrap works with exactly 2 clusters (absolute minimum)."""
|
|
689
|
+
np.random.seed(42)
|
|
690
|
+
|
|
691
|
+
n_clusters = 2
|
|
692
|
+
obs_per_cluster = 50
|
|
693
|
+
|
|
694
|
+
data = []
|
|
695
|
+
for cluster in range(n_clusters):
|
|
696
|
+
is_treated = cluster == 0
|
|
697
|
+
cluster_effect = np.random.normal(0, 2)
|
|
698
|
+
|
|
699
|
+
for obs in range(obs_per_cluster):
|
|
700
|
+
for period in [0, 1]:
|
|
701
|
+
y = 10.0 + cluster_effect
|
|
702
|
+
if period == 1:
|
|
703
|
+
y += 5.0
|
|
704
|
+
if is_treated and period == 1:
|
|
705
|
+
y += 3.0
|
|
706
|
+
y += np.random.normal(0, 1)
|
|
707
|
+
|
|
708
|
+
data.append({
|
|
709
|
+
"cluster": cluster,
|
|
710
|
+
"unit": cluster * obs_per_cluster + obs,
|
|
711
|
+
"period": period,
|
|
712
|
+
"treated": int(is_treated),
|
|
713
|
+
"post": period,
|
|
714
|
+
"outcome": y,
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
df = pd.DataFrame(data)
|
|
718
|
+
|
|
719
|
+
did = DifferenceInDifferences(
|
|
720
|
+
cluster="cluster",
|
|
721
|
+
inference="wild_bootstrap",
|
|
722
|
+
n_bootstrap=99,
|
|
723
|
+
bootstrap_weights="webb",
|
|
724
|
+
seed=42
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# Should warn about few clusters
|
|
728
|
+
with pytest.warns(UserWarning, match="Only 2 clusters"):
|
|
729
|
+
results = did.fit(
|
|
730
|
+
df,
|
|
731
|
+
outcome="outcome",
|
|
732
|
+
treatment="treated",
|
|
733
|
+
time="post"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Results should still be valid (though may have high variance)
|
|
737
|
+
assert results.se > 0
|
|
738
|
+
assert np.isfinite(results.att)
|
|
739
|
+
assert results.n_clusters == 2
|
|
740
|
+
|
|
741
|
+
def test_few_clusters_webb_vs_rademacher(self, few_cluster_data):
|
|
742
|
+
"""Test that Webb weights produce different (often more conservative) SEs than Rademacher with few clusters."""
|
|
743
|
+
did_webb = DifferenceInDifferences(
|
|
744
|
+
cluster="cluster",
|
|
745
|
+
inference="wild_bootstrap",
|
|
746
|
+
n_bootstrap=199,
|
|
747
|
+
bootstrap_weights="webb",
|
|
748
|
+
seed=42
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
did_rademacher = DifferenceInDifferences(
|
|
752
|
+
cluster="cluster",
|
|
753
|
+
inference="wild_bootstrap",
|
|
754
|
+
n_bootstrap=199,
|
|
755
|
+
bootstrap_weights="rademacher",
|
|
756
|
+
seed=42
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
with pytest.warns(UserWarning):
|
|
760
|
+
results_webb = did_webb.fit(
|
|
761
|
+
few_cluster_data,
|
|
762
|
+
outcome="outcome",
|
|
763
|
+
treatment="treated",
|
|
764
|
+
time="post"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
with pytest.warns(UserWarning):
|
|
768
|
+
results_rademacher = did_rademacher.fit(
|
|
769
|
+
few_cluster_data,
|
|
770
|
+
outcome="outcome",
|
|
771
|
+
treatment="treated",
|
|
772
|
+
time="post"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Both should produce valid results
|
|
776
|
+
assert results_webb.se > 0
|
|
777
|
+
assert results_rademacher.se > 0
|
|
778
|
+
# ATT should be identical (same point estimate)
|
|
779
|
+
assert results_webb.att == results_rademacher.att
|
|
780
|
+
# SEs will differ due to different weight distributions
|
|
781
|
+
# (This is expected, not necessarily one > other)
|
|
782
|
+
|
|
783
|
+
def test_few_clusters_confidence_intervals_valid(self, few_cluster_data):
|
|
784
|
+
"""Test that CIs are valid even with few clusters."""
|
|
785
|
+
did = DifferenceInDifferences(
|
|
786
|
+
cluster="cluster",
|
|
787
|
+
inference="wild_bootstrap",
|
|
788
|
+
n_bootstrap=199,
|
|
789
|
+
bootstrap_weights="webb",
|
|
790
|
+
seed=42
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
with pytest.warns(UserWarning):
|
|
794
|
+
results = did.fit(
|
|
795
|
+
few_cluster_data,
|
|
796
|
+
outcome="outcome",
|
|
797
|
+
treatment="treated",
|
|
798
|
+
time="post"
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
lower, upper = results.conf_int
|
|
802
|
+
assert lower < upper
|
|
803
|
+
# CI should contain the point estimate
|
|
804
|
+
assert lower < results.att < upper
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|