diff-diff 2.0.4__cp312-cp312-macosx_11_0_arm64.whl

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,2257 @@
1
+ Metadata-Version: 2.4
2
+ Name: diff-diff
3
+ Version: 2.0.4
4
+ Classifier: Development Status :: 5 - Production/Stable
5
+ Classifier: Intended Audience :: Science/Research
6
+ Classifier: Operating System :: OS Independent
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.9
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
13
+ Requires-Dist: numpy>=1.20.0
14
+ Requires-Dist: pandas>=1.3.0
15
+ Requires-Dist: scipy>=1.7.0
16
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
17
+ Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
18
+ Requires-Dist: black>=23.0 ; extra == 'dev'
19
+ Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
20
+ Requires-Dist: mypy>=1.0 ; extra == 'dev'
21
+ Requires-Dist: sphinx>=6.0 ; extra == 'docs'
22
+ Requires-Dist: sphinx-rtd-theme>=1.0 ; extra == 'docs'
23
+ Provides-Extra: dev
24
+ Provides-Extra: docs
25
+ Summary: A library for Difference-in-Differences causal inference analysis
26
+ Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
27
+ Author: diff-diff contributors
28
+ License-Expression: MIT
29
+ Requires-Python: >=3.9
30
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
31
+ Project-URL: Documentation, https://diff-diff.readthedocs.io
32
+ Project-URL: Homepage, https://github.com/igerber/diff-diff
33
+ Project-URL: Issues, https://github.com/igerber/diff-diff/issues
34
+ Project-URL: Repository, https://github.com/igerber/diff-diff
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.0000, SE=1.7321, p=0.1583)
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.9055
86
+
87
+ ----------------------------------------------------------------------
88
+ Parameter Estimate Std. Err. t-stat P>|t|
89
+ ----------------------------------------------------------------------
90
+ ATT 3.0000 1.7321 1.732 0.1583
91
+ ----------------------------------------------------------------------
92
+
93
+ 95% Confidence Interval: [-1.8089, 7.8089]
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
+ - **Wild cluster bootstrap**: Valid inference with few clusters (<50) using Rademacher, Webb, or Mammen weights
106
+ - **Panel data support**: Two-way fixed effects estimator for panel designs
107
+ - **Multi-period analysis**: Event-study style DiD with period-specific treatment effects
108
+ - **Staggered adoption**: Callaway-Sant'Anna (2021) and Sun-Abraham (2021) estimators for heterogeneous treatment timing
109
+ - **Triple Difference (DDD)**: Ortiz-Villavicencio & Sant'Anna (2025) estimators with proper covariate handling
110
+ - **Synthetic DiD**: Combined DiD with synthetic control for improved robustness
111
+ - **Event study plots**: Publication-ready visualization of treatment effects
112
+ - **Parallel trends testing**: Multiple methods including equivalence tests
113
+ - **Goodman-Bacon decomposition**: Diagnose TWFE bias by decomposing into 2x2 comparisons
114
+ - **Placebo tests**: Comprehensive diagnostics including fake timing, fake group, permutation, and leave-one-out tests
115
+ - **Honest DiD sensitivity analysis**: Rambachan-Roth (2023) bounds and breakdown analysis for parallel trends violations
116
+ - **Pre-trends power analysis**: Roth (2022) minimum detectable violation (MDV) and power curves for pre-trends tests
117
+ - **Power analysis**: MDE, sample size, and power calculations for study design; simulation-based power for any estimator
118
+ - **Data prep utilities**: Helper functions for common data preparation tasks
119
+ - **Validated against R**: Benchmarked against `did`, `synthdid`, and `fixest` packages (see [benchmarks](docs/benchmarks.rst))
120
+
121
+ ## Tutorials
122
+
123
+ We provide Jupyter notebook tutorials in `docs/tutorials/`:
124
+
125
+ | Notebook | Description |
126
+ |----------|-------------|
127
+ | `01_basic_did.ipynb` | Basic 2x2 DiD, formula interface, covariates, fixed effects, cluster-robust SE, wild bootstrap |
128
+ | `02_staggered_did.ipynb` | Staggered adoption with Callaway-Sant'Anna and Sun-Abraham, group-time effects, aggregation methods, Bacon decomposition |
129
+ | `03_synthetic_did.ipynb` | Synthetic DiD, unit/time weights, inference methods, regularization |
130
+ | `04_parallel_trends.ipynb` | Testing parallel trends, equivalence tests, placebo tests, diagnostics |
131
+ | `05_honest_did.ipynb` | Honest DiD sensitivity analysis, bounds, breakdown values, visualization |
132
+ | `06_power_analysis.ipynb` | Power analysis, MDE, sample size calculations, simulation-based power |
133
+ | `07_pretrends_power.ipynb` | Pre-trends power analysis (Roth 2022), MDV, power curves |
134
+ | `08_triple_diff.ipynb` | Triple Difference (DDD) estimation with proper covariate handling |
135
+ | `09_real_world_examples.ipynb` | Real-world data examples (Card-Krueger, Castle Doctrine, Divorce Laws) |
136
+
137
+ ## Data Preparation
138
+
139
+ 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.
140
+
141
+ ### Generate Sample Data
142
+
143
+ Create synthetic data with a known treatment effect for testing and learning:
144
+
145
+ ```python
146
+ from diff_diff import generate_did_data, DifferenceInDifferences
147
+
148
+ # Generate panel data with 100 units, 4 periods, and a treatment effect of 5
149
+ data = generate_did_data(
150
+ n_units=100,
151
+ n_periods=4,
152
+ treatment_effect=5.0,
153
+ treatment_fraction=0.5, # 50% of units are treated
154
+ treatment_period=2, # Treatment starts at period 2
155
+ seed=42
156
+ )
157
+
158
+ # Verify the estimator recovers the treatment effect
159
+ did = DifferenceInDifferences()
160
+ results = did.fit(data, outcome='outcome', treatment='treated', time='post')
161
+ print(f"Estimated ATT: {results.att:.2f} (true: 5.0)")
162
+ ```
163
+
164
+ ### Create Treatment Indicators
165
+
166
+ Convert categorical variables or numeric thresholds to binary treatment indicators:
167
+
168
+ ```python
169
+ from diff_diff import make_treatment_indicator
170
+
171
+ # From categorical variable
172
+ df = make_treatment_indicator(
173
+ data,
174
+ column='state',
175
+ treated_values=['CA', 'NY', 'TX'] # These states are treated
176
+ )
177
+
178
+ # From numeric threshold (e.g., firms above median size)
179
+ df = make_treatment_indicator(
180
+ data,
181
+ column='firm_size',
182
+ threshold=data['firm_size'].median()
183
+ )
184
+
185
+ # Treat units below threshold
186
+ df = make_treatment_indicator(
187
+ data,
188
+ column='income',
189
+ threshold=50000,
190
+ above_threshold=False # Units with income <= 50000 are treated
191
+ )
192
+ ```
193
+
194
+ ### Create Post-Treatment Indicators
195
+
196
+ Convert time/date columns to binary post-treatment indicators:
197
+
198
+ ```python
199
+ from diff_diff import make_post_indicator
200
+
201
+ # From specific post-treatment periods
202
+ df = make_post_indicator(
203
+ data,
204
+ time_column='year',
205
+ post_periods=[2020, 2021, 2022]
206
+ )
207
+
208
+ # From treatment start date
209
+ df = make_post_indicator(
210
+ data,
211
+ time_column='year',
212
+ treatment_start=2020 # All years >= 2020 are post-treatment
213
+ )
214
+
215
+ # Works with datetime columns
216
+ df = make_post_indicator(
217
+ data,
218
+ time_column='date',
219
+ treatment_start='2020-01-01'
220
+ )
221
+ ```
222
+
223
+ ### Reshape Wide to Long Format
224
+
225
+ Convert wide-format data (one row per unit, multiple time columns) to long format:
226
+
227
+ ```python
228
+ from diff_diff import wide_to_long
229
+
230
+ # Wide format: columns like sales_2019, sales_2020, sales_2021
231
+ wide_df = pd.DataFrame({
232
+ 'firm_id': [1, 2, 3],
233
+ 'industry': ['tech', 'retail', 'tech'],
234
+ 'sales_2019': [100, 150, 200],
235
+ 'sales_2020': [110, 160, 210],
236
+ 'sales_2021': [120, 170, 220]
237
+ })
238
+
239
+ # Convert to long format for DiD
240
+ long_df = wide_to_long(
241
+ wide_df,
242
+ value_columns=['sales_2019', 'sales_2020', 'sales_2021'],
243
+ id_column='firm_id',
244
+ time_name='year',
245
+ value_name='sales',
246
+ time_values=[2019, 2020, 2021]
247
+ )
248
+ # Result: 9 rows (3 firms × 3 years), columns: firm_id, year, sales, industry
249
+ ```
250
+
251
+ ### Balance Panel Data
252
+
253
+ Ensure all units have observations for all time periods:
254
+
255
+ ```python
256
+ from diff_diff import balance_panel
257
+
258
+ # Keep only units with complete data (drop incomplete units)
259
+ balanced = balance_panel(
260
+ data,
261
+ unit_column='firm_id',
262
+ time_column='year',
263
+ method='inner'
264
+ )
265
+
266
+ # Include all unit-period combinations (creates NaN for missing)
267
+ balanced = balance_panel(
268
+ data,
269
+ unit_column='firm_id',
270
+ time_column='year',
271
+ method='outer'
272
+ )
273
+
274
+ # Fill missing values
275
+ balanced = balance_panel(
276
+ data,
277
+ unit_column='firm_id',
278
+ time_column='year',
279
+ method='fill',
280
+ fill_value=0 # Or None for forward/backward fill
281
+ )
282
+ ```
283
+
284
+ ### Validate Data
285
+
286
+ Check that your data meets DiD requirements before fitting:
287
+
288
+ ```python
289
+ from diff_diff import validate_did_data
290
+
291
+ # Validate and get informative error messages
292
+ result = validate_did_data(
293
+ data,
294
+ outcome='sales',
295
+ treatment='treated',
296
+ time='post',
297
+ unit='firm_id', # Optional: for panel-specific validation
298
+ raise_on_error=False # Return dict instead of raising
299
+ )
300
+
301
+ if result['valid']:
302
+ print("Data is ready for DiD analysis!")
303
+ print(f"Summary: {result['summary']}")
304
+ else:
305
+ print("Issues found:")
306
+ for error in result['errors']:
307
+ print(f" - {error}")
308
+
309
+ for warning in result['warnings']:
310
+ print(f"Warning: {warning}")
311
+ ```
312
+
313
+ ### Summarize Data by Groups
314
+
315
+ Get summary statistics for each treatment-time cell:
316
+
317
+ ```python
318
+ from diff_diff import summarize_did_data
319
+
320
+ summary = summarize_did_data(
321
+ data,
322
+ outcome='sales',
323
+ treatment='treated',
324
+ time='post'
325
+ )
326
+ print(summary)
327
+ ```
328
+
329
+ Output:
330
+ ```
331
+ n mean std min max
332
+ Control - Pre 250 100.5000 15.2340 65.0000 145.0000
333
+ Control - Post 250 105.2000 16.1230 68.0000 152.0000
334
+ Treated - Pre 250 101.2000 14.8900 67.0000 143.0000
335
+ Treated - Post 250 115.8000 17.5600 72.0000 165.0000
336
+ DiD Estimate - 9.9000 - - -
337
+ ```
338
+
339
+ ### Create Event Time for Staggered Designs
340
+
341
+ For designs where treatment occurs at different times:
342
+
343
+ ```python
344
+ from diff_diff import create_event_time
345
+
346
+ # Add event-time column relative to treatment timing
347
+ df = create_event_time(
348
+ data,
349
+ time_column='year',
350
+ treatment_time_column='treatment_year'
351
+ )
352
+ # Result: event_time = -2, -1, 0, 1, 2 relative to treatment
353
+ ```
354
+
355
+ ### Aggregate to Cohort Means
356
+
357
+ Aggregate unit-level data for visualization:
358
+
359
+ ```python
360
+ from diff_diff import aggregate_to_cohorts
361
+
362
+ cohort_data = aggregate_to_cohorts(
363
+ data,
364
+ unit_column='firm_id',
365
+ time_column='year',
366
+ treatment_column='treated',
367
+ outcome='sales'
368
+ )
369
+ # Result: mean outcome by treatment group and period
370
+ ```
371
+
372
+ ### Rank Control Units
373
+
374
+ Select the best control units for DiD or Synthetic DiD analysis by ranking them based on pre-treatment outcome similarity:
375
+
376
+ ```python
377
+ from diff_diff import rank_control_units, generate_did_data
378
+
379
+ # Generate sample data
380
+ data = generate_did_data(n_units=50, n_periods=6, seed=42)
381
+
382
+ # Rank control units by their similarity to treated units
383
+ ranking = rank_control_units(
384
+ data,
385
+ unit_column='unit',
386
+ time_column='period',
387
+ outcome_column='outcome',
388
+ treatment_column='treated',
389
+ n_top=10 # Return top 10 controls
390
+ )
391
+
392
+ print(ranking[['unit', 'quality_score', 'pre_trend_rmse']])
393
+ ```
394
+
395
+ Output:
396
+ ```
397
+ unit quality_score pre_trend_rmse
398
+ 0 35 1.0000 0.4521
399
+ 1 42 0.9234 0.5123
400
+ 2 28 0.8876 0.5892
401
+ ...
402
+ ```
403
+
404
+ With covariates for matching:
405
+
406
+ ```python
407
+ # Add covariate-based matching
408
+ ranking = rank_control_units(
409
+ data,
410
+ unit_column='unit',
411
+ time_column='period',
412
+ outcome_column='outcome',
413
+ treatment_column='treated',
414
+ covariates=['size', 'age'], # Match on these too
415
+ outcome_weight=0.7, # 70% weight on outcome trends
416
+ covariate_weight=0.3 # 30% weight on covariate similarity
417
+ )
418
+ ```
419
+
420
+ Filter data for SyntheticDiD using top controls:
421
+
422
+ ```python
423
+ from diff_diff import SyntheticDiD
424
+
425
+ # Get top control units
426
+ top_controls = ranking['unit'].tolist()
427
+
428
+ # Filter data to treated + top controls
429
+ filtered_data = data[
430
+ (data['treated'] == 1) | (data['unit'].isin(top_controls))
431
+ ]
432
+
433
+ # Fit SyntheticDiD with selected controls
434
+ sdid = SyntheticDiD()
435
+ results = sdid.fit(
436
+ filtered_data,
437
+ outcome='outcome',
438
+ treatment='treated',
439
+ unit='unit',
440
+ time='period',
441
+ post_periods=[3, 4, 5]
442
+ )
443
+ ```
444
+
445
+ ## Usage
446
+
447
+ ### Basic DiD with Column Names
448
+
449
+ ```python
450
+ from diff_diff import DifferenceInDifferences
451
+
452
+ did = DifferenceInDifferences(robust=True, alpha=0.05)
453
+ results = did.fit(
454
+ data,
455
+ outcome='sales',
456
+ treatment='treated',
457
+ time='post_policy'
458
+ )
459
+
460
+ # Access results
461
+ print(f"ATT: {results.att:.4f}")
462
+ print(f"Standard Error: {results.se:.4f}")
463
+ print(f"P-value: {results.p_value:.4f}")
464
+ print(f"95% CI: {results.conf_int}")
465
+ print(f"Significant: {results.is_significant}")
466
+ ```
467
+
468
+ ### Using Formula Interface
469
+
470
+ ```python
471
+ # R-style formula syntax
472
+ results = did.fit(data, formula='outcome ~ treated * post')
473
+
474
+ # Explicit interaction syntax
475
+ results = did.fit(data, formula='outcome ~ treated + post + treated:post')
476
+
477
+ # With covariates
478
+ results = did.fit(data, formula='outcome ~ treated * post + age + income')
479
+ ```
480
+
481
+ ### Including Covariates
482
+
483
+ ```python
484
+ results = did.fit(
485
+ data,
486
+ outcome='outcome',
487
+ treatment='treated',
488
+ time='post',
489
+ covariates=['age', 'income', 'education']
490
+ )
491
+ ```
492
+
493
+ ### Fixed Effects
494
+
495
+ Use `fixed_effects` for low-dimensional categorical controls (creates dummy variables):
496
+
497
+ ```python
498
+ # State and industry fixed effects
499
+ results = did.fit(
500
+ data,
501
+ outcome='sales',
502
+ treatment='treated',
503
+ time='post',
504
+ fixed_effects=['state', 'industry']
505
+ )
506
+
507
+ # Access fixed effect coefficients
508
+ state_coefs = {k: v for k, v in results.coefficients.items() if k.startswith('state_')}
509
+ ```
510
+
511
+ Use `absorb` for high-dimensional fixed effects (more efficient, uses within-transformation):
512
+
513
+ ```python
514
+ # Absorb firm-level fixed effects (efficient for many firms)
515
+ results = did.fit(
516
+ data,
517
+ outcome='sales',
518
+ treatment='treated',
519
+ time='post',
520
+ absorb=['firm_id']
521
+ )
522
+ ```
523
+
524
+ Combine covariates with fixed effects:
525
+
526
+ ```python
527
+ results = did.fit(
528
+ data,
529
+ outcome='sales',
530
+ treatment='treated',
531
+ time='post',
532
+ covariates=['size', 'age'], # Linear controls
533
+ fixed_effects=['industry'], # Low-dimensional FE (dummies)
534
+ absorb=['firm_id'] # High-dimensional FE (absorbed)
535
+ )
536
+ ```
537
+
538
+ ### Cluster-Robust Standard Errors
539
+
540
+ ```python
541
+ did = DifferenceInDifferences(cluster='state')
542
+ results = did.fit(
543
+ data,
544
+ outcome='outcome',
545
+ treatment='treated',
546
+ time='post'
547
+ )
548
+ ```
549
+
550
+ ### Wild Cluster Bootstrap
551
+
552
+ When you have few clusters (<50), standard cluster-robust SEs are biased. Wild cluster bootstrap provides valid inference even with 5-10 clusters.
553
+
554
+ ```python
555
+ # Use wild bootstrap for inference
556
+ did = DifferenceInDifferences(
557
+ cluster='state',
558
+ inference='wild_bootstrap',
559
+ n_bootstrap=999,
560
+ bootstrap_weights='rademacher', # or 'webb' for <10 clusters, 'mammen'
561
+ seed=42
562
+ )
563
+ results = did.fit(data, outcome='y', treatment='treated', time='post')
564
+
565
+ # Results include bootstrap-based SE and p-value
566
+ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
567
+ print(f"P-value: {results.p_value:.4f}")
568
+ print(f"95% CI: {results.conf_int}")
569
+ print(f"Inference method: {results.inference_method}")
570
+ print(f"Number of clusters: {results.n_clusters}")
571
+ ```
572
+
573
+ **Weight types:**
574
+ - `'rademacher'` - Default, ±1 with p=0.5, good for most cases
575
+ - `'webb'` - 6-point distribution, recommended for <10 clusters
576
+ - `'mammen'` - Two-point distribution, alternative to Rademacher
577
+
578
+ Works with `DifferenceInDifferences` and `TwoWayFixedEffects` estimators.
579
+
580
+ ### Two-Way Fixed Effects (Panel Data)
581
+
582
+ ```python
583
+ from diff_diff import TwoWayFixedEffects
584
+
585
+ twfe = TwoWayFixedEffects()
586
+ results = twfe.fit(
587
+ panel_data,
588
+ outcome='outcome',
589
+ treatment='treated',
590
+ time='year',
591
+ unit='firm_id'
592
+ )
593
+ ```
594
+
595
+ ### Multi-Period DiD (Event Study)
596
+
597
+ For settings with multiple pre- and post-treatment periods:
598
+
599
+ ```python
600
+ from diff_diff import MultiPeriodDiD
601
+
602
+ # Fit with multiple time periods
603
+ did = MultiPeriodDiD()
604
+ results = did.fit(
605
+ panel_data,
606
+ outcome='sales',
607
+ treatment='treated',
608
+ time='period',
609
+ post_periods=[3, 4, 5], # Periods 3-5 are post-treatment
610
+ reference_period=0 # Reference period for comparison
611
+ )
612
+
613
+ # View period-specific treatment effects
614
+ for period, effect in results.period_effects.items():
615
+ print(f"Period {period}: {effect.effect:.3f} (SE: {effect.se:.3f})")
616
+
617
+ # View average treatment effect across post-periods
618
+ print(f"Average ATT: {results.avg_att:.3f}")
619
+ print(f"Average SE: {results.avg_se:.3f}")
620
+
621
+ # Full summary with all period effects
622
+ results.print_summary()
623
+ ```
624
+
625
+ Output:
626
+ ```
627
+ ================================================================================
628
+ Multi-Period Difference-in-Differences Estimation Results
629
+ ================================================================================
630
+
631
+ Observations: 600
632
+ Pre-treatment periods: 3
633
+ Post-treatment periods: 3
634
+
635
+ --------------------------------------------------------------------------------
636
+ Average Treatment Effect
637
+ --------------------------------------------------------------------------------
638
+ Average ATT 5.2000 0.8234 6.315 0.0000
639
+ --------------------------------------------------------------------------------
640
+ 95% Confidence Interval: [3.5862, 6.8138]
641
+
642
+ Period-Specific Effects:
643
+ --------------------------------------------------------------------------------
644
+ Period Effect Std. Err. t-stat P>|t|
645
+ --------------------------------------------------------------------------------
646
+ 3 4.5000 0.9512 4.731 0.0000***
647
+ 4 5.2000 0.8876 5.858 0.0000***
648
+ 5 5.9000 0.9123 6.468 0.0000***
649
+ --------------------------------------------------------------------------------
650
+
651
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
652
+ ================================================================================
653
+ ```
654
+
655
+ ### Staggered Difference-in-Differences (Callaway-Sant'Anna)
656
+
657
+ When treatment is adopted at different times by different units, traditional TWFE estimators can be biased. The Callaway-Sant'Anna estimator provides unbiased estimates with staggered adoption.
658
+
659
+ ```python
660
+ from diff_diff import CallawaySantAnna
661
+
662
+ # Panel data with staggered treatment
663
+ # 'first_treat' = period when unit was first treated (0 if never treated)
664
+ cs = CallawaySantAnna()
665
+ results = cs.fit(
666
+ panel_data,
667
+ outcome='sales',
668
+ unit='firm_id',
669
+ time='year',
670
+ first_treat='first_treat', # 0 for never-treated, else first treatment year
671
+ aggregate='event_study' # Compute event study effects
672
+ )
673
+
674
+ # View results
675
+ results.print_summary()
676
+
677
+ # Access group-time effects ATT(g,t)
678
+ for (group, time), effect in results.group_time_effects.items():
679
+ print(f"Cohort {group}, Period {time}: {effect['effect']:.3f}")
680
+
681
+ # Event study effects (averaged by relative time)
682
+ for rel_time, effect in results.event_study_effects.items():
683
+ print(f"e={rel_time}: {effect['effect']:.3f} (SE: {effect['se']:.3f})")
684
+
685
+ # Convert to DataFrame
686
+ df = results.to_dataframe(level='event_study')
687
+ ```
688
+
689
+ Output:
690
+ ```
691
+ =====================================================================================
692
+ Callaway-Sant'Anna Staggered Difference-in-Differences Results
693
+ =====================================================================================
694
+
695
+ Total observations: 600
696
+ Treated units: 35
697
+ Control units: 15
698
+ Treatment cohorts: 3
699
+ Time periods: 8
700
+ Control group: never_treated
701
+
702
+ -------------------------------------------------------------------------------------
703
+ Overall Average Treatment Effect on the Treated
704
+ -------------------------------------------------------------------------------------
705
+ Parameter Estimate Std. Err. t-stat P>|t| Sig.
706
+ -------------------------------------------------------------------------------------
707
+ ATT 2.5000 0.3521 7.101 0.0000 ***
708
+ -------------------------------------------------------------------------------------
709
+
710
+ 95% Confidence Interval: [1.8099, 3.1901]
711
+
712
+ -------------------------------------------------------------------------------------
713
+ Event Study (Dynamic) Effects
714
+ -------------------------------------------------------------------------------------
715
+ Rel. Period Estimate Std. Err. t-stat P>|t| Sig.
716
+ -------------------------------------------------------------------------------------
717
+ 0 2.1000 0.4521 4.645 0.0000 ***
718
+ 1 2.5000 0.4123 6.064 0.0000 ***
719
+ 2 2.8000 0.5234 5.349 0.0000 ***
720
+ -------------------------------------------------------------------------------------
721
+
722
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
723
+ =====================================================================================
724
+ ```
725
+
726
+ **When to use Callaway-Sant'Anna vs TWFE:**
727
+
728
+ | Scenario | Use TWFE | Use Callaway-Sant'Anna |
729
+ |----------|----------|------------------------|
730
+ | All units treated at same time | ✓ | ✓ |
731
+ | Staggered adoption, homogeneous effects | ✓ | ✓ |
732
+ | Staggered adoption, heterogeneous effects | ✗ | ✓ |
733
+ | Need event study with staggered timing | ✗ | ✓ |
734
+ | Fewer than ~20 treated units | ✓ | Depends on design |
735
+
736
+ **Parameters:**
737
+
738
+ ```python
739
+ CallawaySantAnna(
740
+ control_group='never_treated', # or 'not_yet_treated'
741
+ anticipation=0, # Periods before treatment with effects
742
+ estimation_method='dr', # 'dr', 'ipw', or 'reg'
743
+ alpha=0.05, # Significance level
744
+ cluster=None, # Column for cluster SEs
745
+ n_bootstrap=0, # Bootstrap iterations (0 = analytical SEs)
746
+ bootstrap_weight_type='rademacher', # 'rademacher', 'mammen', or 'webb'
747
+ seed=None # Random seed
748
+ )
749
+ ```
750
+
751
+ **Multiplier bootstrap for inference:**
752
+
753
+ With few clusters or when analytical standard errors may be unreliable, use the multiplier bootstrap for valid inference. This implements the approach from Callaway & Sant'Anna (2021).
754
+
755
+ ```python
756
+ # Bootstrap inference with 999 iterations
757
+ cs = CallawaySantAnna(
758
+ n_bootstrap=999,
759
+ bootstrap_weight_type='rademacher', # or 'mammen', 'webb'
760
+ seed=42
761
+ )
762
+ results = cs.fit(
763
+ data,
764
+ outcome='sales',
765
+ unit='firm_id',
766
+ time='year',
767
+ first_treat='first_treat',
768
+ aggregate='event_study'
769
+ )
770
+
771
+ # Access bootstrap results
772
+ print(f"Overall ATT: {results.overall_att:.3f}")
773
+ print(f"Bootstrap SE: {results.bootstrap_results.overall_att_se:.3f}")
774
+ print(f"Bootstrap 95% CI: {results.bootstrap_results.overall_att_ci}")
775
+ print(f"Bootstrap p-value: {results.bootstrap_results.overall_att_p_value:.4f}")
776
+
777
+ # Event study bootstrap inference
778
+ for rel_time, se in results.bootstrap_results.event_study_ses.items():
779
+ ci = results.bootstrap_results.event_study_cis[rel_time]
780
+ print(f"e={rel_time}: SE={se:.3f}, 95% CI=[{ci[0]:.3f}, {ci[1]:.3f}]")
781
+ ```
782
+
783
+ **Bootstrap weight types:**
784
+ - `'rademacher'` - Default, ±1 with p=0.5, good for most cases
785
+ - `'mammen'` - Two-point distribution matching first 3 moments
786
+ - `'webb'` - Six-point distribution, recommended for very few clusters (<10)
787
+
788
+ **Covariate adjustment for conditional parallel trends:**
789
+
790
+ When parallel trends only holds conditional on covariates, use the `covariates` parameter:
791
+
792
+ ```python
793
+ # Doubly robust estimation with covariates
794
+ cs = CallawaySantAnna(estimation_method='dr') # 'dr', 'ipw', or 'reg'
795
+ results = cs.fit(
796
+ data,
797
+ outcome='sales',
798
+ unit='firm_id',
799
+ time='year',
800
+ first_treat='first_treat',
801
+ covariates=['size', 'age', 'industry'], # Covariates for conditional PT
802
+ aggregate='event_study'
803
+ )
804
+ ```
805
+
806
+ ### Sun-Abraham Interaction-Weighted Estimator
807
+
808
+ The Sun-Abraham (2021) estimator provides an alternative to Callaway-Sant'Anna using an interaction-weighted (IW) regression approach. Running both estimators serves as a useful robustness check—when they agree, results are more credible.
809
+
810
+ ```python
811
+ from diff_diff import SunAbraham
812
+
813
+ # Basic usage
814
+ sa = SunAbraham()
815
+ results = sa.fit(
816
+ panel_data,
817
+ outcome='sales',
818
+ unit='firm_id',
819
+ time='year',
820
+ first_treat='first_treat' # 0 for never-treated, else first treatment year
821
+ )
822
+
823
+ # View results
824
+ results.print_summary()
825
+
826
+ # Event study effects (by relative time to treatment)
827
+ for rel_time, effect in results.event_study_effects.items():
828
+ print(f"e={rel_time}: {effect['effect']:.3f} (SE: {effect['se']:.3f})")
829
+
830
+ # Overall ATT
831
+ print(f"Overall ATT: {results.overall_att:.3f} (SE: {results.overall_se:.3f})")
832
+
833
+ # Cohort weights (how each cohort contributes to each event-time estimate)
834
+ for rel_time, weights in results.cohort_weights.items():
835
+ print(f"e={rel_time}: {weights}")
836
+ ```
837
+
838
+ **Parameters:**
839
+
840
+ ```python
841
+ SunAbraham(
842
+ control_group='never_treated', # or 'not_yet_treated'
843
+ anticipation=0, # Periods before treatment with effects
844
+ alpha=0.05, # Significance level
845
+ cluster=None, # Column for cluster SEs
846
+ n_bootstrap=0, # Bootstrap iterations (0 = analytical SEs)
847
+ bootstrap_weights='rademacher', # 'rademacher', 'mammen', or 'webb'
848
+ seed=None # Random seed
849
+ )
850
+ ```
851
+
852
+ **Bootstrap inference:**
853
+
854
+ ```python
855
+ # Bootstrap inference with 999 iterations
856
+ sa = SunAbraham(
857
+ n_bootstrap=999,
858
+ bootstrap_weights='rademacher',
859
+ seed=42
860
+ )
861
+ results = sa.fit(
862
+ data,
863
+ outcome='sales',
864
+ unit='firm_id',
865
+ time='year',
866
+ first_treat='first_treat'
867
+ )
868
+
869
+ # Access bootstrap results
870
+ print(f"Overall ATT: {results.overall_att:.3f}")
871
+ print(f"Bootstrap SE: {results.bootstrap_results.overall_att_se:.3f}")
872
+ print(f"Bootstrap 95% CI: {results.bootstrap_results.overall_att_ci}")
873
+ print(f"Bootstrap p-value: {results.bootstrap_results.overall_att_p_value:.4f}")
874
+ ```
875
+
876
+ **When to use Sun-Abraham vs Callaway-Sant'Anna:**
877
+
878
+ | Aspect | Sun-Abraham | Callaway-Sant'Anna |
879
+ |--------|-------------|-------------------|
880
+ | Approach | Interaction-weighted regression | 2x2 DiD aggregation |
881
+ | Efficiency | More efficient under homogeneous effects | More robust to heterogeneity |
882
+ | Weighting | Weights by cohort share at each relative time | Weights by sample size |
883
+ | Use case | Robustness check, regression-based inference | Primary staggered DiD estimator |
884
+
885
+ **Both estimators should give similar results when:**
886
+ - Treatment effects are relatively homogeneous across cohorts
887
+ - Parallel trends holds
888
+
889
+ **Running both as robustness check:**
890
+
891
+ ```python
892
+ from diff_diff import CallawaySantAnna, SunAbraham
893
+
894
+ # Callaway-Sant'Anna
895
+ cs = CallawaySantAnna()
896
+ cs_results = cs.fit(data, outcome='y', unit='unit', time='time', first_treat='first_treat')
897
+
898
+ # Sun-Abraham
899
+ sa = SunAbraham()
900
+ sa_results = sa.fit(data, outcome='y', unit='unit', time='time', first_treat='first_treat')
901
+
902
+ # Compare
903
+ print(f"Callaway-Sant'Anna ATT: {cs_results.overall_att:.3f}")
904
+ print(f"Sun-Abraham ATT: {sa_results.overall_att:.3f}")
905
+
906
+ # If results differ substantially, investigate heterogeneity
907
+ ```
908
+
909
+ ### Triple Difference (DDD)
910
+
911
+ Triple Difference (DDD) is used when treatment requires satisfying two criteria: belonging to a treated **group** AND being in an eligible **partition**. The `TripleDifference` class implements the methodology from Ortiz-Villavicencio & Sant'Anna (2025), which correctly handles covariate adjustment (unlike naive implementations).
912
+
913
+ ```python
914
+ from diff_diff import TripleDifference, triple_difference
915
+
916
+ # Basic usage
917
+ ddd = TripleDifference(estimation_method='dr') # doubly robust (recommended)
918
+ results = ddd.fit(
919
+ data,
920
+ outcome='wages',
921
+ group='policy_state', # 1=state enacted policy, 0=control state
922
+ partition='female', # 1=women (affected by policy), 0=men
923
+ time='post' # 1=post-policy, 0=pre-policy
924
+ )
925
+
926
+ # View results
927
+ results.print_summary()
928
+ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
929
+
930
+ # With covariates (properly incorporated, unlike naive DDD)
931
+ results = ddd.fit(
932
+ data,
933
+ outcome='wages',
934
+ group='policy_state',
935
+ partition='female',
936
+ time='post',
937
+ covariates=['age', 'education', 'experience']
938
+ )
939
+ ```
940
+
941
+ **Estimation methods:**
942
+
943
+ | Method | Description | When to use |
944
+ |--------|-------------|-------------|
945
+ | `"dr"` | Doubly robust | Recommended. Consistent if either outcome or propensity model is correct |
946
+ | `"reg"` | Regression adjustment | Simple outcome regression with full interactions |
947
+ | `"ipw"` | Inverse probability weighting | When propensity score model is well-specified |
948
+
949
+ ```python
950
+ # Compare estimation methods
951
+ for method in ['reg', 'ipw', 'dr']:
952
+ est = TripleDifference(estimation_method=method)
953
+ res = est.fit(data, outcome='y', group='g', partition='p', time='t')
954
+ print(f"{method}: ATT={res.att:.3f} (SE={res.se:.3f})")
955
+ ```
956
+
957
+ **Convenience function:**
958
+
959
+ ```python
960
+ # One-liner estimation
961
+ results = triple_difference(
962
+ data,
963
+ outcome='wages',
964
+ group='policy_state',
965
+ partition='female',
966
+ time='post',
967
+ covariates=['age', 'education'],
968
+ estimation_method='dr'
969
+ )
970
+ ```
971
+
972
+ **Why use DDD instead of DiD?**
973
+
974
+ DDD allows for violations of parallel trends that are:
975
+ - Group-specific (e.g., economic shocks in treatment states)
976
+ - Partition-specific (e.g., trends affecting women everywhere)
977
+
978
+ As long as these biases are additive, DDD differences them out. The key assumption is that the *differential* trend between eligible and ineligible units would be the same across groups.
979
+
980
+ ### Event Study Visualization
981
+
982
+ Create publication-ready event study plots:
983
+
984
+ ```python
985
+ from diff_diff import plot_event_study, MultiPeriodDiD, CallawaySantAnna, SunAbraham
986
+
987
+ # From MultiPeriodDiD
988
+ did = MultiPeriodDiD()
989
+ results = did.fit(data, outcome='y', treatment='treated',
990
+ time='period', post_periods=[3, 4, 5])
991
+ plot_event_study(results, title="Treatment Effects Over Time")
992
+
993
+ # From CallawaySantAnna (with event study aggregation)
994
+ cs = CallawaySantAnna()
995
+ results = cs.fit(data, outcome='y', unit='unit', time='period',
996
+ first_treat='first_treat', aggregate='event_study')
997
+ plot_event_study(results, title="Staggered DiD Event Study (CS)")
998
+
999
+ # From SunAbraham
1000
+ sa = SunAbraham()
1001
+ results = sa.fit(data, outcome='y', unit='unit', time='period',
1002
+ first_treat='first_treat')
1003
+ plot_event_study(results, title="Staggered DiD Event Study (SA)")
1004
+
1005
+ # From a DataFrame
1006
+ df = pd.DataFrame({
1007
+ 'period': [-2, -1, 0, 1, 2],
1008
+ 'effect': [0.1, 0.05, 0.0, 2.5, 2.8],
1009
+ 'se': [0.3, 0.25, 0.0, 0.4, 0.45]
1010
+ })
1011
+ plot_event_study(df, reference_period=0)
1012
+
1013
+ # With customization
1014
+ ax = plot_event_study(
1015
+ results,
1016
+ title="Dynamic Treatment Effects",
1017
+ xlabel="Years Relative to Treatment",
1018
+ ylabel="Effect on Sales ($1000s)",
1019
+ color="#2563eb",
1020
+ marker="o",
1021
+ shade_pre=True, # Shade pre-treatment region
1022
+ show_zero_line=True, # Horizontal line at y=0
1023
+ show_reference_line=True, # Vertical line at reference period
1024
+ figsize=(10, 6),
1025
+ show=False # Don't call plt.show(), return axes
1026
+ )
1027
+ ```
1028
+
1029
+ ### Synthetic Difference-in-Differences
1030
+
1031
+ 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.
1032
+
1033
+ ```python
1034
+ from diff_diff import SyntheticDiD
1035
+
1036
+ # Fit Synthetic DiD model
1037
+ sdid = SyntheticDiD()
1038
+ results = sdid.fit(
1039
+ panel_data,
1040
+ outcome='gdp_growth',
1041
+ treatment='treated',
1042
+ unit='state',
1043
+ time='year',
1044
+ post_periods=[2015, 2016, 2017, 2018]
1045
+ )
1046
+
1047
+ # View results
1048
+ results.print_summary()
1049
+ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})")
1050
+
1051
+ # Examine unit weights (which control units matter most)
1052
+ weights_df = results.get_unit_weights_df()
1053
+ print(weights_df.head(10))
1054
+
1055
+ # Examine time weights
1056
+ time_weights_df = results.get_time_weights_df()
1057
+ print(time_weights_df)
1058
+ ```
1059
+
1060
+ Output:
1061
+ ```
1062
+ ===========================================================================
1063
+ Synthetic Difference-in-Differences Estimation Results
1064
+ ===========================================================================
1065
+
1066
+ Observations: 500
1067
+ Treated units: 1
1068
+ Control units: 49
1069
+ Pre-treatment periods: 6
1070
+ Post-treatment periods: 4
1071
+ Regularization (lambda): 0.0000
1072
+ Pre-treatment fit (RMSE): 0.1234
1073
+
1074
+ ---------------------------------------------------------------------------
1075
+ Parameter Estimate Std. Err. t-stat P>|t|
1076
+ ---------------------------------------------------------------------------
1077
+ ATT 2.5000 0.4521 5.530 0.0000
1078
+ ---------------------------------------------------------------------------
1079
+
1080
+ 95% Confidence Interval: [1.6139, 3.3861]
1081
+
1082
+ ---------------------------------------------------------------------------
1083
+ Top Unit Weights (Synthetic Control)
1084
+ ---------------------------------------------------------------------------
1085
+ Unit state_12: 0.3521
1086
+ Unit state_5: 0.2156
1087
+ Unit state_23: 0.1834
1088
+ Unit state_8: 0.1245
1089
+ Unit state_31: 0.0892
1090
+ (8 units with weight > 0.001)
1091
+
1092
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
1093
+ ===========================================================================
1094
+ ```
1095
+
1096
+ #### When to Use Synthetic DiD Over Vanilla DiD
1097
+
1098
+ Use Synthetic DiD instead of standard DiD when:
1099
+
1100
+ 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.
1101
+
1102
+ ```python
1103
+ # Example: California passed a policy, want to estimate its effect
1104
+ # Standard DiD would compare CA to the average of all other states
1105
+ # Synthetic DiD finds states that together best match CA's pre-treatment trend
1106
+ ```
1107
+
1108
+ 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.
1109
+
1110
+ ```python
1111
+ # Example: A tech hub city vs rural areas
1112
+ # Rural areas may not be a good comparison on average
1113
+ # Synthetic DiD can weight urban/suburban controls more heavily
1114
+ ```
1115
+
1116
+ 3. **Heterogeneous control units**: When control units are very different from each other, equal weighting (as in standard DiD) is suboptimal.
1117
+
1118
+ ```python
1119
+ # Example: Comparing a treated developing country to other countries
1120
+ # Some control countries may be much more similar economically
1121
+ # Synthetic DiD upweights the most comparable controls
1122
+ ```
1123
+
1124
+ 4. **You want transparency**: Synthetic DiD provides explicit unit weights showing which controls contribute most to the comparison.
1125
+
1126
+ ```python
1127
+ # See exactly which units are driving the counterfactual
1128
+ print(results.get_unit_weights_df())
1129
+ ```
1130
+
1131
+ **Key differences from standard DiD:**
1132
+
1133
+ | Aspect | Standard DiD | Synthetic DiD |
1134
+ |--------|--------------|---------------|
1135
+ | Control weighting | Equal (1/N) | Optimized to match pre-treatment |
1136
+ | Time weighting | Equal across periods | Can emphasize informative periods |
1137
+ | N treated required | Can be many | Works with 1 treated unit |
1138
+ | Parallel trends | Assumed | Partially relaxed via matching |
1139
+ | Interpretability | Simple average | Explicit weights |
1140
+
1141
+ **Parameters:**
1142
+
1143
+ ```python
1144
+ SyntheticDiD(
1145
+ lambda_reg=0.0, # Regularization toward uniform weights (0 = no reg)
1146
+ zeta=1.0, # Time weight regularization (higher = more uniform)
1147
+ alpha=0.05, # Significance level
1148
+ n_bootstrap=200, # Bootstrap iterations for SE (0 = placebo-based)
1149
+ seed=None # Random seed for reproducibility
1150
+ )
1151
+ ```
1152
+
1153
+ ## Working with Results
1154
+
1155
+ ### Export Results
1156
+
1157
+ ```python
1158
+ # As dictionary
1159
+ results.to_dict()
1160
+ # {'att': 3.5, 'se': 1.26, 'p_value': 0.037, ...}
1161
+
1162
+ # As DataFrame
1163
+ df = results.to_dataframe()
1164
+ ```
1165
+
1166
+ ### Check Significance
1167
+
1168
+ ```python
1169
+ if results.is_significant:
1170
+ print(f"Effect is significant at {did.alpha} level")
1171
+
1172
+ # Get significance stars
1173
+ print(f"ATT: {results.att}{results.significance_stars}")
1174
+ # ATT: 3.5000*
1175
+ ```
1176
+
1177
+ ### Access Full Regression Output
1178
+
1179
+ ```python
1180
+ # All coefficients
1181
+ results.coefficients
1182
+ # {'const': 9.5, 'treated': 1.0, 'post': 2.5, 'treated:post': 3.5}
1183
+
1184
+ # Variance-covariance matrix
1185
+ results.vcov
1186
+
1187
+ # Residuals and fitted values
1188
+ results.residuals
1189
+ results.fitted_values
1190
+
1191
+ # R-squared
1192
+ results.r_squared
1193
+ ```
1194
+
1195
+ ## Checking Assumptions
1196
+
1197
+ ### Parallel Trends
1198
+
1199
+ **Simple slope-based test:**
1200
+
1201
+ ```python
1202
+ from diff_diff.utils import check_parallel_trends
1203
+
1204
+ trends = check_parallel_trends(
1205
+ data,
1206
+ outcome='outcome',
1207
+ time='period',
1208
+ treatment_group='treated'
1209
+ )
1210
+
1211
+ print(f"Treated trend: {trends['treated_trend']:.4f}")
1212
+ print(f"Control trend: {trends['control_trend']:.4f}")
1213
+ print(f"Difference p-value: {trends['p_value']:.4f}")
1214
+ ```
1215
+
1216
+ **Robust distributional test (Wasserstein distance):**
1217
+
1218
+ ```python
1219
+ from diff_diff.utils import check_parallel_trends_robust
1220
+
1221
+ results = check_parallel_trends_robust(
1222
+ data,
1223
+ outcome='outcome',
1224
+ time='period',
1225
+ treatment_group='treated',
1226
+ unit='firm_id', # Unit identifier for panel data
1227
+ pre_periods=[2018, 2019], # Pre-treatment periods
1228
+ n_permutations=1000 # Permutations for p-value
1229
+ )
1230
+
1231
+ print(f"Wasserstein distance: {results['wasserstein_distance']:.4f}")
1232
+ print(f"Wasserstein p-value: {results['wasserstein_p_value']:.4f}")
1233
+ print(f"KS test p-value: {results['ks_p_value']:.4f}")
1234
+ print(f"Parallel trends plausible: {results['parallel_trends_plausible']}")
1235
+ ```
1236
+
1237
+ The Wasserstein (Earth Mover's) distance compares the full distribution of outcome changes, not just means. This is more robust to:
1238
+ - Non-normal distributions
1239
+ - Heterogeneous effects across units
1240
+ - Outliers
1241
+
1242
+ **Equivalence testing (TOST):**
1243
+
1244
+ ```python
1245
+ from diff_diff.utils import equivalence_test_trends
1246
+
1247
+ results = equivalence_test_trends(
1248
+ data,
1249
+ outcome='outcome',
1250
+ time='period',
1251
+ treatment_group='treated',
1252
+ unit='firm_id',
1253
+ equivalence_margin=0.5 # Define "practically equivalent"
1254
+ )
1255
+
1256
+ print(f"Mean difference: {results['mean_difference']:.4f}")
1257
+ print(f"TOST p-value: {results['tost_p_value']:.4f}")
1258
+ print(f"Trends equivalent: {results['equivalent']}")
1259
+ ```
1260
+
1261
+ ### Honest DiD Sensitivity Analysis (Rambachan-Roth)
1262
+
1263
+ Pre-trends tests have low power and can exacerbate bias. **Honest DiD** (Rambachan & Roth 2023) provides sensitivity analysis showing how robust your results are to violations of parallel trends.
1264
+
1265
+ ```python
1266
+ from diff_diff import HonestDiD, MultiPeriodDiD
1267
+
1268
+ # First, fit a standard event study
1269
+ did = MultiPeriodDiD()
1270
+ event_results = did.fit(
1271
+ data,
1272
+ outcome='outcome',
1273
+ treatment='treated',
1274
+ time='period',
1275
+ post_periods=[5, 6, 7, 8, 9]
1276
+ )
1277
+
1278
+ # Compute honest bounds with relative magnitudes restriction
1279
+ # M=1 means post-treatment violations can be up to 1x the worst pre-treatment violation
1280
+ honest = HonestDiD(method='relative_magnitude', M=1.0)
1281
+ honest_results = honest.fit(event_results)
1282
+
1283
+ print(honest_results.summary())
1284
+ print(f"Original estimate: {honest_results.original_estimate:.4f}")
1285
+ print(f"Robust 95% CI: [{honest_results.ci_lb:.4f}, {honest_results.ci_ub:.4f}]")
1286
+ print(f"Effect robust to violations: {honest_results.is_significant}")
1287
+ ```
1288
+
1289
+ **Sensitivity analysis over M values:**
1290
+
1291
+ ```python
1292
+ # How do results change as we allow larger violations?
1293
+ sensitivity = honest.sensitivity_analysis(
1294
+ event_results,
1295
+ M_grid=[0, 0.5, 1.0, 1.5, 2.0]
1296
+ )
1297
+
1298
+ print(sensitivity.summary())
1299
+ print(f"Breakdown value: M = {sensitivity.breakdown_M}")
1300
+ # Breakdown = smallest M where the robust CI includes zero
1301
+ ```
1302
+
1303
+ **Breakdown value:**
1304
+
1305
+ The breakdown value tells you how robust your conclusion is:
1306
+
1307
+ ```python
1308
+ breakdown = honest.breakdown_value(event_results)
1309
+ if breakdown >= 1.0:
1310
+ print("Result holds even if post-treatment violations are as bad as pre-treatment")
1311
+ else:
1312
+ print(f"Result requires violations smaller than {breakdown:.1f}x pre-treatment")
1313
+ ```
1314
+
1315
+ **Smoothness restriction (alternative approach):**
1316
+
1317
+ ```python
1318
+ # Bounds second differences of trend violations
1319
+ # M=0 means linear extrapolation of pre-trends
1320
+ honest_smooth = HonestDiD(method='smoothness', M=0.5)
1321
+ smooth_results = honest_smooth.fit(event_results)
1322
+ ```
1323
+
1324
+ **Visualization:**
1325
+
1326
+ ```python
1327
+ from diff_diff import plot_sensitivity, plot_honest_event_study
1328
+
1329
+ # Plot sensitivity analysis
1330
+ plot_sensitivity(sensitivity, title="Sensitivity to Parallel Trends Violations")
1331
+
1332
+ # Event study with honest confidence intervals
1333
+ plot_honest_event_study(event_results, honest_results)
1334
+ ```
1335
+
1336
+ ### Pre-Trends Power Analysis (Roth 2022)
1337
+
1338
+ A passing pre-trends test doesn't mean parallel trends holds—it may just mean the test has low power. **Pre-Trends Power Analysis** (Roth 2022) answers: "What violations could my pre-trends test have detected?"
1339
+
1340
+ ```python
1341
+ from diff_diff import PreTrendsPower, MultiPeriodDiD
1342
+
1343
+ # First, fit an event study
1344
+ did = MultiPeriodDiD()
1345
+ event_results = did.fit(
1346
+ data,
1347
+ outcome='outcome',
1348
+ treatment='treated',
1349
+ time='period',
1350
+ post_periods=[5, 6, 7, 8, 9]
1351
+ )
1352
+
1353
+ # Analyze pre-trends test power
1354
+ pt = PreTrendsPower(alpha=0.05, power=0.80)
1355
+ power_results = pt.fit(event_results)
1356
+
1357
+ print(power_results.summary())
1358
+ print(f"Minimum Detectable Violation (MDV): {power_results.mdv:.4f}")
1359
+ print(f"Power to detect violations of size MDV: {power_results.power:.1%}")
1360
+ ```
1361
+
1362
+ **Key concepts:**
1363
+
1364
+ - **Minimum Detectable Violation (MDV)**: Smallest violation magnitude that would be detected with your target power (e.g., 80%). Passing the pre-trends test does NOT rule out violations up to this size.
1365
+ - **Power**: Probability of detecting a violation of given size if it exists.
1366
+ - **Violation types**: Linear trend, constant violation, last-period only, or custom patterns.
1367
+
1368
+ **Power curve visualization:**
1369
+
1370
+ ```python
1371
+ from diff_diff import plot_pretrends_power
1372
+
1373
+ # Generate power curve across violation magnitudes
1374
+ curve = pt.power_curve(event_results)
1375
+
1376
+ # Plot the power curve
1377
+ plot_pretrends_power(curve, title="Pre-Trends Test Power Curve")
1378
+
1379
+ # Or from the curve object directly
1380
+ curve.plot()
1381
+ ```
1382
+
1383
+ **Different violation patterns:**
1384
+
1385
+ ```python
1386
+ # Linear trend violations (default) - most common assumption
1387
+ pt_linear = PreTrendsPower(violation_type='linear')
1388
+
1389
+ # Constant violation in all pre-periods
1390
+ pt_constant = PreTrendsPower(violation_type='constant')
1391
+
1392
+ # Violation only in the last pre-period (sharp break)
1393
+ pt_last = PreTrendsPower(violation_type='last_period')
1394
+
1395
+ # Custom violation pattern
1396
+ custom_weights = np.array([0.1, 0.3, 0.6]) # Increasing violations
1397
+ pt_custom = PreTrendsPower(violation_type='custom', violation_weights=custom_weights)
1398
+ ```
1399
+
1400
+ **Combining with HonestDiD:**
1401
+
1402
+ Pre-trends power analysis and HonestDiD are complementary:
1403
+ 1. **Pre-trends power** tells you what the test could have detected
1404
+ 2. **HonestDiD** tells you how robust your results are to violations
1405
+
1406
+ ```python
1407
+ from diff_diff import HonestDiD, PreTrendsPower
1408
+
1409
+ # If MDV is large relative to your estimated effect, be cautious
1410
+ pt = PreTrendsPower()
1411
+ power_results = pt.fit(event_results)
1412
+ sensitivity = pt.sensitivity_to_honest_did(event_results)
1413
+ print(sensitivity['interpretation'])
1414
+
1415
+ # Use HonestDiD for robust inference
1416
+ honest = HonestDiD(method='relative_magnitude', M=1.0)
1417
+ honest_results = honest.fit(event_results)
1418
+ ```
1419
+
1420
+ ### Placebo Tests
1421
+
1422
+ Placebo tests help validate the parallel trends assumption by checking whether effects appear where they shouldn't (before treatment or in untreated groups).
1423
+
1424
+ **Fake timing test:**
1425
+
1426
+ ```python
1427
+ from diff_diff import run_placebo_test
1428
+
1429
+ # Test: Is there an effect before treatment actually occurred?
1430
+ # Actual treatment is at period 3 (post_periods=[3, 4, 5])
1431
+ # We test if a "fake" treatment at period 1 shows an effect
1432
+ results = run_placebo_test(
1433
+ data,
1434
+ outcome='outcome',
1435
+ treatment='treated',
1436
+ time='period',
1437
+ test_type='fake_timing',
1438
+ fake_treatment_period=1, # Pretend treatment was in period 1
1439
+ post_periods=[3, 4, 5] # Actual post-treatment periods
1440
+ )
1441
+
1442
+ print(results.summary())
1443
+ # If parallel trends hold, placebo_effect should be ~0 and not significant
1444
+ print(f"Placebo effect: {results.placebo_effect:.3f} (p={results.p_value:.3f})")
1445
+ print(f"Is significant (bad): {results.is_significant}")
1446
+ ```
1447
+
1448
+ **Fake group test:**
1449
+
1450
+ ```python
1451
+ # Test: Is there an effect among never-treated units?
1452
+ # Get some control unit IDs to use as "fake treated"
1453
+ control_units = data[data['treated'] == 0]['firm_id'].unique()[:5]
1454
+
1455
+ results = run_placebo_test(
1456
+ data,
1457
+ outcome='outcome',
1458
+ treatment='treated',
1459
+ time='period',
1460
+ unit='firm_id',
1461
+ test_type='fake_group',
1462
+ fake_treatment_group=list(control_units), # List of control unit IDs
1463
+ post_periods=[3, 4, 5]
1464
+ )
1465
+ ```
1466
+
1467
+ **Permutation test:**
1468
+
1469
+ ```python
1470
+ # Randomly reassign treatment and compute distribution of effects
1471
+ # Note: requires binary post indicator (use 'post' column, not 'period')
1472
+ results = run_placebo_test(
1473
+ data,
1474
+ outcome='outcome',
1475
+ treatment='treated',
1476
+ time='post', # Binary post-treatment indicator
1477
+ unit='firm_id',
1478
+ test_type='permutation',
1479
+ n_permutations=1000,
1480
+ seed=42
1481
+ )
1482
+
1483
+ print(f"Original effect: {results.original_effect:.3f}")
1484
+ print(f"Permutation p-value: {results.p_value:.4f}")
1485
+ # Low p-value indicates the effect is unlikely to be due to chance
1486
+ ```
1487
+
1488
+ **Leave-one-out sensitivity:**
1489
+
1490
+ ```python
1491
+ # Test sensitivity to individual treated units
1492
+ # Note: requires binary post indicator (use 'post' column, not 'period')
1493
+ results = run_placebo_test(
1494
+ data,
1495
+ outcome='outcome',
1496
+ treatment='treated',
1497
+ time='post', # Binary post-treatment indicator
1498
+ unit='firm_id',
1499
+ test_type='leave_one_out'
1500
+ )
1501
+
1502
+ # Check if any single unit drives the result
1503
+ print(results.leave_one_out_effects) # Effect when each unit is dropped
1504
+ ```
1505
+
1506
+ **Run all placebo tests:**
1507
+
1508
+ ```python
1509
+ from diff_diff import run_all_placebo_tests
1510
+
1511
+ # Comprehensive diagnostic suite
1512
+ # Note: This function runs fake_timing tests on pre-treatment periods.
1513
+ # The permutation and leave_one_out tests require a binary post indicator,
1514
+ # so they may return errors if the data uses multi-period time column.
1515
+ all_results = run_all_placebo_tests(
1516
+ data,
1517
+ outcome='outcome',
1518
+ treatment='treated',
1519
+ time='period',
1520
+ unit='firm_id',
1521
+ pre_periods=[0, 1, 2],
1522
+ post_periods=[3, 4, 5],
1523
+ n_permutations=500,
1524
+ seed=42
1525
+ )
1526
+
1527
+ for test_name, result in all_results.items():
1528
+ if hasattr(result, 'p_value'):
1529
+ print(f"{test_name}: p={result.p_value:.3f}, significant={result.is_significant}")
1530
+ elif isinstance(result, dict) and 'error' in result:
1531
+ print(f"{test_name}: Error - {result['error']}")
1532
+ ```
1533
+
1534
+ ## API Reference
1535
+
1536
+ ### DifferenceInDifferences
1537
+
1538
+ ```python
1539
+ DifferenceInDifferences(
1540
+ robust=True, # Use HC1 robust standard errors
1541
+ cluster=None, # Column for cluster-robust SEs
1542
+ alpha=0.05 # Significance level for CIs
1543
+ )
1544
+ ```
1545
+
1546
+ **Methods:**
1547
+
1548
+ | Method | Description |
1549
+ |--------|-------------|
1550
+ | `fit(data, outcome, treatment, time, ...)` | Fit the DiD model |
1551
+ | `summary()` | Get formatted summary string |
1552
+ | `print_summary()` | Print summary to stdout |
1553
+ | `get_params()` | Get estimator parameters (sklearn-compatible) |
1554
+ | `set_params(**params)` | Set estimator parameters (sklearn-compatible) |
1555
+
1556
+ **fit() Parameters:**
1557
+
1558
+ | Parameter | Type | Description |
1559
+ |-----------|------|-------------|
1560
+ | `data` | DataFrame | Input data |
1561
+ | `outcome` | str | Outcome variable column name |
1562
+ | `treatment` | str | Treatment indicator column (0/1) |
1563
+ | `time` | str | Post-treatment indicator column (0/1) |
1564
+ | `formula` | str | R-style formula (alternative to column names) |
1565
+ | `covariates` | list | Linear control variables |
1566
+ | `fixed_effects` | list | Categorical FE columns (creates dummies) |
1567
+ | `absorb` | list | High-dimensional FE (within-transformation) |
1568
+
1569
+ ### DiDResults
1570
+
1571
+ **Attributes:**
1572
+
1573
+ | Attribute | Description |
1574
+ |-----------|-------------|
1575
+ | `att` | Average Treatment effect on the Treated |
1576
+ | `se` | Standard error of ATT |
1577
+ | `t_stat` | T-statistic |
1578
+ | `p_value` | P-value for H0: ATT = 0 |
1579
+ | `conf_int` | Tuple of (lower, upper) confidence bounds |
1580
+ | `n_obs` | Number of observations |
1581
+ | `n_treated` | Number of treated units |
1582
+ | `n_control` | Number of control units |
1583
+ | `r_squared` | R-squared of regression |
1584
+ | `coefficients` | Dictionary of all coefficients |
1585
+ | `is_significant` | Boolean for significance at alpha |
1586
+ | `significance_stars` | String of significance stars |
1587
+
1588
+ **Methods:**
1589
+
1590
+ | Method | Description |
1591
+ |--------|-------------|
1592
+ | `summary(alpha)` | Get formatted summary string |
1593
+ | `print_summary(alpha)` | Print summary to stdout |
1594
+ | `to_dict()` | Convert to dictionary |
1595
+ | `to_dataframe()` | Convert to pandas DataFrame |
1596
+
1597
+ ### MultiPeriodDiD
1598
+
1599
+ ```python
1600
+ MultiPeriodDiD(
1601
+ robust=True, # Use HC1 robust standard errors
1602
+ cluster=None, # Column for cluster-robust SEs
1603
+ alpha=0.05 # Significance level for CIs
1604
+ )
1605
+ ```
1606
+
1607
+ **fit() Parameters:**
1608
+
1609
+ | Parameter | Type | Description |
1610
+ |-----------|------|-------------|
1611
+ | `data` | DataFrame | Input data |
1612
+ | `outcome` | str | Outcome variable column name |
1613
+ | `treatment` | str | Treatment indicator column (0/1) |
1614
+ | `time` | str | Time period column (multiple values) |
1615
+ | `post_periods` | list | List of post-treatment period values |
1616
+ | `covariates` | list | Linear control variables |
1617
+ | `fixed_effects` | list | Categorical FE columns (creates dummies) |
1618
+ | `absorb` | list | High-dimensional FE (within-transformation) |
1619
+ | `reference_period` | any | Omitted period for time dummies |
1620
+
1621
+ ### MultiPeriodDiDResults
1622
+
1623
+ **Attributes:**
1624
+
1625
+ | Attribute | Description |
1626
+ |-----------|-------------|
1627
+ | `period_effects` | Dict mapping periods to PeriodEffect objects |
1628
+ | `avg_att` | Average ATT across post-treatment periods |
1629
+ | `avg_se` | Standard error of average ATT |
1630
+ | `avg_t_stat` | T-statistic for average ATT |
1631
+ | `avg_p_value` | P-value for average ATT |
1632
+ | `avg_conf_int` | Confidence interval for average ATT |
1633
+ | `n_obs` | Number of observations |
1634
+ | `pre_periods` | List of pre-treatment periods |
1635
+ | `post_periods` | List of post-treatment periods |
1636
+
1637
+ **Methods:**
1638
+
1639
+ | Method | Description |
1640
+ |--------|-------------|
1641
+ | `get_effect(period)` | Get PeriodEffect for specific period |
1642
+ | `summary(alpha)` | Get formatted summary string |
1643
+ | `print_summary(alpha)` | Print summary to stdout |
1644
+ | `to_dict()` | Convert to dictionary |
1645
+ | `to_dataframe()` | Convert to pandas DataFrame |
1646
+
1647
+ ### PeriodEffect
1648
+
1649
+ **Attributes:**
1650
+
1651
+ | Attribute | Description |
1652
+ |-----------|-------------|
1653
+ | `period` | Time period identifier |
1654
+ | `effect` | Treatment effect estimate |
1655
+ | `se` | Standard error |
1656
+ | `t_stat` | T-statistic |
1657
+ | `p_value` | P-value |
1658
+ | `conf_int` | Confidence interval |
1659
+ | `is_significant` | Boolean for significance at 0.05 |
1660
+ | `significance_stars` | String of significance stars |
1661
+
1662
+ ### SyntheticDiD
1663
+
1664
+ ```python
1665
+ SyntheticDiD(
1666
+ lambda_reg=0.0, # L2 regularization for unit weights
1667
+ zeta=1.0, # Regularization for time weights
1668
+ alpha=0.05, # Significance level for CIs
1669
+ n_bootstrap=200, # Bootstrap iterations for SE
1670
+ seed=None # Random seed for reproducibility
1671
+ )
1672
+ ```
1673
+
1674
+ **fit() Parameters:**
1675
+
1676
+ | Parameter | Type | Description |
1677
+ |-----------|------|-------------|
1678
+ | `data` | DataFrame | Panel data |
1679
+ | `outcome` | str | Outcome variable column name |
1680
+ | `treatment` | str | Treatment indicator column (0/1) |
1681
+ | `unit` | str | Unit identifier column |
1682
+ | `time` | str | Time period column |
1683
+ | `post_periods` | list | List of post-treatment period values |
1684
+ | `covariates` | list | Covariates to residualize out |
1685
+
1686
+ ### SyntheticDiDResults
1687
+
1688
+ **Attributes:**
1689
+
1690
+ | Attribute | Description |
1691
+ |-----------|-------------|
1692
+ | `att` | Average Treatment effect on the Treated |
1693
+ | `se` | Standard error (bootstrap or placebo-based) |
1694
+ | `t_stat` | T-statistic |
1695
+ | `p_value` | P-value |
1696
+ | `conf_int` | Confidence interval |
1697
+ | `n_obs` | Number of observations |
1698
+ | `n_treated` | Number of treated units |
1699
+ | `n_control` | Number of control units |
1700
+ | `unit_weights` | Dict mapping control unit IDs to weights |
1701
+ | `time_weights` | Dict mapping pre-treatment periods to weights |
1702
+ | `pre_periods` | List of pre-treatment periods |
1703
+ | `post_periods` | List of post-treatment periods |
1704
+ | `pre_treatment_fit` | RMSE of synthetic vs treated in pre-period |
1705
+ | `placebo_effects` | Array of placebo effect estimates |
1706
+
1707
+ **Methods:**
1708
+
1709
+ | Method | Description |
1710
+ |--------|-------------|
1711
+ | `summary(alpha)` | Get formatted summary string |
1712
+ | `print_summary(alpha)` | Print summary to stdout |
1713
+ | `to_dict()` | Convert to dictionary |
1714
+ | `to_dataframe()` | Convert to pandas DataFrame |
1715
+ | `get_unit_weights_df()` | Get unit weights as DataFrame |
1716
+ | `get_time_weights_df()` | Get time weights as DataFrame |
1717
+
1718
+ ### SunAbraham
1719
+
1720
+ ```python
1721
+ SunAbraham(
1722
+ control_group='never_treated', # or 'not_yet_treated'
1723
+ anticipation=0, # Periods of anticipation effects
1724
+ alpha=0.05, # Significance level for CIs
1725
+ cluster=None, # Column for cluster-robust SEs
1726
+ n_bootstrap=0, # Bootstrap iterations (0 = analytical SEs)
1727
+ bootstrap_weights='rademacher', # 'rademacher', 'mammen', or 'webb'
1728
+ seed=None # Random seed
1729
+ )
1730
+ ```
1731
+
1732
+ **fit() Parameters:**
1733
+
1734
+ | Parameter | Type | Description |
1735
+ |-----------|------|-------------|
1736
+ | `data` | DataFrame | Panel data |
1737
+ | `outcome` | str | Outcome variable column name |
1738
+ | `unit` | str | Unit identifier column |
1739
+ | `time` | str | Time period column |
1740
+ | `first_treat` | str | Column with first treatment period (0 for never-treated) |
1741
+ | `covariates` | list | Covariate column names |
1742
+ | `min_pre_periods` | int | Minimum pre-treatment periods to include |
1743
+ | `min_post_periods` | int | Minimum post-treatment periods to include |
1744
+
1745
+ ### SunAbrahamResults
1746
+
1747
+ **Attributes:**
1748
+
1749
+ | Attribute | Description |
1750
+ |-----------|-------------|
1751
+ | `event_study_effects` | Dict mapping relative time to effect info |
1752
+ | `overall_att` | Overall average treatment effect |
1753
+ | `overall_se` | Standard error of overall ATT |
1754
+ | `overall_t_stat` | T-statistic for overall ATT |
1755
+ | `overall_p_value` | P-value for overall ATT |
1756
+ | `overall_conf_int` | Confidence interval for overall ATT |
1757
+ | `cohort_weights` | Dict mapping relative time to cohort weights |
1758
+ | `groups` | List of treatment cohorts |
1759
+ | `time_periods` | List of all time periods |
1760
+ | `n_obs` | Total number of observations |
1761
+ | `n_treated_units` | Number of ever-treated units |
1762
+ | `n_control_units` | Number of never-treated units |
1763
+ | `is_significant` | Boolean for significance at alpha |
1764
+ | `significance_stars` | String of significance stars |
1765
+ | `bootstrap_results` | SABootstrapResults (if bootstrap enabled) |
1766
+
1767
+ **Methods:**
1768
+
1769
+ | Method | Description |
1770
+ |--------|-------------|
1771
+ | `summary(alpha)` | Get formatted summary string |
1772
+ | `print_summary(alpha)` | Print summary to stdout |
1773
+ | `to_dataframe(level)` | Convert to DataFrame ('event_study' or 'cohort') |
1774
+
1775
+ ### TripleDifference
1776
+
1777
+ ```python
1778
+ TripleDifference(
1779
+ estimation_method='dr', # 'dr' (doubly robust), 'reg', or 'ipw'
1780
+ robust=True, # Use HC1 robust standard errors
1781
+ cluster=None, # Column for cluster-robust SEs
1782
+ alpha=0.05, # Significance level for CIs
1783
+ pscore_trim=0.01 # Propensity score trimming threshold
1784
+ )
1785
+ ```
1786
+
1787
+ **fit() Parameters:**
1788
+
1789
+ | Parameter | Type | Description |
1790
+ |-----------|------|-------------|
1791
+ | `data` | DataFrame | Input data |
1792
+ | `outcome` | str | Outcome variable column name |
1793
+ | `group` | str | Group indicator column (0/1): 1=treated group |
1794
+ | `partition` | str | Partition/eligibility indicator column (0/1): 1=eligible |
1795
+ | `time` | str | Time indicator column (0/1): 1=post-treatment |
1796
+ | `covariates` | list | Covariate column names for adjustment |
1797
+
1798
+ ### TripleDifferenceResults
1799
+
1800
+ **Attributes:**
1801
+
1802
+ | Attribute | Description |
1803
+ |-----------|-------------|
1804
+ | `att` | Average Treatment effect on the Treated |
1805
+ | `se` | Standard error of ATT |
1806
+ | `t_stat` | T-statistic |
1807
+ | `p_value` | P-value for H0: ATT = 0 |
1808
+ | `conf_int` | Tuple of (lower, upper) confidence bounds |
1809
+ | `n_obs` | Total number of observations |
1810
+ | `n_treated_eligible` | Obs in treated group & eligible partition |
1811
+ | `n_treated_ineligible` | Obs in treated group & ineligible partition |
1812
+ | `n_control_eligible` | Obs in control group & eligible partition |
1813
+ | `n_control_ineligible` | Obs in control group & ineligible partition |
1814
+ | `estimation_method` | Method used ('dr', 'reg', or 'ipw') |
1815
+ | `group_means` | Dict of cell means for diagnostics |
1816
+ | `pscore_stats` | Propensity score statistics (IPW/DR only) |
1817
+ | `is_significant` | Boolean for significance at alpha |
1818
+ | `significance_stars` | String of significance stars |
1819
+
1820
+ **Methods:**
1821
+
1822
+ | Method | Description |
1823
+ |--------|-------------|
1824
+ | `summary(alpha)` | Get formatted summary string |
1825
+ | `print_summary(alpha)` | Print summary to stdout |
1826
+ | `to_dict()` | Convert to dictionary |
1827
+ | `to_dataframe()` | Convert to pandas DataFrame |
1828
+
1829
+ ### HonestDiD
1830
+
1831
+ ```python
1832
+ HonestDiD(
1833
+ method='relative_magnitude', # 'relative_magnitude' or 'smoothness'
1834
+ M=None, # Restriction parameter (default: 1.0 for RM, 0.0 for SD)
1835
+ alpha=0.05, # Significance level for CIs
1836
+ l_vec=None # Linear combination vector for target parameter
1837
+ )
1838
+ ```
1839
+
1840
+ **fit() Parameters:**
1841
+
1842
+ | Parameter | Type | Description |
1843
+ |-----------|------|-------------|
1844
+ | `results` | MultiPeriodDiDResults | Results from MultiPeriodDiD.fit() |
1845
+ | `M` | float | Restriction parameter (overrides constructor value) |
1846
+
1847
+ **Methods:**
1848
+
1849
+ | Method | Description |
1850
+ |--------|-------------|
1851
+ | `fit(results, M)` | Compute bounds for given event study results |
1852
+ | `sensitivity_analysis(results, M_grid)` | Compute bounds over grid of M values |
1853
+ | `breakdown_value(results, tol)` | Find smallest M where CI includes zero |
1854
+
1855
+ ### HonestDiDResults
1856
+
1857
+ **Attributes:**
1858
+
1859
+ | Attribute | Description |
1860
+ |-----------|-------------|
1861
+ | `original_estimate` | Point estimate under parallel trends |
1862
+ | `lb` | Lower bound of identified set |
1863
+ | `ub` | Upper bound of identified set |
1864
+ | `ci_lb` | Lower bound of robust confidence interval |
1865
+ | `ci_ub` | Upper bound of robust confidence interval |
1866
+ | `ci_width` | Width of robust CI |
1867
+ | `M` | Restriction parameter used |
1868
+ | `method` | Restriction method ('relative_magnitude' or 'smoothness') |
1869
+ | `alpha` | Significance level |
1870
+ | `is_significant` | True if robust CI excludes zero |
1871
+
1872
+ **Methods:**
1873
+
1874
+ | Method | Description |
1875
+ |--------|-------------|
1876
+ | `summary()` | Get formatted summary string |
1877
+ | `to_dict()` | Convert to dictionary |
1878
+ | `to_dataframe()` | Convert to pandas DataFrame |
1879
+
1880
+ ### SensitivityResults
1881
+
1882
+ **Attributes:**
1883
+
1884
+ | Attribute | Description |
1885
+ |-----------|-------------|
1886
+ | `M_grid` | Array of M values analyzed |
1887
+ | `results` | List of HonestDiDResults for each M |
1888
+ | `breakdown_M` | Smallest M where CI includes zero (None if always significant) |
1889
+
1890
+ **Methods:**
1891
+
1892
+ | Method | Description |
1893
+ |--------|-------------|
1894
+ | `summary()` | Get formatted summary string |
1895
+ | `plot(ax)` | Plot sensitivity analysis |
1896
+ | `to_dataframe()` | Convert to pandas DataFrame |
1897
+
1898
+ ### PreTrendsPower
1899
+
1900
+ ```python
1901
+ PreTrendsPower(
1902
+ alpha=0.05, # Significance level for pre-trends test
1903
+ power=0.80, # Target power for MDV calculation
1904
+ violation_type='linear', # 'linear', 'constant', 'last_period', 'custom'
1905
+ violation_weights=None # Custom weights (required if violation_type='custom')
1906
+ )
1907
+ ```
1908
+
1909
+ **fit() Parameters:**
1910
+
1911
+ | Parameter | Type | Description |
1912
+ |-----------|------|-------------|
1913
+ | `results` | MultiPeriodDiDResults | Results from event study |
1914
+ | `M` | float | Specific violation magnitude to evaluate |
1915
+
1916
+ **Methods:**
1917
+
1918
+ | Method | Description |
1919
+ |--------|-------------|
1920
+ | `fit(results, M)` | Compute power analysis for given event study |
1921
+ | `power_at(results, M)` | Compute power for specific violation magnitude |
1922
+ | `power_curve(results, M_grid, n_points)` | Compute power across range of M values |
1923
+ | `sensitivity_to_honest_did(results)` | Compare with HonestDiD analysis |
1924
+
1925
+ ### PreTrendsPowerResults
1926
+
1927
+ **Attributes:**
1928
+
1929
+ | Attribute | Description |
1930
+ |-----------|-------------|
1931
+ | `power` | Power to detect the specified violation |
1932
+ | `mdv` | Minimum detectable violation at target power |
1933
+ | `violation_magnitude` | Violation magnitude (M) tested |
1934
+ | `violation_type` | Type of violation pattern |
1935
+ | `alpha` | Significance level |
1936
+ | `target_power` | Target power level |
1937
+ | `n_pre_periods` | Number of pre-treatment periods |
1938
+ | `test_statistic` | Expected test statistic under violation |
1939
+ | `critical_value` | Critical value for pre-trends test |
1940
+ | `noncentrality` | Non-centrality parameter |
1941
+ | `is_informative` | Heuristic check if test is informative |
1942
+ | `power_adequate` | Whether power meets target |
1943
+
1944
+ **Methods:**
1945
+
1946
+ | Method | Description |
1947
+ |--------|-------------|
1948
+ | `summary()` | Get formatted summary string |
1949
+ | `print_summary()` | Print summary to stdout |
1950
+ | `to_dict()` | Convert to dictionary |
1951
+ | `to_dataframe()` | Convert to pandas DataFrame |
1952
+
1953
+ ### PreTrendsPowerCurve
1954
+
1955
+ **Attributes:**
1956
+
1957
+ | Attribute | Description |
1958
+ |-----------|-------------|
1959
+ | `M_values` | Array of violation magnitudes |
1960
+ | `powers` | Array of power values |
1961
+ | `mdv` | Minimum detectable violation |
1962
+ | `alpha` | Significance level |
1963
+ | `target_power` | Target power level |
1964
+ | `violation_type` | Type of violation pattern |
1965
+
1966
+ **Methods:**
1967
+
1968
+ | Method | Description |
1969
+ |--------|-------------|
1970
+ | `plot(ax, show_mdv, show_target)` | Plot power curve |
1971
+ | `to_dataframe()` | Convert to DataFrame with M and power columns |
1972
+
1973
+ ### Data Preparation Functions
1974
+
1975
+ #### generate_did_data
1976
+
1977
+ ```python
1978
+ generate_did_data(
1979
+ n_units=100, # Number of units
1980
+ n_periods=4, # Number of time periods
1981
+ treatment_effect=5.0, # True ATT
1982
+ treatment_fraction=0.5, # Fraction treated
1983
+ treatment_period=2, # First post-treatment period
1984
+ unit_fe_sd=2.0, # Unit fixed effect std dev
1985
+ time_trend=0.5, # Linear time trend
1986
+ noise_sd=1.0, # Idiosyncratic noise std dev
1987
+ seed=None # Random seed
1988
+ )
1989
+ ```
1990
+
1991
+ Returns DataFrame with columns: `unit`, `period`, `treated`, `post`, `outcome`, `true_effect`.
1992
+
1993
+ #### make_treatment_indicator
1994
+
1995
+ ```python
1996
+ make_treatment_indicator(
1997
+ data, # Input DataFrame
1998
+ column, # Column to create treatment from
1999
+ treated_values=None, # Value(s) indicating treatment
2000
+ threshold=None, # Numeric threshold for treatment
2001
+ above_threshold=True, # If True, >= threshold is treated
2002
+ new_column='treated' # Output column name
2003
+ )
2004
+ ```
2005
+
2006
+ #### make_post_indicator
2007
+
2008
+ ```python
2009
+ make_post_indicator(
2010
+ data, # Input DataFrame
2011
+ time_column, # Time/period column
2012
+ post_periods=None, # Specific post-treatment period(s)
2013
+ treatment_start=None, # First post-treatment period
2014
+ new_column='post' # Output column name
2015
+ )
2016
+ ```
2017
+
2018
+ #### wide_to_long
2019
+
2020
+ ```python
2021
+ wide_to_long(
2022
+ data, # Wide-format DataFrame
2023
+ value_columns, # List of time-varying columns
2024
+ id_column, # Unit identifier column
2025
+ time_name='period', # Name for time column
2026
+ value_name='value', # Name for value column
2027
+ time_values=None # Values for time periods
2028
+ )
2029
+ ```
2030
+
2031
+ #### balance_panel
2032
+
2033
+ ```python
2034
+ balance_panel(
2035
+ data, # Panel DataFrame
2036
+ unit_column, # Unit identifier column
2037
+ time_column, # Time period column
2038
+ method='inner', # 'inner', 'outer', or 'fill'
2039
+ fill_value=None # Value for filling (if method='fill')
2040
+ )
2041
+ ```
2042
+
2043
+ #### validate_did_data
2044
+
2045
+ ```python
2046
+ validate_did_data(
2047
+ data, # DataFrame to validate
2048
+ outcome, # Outcome column name
2049
+ treatment, # Treatment column name
2050
+ time, # Time/post column name
2051
+ unit=None, # Unit column (for panel validation)
2052
+ raise_on_error=True # Raise ValueError or return dict
2053
+ )
2054
+ ```
2055
+
2056
+ Returns dict with `valid`, `errors`, `warnings`, and `summary` keys.
2057
+
2058
+ #### summarize_did_data
2059
+
2060
+ ```python
2061
+ summarize_did_data(
2062
+ data, # Input DataFrame
2063
+ outcome, # Outcome column name
2064
+ treatment, # Treatment column name
2065
+ time, # Time/post column name
2066
+ unit=None # Unit column (optional)
2067
+ )
2068
+ ```
2069
+
2070
+ Returns DataFrame with summary statistics by treatment-time cell.
2071
+
2072
+ #### create_event_time
2073
+
2074
+ ```python
2075
+ create_event_time(
2076
+ data, # Panel DataFrame
2077
+ time_column, # Calendar time column
2078
+ treatment_time_column, # Column with treatment timing
2079
+ new_column='event_time' # Output column name
2080
+ )
2081
+ ```
2082
+
2083
+ #### aggregate_to_cohorts
2084
+
2085
+ ```python
2086
+ aggregate_to_cohorts(
2087
+ data, # Unit-level panel data
2088
+ unit_column, # Unit identifier column
2089
+ time_column, # Time period column
2090
+ treatment_column, # Treatment indicator column
2091
+ outcome, # Outcome variable column
2092
+ covariates=None # Additional columns to aggregate
2093
+ )
2094
+ ```
2095
+
2096
+ #### rank_control_units
2097
+
2098
+ ```python
2099
+ rank_control_units(
2100
+ data, # Panel data in long format
2101
+ unit_column, # Unit identifier column
2102
+ time_column, # Time period column
2103
+ outcome_column, # Outcome variable column
2104
+ treatment_column=None, # Treatment indicator column (0/1)
2105
+ treated_units=None, # Explicit list of treated unit IDs
2106
+ pre_periods=None, # Pre-treatment periods (default: first half)
2107
+ covariates=None, # Covariate columns for matching
2108
+ outcome_weight=0.7, # Weight for outcome trend similarity (0-1)
2109
+ covariate_weight=0.3, # Weight for covariate distance (0-1)
2110
+ exclude_units=None, # Units to exclude from control pool
2111
+ require_units=None, # Units that must appear in output
2112
+ n_top=None, # Return only top N controls
2113
+ suggest_treatment_candidates=False, # Identify treatment candidates
2114
+ n_treatment_candidates=5, # Number of treatment candidates
2115
+ lambda_reg=0.0 # Regularization for synthetic weights
2116
+ )
2117
+ ```
2118
+
2119
+ Returns DataFrame with columns: `unit`, `quality_score`, `outcome_trend_score`, `covariate_score`, `synthetic_weight`, `pre_trend_rmse`, `is_required`.
2120
+
2121
+ ## Requirements
2122
+
2123
+ - Python >= 3.9
2124
+ - numpy >= 1.20
2125
+ - pandas >= 1.3
2126
+ - scipy >= 1.7
2127
+
2128
+ ## Development
2129
+
2130
+ ```bash
2131
+ # Install with dev dependencies
2132
+ pip install -e ".[dev]"
2133
+
2134
+ # Run tests
2135
+ pytest
2136
+
2137
+ # Format code
2138
+ black diff_diff tests
2139
+ ruff check diff_diff tests
2140
+ ```
2141
+
2142
+ ## References
2143
+
2144
+ This library implements methods from the following scholarly works:
2145
+
2146
+ ### Difference-in-Differences
2147
+
2148
+ - **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)
2149
+
2150
+ - **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)
2151
+
2152
+ - **Angrist, J. D., & Pischke, J.-S. (2009).** *Mostly Harmless Econometrics: An Empiricist's Companion*. Princeton University Press. Chapter 5: Differences-in-Differences.
2153
+
2154
+ ### Two-Way Fixed Effects
2155
+
2156
+ - **Wooldridge, J. M. (2010).** *Econometric Analysis of Cross Section and Panel Data* (2nd ed.). MIT Press.
2157
+
2158
+ - **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)
2159
+
2160
+ ### Robust Standard Errors
2161
+
2162
+ - **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)
2163
+
2164
+ - **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)
2165
+
2166
+ - **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)
2167
+
2168
+ ### Wild Cluster Bootstrap
2169
+
2170
+ - **Cameron, A. C., Gelbach, J. B., & Miller, D. L. (2008).** "Bootstrap-Based Improvements for Inference with Clustered Errors." *The Review of Economics and Statistics*, 90(3), 414-427. [https://doi.org/10.1162/rest.90.3.414](https://doi.org/10.1162/rest.90.3.414)
2171
+
2172
+ - **Webb, M. D. (2014).** "Reworking Wild Bootstrap Based Inference for Clustered Errors." Queen's Economics Department Working Paper No. 1315. [https://www.econ.queensu.ca/sites/econ.queensu.ca/files/qed_wp_1315.pdf](https://www.econ.queensu.ca/sites/econ.queensu.ca/files/qed_wp_1315.pdf)
2173
+
2174
+ - **MacKinnon, J. G., & Webb, M. D. (2018).** "The Wild Bootstrap for Few (Treated) Clusters." *The Econometrics Journal*, 21(2), 114-135. [https://doi.org/10.1111/ectj.12107](https://doi.org/10.1111/ectj.12107)
2175
+
2176
+ ### Placebo Tests and DiD Diagnostics
2177
+
2178
+ - **Bertrand, M., Duflo, E., & Mullainathan, S. (2004).** "How Much Should We Trust Differences-in-Differences Estimates?" *The Quarterly Journal of Economics*, 119(1), 249-275. [https://doi.org/10.1162/003355304772839588](https://doi.org/10.1162/003355304772839588)
2179
+
2180
+ ### Synthetic Control Method
2181
+
2182
+ - **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)
2183
+
2184
+ - **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)
2185
+
2186
+ - **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)
2187
+
2188
+ ### Synthetic Difference-in-Differences
2189
+
2190
+ - **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)
2191
+
2192
+ ### Triple Difference (DDD)
2193
+
2194
+ - **Ortiz-Villavicencio, M., & Sant'Anna, P. H. C. (2025).** "Better Understanding Triple Differences Estimators." *Working Paper*. [https://arxiv.org/abs/2505.09942](https://arxiv.org/abs/2505.09942)
2195
+
2196
+ This paper shows that common DDD implementations (taking the difference between two DiDs, or applying three-way fixed effects regressions) are generally invalid when identification requires conditioning on covariates. The `TripleDifference` class implements their regression adjustment, inverse probability weighting, and doubly robust estimators.
2197
+
2198
+ - **Gruber, J. (1994).** "The Incidence of Mandated Maternity Benefits." *American Economic Review*, 84(3), 622-641. [https://www.jstor.org/stable/2118071](https://www.jstor.org/stable/2118071)
2199
+
2200
+ Classic paper introducing the Triple Difference design for policy evaluation.
2201
+
2202
+ - **Olden, A., & Møen, J. (2022).** "The Triple Difference Estimator." *The Econometrics Journal*, 25(3), 531-553. [https://doi.org/10.1093/ectj/utac010](https://doi.org/10.1093/ectj/utac010)
2203
+
2204
+ ### Parallel Trends and Pre-Trend Testing
2205
+
2206
+ - **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)
2207
+
2208
+ - **Lakens, D. (2017).** "Equivalence Tests: A Practical Primer for t Tests, Correlations, and Meta-Analyses." *Social Psychological and Personality Science*, 8(4), 355-362. [https://doi.org/10.1177/1948550617697177](https://doi.org/10.1177/1948550617697177)
2209
+
2210
+ ### Honest DiD / Sensitivity Analysis
2211
+
2212
+ The `HonestDiD` module implements sensitivity analysis methods for relaxing the parallel trends assumption:
2213
+
2214
+ - **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)
2215
+
2216
+ This paper introduces the "Honest DiD" framework implemented in our `HonestDiD` class:
2217
+ - **Relative Magnitudes (ΔRM)**: Bounds post-treatment violations by a multiple of observed pre-treatment violations
2218
+ - **Smoothness (ΔSD)**: Bounds on second differences of trend violations, allowing for linear extrapolation of pre-trends
2219
+ - **Breakdown Analysis**: Finding the smallest violation magnitude that would overturn conclusions
2220
+ - **Robust Confidence Intervals**: Valid inference under partial identification
2221
+
2222
+ - **Roth, J., & Sant'Anna, P. H. C. (2023).** "When Is Parallel Trends Sensitive to Functional Form?" *Econometrica*, 91(2), 737-747. [https://doi.org/10.3982/ECTA19402](https://doi.org/10.3982/ECTA19402)
2223
+
2224
+ Discusses functional form sensitivity in parallel trends assumptions, relevant to understanding when smoothness restrictions are appropriate.
2225
+
2226
+ ### Multi-Period and Staggered Adoption
2227
+
2228
+ - **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)
2229
+
2230
+ - **Sant'Anna, P. H. C., & Zhao, J. (2020).** "Doubly Robust Difference-in-Differences Estimators." *Journal of Econometrics*, 219(1), 101-122. [https://doi.org/10.1016/j.jeconom.2020.06.003](https://doi.org/10.1016/j.jeconom.2020.06.003)
2231
+
2232
+ - **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)
2233
+
2234
+ - **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)
2235
+
2236
+ - **Goodman-Bacon, A. (2021).** "Difference-in-Differences with Variation in Treatment Timing." *Journal of Econometrics*, 225(2), 254-277. [https://doi.org/10.1016/j.jeconom.2021.03.014](https://doi.org/10.1016/j.jeconom.2021.03.014)
2237
+
2238
+ ### Power Analysis
2239
+
2240
+ - **Bloom, H. S. (1995).** "Minimum Detectable Effects: A Simple Way to Report the Statistical Power of Experimental Designs." *Evaluation Review*, 19(5), 547-556. [https://doi.org/10.1177/0193841X9501900504](https://doi.org/10.1177/0193841X9501900504)
2241
+
2242
+ - **Burlig, F., Preonas, L., & Woerman, M. (2020).** "Panel Data and Experimental Design." *Journal of Development Economics*, 144, 102458. [https://doi.org/10.1016/j.jdeveco.2020.102458](https://doi.org/10.1016/j.jdeveco.2020.102458)
2243
+
2244
+ Essential reference for power analysis in panel DiD designs. Discusses how serial correlation (ICC) affects power and provides formulas for panel data settings.
2245
+
2246
+ - **Djimeu, E. W., & Houndolo, D.-G. (2016).** "Power Calculation for Causal Inference in Social Science: Sample Size and Minimum Detectable Effect Determination." *Journal of Development Effectiveness*, 8(4), 508-527. [https://doi.org/10.1080/19439342.2016.1244555](https://doi.org/10.1080/19439342.2016.1244555)
2247
+
2248
+ ### General Causal Inference
2249
+
2250
+ - **Imbens, G. W., & Rubin, D. B. (2015).** *Causal Inference for Statistics, Social, and Biomedical Sciences: An Introduction*. Cambridge University Press.
2251
+
2252
+ - **Cunningham, S. (2021).** *Causal Inference: The Mixtape*. Yale University Press. [https://mixtape.scunning.com/](https://mixtape.scunning.com/)
2253
+
2254
+ ## License
2255
+
2256
+ MIT License
2257
+