diff-diff 2.1.0__cp39-cp39-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/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)