-
Notifications
You must be signed in to change notification settings - Fork 49
Expand file tree
/
Copy pathtest_estimators.py
More file actions
3557 lines (3011 loc) · 121 KB
/
Copy pathtest_estimators.py
File metadata and controls
3557 lines (3011 loc) · 121 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Tests for difference-in-differences estimators."""
import numpy as np
import pandas as pd
import pytest
from diff_diff import (
DiDResults,
DifferenceInDifferences,
MultiPeriodDiD,
MultiPeriodDiDResults,
PeriodEffect,
SyntheticDiD,
SyntheticDiDResults,
)
@pytest.fixture
def simple_did_data():
"""Create simple 2x2 DiD data with known ATT."""
np.random.seed(42)
# Create balanced panel: 100 units, 2 periods
n_units = 100
n_treated = 50
data = []
for unit in range(n_units):
is_treated = unit < n_treated
for period in [0, 1]:
# Base outcome
y = 10.0
# Unit effect
y += unit * 0.1
# Time effect (period 1 is higher for everyone)
if period == 1:
y += 5.0
# Treatment effect (only for treated in post period)
if is_treated and period == 1:
y += 3.0 # True ATT = 3.0
# Add noise
y += np.random.normal(0, 1)
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated),
"post": period,
"outcome": y,
}
)
return pd.DataFrame(data)
@pytest.fixture
def simple_2x2_data():
"""Minimal 2x2 DiD data."""
return pd.DataFrame(
{
"outcome": [10, 11, 15, 18, 9, 10, 12, 13],
"treated": [1, 1, 1, 1, 0, 0, 0, 0],
"post": [0, 0, 1, 1, 0, 0, 1, 1],
}
)
class TestDifferenceInDifferences:
"""Tests for DifferenceInDifferences estimator."""
def test_basic_fit(self, simple_2x2_data):
"""Test basic model fitting."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post")
assert isinstance(results, DiDResults)
assert did.is_fitted_
assert results.n_obs == 8
assert results.n_treated == 4
assert results.n_control == 4
def test_att_direction(self, simple_did_data):
"""Test that ATT is estimated in correct direction."""
did = DifferenceInDifferences()
results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post")
# True ATT is 3.0, estimate should be close
assert results.att > 0
assert abs(results.att - 3.0) < 1.0 # Within 1 unit
def test_formula_interface(self, simple_2x2_data):
"""Test formula-based fitting."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, formula="outcome ~ treated * post")
assert isinstance(results, DiDResults)
assert did.is_fitted_
def test_formula_with_explicit_interaction(self, simple_2x2_data):
"""Test formula with explicit interaction syntax."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, formula="outcome ~ treated + post + treated:post")
assert isinstance(results, DiDResults)
def test_robust_vs_classical_se(self, simple_did_data):
"""Test that robust and classical SEs differ."""
did_robust = DifferenceInDifferences(robust=True)
did_classical = DifferenceInDifferences(robust=False)
results_robust = did_robust.fit(
simple_did_data, outcome="outcome", treatment="treated", time="post"
)
results_classical = did_classical.fit(
simple_did_data, outcome="outcome", treatment="treated", time="post"
)
# The vcov matrices should differ (HC1 vs classical)
# Note: For balanced designs with homoskedastic errors, the ATT SE
# may coincidentally be equal, but other coefficients will differ
assert not np.allclose(results_robust.vcov, results_classical.vcov)
# But ATT should be the same
assert results_robust.att == results_classical.att
def test_confidence_interval(self, simple_did_data):
"""Test confidence interval properties."""
did = DifferenceInDifferences(alpha=0.05)
results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post")
lower, upper = results.conf_int
assert lower < results.att < upper
assert lower < upper
def test_get_set_params(self):
"""Test sklearn-compatible get_params and set_params."""
did = DifferenceInDifferences(robust=True, alpha=0.05)
params = did.get_params()
assert params["robust"] is True
assert params["alpha"] == 0.05
did.set_params(alpha=0.10)
assert did.alpha == 0.10
def test_summary_output(self, simple_2x2_data):
"""Test that summary produces string output."""
did = DifferenceInDifferences()
did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post")
summary = did.summary()
assert isinstance(summary, str)
assert "ATT" in summary
assert "Difference-in-Differences" in summary
def test_invalid_treatment_values(self):
"""Test error on non-binary treatment."""
data = pd.DataFrame(
{
"outcome": [1, 2, 3, 4],
"treated": [0, 1, 2, 3], # Invalid: not binary
"post": [0, 0, 1, 1],
}
)
did = DifferenceInDifferences()
with pytest.raises(ValueError, match="binary"):
did.fit(data, outcome="outcome", treatment="treated", time="post")
def test_missing_column_error(self):
"""Test error when column is missing."""
data = pd.DataFrame(
{
"outcome": [1, 2, 3, 4],
"treated": [0, 0, 1, 1],
}
)
did = DifferenceInDifferences()
with pytest.raises(ValueError, match="Missing columns"):
did.fit(data, outcome="outcome", treatment="treated", time="post")
def test_unfitted_model_error(self):
"""Test error when accessing results before fitting."""
did = DifferenceInDifferences()
with pytest.raises(RuntimeError, match="fitted"):
did.summary()
def test_rank_deficient_action_error_raises(self, simple_2x2_data):
"""Test that rank_deficient_action='error' raises ValueError on collinear data."""
# Add a covariate that is perfectly collinear with treatment
data = simple_2x2_data.copy()
data["collinear_cov"] = data["treated"].copy()
did = DifferenceInDifferences(rank_deficient_action="error")
with pytest.raises(ValueError, match="rank-deficient"):
did.fit(
data,
outcome="outcome",
treatment="treated",
time="post",
covariates=["collinear_cov"],
)
def test_rank_deficient_action_silent_no_warning(self, simple_2x2_data):
"""Test that rank_deficient_action='silent' produces no warning."""
import warnings
# Add a covariate that is perfectly collinear with treatment
data = simple_2x2_data.copy()
data["collinear_cov"] = data["treated"].copy()
did = DifferenceInDifferences(rank_deficient_action="silent")
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
results = did.fit(
data,
outcome="outcome",
treatment="treated",
time="post",
covariates=["collinear_cov"],
)
# No warnings about rank deficiency should be emitted
rank_warnings = [
x
for x in w
if "Rank-deficient" in str(x.message) or "rank-deficient" in str(x.message).lower()
]
assert len(rank_warnings) == 0, f"Expected no rank warnings, got {rank_warnings}"
# Should still have NaN for dropped coefficient
assert "collinear_cov" in results.coefficients
# Either collinear_cov or treated will be NaN
has_nan = np.isnan(results.coefficients.get("collinear_cov", 0)) or np.isnan(
results.coefficients.get("treated", 0)
)
assert has_nan, "Expected NaN for one of the collinear coefficients"
def test_rank_deficient_action_warn_default(self, simple_2x2_data):
"""Test that rank_deficient_action='warn' (default) emits warning."""
import warnings
# Add a covariate that is perfectly collinear with treatment
data = simple_2x2_data.copy()
data["collinear_cov"] = data["treated"].copy()
did = DifferenceInDifferences() # Default is "warn"
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
results = did.fit(
data,
outcome="outcome",
treatment="treated",
time="post",
covariates=["collinear_cov"],
)
# Should have a warning about rank deficiency
rank_warnings = [
x
for x in w
if "Rank-deficient" in str(x.message) or "rank-deficient" in str(x.message).lower()
]
assert len(rank_warnings) > 0, "Expected warning about rank deficiency"
class TestDiDResults:
"""Tests for DiDResults class."""
def test_repr(self, simple_2x2_data):
"""Test string representation."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post")
repr_str = repr(results)
assert "DiDResults" in repr_str
assert "ATT=" in repr_str
def test_to_dict(self, simple_2x2_data):
"""Test conversion to dictionary."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post")
result_dict = results.to_dict()
assert "att" in result_dict
assert "se" in result_dict
assert "p_value" in result_dict
def test_to_dataframe(self, simple_2x2_data):
"""Test conversion to DataFrame."""
did = DifferenceInDifferences()
results = did.fit(simple_2x2_data, outcome="outcome", treatment="treated", time="post")
df = results.to_dataframe()
assert isinstance(df, pd.DataFrame)
assert len(df) == 1
assert "att" in df.columns
def test_significance_stars(self, simple_did_data):
"""Test significance star notation."""
did = DifferenceInDifferences()
results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post")
# With true effect of 3.0 and n=200, should be significant
assert results.significance_stars in ["*", "**", "***"]
def test_is_significant_property(self, simple_did_data):
"""Test is_significant property."""
did = DifferenceInDifferences(alpha=0.05)
results = did.fit(simple_did_data, outcome="outcome", treatment="treated", time="post")
# Boolean check
assert isinstance(results.is_significant, bool)
# With true effect, should be significant
assert results.is_significant
class TestFixedEffects:
"""Tests for fixed effects functionality."""
@pytest.fixture
def panel_data_with_fe(self):
"""Create panel data with fixed effects."""
np.random.seed(42)
n_units = 50
n_periods = 4
n_states = 5
data = []
for unit in range(n_units):
state = unit % n_states
is_treated = unit < n_units // 2
# State-level effect
state_effect = state * 2.0
for period in range(n_periods):
post = 1 if period >= 2 else 0
y = 10.0 + state_effect + period * 0.5
if is_treated and post:
y += 3.0 # True ATT
y += np.random.normal(0, 0.5)
data.append(
{
"unit": unit,
"state": f"state_{state}",
"period": period,
"treated": int(is_treated),
"post": post,
"outcome": y,
}
)
return pd.DataFrame(data)
def test_fixed_effects_dummy(self, panel_data_with_fe):
"""Test fixed effects using dummy variables."""
did = DifferenceInDifferences()
results = did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
fixed_effects=["state"],
)
assert results is not None
assert did.is_fitted_
# ATT should still be close to 3.0
assert abs(results.att - 3.0) < 1.0
def test_fixed_effects_coefficients_include_dummies(self, panel_data_with_fe):
"""Test that dummy coefficients are included in results."""
did = DifferenceInDifferences()
results = did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
fixed_effects=["state"],
)
# Should have state dummy coefficients
state_coefs = [k for k in results.coefficients.keys() if k.startswith("state_")]
assert len(state_coefs) == 4 # 5 states - 1 (dropped first)
def test_absorb_fixed_effects(self, panel_data_with_fe):
"""Test absorbed (within-transformed) fixed effects."""
did = DifferenceInDifferences()
results = did.fit(
panel_data_with_fe, outcome="outcome", treatment="treated", time="post", absorb=["unit"]
)
assert results is not None
assert did.is_fitted_
# ATT should still be close to 3.0
assert abs(results.att - 3.0) < 1.0
def test_fixed_effects_vs_no_fe(self, panel_data_with_fe):
"""Test that FE produces different (usually better) estimates."""
did_no_fe = DifferenceInDifferences()
did_with_fe = DifferenceInDifferences()
results_no_fe = did_no_fe.fit(
panel_data_with_fe, outcome="outcome", treatment="treated", time="post"
)
results_with_fe = did_with_fe.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
fixed_effects=["state"],
)
# Both should estimate positive ATT
assert results_no_fe.att > 0
assert results_with_fe.att > 0
# FE model should have higher R-squared (explains more variance)
assert results_with_fe.r_squared >= results_no_fe.r_squared
def test_invalid_fixed_effects_column(self, panel_data_with_fe):
"""Test error when fixed effects column doesn't exist."""
did = DifferenceInDifferences()
with pytest.raises(ValueError, match="not found"):
did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
fixed_effects=["nonexistent_column"],
)
def test_invalid_absorb_column(self, panel_data_with_fe):
"""Test error when absorb column doesn't exist."""
did = DifferenceInDifferences()
with pytest.raises(ValueError, match="not found"):
did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
absorb=["nonexistent_column"],
)
def test_multiple_fixed_effects(self, panel_data_with_fe):
"""Test multiple fixed effects."""
# Add another categorical variable
panel_data_with_fe["industry"] = panel_data_with_fe["unit"] % 3
did = DifferenceInDifferences()
results = did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
fixed_effects=["state", "industry"],
)
assert results is not None
# Should have both state and industry dummies
state_coefs = [k for k in results.coefficients.keys() if k.startswith("state_")]
industry_coefs = [k for k in results.coefficients.keys() if k.startswith("industry_")]
assert len(state_coefs) > 0
assert len(industry_coefs) > 0
def test_covariates_with_fixed_effects(self, panel_data_with_fe):
"""Test combining covariates with fixed effects."""
# Add a continuous covariate
panel_data_with_fe["size"] = np.random.normal(100, 10, len(panel_data_with_fe))
did = DifferenceInDifferences()
results = did.fit(
panel_data_with_fe,
outcome="outcome",
treatment="treated",
time="post",
covariates=["size"],
fixed_effects=["state"],
)
assert results is not None
assert "size" in results.coefficients
class TestParallelTrendsRobust:
"""Tests for robust parallel trends checking."""
@pytest.fixture
def parallel_trends_data(self):
"""Create panel data where parallel trends holds."""
np.random.seed(42)
n_units = 100
n_periods = 6 # 3 pre, 3 post
data = []
for unit in range(n_units):
is_treated = unit < n_units // 2
unit_effect = np.random.normal(0, 2)
for period in range(n_periods):
# Common trend for both groups
time_effect = period * 1.5
y = 10.0 + unit_effect + time_effect
# Treatment effect only in post period (period >= 3)
if is_treated and period >= 3:
y += 5.0
y += np.random.normal(0, 0.5)
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated),
"outcome": y,
}
)
return pd.DataFrame(data)
@pytest.fixture
def non_parallel_trends_data(self):
"""Create panel data where parallel trends is violated."""
np.random.seed(42)
n_units = 100
n_periods = 6
data = []
for unit in range(n_units):
is_treated = unit < n_units // 2
unit_effect = np.random.normal(0, 2)
for period in range(n_periods):
# Different trends for treated vs control
if is_treated:
time_effect = period * 3.0 # Steeper trend
else:
time_effect = period * 1.0 # Flatter trend
y = 10.0 + unit_effect + time_effect
# Treatment effect in post period
if is_treated and period >= 3:
y += 5.0
y += np.random.normal(0, 0.5)
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated),
"outcome": y,
}
)
return pd.DataFrame(data)
def test_wasserstein_parallel_trends_valid(self, parallel_trends_data):
"""Test Wasserstein check when parallel trends holds."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
seed=42,
)
assert "wasserstein_distance" in results
assert "wasserstein_p_value" in results
assert "ks_statistic" in results
# When trends are parallel, p-value should be high
assert results["wasserstein_p_value"] > 0.05
assert results["parallel_trends_plausible"] is True
def test_wasserstein_parallel_trends_violated(self, non_parallel_trends_data):
"""Test Wasserstein check when parallel trends is violated."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
non_parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
seed=42,
)
# When trends are not parallel, should detect it
# Either low p-value or high normalized Wasserstein
assert results["wasserstein_distance"] > 0
# The test should flag this as problematic
assert results["parallel_trends_plausible"] is False
def test_wasserstein_returns_changes(self, parallel_trends_data):
"""Test that changes arrays are returned."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
seed=42,
)
assert "treated_changes" in results
assert "control_changes" in results
assert len(results["treated_changes"]) > 0
assert len(results["control_changes"]) > 0
def test_wasserstein_without_unit(self, parallel_trends_data):
"""Test Wasserstein check without unit specification."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
pre_periods=[0, 1, 2],
seed=42,
)
assert "wasserstein_distance" in results
assert not np.isnan(results["wasserstein_distance"])
def test_equivalence_test_parallel(self, parallel_trends_data):
"""Test equivalence testing when trends are parallel."""
from diff_diff.utils import equivalence_test_trends
results = equivalence_test_trends(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
)
assert "tost_p_value" in results
assert "equivalent" in results
assert "equivalence_margin" in results
# When trends are parallel, should be equivalent
assert results["equivalent"] is True
def test_equivalence_test_non_parallel(self, non_parallel_trends_data):
"""Test equivalence testing when trends are not parallel."""
from diff_diff.utils import equivalence_test_trends
results = equivalence_test_trends(
non_parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
)
# When trends are not parallel, should not be equivalent
assert results["equivalent"] is False
def test_equivalence_test_custom_margin(self, parallel_trends_data):
"""Test equivalence testing with custom margin."""
from diff_diff.utils import equivalence_test_trends
results = equivalence_test_trends(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
equivalence_margin=0.1, # Very tight margin
)
assert results["equivalence_margin"] == 0.1
def test_ks_test_included(self, parallel_trends_data):
"""Test that KS test results are included."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
seed=42,
)
assert "ks_statistic" in results
assert "ks_p_value" in results
assert 0 <= results["ks_statistic"] <= 1
assert 0 <= results["ks_p_value"] <= 1
def test_variance_ratio(self, parallel_trends_data):
"""Test that variance ratio is computed."""
from diff_diff.utils import check_parallel_trends_robust
results = check_parallel_trends_robust(
parallel_trends_data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1, 2],
seed=42,
)
assert "variance_ratio" in results
assert results["variance_ratio"] > 0
class TestEdgeCases:
"""Tests for edge cases and robustness."""
def test_multicollinearity_detection(self):
"""Test that perfect multicollinearity is detected and warning is emitted."""
import warnings
# Create data where a covariate is perfectly correlated with treatment
data = pd.DataFrame(
{
"outcome": [10, 11, 15, 18, 9, 10, 12, 13],
"treated": [1, 1, 1, 1, 0, 0, 0, 0],
"post": [0, 0, 1, 1, 0, 0, 1, 1],
"duplicate_treated": [1, 1, 1, 1, 0, 0, 0, 0], # Same as treated
}
)
did = DifferenceInDifferences()
# With R-style rank deficiency handling, a warning is emitted
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = did.fit(
data,
outcome="outcome",
treatment="treated",
time="post",
covariates=["duplicate_treated"],
)
# Should emit a warning about rank deficiency
rank_warnings = [x for x in w if "Rank-deficient" in str(x.message)]
assert len(rank_warnings) > 0, "Expected warning about rank deficiency"
# ATT should still be finite
assert np.isfinite(result.att)
def test_wasserstein_custom_threshold(self):
"""Test that custom Wasserstein threshold is respected."""
from diff_diff.utils import check_parallel_trends_robust
np.random.seed(42)
n_units = 50
n_periods = 4
data = []
for unit in range(n_units):
is_treated = unit < n_units // 2
for period in range(n_periods):
y = 10.0 + period * 1.5 + np.random.normal(0, 0.5)
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated),
"outcome": y,
}
)
df = pd.DataFrame(data)
# Test with very low threshold (more strict)
results_strict = check_parallel_trends_robust(
df,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1],
seed=42,
wasserstein_threshold=0.01, # Very strict
)
# Test with high threshold (more lenient)
results_lenient = check_parallel_trends_robust(
df,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0, 1],
seed=42,
wasserstein_threshold=1.0, # Very lenient
)
# Both should return valid results
assert "wasserstein_distance" in results_strict
assert "wasserstein_distance" in results_lenient
def test_equivalence_test_insufficient_data(self):
"""Test equivalence test handles insufficient data gracefully."""
from diff_diff.utils import equivalence_test_trends
# Create minimal data with only 1 observation per group
data = pd.DataFrame(
{
"outcome": [10, 15],
"period": [0, 1],
"treated": [1, 0],
"unit": [0, 1],
}
)
results = equivalence_test_trends(
data,
outcome="outcome",
time="period",
treatment_group="treated",
unit="unit",
pre_periods=[0],
)
# Should return NaN values with error message
assert np.isnan(results["tost_p_value"])
assert results["equivalent"] is None
assert "error" in results
def test_parallel_trends_single_period(self):
"""Test that single pre-period returns NaN values."""
from diff_diff.utils import check_parallel_trends
data = pd.DataFrame(
{
"outcome": [10, 11, 12, 13],
"time": [0, 0, 0, 0], # All same period
"treated": [1, 1, 0, 0],
}
)
results = check_parallel_trends(
data, outcome="outcome", time="time", treatment_group="treated", pre_periods=[0]
)
# Should handle gracefully with NaN
assert np.isnan(results["treated_trend"]) or results["treated_trend"] is None
class TestTwoWayFixedEffects:
"""Tests for TwoWayFixedEffects estimator."""
@pytest.fixture
def twfe_panel_data(self):
"""Create panel data for TWFE testing."""
np.random.seed(42)
n_units = 20
n_periods = 4
data = []
for unit in range(n_units):
is_treated = unit < n_units // 2
unit_effect = np.random.normal(0, 2)
for period in range(n_periods):
time_effect = period * 1.0
post = 1 if period >= 2 else 0
y = 10.0 + unit_effect + time_effect
if is_treated and post:
y += 3.0 # True ATT
y += np.random.normal(0, 0.5)
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated),
"post": post,
"outcome": y,
}
)
return pd.DataFrame(data)
def test_twfe_basic_fit(self, twfe_panel_data):
"""Test basic TWFE model fitting."""
from diff_diff.estimators import TwoWayFixedEffects
twfe = TwoWayFixedEffects()
results = twfe.fit(
twfe_panel_data, outcome="outcome", treatment="treated", time="post", unit="unit"
)
assert results is not None
assert twfe.is_fitted_
# ATT should be positive (true effect is 3.0)
# Note: TWFE with within-transformation may give different estimates
# due to the mechanics of two-way demeaning
assert results.att > 0
assert results.se > 0
def test_twfe_with_covariates(self, twfe_panel_data):
"""Test TWFE with covariates."""
from diff_diff.estimators import TwoWayFixedEffects
# Add a covariate
twfe_panel_data["size"] = np.random.normal(100, 10, len(twfe_panel_data))
twfe = TwoWayFixedEffects()
results = twfe.fit(
twfe_panel_data,
outcome="outcome",
treatment="treated",
time="post",
unit="unit",
covariates=["size"],
)
assert results is not None
assert twfe.is_fitted_
def test_twfe_invalid_unit_column(self, twfe_panel_data):
"""Test error when unit column doesn't exist."""
from diff_diff.estimators import TwoWayFixedEffects
twfe = TwoWayFixedEffects()
with pytest.raises(ValueError, match="not found"):
twfe.fit(
twfe_panel_data,
outcome="outcome",
treatment="treated",
time="post",
unit="nonexistent_unit",
)
def test_twfe_clusters_at_unit_level(self, twfe_panel_data):
"""Test that TWFE defaults to clustering at unit level."""
from diff_diff.estimators import TwoWayFixedEffects
twfe = TwoWayFixedEffects()
results = twfe.fit(
twfe_panel_data, outcome="outcome", treatment="treated", time="post", unit="unit"
)
# Cluster should NOT be mutated (remains None) - clustering is handled internally
# This ensures the estimator config is immutable as per sklearn convention
assert twfe.cluster is None
# But the results should still reflect cluster-robust SEs were computed correctly
assert results.se > 0
def test_twfe_treatment_collinearity_raises_error(self):
"""Test that TWFE raises informative error when treatment is collinear."""
from diff_diff.estimators import TwoWayFixedEffects
# Create data where treatment is perfectly collinear with fixed effects
# (all treated units are treated in all periods)
data = []
for unit in range(10):
is_treated = unit < 5
for period in range(4):
data.append(
{
"unit": unit,
"period": period,
"treated": int(is_treated), # Same for all periods
"post": 1 if period >= 2 else 0,
"outcome": 10.0 + unit * 0.5 + period * 0.3 + np.random.normal(0, 0.1),
}
)
df = pd.DataFrame(data)
# Make treatment_post constant for treated units (collinear)
# by making treatment only occur in post periods
df_collinear = df.copy()