diff-diff 0.1.0__py3-none-any.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.
diff_diff/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ diff-diff: A library for Difference-in-Differences analysis.
3
+
4
+ This library provides sklearn-like estimators for causal inference
5
+ using the difference-in-differences methodology.
6
+ """
7
+
8
+ from diff_diff.estimators import DifferenceInDifferences
9
+ from diff_diff.results import DiDResults
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = ["DifferenceInDifferences", "DiDResults"]
@@ -0,0 +1,701 @@
1
+ """
2
+ Difference-in-Differences estimators with sklearn-like API.
3
+ """
4
+
5
+ from typing import Optional, Union
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from scipy import stats
10
+
11
+ from diff_diff.results import DiDResults
12
+ from diff_diff.utils import (
13
+ validate_binary,
14
+ compute_robust_se,
15
+ compute_confidence_interval,
16
+ compute_p_value,
17
+ )
18
+
19
+
20
+ class DifferenceInDifferences:
21
+ """
22
+ Difference-in-Differences estimator with sklearn-like interface.
23
+
24
+ Estimates the Average Treatment effect on the Treated (ATT) using
25
+ the canonical 2x2 DiD design or panel data with two-way fixed effects.
26
+
27
+ Parameters
28
+ ----------
29
+ formula : str, optional
30
+ R-style formula for the model (e.g., "outcome ~ treated * post").
31
+ If provided, overrides column name parameters.
32
+ robust : bool, default=True
33
+ Whether to use heteroskedasticity-robust standard errors (HC1).
34
+ cluster : str, optional
35
+ Column name for cluster-robust standard errors.
36
+ alpha : float, default=0.05
37
+ Significance level for confidence intervals.
38
+
39
+ Attributes
40
+ ----------
41
+ results_ : DiDResults
42
+ Estimation results after calling fit().
43
+ is_fitted_ : bool
44
+ Whether the model has been fitted.
45
+
46
+ Examples
47
+ --------
48
+ Basic usage with a DataFrame:
49
+
50
+ >>> import pandas as pd
51
+ >>> from diff_diff import DifferenceInDifferences
52
+ >>>
53
+ >>> # Create sample data
54
+ >>> data = pd.DataFrame({
55
+ ... 'outcome': [10, 11, 15, 18, 9, 10, 12, 13],
56
+ ... 'treated': [1, 1, 1, 1, 0, 0, 0, 0],
57
+ ... 'post': [0, 0, 1, 1, 0, 0, 1, 1]
58
+ ... })
59
+ >>>
60
+ >>> # Fit the model
61
+ >>> did = DifferenceInDifferences()
62
+ >>> results = did.fit(data, outcome='outcome', treatment='treated', time='post')
63
+ >>>
64
+ >>> # View results
65
+ >>> print(results.att) # ATT estimate
66
+ >>> results.print_summary() # Full summary table
67
+
68
+ Using formula interface:
69
+
70
+ >>> did = DifferenceInDifferences()
71
+ >>> results = did.fit(data, formula='outcome ~ treated * post')
72
+
73
+ Notes
74
+ -----
75
+ The ATT is computed using the standard DiD formula:
76
+
77
+ ATT = (E[Y|D=1,T=1] - E[Y|D=1,T=0]) - (E[Y|D=0,T=1] - E[Y|D=0,T=0])
78
+
79
+ Or equivalently via OLS regression:
80
+
81
+ Y = α + β₁*D + β₂*T + β₃*(D×T) + ε
82
+
83
+ Where β₃ is the ATT.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ robust: bool = True,
89
+ cluster: str = None,
90
+ alpha: float = 0.05
91
+ ):
92
+ self.robust = robust
93
+ self.cluster = cluster
94
+ self.alpha = alpha
95
+
96
+ self.is_fitted_ = False
97
+ self.results_ = None
98
+ self._coefficients = None
99
+ self._vcov = None
100
+
101
+ def fit(
102
+ self,
103
+ data: pd.DataFrame,
104
+ outcome: str = None,
105
+ treatment: str = None,
106
+ time: str = None,
107
+ formula: str = None,
108
+ covariates: list = None,
109
+ fixed_effects: list = None,
110
+ absorb: list = None
111
+ ) -> DiDResults:
112
+ """
113
+ Fit the Difference-in-Differences model.
114
+
115
+ Parameters
116
+ ----------
117
+ data : pd.DataFrame
118
+ DataFrame containing the outcome, treatment, and time variables.
119
+ outcome : str
120
+ Name of the outcome variable column.
121
+ treatment : str
122
+ Name of the treatment group indicator column (0/1).
123
+ time : str
124
+ Name of the post-treatment period indicator column (0/1).
125
+ formula : str, optional
126
+ R-style formula (e.g., "outcome ~ treated * post").
127
+ If provided, overrides outcome, treatment, and time parameters.
128
+ covariates : list, optional
129
+ List of covariate column names to include as linear controls.
130
+ fixed_effects : list, optional
131
+ List of categorical column names to include as fixed effects.
132
+ Creates dummy variables for each category (drops first level).
133
+ Use for low-dimensional fixed effects (e.g., industry, region).
134
+ absorb : list, optional
135
+ List of categorical column names for high-dimensional fixed effects.
136
+ Uses within-transformation (demeaning) instead of dummy variables.
137
+ More efficient for large numbers of categories (e.g., firm, individual).
138
+
139
+ Returns
140
+ -------
141
+ DiDResults
142
+ Object containing estimation results.
143
+
144
+ Raises
145
+ ------
146
+ ValueError
147
+ If required parameters are missing or data validation fails.
148
+
149
+ Examples
150
+ --------
151
+ Using fixed effects (dummy variables):
152
+
153
+ >>> did.fit(data, outcome='sales', treatment='treated', time='post',
154
+ ... fixed_effects=['state', 'industry'])
155
+
156
+ Using absorbed fixed effects (within-transformation):
157
+
158
+ >>> did.fit(data, outcome='sales', treatment='treated', time='post',
159
+ ... absorb=['firm_id'])
160
+ """
161
+ # Parse formula if provided
162
+ if formula is not None:
163
+ outcome, treatment, time, covariates = self._parse_formula(formula, data)
164
+ elif outcome is None or treatment is None or time is None:
165
+ raise ValueError(
166
+ "Must provide either 'formula' or all of 'outcome', 'treatment', and 'time'"
167
+ )
168
+
169
+ # Validate inputs
170
+ self._validate_data(data, outcome, treatment, time, covariates)
171
+
172
+ # Validate fixed effects and absorb columns
173
+ if fixed_effects:
174
+ for fe in fixed_effects:
175
+ if fe not in data.columns:
176
+ raise ValueError(f"Fixed effect column '{fe}' not found in data")
177
+ if absorb:
178
+ for ab in absorb:
179
+ if ab not in data.columns:
180
+ raise ValueError(f"Absorb column '{ab}' not found in data")
181
+
182
+ # Handle absorbed fixed effects (within-transformation)
183
+ working_data = data.copy()
184
+ absorbed_vars = []
185
+ n_absorbed_effects = 0
186
+
187
+ if absorb:
188
+ # Apply within-transformation for each absorbed variable
189
+ vars_to_demean = [outcome] + (covariates or [])
190
+ for ab_var in absorb:
191
+ n_absorbed_effects += working_data[ab_var].nunique() - 1
192
+ for var in vars_to_demean:
193
+ group_means = working_data.groupby(ab_var)[var].transform("mean")
194
+ working_data[var] = working_data[var] - group_means
195
+ absorbed_vars.append(ab_var)
196
+
197
+ # Extract variables
198
+ y = working_data[outcome].values.astype(float)
199
+ d = working_data[treatment].values.astype(float)
200
+ t = working_data[time].values.astype(float)
201
+
202
+ # Validate binary variables
203
+ validate_binary(d, "treatment")
204
+ validate_binary(t, "time")
205
+
206
+ # Create interaction term
207
+ dt = d * t
208
+
209
+ # Build design matrix
210
+ X = np.column_stack([np.ones(len(y)), d, t, dt])
211
+ var_names = ["const", treatment, time, f"{treatment}:{time}"]
212
+
213
+ # Add covariates if provided
214
+ if covariates:
215
+ for cov in covariates:
216
+ X = np.column_stack([X, working_data[cov].values.astype(float)])
217
+ var_names.append(cov)
218
+
219
+ # Add fixed effects as dummy variables
220
+ if fixed_effects:
221
+ for fe in fixed_effects:
222
+ # Create dummies, drop first category to avoid multicollinearity
223
+ dummies = pd.get_dummies(data[fe], prefix=fe, drop_first=True)
224
+ for col in dummies.columns:
225
+ X = np.column_stack([X, dummies[col].values.astype(float)])
226
+ var_names.append(col)
227
+
228
+ # Fit OLS
229
+ coefficients, residuals, fitted, r_squared = self._fit_ols(X, y)
230
+
231
+ # Compute standard errors
232
+ if self.cluster is not None:
233
+ cluster_ids = data[self.cluster].values
234
+ vcov = compute_robust_se(X, residuals, cluster_ids)
235
+ elif self.robust:
236
+ vcov = compute_robust_se(X, residuals)
237
+ else:
238
+ # Classical OLS standard errors
239
+ n = len(y)
240
+ k = X.shape[1]
241
+ mse = np.sum(residuals ** 2) / (n - k)
242
+ vcov = mse * np.linalg.inv(X.T @ X)
243
+
244
+ # Extract ATT (coefficient on interaction term)
245
+ att_idx = 3 # Index of interaction term
246
+ att = coefficients[att_idx]
247
+ se = np.sqrt(vcov[att_idx, att_idx])
248
+
249
+ # Compute test statistics (adjust df for absorbed fixed effects)
250
+ df = len(y) - X.shape[1] - n_absorbed_effects
251
+ t_stat = att / se
252
+ p_value = compute_p_value(t_stat, df=df)
253
+ conf_int = compute_confidence_interval(att, se, self.alpha, df=df)
254
+
255
+ # Count observations
256
+ n_treated = int(np.sum(d))
257
+ n_control = int(np.sum(1 - d))
258
+
259
+ # Create coefficient dictionary
260
+ coef_dict = {name: coef for name, coef in zip(var_names, coefficients)}
261
+
262
+ # Store results
263
+ self.results_ = DiDResults(
264
+ att=att,
265
+ se=se,
266
+ t_stat=t_stat,
267
+ p_value=p_value,
268
+ conf_int=conf_int,
269
+ n_obs=len(y),
270
+ n_treated=n_treated,
271
+ n_control=n_control,
272
+ alpha=self.alpha,
273
+ coefficients=coef_dict,
274
+ vcov=vcov,
275
+ residuals=residuals,
276
+ fitted_values=fitted,
277
+ r_squared=r_squared,
278
+ )
279
+
280
+ self._coefficients = coefficients
281
+ self._vcov = vcov
282
+ self.is_fitted_ = True
283
+
284
+ return self.results_
285
+
286
+ def _fit_ols(self, X: np.ndarray, y: np.ndarray) -> tuple:
287
+ """
288
+ Fit OLS regression.
289
+
290
+ Parameters
291
+ ----------
292
+ X : np.ndarray
293
+ Design matrix.
294
+ y : np.ndarray
295
+ Outcome vector.
296
+
297
+ Returns
298
+ -------
299
+ tuple
300
+ (coefficients, residuals, fitted_values, r_squared)
301
+ """
302
+ # Solve normal equations: β = (X'X)^(-1) X'y
303
+ coefficients = np.linalg.lstsq(X, y, rcond=None)[0]
304
+
305
+ # Compute fitted values and residuals
306
+ fitted = X @ coefficients
307
+ residuals = y - fitted
308
+
309
+ # Compute R-squared
310
+ ss_res = np.sum(residuals ** 2)
311
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
312
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
313
+
314
+ return coefficients, residuals, fitted, r_squared
315
+
316
+ def _parse_formula(
317
+ self, formula: str, data: pd.DataFrame
318
+ ) -> tuple:
319
+ """
320
+ Parse R-style formula.
321
+
322
+ Supports basic formulas like:
323
+ - "outcome ~ treatment * time"
324
+ - "outcome ~ treatment + time + treatment:time"
325
+ - "outcome ~ treatment * time + covariate1 + covariate2"
326
+
327
+ Parameters
328
+ ----------
329
+ formula : str
330
+ R-style formula string.
331
+ data : pd.DataFrame
332
+ DataFrame to validate column names against.
333
+
334
+ Returns
335
+ -------
336
+ tuple
337
+ (outcome, treatment, time, covariates)
338
+ """
339
+ # Split into LHS and RHS
340
+ if "~" not in formula:
341
+ raise ValueError("Formula must contain '~' to separate outcome from predictors")
342
+
343
+ lhs, rhs = formula.split("~")
344
+ outcome = lhs.strip()
345
+
346
+ # Parse RHS
347
+ rhs = rhs.strip()
348
+
349
+ # Check for interaction term
350
+ if "*" in rhs:
351
+ # Handle "treatment * time" syntax
352
+ parts = rhs.split("*")
353
+ if len(parts) != 2:
354
+ raise ValueError("Currently only supports single interaction (treatment * time)")
355
+
356
+ treatment = parts[0].strip()
357
+ time = parts[1].strip()
358
+
359
+ # Check for additional covariates after interaction
360
+ if "+" in time:
361
+ time_parts = time.split("+")
362
+ time = time_parts[0].strip()
363
+ covariates = [p.strip() for p in time_parts[1:]]
364
+ else:
365
+ covariates = None
366
+
367
+ elif ":" in rhs:
368
+ # Handle explicit interaction syntax
369
+ terms = [t.strip() for t in rhs.split("+")]
370
+ interaction_term = None
371
+ main_effects = []
372
+ covariates = []
373
+
374
+ for term in terms:
375
+ if ":" in term:
376
+ interaction_term = term
377
+ else:
378
+ main_effects.append(term)
379
+
380
+ if interaction_term is None:
381
+ raise ValueError("Formula must contain an interaction term (treatment:time)")
382
+
383
+ treatment, time = [t.strip() for t in interaction_term.split(":")]
384
+
385
+ # Remaining terms after treatment and time are covariates
386
+ for term in main_effects:
387
+ if term != treatment and term != time:
388
+ covariates.append(term)
389
+
390
+ covariates = covariates if covariates else None
391
+ else:
392
+ raise ValueError(
393
+ "Formula must contain interaction term. "
394
+ "Use 'outcome ~ treatment * time' or 'outcome ~ treatment + time + treatment:time'"
395
+ )
396
+
397
+ # Validate columns exist
398
+ for col in [outcome, treatment, time]:
399
+ if col not in data.columns:
400
+ raise ValueError(f"Column '{col}' not found in data")
401
+
402
+ if covariates:
403
+ for cov in covariates:
404
+ if cov not in data.columns:
405
+ raise ValueError(f"Covariate '{cov}' not found in data")
406
+
407
+ return outcome, treatment, time, covariates
408
+
409
+ def _validate_data(
410
+ self,
411
+ data: pd.DataFrame,
412
+ outcome: str,
413
+ treatment: str,
414
+ time: str,
415
+ covariates: list = None
416
+ ) -> None:
417
+ """Validate input data."""
418
+ # Check DataFrame
419
+ if not isinstance(data, pd.DataFrame):
420
+ raise TypeError("data must be a pandas DataFrame")
421
+
422
+ # Check required columns exist
423
+ required_cols = [outcome, treatment, time]
424
+ if covariates:
425
+ required_cols.extend(covariates)
426
+
427
+ missing_cols = [col for col in required_cols if col not in data.columns]
428
+ if missing_cols:
429
+ raise ValueError(f"Missing columns in data: {missing_cols}")
430
+
431
+ # Check for missing values
432
+ for col in required_cols:
433
+ if data[col].isna().any():
434
+ raise ValueError(f"Column '{col}' contains missing values")
435
+
436
+ # Check for sufficient variation
437
+ if data[treatment].nunique() < 2:
438
+ raise ValueError("Treatment variable must have both 0 and 1 values")
439
+ if data[time].nunique() < 2:
440
+ raise ValueError("Time variable must have both 0 and 1 values")
441
+
442
+ def predict(self, data: pd.DataFrame) -> np.ndarray:
443
+ """
444
+ Predict outcomes using fitted model.
445
+
446
+ Parameters
447
+ ----------
448
+ data : pd.DataFrame
449
+ DataFrame with same structure as training data.
450
+
451
+ Returns
452
+ -------
453
+ np.ndarray
454
+ Predicted values.
455
+ """
456
+ if not self.is_fitted_:
457
+ raise RuntimeError("Model must be fitted before calling predict()")
458
+
459
+ # This is a placeholder - would need to store column names
460
+ # for full implementation
461
+ raise NotImplementedError(
462
+ "predict() is not yet implemented. "
463
+ "Use results_.fitted_values for training data predictions."
464
+ )
465
+
466
+ def get_params(self) -> dict:
467
+ """
468
+ Get estimator parameters (sklearn-compatible).
469
+
470
+ Returns
471
+ -------
472
+ dict
473
+ Estimator parameters.
474
+ """
475
+ return {
476
+ "robust": self.robust,
477
+ "cluster": self.cluster,
478
+ "alpha": self.alpha,
479
+ }
480
+
481
+ def set_params(self, **params) -> "DifferenceInDifferences":
482
+ """
483
+ Set estimator parameters (sklearn-compatible).
484
+
485
+ Parameters
486
+ ----------
487
+ **params
488
+ Estimator parameters.
489
+
490
+ Returns
491
+ -------
492
+ self
493
+ """
494
+ for key, value in params.items():
495
+ if hasattr(self, key):
496
+ setattr(self, key, value)
497
+ else:
498
+ raise ValueError(f"Unknown parameter: {key}")
499
+ return self
500
+
501
+ def summary(self) -> str:
502
+ """
503
+ Get summary of estimation results.
504
+
505
+ Returns
506
+ -------
507
+ str
508
+ Formatted summary.
509
+ """
510
+ if not self.is_fitted_:
511
+ raise RuntimeError("Model must be fitted before calling summary()")
512
+ return self.results_.summary()
513
+
514
+ def print_summary(self) -> None:
515
+ """Print summary to stdout."""
516
+ print(self.summary())
517
+
518
+
519
+ class TwoWayFixedEffects(DifferenceInDifferences):
520
+ """
521
+ Two-Way Fixed Effects (TWFE) estimator for panel DiD.
522
+
523
+ Extends DifferenceInDifferences to handle panel data with unit
524
+ and time fixed effects.
525
+
526
+ Parameters
527
+ ----------
528
+ robust : bool, default=True
529
+ Whether to use heteroskedasticity-robust standard errors.
530
+ cluster : str, optional
531
+ Column name for cluster-robust standard errors.
532
+ Defaults to clustering at the unit level.
533
+ alpha : float, default=0.05
534
+ Significance level for confidence intervals.
535
+
536
+ Notes
537
+ -----
538
+ This estimator uses the regression:
539
+
540
+ Y_it = α_i + γ_t + β*(D_i × Post_t) + X_it'δ + ε_it
541
+
542
+ where α_i are unit fixed effects and γ_t are time fixed effects.
543
+
544
+ Warning: TWFE can be biased with staggered treatment timing
545
+ and heterogeneous treatment effects. Consider using
546
+ more robust estimators (e.g., Callaway-Sant'Anna) for
547
+ staggered designs.
548
+ """
549
+
550
+ def fit(
551
+ self,
552
+ data: pd.DataFrame,
553
+ outcome: str,
554
+ treatment: str,
555
+ time: str,
556
+ unit: str,
557
+ covariates: list = None
558
+ ) -> DiDResults:
559
+ """
560
+ Fit Two-Way Fixed Effects model.
561
+
562
+ Parameters
563
+ ----------
564
+ data : pd.DataFrame
565
+ Panel data.
566
+ outcome : str
567
+ Name of outcome variable column.
568
+ treatment : str
569
+ Name of treatment indicator column.
570
+ time : str
571
+ Name of time period column.
572
+ unit : str
573
+ Name of unit identifier column.
574
+ covariates : list, optional
575
+ List of covariate column names.
576
+
577
+ Returns
578
+ -------
579
+ DiDResults
580
+ Estimation results.
581
+ """
582
+ # Validate unit column exists
583
+ if unit not in data.columns:
584
+ raise ValueError(f"Unit column '{unit}' not found in data")
585
+
586
+ # Set cluster to unit level if not specified
587
+ if self.cluster is None:
588
+ self.cluster = unit
589
+
590
+ # Demean data (within transformation for fixed effects)
591
+ data_demeaned = self._within_transform(data, outcome, unit, time, covariates)
592
+
593
+ # Create treatment × post interaction
594
+ # For staggered designs, we'd need to identify treatment timing per unit
595
+ # For now, assume standard 2-period design
596
+ data_demeaned["_treatment_post"] = (
597
+ data_demeaned[treatment] * data_demeaned[time]
598
+ )
599
+
600
+ # Extract variables for regression
601
+ y = data_demeaned[f"{outcome}_demeaned"].values
602
+ X_list = [data_demeaned["_treatment_post"].values]
603
+
604
+ if covariates:
605
+ for cov in covariates:
606
+ X_list.append(data_demeaned[f"{cov}_demeaned"].values)
607
+
608
+ X = np.column_stack([np.ones(len(y))] + X_list)
609
+
610
+ # Fit OLS on demeaned data
611
+ coefficients, residuals, fitted, r_squared = self._fit_ols(X, y)
612
+
613
+ # ATT is the coefficient on treatment_post (index 1)
614
+ att = coefficients[1]
615
+
616
+ # Compute cluster-robust standard errors
617
+ cluster_ids = data[self.cluster].values
618
+ vcov = compute_robust_se(X, residuals, cluster_ids)
619
+ se = np.sqrt(vcov[1, 1])
620
+
621
+ # Degrees of freedom adjustment for fixed effects
622
+ n_units = data[unit].nunique()
623
+ n_times = data[time].nunique()
624
+ df = len(y) - X.shape[1] - n_units - n_times + 2
625
+
626
+ t_stat = att / se
627
+ p_value = compute_p_value(t_stat, df=df)
628
+ conf_int = compute_confidence_interval(att, se, self.alpha, df=df)
629
+
630
+ # Count observations
631
+ treated_units = data[data[treatment] == 1][unit].unique()
632
+ n_treated = len(treated_units)
633
+ n_control = n_units - n_treated
634
+
635
+ self.results_ = DiDResults(
636
+ att=att,
637
+ se=se,
638
+ t_stat=t_stat,
639
+ p_value=p_value,
640
+ conf_int=conf_int,
641
+ n_obs=len(y),
642
+ n_treated=n_treated,
643
+ n_control=n_control,
644
+ alpha=self.alpha,
645
+ coefficients={"ATT": att},
646
+ vcov=vcov,
647
+ residuals=residuals,
648
+ fitted_values=fitted,
649
+ r_squared=r_squared,
650
+ )
651
+
652
+ self.is_fitted_ = True
653
+ return self.results_
654
+
655
+ def _within_transform(
656
+ self,
657
+ data: pd.DataFrame,
658
+ outcome: str,
659
+ unit: str,
660
+ time: str,
661
+ covariates: list = None
662
+ ) -> pd.DataFrame:
663
+ """
664
+ Apply within transformation to remove unit and time fixed effects.
665
+
666
+ This implements the standard two-way within transformation:
667
+ y_it - y_i. - y_.t + y_..
668
+
669
+ Parameters
670
+ ----------
671
+ data : pd.DataFrame
672
+ Panel data.
673
+ outcome : str
674
+ Outcome variable name.
675
+ unit : str
676
+ Unit identifier column.
677
+ time : str
678
+ Time period column.
679
+ covariates : list, optional
680
+ Covariate column names.
681
+
682
+ Returns
683
+ -------
684
+ pd.DataFrame
685
+ Data with demeaned variables.
686
+ """
687
+ data = data.copy()
688
+ variables = [outcome] + (covariates or [])
689
+
690
+ for var in variables:
691
+ # Unit means
692
+ unit_means = data.groupby(unit)[var].transform("mean")
693
+ # Time means
694
+ time_means = data.groupby(time)[var].transform("mean")
695
+ # Grand mean
696
+ grand_mean = data[var].mean()
697
+
698
+ # Within transformation
699
+ data[f"{var}_demeaned"] = data[var] - unit_means - time_means + grand_mean
700
+
701
+ return data