diff-diff 2.2.0__cp310-cp310-win_amd64.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,397 @@
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
+
131
+ # Pass rank_deficient_action to LinearRegression
132
+ # If "error", let LinearRegression raise immediately
133
+ # If "warn" or "silent", suppress generic warning and use TWFE's context-specific
134
+ # error/warning messages (more informative for panel data)
135
+ if self.rank_deficient_action == "error":
136
+ reg = LinearRegression(
137
+ include_intercept=False,
138
+ robust=True,
139
+ cluster_ids=cluster_ids if self.inference != "wild_bootstrap" else None,
140
+ alpha=self.alpha,
141
+ rank_deficient_action="error",
142
+ ).fit(X, y, df_adjustment=df_adjustment)
143
+ else:
144
+ # Suppress generic warning, TWFE provides context-specific messages below
145
+ with warnings.catch_warnings():
146
+ warnings.filterwarnings("ignore", message="Rank-deficient design matrix")
147
+ reg = LinearRegression(
148
+ include_intercept=False,
149
+ robust=True,
150
+ cluster_ids=cluster_ids if self.inference != "wild_bootstrap" else None,
151
+ alpha=self.alpha,
152
+ rank_deficient_action="silent",
153
+ ).fit(X, y, df_adjustment=df_adjustment)
154
+
155
+ coefficients = reg.coefficients_
156
+ residuals = reg.residuals_
157
+ fitted = reg.fitted_values_
158
+ r_squared = reg.r_squared()
159
+ att = coefficients[att_idx]
160
+
161
+ # Check for unidentified coefficients (collinearity)
162
+ # Build column names for informative error messages
163
+ column_names = ["intercept", "treatment×post"]
164
+ if covariates:
165
+ column_names.extend(covariates)
166
+
167
+ nan_mask = np.isnan(coefficients)
168
+ if np.any(nan_mask):
169
+ dropped_indices = np.where(nan_mask)[0]
170
+ dropped_names = [column_names[i] if i < len(column_names)
171
+ else f"column {i}" for i in dropped_indices]
172
+
173
+ # Determine the source of collinearity for better error message
174
+ if att_idx in dropped_indices:
175
+ # Treatment coefficient is unidentified
176
+ raise ValueError(
177
+ f"Treatment effect cannot be identified due to collinearity. "
178
+ f"Dropped columns: {', '.join(dropped_names)}. "
179
+ "This can happen when: (1) treatment is perfectly collinear with "
180
+ "unit/time fixed effects, (2) all treated units are treated in all "
181
+ "periods, or (3) a covariate is collinear with the treatment indicator. "
182
+ "Check your data structure and model specification."
183
+ )
184
+ else:
185
+ # Only covariates are dropped - this is a warning, not an error
186
+ # The ATT can still be estimated
187
+ # Respect rank_deficient_action setting for warning
188
+ if self.rank_deficient_action == "warn":
189
+ warnings.warn(
190
+ f"Some covariates are collinear and were dropped: "
191
+ f"{', '.join(dropped_names)}. The treatment effect is still identified.",
192
+ UserWarning,
193
+ stacklevel=2,
194
+ )
195
+
196
+ # Get inference - either from bootstrap or analytical
197
+ if self.inference == "wild_bootstrap":
198
+ # Override with wild cluster bootstrap inference
199
+ se, p_value, conf_int, t_stat, vcov, _ = self._run_wild_bootstrap_inference(
200
+ X, y, residuals, cluster_ids, att_idx
201
+ )
202
+ else:
203
+ # Use analytical inference from LinearRegression
204
+ vcov = reg.vcov_
205
+ inference = reg.get_inference(att_idx)
206
+ se = inference.se
207
+ t_stat = inference.t_stat
208
+ p_value = inference.p_value
209
+ conf_int = inference.conf_int
210
+
211
+ # Count observations
212
+ treated_units = data[data[treatment] == 1][unit].unique()
213
+ n_treated = len(treated_units)
214
+ n_control = n_units - n_treated
215
+
216
+ # Determine inference method and bootstrap info
217
+ inference_method = "analytical"
218
+ n_bootstrap_used = None
219
+ n_clusters_used = None
220
+ if self._bootstrap_results is not None:
221
+ inference_method = "wild_bootstrap"
222
+ n_bootstrap_used = self._bootstrap_results.n_bootstrap
223
+ n_clusters_used = self._bootstrap_results.n_clusters
224
+
225
+ self.results_ = DiDResults(
226
+ att=att,
227
+ se=se,
228
+ t_stat=t_stat,
229
+ p_value=p_value,
230
+ conf_int=conf_int,
231
+ n_obs=len(y),
232
+ n_treated=n_treated,
233
+ n_control=n_control,
234
+ alpha=self.alpha,
235
+ coefficients={"ATT": float(att)},
236
+ vcov=vcov,
237
+ residuals=residuals,
238
+ fitted_values=fitted,
239
+ r_squared=r_squared,
240
+ inference_method=inference_method,
241
+ n_bootstrap=n_bootstrap_used,
242
+ n_clusters=n_clusters_used,
243
+ )
244
+
245
+ self.is_fitted_ = True
246
+ return self.results_
247
+
248
+ def _within_transform(
249
+ self,
250
+ data: pd.DataFrame,
251
+ outcome: str,
252
+ unit: str,
253
+ time: str,
254
+ covariates: Optional[List[str]] = None
255
+ ) -> pd.DataFrame:
256
+ """
257
+ Apply within transformation to remove unit and time fixed effects.
258
+
259
+ This implements the standard two-way within transformation:
260
+ y_it - y_i. - y_.t + y_..
261
+
262
+ Parameters
263
+ ----------
264
+ data : pd.DataFrame
265
+ Panel data.
266
+ outcome : str
267
+ Outcome variable name.
268
+ unit : str
269
+ Unit identifier column.
270
+ time : str
271
+ Time period column.
272
+ covariates : list, optional
273
+ Covariate column names.
274
+
275
+ Returns
276
+ -------
277
+ pd.DataFrame
278
+ Data with demeaned variables.
279
+ """
280
+ variables = [outcome] + (covariates or [])
281
+ return _within_transform_util(data, variables, unit, time, suffix="_demeaned")
282
+
283
+ def _check_staggered_treatment(
284
+ self,
285
+ data: pd.DataFrame,
286
+ treatment: str,
287
+ time: str,
288
+ unit: str,
289
+ ) -> None:
290
+ """
291
+ Check for staggered treatment timing and warn if detected.
292
+
293
+ Identifies if different units start treatment at different times,
294
+ which can bias TWFE estimates when treatment effects are heterogeneous.
295
+ """
296
+ # Find first treatment time for each unit
297
+ treated_obs = data[data[treatment] == 1]
298
+ if len(treated_obs) == 0:
299
+ return # No treated observations
300
+
301
+ # Get first treatment time per unit
302
+ first_treat_times = treated_obs.groupby(unit)[time].min()
303
+ unique_treat_times = first_treat_times.unique()
304
+
305
+ if len(unique_treat_times) > 1:
306
+ n_groups = len(unique_treat_times)
307
+ warnings.warn(
308
+ f"Staggered treatment timing detected: {n_groups} treatment cohorts "
309
+ f"start treatment at different times. TWFE can be biased when treatment "
310
+ f"effects are heterogeneous across time. Consider using:\n"
311
+ f" - CallawaySantAnna estimator for robust estimates\n"
312
+ f" - TwoWayFixedEffects.decompose() to diagnose the decomposition\n"
313
+ f" - bacon_decompose() to see weight on 'forbidden' comparisons",
314
+ UserWarning,
315
+ stacklevel=3,
316
+ )
317
+
318
+ def decompose(
319
+ self,
320
+ data: pd.DataFrame,
321
+ outcome: str,
322
+ unit: str,
323
+ time: str,
324
+ first_treat: str,
325
+ weights: str = "approximate",
326
+ ) -> "BaconDecompositionResults":
327
+ """
328
+ Perform Goodman-Bacon decomposition of TWFE estimate.
329
+
330
+ Decomposes the TWFE estimate into a weighted average of all possible
331
+ 2x2 DiD comparisons, revealing which comparisons drive the estimate
332
+ and whether problematic "forbidden comparisons" are involved.
333
+
334
+ Parameters
335
+ ----------
336
+ data : pd.DataFrame
337
+ Panel data with unit and time identifiers.
338
+ outcome : str
339
+ Name of outcome variable column.
340
+ unit : str
341
+ Name of unit identifier column.
342
+ time : str
343
+ Name of time period column.
344
+ first_treat : str
345
+ Name of column indicating when each unit was first treated.
346
+ Use 0 (or np.inf) for never-treated units.
347
+ weights : str, default="approximate"
348
+ Weight calculation method:
349
+ - "approximate": Fast simplified formula (default). Good for
350
+ diagnostic purposes where relative weights are sufficient.
351
+ - "exact": Variance-based weights from Goodman-Bacon (2021)
352
+ Theorem 1. Use for publication-quality decompositions.
353
+
354
+ Returns
355
+ -------
356
+ BaconDecompositionResults
357
+ Decomposition results showing:
358
+ - TWFE estimate and its weighted-average breakdown
359
+ - List of all 2x2 comparisons with estimates and weights
360
+ - Total weight by comparison type (clean vs forbidden)
361
+
362
+ Examples
363
+ --------
364
+ >>> twfe = TwoWayFixedEffects()
365
+ >>> decomp = twfe.decompose(
366
+ ... data, outcome='y', unit='id', time='t', first_treat='treat_year'
367
+ ... )
368
+ >>> decomp.print_summary()
369
+ >>> # Check weight on forbidden comparisons
370
+ >>> if decomp.total_weight_later_vs_earlier > 0.2:
371
+ ... print("Warning: significant forbidden comparison weight")
372
+
373
+ Notes
374
+ -----
375
+ This decomposition is essential for understanding potential TWFE bias
376
+ in staggered adoption designs. The three comparison types are:
377
+
378
+ 1. **Treated vs Never-treated**: Clean comparisons using never-treated
379
+ units as controls. These are always valid.
380
+
381
+ 2. **Earlier vs Later treated**: Uses later-treated units as controls
382
+ before they receive treatment. These are valid.
383
+
384
+ 3. **Later vs Earlier treated**: Uses already-treated units as controls.
385
+ These "forbidden comparisons" can introduce bias when treatment
386
+ effects are dynamic (changing over time since treatment).
387
+
388
+ See Also
389
+ --------
390
+ bacon_decompose : Standalone decomposition function
391
+ BaconDecomposition : Class-based decomposition interface
392
+ CallawaySantAnna : Robust estimator that avoids forbidden comparisons
393
+ """
394
+ from diff_diff.bacon import BaconDecomposition
395
+
396
+ decomp = BaconDecomposition(weights=weights)
397
+ return decomp.fit(data, outcome, unit, time, first_treat)