diff-diff 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1145 @@
1
+ Metadata-Version: 2.4
2
+ Name: diff-diff
3
+ Version: 0.3.0
4
+ Summary: A library for Difference-in-Differences causal inference analysis
5
+ Author: diff-diff contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/igerber/diff-diff
8
+ Project-URL: Documentation, https://github.com/igerber/diff-diff#readme
9
+ Project-URL: Repository, https://github.com/igerber/diff-diff
10
+ Project-URL: Issues, https://github.com/igerber/diff-diff/issues
11
+ Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: numpy>=1.20.0
24
+ Requires-Dist: pandas>=1.3.0
25
+ Requires-Dist: scipy>=1.7.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
+ Requires-Dist: black>=23.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.0; extra == "dev"
32
+ Provides-Extra: docs
33
+ Requires-Dist: sphinx>=6.0; extra == "docs"
34
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == "docs"
35
+
36
+ # diff-diff
37
+
38
+ A Python library for Difference-in-Differences (DiD) causal inference analysis with an sklearn-like API and statsmodels-style outputs.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install diff-diff
44
+ ```
45
+
46
+ Or install from source:
47
+
48
+ ```bash
49
+ git clone https://github.com/igerber/diff-diff.git
50
+ cd diff-diff
51
+ pip install -e .
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ```python
57
+ import pandas as pd
58
+ from diff_diff import DifferenceInDifferences
59
+
60
+ # Create sample data
61
+ data = pd.DataFrame({
62
+ 'outcome': [10, 11, 15, 18, 9, 10, 12, 13],
63
+ 'treated': [1, 1, 1, 1, 0, 0, 0, 0],
64
+ 'post': [0, 0, 1, 1, 0, 0, 1, 1]
65
+ })
66
+
67
+ # Fit the model
68
+ did = DifferenceInDifferences()
69
+ results = did.fit(data, outcome='outcome', treatment='treated', time='post')
70
+
71
+ # View results
72
+ print(results) # DiDResults(ATT=3.5000*, SE=1.2583, p=0.0367)
73
+ results.print_summary()
74
+ ```
75
+
76
+ Output:
77
+ ```
78
+ ======================================================================
79
+ Difference-in-Differences Estimation Results
80
+ ======================================================================
81
+
82
+ Observations: 8
83
+ Treated units: 4
84
+ Control units: 4
85
+ R-squared: 0.9123
86
+
87
+ ----------------------------------------------------------------------
88
+ Parameter Estimate Std. Err. t-stat P>|t|
89
+ ----------------------------------------------------------------------
90
+ ATT 3.5000 1.2583 2.782 0.0367
91
+ ----------------------------------------------------------------------
92
+
93
+ 95% Confidence Interval: [0.3912, 6.6088]
94
+
95
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
96
+ ======================================================================
97
+ ```
98
+
99
+ ## Features
100
+
101
+ - **sklearn-like API**: Familiar `fit()` interface with `get_params()` and `set_params()`
102
+ - **Pythonic results**: Easy access to coefficients, standard errors, and confidence intervals
103
+ - **Multiple interfaces**: Column names or R-style formulas
104
+ - **Robust inference**: Heteroskedasticity-robust (HC1) and cluster-robust standard errors
105
+ - **Panel data support**: Two-way fixed effects estimator for panel designs
106
+ - **Multi-period analysis**: Event-study style DiD with period-specific treatment effects
107
+ - **Synthetic DiD**: Combined DiD with synthetic control for improved robustness
108
+ - **Data prep utilities**: Helper functions for common data preparation tasks
109
+
110
+ ## Data Preparation
111
+
112
+ diff-diff provides utility functions to help prepare your data for DiD analysis. These functions handle common data transformation tasks like creating treatment indicators, reshaping panel data, and validating data formats.
113
+
114
+ ### Generate Sample Data
115
+
116
+ Create synthetic data with a known treatment effect for testing and learning:
117
+
118
+ ```python
119
+ from diff_diff import generate_did_data, DifferenceInDifferences
120
+
121
+ # Generate panel data with 100 units, 4 periods, and a treatment effect of 5
122
+ data = generate_did_data(
123
+ n_units=100,
124
+ n_periods=4,
125
+ treatment_effect=5.0,
126
+ treatment_fraction=0.5, # 50% of units are treated
127
+ treatment_period=2, # Treatment starts at period 2
128
+ seed=42
129
+ )
130
+
131
+ # Verify the estimator recovers the treatment effect
132
+ did = DifferenceInDifferences()
133
+ results = did.fit(data, outcome='outcome', treatment='treated', time='post')
134
+ print(f"Estimated ATT: {results.att:.2f} (true: 5.0)")
135
+ ```
136
+
137
+ ### Create Treatment Indicators
138
+
139
+ Convert categorical variables or numeric thresholds to binary treatment indicators:
140
+
141
+ ```python
142
+ from diff_diff import make_treatment_indicator
143
+
144
+ # From categorical variable
145
+ df = make_treatment_indicator(
146
+ data,
147
+ column='state',
148
+ treated_values=['CA', 'NY', 'TX'] # These states are treated
149
+ )
150
+
151
+ # From numeric threshold (e.g., firms above median size)
152
+ df = make_treatment_indicator(
153
+ data,
154
+ column='firm_size',
155
+ threshold=data['firm_size'].median()
156
+ )
157
+
158
+ # Treat units below threshold
159
+ df = make_treatment_indicator(
160
+ data,
161
+ column='income',
162
+ threshold=50000,
163
+ above_threshold=False # Units with income <= 50000 are treated
164
+ )
165
+ ```
166
+
167
+ ### Create Post-Treatment Indicators
168
+
169
+ Convert time/date columns to binary post-treatment indicators:
170
+
171
+ ```python
172
+ from diff_diff import make_post_indicator
173
+
174
+ # From specific post-treatment periods
175
+ df = make_post_indicator(
176
+ data,
177
+ time_column='year',
178
+ post_periods=[2020, 2021, 2022]
179
+ )
180
+
181
+ # From treatment start date
182
+ df = make_post_indicator(
183
+ data,
184
+ time_column='year',
185
+ treatment_start=2020 # All years >= 2020 are post-treatment
186
+ )
187
+
188
+ # Works with datetime columns
189
+ df = make_post_indicator(
190
+ data,
191
+ time_column='date',
192
+ treatment_start='2020-01-01'
193
+ )
194
+ ```
195
+
196
+ ### Reshape Wide to Long Format
197
+
198
+ Convert wide-format data (one row per unit, multiple time columns) to long format:
199
+
200
+ ```python
201
+ from diff_diff import wide_to_long
202
+
203
+ # Wide format: columns like sales_2019, sales_2020, sales_2021
204
+ wide_df = pd.DataFrame({
205
+ 'firm_id': [1, 2, 3],
206
+ 'industry': ['tech', 'retail', 'tech'],
207
+ 'sales_2019': [100, 150, 200],
208
+ 'sales_2020': [110, 160, 210],
209
+ 'sales_2021': [120, 170, 220]
210
+ })
211
+
212
+ # Convert to long format for DiD
213
+ long_df = wide_to_long(
214
+ wide_df,
215
+ value_columns=['sales_2019', 'sales_2020', 'sales_2021'],
216
+ id_column='firm_id',
217
+ time_name='year',
218
+ value_name='sales',
219
+ time_values=[2019, 2020, 2021]
220
+ )
221
+ # Result: 9 rows (3 firms × 3 years), columns: firm_id, year, sales, industry
222
+ ```
223
+
224
+ ### Balance Panel Data
225
+
226
+ Ensure all units have observations for all time periods:
227
+
228
+ ```python
229
+ from diff_diff import balance_panel
230
+
231
+ # Keep only units with complete data (drop incomplete units)
232
+ balanced = balance_panel(
233
+ data,
234
+ unit_column='firm_id',
235
+ time_column='year',
236
+ method='inner'
237
+ )
238
+
239
+ # Include all unit-period combinations (creates NaN for missing)
240
+ balanced = balance_panel(
241
+ data,
242
+ unit_column='firm_id',
243
+ time_column='year',
244
+ method='outer'
245
+ )
246
+
247
+ # Fill missing values
248
+ balanced = balance_panel(
249
+ data,
250
+ unit_column='firm_id',
251
+ time_column='year',
252
+ method='fill',
253
+ fill_value=0 # Or None for forward/backward fill
254
+ )
255
+ ```
256
+
257
+ ### Validate Data
258
+
259
+ Check that your data meets DiD requirements before fitting:
260
+
261
+ ```python
262
+ from diff_diff import validate_did_data
263
+
264
+ # Validate and get informative error messages
265
+ result = validate_did_data(
266
+ data,
267
+ outcome='sales',
268
+ treatment='treated',
269
+ time='post',
270
+ unit='firm_id', # Optional: for panel-specific validation
271
+ raise_on_error=False # Return dict instead of raising
272
+ )
273
+
274
+ if result['valid']:
275
+ print("Data is ready for DiD analysis!")
276
+ print(f"Summary: {result['summary']}")
277
+ else:
278
+ print("Issues found:")
279
+ for error in result['errors']:
280
+ print(f" - {error}")
281
+
282
+ for warning in result['warnings']:
283
+ print(f"Warning: {warning}")
284
+ ```
285
+
286
+ ### Summarize Data by Groups
287
+
288
+ Get summary statistics for each treatment-time cell:
289
+
290
+ ```python
291
+ from diff_diff import summarize_did_data
292
+
293
+ summary = summarize_did_data(
294
+ data,
295
+ outcome='sales',
296
+ treatment='treated',
297
+ time='post'
298
+ )
299
+ print(summary)
300
+ ```
301
+
302
+ Output:
303
+ ```
304
+ n mean std min max
305
+ Control - Pre 250 100.5000 15.2340 65.0000 145.0000
306
+ Control - Post 250 105.2000 16.1230 68.0000 152.0000
307
+ Treated - Pre 250 101.2000 14.8900 67.0000 143.0000
308
+ Treated - Post 250 115.8000 17.5600 72.0000 165.0000
309
+ DiD Estimate - 9.9000 - - -
310
+ ```
311
+
312
+ ### Create Event Time for Staggered Designs
313
+
314
+ For designs where treatment occurs at different times:
315
+
316
+ ```python
317
+ from diff_diff import create_event_time
318
+
319
+ # Add event-time column relative to treatment timing
320
+ df = create_event_time(
321
+ data,
322
+ time_column='year',
323
+ treatment_time_column='treatment_year'
324
+ )
325
+ # Result: event_time = -2, -1, 0, 1, 2 relative to treatment
326
+ ```
327
+
328
+ ### Aggregate to Cohort Means
329
+
330
+ Aggregate unit-level data for visualization:
331
+
332
+ ```python
333
+ from diff_diff import aggregate_to_cohorts
334
+
335
+ cohort_data = aggregate_to_cohorts(
336
+ data,
337
+ unit_column='firm_id',
338
+ time_column='year',
339
+ treatment_column='treated',
340
+ outcome='sales'
341
+ )
342
+ # Result: mean outcome by treatment group and period
343
+ ```
344
+
345
+ ## Usage
346
+
347
+ ### Basic DiD with Column Names
348
+
349
+ ```python
350
+ from diff_diff import DifferenceInDifferences
351
+
352
+ did = DifferenceInDifferences(robust=True, alpha=0.05)
353
+ results = did.fit(
354
+ data,
355
+ outcome='sales',
356
+ treatment='treated',
357
+ time='post_policy'
358
+ )
359
+
360
+ # Access results
361
+ print(f"ATT: {results.att:.4f}")
362
+ print(f"Standard Error: {results.se:.4f}")
363
+ print(f"P-value: {results.p_value:.4f}")
364
+ print(f"95% CI: {results.conf_int}")
365
+ print(f"Significant: {results.is_significant}")
366
+ ```
367
+
368
+ ### Using Formula Interface
369
+
370
+ ```python
371
+ # R-style formula syntax
372
+ results = did.fit(data, formula='outcome ~ treated * post')
373
+
374
+ # Explicit interaction syntax
375
+ results = did.fit(data, formula='outcome ~ treated + post + treated:post')
376
+
377
+ # With covariates
378
+ results = did.fit(data, formula='outcome ~ treated * post + age + income')
379
+ ```
380
+
381
+ ### Including Covariates
382
+
383
+ ```python
384
+ results = did.fit(
385
+ data,
386
+ outcome='outcome',
387
+ treatment='treated',
388
+ time='post',
389
+ covariates=['age', 'income', 'education']
390
+ )
391
+ ```
392
+
393
+ ### Fixed Effects
394
+
395
+ Use `fixed_effects` for low-dimensional categorical controls (creates dummy variables):
396
+
397
+ ```python
398
+ # State and industry fixed effects
399
+ results = did.fit(
400
+ data,
401
+ outcome='sales',
402
+ treatment='treated',
403
+ time='post',
404
+ fixed_effects=['state', 'industry']
405
+ )
406
+
407
+ # Access fixed effect coefficients
408
+ state_coefs = {k: v for k, v in results.coefficients.items() if k.startswith('state_')}
409
+ ```
410
+
411
+ Use `absorb` for high-dimensional fixed effects (more efficient, uses within-transformation):
412
+
413
+ ```python
414
+ # Absorb firm-level fixed effects (efficient for many firms)
415
+ results = did.fit(
416
+ data,
417
+ outcome='sales',
418
+ treatment='treated',
419
+ time='post',
420
+ absorb=['firm_id']
421
+ )
422
+ ```
423
+
424
+ Combine covariates with fixed effects:
425
+
426
+ ```python
427
+ results = did.fit(
428
+ data,
429
+ outcome='sales',
430
+ treatment='treated',
431
+ time='post',
432
+ covariates=['size', 'age'], # Linear controls
433
+ fixed_effects=['industry'], # Low-dimensional FE (dummies)
434
+ absorb=['firm_id'] # High-dimensional FE (absorbed)
435
+ )
436
+ ```
437
+
438
+ ### Cluster-Robust Standard Errors
439
+
440
+ ```python
441
+ did = DifferenceInDifferences(cluster='state')
442
+ results = did.fit(
443
+ data,
444
+ outcome='outcome',
445
+ treatment='treated',
446
+ time='post'
447
+ )
448
+ ```
449
+
450
+ ### Two-Way Fixed Effects (Panel Data)
451
+
452
+ ```python
453
+ from diff_diff.estimators import TwoWayFixedEffects
454
+
455
+ twfe = TwoWayFixedEffects()
456
+ results = twfe.fit(
457
+ panel_data,
458
+ outcome='outcome',
459
+ treatment='treated',
460
+ time='year',
461
+ unit='firm_id'
462
+ )
463
+ ```
464
+
465
+ ### Multi-Period DiD (Event Study)
466
+
467
+ For settings with multiple pre- and post-treatment periods:
468
+
469
+ ```python
470
+ from diff_diff import MultiPeriodDiD
471
+
472
+ # Fit with multiple time periods
473
+ did = MultiPeriodDiD()
474
+ results = did.fit(
475
+ panel_data,
476
+ outcome='sales',
477
+ treatment='treated',
478
+ time='period',
479
+ post_periods=[3, 4, 5], # Periods 3-5 are post-treatment
480
+ reference_period=0 # Reference period for comparison
481
+ )
482
+
483
+ # View period-specific treatment effects
484
+ for period, effect in results.period_effects.items():
485
+ print(f"Period {period}: {effect.effect:.3f} (SE: {effect.se:.3f})")
486
+
487
+ # View average treatment effect across post-periods
488
+ print(f"Average ATT: {results.avg_att:.3f}")
489
+ print(f"Average SE: {results.avg_se:.3f}")
490
+
491
+ # Full summary with all period effects
492
+ results.print_summary()
493
+ ```
494
+
495
+ Output:
496
+ ```
497
+ ================================================================================
498
+ Multi-Period Difference-in-Differences Estimation Results
499
+ ================================================================================
500
+
501
+ Observations: 600
502
+ Pre-treatment periods: 3
503
+ Post-treatment periods: 3
504
+
505
+ --------------------------------------------------------------------------------
506
+ Average Treatment Effect
507
+ --------------------------------------------------------------------------------
508
+ Average ATT 5.2000 0.8234 6.315 0.0000
509
+ --------------------------------------------------------------------------------
510
+ 95% Confidence Interval: [3.5862, 6.8138]
511
+
512
+ Period-Specific Effects:
513
+ --------------------------------------------------------------------------------
514
+ Period Effect Std. Err. t-stat P>|t|
515
+ --------------------------------------------------------------------------------
516
+ 3 4.5000 0.9512 4.731 0.0000***
517
+ 4 5.2000 0.8876 5.858 0.0000***
518
+ 5 5.9000 0.9123 6.468 0.0000***
519
+ --------------------------------------------------------------------------------
520
+
521
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
522
+ ================================================================================
523
+ ```
524
+
525
+ ### Synthetic Difference-in-Differences
526
+
527
+ Synthetic DiD combines the strengths of Difference-in-Differences and Synthetic Control methods by re-weighting control units to better match treated units' pre-treatment outcomes.
528
+
529
+ ```python
530
+ from diff_diff import SyntheticDiD
531
+
532
+ # Fit Synthetic DiD model
533
+ sdid = SyntheticDiD()
534
+ results = sdid.fit(
535
+ panel_data,
536
+ outcome='gdp_growth',
537
+ treatment='treated',
538
+ unit='state',
539
+ time='year',
540
+ post_periods=[2015, 2016, 2017, 2018]
541
+ )
542
+
543
+ # View results
544
+ results.print_summary()
545
+ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
546
+
547
+ # Examine unit weights (which control units matter most)
548
+ weights_df = results.get_unit_weights_df()
549
+ print(weights_df.head(10))
550
+
551
+ # Examine time weights
552
+ time_weights_df = results.get_time_weights_df()
553
+ print(time_weights_df)
554
+ ```
555
+
556
+ Output:
557
+ ```
558
+ ===========================================================================
559
+ Synthetic Difference-in-Differences Estimation Results
560
+ ===========================================================================
561
+
562
+ Observations: 500
563
+ Treated units: 1
564
+ Control units: 49
565
+ Pre-treatment periods: 6
566
+ Post-treatment periods: 4
567
+ Regularization (lambda): 0.0000
568
+ Pre-treatment fit (RMSE): 0.1234
569
+
570
+ ---------------------------------------------------------------------------
571
+ Parameter Estimate Std. Err. t-stat P>|t|
572
+ ---------------------------------------------------------------------------
573
+ ATT 2.5000 0.4521 5.530 0.0000
574
+ ---------------------------------------------------------------------------
575
+
576
+ 95% Confidence Interval: [1.6139, 3.3861]
577
+
578
+ ---------------------------------------------------------------------------
579
+ Top Unit Weights (Synthetic Control)
580
+ ---------------------------------------------------------------------------
581
+ Unit state_12: 0.3521
582
+ Unit state_5: 0.2156
583
+ Unit state_23: 0.1834
584
+ Unit state_8: 0.1245
585
+ Unit state_31: 0.0892
586
+ (8 units with weight > 0.001)
587
+
588
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
589
+ ===========================================================================
590
+ ```
591
+
592
+ #### When to Use Synthetic DiD Over Vanilla DiD
593
+
594
+ Use Synthetic DiD instead of standard DiD when:
595
+
596
+ 1. **Few treated units**: When you have only one or a small number of treated units (e.g., a single state passed a policy), standard DiD averages across all controls equally. Synthetic DiD finds the optimal weighted combination of controls.
597
+
598
+ ```python
599
+ # Example: California passed a policy, want to estimate its effect
600
+ # Standard DiD would compare CA to the average of all other states
601
+ # Synthetic DiD finds states that together best match CA's pre-treatment trend
602
+ ```
603
+
604
+ 2. **Parallel trends is questionable**: When treated and control groups have different pre-treatment levels or trends, Synthetic DiD can construct a better counterfactual by matching the pre-treatment trajectory.
605
+
606
+ ```python
607
+ # Example: A tech hub city vs rural areas
608
+ # Rural areas may not be a good comparison on average
609
+ # Synthetic DiD can weight urban/suburban controls more heavily
610
+ ```
611
+
612
+ 3. **Heterogeneous control units**: When control units are very different from each other, equal weighting (as in standard DiD) is suboptimal.
613
+
614
+ ```python
615
+ # Example: Comparing a treated developing country to other countries
616
+ # Some control countries may be much more similar economically
617
+ # Synthetic DiD upweights the most comparable controls
618
+ ```
619
+
620
+ 4. **You want transparency**: Synthetic DiD provides explicit unit weights showing which controls contribute most to the comparison.
621
+
622
+ ```python
623
+ # See exactly which units are driving the counterfactual
624
+ print(results.get_unit_weights_df())
625
+ ```
626
+
627
+ **Key differences from standard DiD:**
628
+
629
+ | Aspect | Standard DiD | Synthetic DiD |
630
+ |--------|--------------|---------------|
631
+ | Control weighting | Equal (1/N) | Optimized to match pre-treatment |
632
+ | Time weighting | Equal across periods | Can emphasize informative periods |
633
+ | N treated required | Can be many | Works with 1 treated unit |
634
+ | Parallel trends | Assumed | Partially relaxed via matching |
635
+ | Interpretability | Simple average | Explicit weights |
636
+
637
+ **Parameters:**
638
+
639
+ ```python
640
+ SyntheticDiD(
641
+ lambda_reg=0.0, # Regularization toward uniform weights (0 = no reg)
642
+ zeta=1.0, # Time weight regularization (higher = more uniform)
643
+ alpha=0.05, # Significance level
644
+ n_bootstrap=200, # Bootstrap iterations for SE (0 = placebo-based)
645
+ seed=None # Random seed for reproducibility
646
+ )
647
+ ```
648
+
649
+ ## Working with Results
650
+
651
+ ### Export Results
652
+
653
+ ```python
654
+ # As dictionary
655
+ results.to_dict()
656
+ # {'att': 3.5, 'se': 1.26, 'p_value': 0.037, ...}
657
+
658
+ # As DataFrame
659
+ df = results.to_dataframe()
660
+ ```
661
+
662
+ ### Check Significance
663
+
664
+ ```python
665
+ if results.is_significant:
666
+ print(f"Effect is significant at {did.alpha} level")
667
+
668
+ # Get significance stars
669
+ print(f"ATT: {results.att}{results.significance_stars}")
670
+ # ATT: 3.5000*
671
+ ```
672
+
673
+ ### Access Full Regression Output
674
+
675
+ ```python
676
+ # All coefficients
677
+ results.coefficients
678
+ # {'const': 9.5, 'treated': 1.0, 'post': 2.5, 'treated:post': 3.5}
679
+
680
+ # Variance-covariance matrix
681
+ results.vcov
682
+
683
+ # Residuals and fitted values
684
+ results.residuals
685
+ results.fitted_values
686
+
687
+ # R-squared
688
+ results.r_squared
689
+ ```
690
+
691
+ ## Checking Assumptions
692
+
693
+ ### Parallel Trends
694
+
695
+ **Simple slope-based test:**
696
+
697
+ ```python
698
+ from diff_diff.utils import check_parallel_trends
699
+
700
+ trends = check_parallel_trends(
701
+ data,
702
+ outcome='outcome',
703
+ time='period',
704
+ treatment_group='treated'
705
+ )
706
+
707
+ print(f"Treated trend: {trends['treated_trend']:.4f}")
708
+ print(f"Control trend: {trends['control_trend']:.4f}")
709
+ print(f"Difference p-value: {trends['p_value']:.4f}")
710
+ ```
711
+
712
+ **Robust distributional test (Wasserstein distance):**
713
+
714
+ ```python
715
+ from diff_diff.utils import check_parallel_trends_robust
716
+
717
+ results = check_parallel_trends_robust(
718
+ data,
719
+ outcome='outcome',
720
+ time='period',
721
+ treatment_group='treated',
722
+ unit='firm_id', # Unit identifier for panel data
723
+ pre_periods=[2018, 2019], # Pre-treatment periods
724
+ n_permutations=1000 # Permutations for p-value
725
+ )
726
+
727
+ print(f"Wasserstein distance: {results['wasserstein_distance']:.4f}")
728
+ print(f"Wasserstein p-value: {results['wasserstein_p_value']:.4f}")
729
+ print(f"KS test p-value: {results['ks_p_value']:.4f}")
730
+ print(f"Parallel trends plausible: {results['parallel_trends_plausible']}")
731
+ ```
732
+
733
+ The Wasserstein (Earth Mover's) distance compares the full distribution of outcome changes, not just means. This is more robust to:
734
+ - Non-normal distributions
735
+ - Heterogeneous effects across units
736
+ - Outliers
737
+
738
+ **Equivalence testing (TOST):**
739
+
740
+ ```python
741
+ from diff_diff.utils import equivalence_test_trends
742
+
743
+ results = equivalence_test_trends(
744
+ data,
745
+ outcome='outcome',
746
+ time='period',
747
+ treatment_group='treated',
748
+ unit='firm_id',
749
+ equivalence_margin=0.5 # Define "practically equivalent"
750
+ )
751
+
752
+ print(f"Mean difference: {results['mean_difference']:.4f}")
753
+ print(f"TOST p-value: {results['tost_p_value']:.4f}")
754
+ print(f"Trends equivalent: {results['equivalent']}")
755
+ ```
756
+
757
+ ## API Reference
758
+
759
+ ### DifferenceInDifferences
760
+
761
+ ```python
762
+ DifferenceInDifferences(
763
+ robust=True, # Use HC1 robust standard errors
764
+ cluster=None, # Column for cluster-robust SEs
765
+ alpha=0.05 # Significance level for CIs
766
+ )
767
+ ```
768
+
769
+ **Methods:**
770
+
771
+ | Method | Description |
772
+ |--------|-------------|
773
+ | `fit(data, outcome, treatment, time, ...)` | Fit the DiD model |
774
+ | `summary()` | Get formatted summary string |
775
+ | `print_summary()` | Print summary to stdout |
776
+ | `get_params()` | Get estimator parameters (sklearn-compatible) |
777
+ | `set_params(**params)` | Set estimator parameters (sklearn-compatible) |
778
+
779
+ **fit() Parameters:**
780
+
781
+ | Parameter | Type | Description |
782
+ |-----------|------|-------------|
783
+ | `data` | DataFrame | Input data |
784
+ | `outcome` | str | Outcome variable column name |
785
+ | `treatment` | str | Treatment indicator column (0/1) |
786
+ | `time` | str | Post-treatment indicator column (0/1) |
787
+ | `formula` | str | R-style formula (alternative to column names) |
788
+ | `covariates` | list | Linear control variables |
789
+ | `fixed_effects` | list | Categorical FE columns (creates dummies) |
790
+ | `absorb` | list | High-dimensional FE (within-transformation) |
791
+
792
+ ### DiDResults
793
+
794
+ **Attributes:**
795
+
796
+ | Attribute | Description |
797
+ |-----------|-------------|
798
+ | `att` | Average Treatment effect on the Treated |
799
+ | `se` | Standard error of ATT |
800
+ | `t_stat` | T-statistic |
801
+ | `p_value` | P-value for H0: ATT = 0 |
802
+ | `conf_int` | Tuple of (lower, upper) confidence bounds |
803
+ | `n_obs` | Number of observations |
804
+ | `n_treated` | Number of treated units |
805
+ | `n_control` | Number of control units |
806
+ | `r_squared` | R-squared of regression |
807
+ | `coefficients` | Dictionary of all coefficients |
808
+ | `is_significant` | Boolean for significance at alpha |
809
+ | `significance_stars` | String of significance stars |
810
+
811
+ **Methods:**
812
+
813
+ | Method | Description |
814
+ |--------|-------------|
815
+ | `summary(alpha)` | Get formatted summary string |
816
+ | `print_summary(alpha)` | Print summary to stdout |
817
+ | `to_dict()` | Convert to dictionary |
818
+ | `to_dataframe()` | Convert to pandas DataFrame |
819
+
820
+ ### MultiPeriodDiD
821
+
822
+ ```python
823
+ MultiPeriodDiD(
824
+ robust=True, # Use HC1 robust standard errors
825
+ cluster=None, # Column for cluster-robust SEs
826
+ alpha=0.05 # Significance level for CIs
827
+ )
828
+ ```
829
+
830
+ **fit() Parameters:**
831
+
832
+ | Parameter | Type | Description |
833
+ |-----------|------|-------------|
834
+ | `data` | DataFrame | Input data |
835
+ | `outcome` | str | Outcome variable column name |
836
+ | `treatment` | str | Treatment indicator column (0/1) |
837
+ | `time` | str | Time period column (multiple values) |
838
+ | `post_periods` | list | List of post-treatment period values |
839
+ | `covariates` | list | Linear control variables |
840
+ | `fixed_effects` | list | Categorical FE columns (creates dummies) |
841
+ | `absorb` | list | High-dimensional FE (within-transformation) |
842
+ | `reference_period` | any | Omitted period for time dummies |
843
+
844
+ ### MultiPeriodDiDResults
845
+
846
+ **Attributes:**
847
+
848
+ | Attribute | Description |
849
+ |-----------|-------------|
850
+ | `period_effects` | Dict mapping periods to PeriodEffect objects |
851
+ | `avg_att` | Average ATT across post-treatment periods |
852
+ | `avg_se` | Standard error of average ATT |
853
+ | `avg_t_stat` | T-statistic for average ATT |
854
+ | `avg_p_value` | P-value for average ATT |
855
+ | `avg_conf_int` | Confidence interval for average ATT |
856
+ | `n_obs` | Number of observations |
857
+ | `pre_periods` | List of pre-treatment periods |
858
+ | `post_periods` | List of post-treatment periods |
859
+
860
+ **Methods:**
861
+
862
+ | Method | Description |
863
+ |--------|-------------|
864
+ | `get_effect(period)` | Get PeriodEffect for specific period |
865
+ | `summary(alpha)` | Get formatted summary string |
866
+ | `print_summary(alpha)` | Print summary to stdout |
867
+ | `to_dict()` | Convert to dictionary |
868
+ | `to_dataframe()` | Convert to pandas DataFrame |
869
+
870
+ ### PeriodEffect
871
+
872
+ **Attributes:**
873
+
874
+ | Attribute | Description |
875
+ |-----------|-------------|
876
+ | `period` | Time period identifier |
877
+ | `effect` | Treatment effect estimate |
878
+ | `se` | Standard error |
879
+ | `t_stat` | T-statistic |
880
+ | `p_value` | P-value |
881
+ | `conf_int` | Confidence interval |
882
+ | `is_significant` | Boolean for significance at 0.05 |
883
+ | `significance_stars` | String of significance stars |
884
+
885
+ ### SyntheticDiD
886
+
887
+ ```python
888
+ SyntheticDiD(
889
+ lambda_reg=0.0, # L2 regularization for unit weights
890
+ zeta=1.0, # Regularization for time weights
891
+ alpha=0.05, # Significance level for CIs
892
+ n_bootstrap=200, # Bootstrap iterations for SE
893
+ seed=None # Random seed for reproducibility
894
+ )
895
+ ```
896
+
897
+ **fit() Parameters:**
898
+
899
+ | Parameter | Type | Description |
900
+ |-----------|------|-------------|
901
+ | `data` | DataFrame | Panel data |
902
+ | `outcome` | str | Outcome variable column name |
903
+ | `treatment` | str | Treatment indicator column (0/1) |
904
+ | `unit` | str | Unit identifier column |
905
+ | `time` | str | Time period column |
906
+ | `post_periods` | list | List of post-treatment period values |
907
+ | `covariates` | list | Covariates to residualize out |
908
+
909
+ ### SyntheticDiDResults
910
+
911
+ **Attributes:**
912
+
913
+ | Attribute | Description |
914
+ |-----------|-------------|
915
+ | `att` | Average Treatment effect on the Treated |
916
+ | `se` | Standard error (bootstrap or placebo-based) |
917
+ | `t_stat` | T-statistic |
918
+ | `p_value` | P-value |
919
+ | `conf_int` | Confidence interval |
920
+ | `n_obs` | Number of observations |
921
+ | `n_treated` | Number of treated units |
922
+ | `n_control` | Number of control units |
923
+ | `unit_weights` | Dict mapping control unit IDs to weights |
924
+ | `time_weights` | Dict mapping pre-treatment periods to weights |
925
+ | `pre_periods` | List of pre-treatment periods |
926
+ | `post_periods` | List of post-treatment periods |
927
+ | `pre_treatment_fit` | RMSE of synthetic vs treated in pre-period |
928
+ | `placebo_effects` | Array of placebo effect estimates |
929
+
930
+ **Methods:**
931
+
932
+ | Method | Description |
933
+ |--------|-------------|
934
+ | `summary(alpha)` | Get formatted summary string |
935
+ | `print_summary(alpha)` | Print summary to stdout |
936
+ | `to_dict()` | Convert to dictionary |
937
+ | `to_dataframe()` | Convert to pandas DataFrame |
938
+ | `get_unit_weights_df()` | Get unit weights as DataFrame |
939
+ | `get_time_weights_df()` | Get time weights as DataFrame |
940
+
941
+ ### Data Preparation Functions
942
+
943
+ #### generate_did_data
944
+
945
+ ```python
946
+ generate_did_data(
947
+ n_units=100, # Number of units
948
+ n_periods=4, # Number of time periods
949
+ treatment_effect=5.0, # True ATT
950
+ treatment_fraction=0.5, # Fraction treated
951
+ treatment_period=2, # First post-treatment period
952
+ unit_fe_sd=2.0, # Unit fixed effect std dev
953
+ time_trend=0.5, # Linear time trend
954
+ noise_sd=1.0, # Idiosyncratic noise std dev
955
+ seed=None # Random seed
956
+ )
957
+ ```
958
+
959
+ Returns DataFrame with columns: `unit`, `period`, `treated`, `post`, `outcome`, `true_effect`.
960
+
961
+ #### make_treatment_indicator
962
+
963
+ ```python
964
+ make_treatment_indicator(
965
+ data, # Input DataFrame
966
+ column, # Column to create treatment from
967
+ treated_values=None, # Value(s) indicating treatment
968
+ threshold=None, # Numeric threshold for treatment
969
+ above_threshold=True, # If True, >= threshold is treated
970
+ new_column='treated' # Output column name
971
+ )
972
+ ```
973
+
974
+ #### make_post_indicator
975
+
976
+ ```python
977
+ make_post_indicator(
978
+ data, # Input DataFrame
979
+ time_column, # Time/period column
980
+ post_periods=None, # Specific post-treatment period(s)
981
+ treatment_start=None, # First post-treatment period
982
+ new_column='post' # Output column name
983
+ )
984
+ ```
985
+
986
+ #### wide_to_long
987
+
988
+ ```python
989
+ wide_to_long(
990
+ data, # Wide-format DataFrame
991
+ value_columns, # List of time-varying columns
992
+ id_column, # Unit identifier column
993
+ time_name='period', # Name for time column
994
+ value_name='value', # Name for value column
995
+ time_values=None # Values for time periods
996
+ )
997
+ ```
998
+
999
+ #### balance_panel
1000
+
1001
+ ```python
1002
+ balance_panel(
1003
+ data, # Panel DataFrame
1004
+ unit_column, # Unit identifier column
1005
+ time_column, # Time period column
1006
+ method='inner', # 'inner', 'outer', or 'fill'
1007
+ fill_value=None # Value for filling (if method='fill')
1008
+ )
1009
+ ```
1010
+
1011
+ #### validate_did_data
1012
+
1013
+ ```python
1014
+ validate_did_data(
1015
+ data, # DataFrame to validate
1016
+ outcome, # Outcome column name
1017
+ treatment, # Treatment column name
1018
+ time, # Time/post column name
1019
+ unit=None, # Unit column (for panel validation)
1020
+ raise_on_error=True # Raise ValueError or return dict
1021
+ )
1022
+ ```
1023
+
1024
+ Returns dict with `valid`, `errors`, `warnings`, and `summary` keys.
1025
+
1026
+ #### summarize_did_data
1027
+
1028
+ ```python
1029
+ summarize_did_data(
1030
+ data, # Input DataFrame
1031
+ outcome, # Outcome column name
1032
+ treatment, # Treatment column name
1033
+ time, # Time/post column name
1034
+ unit=None # Unit column (optional)
1035
+ )
1036
+ ```
1037
+
1038
+ Returns DataFrame with summary statistics by treatment-time cell.
1039
+
1040
+ #### create_event_time
1041
+
1042
+ ```python
1043
+ create_event_time(
1044
+ data, # Panel DataFrame
1045
+ time_column, # Calendar time column
1046
+ treatment_time_column, # Column with treatment timing
1047
+ new_column='event_time' # Output column name
1048
+ )
1049
+ ```
1050
+
1051
+ #### aggregate_to_cohorts
1052
+
1053
+ ```python
1054
+ aggregate_to_cohorts(
1055
+ data, # Unit-level panel data
1056
+ unit_column, # Unit identifier column
1057
+ time_column, # Time period column
1058
+ treatment_column, # Treatment indicator column
1059
+ outcome, # Outcome variable column
1060
+ covariates=None # Additional columns to aggregate
1061
+ )
1062
+ ```
1063
+
1064
+ ## Requirements
1065
+
1066
+ - Python >= 3.9
1067
+ - numpy >= 1.20
1068
+ - pandas >= 1.3
1069
+ - scipy >= 1.7
1070
+
1071
+ ## Development
1072
+
1073
+ ```bash
1074
+ # Install with dev dependencies
1075
+ pip install -e ".[dev]"
1076
+
1077
+ # Run tests
1078
+ pytest
1079
+
1080
+ # Format code
1081
+ black diff_diff tests
1082
+ ruff check diff_diff tests
1083
+ ```
1084
+
1085
+ ## References
1086
+
1087
+ This library implements methods from the following scholarly works:
1088
+
1089
+ ### Difference-in-Differences
1090
+
1091
+ - **Ashenfelter, O., & Card, D. (1985).** "Using the Longitudinal Structure of Earnings to Estimate the Effect of Training Programs." *The Review of Economics and Statistics*, 67(4), 648-660. [https://doi.org/10.2307/1924810](https://doi.org/10.2307/1924810)
1092
+
1093
+ - **Card, D., & Krueger, A. B. (1994).** "Minimum Wages and Employment: A Case Study of the Fast-Food Industry in New Jersey and Pennsylvania." *The American Economic Review*, 84(4), 772-793. [https://www.jstor.org/stable/2118030](https://www.jstor.org/stable/2118030)
1094
+
1095
+ - **Angrist, J. D., & Pischke, J.-S. (2009).** *Mostly Harmless Econometrics: An Empiricist's Companion*. Princeton University Press. Chapter 5: Differences-in-Differences.
1096
+
1097
+ ### Two-Way Fixed Effects
1098
+
1099
+ - **Wooldridge, J. M. (2010).** *Econometric Analysis of Cross Section and Panel Data* (2nd ed.). MIT Press.
1100
+
1101
+ - **Imai, K., & Kim, I. S. (2021).** "On the Use of Two-Way Fixed Effects Regression Models for Causal Inference with Panel Data." *Political Analysis*, 29(3), 405-415. [https://doi.org/10.1017/pan.2020.33](https://doi.org/10.1017/pan.2020.33)
1102
+
1103
+ ### Robust Standard Errors
1104
+
1105
+ - **White, H. (1980).** "A Heteroskedasticity-Consistent Covariance Matrix Estimator and a Direct Test for Heteroskedasticity." *Econometrica*, 48(4), 817-838. [https://doi.org/10.2307/1912934](https://doi.org/10.2307/1912934)
1106
+
1107
+ - **MacKinnon, J. G., & White, H. (1985).** "Some Heteroskedasticity-Consistent Covariance Matrix Estimators with Improved Finite Sample Properties." *Journal of Econometrics*, 29(3), 305-325. [https://doi.org/10.1016/0304-4076(85)90158-7](https://doi.org/10.1016/0304-4076(85)90158-7)
1108
+
1109
+ - **Cameron, A. C., Gelbach, J. B., & Miller, D. L. (2011).** "Robust Inference With Multiway Clustering." *Journal of Business & Economic Statistics*, 29(2), 238-249. [https://doi.org/10.1198/jbes.2010.07136](https://doi.org/10.1198/jbes.2010.07136)
1110
+
1111
+ ### Synthetic Control Method
1112
+
1113
+ - **Abadie, A., & Gardeazabal, J. (2003).** "The Economic Costs of Conflict: A Case Study of the Basque Country." *The American Economic Review*, 93(1), 113-132. [https://doi.org/10.1257/000282803321455188](https://doi.org/10.1257/000282803321455188)
1114
+
1115
+ - **Abadie, A., Diamond, A., & Hainmueller, J. (2010).** "Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California's Tobacco Control Program." *Journal of the American Statistical Association*, 105(490), 493-505. [https://doi.org/10.1198/jasa.2009.ap08746](https://doi.org/10.1198/jasa.2009.ap08746)
1116
+
1117
+ - **Abadie, A., Diamond, A., & Hainmueller, J. (2015).** "Comparative Politics and the Synthetic Control Method." *American Journal of Political Science*, 59(2), 495-510. [https://doi.org/10.1111/ajps.12116](https://doi.org/10.1111/ajps.12116)
1118
+
1119
+ ### Synthetic Difference-in-Differences
1120
+
1121
+ - **Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021).** "Synthetic Difference-in-Differences." *American Economic Review*, 111(12), 4088-4118. [https://doi.org/10.1257/aer.20190159](https://doi.org/10.1257/aer.20190159)
1122
+
1123
+ ### Parallel Trends and Pre-Trend Testing
1124
+
1125
+ - **Roth, J. (2022).** "Pretest with Caution: Event-Study Estimates after Testing for Parallel Trends." *American Economic Review: Insights*, 4(3), 305-322. [https://doi.org/10.1257/aeri.20210236](https://doi.org/10.1257/aeri.20210236)
1126
+
1127
+ - **Rambachan, A., & Roth, J. (2023).** "A More Credible Approach to Parallel Trends." *The Review of Economic Studies*, 90(5), 2555-2591. [https://doi.org/10.1093/restud/rdad018](https://doi.org/10.1093/restud/rdad018)
1128
+
1129
+ ### Multi-Period and Staggered Adoption
1130
+
1131
+ - **Callaway, B., & Sant'Anna, P. H. C. (2021).** "Difference-in-Differences with Multiple Time Periods." *Journal of Econometrics*, 225(2), 200-230. [https://doi.org/10.1016/j.jeconom.2020.12.001](https://doi.org/10.1016/j.jeconom.2020.12.001)
1132
+
1133
+ - **Sun, L., & Abraham, S. (2021).** "Estimating Dynamic Treatment Effects in Event Studies with Heterogeneous Treatment Effects." *Journal of Econometrics*, 225(2), 175-199. [https://doi.org/10.1016/j.jeconom.2020.09.006](https://doi.org/10.1016/j.jeconom.2020.09.006)
1134
+
1135
+ - **de Chaisemartin, C., & D'Haultfœuille, X. (2020).** "Two-Way Fixed Effects Estimators with Heterogeneous Treatment Effects." *American Economic Review*, 110(9), 2964-2996. [https://doi.org/10.1257/aer.20181169](https://doi.org/10.1257/aer.20181169)
1136
+
1137
+ ### General Causal Inference
1138
+
1139
+ - **Imbens, G. W., & Rubin, D. B. (2015).** *Causal Inference for Statistics, Social, and Biomedical Sciences: An Introduction*. Cambridge University Press.
1140
+
1141
+ - **Cunningham, S. (2021).** *Causal Inference: The Mixtape*. Yale University Press. [https://mixtape.scunning.com/](https://mixtape.scunning.com/)
1142
+
1143
+ ## License
1144
+
1145
+ MIT License