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.
- diff_diff/__init__.py +226 -0
- diff_diff/_backend.py +64 -0
- diff_diff/_rust_backend.cpython-312-darwin.so +0 -0
- diff_diff/bacon.py +979 -0
- diff_diff/datasets.py +708 -0
- diff_diff/diagnostics.py +927 -0
- diff_diff/estimators.py +1000 -0
- diff_diff/honest_did.py +1493 -0
- diff_diff/linalg.py +980 -0
- diff_diff/power.py +1350 -0
- diff_diff/prep.py +1338 -0
- diff_diff/pretrends.py +1067 -0
- diff_diff/results.py +703 -0
- diff_diff/staggered.py +2297 -0
- diff_diff/sun_abraham.py +1176 -0
- diff_diff/synthetic_did.py +738 -0
- diff_diff/triple_diff.py +1291 -0
- diff_diff/twfe.py +344 -0
- diff_diff/utils.py +1481 -0
- diff_diff/visualization.py +1627 -0
- diff_diff-2.0.4.dist-info/METADATA +2257 -0
- diff_diff-2.0.4.dist-info/RECORD +23 -0
- diff_diff-2.0.4.dist-info/WHEEL +4 -0
diff_diff/twfe.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Two-Way Fixed Effects estimator for panel Difference-in-Differences.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from diff_diff.bacon import BaconDecompositionResults
|
|
13
|
+
|
|
14
|
+
from diff_diff.estimators import DifferenceInDifferences
|
|
15
|
+
from diff_diff.linalg import LinearRegression
|
|
16
|
+
from diff_diff.results import DiDResults
|
|
17
|
+
from diff_diff.utils import (
|
|
18
|
+
compute_confidence_interval,
|
|
19
|
+
compute_p_value,
|
|
20
|
+
within_transform as _within_transform_util,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TwoWayFixedEffects(DifferenceInDifferences):
|
|
25
|
+
"""
|
|
26
|
+
Two-Way Fixed Effects (TWFE) estimator for panel DiD.
|
|
27
|
+
|
|
28
|
+
Extends DifferenceInDifferences to handle panel data with unit
|
|
29
|
+
and time fixed effects.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
robust : bool, default=True
|
|
34
|
+
Whether to use heteroskedasticity-robust standard errors.
|
|
35
|
+
cluster : str, optional
|
|
36
|
+
Column name for cluster-robust standard errors.
|
|
37
|
+
If None, automatically clusters at the unit level (the `unit`
|
|
38
|
+
parameter passed to `fit()`). This differs from
|
|
39
|
+
DifferenceInDifferences where cluster=None means no clustering.
|
|
40
|
+
alpha : float, default=0.05
|
|
41
|
+
Significance level for confidence intervals.
|
|
42
|
+
|
|
43
|
+
Notes
|
|
44
|
+
-----
|
|
45
|
+
This estimator uses the regression:
|
|
46
|
+
|
|
47
|
+
Y_it = α_i + γ_t + β*(D_i × Post_t) + X_it'δ + ε_it
|
|
48
|
+
|
|
49
|
+
where α_i are unit fixed effects and γ_t are time fixed effects.
|
|
50
|
+
|
|
51
|
+
Warning: TWFE can be biased with staggered treatment timing
|
|
52
|
+
and heterogeneous treatment effects. Consider using
|
|
53
|
+
more robust estimators (e.g., Callaway-Sant'Anna) for
|
|
54
|
+
staggered designs.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def fit( # type: ignore[override]
|
|
58
|
+
self,
|
|
59
|
+
data: pd.DataFrame,
|
|
60
|
+
outcome: str,
|
|
61
|
+
treatment: str,
|
|
62
|
+
time: str,
|
|
63
|
+
unit: str,
|
|
64
|
+
covariates: Optional[List[str]] = None
|
|
65
|
+
) -> DiDResults:
|
|
66
|
+
"""
|
|
67
|
+
Fit Two-Way Fixed Effects model.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
data : pd.DataFrame
|
|
72
|
+
Panel data.
|
|
73
|
+
outcome : str
|
|
74
|
+
Name of outcome variable column.
|
|
75
|
+
treatment : str
|
|
76
|
+
Name of treatment indicator column.
|
|
77
|
+
time : str
|
|
78
|
+
Name of time period column.
|
|
79
|
+
unit : str
|
|
80
|
+
Name of unit identifier column.
|
|
81
|
+
covariates : list, optional
|
|
82
|
+
List of covariate column names.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
DiDResults
|
|
87
|
+
Estimation results.
|
|
88
|
+
"""
|
|
89
|
+
# Validate unit column exists
|
|
90
|
+
if unit not in data.columns:
|
|
91
|
+
raise ValueError(f"Unit column '{unit}' not found in data")
|
|
92
|
+
|
|
93
|
+
# Check for staggered treatment timing and warn if detected
|
|
94
|
+
self._check_staggered_treatment(data, treatment, time, unit)
|
|
95
|
+
|
|
96
|
+
# Use unit-level clustering if not specified (use local variable to avoid mutation)
|
|
97
|
+
cluster_var = self.cluster if self.cluster is not None else unit
|
|
98
|
+
|
|
99
|
+
# Demean data (within transformation for fixed effects)
|
|
100
|
+
data_demeaned = self._within_transform(data, outcome, unit, time, covariates)
|
|
101
|
+
|
|
102
|
+
# Create treatment × post interaction
|
|
103
|
+
# For staggered designs, we'd need to identify treatment timing per unit
|
|
104
|
+
# For now, assume standard 2-period design
|
|
105
|
+
data_demeaned["_treatment_post"] = (
|
|
106
|
+
data_demeaned[treatment] * data_demeaned[time]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Extract variables for regression
|
|
110
|
+
y = data_demeaned[f"{outcome}_demeaned"].values
|
|
111
|
+
X_list = [data_demeaned["_treatment_post"].values]
|
|
112
|
+
|
|
113
|
+
if covariates:
|
|
114
|
+
for cov in covariates:
|
|
115
|
+
X_list.append(data_demeaned[f"{cov}_demeaned"].values)
|
|
116
|
+
|
|
117
|
+
X = np.column_stack([np.ones(len(y))] + X_list)
|
|
118
|
+
|
|
119
|
+
# ATT is the coefficient on treatment_post (index 1)
|
|
120
|
+
att_idx = 1
|
|
121
|
+
|
|
122
|
+
# Degrees of freedom adjustment for fixed effects
|
|
123
|
+
n_units = data[unit].nunique()
|
|
124
|
+
n_times = data[time].nunique()
|
|
125
|
+
df_adjustment = n_units + n_times - 2
|
|
126
|
+
|
|
127
|
+
# Always use LinearRegression for initial fit (unified code path)
|
|
128
|
+
# For wild bootstrap, we don't need cluster SEs from the initial fit
|
|
129
|
+
cluster_ids = data[cluster_var].values
|
|
130
|
+
reg = LinearRegression(
|
|
131
|
+
include_intercept=False, # Intercept already in X
|
|
132
|
+
robust=True, # TWFE always uses robust/cluster SEs
|
|
133
|
+
cluster_ids=cluster_ids if self.inference != "wild_bootstrap" else None,
|
|
134
|
+
alpha=self.alpha,
|
|
135
|
+
).fit(X, y, df_adjustment=df_adjustment)
|
|
136
|
+
|
|
137
|
+
coefficients = reg.coefficients_
|
|
138
|
+
residuals = reg.residuals_
|
|
139
|
+
fitted = reg.fitted_values_
|
|
140
|
+
r_squared = reg.r_squared()
|
|
141
|
+
att = coefficients[att_idx]
|
|
142
|
+
|
|
143
|
+
# Get inference - either from bootstrap or analytical
|
|
144
|
+
if self.inference == "wild_bootstrap":
|
|
145
|
+
# Override with wild cluster bootstrap inference
|
|
146
|
+
se, p_value, conf_int, t_stat, vcov, _ = self._run_wild_bootstrap_inference(
|
|
147
|
+
X, y, residuals, cluster_ids, att_idx
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Use analytical inference from LinearRegression
|
|
151
|
+
vcov = reg.vcov_
|
|
152
|
+
inference = reg.get_inference(att_idx)
|
|
153
|
+
se = inference.se
|
|
154
|
+
t_stat = inference.t_stat
|
|
155
|
+
p_value = inference.p_value
|
|
156
|
+
conf_int = inference.conf_int
|
|
157
|
+
|
|
158
|
+
# Count observations
|
|
159
|
+
treated_units = data[data[treatment] == 1][unit].unique()
|
|
160
|
+
n_treated = len(treated_units)
|
|
161
|
+
n_control = n_units - n_treated
|
|
162
|
+
|
|
163
|
+
# Determine inference method and bootstrap info
|
|
164
|
+
inference_method = "analytical"
|
|
165
|
+
n_bootstrap_used = None
|
|
166
|
+
n_clusters_used = None
|
|
167
|
+
if self._bootstrap_results is not None:
|
|
168
|
+
inference_method = "wild_bootstrap"
|
|
169
|
+
n_bootstrap_used = self._bootstrap_results.n_bootstrap
|
|
170
|
+
n_clusters_used = self._bootstrap_results.n_clusters
|
|
171
|
+
|
|
172
|
+
self.results_ = DiDResults(
|
|
173
|
+
att=att,
|
|
174
|
+
se=se,
|
|
175
|
+
t_stat=t_stat,
|
|
176
|
+
p_value=p_value,
|
|
177
|
+
conf_int=conf_int,
|
|
178
|
+
n_obs=len(y),
|
|
179
|
+
n_treated=n_treated,
|
|
180
|
+
n_control=n_control,
|
|
181
|
+
alpha=self.alpha,
|
|
182
|
+
coefficients={"ATT": float(att)},
|
|
183
|
+
vcov=vcov,
|
|
184
|
+
residuals=residuals,
|
|
185
|
+
fitted_values=fitted,
|
|
186
|
+
r_squared=r_squared,
|
|
187
|
+
inference_method=inference_method,
|
|
188
|
+
n_bootstrap=n_bootstrap_used,
|
|
189
|
+
n_clusters=n_clusters_used,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self.is_fitted_ = True
|
|
193
|
+
return self.results_
|
|
194
|
+
|
|
195
|
+
def _within_transform(
|
|
196
|
+
self,
|
|
197
|
+
data: pd.DataFrame,
|
|
198
|
+
outcome: str,
|
|
199
|
+
unit: str,
|
|
200
|
+
time: str,
|
|
201
|
+
covariates: Optional[List[str]] = None
|
|
202
|
+
) -> pd.DataFrame:
|
|
203
|
+
"""
|
|
204
|
+
Apply within transformation to remove unit and time fixed effects.
|
|
205
|
+
|
|
206
|
+
This implements the standard two-way within transformation:
|
|
207
|
+
y_it - y_i. - y_.t + y_..
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
data : pd.DataFrame
|
|
212
|
+
Panel data.
|
|
213
|
+
outcome : str
|
|
214
|
+
Outcome variable name.
|
|
215
|
+
unit : str
|
|
216
|
+
Unit identifier column.
|
|
217
|
+
time : str
|
|
218
|
+
Time period column.
|
|
219
|
+
covariates : list, optional
|
|
220
|
+
Covariate column names.
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
pd.DataFrame
|
|
225
|
+
Data with demeaned variables.
|
|
226
|
+
"""
|
|
227
|
+
variables = [outcome] + (covariates or [])
|
|
228
|
+
return _within_transform_util(data, variables, unit, time, suffix="_demeaned")
|
|
229
|
+
|
|
230
|
+
def _check_staggered_treatment(
|
|
231
|
+
self,
|
|
232
|
+
data: pd.DataFrame,
|
|
233
|
+
treatment: str,
|
|
234
|
+
time: str,
|
|
235
|
+
unit: str,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Check for staggered treatment timing and warn if detected.
|
|
239
|
+
|
|
240
|
+
Identifies if different units start treatment at different times,
|
|
241
|
+
which can bias TWFE estimates when treatment effects are heterogeneous.
|
|
242
|
+
"""
|
|
243
|
+
# Find first treatment time for each unit
|
|
244
|
+
treated_obs = data[data[treatment] == 1]
|
|
245
|
+
if len(treated_obs) == 0:
|
|
246
|
+
return # No treated observations
|
|
247
|
+
|
|
248
|
+
# Get first treatment time per unit
|
|
249
|
+
first_treat_times = treated_obs.groupby(unit)[time].min()
|
|
250
|
+
unique_treat_times = first_treat_times.unique()
|
|
251
|
+
|
|
252
|
+
if len(unique_treat_times) > 1:
|
|
253
|
+
n_groups = len(unique_treat_times)
|
|
254
|
+
warnings.warn(
|
|
255
|
+
f"Staggered treatment timing detected: {n_groups} treatment cohorts "
|
|
256
|
+
f"start treatment at different times. TWFE can be biased when treatment "
|
|
257
|
+
f"effects are heterogeneous across time. Consider using:\n"
|
|
258
|
+
f" - CallawaySantAnna estimator for robust estimates\n"
|
|
259
|
+
f" - TwoWayFixedEffects.decompose() to diagnose the decomposition\n"
|
|
260
|
+
f" - bacon_decompose() to see weight on 'forbidden' comparisons",
|
|
261
|
+
UserWarning,
|
|
262
|
+
stacklevel=3,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def decompose(
|
|
266
|
+
self,
|
|
267
|
+
data: pd.DataFrame,
|
|
268
|
+
outcome: str,
|
|
269
|
+
unit: str,
|
|
270
|
+
time: str,
|
|
271
|
+
first_treat: str,
|
|
272
|
+
weights: str = "approximate",
|
|
273
|
+
) -> "BaconDecompositionResults":
|
|
274
|
+
"""
|
|
275
|
+
Perform Goodman-Bacon decomposition of TWFE estimate.
|
|
276
|
+
|
|
277
|
+
Decomposes the TWFE estimate into a weighted average of all possible
|
|
278
|
+
2x2 DiD comparisons, revealing which comparisons drive the estimate
|
|
279
|
+
and whether problematic "forbidden comparisons" are involved.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
data : pd.DataFrame
|
|
284
|
+
Panel data with unit and time identifiers.
|
|
285
|
+
outcome : str
|
|
286
|
+
Name of outcome variable column.
|
|
287
|
+
unit : str
|
|
288
|
+
Name of unit identifier column.
|
|
289
|
+
time : str
|
|
290
|
+
Name of time period column.
|
|
291
|
+
first_treat : str
|
|
292
|
+
Name of column indicating when each unit was first treated.
|
|
293
|
+
Use 0 (or np.inf) for never-treated units.
|
|
294
|
+
weights : str, default="approximate"
|
|
295
|
+
Weight calculation method:
|
|
296
|
+
- "approximate": Fast simplified formula (default). Good for
|
|
297
|
+
diagnostic purposes where relative weights are sufficient.
|
|
298
|
+
- "exact": Variance-based weights from Goodman-Bacon (2021)
|
|
299
|
+
Theorem 1. Use for publication-quality decompositions.
|
|
300
|
+
|
|
301
|
+
Returns
|
|
302
|
+
-------
|
|
303
|
+
BaconDecompositionResults
|
|
304
|
+
Decomposition results showing:
|
|
305
|
+
- TWFE estimate and its weighted-average breakdown
|
|
306
|
+
- List of all 2x2 comparisons with estimates and weights
|
|
307
|
+
- Total weight by comparison type (clean vs forbidden)
|
|
308
|
+
|
|
309
|
+
Examples
|
|
310
|
+
--------
|
|
311
|
+
>>> twfe = TwoWayFixedEffects()
|
|
312
|
+
>>> decomp = twfe.decompose(
|
|
313
|
+
... data, outcome='y', unit='id', time='t', first_treat='treat_year'
|
|
314
|
+
... )
|
|
315
|
+
>>> decomp.print_summary()
|
|
316
|
+
>>> # Check weight on forbidden comparisons
|
|
317
|
+
>>> if decomp.total_weight_later_vs_earlier > 0.2:
|
|
318
|
+
... print("Warning: significant forbidden comparison weight")
|
|
319
|
+
|
|
320
|
+
Notes
|
|
321
|
+
-----
|
|
322
|
+
This decomposition is essential for understanding potential TWFE bias
|
|
323
|
+
in staggered adoption designs. The three comparison types are:
|
|
324
|
+
|
|
325
|
+
1. **Treated vs Never-treated**: Clean comparisons using never-treated
|
|
326
|
+
units as controls. These are always valid.
|
|
327
|
+
|
|
328
|
+
2. **Earlier vs Later treated**: Uses later-treated units as controls
|
|
329
|
+
before they receive treatment. These are valid.
|
|
330
|
+
|
|
331
|
+
3. **Later vs Earlier treated**: Uses already-treated units as controls.
|
|
332
|
+
These "forbidden comparisons" can introduce bias when treatment
|
|
333
|
+
effects are dynamic (changing over time since treatment).
|
|
334
|
+
|
|
335
|
+
See Also
|
|
336
|
+
--------
|
|
337
|
+
bacon_decompose : Standalone decomposition function
|
|
338
|
+
BaconDecomposition : Class-based decomposition interface
|
|
339
|
+
CallawaySantAnna : Robust estimator that avoids forbidden comparisons
|
|
340
|
+
"""
|
|
341
|
+
from diff_diff.bacon import BaconDecomposition
|
|
342
|
+
|
|
343
|
+
decomp = BaconDecomposition(weights=weights)
|
|
344
|
+
return decomp.fit(data, outcome, unit, time, first_treat)
|