diff-diff 3.0.1__cp314-cp314-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.
Files changed (62) hide show
  1. diff_diff/__init__.py +382 -0
  2. diff_diff/_backend.py +134 -0
  3. diff_diff/_rust_backend.cp314-win_amd64.pyd +0 -0
  4. diff_diff/bacon.py +1140 -0
  5. diff_diff/bootstrap_utils.py +730 -0
  6. diff_diff/continuous_did.py +1626 -0
  7. diff_diff/continuous_did_bspline.py +190 -0
  8. diff_diff/continuous_did_results.py +374 -0
  9. diff_diff/datasets.py +815 -0
  10. diff_diff/diagnostics.py +882 -0
  11. diff_diff/efficient_did.py +1770 -0
  12. diff_diff/efficient_did_bootstrap.py +359 -0
  13. diff_diff/efficient_did_covariates.py +899 -0
  14. diff_diff/efficient_did_results.py +368 -0
  15. diff_diff/efficient_did_weights.py +617 -0
  16. diff_diff/estimators.py +1501 -0
  17. diff_diff/honest_did.py +2585 -0
  18. diff_diff/imputation.py +2458 -0
  19. diff_diff/imputation_bootstrap.py +418 -0
  20. diff_diff/imputation_results.py +448 -0
  21. diff_diff/linalg.py +2538 -0
  22. diff_diff/power.py +2588 -0
  23. diff_diff/practitioner.py +869 -0
  24. diff_diff/prep.py +1738 -0
  25. diff_diff/prep_dgp.py +1718 -0
  26. diff_diff/pretrends.py +1105 -0
  27. diff_diff/results.py +918 -0
  28. diff_diff/stacked_did.py +1049 -0
  29. diff_diff/stacked_did_results.py +339 -0
  30. diff_diff/staggered.py +3895 -0
  31. diff_diff/staggered_aggregation.py +864 -0
  32. diff_diff/staggered_bootstrap.py +752 -0
  33. diff_diff/staggered_results.py +416 -0
  34. diff_diff/staggered_triple_diff.py +1545 -0
  35. diff_diff/staggered_triple_diff_results.py +416 -0
  36. diff_diff/sun_abraham.py +1685 -0
  37. diff_diff/survey.py +1981 -0
  38. diff_diff/synthetic_did.py +1136 -0
  39. diff_diff/triple_diff.py +2047 -0
  40. diff_diff/trop.py +952 -0
  41. diff_diff/trop_global.py +1270 -0
  42. diff_diff/trop_local.py +1307 -0
  43. diff_diff/trop_results.py +356 -0
  44. diff_diff/twfe.py +542 -0
  45. diff_diff/two_stage.py +1952 -0
  46. diff_diff/two_stage_bootstrap.py +520 -0
  47. diff_diff/two_stage_results.py +400 -0
  48. diff_diff/utils.py +1902 -0
  49. diff_diff/visualization/__init__.py +61 -0
  50. diff_diff/visualization/_common.py +328 -0
  51. diff_diff/visualization/_continuous.py +274 -0
  52. diff_diff/visualization/_diagnostic.py +817 -0
  53. diff_diff/visualization/_event_study.py +1086 -0
  54. diff_diff/visualization/_power.py +661 -0
  55. diff_diff/visualization/_staggered.py +833 -0
  56. diff_diff/visualization/_synthetic.py +197 -0
  57. diff_diff/wooldridge.py +1285 -0
  58. diff_diff/wooldridge_results.py +349 -0
  59. diff_diff-3.0.1.dist-info/METADATA +2997 -0
  60. diff_diff-3.0.1.dist-info/RECORD +62 -0
  61. diff_diff-3.0.1.dist-info/WHEEL +4 -0
  62. diff_diff-3.0.1.dist-info/sboms/diff_diff_rust.cyclonedx.json +5843 -0
