Wooldridge Extended Two-Way Fixed Effects (ETWFE)#
Extended Two-Way Fixed Effects estimator from Wooldridge (2025, 2023),
based on the Stata jwdid package specification (Friosavila 2021),
with documented SE/aggregation deviations noted in the Methodology Registry.
This module implements ETWFE via a single saturated regression that:
Estimates ATT(g,t) for each cohort×time treatment cell simultaneously
Supports linear (OLS), Poisson QMLE, and logit link functions
Uses ASF-based ATT for nonlinear models: E[f(η₁)] − E[f(η₀)]
Computes delta-method SEs for all aggregations (event, group, calendar, simple)
Supports paper W2025 cohort-share aggregation via
aggregate(weights="cohort_share")(Eqs. 7.4 + 7.6; default is cell-count matching Statajwdid_estat)Supports paper W2025 Section 8 heterogeneous cohort trends via
cohort_trends=True(OLS path only; auto-routes to full-dummy mode; requirescontrol_group="not_yet_treated"— the default — andsurvey_design=None; thenever_treatedand survey paths are fail-closed withNotImplementedErrorbecause the all-(g, t)-cells placebo basis collinearity / unvalidated survey-TSL composition would make the trend specification unidentified or unverified — see Methodology Registry for the full contract)Follows the Stata jwdid specification for OLS defaults and nonlinear paths (see Methodology Registry for documented SE/aggregation deviations)
When to use WooldridgeDiD:
Staggered adoption design with heterogeneous treatment timing
Nonlinear outcomes (binary, count, non-negative continuous)
You want a single-regression approach matching Stata’s
jwdidYou need event-study, group, calendar, or simple ATT aggregations
You need paper W2025 cohort-share aggregation weights as an alternative to the default cell-count weighting
You need heterogeneous cohort-specific linear trends when parallel trends is violated (paper W2025 Section 8)
References:
Wooldridge, J. M. (2025). Two-way fixed effects, the two-way Mundlak regression, and difference-in-differences estimators. Empirical Economics, 69(5), 2545-2587. DOI 10.1007/s00181-025-02807-z.
Wooldridge, J. M. (2023). Simple approaches to nonlinear difference-in-differences with panel data. The Econometrics Journal, 26(3), C31-C66.
Friosavila, F. (2021).
jwdid: Stata module for ETWFE. SSC s459114.
WooldridgeDiD#
Main estimator class for Wooldridge ETWFE.
- class diff_diff.WooldridgeDiD[source]
Bases:
objectExtended Two-Way Fixed Effects (ETWFE) DiD estimator.
Implements the Wooldridge (2025) saturated cohort×time regression (Empirical Economics 69(5), 2545-2587; DOI 10.1007/s00181-025-02807-z) and Wooldridge (2023) nonlinear extensions (logit, Poisson). Produces all four
jwdid_estataggregation types: simple, group, calendar, event. Opt-in surfaces include paper W2025 Section 7 cohort-share aggregation (aggregate(weights="cohort_share"), Eqs. 7.4 + 7.6) and paper W2025 Section 8 heterogeneous cohort-specific linear trends (cohort_trends=True, Eq. 8.1; OLS path only).- Parameters:
method ({"ols", "logit", "poisson"}) – Estimation method. “ols” for continuous outcomes; “logit” for binary or fractional outcomes; “poisson” for count data.
control_group ({"not_yet_treated", "never_treated"}) – Which units serve as the comparison group. “not_yet_treated” (jwdid default) uses all untreated observations at each time period; “never_treated” uses only units never treated throughout the sample.
anticipation (int) – Number of periods before treatment onset to include as treatment cells (anticipation effects). 0 means no anticipation.
demean_covariates (bool) – If True (jwdid default),
xtvarcovariates are demeaned within each cohort×period cell before entering the regression. Set to False to replicate jwdid’sxasisoption.alpha (float) – Significance level for confidence intervals.
cluster (str or None) – Column name to use for cluster-robust SEs. Defaults to the
unitidentifier passed tofit().n_bootstrap (int) – Number of bootstrap replications. 0 disables bootstrap.
bootstrap_weights ({"rademacher", "webb", "mammen"}) – Bootstrap weight distribution.
seed (int or None) – Random seed for reproducibility.
rank_deficient_action ({"warn", "error", "silent"}) – How to handle rank-deficient design matrices.
vcov_type ({"classical", "hc1", "hc2", "hc2_bm"}, default "hc1") –
Variance-covariance family for the analytical sandwich, OLS path only.
hc1(default) preserves the prior bit-equal CR1 Liang-Zeger cluster-robust behavior via the within-transform path.hc2_bmauto-routes to a full-dummy saturated design (intercept + treatment cells + unit dummies + time dummies) — FWL preserves cohort coefficients but NOT the hat matrix, so HC2 leverage and Bell-McCaffrey Satterthwaite DOF must be computed on the full FE projection (matchesclubSandwich::vcovCR(lm(...), type="CR2") + coef_test()$df_Satt).classical/hc2are supported via the same full-dummy route AND an auto-drop of the unit auto-cluster (one-way families don’t compose with cluster_ids per the linalg validator). Explicitcluster="X"+ one-wayvcov_typeraises at the validator.conleyis REJECTED at__init__(would require threadingconley_*params throughsolve_ols; tracked in TODO.md).methodin{"logit","poisson"}+vcov_type != "hc1"is REJECTED at__init__: the GLM QMLE sandwich path uses pseudo- residuals, and CR2-BM composition with QMLE on canonical-link pseudo- residuals needs derivation + R parity (tracked in TODO.md). Survey designs combined withvcov_type != "hc1"raiseNotImplementedErroratfit()because the survey TSL / replicate- refit variance overrides the analytical sandwich.cohort_trends (bool, default False) – When True, adds linear
dg_i · tcohort-specific trend interactions to the design matrix per paper W2025 Section 8 / Eq. 8.1. Under a heterogeneous-trends DGP this recoversτeven when parallel trends fails (paper Section 8.3). OLS-path only:cohort_trends=True+method ∈ {"logit","poisson"}raisesNotImplementedErrorat__init__. Auto-routes to the full-dummy design regardless ofvcov_type(matching the absorb→fixed_effects auto-route). Each treated cohort must have ≥ 2 observed pre-periods in the analysis sample fordg_i · tto be separately identified from cohort + time FE;fit()raisesValueErrorotherwise. On all-eventually-treated panels the last cohort’s trend column is dropped per paper Section 5.4.cohort_trends=True+survey_designraisesNotImplementedErroratfit()(deferred follow-up).cohort_trends=True+control_group="never_treated"also raisesNotImplementedErroratfit()because the OLS + never_treated branch emits ALL(g, t)placebo cell dummies (paper Section 4.4 placebo coverage); the appendeddg_i · ttrend columns are linearly spanned by the per-cohort sum of those cell dummies, so the Section 8 trend specification is unidentified on this branch. Usecontrol_group="not_yet_treated"(the default) for the cohort_trends surface.
Methods
fit(data, outcome, unit, time, cohort[, ...])Fit the ETWFE model.
get_params()Return estimator parameters (sklearn-compatible).
set_params(**params)Set estimator parameters (sklearn-compatible).
- __init__(method='ols', control_group='not_yet_treated', anticipation=0, demean_covariates=True, alpha=0.05, cluster=None, n_bootstrap=0, bootstrap_weights='rademacher', seed=None, rank_deficient_action='warn', vcov_type='hc1', cohort_trends=False)[source]
- property results_: WooldridgeDiDResults
- set_params(**params)[source]
Set estimator parameters (sklearn-compatible). Returns self.
Atomic: if validation rejects the incoming combination (unknown parameter, invalid value, or the
method×vcov_typeinteraction guard fires),selfis unchanged so a caller that catchesValueError/NotImplementedErrorcan keep using the estimator with its previous configuration. Mirrors theDifferenceInDifferences.set_paramspattern atestimators.py:995-1023.- Parameters:
params (Any)
- Return type:
- fit(data, outcome, unit, time, cohort, exovar=None, xtvar=None, xgvar=None, survey_design=None)[source]
Fit the ETWFE model. See class docstring for parameter details.
- Parameters:
data (DataFrame with panel data (long format))
outcome (outcome column name)
unit (unit identifier column)
time (time period column)
cohort (first treatment period (0 or NaN = never treated))
exovar (time-invariant covariates added without interaction/demeaning)
xtvar (time-varying covariates (demeaned within cohort×period cells) – when
demean_covariates=True)xgvar (covariates interacted with each cohort indicator)
survey_design (SurveyDesign, optional) – Survey design specification for complex survey data. Supports stratified, clustered, and weighted designs via Taylor Series Linearization (TSL). Replicate-weight designs raise
NotImplementedError.
- Return type:
WooldridgeDiDResults#
Results container returned by WooldridgeDiD.fit().
cohort_trend_coefs (populated under cohort_trends=True, OLS path
only): Dict[g → δ_g] keyed by treated cohort. The reported slopes
are relative to the baseline trend absorbed by the design — the
never-treated cohort’s trend (when a never-treated cohort exists) OR
the last cohort’s trend (when no never-treated cohort exists, per
paper W2025 Section 5.4’s all-eventually-treated drop rule). On
all-treated panels the last cohort is intentionally absent from the
dict; its slope is the baseline (zero in deviation form). See
docs/methodology/REGISTRY.md → ## WooldridgeDiD (ETWFE) →
“Heterogeneous cohort trends” for the full normalization contract.
- class diff_diff.wooldridge_results.WooldridgeDiDResults[source]
Bases:
objectResults from WooldridgeDiD.fit().
Core output is
group_time_effects: a dict keyed by (cohort_g, time_t) with per-cell ATT estimates and inference. Call.aggregate(type, weights=...)to compute any of the fourjwdid_estataggregation types under either the default cell-count weighting (weights="cell", matches Statajwdid_estat) or the paper W2025 opt-in cohort-share weighting (weights="cohort_share", Eqs. 7.4 / 7.6; restricted totype ∈ {"simple", "event"}).cohort_trend_coefscarries Section 8 / Eq. 8.1 estimatedδ_gslopes when the fit was produced underWooldridgeDiD(cohort_trends=True).aggregation_weightsis keyed by aggregation type and records the active weighting scheme that wrote to each cached surface (surfaced insummary()/to_dataframe()/__repr__).Methods
aggregate(type[, weights])Compute and store one of the four jwdid_estat aggregation types.
summary([aggregation])Print formatted summary table.
- group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]]
key=(g,t), value={att, se, t_stat, p_value, conf_int}
- overall_att: float
- overall_se: float
- overall_t_stat: float
- overall_p_value: float
- method: str = 'ols'
- control_group: str = 'not_yet_treated'
- n_obs: int = 0
- n_treated_units: int = 0
- n_control_units: int = 0
- alpha: float = 0.05
- anticipation: int = 0
- vcov_type: str = 'hc1'
- cohort_trends: bool = False
- aggregate(type, weights='cell')[source]
Compute and store one of the four jwdid_estat aggregation types.
- Parameters:
type ("simple" | "group" | "calendar" | "event")
weights ("cell" | "cohort_share", default "cell") – Aggregation weighting scheme.
"cell"(default) uses cell- countn_{g,t}observation counts and matches Statajwdid_estat."cohort_share"uses paper W2025 Eq. 7.4ω̂_g = N_g / Σ_{g'} N_{g'} M_{g'}fortype="simple"and Eq. 7.6ω̂_{ge} = N_g / Σ_{g': g'+e ≤ T} N_{g'}fortype="event". Both formulas reduce toN_g-proportional per-cell weights with the appropriate normalization. The two schemes coincide on balanced panels with uniform within-cohort cell counts (paper Section 7.5). The cohort-share scheme is supported only fortype="simple"andtype="event"; the paper provides no explicit cohort-share formula for"group"or"calendar"aggregations and the library raisesValueErrorto preserve a fail-closed contract.chaining. (Returns self for)
- Return type:
Notes
When
vcov_type == "hc2_bm", aggregated inference (t_stat / p_value / conf_int) uses Bell-McCaffrey Satterthwaite contrast-specific DOFs rather than the survey/None default. The BM DOFs are computed lazily from_bm_artifactsvia_compute_cr2_bm_contrast_dofand fail-closed (NaN inference) when the helper raises or returns NaN — perfeedback_bm_contrast_dof_fail_closed. The contrast column is rebuilt under the activeweightsscheme so the BM DOF reflects the actual weighting used by ATT + SE.
- summary(aggregation='simple')[source]
Print formatted summary table.
- Parameters:
aggregation (which aggregation to display ("simple", "group", "calendar", "event"))
- Return type:
- __init__(group_time_effects, overall_att, overall_se, overall_t_stat, overall_p_value, overall_conf_int, group_effects=None, calendar_effects=None, event_study_effects=None, method='ols', control_group='not_yet_treated', groups=<factory>, time_periods=<factory>, n_obs=0, n_treated_units=0, n_control_units=0, alpha=0.05, anticipation=0, survey_metadata=None, vcov_type='hc1', cluster_name=None, n_clusters=None, cohort_trend_coefs=<factory>, _bootstrap_used=False, cohort_trends=False, aggregation_weights=<factory>, _gt_weights=<factory>, _n_g_per_cohort=<factory>, _gt_vcov=None, _gt_keys=<factory>, _df_survey=None, _bm_per_cell_dof=<factory>, _bm_artifacts=None, _df_one_way=None)
- Parameters:
overall_att (float)
overall_se (float)
overall_t_stat (float)
overall_p_value (float)
method (str)
control_group (str)
n_obs (int)
n_treated_units (int)
n_control_units (int)
alpha (float)
anticipation (int)
survey_metadata (Any | None)
vcov_type (str)
cluster_name (str | None)
n_clusters (int | None)
_bootstrap_used (bool)
cohort_trends (bool)
_gt_vcov (ndarray | None)
_df_survey (int | None)
_bm_artifacts (Tuple[ndarray, ndarray, ndarray, Dict[Tuple[Any, Any], int]] | None)
_df_one_way (float | None)
- Return type:
None
- to_dataframe(aggregation='event')[source]
Export aggregated effects to a DataFrame.
- Parameters:
aggregation ("simple" | "group" | "calendar" | "event" | "gt") – Use “gt” to export raw group-time effects.
- Return type:
- plot_event_study(weights='cell', **kwargs)[source]
Event study plot. Always calls
aggregate('event', weights=weights).- Parameters:
weights ("cell" | "cohort_share", default "cell") – Aggregation weighting scheme threaded into the underlying
aggregate("event", ...)call."cohort_share"produces paper W2025 Eq. 7.6 cohort-share-by-exposure weights (post-treatmentk >= 0only); inference fields are fail-closed to NaN per the Section 7.5 conditional-on-shares contract documented in REGISTRY, and the plot suppresses error bars / CI bands to honor the fail-closed contract (the conditional-on-shares SE would build a misleading normal-theory CI in the plotter).**kwargs – Forwarded to
diff_diff.visualization.plot_event_study.
- Return type:
None
Notes
The wrapper unconditionally re-aggregates the event study under the requested
weightsscheme. This avoids the stale-cache hazard where a priorplot_event_study(weights="cohort_share")call would leave the cachedevent_study_effectsrestricted tok >= 0(per the Eq. 7.6 scope), and a subsequentplot_event_study()(defaultweights="cell") call would silently reuse the cohort-share-keyed cache instead of restoring the full event range including pre-period placebo leads.
- property att: float
- property se: float
- property p_value: float
- property t_stat: float
Example Usage#
Basic OLS (follows Stata jwdid y, ivar(unit) tvar(time) gvar(cohort)):
import pandas as pd
from diff_diff import WooldridgeDiD
df = pd.read_stata("mpdta.dta")
df['first_treat'] = df['first_treat'].astype(int)
m = WooldridgeDiD()
r = m.fit(df, outcome='lemp', unit='countyreal', time='year', cohort='first_treat')
r.aggregate('event').aggregate('group').aggregate('simple')
print(r.summary('event'))
print(r.summary('group'))
print(r.summary('simple'))
View cohort×time cell estimates (post-treatment):
for (g, t), v in sorted(r.group_time_effects.items()):
if t >= g:
print(f"g={g} t={t} ATT={v['att']:.4f} SE={v['se']:.4f}")
Poisson QMLE for non-negative outcomes
(follows Stata jwdid emp, method(poisson)):
import numpy as np
df['emp'] = np.exp(df['lemp'])
m_pois = WooldridgeDiD(method='poisson')
r_pois = m_pois.fit(df, outcome='emp', unit='countyreal',
time='year', cohort='first_treat')
r_pois.aggregate('event').aggregate('group').aggregate('simple')
print(r_pois.summary('simple'))
Logit for binary outcomes
(follows Stata jwdid y, method(logit)):
m_logit = WooldridgeDiD(method='logit')
r_logit = m_logit.fit(df, outcome='hi_emp', unit='countyreal',
time='year', cohort='first_treat')
r_logit.aggregate('group').aggregate('simple')
print(r_logit.summary('group'))
Aggregation Methods#
Call .aggregate(type, weights=...) before .summary(type):
Type |
Description |
Stata equivalent |
|---|---|---|
|
ATT by relative time k = t − g |
|
|
ATT averaged across post-treatment periods per cohort |
|
|
ATT averaged across cohorts per calendar period |
|
|
Overall weighted average ATT |
|
Weighting schemes (weights="cell" default, weights="cohort_share"
opt-in):
weights="cell"(default) — cell-countn_{g,t}weighting; matches Statajwdid_estat. Supported for all four aggregation types.weights="cohort_share"— paper W2025 Eq. 7.4 (simple) and Eq. 7.6 (event, restricted tok >= 0) cohort-share weighting. Supported only fortype="simple"andtype="event"; raises ontype ∈ {"group","calendar"}(no paper closed-form). Inference fields (t-stat / p-value / conf-int) are fail-closed toNaNwith aUserWarningdocumenting the conditional-on-shares limitation (paper W2025 Section 7.5). Raises onsurvey_design is not None(design-consistent cohort totals pending follow-up).
Comparison with Other Staggered Estimators#
Feature |
WooldridgeDiD (ETWFE) |
CallawaySantAnna |
ImputationDiD |
|---|---|---|---|
Approach |
Single saturated regression |
Separate 2×2 DiD per cell |
Impute Y(0) via FE model |
Nonlinear outcomes |
Yes (Poisson, Logit) |
No |
No |
Covariates |
Via regression (linear index) |
OR, IPW, DR |
Supported |
SE for aggregations |
Delta method |
Multiplier bootstrap |
Multiplier bootstrap |
Stata equivalent |
|
|
|