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 +12 -0
- diff_diff/estimators.py +701 -0
- diff_diff/results.py +170 -0
- diff_diff/utils.py +581 -0
- diff_diff-0.1.0.dist-info/METADATA +421 -0
- diff_diff-0.1.0.dist-info/RECORD +8 -0
- diff_diff-0.1.0.dist-info/WHEEL +5 -0
- diff_diff-0.1.0.dist-info/top_level.txt +1 -0
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"]
|
diff_diff/estimators.py
ADDED
|
@@ -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
|