diff_diff/twfe.py ADDED
@@ -0,0 +1,542 @@
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
+ within_transform as _within_transform_util,
19
+ )
20
+
21
+
22
+ class TwoWayFixedEffects(DifferenceInDifferences):
23
+ """
24
+ Two-Way Fixed Effects (TWFE) estimator for panel DiD.
25
+
26
+ Extends DifferenceInDifferences to handle panel data with unit
27
+ and time fixed effects.
28
+
29
+ Parameters
30
+ ----------
31
+ robust : bool, default=True
32
+ Whether to use heteroskedasticity-robust standard errors.
33
+ cluster : str, optional
34
+ Column name for cluster-robust standard errors.
35
+ If None, automatically clusters at the unit level (the `unit`
36
+ parameter passed to `fit()`). This differs from
37
+ DifferenceInDifferences where cluster=None means no clustering.
38
+ alpha : float, default=0.05
39
+ Significance level for confidence intervals.
40
+
41
+ Notes
42
+ -----
43
+ This estimator uses the regression:
44
+
45
+ Y_it = α_i + γ_t + β*(D_i × Post_t) + X_it'δ + ε_it
46
+
47
+ where α_i are unit fixed effects and γ_t are time fixed effects.
48
+
49
+ Warning: TWFE can be biased with staggered treatment timing
50
+ and heterogeneous treatment effects. Consider using
51
+ more robust estimators (e.g., Callaway-Sant'Anna) for
52
+ staggered designs.
53
+ """
54
+
55
+ def fit( # type: ignore[override]
56
+ self,
57
+ data: pd.DataFrame,
58
+ outcome: str,
59
+ treatment: str,
60
+ time: str,
61
+ unit: str,
62
+ covariates: Optional[List[str]] = None,
63
+ survey_design: object = None,
64
+ ) -> DiDResults:
65
+ """
66
+ Fit Two-Way Fixed Effects model.
67
+
68
+ Parameters
69
+ ----------
70
+ data : pd.DataFrame
71
+ Panel data.
72
+ outcome : str
73
+ Name of outcome variable column.
74
+ treatment : str
75
+ Name of treatment indicator column.
76
+ time : str
77
+ Name of time period column.
78
+ unit : str
79
+ Name of unit identifier column.
80
+ covariates : list, optional
81
+ List of covariate column names.
82
+ survey_design : SurveyDesign, optional
83
+ Survey design specification for design-based inference. When provided,
84
+ uses Taylor Series Linearization for variance estimation and
85
+ applies sampling weights to the regression.
86
+
87
+ Returns
88
+ -------
89
+ DiDResults
90
+ Estimation results.
91
+ """
92
+ # Validate unit column exists
93
+ if unit not in data.columns:
94
+ raise ValueError(f"Unit column '{unit}' not found in data")
95
+
96
+ # Check for staggered treatment timing and warn if detected
97
+ self._check_staggered_treatment(data, treatment, time, unit)
98
+
99
+ # Warn if time has more than 2 unique values (not a binary post indicator)
100
+ n_unique_time = data[time].nunique()
101
+ if n_unique_time > 2:
102
+ warnings.warn(
103
+ f"The '{time}' column has {n_unique_time} unique values. "
104
+ f"TwoWayFixedEffects expects a binary (0/1) post indicator. "
105
+ f"Multi-period time values produce 'treated * period_number' instead of "
106
+ f"'treated * post_indicator', which may not estimate the standard DiD ATT. "
107
+ f"Consider creating a binary post column: "
108
+ f"df['post'] = (df['{time}'] >= cutoff).astype(int)",
109
+ UserWarning,
110
+ stacklevel=2,
111
+ )
112
+ elif n_unique_time == 2:
113
+ unique_vals = set(data[time].unique())
114
+ if unique_vals != {0, 1} and unique_vals != {False, True}:
115
+ warnings.warn(
116
+ f"The '{time}' column has values {sorted(unique_vals)} instead of {{0, 1}}. "
117
+ f"The ATT estimate is mathematically correct (within-transformation "
118
+ f"absorbs the scaling), but 0/1 encoding is recommended for clarity. "
119
+ f"Consider: df['{time}'] = (df['{time}'] == {max(unique_vals)}).astype(int)",
120
+ UserWarning,
121
+ stacklevel=2,
122
+ )
123
+
124
+ # Resolve survey design if provided
125
+ from diff_diff.survey import _resolve_effective_cluster, _resolve_survey_for_fit
126
+
127
+ resolved_survey, survey_weights, survey_weight_type, survey_metadata = (
128
+ _resolve_survey_for_fit(survey_design, data, self.inference)
129
+ )
130
+ _uses_replicate_twfe = (
131
+ resolved_survey is not None and resolved_survey.uses_replicate_variance
132
+ )
133
+ if _uses_replicate_twfe and self.inference == "wild_bootstrap":
134
+ raise ValueError(
135
+ "Cannot use inference='wild_bootstrap' with replicate-weight "
136
+ "survey designs. Replicate weights provide their own variance "
137
+ "estimation."
138
+ )
139
+
140
+ # Use unit-level clustering if not specified (use local variable to avoid mutation)
141
+ cluster_var = self.cluster if self.cluster is not None else unit
142
+
143
+ # Create treatment × post interaction from raw data before demeaning.
144
+ # This must be within-transformed alongside the outcome and covariates
145
+ # so that the regression uses demeaned regressors (FWL theorem).
146
+ data = data.copy()
147
+ data["_treatment_post"] = data[treatment] * data[time]
148
+
149
+ # Demean outcome, covariates, AND interaction in a single pass
150
+ all_vars = [outcome] + (covariates or []) + ["_treatment_post"]
151
+ data_demeaned = _within_transform_util(
152
+ data,
153
+ all_vars,
154
+ unit,
155
+ time,
156
+ suffix="_demeaned",
157
+ weights=survey_weights,
158
+ )
159
+
160
+ # Extract variables for regression
161
+ y = data_demeaned[f"{outcome}_demeaned"].values
162
+ X_list = [data_demeaned["_treatment_post_demeaned"].values]
163
+
164
+ if covariates:
165
+ for cov in covariates:
166
+ X_list.append(data_demeaned[f"{cov}_demeaned"].values)
167
+
168
+ X = np.column_stack([np.ones(len(y))] + X_list)
169
+
170
+ # ATT is the coefficient on treatment_post (index 1)
171
+ att_idx = 1
172
+
173
+ # Degrees of freedom adjustment for fixed effects
174
+ n_units = data[unit].nunique()
175
+ n_times = data[time].nunique()
176
+ df_adjustment = n_units + n_times - 2
177
+
178
+ # Always use LinearRegression for initial fit (unified code path)
179
+ # For wild bootstrap, we don't need cluster SEs from the initial fit
180
+ cluster_ids = data[cluster_var].values
181
+
182
+ # When survey PSU is present, it overrides cluster for variance estimation
183
+ effective_cluster_ids = _resolve_effective_cluster(
184
+ resolved_survey, cluster_ids, self.cluster
185
+ )
186
+
187
+ # For survey variance: only inject user-explicit cluster as PSU.
188
+ # TWFE's default unit clustering should not override the documented
189
+ # no-PSU survey path (implicit per-observation PSUs).
190
+ if resolved_survey is not None and self.cluster is None:
191
+ survey_cluster_ids = None
192
+ else:
193
+ survey_cluster_ids = effective_cluster_ids
194
+
195
+ # Inject cluster as effective PSU for survey variance estimation
196
+ if resolved_survey is not None and survey_cluster_ids is not None:
197
+ from diff_diff.survey import _inject_cluster_as_psu, compute_survey_metadata
198
+
199
+ resolved_survey = _inject_cluster_as_psu(resolved_survey, survey_cluster_ids)
200
+ if resolved_survey.psu is not None and survey_metadata is not None:
201
+ raw_w = (
202
+ data[survey_design.weights].values.astype(np.float64)
203
+ if survey_design.weights
204
+ else np.ones(len(data), dtype=np.float64)
205
+ )
206
+ survey_metadata = compute_survey_metadata(resolved_survey, raw_w)
207
+
208
+ # Pass rank_deficient_action to LinearRegression
209
+ # If "error", let LinearRegression raise immediately
210
+ # If "warn" or "silent", suppress generic warning and use TWFE's context-specific
211
+ # error/warning messages (more informative for panel data)
212
+ # For replicate designs: pass survey_design=None to prevent LinearRegression
213
+ # from computing replicate vcov on already-demeaned data (demeaning depends
214
+ # on weights, so replicate refits must re-demean at the estimator level).
215
+ _lr_survey_twfe = None if _uses_replicate_twfe else resolved_survey
216
+ if self.rank_deficient_action == "error":
217
+ reg = LinearRegression(
218
+ include_intercept=False,
219
+ robust=True,
220
+ cluster_ids=survey_cluster_ids if self.inference != "wild_bootstrap" else None,
221
+ alpha=self.alpha,
222
+ rank_deficient_action="error",
223
+ weights=survey_weights,
224
+ weight_type=survey_weight_type,
225
+ survey_design=_lr_survey_twfe,
226
+ ).fit(X, y, df_adjustment=df_adjustment)
227
+ else:
228
+ # Suppress generic warning, TWFE provides context-specific messages below
229
+ with warnings.catch_warnings():
230
+ warnings.filterwarnings("ignore", message="Rank-deficient design matrix")
231
+ reg = LinearRegression(
232
+ include_intercept=False,
233
+ robust=True,
234
+ cluster_ids=(
235
+ survey_cluster_ids if self.inference != "wild_bootstrap" else None
236
+ ),
237
+ alpha=self.alpha,
238
+ rank_deficient_action="silent",
239
+ weights=survey_weights,
240
+ weight_type=survey_weight_type,
241
+ survey_design=_lr_survey_twfe,
242
+ ).fit(X, y, df_adjustment=df_adjustment)
243
+
244
+ coefficients = reg.coefficients_
245
+ residuals = reg.residuals_
246
+ fitted = reg.fitted_values_
247
+ r_squared = reg.r_squared()
248
+ assert coefficients is not None
249
+ att = coefficients[att_idx]
250
+
251
+ # Check for unidentified coefficients (collinearity)
252
+ # Build column names for informative error messages
253
+ column_names = ["intercept", "treatment×post"]
254
+ if covariates:
255
+ column_names.extend(covariates)
256
+
257
+ nan_mask = np.isnan(coefficients)
258
+ if np.any(nan_mask):
259
+ dropped_indices = np.where(nan_mask)[0]
260
+ dropped_names = [
261
+ column_names[i] if i < len(column_names) else f"column {i}" for i in dropped_indices
262
+ ]
263
+
264
+ # Determine the source of collinearity for better error message
265
+ if att_idx in dropped_indices:
266
+ # Treatment coefficient is unidentified
267
+ raise ValueError(
268
+ f"Treatment effect cannot be identified due to collinearity. "
269
+ f"Dropped columns: {', '.join(dropped_names)}. "
270
+ "This can happen when: (1) treatment is perfectly collinear with "
271
+ "unit/time fixed effects, (2) all treated units are treated in all "
272
+ "periods, or (3) a covariate is collinear with the treatment indicator. "
273
+ "Check your data structure and model specification."
274
+ )
275
+ else:
276
+ # Only covariates are dropped - this is a warning, not an error
277
+ # The ATT can still be estimated
278
+ # Respect rank_deficient_action setting for warning
279
+ if self.rank_deficient_action == "warn":
280
+ warnings.warn(
281
+ f"Some covariates are collinear and were dropped: "
282
+ f"{', '.join(dropped_names)}. The treatment effect is still identified.",
283
+ UserWarning,
284
+ stacklevel=2,
285
+ )
286
+
287
+ # Get inference - replicate, bootstrap, or analytical
288
+ if _uses_replicate_twfe:
289
+ # Estimator-level replicate variance: re-do within-transform per replicate
290
+ from diff_diff.linalg import solve_ols
291
+ from diff_diff.survey import compute_replicate_refit_variance
292
+ from diff_diff.utils import safe_inference as _safe_inf
293
+
294
+ _all_vars_twfe = list(all_vars)
295
+ _covariates_twfe = list(covariates) if covariates else []
296
+ # Handle rank-deficient nuisance: refit only identified columns
297
+ _id_mask_twfe = ~np.isnan(coefficients)
298
+ _id_cols_twfe = np.where(_id_mask_twfe)[0]
299
+
300
+ def _refit_twfe(w_r):
301
+ # Drop zero-weight obs to prevent zero-sum demeaning
302
+ # (JK1/BRR half-samples zero entire clusters)
303
+ nz = w_r > 0
304
+ data_nz = data[nz].copy()
305
+ w_nz = w_r[nz]
306
+ data_dem_r = _within_transform_util(
307
+ data_nz, _all_vars_twfe, unit, time, suffix="_demeaned", weights=w_nz,
308
+ )
309
+ y_r = data_dem_r[f"{outcome}_demeaned"].values
310
+ X_list_r = [data_dem_r["_treatment_post_demeaned"].values]
311
+ for cov_ in _covariates_twfe:
312
+ X_list_r.append(data_dem_r[f"{cov_}_demeaned"].values)
313
+ X_r = np.column_stack([np.ones(len(y_r))] + X_list_r)
314
+ coef_r, _, _ = solve_ols(
315
+ X_r[:, _id_cols_twfe], y_r,
316
+ weights=w_nz, weight_type=survey_weight_type,
317
+ rank_deficient_action="silent", return_vcov=False,
318
+ )
319
+ return coef_r
320
+
321
+ from diff_diff.linalg import _expand_vcov_with_nan as _expand_twfe
322
+ vcov_reduced, _n_valid_rep_twfe = compute_replicate_refit_variance(
323
+ _refit_twfe, coefficients[_id_mask_twfe], resolved_survey
324
+ )
325
+ vcov = _expand_twfe(vcov_reduced, len(coefficients), _id_cols_twfe)
326
+ se = float(np.sqrt(max(vcov[att_idx, att_idx], 0.0)))
327
+ _df_rep = (
328
+ survey_metadata.df_survey
329
+ if survey_metadata and survey_metadata.df_survey
330
+ else 0 # rank-deficient replicate → NaN inference
331
+ )
332
+ if _n_valid_rep_twfe < resolved_survey.n_replicates:
333
+ _df_rep = _n_valid_rep_twfe - 1 if _n_valid_rep_twfe > 1 else 0
334
+ if survey_metadata is not None:
335
+ survey_metadata.df_survey = _df_rep if _df_rep > 0 else None
336
+ t_stat, p_value, conf_int = _safe_inf(att, se, alpha=self.alpha, df=_df_rep)
337
+ elif self.inference == "wild_bootstrap":
338
+ # Override with wild cluster bootstrap inference
339
+ se, p_value, conf_int, t_stat, vcov, _ = self._run_wild_bootstrap_inference(
340
+ X, y, residuals, cluster_ids, att_idx
341
+ )
342
+ else:
343
+ # Use analytical inference from LinearRegression
344
+ vcov = reg.vcov_
345
+ inference = reg.get_inference(att_idx)
346
+ se = inference.se
347
+ t_stat = inference.t_stat
348
+ p_value = inference.p_value
349
+ conf_int = inference.conf_int
350
+
351
+ # Count observations
352
+ treated_units = data[data[treatment] == 1][unit].unique()
353
+ n_treated = len(treated_units)
354
+ n_control = n_units - n_treated
355
+
356
+ # Determine inference method and bootstrap info
357
+ inference_method = "analytical"
358
+ n_bootstrap_used = None
359
+ n_clusters_used = None
360
+ if self._bootstrap_results is not None:
361
+ inference_method = "wild_bootstrap"
362
+ n_bootstrap_used = self._bootstrap_results.n_bootstrap
363
+ n_clusters_used = self._bootstrap_results.n_clusters
364
+
365
+ self.results_ = DiDResults(
366
+ att=att,
367
+ se=se,
368
+ t_stat=t_stat,
369
+ p_value=p_value,
370
+ conf_int=conf_int,
371
+ n_obs=len(y),
372
+ n_treated=n_treated,
373
+ n_control=n_control,
374
+ alpha=self.alpha,
375
+ coefficients={"ATT": float(att)},
376
+ vcov=vcov,
377
+ residuals=residuals,
378
+ fitted_values=fitted,
379
+ r_squared=r_squared,
380
+ inference_method=inference_method,
381
+ n_bootstrap=n_bootstrap_used,
382
+ n_clusters=n_clusters_used,
383
+ survey_metadata=survey_metadata,
384
+ )
385
+
386
+ self.is_fitted_ = True
387
+ return self.results_
388
+
389
+ def _within_transform(
390
+ self,
391
+ data: pd.DataFrame,
392
+ outcome: str,
393
+ unit: str,
394
+ time: str,
395
+ covariates: Optional[List[str]] = None,
396
+ ) -> pd.DataFrame:
397
+ """
398
+ Apply within transformation to remove unit and time fixed effects.
399
+
400
+ This implements the standard two-way within transformation:
401
+ y_it - y_i. - y_.t + y_..
402
+
403
+ Parameters
404
+ ----------
405
+ data : pd.DataFrame
406
+ Panel data.
407
+ outcome : str
408
+ Outcome variable name.
409
+ unit : str
410
+ Unit identifier column.
411
+ time : str
412
+ Time period column.
413
+ covariates : list, optional
414
+ Covariate column names.
415
+
416
+ Returns
417
+ -------
418
+ pd.DataFrame
419
+ Data with demeaned variables.
420
+ """
421
+ variables = [outcome] + (covariates or [])
422
+ return _within_transform_util(data, variables, unit, time, suffix="_demeaned")
423
+
424
+ def _check_staggered_treatment(
425
+ self,
426
+ data: pd.DataFrame,
427
+ treatment: str,
428
+ time: str,
429
+ unit: str,
430
+ ) -> None:
431
+ """
432
+ Check for staggered treatment timing and warn if detected.
433
+
434
+ Identifies if different units start treatment at different times,
435
+ which can bias TWFE estimates when treatment effects are heterogeneous.
436
+
437
+ Note: This check requires ``time`` to have actual period values (not
438
+ binary 0/1). With binary time, all treated units appear to start at
439
+ time=1, so staggering is undetectable.
440
+ """
441
+ # Find first treatment time for each unit
442
+ treated_obs = data[data[treatment] == 1]
443
+ if len(treated_obs) == 0:
444
+ return # No treated observations
445
+
446
+ # Get first treatment time per unit
447
+ first_treat_times = treated_obs.groupby(unit)[time].min()
448
+ unique_treat_times = first_treat_times.unique()
449
+
450
+ if len(unique_treat_times) > 1:
451
+ n_groups = len(unique_treat_times)
452
+ warnings.warn(
453
+ f"Staggered treatment timing detected: {n_groups} treatment cohorts "
454
+ f"start treatment at different times. TWFE can be biased when treatment "
455
+ f"effects are heterogeneous across time. Consider using:\n"
456
+ f" - CallawaySantAnna estimator for robust estimates\n"
457
+ f" - TwoWayFixedEffects.decompose() to diagnose the decomposition\n"
458
+ f" - bacon_decompose() to see weight on 'forbidden' comparisons",
459
+ UserWarning,
460
+ stacklevel=3,
461
+ )
462
+
463
+ def decompose(
464
+ self,
465
+ data: pd.DataFrame,
466
+ outcome: str,
467
+ unit: str,
468
+ time: str,
469
+ first_treat: str,
470
+ weights: str = "approximate",
471
+ ) -> "BaconDecompositionResults":
472
+ """
473
+ Perform Goodman-Bacon decomposition of TWFE estimate.
474
+
475
+ Decomposes the TWFE estimate into a weighted average of all possible
476
+ 2x2 DiD comparisons, revealing which comparisons drive the estimate
477
+ and whether problematic "forbidden comparisons" are involved.
478
+
479
+ Parameters
480
+ ----------
481
+ data : pd.DataFrame
482
+ Panel data with unit and time identifiers.
483
+ outcome : str
484
+ Name of outcome variable column.
485
+ unit : str
486
+ Name of unit identifier column.
487
+ time : str
488
+ Name of time period column.
489
+ first_treat : str
490
+ Name of column indicating when each unit was first treated.
491
+ Use 0 (or np.inf) for never-treated units.
492
+ weights : str, default="approximate"
493
+ Weight calculation method:
494
+ - "approximate": Fast simplified formula (default). Good for
495
+ diagnostic purposes where relative weights are sufficient.
496
+ - "exact": Variance-based weights from Goodman-Bacon (2021)
497
+ Theorem 1. Use for publication-quality decompositions.
498
+
499
+ Returns
500
+ -------
501
+ BaconDecompositionResults
502
+ Decomposition results showing:
503
+ - TWFE estimate and its weighted-average breakdown
504
+ - List of all 2x2 comparisons with estimates and weights
505
+ - Total weight by comparison type (clean vs forbidden)
506
+
507
+ Examples
508
+ --------
509
+ >>> twfe = TwoWayFixedEffects()
510
+ >>> decomp = twfe.decompose(
511
+ ... data, outcome='y', unit='id', time='t', first_treat='treat_year'
512
+ ... )
513
+ >>> decomp.print_summary()
514
+ >>> # Check weight on forbidden comparisons
515
+ >>> if decomp.total_weight_later_vs_earlier > 0.2:
516
+ ... print("Warning: significant forbidden comparison weight")
517
+
518
+ Notes
519
+ -----
520
+ This decomposition is essential for understanding potential TWFE bias
521
+ in staggered adoption designs. The three comparison types are:
522
+
523
+ 1. **Treated vs Never-treated**: Clean comparisons using never-treated
524
+ units as controls. These are always valid.
525
+
526
+ 2. **Earlier vs Later treated**: Uses later-treated units as controls
527
+ before they receive treatment. These are valid.
528
+
529
+ 3. **Later vs Earlier treated**: Uses already-treated units as controls.
530
+ These "forbidden comparisons" can introduce bias when treatment
531
+ effects are dynamic (changing over time since treatment).
532
+
533
+ See Also
534
+ --------
535
+ bacon_decompose : Standalone decomposition function
536
+ BaconDecomposition : Class-based decomposition interface
537
+ CallawaySantAnna : Robust estimator that avoids forbidden comparisons
538
+ """
539
+ from diff_diff.bacon import BaconDecomposition
540
+
541
+ decomp = BaconDecomposition(weights=weights)
542
+ return decomp.fit(data, outcome, unit, time, first_treat)