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.
Files changed (36) hide show
  1. {diff_diff-1.2.0 → diff_diff-1.2.1}/PKG-INFO +1 -1
  2. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/__init__.py +1 -1
  3. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/staggered.py +49 -29
  4. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/PKG-INFO +1 -1
  5. {diff_diff-1.2.0 → diff_diff-1.2.1}/pyproject.toml +1 -1
  6. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_estimators.py +518 -0
  7. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_staggered.py +231 -0
  8. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_wild_bootstrap.py +181 -0
  9. {diff_diff-1.2.0 → diff_diff-1.2.1}/README.md +0 -0
  10. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/bacon.py +0 -0
  11. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/diagnostics.py +0 -0
  12. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/estimators.py +0 -0
  13. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/honest_did.py +0 -0
  14. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/power.py +0 -0
  15. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/prep.py +0 -0
  16. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/pretrends.py +0 -0
  17. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/results.py +0 -0
  18. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/sun_abraham.py +0 -0
  19. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/synthetic_did.py +0 -0
  20. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/twfe.py +0 -0
  21. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/utils.py +0 -0
  22. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff/visualization.py +0 -0
  23. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/SOURCES.txt +0 -0
  24. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/dependency_links.txt +0 -0
  25. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/requires.txt +0 -0
  26. {diff_diff-1.2.0 → diff_diff-1.2.1}/diff_diff.egg-info/top_level.txt +0 -0
  27. {diff_diff-1.2.0 → diff_diff-1.2.1}/setup.cfg +0 -0
  28. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_bacon.py +0 -0
  29. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_diagnostics.py +0 -0
  30. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_honest_did.py +0 -0
  31. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_power.py +0 -0
  32. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_prep.py +0 -0
  33. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_pretrends.py +0 -0
  34. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_sun_abraham.py +0 -0
  35. {diff_diff-1.2.0 → diff_diff-1.2.1}/tests/test_utils.py +0 -0
  36. {diff_diff-1.2.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.2.0
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
@@ -98,7 +98,7 @@ from diff_diff.visualization import (
98
98
  plot_sensitivity,
99
99
  )
100
100
 
101
- __version__ = "1.2.0"
101
+ __version__ = "1.2.1"
102
102
  __all__ = [
103
103
  # Estimators
104
104
  "DifferenceInDifferences",
@@ -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
- original_effect = original_atts[j]
1614
- boot_dist = bootstrap_atts_gt[:, j]
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
- # Overall ATT statistics
1625
- overall_se = float(np.std(bootstrap_overall, ddof=1))
1626
- overall_ci = self._compute_percentile_ci(bootstrap_overall, self.alpha)
1627
- overall_p_value = self._compute_bootstrap_pvalue(original_overall, bootstrap_overall)
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
- # Event study statistics
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
- original_effect = event_study_info[e]['effect']
1641
- boot_dist = bootstrap_event_study[e]
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
- # Group effect statistics
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
- original_effect = group_agg_info[g]['effect']
1661
- boot_dist = bootstrap_group[g]
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 {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 1.2.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diff-diff"
7
- version = "1.2.0"
7
+ version = "1.2.1"
8
8
  description = "A library for Difference-in-Differences causal inference analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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