Interactive notebook
This tutorial is a Jupyter notebook. You can view it on GitHub or download it to run locally.
Wooldridge Extended Two-Way Fixed Effects (ETWFE)#
This tutorial demonstrates the WooldridgeDiD estimator (alias: ETWFE), which implements Wooldridge’s Extended Two-Way Fixed Effects approach — the linear design from Wooldridge (2025) and the nonlinear paths from Wooldridge (2023) — the basis of the Stata jwdid package.
What ETWFE does: Estimates cohort×time Average Treatment Effects (ATT(g,t)) via a single saturated regression that interacts treatment indicators with cohort×time cells. Unlike standard TWFE, it correctly handles heterogeneous treatment effects across cohorts and time periods. The key insight is to include all cohort×time interaction terms simultaneously, with unit and time fixed effects absorbed via within-transformation.
Key features:
Follows the Stata
jwdidspecification (OLS and nonlinear paths; see Methodology Registry for documented SE/aggregation deviations)Supports linear (OLS), Poisson, and logit link functions
Nonlinear ATTs use the Average Structural Function (ASF): E[f(η₁)] − E[f(η₀)]
Delta-method standard errors for all aggregations
Cluster-robust sandwich variance
Topics covered:
Basic OLS estimation
Cohort×time cell estimates ATT(g,t)
Aggregation: event-study, group, simple
Poisson QMLE for count / non-negative outcomes
Logit for binary outcomes
Comparison with Callaway-Sant’Anna
All-eventually-treated panels (no never-treated group)
Parameter reference and guidance
Prerequisites:Tutorial 02(Staggered DiD).
See also:Tutorial 15for Efficient DiD,Tutorial 11for Imputation DiD.
[1]:
import numpy as np
import pandas as pd
from diff_diff import WooldridgeDiD, CallawaySantAnna, generate_staggered_data
try:
import matplotlib.pyplot as plt
plt.style.use('seaborn-v0_8-whitegrid')
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
print("matplotlib not installed - visualization examples will be skipped")
Data Setup#
We use generate_staggered_data() to create a balanced panel with 3 treatment cohorts, a never-treated group, and a known ATT of 2.0. This makes it easy to verify estimation accuracy.
We also demonstrate with the mpdta dataset (Callaway & Sant’Anna 2021), which contains county-level log employment data with staggered minimum-wage adoption — the canonical benchmark for staggered DiD methods.
[2]:
# Simulated data
data = generate_staggered_data(
n_units=300, n_periods=10, treatment_effect=2.0,
dynamic_effects=False, seed=42
)
print(f"Shape: {data.shape}")
print(f"Cohorts: {sorted(data['first_treat'].unique())}")
print(f"Periods: {sorted(data['period'].unique())}")
print()
data.head()
Shape: (3000, 7)
Cohorts: [np.int64(0), np.int64(3), np.int64(5), np.int64(7)]
Periods: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9)]
[2]:
| unit | period | outcome | first_treat | treated | treat | true_effect | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 11.278161 | 0 | 0 | 0 | 0.0 |
| 1 | 0 | 1 | 11.835615 | 0 | 0 | 0 | 0.0 |
| 2 | 0 | 2 | 11.542112 | 0 | 0 | 0 | 0.0 |
| 3 | 0 | 3 | 11.716260 | 0 | 0 | 0 | 0.0 |
| 4 | 0 | 4 | 12.289791 | 0 | 0 | 0 | 0.0 |
Basic OLS Estimation#
The default method='ols' fits a single regression with:
Treatment interaction dummies (one per treatment cohort x post-treatment period cell)
Unit fixed effects (absorbed via within-transformation)
Time fixed effects (absorbed via within-transformation)
The two control groups produce different designs, and the distinction matters below.
With control_group='not_yet_treated' (the default, used in this section), only cells at t >= g - anticipation are emitted. Pre-treatment observations from treated units sit in the regression baseline alongside not-yet-treated controls, so there are no placebo cells and no omitted reference cell — the lag-only specification.
With control_group='never_treated', pre-treatment interaction indicators are added so only never-treated units define the counterfactual baseline, and pre-treatment coefficients serve as placebo checks. That is the lead-and-lag specification of Wooldridge (2025) Eq. 6.1/6.4, and it is the one that deliberately omits one cell per cohort — the reference period. Eq. 6.1 excludes the g − 1 indicator so that s = g − 1 is the reference, which is what makes each coefficient a 2×2 DiD
against that baseline. The cohort’s full block of cell dummies would otherwise sum to its cohort indicator, which the unit fixed effects already absorb, so the design would be rank-deficient and an arbitrary cell — possibly a real post-treatment one — would be dropped instead. Stata jwdid ... never omits the same cell. On an unbalanced panel the omitted cell is that cohort’s latest observed pre-period rather than g − 1.
[3]:
m = WooldridgeDiD() # default: method='ols'
r = m.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')
# Compute aggregations
r.aggregate('event_study').aggregate('group').aggregate('simple')
print(r.summary())
======================================================================
Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results
======================================================================
Method: ols
Control group: not_yet_treated
Observations: 3000
Treated units: 210
Control units: 300
Cohort trends: False
Aggregation w: cell
----------------------------------------------------------------------
Parameter Estimate Std. Err. t-stat P>|t| [95% CI]
----------------------------------------------------------------------
ATT (simple) 1.9817 0.0371 53.422 0.0000*** [1.9090, 2.0544]
======================================================================
Cohort×Time Cell Estimates ATT(g,t)#
The raw building blocks are ATT(g,t) — the treatment effect for cohort g at calendar time t. These are stored in r.group_time_effects and correspond to Stata’s regression output table (first_treat#year#c.__tr__).
r above is the default not_yet_treated fit, so it contains post-treatment cells (t >= g) only. Pre-treatment placebo cells belong to the never_treated specification, which the next cell fits explicitly so the two can be compared.
[4]:
print("Post-treatment ATT(g,t) cells")
print("{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}".format(
"cohort", "year", "Coef.", "Std.Err.", "t", "P>|t|"))
print("-" * 60)
for (g, t), v in sorted(r.group_time_effects.items()):
if t < g:
continue
row = "{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}".format(
int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']
)
print(row)
Post-treatment ATT(g,t) cells
cohort year | Coef. Std.Err. t P>|t|
------------------------------------------------------------
3 3 | 2.0725 0.0941 22.02 0.000
3 4 | 2.0134 0.0852 23.64 0.000
3 5 | 2.0190 0.0934 21.63 0.000
3 6 | 1.9355 0.0804 24.07 0.000
3 7 | 2.0329 0.1005 20.23 0.000
3 8 | 1.9614 0.0994 19.73 0.000
3 9 | 2.0018 0.0839 23.85 0.000
5 5 | 2.1152 0.0788 26.86 0.000
5 6 | 1.9647 0.0805 24.42 0.000
5 7 | 1.8765 0.0824 22.77 0.000
5 8 | 2.1117 0.0828 25.50 0.000
5 9 | 1.9286 0.0822 23.45 0.000
7 7 | 1.8762 0.0801 23.43 0.000
7 8 | 1.9168 0.0889 21.57 0.000
7 9 | 1.9321 0.0801 24.11 0.000
[5]:
# Placebo cells exist only under control_group='never_treated' (the lead-and-lag
# specification). The default not_yet_treated fit `r` above has none.
print(f"default (not_yet_treated) pre-treatment cells: "
f"{sorted(k for k in r.group_time_effects if k[1] < k[0])}")
r_never = WooldridgeDiD(control_group='never_treated').fit(
data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')
print("\nPre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)")
print("{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}".format(
"cohort", "year", "Coef.", "Std.Err.", "t", "P>|t|"))
print("-" * 60)
for (g, t), v in sorted(r_never.group_time_effects.items()):
if t >= g:
continue
print("{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}".format(
int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']))
# One cell per cohort is absent: the omitted reference period (g-1 here, since
# this panel is balanced). Every coefficient above is measured against it.
for g in sorted(c for c in data['first_treat'].unique() if c > 0):
present = {t for (gg, t) in r_never.group_time_effects if gg == g}
missing = sorted(set(data['period'].unique()) - present)
print(f"cohort {g}: omitted reference cell -> {[(g, t) for t in missing]}")
default (not_yet_treated) pre-treatment cells: []
Pre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)
cohort year | Coef. Std.Err. t P>|t|
------------------------------------------------------------
3 0 | 0.0568 0.1181 0.48 0.630
3 1 | 0.1425 0.0996 1.43 0.152
5 0 | -0.0206 0.1175 -0.18 0.861
5 1 | 0.0423 0.1086 0.39 0.697
5 2 | -0.0191 0.1044 -0.18 0.855
5 3 | -0.1359 0.1089 -1.25 0.212
7 0 | -0.1344 0.1180 -1.14 0.255
7 1 | -0.0188 0.1135 -0.17 0.868
7 2 | -0.2664 0.1118 -2.38 0.017
7 3 | -0.0605 0.1092 -0.55 0.580
7 4 | -0.2214 0.1177 -1.88 0.060
7 5 | -0.1768 0.1122 -1.57 0.115
cohort 3: omitted reference cell -> [(np.int64(3), np.int64(2))]
cohort 5: omitted reference cell -> [(np.int64(5), np.int64(4))]
cohort 7: omitted reference cell -> [(np.int64(7), np.int64(6))]
Aggregation Methods#
ETWFE supports four aggregation types, matching Stata’s estat post-estimation commands:
Python |
Stata |
Description |
|---|---|---|
|
|
By relative time k = t − g |
|
|
By treatment cohort g |
|
|
By calendar time t |
|
|
Overall weighted average ATT |
Standard errors use the delta method, propagating uncertainty from the cell-level ATT covariance matrix.
[6]:
# Event-study aggregation: ATT by relative time k = t - g
print(r.to_dataframe(level='event_study'))
relative_period att se t_stat p_value conf_int_lo \
0 0 2.017643 0.048198 41.861506 4.889180e-295 1.923134
1 1 1.961511 0.048986 40.041886 3.922971e-275 1.865456
2 2 1.937067 0.050790 38.138931 1.473713e-254 1.837476
3 3 2.033384 0.059205 34.344547 1.730538e-214 1.917291
4 4 1.974940 0.064935 30.414086 9.171112e-175 1.847612
5 5 1.961401 0.099388 19.734829 4.782277e-81 1.766516
6 6 2.001811 0.083933 23.850075 3.696498e-114 1.837230
conf_int_hi cohort_trends aggregation_weights
0 2.112152 False cell
1 2.057567 False cell
2 2.036658 False cell
3 2.149477 False cell
4 2.102268 False cell
5 2.156285 False cell
6 2.166391 False cell
[7]:
# Group aggregation: ATT averaged across post-treatment periods for each cohort
print(r.to_dataframe(level='group'))
cohort att se t_stat p_value conf_int_lo \
0 3 2.005216 0.050244 39.909748 1.073586e-273 1.906696
1 5 1.999333 0.049288 40.564523 7.899715e-281 1.902687
2 7 1.908365 0.055531 34.365894 1.039854e-214 1.799477
conf_int_hi cohort_trends aggregation_weights
0 2.103737 False cell
1 2.095979 False cell
2 2.017253 False cell
[8]:
# Simple ATT: overall weighted average
print(r.summary())
======================================================================
Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results
======================================================================
Method: ols
Control group: not_yet_treated
Observations: 3000
Treated units: 210
Control units: 300
Cohort trends: False
Aggregation w: cell
----------------------------------------------------------------------
Parameter Estimate Std. Err. t-stat P>|t| [95% CI]
----------------------------------------------------------------------
ATT (simple) 1.9817 0.0371 53.422 0.0000*** [1.9090, 2.0544]
======================================================================
[9]:
# Event study plot
if HAS_MATPLOTLIB:
es = r.event_study_effects
ks = sorted(es.keys())
atts = [es[k]['att'] for k in ks]
lo = [es[k]['conf_int'][0] for k in ks]
hi = [es[k]['conf_int'][1] for k in ks]
fig, ax = plt.subplots(figsize=(9, 5))
ax.errorbar(ks, atts, yerr=[np.array(atts) - np.array(lo), np.array(hi) - np.array(atts)],
fmt='o-', capsize=4, color='steelblue', label='ETWFE (OLS)')
ax.axhline(0, color='black', linestyle='--', linewidth=0.8)
ax.axvline(-0.5, color='red', linestyle=':', linewidth=0.8, label='Treatment onset')
ax.set_xlabel('Relative period (k = t − g)')
ax.set_ylabel('ATT')
ax.set_title('ETWFE Event Study')
ax.legend()
plt.tight_layout()
plt.show()
else:
print("Install matplotlib to see the event study plot: pip install matplotlib")
Poisson QMLE for Count / Non-Negative Outcomes#
method='poisson' fits a Poisson QMLE regression. This is valid for any non-negative continuous outcome, not just count data — the Poisson log-likelihood produces consistent estimates whenever the conditional mean is correctly specified as exp(Xβ).
The ATT is computed as the Average Structural Function (ASF) difference:
where η₁ = Xβ (with treatment) and η₀ = Xβ − δ (counterfactual without treatment).
This matches Stata’s jwdid y, method(poisson).
[10]:
# Simulate a non-negative outcome (e.g., employment level)
data_pois = data.copy()
data_pois['emp'] = np.exp(data_pois['outcome'] / 4 + 3) # positive outcome
m_pois = WooldridgeDiD(method='poisson')
r_pois = m_pois.fit(data_pois, outcome='emp', unit='unit', time='period', first_treat='first_treat')
r_pois.aggregate('event_study').aggregate('group').aggregate('simple')
print(r_pois.summary())
======================================================================
Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results
======================================================================
Method: poisson
Control group: not_yet_treated
Observations: 3000
Treated units: 210
Control units: 300
Cohort trends: False
Aggregation w: cell
----------------------------------------------------------------------
Parameter Estimate Std. Err. t-stat P>|t| [95% CI]
----------------------------------------------------------------------
ATT (simple) 222.6119 9.7423 22.850 0.0000*** [203.5172, 241.7065]
======================================================================
[11]:
# Cohort×time cells (post-treatment, Poisson)
print("Poisson ATT(g,t) — post-treatment cells")
print("{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}".format(
"cohort", "year", "ATT", "Std.Err.", "t", "P>|t|"))
print("-" * 60)
for (g, t), v in sorted(r_pois.group_time_effects.items()):
if t < g:
continue
print("{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}".format(
int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']
))
Poisson ATT(g,t) — post-treatment cells
cohort year | ATT Std.Err. t P>|t|
------------------------------------------------------------
3 3 | 232.1684 20.1192 11.54 0.000
3 4 | 237.9872 23.0383 10.33 0.000
3 5 | 231.4901 19.8361 11.67 0.000
3 6 | 223.6876 17.1670 13.03 0.000
3 7 | 237.2743 19.9434 11.90 0.000
3 8 | 238.1305 19.9106 11.96 0.000
3 9 | 265.6228 26.4917 10.03 0.000
5 5 | 219.3397 14.5190 15.11 0.000
5 6 | 214.5765 17.5795 12.21 0.000
5 7 | 198.8336 14.8757 13.37 0.000
5 8 | 242.4799 17.3696 13.96 0.000
5 9 | 226.3546 17.2521 13.12 0.000
7 7 | 182.5155 14.1802 12.87 0.000
7 8 | 202.8975 18.3660 11.05 0.000
7 9 | 207.4356 17.4584 11.88 0.000
[12]:
print(r_pois.to_dataframe(level='event_study'))
print(r_pois.to_dataframe(level='group'))
relative_period att se t_stat p_value \
0 0 209.853525 9.262249 22.656865 1.193811e-113
1 1 217.094215 11.301395 19.209506 3.081876e-82
2 2 211.236155 10.015702 21.090498 9.723169e-99
3 3 234.127753 12.505668 18.721732 3.292711e-78
4 4 231.207818 12.990070 17.798813 7.218311e-71
5 5 238.130506 19.910574 11.960002 5.756036e-33
6 6 265.622807 26.491724 10.026634 1.164183e-23
conf_int_lo conf_int_hi cohort_trends aggregation_weights
0 191.699851 228.007200 False cell
1 194.943888 239.244543 False cell
2 191.605739 230.866571 False cell
3 209.617095 258.638412 False cell
4 205.747749 256.667887 False cell
5 199.106499 277.154513 False cell
6 213.699982 317.545631 False cell
cohort att se t_stat p_value conf_int_lo \
0 3 238.051549 17.463438 13.631425 2.604447e-42 203.823839
1 5 220.316861 13.840221 15.918594 4.708403e-57 193.190525
2 7 197.616203 14.969396 13.201348 8.617899e-40 168.276726
conf_int_hi cohort_trends aggregation_weights
0 272.279259 False cell
1 247.443196 False cell
2 226.955680 False cell
Logit for Binary Outcomes#
method='logit' fits a logit model and computes ATT as the ASF probability difference:
where Λ(·) is the logistic function. Standard errors use the delta method.
This matches Stata’s jwdid y, method(logit).
[13]:
# Create a binary outcome
data_logit = data.copy()
median_val = data_logit.loc[data_logit['period'] == data_logit['period'].min(), 'outcome'].median()
data_logit['hi_outcome'] = (data_logit['outcome'] > median_val).astype(int)
print(f"Binary outcome mean: {data_logit['hi_outcome'].mean():.3f}")
m_logit = WooldridgeDiD(method='logit')
r_logit = m_logit.fit(data_logit, outcome='hi_outcome', unit='unit', time='period', first_treat='first_treat')
r_logit.aggregate('event_study').aggregate('group').aggregate('simple')
print(r_logit.summary())
Binary outcome mean: 0.668
======================================================================
Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results
======================================================================
Method: logit
Control group: not_yet_treated
Observations: 3000
Treated units: 210
Control units: 300
Cohort trends: False
Aggregation w: cell
----------------------------------------------------------------------
Parameter Estimate Std. Err. t-stat P>|t| [95% CI]
----------------------------------------------------------------------
ATT (simple) 0.2460 0.0311 7.919 0.0000*** [0.1851, 0.3069]
======================================================================
[14]:
print(r_logit.to_dataframe(level='group'))
cohort att se t_stat p_value conf_int_lo \
0 3 0.238387 0.048375 4.927931 8.310506e-07 0.143574
1 5 0.229646 0.045347 5.064162 4.101997e-07 0.140767
2 7 0.287403 0.053016 5.421110 5.923009e-08 0.183495
conf_int_hi cohort_trends aggregation_weights
0 0.333200 False cell
1 0.318525 False cell
2 0.391312 False cell
mpdta: Real-World Example#
The mpdta dataset (Callaway & Sant’Anna 2021) contains county-level log employment (lemp) data with staggered minimum-wage adoption (first_treat = year of treatment, 0 = never treated). It is the canonical benchmark for staggered DiD methods.
This follows Stata’s jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat) specification. See the Methodology Registry for documented SE/aggregation deviations.
[15]:
from diff_diff import load_mpdta
mpdta = load_mpdta()
print(f"mpdta loaded: {mpdta.shape}")
print(f"Cohorts: {sorted(mpdta['first_treat'].unique())}")
mpdta loaded: (2500, 7)
Cohorts: [np.int64(0), np.int64(2004), np.int64(2006), np.int64(2007)]
[16]:
# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)
m_ols = WooldridgeDiD(method='ols')
r_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', first_treat='first_treat')
r_ols.aggregate('event_study').aggregate('group').aggregate('simple')
print(r_ols.to_dataframe(level='event_study'))
relative_period att se t_stat p_value conf_int_lo \
0 0 -0.031067 0.013621 -2.280835 0.022664 -0.057780
1 1 -0.052235 0.018873 -2.767725 0.005697 -0.089247
2 2 -0.136078 0.035455 -3.837999 0.000128 -0.205612
3 3 -0.104707 0.033874 -3.091059 0.002022 -0.171140
conf_int_hi cohort_trends aggregation_weights
0 -0.004354 False cell
1 -0.015222 False cell
2 -0.066544 False cell
3 -0.038275 False cell
[17]:
# cohort x time ATT cells (post-treatment)
# Matches Stata: first_treat#year#c.__tr__ output table
print("ATT(g,t) — post-treatment cells (matches Stata jwdid output)")
print("{:>6} {:>6} | {:>9} {:>9} {:>7} {:>7}".format(
"cohort", "year", "Coef.", "Std.Err.", "t", "P>|t|"))
print("-" * 55)
for (g, t), v in sorted(r_ols.group_time_effects.items()):
if t < g:
continue
print("{:>6} {:>6} | {:>9.4f} {:>9.4f} {:>7.2f} {:>7.3f}".format(
g, t, v['att'], v['se'], v['t_stat'], v['p_value']))
ATT(g,t) — post-treatment cells (matches Stata jwdid output)
cohort year | Coef. Std.Err. t P>|t|
-------------------------------------------------------
2004 2004 | -0.0194 0.0224 -0.87 0.387
2004 2005 | -0.0783 0.0305 -2.57 0.010
2004 2006 | -0.1361 0.0355 -3.84 0.000
2004 2007 | -0.1047 0.0339 -3.09 0.002
2006 2006 | 0.0025 0.0199 0.13 0.900
2006 2007 | -0.0392 0.0240 -1.63 0.103
2007 2007 | -0.0431 0.0184 -2.34 0.019
[18]:
# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)
mpdta['emp'] = np.exp(mpdta['lemp'])
m_pois2 = WooldridgeDiD(method='poisson')
r_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', first_treat='first_treat')
r_pois2.aggregate('event_study').aggregate('group').aggregate('simple')
print(r_pois2.to_dataframe(level='event_study'))
print(r_pois2.to_dataframe(level='group'))
print(r_pois2.summary())
relative_period att se t_stat p_value conf_int_lo \
0 0 -25.572049 21.809918 -1.172496 0.240998 -68.318703
1 1 0.873630 53.217084 0.016416 0.986902 -103.429938
2 2 -76.746573 28.036277 -2.737402 0.006193 -131.696667
3 3 -102.357517 41.590684 -2.461068 0.013852 -183.873759
conf_int_hi cohort_trends aggregation_weights
0 17.174605 False cell
1 105.177198 False cell
2 -21.796478 False cell
3 -20.841275 False cell
cohort att se t_stat p_value conf_int_lo conf_int_hi \
0 2004 -56.868740 24.371745 -2.333388 0.019628 -104.636481 -9.100998
1 2006 58.439881 72.503696 0.806026 0.420228 -83.664751 200.544514
2 2007 -65.187913 23.560932 -2.766780 0.005661 -111.366491 -19.009336
cohort_trends aggregation_weights
0 False cell
1 False cell
2 False cell
======================================================================
Wooldridge Extended Two-Way Fixed Effects (ETWFE) Results
======================================================================
Method: poisson
Control group: not_yet_treated
Observations: 2500
Treated units: 191
Control units: 500
Cohort trends: False
Aggregation w: cell
----------------------------------------------------------------------
Parameter Estimate Std. Err. t-stat P>|t| [95% CI]
----------------------------------------------------------------------
ATT (simple) -28.9138 23.7821 -1.216 0.2241 [-75.5258, 17.6981]
======================================================================
Comparison with Callaway-Sant’Anna#
ETWFE and Callaway-Sant’Anna are both valid for staggered designs. Under homogeneous treatment effects and additive parallel trends, they should produce similar ATT(g,t) point estimates. Key differences:
Aspect |
WooldridgeDiD (ETWFE) |
CallawaySantAnna |
|---|---|---|
Approach |
Single saturated regression |
Separate 2×2 DiD per cell |
Nonlinear outcomes |
Yes (Poisson, Logit) |
No |
Covariates |
Via regression (linear index) |
OR, IPW, DR |
SE for aggregations |
Delta method |
Multiplier bootstrap |
Stata equivalent |
|
|
[19]:
# Compare overall ATT: ETWFE vs Callaway-Sant'Anna
cs = CallawaySantAnna()
r_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')
m_etwfe = WooldridgeDiD(method='ols')
r_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')
r_etwfe.aggregate('event_study').aggregate('simple')
print("Overall ATT Comparison (true effect = 2.0)")
print("=" * 60)
print("{:<25} {:>10} {:>10} {:>12}".format("Estimator", "ATT", "SE", "95% CI"))
print("-" * 60)
for name, est_r in [("WooldridgeDiD (ETWFE)", r_etwfe), ("CallawaySantAnna", r_cs)]:
ci = est_r.overall_conf_int
print("{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]".format(
name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]
))
Overall ATT Comparison (true effect = 2.0)
============================================================
Estimator ATT SE 95% CI
------------------------------------------------------------
WooldridgeDiD (ETWFE) 1.9817 0.0371 [1.909, 2.054]
CallawaySantAnna 1.9754 0.0528 [1.872, 2.079]
[20]:
# Event-study comparison
r_cs_es = CallawaySantAnna().fit(
data, outcome='outcome', unit='unit', time='period',
first_treat='first_treat', aggregate='event_study'
)
if HAS_MATPLOTLIB:
es_etwfe = r_etwfe.event_study_effects
es_cs = {int(row['relative_period']): row
for _, row in r_cs_es.to_dataframe(level='event_study').iterrows()}
ks = sorted(es_etwfe.keys())
fig, ax = plt.subplots(figsize=(10, 5))
offset = 0.1
atts_e = [es_etwfe[k]['att'] for k in ks]
lo_e = [es_etwfe[k]['conf_int'][0] for k in ks]
hi_e = [es_etwfe[k]['conf_int'][1] for k in ks]
ax.errorbar([k - offset for k in ks], atts_e,
yerr=[np.array(atts_e) - np.array(lo_e), np.array(hi_e) - np.array(atts_e)],
fmt='o-', capsize=4, color='steelblue', label='ETWFE')
ks_cs = sorted(es_cs.keys())
atts_cs = [es_cs[k]['effect'] for k in ks_cs]
lo_cs = [es_cs[k]['conf_int_lower'] for k in ks_cs]
hi_cs = [es_cs[k]['conf_int_upper'] for k in ks_cs]
ax.errorbar([k + offset for k in ks_cs], atts_cs,
yerr=[np.array(atts_cs) - np.array(lo_cs), np.array(hi_cs) - np.array(atts_cs)],
fmt='s--', capsize=4, color='darkorange', label='Callaway-Sant\'Anna')
ax.axhline(0, color='black', linestyle='--', linewidth=0.8)
ax.axvline(-0.5, color='red', linestyle=':', linewidth=0.8)
ax.set_xlabel('Relative period (k = t − g)')
ax.set_ylabel('ATT')
ax.set_title('Event Study: ETWFE vs Callaway-Sant\'Anna')
ax.legend()
plt.tight_layout()
plt.show()
else:
print("Install matplotlib to see the comparison plot: pip install matplotlib")
/var/folders/bh/mzf05nq92hs6t7vn2ssvfhpr0000gn/T/ipykernel_96398/1757607880.py:2: FutureWarning: CallawaySantAnna.fit(aggregate=) is deprecated and will be removed in 4.0. Fit once, then aggregate as a post-fit step: results = CallawaySantAnna().fit(...); results.aggregate('event_study') / .aggregate('group') / .aggregate('simple'). balance_e moves onto aggregate() alongside it: results.aggregate('event_study', balance_e=2).
r_cs_es = CallawaySantAnna().fit(
All-Eventually-Treated Panels (no never-treated group)#
Many applied panels have no never-treated units — every unit is eventually treated, just at different times. At the late periods where everyone is treated there is no untreated outcome left to difference against, so those ATT(g, t) are not identified.
Wooldridge (2025) Section 5.4 gives the answer: the last cohort serves as the reference, and “all variables in regression (5.3) involving dT_i get dropped”. WooldridgeDiD implements that — it removes the unsupported periods before estimating and tells you it did.
Use control_group="not_yet_treated" (the default); "never_treated" raises when there are no never-treated units to use.
[21]:
import warnings
# Three cohorts, no never-treated group: everyone is treated by t=8.
rng = np.random.default_rng(7)
rows = []
for u in range(200):
g = 3 if u < 70 else (5 if u < 140 else 8)
for t in range(1, 10):
rows.append({"unit": u, "time": t, "cohort": g,
"y": rng.standard_normal() + 1.5 * (t >= g)})
all_treated = pd.DataFrame(rows)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
res_at = WooldridgeDiD(control_group="not_yet_treated").fit(
all_treated, outcome="y", unit="unit", time="time", first_treat="cohort"
)
for w in caught:
print(f"WARNING: {w.message}\n")
print(f"overall ATT = {res_at.overall_att:.4f} (true effect: 1.5)")
print(f"estimated cohorts = {sorted(int(g) for g in res_at.groups)}")
print(f"cells = {sorted((int(g), int(t)) for g, t in res_at.group_time_effects)}")
WARNING: Dropped 400 of 1800 observations (2 of 9 periods: 8, 9) from the estimation sample: no eligible comparison group exists at those periods -- every unit is already treated (accounting for `anticipation=0`), so ATT(g, t) there is not identified against any untreated outcome. To estimate those periods, add never-treated units or restrict the panel.
WARNING: Cohort(s) 8 have NO estimated cells and are excluded from `results.groups`. Their ATT(g, t) are not estimated. Cohort 8 is the W2025 Section 5.4 reference: with no never-treated group the last cohort serves as the comparison and receives no cells of its own.
overall ATT = 1.5502 (true effect: 1.5)
estimated cohorts = [3, 5]
cells = [(3, 3), (3, 4), (3, 5), (3, 6), (3, 7), (5, 5), (5, 6), (5, 7)]
Two things to read from that output.
The estimation sample shrank, and you were told. Periods 8 and 9 have no untreated unit, so their rows are dropped before the solve. Stata’s jwdid performs the same reduction but reports only a smaller N — here the periods, the row count and the reason are all stated. If you are wrapping this in a pipeline, surface these warnings.
Cohort 8 has no estimates. It is the reference — the role a never-treated group would otherwise play — so it receives no cells and is excluded from results.groups. That is the Section 5.4 normalization, not a failure.
The retained ATTs are the absolute effects (≈ 1.5 here), not effects relative to the last cohort, because cohort 8 is untreated throughout every period that survives.
Covariates on these panels.
exovar/xgvar, andxtvarwithdemean_covariates=False, are still rank-deficient here:D_g × Xis built for the last cohort too, which the paper’s rule would drop. Coefficients are unaffected, butrank_deficient_action="error"will raise. Default ``xtvar`` (``demean_covariates=True``) is full rank and fits normally. Tracked as follow-up work.
Summary#
Key takeaways:
ETWFE via a single regression: all ATT(g,t) cells estimated jointly, not separately — computationally efficient and internally consistent
OLS path follows the Stata
jwdidspecification: unit + time FEs (absorbed via within-transformation), treatment interaction dummiesNonlinear paths (Poisson, Logit) use the ASF formula: E[f(η₁)] − E[f(η₀)] — the only valid ATT definition for nonlinear models
Four aggregations mirror Stata’s
estatcommands: event, group, calendar, simpleDelta-method SEs for all aggregations, including nonlinear paths
All-eventually-treated panels estimate via the Section 5.4 normalization: the last cohort is the reference, unsupported periods are dropped, and the reduction is always reported
When to prefer ETWFE: nonlinear outcomes, or when a single-regression framework is preferred
When to prefer CS/ImputationDiD: covariate adjustment via IPW/DR, or multiplier bootstrap inference
Parameter reference:
Parameter |
Default |
Description |
|---|---|---|
|
|
|
|
|
|
|
|
Anticipation periods before treatment |
|
|
Significance level |
|
|
Column for clustering (default: unit variable) |
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. Published version of SSRN 3906345 / NBER Working Paper 29154; cited as Wooldridge (2025) throughout this tutorial, including Eq. 6.1/6.4 (reference period) and Section 5.4 (all-eventually-treated panels).
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.
See also:Tutorial 02for Callaway-Sant’Anna,Tutorial 15for Efficient DiD.