Applied Informatics and Computational Economics Lab
28 April 2026
Outline
Background & Theory
Motivation: the two confounders problem
Between, within, and double-demeaning
Literature highlights
The TWFE model & DGP
Mathematical framework
Model specification
Identification: FWL theorem
Double-demeaning estimator
Estimation, Tests & Applications
Required libraries
Data generation & diagnostics
Estimation in R · Python · Stata
Tabular results & LaTeX export
Tests: entity FE, time FE, serial correlation, CD
Variations: clustered SEs · event study · staggered DiD
Empirical examples: crime4 · wagepan · airfare
Further reading & exercises
Motivation (1/3) — The Two-Confounder Problem
In many empirical settings a regressor of interest \(D_{it}\) is correlated with two distinct sources of unobserved heterogeneity that operate along orthogonal dimensions of the panel:
Confounder
Structure
Economic content
Estimator
\(\alpha_i\)
Varies across \(i\), constant over \(t\)
Ability, geography, firm culture
Entity FE
\(\lambda_t\)
Common across \(i\), varies over \(t\)
Recessions, policy shifts, technology
Time FE
Both
—
Any setting where selection and aggregate shocks coexist
Both biases persist regardless of sample size — they are features of the design, not of estimation noise.
Entity FE removes the first term by demeaning within units, but: \[\hat{\beta}^{FE(entity)} \xrightarrow{p} \beta + \frac{\text{Cov}(\tilde{D}_{it},\, \lambda_t)}{\text{Var}(\tilde{D}_{it})}\]
where \(\tilde{D}_{it} = D_{it} - \bar{D}_{i\cdot}\) is the within-unit demeaned treatment. The time-trend bias survives entity-only FE whenever treatment timing is non-random.
Tip
TWFE eliminates both terms simultaneously by partialling out entity and time means.
Motivation (3/3) — Running Example and Intuition
Running example throughout this lecture: county crime rates in North Carolina, 1981–1987.
\(\alpha_i\) — persistent county characteristics: rural vs urban, proximity to state prison, local political climate, baseline policing culture. These never change within the sample window and would confound any cross-sectional comparison of counties.
\(\lambda_t\) — year-specific shocks common to all counties: federal crime bills (1984, 1986), national economic cycle, crack cocaine epidemic. These shift all counties’ crime rates simultaneously and would confound any time-series comparison.
\(\varepsilon_{it}\) — idiosyncratic shocks: a local factory closure, a one-off gang dispute, a drought. This is the variation TWFE exploits.
What identification requires:
After removing \(\alpha_i\) and \(\lambda_t\), we need the remaining variation in police per capita to be uncorrelated with the remaining error:
where \(\ddot{\cdot}\) denotes double-demeaning. This is the strict exogeneity assumption — idiosyncratic policing changes are not themselves driven by contemporaneous crime shocks.
Note
This assumption is testable via pre-trends in an event study specification and falsifiable via placebo tests on pre-treatment periods.
The Panel Data Advantage
The same outcome can be decomposed along two orthogonal dimensions:
Each estimator exploits a different slice of this variance decomposition. Choosing the wrong estimator means attributing confounder variation to the treatment effect.
Why pooled OLS fails: it mixes variation from all three components. If high-police counties also have systematically different crime cultures (\(\alpha_i\)), the cross-sectional comparison contaminates \(\hat\beta\). The OLS estimator converges to:
Why entity FE alone is insufficient: within-unit variation still contains common time shocks. If policing expanded nationally during low-crime years, \(\lambda_t\) inflates the estimated deterrence effect even after removing unit means.
Tip
TWFE’s identifying variation is the residual \(\ddot{D}_{it}\) after projecting \(D_{it}\) onto both entity and time dummies. Formally:
This is the part of \(D_{it}\) that is neither explained by which unit we observe nor by which year we are in — the only variation that cannot be attributed to either confounder. It is the cleanest identification available in a panel without an instrument.
Loss of identification: any regressor that is collinear with either \(\alpha_i\) or \(\lambda_t\) double-demeans to zero. Time-invariant variables (e.g., sex, region) are absorbed by entity FE. Unit-invariant variables (e.g., GDP growth) are absorbed by time FE.
FWL Theorem for TWFE
The Frisch-Waugh-Lovell (FWL) theorem gives a precise interpretation: \(\hat{\beta}_{TWFE}\) is identical to the coefficient obtained by:
Regress\(D_{it}\) on a full set of entity dummies and time dummies → residuals \(\tilde{D}_{it}\)
Regress\(y_{it}\) on the same dummies → residuals \(\tilde{y}_{it}\)
The part of treatment variation that is neither explained by which unit we observe nor by which period we are in. This is the pure treatment variation TWFE exploits.
Note
Practical implication: if treatment \(D_{it}\) has little within-unit, within-time variation (e.g., treatment is almost always 0 or almost always 1 for each unit), \(\tilde{D}_{it} \approx 0\) and estimates will be imprecise.
Goodman-Bacon (2021) — “Difference-in-Differences with Variation in Treatment Timing.” Journal of Econometrics 225(2): 254–277. Bacon decomposition; negative weights.
Callaway & Sant’Anna (2021) — “Difference-in-Differences with Multiple Time Periods.” JOE 225(2): 200–230. Heterogeneity-robust ATT(g,t) estimator.
Sun & Abraham (2021) — “Estimating Dynamic Treatment Effects in Event Studies with Heterogeneous Treatment Effects.” JOE 225(2): 175–199. Interaction-weighted estimator.
Borusyak, Jaravel & Spiess (2024) — “Revisiting Event-Study Designs: Robust and Efficient Estimation.” Review of Economic Studies 91(6): 3253–3285. Imputation-based efficient DiD estimator.
Wooldridge (2025) — “Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators.” Empirical Economics 69(5): 2545–2587. Unified framework via Mundlak correction; the published version of the widely-circulated 2021 working paper.
Roth, Sant’Anna, Bilinski & Poe (2023) — “What’s Trending in Difference-in-Differences? A Synthesis of the Recent Econometrics Literature.” JOE 235(2): 2218–2244. Excellent review of the full literature.
Units \(i = 101\)–\(200\) are treated starting at \(t = 6\) (a clean simultaneous treatment for the main DGP). A staggered variant is generated separately for the variations section.
quietly import delimited "../data/panel-TWFE.csv", clearquietlydestring_all, replacequietly xtset id timelist id time y x1 x2 treat first_treat in 1/12, sep(0) noobsxtdescribe
y x1 x2 treat
Min. :-4.7526 Min. :-2.88543 Min. :-2.1136 Min. :0.00
1st Qu.: 0.9389 1st Qu.:-0.73883 1st Qu.: 0.3103 1st Qu.:0.00
Median : 2.8127 Median :-0.05318 Median : 1.0169 Median :0.00
Mean : 2.8894 Mean :-0.04736 Mean : 1.0158 Mean :0.25
3rd Qu.: 4.7264 3rd Qu.: 0.63008 3rd Qu.: 1.6962 3rd Qu.:0.25
Max. :13.2553 Max. : 3.17948 Max. : 4.9439 Max. :1.00
Variable | Mean Std. dev. Min Max | Observations
-----------------+--------------------------------------------+----------------
y overall | 2.88943 2.789264 -4.752584 13.25526 | N = 2000
between | 1.902014 -1.630829 8.215165 | n = 200
within | 2.044168 -3.932627 8.900282 | T = 10
| |
x1 overall | -.0473601 1.008786 -2.885428 3.17948 | N = 2000
between | .5717692 -1.385513 1.460393 | n = 200
within | .8319861 -2.650447 2.885352 | T = 10
| |
x2 overall | 1.015845 1.01005 -2.113644 4.943884 | N = 2000
between | .2940984 .0943723 1.953338 | n = 200
within | .9664866 -1.925585 4.790418 | T = 10
| |
treat overall | .25 .433121 0 1 | N = 2000
between | .2506274 0 .5 | n = 200
within | .3536418 -.25 .75 | T = 10
TWFE Estimation
Code
# Pooled OLS (biased — ignores both alpha_i and lambda_t)ols_r <-lm(y ~ x1 + x2 + treat, data = df)# Entity FE only (via within demeaning; time shocks still omitted)fe_r <-plm(y ~ x1 + x2 + treat, data = pdf, model ="within", effect ="individual")# Two-Way FE — the recommended estimatortwfe_r <-feols(y ~ x1 + x2 + treat | id + time, data = df, cluster =~id)summary(twfe_r)
Time effects \(\lambda_t = 0.3t\) are omitted. Since treatment turns on at \(t \geq 6\) (high-\(\lambda_t\) periods), entity-only FE confounds the time trend with the treatment effect.
Entity FE removes \(\alpha_i\); time FE removes \(\lambda_t\). The double-demeaned treatment \(\ddot{D}_{it}\) is orthogonal to both fixed effects by construction:
Pooled OLS Entity FE TWFE
----------------------------------------------------------------------
Code
for v, lbl in var_labels.items(): row_c =f"{lbl:<22}" row_s =f"{'':22}"for _, m in models: c, s, p = m.coef(), m.se(), m.pvalue()if v in c.index: row_c +=f"{c[v]:>10.4f}{_stars(p[v]):>6}" row_s +=f"({'('+f'{s[v]:.4f}'+')':>14}"else: row_c +=f"{'—':>{col_w}}" row_s +=f"{'':>{col_w}}"print(row_c);print(row_s)
Reject when:\(p\)-value \(< 0.05\) — entity effects are jointly significant; prefer FE over pooled OLS.
F-test for Entity Fixed Effects — Code
Code
pFtest(fe_r, lm(y ~ x1 + x2 + treat, data = df))
F test for individual effects
data: y ~ x1 + x2 + treat
F = 10.872, df1 = 199, df2 = 1797, p-value < 2.2e-16
alternative hypothesis: significant effects
\[F_{time} = \frac{(RSS_{FE_{entity}} - RSS_{TWFE})\,/\,(T-1)}{RSS_{TWFE}\,/\,(NT - N - T - k + 1)} \;\sim\; F(T-1,\; NT-N-T-k+1)\]
Component
Meaning
\(RSS_{FE_{entity}}\)
RSS from entity FE only
\(RSS_{TWFE}\)
RSS from TWFE (entity + time FE)
\(T - 1\)
Numerator df (time dummies less one)
Reject when:\(p\)-value \(< 0.05\) — time effects are jointly significant; use TWFE, not entity-only FE.
Warning
Presence of significant time effects does not by itself imply violation of parallel trends — it means the common time trend is non-trivial and must be controlled for.
F-test for Time Fixed Effects — Code
Code
fe_time <-plm(y ~ x1 + x2 + treat, data = pdf, model ="within", effect ="time")twfe_plm <-plm(y ~ x1 + x2 + treat, data = pdf, model ="within", effect ="twoways")pFtest(twfe_plm, fe_r) # tests whether time FE are jointly significant
F test for twoways effects
data: y ~ x1 + x2 + treat
F = 443.34, df1 = 9, df2 = 1788, p-value < 2.2e-16
alternative hypothesis: significant effects
print("Decision:", "Reject H₀ → time FE significant → use TWFE"if p_time <0.05else"Do not reject")
Decision: Reject H₀ → time FE significant → use TWFE
Code
quietly import delimited "../data/panel-TWFE.csv", clearquietlydestring_all, replacequietly xtset id time* Compare entity-only FE vs TWFE via manual F-test (RSS difference)cap ssc install require, replacequietly reghdfe y x1 x2 treat, absorb(id)scalar rss_fe = e(rss)scalar df_fe = e(df_r)quietly reghdfe y x1 x2 treat, absorb(id time)scalar rss_twfe = e(rss)scalar df_twfe = e(df_r)* F = [(RSS_fe - RSS_twfe)/(T-1)] / [RSS_twfe / df_twfe]scalar F_time = ((rss_fe - rss_twfe) / (10 - 1)) / (rss_twfe / df_twfe)scalar p_time = Ftail(9, df_twfe, F_time)display"F(9, " df_twfe ") = " %8.4f F_time " p = " %8.4f p_timedisplaycond(p_time < 0.05, "Reject H0: time FE significant -> use TWFE", "Cannot reject H0")
F(9, 1788) = 443.3440 p = 0.0000
Reject H0: time FE significant -> use TWFE
Wooldridge Serial Correlation Test — Theory
Wooldridge (2002) proposes a simple test for serial correlation in the idiosyncratic errors \(\varepsilon_{it}\) of a linear panel model; the implementation follows Drukker (2003).
Procedure: estimate the first-differenced equation and test whether residuals \(\Delta\hat{\varepsilon}_{it}\) are serially correlated:
Under \(H_0\) (no serial correlation), \(\text{Corr}(\Delta\varepsilon_{it}, \Delta\varepsilon_{i,t-1}) = -0.5\).
\[F_{SC} \sim F(1, N-1)\]
The test regresses \(\Delta\hat{\varepsilon}_{it}\) on \(\Delta\hat{\varepsilon}_{i,t-1}\) across units and tests whether the slope equals \(-0.5\).
Reject when:\(p\)-value \(< 0.05\) → serial correlation is present in the idiosyncratic errors → cluster standard errors at the individual level.
Wooldridge Serial Correlation Test — Code
Code
# pbgtest: panel Breusch-Godfrey / Wooldridge-type serial correlation testpbgtest(twfe_plm, order =1)
Breusch-Godfrey/Wooldridge test for serial correlation in panel models
data: y ~ x1 + x2 + treat
chisq = 17.512, df = 1, p-value = 2.855e-05
alternative hypothesis: serial correlation in idiosyncratic errors
Code
from scipy.stats import t as t_dist# Step 1: fit TWFE and extract residuals (pyfixest returns a plain numpy array)twfe_sc = pf.feols("y ~ x1 + x2 + treat | id + time", data=df_py.sort_values(["id","time"]).reset_index(drop=True))resid_np = np.asarray(twfe_sc.resid()) # numpy array — no .values needed# Step 2: attach residuals to a sorted copy of the datadf_sc = df_py.sort_values(["id","time"]).reset_index(drop=True).copy()df_sc["e"] = resid_np# Step 3: first-difference residuals within each unitdf_sc["de"] = df_sc.groupby("id")["e"].diff()df_sc["de_l"] = df_sc.groupby("id")["de"].shift(1)clean = df_sc.dropna(subset=["de","de_l"])# Step 4: OLS of Δε on Δε_{t-1}; test H₀: coef = -0.5X_sc = sm.add_constant(clean["de_l"])sc_m = sm.OLS(clean["de"], X_sc).fit()b_sc = sc_m.params["de_l"]se_sc = sc_m.bse["de_l"]t_sc = (b_sc - (-0.5)) / se_scp_sc =float(2* (1- t_dist.cdf(abs(t_sc), df=len(clean) -2)))print(f"Coef on lagged \u0394\u03b5 : {b_sc:.4f} (H\u2080: coef = \u22120.5)")
Coef on lagged Δε : -0.4960 (H₀: coef = −0.5)
Code
print(f"t-stat : {t_sc:.4f}")
t-stat : 0.1845
Code
print(f"p-value : {p_sc:.4f}")
p-value : 0.8537
Code
print("Decision:", "Reject H\u2080\u2192 serial correlation present"if p_sc <0.05else"Cannot reject H\u2080")
Decision: Cannot reject H₀
Code
quietly import delimited "../data/panel-TWFE.csv", clearquietlydestring_all, replacequietly xtset id time* Wooldridge (2002) test — manual via first differences of TWFE residuals* Step 1: obtain TWFE residuals using residuals(varname) syntaxcap ssc install require, replacequietly reghdfe y x1 x2 treat, absorb(id time) residuals(resid_fe)* Step 2: first-difference the residuals within each unitquietlyby id (time): gen d_resid = resid_fe - resid_fe[_n-1]quietlyby id (time): gen d_resid_lag = d_resid[_n-1]* Step 3: regress Δε on Δε_{t-1} and test H0: coef = -0.5regress d_resid d_resid_lag, cluster(id)test d_resid_lag = -0.5* H0: no serial correlation (coef = -0.5 under independence of ε)
Linear regression Number of obs = 1,600
F(1, 199) = 792.49
Prob > F = 0.0000
R-squared = 0.2422
Root MSE = .62327
(Std. err. adjusted for 200 clusters in id)
------------------------------------------------------------------------------
| Robust
d_resid | Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
d_resid_lag | -.4959515 .0176174 -28.15 0.000 -.5306923 -.4612108
_cons | 5.22e-10 .0066031 0.00 1.000 -.0130211 .0130211
------------------------------------------------------------------------------
( 1) d_resid_lag = -.5
F( 1, 199) = 0.05
Prob > F = 0.8185
Pesaran Cross-Sectional Dependence Test — Theory
In macro/regional panels, idiosyncratic errors may be correlated across units at the same time \(t\) (e.g., due to unobserved common factors). This violates the assumption \(\mathbb{E}[\varepsilon_{it}\varepsilon_{jt}] = 0\) for \(i \neq j\).
This test detects the problem; it does not fix it. The companion deck Spatial Econometrics supplies the remedy — Conley standard errors when dependence decays with distance, and spatial panel models when it is structural.
where \(\hat{\rho}_{ij} = \frac{\sum_t \hat{\varepsilon}_{it}\hat{\varepsilon}_{jt}}{\left(\sum_t \hat{\varepsilon}_{it}^2\right)^{1/2}\left(\sum_t \hat{\varepsilon}_{jt}^2\right)^{1/2}}\) is the pair-wise correlation of residuals.
Reject when:\(|CD| > 1.96\) (or \(p < 0.05\)) → cross-sectional dependence is present → consider Driscoll-Kraay standard errors or common correlated effects (CCE) estimator.
Note
In balanced panels with \(T < N\), the CD statistic has good power. For \(T > N\), the LM statistic (\(\sum_{i<j}\hat{\rho}_{ij}^2\)) is preferred.
Pesaran Cross-Sectional Dependence Test — Code
Code
pcdtest(twfe_plm, test ="cd")
Pesaran CD test for cross-sectional dependence in panels
data: y ~ x1 + x2 + treat
z = -2.2175, p-value = 0.02659
alternative hypothesis: cross-sectional dependence
Code
# Manual CD test on TWFE residualsresid_arr = twfe_lm.resids.to_numpy().reshape(200, 10) # N × Trhos = []for i inrange(200):for j inrange(i+1, 200): r = np.corrcoef(resid_arr[i], resid_arr[j])[0,1]ifnot np.isnan(r): rhos.append(r)T_i =10CD_stat = np.sqrt(2*T_i / (200*199)) * np.sum(rhos)from scipy.stats import norm as norm_distp_cd =float(2*(1- norm_dist.cdf(abs(CD_stat))))print(f"Pesaran CD = {CD_stat:.4f}, p = {p_cd:.4f}")
where \(\hat{\sigma}_i^2 = \frac{1}{T}\sum_t \hat{\varepsilon}_{it}^2\) is the within-unit residual variance and \(\hat{\sigma}^2 = \frac{1}{N}\sum_i \hat{\sigma}_i^2\).
Reject when:\(p\)-value \(< 0.05\) → heteroskedastic errors → use heteroskedasticity-robust (or cluster-robust) standard errors.
Tip
In practice, clustering by unit absorbs both serial correlation and heteroskedasticity: vce(cluster id) in Stata; vcov={"CRV1": "id"} in pyfixest; vcovCL(model, cluster=~id) in R.
Heteroskedasticity Test — Code
Code
# Breusch-Pagan test on FE residuals (auxiliary regression of ε² on regressors)resid_twfe <-residuals(twfe_plm)df_het <- df %>%mutate(resid_sq = resid_twfe^2)bptest(twfe_plm, studentize =FALSE)
Breusch-Pagan test
data: twfe_plm
BP = 22.125, df = 3, p-value = 6.143e-05
Code
# Modified Wald: test H0: σ₁² = σ₂² = ... = σ_N²sigma2_i = np.array([resid_arr[i].var() for i inrange(200)])sigma2 = sigma2_i.mean()W_wald =float(200* np.sum((sigma2_i - sigma2)**2) / (2* sigma2**2))p_wald =float(1- chi2_dist.cdf(W_wald, df=200))print(f"Modified Wald χ²(200) = {W_wald:.4f}, p = {p_wald:.4f}")
Modified Wald test for groupwise heteroskedasticity
in fixed effect regression model
H0: sigma(i)^2 = sigma^2 for all i
chi2 (200) = 2087.60
Prob > chi2 = 0.0000
Test Summary
TWFE Diagnostic Test Results
Test
H0
Statistic
p-value
Decision
F-test: entity FE
All αᵢ = 0
10.872
<2e-16
Reject → entity FE needed
F-test: time FE
All λₜ = 0
443.344
<2e-16
Reject → time FE needed
Serial correlation (BG)
No serial correlation
17.512
2.85e-05
Reject → cluster SEs
Pesaran CD
Cross-sect. independence
-2.218
0.0266
Do not reject → no CD
Heteroskedasticity (BP)
Homoskedasticity
22.125
6.14e-05
Reject → robust SEs
Important
The simulation confirms: both entity and time FEs are necessary, and clustering by unit is warranted. Use feols(... | id + time, cluster = ~id).
Variation: Clustered Standard Errors
OLS/FE SEs assume iid errors. In panels, errors within a unit are serially correlated. Cluster-robust SEs (Liang & Zeger 1986) correct for within-unit dependence:
for v, lbl in {"treat":"Treatment (D)","x1":"x\u2081","x2":"x\u2082"}.items(): rc =f"{lbl:<22}"; rs =f"{'':22}"for _, m in models: c, s, p = m.coef(), m.se(), m.pvalue() rc +=f"{c[v]:>14.4f}{_stars(p[v]):>6}" rs +=f"{'('+f'{s[v]:.4f}'+')':>{col_w}}"print(rc);print(rs)
(1,000 missing values generated)
(1,000 missing values generated)
(MWFE estimator converged in 2 iterations)
note: 10.rel_time_s omitted because of collinearity
note: 11.rel_time_s omitted because of collinearity
note: 12.rel_time_s omitted because of collinearity
HDFE Linear regression Number of obs = 1,000
Absorbing 2 HDFE groups F( 11, 99) = 1263.84
Statistics robust to heteroskedasticity Prob > F = 0.0000
R-squared = 0.9752
Adj R-squared = 0.9718
Within R-sq. = 0.9061
Number of clusters (id) = 100 Root MSE = 0.4985
(Std. err. adjusted for 100 clusters in id)
------------------------------------------------------------------------------
| Robust
y | Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
x1 | 1.524762 .0155246 98.22 0.000 1.493957 1.555566
x2 | .8153061 .0153418 53.14 0.000 .7848647 .8457475
|
rel_time_s |
0 | -.4984414 .3761445 -1.33 0.188 -1.244794 .2479108
1 | -.2115103 .2732895 -0.77 0.441 -.7537758 .3307553
2 | -.1084427 .1114919 -0.97 0.333 -.3296667 .1127813
3 | -.3323009 .2957002 -1.12 0.264 -.9190342 .2544324
4 | -.2506603 .1872804 -1.34 0.184 -.6222652 .1209446
6 | -.1329394 .2113016 -0.63 0.531 -.5522076 .2863289
7 | .0401692 .1113394 0.36 0.719 -.1807522 .2610907
8 | -.0828773 .07248 -1.14 0.256 -.2266933 .0609388
9 | -.0635272 .1282498 -0.50 0.621 -.3180027 .1909482
10 | 0 (omitted)
11 | 0 (omitted)
12 | 0 (omitted)
|
_cons | 2.775178 .1087145 25.53 0.000 2.559464 2.990891
------------------------------------------------------------------------------
Absorbed degrees of freedom:
-----------------------------------------------------+
Absorbed FE | Categories - Redundant = Num. Coefs |
-------------+---------------------------------------|
id | 100 100 0 *|
time | 10 1 9 |
-----------------------------------------------------+
* = FE nested within cluster; treated as redundant for DoF computation
Variation: Staggered DiD — Bacon Decomposition
The problem (Goodman-Bacon 2021): with staggered treatment adoption, the TWFE estimator is a weighted average of all possible 2×2 DiD comparisons — some with negative weights when early-treated units serve as controls for later-treated units.
where the weights \(w_{kl}\) can be negative, and \(\hat{\delta}_{kl}\) is the 2×2 DiD between timing groups \(k\) and \(l\).
The Bacon decomposition identifies and separates each 2×2 component.
Where staggered adoption goes next. When the negative weights cannot be argued away, two companion decks take over. Synthetic Control & Matrix Completions builds a bespoke counterfactual per treated unit instead of averaging 2×2 comparisons; Causal Machine Learning in Econometrics keeps the DiD structure but estimates the nuisance functions with ML, via did and DML.
Code
df_stag <- df %>%mutate(D_stag =as.integer(first_treat >0& time >= first_treat))bacon_res <-bacon(y ~ D_stag, data = df_stag,id_var ="id", time_var ="time")
type weight avg_est
1 Earlier vs Later Treated 0.08108 -0.23822
2 Later vs Earlier Treated 0.10811 0.10409
3 Treated vs Untreated 0.81081 1.40644
Code
ggplot(bacon_res, aes(x = weight, y = estimate, colour = type)) +geom_point(size =4, alpha =0.85) +geom_hline(yintercept =2.0, linetype ="dashed",colour = col_muted, linewidth =0.9) +geom_hline(yintercept =0, linetype ="solid",colour ="grey80", linewidth =0.6) +scale_colour_manual(values =c(col_main, col_accent, col_ok, col_warn)) +labs(title ="Goodman-Bacon Decomposition",subtitle ="Each point is a 2\u00d72 DiD comparison | dashed line = true \u03b2 = 2.0",x ="Weight",y ="2\u00d72 DiD Estimate",colour ="Comparison type" ) +theme(plot.title =element_text(size =16, face ="bold"),plot.subtitle =element_text(size =12, colour ="grey40"),axis.title =element_text(size =13),axis.text =element_text(size =12),legend.text =element_text(size =11),legend.title =element_text(size =12, face ="bold") )
Code
# Manual Bacon decomposition: enumerate all 2x2 DiD comparisonsdf_stag_py = pd.read_csv("../data/panel-TWFE-stag.csv")cohorts =sorted(df_stag_py.query("first_treat > 0")["first_treat"].unique())never_py = df_stag_py.query("first_treat == 0")n_total = df_stag_py["id"].nunique()records = []# 1. Each cohort vs never-treatedfor g in cohorts: grp = df_stag_py.query("first_treat == @g") n_g = grp["id"].nunique() pre_t = grp.query("time < @g")["y"].mean() post_t = grp.query("time >= @g")["y"].mean() pre_c = never_py.query("time < @g")["y"].mean() post_c = never_py.query("time >= @g")["y"].mean() records.append({"comparison": f"g={g} vs never","estimate": (post_t - pre_t) - (post_c - pre_c),"weight": n_g / n_total,"type": "Treated vs Never-Treated"})# 2. Early vs late cohort (early as treated, late as control for early period)for i, g1 inenumerate(cohorts):for g2 in cohorts[i+1:]: g1_grp = df_stag_py.query("first_treat == @g1") g2_grp = df_stag_py.query("first_treat == @g2")# post for g1 = [g1, g2); control = not-yet-treated g2 units post_t = g1_grp.query("@g1 <= time < @g2")["y"].mean() pre_t = g1_grp.query("time < @g1")["y"].mean() post_c = g2_grp.query("@g1 <= time < @g2")["y"].mean() pre_c = g2_grp.query("time < @g1")["y"].mean()ifnotany(np.isnan([post_t, pre_t, post_c, pre_c])): n_pair = (g1_grp["id"].nunique() + g2_grp["id"].nunique()) records.append({"comparison": f"g={g1} vs g={g2}","estimate": (post_t - pre_t) - (post_c - pre_c),"weight": n_pair / n_total,"type": "Early vs Late Treated"})bacon_df = pd.DataFrame(records)wt_avg = (bacon_df["estimate"] * bacon_df["weight"]).sum() / bacon_df["weight"].sum()print(bacon_df.round(4).to_string(index=False))
comparison estimate weight type
g=4 vs never 1.8173 0.3333 Treated vs Never-Treated
g=8 vs never 2.0763 0.3333 Treated vs Never-Treated
g=4 vs g=8 1.8811 0.6667 Early vs Late Treated
Code
print(f"\nWeighted average ATT \u2248{wt_avg:.4f} (true \u03b2 = 2.0)")
Weighted average ATT ≈ 1.9140 (true β = 2.0)
Code
palette = {"Treated vs Never-Treated": "#185FA5", "Early vs Late Treated": "#D85A30"}fig, ax = plt.subplots(figsize=(10, 4.5))for tp, col in palette.items(): sub = bacon_df[bacon_df["type"] == tp] ax.scatter(sub["weight"], sub["estimate"], color=col, s=90, label=tp, zorder=3, alpha=0.85);ax.axhline(2.0, linestyle="--", color="#6c757d", linewidth=1.2, label="True \u03b2 = 2.0");ax.set_xlabel("Weight", fontsize=12); ax.set_ylabel("2\u00d72 DiD Estimate", fontsize=12);ax.set_title("Goodman-Bacon Decomposition", fontsize=13, fontweight="bold");ax.legend(fontsize=10); ax.grid(alpha=0.3);plt.tight_layout(); plt.show()
Code
quietly import delimited "../data/panel-TWFE-stag.csv", clearquietlydestring_all, replacequietly xtset id timegen D_stag = (first_treat > 0 & time >= first_treat)bacondecomp y D_stag, ddetail
Computing decomposition across 3 timing groups
including a never-treated group
------------------------------------------------------------------------------
y | Coefficient Std. err. z P>|z| [95% conf. interval]
-------------+----------------------------------------------------------------
D_stag | 1.991037 .098514 20.21 0.000 1.797953 2.184121
------------------------------------------------------------------------------
Bacon Decomposition
+---------------------------------------------------+
| | Beta TotalWeight |
|----------------------+----------------------------|
| Early_v_Late | 1.881131291 .1276595728 |
| Late_v_Early | 2.142217398 .2127659547 |
| Never_v_timing | 1.963540906 .6595744725 |
+---------------------------------------------------+
Variation: Callaway-Sant’Anna Estimator
Motivation: obtain heterogeneity-robust DiD estimates by computing group-time average treatment effects\(ATT(g,t)\) using only clean controls (never-treated or not-yet-treated).
Call:
aggte(MP = cs_fit, type = "simple")
Reference: Callaway, Brantly and Pedro H.C. Sant'Anna. "Difference-in-Differences with Multiple Time Periods." Journal of Econometrics, Vol. 225, No. 2, pp. 200-230, 2021. <https://doi.org/10.1016/j.jeconom.2020.12.001>, <https://arxiv.org/abs/1803.09015>
ATT Std. Error [ 95% Conf. Int.]
0.7405 0.229 0.2916 1.1894 *
---
Signif. codes: `*' confidence band does not cover 0
Control Group: Never Treated, Anticipation Periods: 0
Estimation Method: Outcome Regression
Code
# Manual ATT(g,t): DiD between each cohort and never-treated, using t-1 as basedf_cs_py = pd.read_csv("../data/panel-TWFE-stag.csv")cohorts_cs =sorted(df_cs_py.query("first_treat > 0")["first_treat"].unique())never_cs = df_cs_py.query("first_treat == 0")att_records = []for g in cohorts_cs: grp = df_cs_py.query("first_treat == @g") t_pre =int(g) -1# last pre-treatment period (clean baseline)for t insorted(df_cs_py.query("time >= @g")["time"].unique()): y_post_t = grp.query("time == @t")["y"].mean() y_pre_t = grp.query("time == @t_pre")["y"].mean() y_post_c = never_cs.query("time == @t")["y"].mean() y_pre_c = never_cs.query("time == @t_pre")["y"].mean() att_records.append({"g": int(g), "t": int(t),"att_gt": (y_post_t - y_pre_t) - (y_post_c - y_pre_c)})att_gt_df = pd.DataFrame(att_records)att_simple = att_gt_df["att_gt"].mean()print("ATT(g,t) — never-treated comparison, baseline t\u22121:")
Do harsher sanctions deter crime? Cornwell & Trumbull (1994) study deterrence using county-level data from North Carolina. The challenge: counties differ in unobserved ways (culture, geography, policing quality), and state-wide policy changes affect all counties simultaneously. TWFE controls for both.
Data description
Feature
Value
Source
Wooldridge crime4 dataset
Units
90 North Carolina counties
Periods
7 years (1981–1987)
Outcome
crmrte — crimes per person
Key regressors
prbarr, prbconv, avgsen, polpc
Why TWFE matters here
\(\alpha_i\) absorbs: persistent county crime culture, geographic isolation, historic policing capacity — all stable within the 1981–1987 window and impossible to measure.
\(\lambda_t\) absorbs: national crime trends, federal crime bills (1984, 1986), macroeconomic cycles that affect all NC counties simultaneously.
Models estimated:
Pooled OLS — biased (\(\alpha_i\) and \(\lambda_t\) omitted)
Entity FE only — time shocks uncontrolled
TWFE — the recommended specification
Example 1: crime4 — Diagnostics
Two plots motivate identification before we look at any estimates: (1) is there enough within-county variation in the outcome for TWFE to work? (2) do high- and low-enforcement counties move in parallel — the visual precondition for common trends?
Code
library(ggdist)# ── Plot 1: raincloud of log crime rate by year ───────────────────────────────# Half-violin (density shape) + thin boxplot + jittered dots.# Wide density and scattered points = ample cross-county variance each year;# the time FE λ_t will absorb the common level shift across years.p1 <- crime4 %>%mutate(yr =factor(year)) %>%ggplot(aes(x = yr, y = lcrmrte, fill = yr, colour = yr)) +stat_halfeye(adjust =0.6, width =0.5, justification =-0.18,.width =0, point_colour =NA, alpha =0.65) +geom_boxplot(width =0.1, outlier.shape =NA,fill ="white", alpha =0.7, colour ="grey30") +stat_dots(side ="left", justification =1.12, binwidth =NA, overflow ="compress", alpha =0.35) +scale_fill_manual(values =rep(col_main, 7), guide ="none") +scale_colour_manual(values =rep(col_main, 7), guide ="none") +labs(title ="Distribution of Log Crime Rate by Year",subtitle ="Half-violin = density | dots = counties | box = IQR",x ="Year", y ="log(crmrte)") +theme(plot.subtitle =element_text(size =10, colour = col_muted))# ── Plot 2: parallel-trends check — high vs low enforcement ──────────────────# Counties are split ONCE at their sample-average prbarr (probability of arrest).# Both groups must move together before any policy shift; divergence afterwards# is consistent with a deterrence effect.crime4_grp <- crime4 %>%group_by(county) %>%mutate(hi =mean(prbarr) >median(crime4$prbarr)) %>%ungroup() %>%mutate(grp =if_else(hi, "High enforcement (prbarr > median)","Low enforcement (prbarr ≤ median)")) %>%group_by(grp, year) %>%summarise(mean_y =mean(lcrmrte), .groups ="drop")p2 <- crime4_grp %>%ggplot(aes(x = year, y = mean_y, colour = grp, group = grp)) +geom_line(linewidth =1.3) +geom_point(size =3.2) +scale_colour_manual(values =c(col_main, col_accent)) +labs(title ="Mean Log Crime Rate: High vs Low Enforcement Counties",subtitle ="Parallel trends support TWFE's common-trend assumption",x ="Year", y ="Mean log(crmrte)", colour =NULL) +theme(legend.position ="bottom",plot.subtitle =element_text(size =10, colour = col_muted))p1 + p2
Code
import pandas as pd, numpy as np, matplotlib.pyplot as pltfrom scipy.stats import gaussian_kde# ── Raincloud helper ──────────────────────────────────────────────────────────def raincloud(ax, arr, pos, color, width=0.28, dot_alpha=0.18, dot_size=4):"""Half-violin (right) + jitter dots (left) + IQR strip (centre).""" arr = np.asarray(arr)[~np.isnan(np.asarray(arr))] kde = gaussian_kde(arr, bw_method=0.28) ygrid = np.linspace(arr.min(), arr.max(), 200) xkde = kde(ygrid) / kde(ygrid).max() * width ax.fill_betweenx(ygrid, pos, pos + xkde, # right half-violin; alpha=0.55, color=color); jit = np.random.uniform(-width *0.45, 0, len(arr)) ax.scatter(pos + jit, arr, alpha=dot_alpha, # left jittered dots; s=dot_size, color=color, zorder=2); q25, med, q75 = np.percentile(arr, [25, 50, 75]) ax.vlines(pos, q25, q75, color="k", linewidth=2.5, zorder=3) # IQR; ax.scatter(pos, med, color="white", s=45, zorder=4, # median; edgecolors="k", linewidth=1.2);# ── Data ──────────────────────────────────────────────────────────────────────c4 = pd.read_csv("https://vincentarelbundock.github.io/Rdatasets/csv/wooldridge/crime4.csv")c4["lcrmrte"] = np.log(c4["crmrte"])np.random.seed(42)fig, axes = plt.subplots(1, 2, figsize=(12, 4.8))# ── Plot 1: raincloud of log crime rate by year ───────────────────────────────# Each raincloud shows the full cross-county distribution for one year.# Consistent width across years means heterogeneity is stable —# the entity FE α_i captures this persistent spread.years_c =sorted(c4["year"].unique())for i, yr inenumerate(years_c): raincloud(axes[0], c4.loc[c4["year"]==yr, "lcrmrte"], i, "#185FA5")axes[0].set_xticks(range(len(years_c))); axes[0].set_xticklabels(years_c);axes[0].set_xlabel("Year"); axes[0].set_ylabel("log(crmrte)");axes[0].set_title("Distribution of Log Crime Rate by Year", fontsize=11, fontweight="bold");# ── Plot 2: parallel trends — high vs low enforcement ────────────────────────# Counties classified by sample-average prbarr (probability of arrest).# Parallel movement across years validates the TWFE common-trend assumption.med_arr = c4.groupby("county")["prbarr"].mean().median()grp_map = (c4.groupby("county")["prbarr"].mean() > med_arr) \ .map({True:"High enforcement", False:"Low enforcement"})c4["grp"] = c4["county"].map(grp_map)for grp, col, ls in [("High enforcement","#185FA5","-"), ("Low enforcement", "#D85A30","--")]: mn = c4[c4["grp"]==grp].groupby("year")["lcrmrte"].mean() axes[1].plot(mn.index, mn.values, marker="o", color=col, linestyle=ls, linewidth=1.8, label=grp);axes[1].set_xlabel("Year"); axes[1].set_ylabel("Mean log(crmrte)");axes[1].set_title("Mean Log Crime: High vs Low Enforcement", fontsize=11, fontweight="bold");axes[1].legend(fontsize=10); axes[1].grid(axis="y", alpha=0.3);plt.tight_layout(); plt.show()
Code
use"http://fmwww.bc.edu/ec-p/data/wooldridge/crime4.dta", clearcap drop lcrmrtegen lcrmrte = log(crmrte)quietly xtset county year* ── Plot 1: distribution oflog crime rate byyear ───────────────────────────* Box plot across counties per year. Wide boxes = ample cross-county variance;* the entity FE α_i absorbs persistent county-level differences.graph box lcrmrte, over(year, label(angle(45))) /// box(1, fcolor(navy%40) lcolor(navy)) ///title("Distribution of Log Crime Rate by Year") ///ytitle("log(crmrte)")cap mkdir plotsquietlygraphexport"../plots/crime4-diag1-stata.png", replacewidth(800)* ── Plot 2: parallel trends — high vs low enforcement ────────────────────────* Counties above mediansample-average prbarr = "high enforcement".* Parallel slopes before any policy change validate TWFE's key assumption.preservebysort county: egen mean_prbarr = mean(prbarr)quietlysummarize mean_prbarr, detailgen hi = (mean_prbarr > r(p50))collapse (mean) mean_lcrmrte = lcrmrte, by(hi year)twoway (line mean_lcrmrte yearif hi==1, lcolor(navy) lwidth(medthick)) /// (line mean_lcrmrte yearif hi==0, lcolor(orange_red) lwidth(medthick) lpattern(dash)), ///legend(order(1 "High enforcement" 2 "Low enforcement") rows(1)) ///title("Mean Log Crime: High vs Low Enforcement Counties") ///xtitle("Year") ytitle("Mean log(crmrte)") xlabel(81(1)87)quietlygraphexport"../plots/crime4-diag2-stata.png", replacewidth(800)restore
Interpretation. The TWFE estimate for prbarr of -0.333 implies that a 10 pp increase in the probability of arrest is associated with a 332.6% change in the crime rate (ceteris paribus, within county, net of year shocks). All four deterrence variables carry the theoretically predicted negative sign in TWFE — confirming that deterrence works once county-level and time confounders are removed.
County Crime: Deterrence Effects
------------------------------------------------------------
(1) (2) (3)
Pooled OLS Entity FE TWFE
------------------------------------------------------------
prbarr -1.8176*** -0.3510*** -0.3326***
(0.1172) (0.0659) (0.1220)
prbconv -0.0975*** -0.0279*** -0.0258***
(0.0127) (0.0052) (0.0084)
avgsen -0.0014 0.0081*** 0.0018
(0.0072) (0.0030) (0.0043)
polpc 54.8167*** 42.7587*** 45.1324***
(8.2032) (4.3079) (3.0238)
------------------------------------------------------------
N 630 630 630
R² within 0.176
------------------------------------------------------------
Standard errors in parentheses
* p<0.10, ** p<0.05, *** p<0.01
Example 1: crime4 — Robustness
Code
twfe_plm_c <-plm(lcrmrte ~ prbarr + prbconv + avgsen + polpc,data = pdf_c, model ="within", effect ="twoways")# F-tests and serial correlationpFtest(fe_c, ols_c) # entity FE jointly significant?
F test for individual effects
data: lcrmrte ~ prbarr + prbconv + avgsen + polpc
F = 52.035, df1 = 89, df2 = 536, p-value < 2.2e-16
alternative hypothesis: significant effects
Code
pFtest(twfe_plm_c, fe_c) # time FE jointly significant?
F test for twoways effects
data: lcrmrte ~ prbarr + prbconv + avgsen + polpc
F = 13.774, df1 = 6, df2 = 530, p-value = 1.425e-14
alternative hypothesis: significant effects
Code
pbgtest(twfe_plm_c, order =1) # serial correlation in errors?
Breusch-Godfrey/Wooldridge test for serial correlation in panel models
data: lcrmrte ~ prbarr + prbconv + avgsen + polpc
chisq = 22.795, df = 1, p-value = 1.803e-06
alternative hypothesis: serial correlation in idiosyncratic errors
Code
# SE robustness: iid vs county-clusteredtwfe_se <-feols(lcrmrte ~ prbarr + prbconv + avgsen + polpc | county + year, data = crime4)twfe_cl <-feols(lcrmrte ~ prbarr + prbconv + avgsen + polpc | county + year,data = crime4, cluster =~county)modelsummary(list("TWFE (iid SE)"= twfe_se, "TWFE (cluster county)"= twfe_cl),coef_map =c("prbarr"="Prob. arrest","prbconv"="Prob. conviction","avgsen"="Avg. sentence","polpc"="Police p.c."),stars =TRUE, gof_omit ="IC|Log|RMSE",title ="Robustness: Standard Errors")
Robustness: Standard Errors
--------------------------------------------
(1) (2)
iid SE Cluster(co~)
--------------------------------------------
prbarr -0.3326*** -0.3326***
(0.0619) (0.1220)
prbconv -0.0258*** -0.0258***
(0.0049) (0.0084)
avgsen 0.0018 0.0018
(0.0031) (0.0043)
polpc 45.1324*** 45.1324***
(4.0441) (3.0238)
--------------------------------------------
N 630 630
--------------------------------------------
Standard errors in parentheses
* p<0.10, ** p<0.05, *** p<0.01
Modified Wald test for groupwise heteroskedasticity
in fixed effect regression model
H0: sigma(i)^2 = sigma^2 for all i
chi2 (90) = 10053.99
Prob > chi2 = 0.0000
Example 2: Wage Panel — wagepan
Idea & Motivation
What is the wage premium for union membership after controlling for individual ability? OLS overstates the union premium because high-ability workers self-select into unions. TWFE isolates within-individual changes in union status from changes in wages, eliminating the ability bias.
Data description
Feature
Value
Source
Wooldridge wagepan (Vella & Verbeek 1998)
Units
545 males (NLSY)
Periods
8 years (1980–1987)
Outcome
lwage — log hourly wage
Key regressors
union (union status), married, exper, expersq, educ
Note: educ is time-invariant — it is absorbed by entity FE \(\alpha_i\) and cannot be estimated by TWFE. Year dummies \(\lambda_t\) absorb economy-wide wage growth.
Warning
TWFE identifies the within-person union premium. The between-person OLS estimate is confounded by ability bias.
Example 2: wagepan — Diagnostics
Two plots motivate identification: (1) is there enough within-person switching in union status to identify a wage effect? (2) do workers with different union histories show parallel wage trends — validating TWFE’s common-trend assumption?
Code
library(ggdist)data("wagepan", package ="wooldridge")# ── Plot 1: raincloud of log wage by union status ────────────────────────────# Compare the full wage distribution for union (1) vs non-union (0) workers.# A rightward shift for union workers is the cross-sectional signal — but# this conflates the causal wage premium with ability selection (α_i).# TWFE will purge that selection by using only within-person variation.p1 <- wagepan %>%mutate(status =if_else(union ==1, "Union", "Non-union")) %>%ggplot(aes(x = status, y = lwage, fill = status, colour = status)) +stat_halfeye(adjust =0.6, width =0.5, justification =-0.18,.width =0, point_colour =NA, alpha =0.65) +geom_boxplot(width =0.1, outlier.shape =NA,fill ="white", alpha =0.7, colour ="grey30") +stat_dots(side ="left", justification =1.12, binwidth =NA, overflow ="compress", alpha =0.25) +scale_fill_manual(values =c("Union"= col_main, "Non-union"= col_accent),guide ="none") +scale_colour_manual(values =c("Union"= col_main, "Non-union"= col_accent),guide ="none") +labs(title ="Log Wage Distribution by Union Status",subtitle ="Cross-sectional gap conflates premium with ability bias — TWFE fixes this",x =NULL, y ="log(wage)") +theme(plot.subtitle =element_text(size =10, colour = col_muted))# ── Plot 2: mean log wage by union history over time ─────────────────────────# Classify workers as: Always union / Switcher / Never union.# Parallel pre-trends across all three groups validate the within-person design.# The key identifying group is "Switcher" — workers who change union status.wagepan_grp <- wagepan %>%group_by(nr) %>%mutate(grp =case_when(all(union ==1) ~"Always union",all(union ==0) ~"Never union",TRUE~"Switcher" )) %>%ungroup() %>%group_by(grp, year) %>%summarise(mean_lwage =mean(lwage), .groups ="drop")p2 <- wagepan_grp %>%ggplot(aes(x = year, y = mean_lwage, colour = grp, group = grp)) +geom_line(linewidth =1.3) +geom_point(size =3) +scale_colour_manual(values =c("Always union"= col_main,"Never union"= col_muted,"Switcher"= col_accent)) +labs(title ="Mean Log Wage by Union History",subtitle ="Parallel slopes pre-switch support TWFE | switchers = identifying variation",x ="Year", y ="Mean log wage", colour =NULL) +theme(legend.position ="bottom",plot.subtitle =element_text(size =10, colour = col_muted))p1 + p2
Code
import pandas as pd, numpy as np, matplotlib.pyplot as pltfrom scipy.stats import gaussian_kdedef raincloud(ax, arr, pos, color, width=0.28, dot_alpha=0.15, dot_size=3): arr = np.asarray(arr)[~np.isnan(np.asarray(arr))] kde = gaussian_kde(arr, bw_method=0.28) ygrid = np.linspace(arr.min(), arr.max(), 200) xkde = kde(ygrid) / kde(ygrid).max() * width ax.fill_betweenx(ygrid, pos, pos + xkde, alpha=0.55, color=color); jit = np.random.uniform(-width *0.45, 0, len(arr)) ax.scatter(pos + jit, arr, alpha=dot_alpha, s=dot_size, color=color, zorder=2); q25, med, q75 = np.percentile(arr, [25, 50, 75]) ax.vlines(pos, q25, q75, color="k", linewidth=2.5, zorder=3); ax.scatter(pos, med, color="white", s=45, zorder=4, edgecolors="k", linewidth=1.2);# ── Data ──────────────────────────────────────────────────────────────────────# The cross-sectional wage gap between union and non-union workers is visible# in Plot 1 — but it mixes the causal premium with ability (α_i) selection.wp = pd.read_csv("https://vincentarelbundock.github.io/Rdatasets/csv/wooldridge/wagepan.csv")wp = wp.dropna(subset=["lwage","union"])np.random.seed(42)fig, axes = plt.subplots(1, 2, figsize=(12, 4.8))# ── Plot 1: raincloud of log wage by union status ────────────────────────────for i, (label, val, col) inenumerate([("Non-union", 0, "#D85A30"), ("Union", 1, "#185FA5")]): raincloud(axes[0], wp.loc[wp["union"]==val, "lwage"], i, col)axes[0].set_xticks([0,1]); axes[0].set_xticklabels(["Non-union","Union"]);axes[0].set_ylabel("log(wage)");axes[0].set_title("Log Wage Distribution by Union Status", fontsize=11, fontweight="bold");# ── Plot 2: mean log wage by union history ────────────────────────────────────# Workers classified as Always/Never/Switcher.# Parallel trends across groups validate the within-person TWFE design.always = wp.groupby("nr")["union"].transform("min") ==1never = wp.groupby("nr")["union"].transform("max") ==0wp["grp"] = np.where(always, "Always union", np.where(never, "Never union", "Switcher"))palette = {"Always union":"#185FA5", "Never union":"#3D4B6B", "Switcher":"#D85A30"}for grp, col in palette.items(): mn = wp[wp["grp"]==grp].groupby("year")["lwage"].mean() ls ="--"if grp =="Never union"else"-" axes[1].plot(mn.index, mn.values, marker="o", color=col, linestyle=ls, linewidth=1.6, label=grp);axes[1].set_xlabel("Year"); axes[1].set_ylabel("Mean log wage");axes[1].set_title("Mean Log Wage by Union History", fontsize=11, fontweight="bold");axes[1].legend(fontsize=9); axes[1].grid(axis="y", alpha=0.3);plt.tight_layout(); plt.show()
Code
use"http://fmwww.bc.edu/ec-p/data/wooldridge/wagepan.dta", clearquietly xtset nryear* ── Plot 1: distribution oflog wage byunion status ─────────────────────────* The cross-sectional wage gap between union and non-union workers is visible,* but it conflates the causal premium with ability selection (α_i).* TWFE uses only within-person union changes to isolate the causal effect.graph box lwage, over(union, relabel(1 "Non-union" 2 "Union")) /// box(1, fcolor(orange_red%40) lcolor(orange_red)) /// box(2, fcolor(navy%40) lcolor(navy)) ///title("Log Wage Distribution by Union Status") ytitle("log(wage)")cap mkdir plotsquietlygraphexport"../plots/wagepan-diag1-stata.png", replacewidth(800)* ── Plot 2: meanlog wage byunion history ────────────────────────────────────* Parallel wage trends among always-union, never-union, and switchers* are the visual precondition for TWFE credibility.preservebysortnr: egen min_u = min(union)bysortnr: egen max_u = max(union)gen grp = cond(min_u==1, 1, cond(max_u==0, 2, 3))collapse (mean) mean_lwage = lwage, by(grp year)twoway (line mean_lwage yearif grp==1, lcolor(navy) lwidth(medthick)) /// (line mean_lwage yearif grp==2, lcolor(dkgreen) lwidth(medthick) lpattern(dash)) /// (line mean_lwage yearif grp==3, lcolor(orange_red) lwidth(medthick)), ///legend(order(1 "Always union" 2 "Never union" 3 "Switcher") rows(1)) ///title("Mean Log Wage by Union History") xtitle("Year") ytitle("Mean log wage")quietlygraphexport"../plots/wagepan-diag2-stata.png", replacewidth(800)restore
Example 2: wagepan — Estimation & Results
Code
pdf_w <-pdata.frame(wagepan, index =c("nr","year"))ols_w <-lm(lwage ~ union + married + exper + expersq + black + hisp + educ, data = wagepan)fe_w <-plm(lwage ~ union + married + exper + expersq, data = pdf_w,model ="within", effect ="individual")twfe_w <-feols(lwage ~ union + married + exper + expersq | nr + year,data = wagepan, cluster =~nr)modelsummary(list("Pooled OLS"= ols_w, "Entity FE"= fe_w, "TWFE"= twfe_w),coef_map =c("union"="Union member","married"="Married","exper"="Experience","expersq"="Experience²","black"="Black","hisp"="Hispanic","educ"="Education"),stars =TRUE, gof_omit ="IC|Log|RMSE",title ="Wage Panel: Union Premium (NLSY 1980–1987)")
Wage Panel: Union Premium (NLSY 1980–1987)
Pooled OLS
Entity FE
TWFE
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001
Union member
0.180***
0.082***
0.080***
(0.017)
(0.019)
(0.023)
Married
0.108***
0.045*
0.047*
(0.016)
(0.018)
(0.021)
Experience
0.089***
0.117***
(0.010)
(0.008)
Experience²
-0.003***
-0.004***
-0.005***
(0.001)
(0.001)
(0.001)
Black
-0.144***
(0.024)
Hispanic
0.016
(0.021)
Education
0.099***
(0.005)
Num.Obs.
4360
4360
4360
R2
0.187
0.178
0.621
R2 Adj.
0.185
0.060
0.566
R2 Within
0.022
R2 Within Adj.
0.021
F
142.613
FE: nr
X
FE: year
X
Code
import pandas as pd, numpy as np, pyfixest as pfwp2 = pd.read_csv("https://vincentarelbundock.github.io/Rdatasets/csv/wooldridge/wagepan.csv")wp2 = wp2.dropna(subset=["lwage","union","married","exper","expersq"])twfe_w_py = pf.feols("lwage ~ union + married + exper + expersq | nr + year", data=wp2, vcov={"CRV1":"nr"})twfe_w_py.summary()
Interpretation. The TWFE estimate of 0.08 implies that switching into union membership raises the hourly wage by approximately 8.3% (\(e^{0.08}-1\)), net of economy-wide wage trends and individual fixed effects. The OLS estimate (19.7%) is inflated upward by the positive correlation between ability (\(\alpha_i\)) and union selection. The marriage premium (4.8%) reflects the within-person wage increase upon marriage — consistent with household economics theories.
Note
Time-invariant variables educ, black, and hisp are absorbed by \(\alpha_i\) and are not identified by TWFE. Their between-person effects are captured in the entity fixed effects.
Example 3: Airline Fares — airfare
Idea & Motivation
Do more concentrated airline routes have higher fares? Cross-sectional OLS confounds route-level cost differences (distance, hub dominance) with the concentration effect. TWFE exploits within-route variation in concentration over time — cleanly identifying the pricing effect of market power.
ldist is time-invariant (distance doesn’t change) → absorbed by route FE. Only concen and year dummies are identified.
Example 3: airfare — Diagnostics
Two plots motivate identification: (1) does market concentration (HHI) vary enough within routes over time to provide identifying variation? (2) do high- and low-concentration routes show parallel fare trends before deregulation effects kick in?
Code
library(ggdist)data("airfare", package ="wooldridge")pdf_a <-pdata.frame(airfare, index =c("id","year"))# ── Plot 1: raincloud of HHI concentration by year ───────────────────────────# Half-violin + dots + IQR shows the full distribution of HHI across routes.# If the distribution barely shifts year-to-year, within-route variation is# limited and identification is weak. Visible movement = credible design.p1 <- airfare %>%mutate(yr =factor(year)) %>%ggplot(aes(x = yr, y = concen, fill = yr, colour = yr)) +stat_halfeye(adjust =0.6, width =0.5, justification =-0.18,.width =0, point_colour =NA, alpha =0.65) +geom_boxplot(width =0.1, outlier.shape =NA,fill ="white", alpha =0.7, colour ="grey30") +stat_dots(side ="left", justification =1.12, binwidth =NA, overflow ="compress", alpha =0.18) +scale_fill_manual(values =rep(col_accent, 4), guide ="none") +scale_colour_manual(values =rep(col_accent, 4), guide ="none") +labs(title ="Distribution of Market Concentration (HHI) by Year",subtitle ="Within-route HHI shifts = identifying variation | year-FE absorbs common level changes",x ="Year", y ="Herfindahl–Hirschman Index") +theme(plot.subtitle =element_text(size =10, colour = col_muted))# ── Plot 2: mean log fare by concentration group over time ───────────────────# Routes above median first-year HHI = "high concentration".# A persistent level gap confirms the market-power price premium;# parallel slopes across years validate the common-trend assumption.med_hhi_yr1 <- airfare %>%filter(year ==min(year)) %>%pull(concen) %>%median()airfare_grp <- airfare %>%group_by(id) %>%mutate(hi = concen[year ==min(year)] > med_hhi_yr1) %>%ungroup() %>%mutate(grp =if_else(hi, "High concentration (HHI > median)","Low concentration (HHI ≤ median)")) %>%group_by(grp, year) %>%summarise(mean_lfare =mean(lfare), .groups ="drop")p2 <- airfare_grp %>%ggplot(aes(x = year, y = mean_lfare, colour = grp, group = grp)) +geom_line(linewidth =1.3) +geom_point(size =3.2) +scale_colour_manual(values =c(col_accent, col_main)) +labs(title ="Mean Log Fare: High vs Low Concentration Routes",subtitle ="Persistent gap = market-power premium | parallel slopes = common-trend",x ="Year", y ="Mean log fare", colour =NULL) +theme(legend.position ="bottom",plot.subtitle =element_text(size =10, colour = col_muted))p1 + p2
Code
import pandas as pd, numpy as np, matplotlib.pyplot as pltfrom scipy.stats import gaussian_kdedef raincloud(ax, arr, pos, color, width=0.28, dot_alpha=0.12, dot_size=3): arr = np.asarray(arr)[~np.isnan(np.asarray(arr))] kde = gaussian_kde(arr, bw_method=0.28) ygrid = np.linspace(arr.min(), arr.max(), 200) xkde = kde(ygrid) / kde(ygrid).max() * width ax.fill_betweenx(ygrid, pos, pos + xkde, alpha=0.55, color=color); jit = np.random.uniform(-width *0.45, 0, len(arr)) ax.scatter(pos + jit, arr, alpha=dot_alpha, s=dot_size, color=color, zorder=2); q25, med, q75 = np.percentile(arr, [25, 50, 75]) ax.vlines(pos, q25, q75, color="k", linewidth=2.5, zorder=3); ax.scatter(pos, med, color="white", s=45, zorder=4, edgecolors="k", linewidth=1.2);# ── Data ──────────────────────────────────────────────────────────────────────air = pd.read_csv("https://vincentarelbundock.github.io/Rdatasets/csv/wooldridge/airfare.csv")air = air.dropna(subset=["lfare","concen"])np.random.seed(14159)fig, axes = plt.subplots(1, 2, figsize=(12, 4.8))# ── Plot 1: raincloud of HHI by year ─────────────────────────────────────────# Each raincloud shows the route HHI distribution for one year.# Visible year-to-year movement in the body of the distribution confirms# there is within-route concentration variation for TWFE to exploit.years_a =sorted(air["year"].unique())for i, yr inenumerate(years_a): raincloud(axes[0], air.loc[air["year"]==yr, "concen"], i, "#D85A30")axes[0].set_xticks(range(len(years_a))); axes[0].set_xticklabels(years_a);axes[0].set_xlabel("Year"); axes[0].set_ylabel("HHI (Concentration)");axes[0].set_title("Distribution of Market Concentration (HHI) by Year", fontsize=11, fontweight="bold");# ── Plot 2: mean log fare by concentration group ──────────────────────────────# Routes above/below median first-year HHI compared over time.# Parallel slopes validate common trends; the level gap confirms the premium.yr0 = air["year"].min()med_hhi = air[air["year"]==yr0]["concen"].median()grp_map = (air[air["year"]==yr0].set_index("id")["concen"] > med_hhi) \ .map({True:"High concentration", False:"Low concentration"})air["grp"] = air["id"].map(grp_map)for grp, col, ls in [("High concentration","#D85A30","-"), ("Low concentration", "#185FA5","--")]: mn = air[air["grp"]==grp].groupby("year")["lfare"].mean() axes[1].plot(mn.index, mn.values, marker="o", color=col, linestyle=ls, linewidth=1.8, label=grp);axes[1].set_xlabel("Year"); axes[1].set_ylabel("Mean log fare");axes[1].set_title("Mean Log Fare: High vs Low Concentration Routes", fontsize=11, fontweight="bold");axes[1].legend(fontsize=9); axes[1].grid(axis="y", alpha=0.3);plt.tight_layout(); plt.show()
Code
use"http://fmwww.bc.edu/ec-p/data/wooldridge/airfare.dta", clearquietly xtset id year* ── Plot 1: distribution of HHI byyear ──────────────────────────────────────* Box plot of route HHI byyear. Any visible shiftmeans the year FE* absorbs aggregate concentration trends; within-route variation* (the identifying signal) remains after double-demeaning.graph box concen, over(year) /// box(1, fcolor(orange_red%40) lcolor(orange_red)) ///title("Distribution of Market Concentration (HHI) by Year") ytitle("HHI")cap mkdir plotsquietlygraphexport"../plots/airfare-diag1-stata.png", replacewidth(800)* ── Plot 2: meanlog fare by concentration group ─────────────────────────────* Routes above median first-year HHI = high concentration.* Parallel trends validate common-trend; persistent level gap = market-power premium.preservequietlysummarize concen ifyear == 1997, detailgen hi = (concen > r(p50)) ifyear == 1997bysort id: replace hi = hi[1]dropifmissing(hi)collapse (mean) mean_lfare = lfare, by(hi year)twoway (line mean_lfare yearif hi==1, lcolor(orange_red) lwidth(medthick)) /// (line mean_lfare yearif hi==0, lcolor(navy) lwidth(medthick) lpattern(dash)), ///legend(order(1 "High concentration" 2 "Low concentration") rows(1)) ///title("Mean Log Fare: High vs Low Concentration Routes") ///xtitle("Year") ytitle("Mean log fare")quietlygraphexport"../plots/airfare-diag2-stata.png", replacewidth(800)restore
Interpretation. Moving from a perfectly competitive route (HHI = 0) to a monopoly route (HHI = 1) is associated with a fare increase of approximately 18.4% (\(e^{0.169}-1\)), holding route characteristics and year shocks constant. The OLS estimate is larger in magnitude, reflecting the confounding effect of route distance and hub geography on both fares and concentration.
Summary across all three examples:
Example
Entity
Time
Key finding
crime4
County
Year
Deterrence reduces crime; OLS confounded by county culture
wagepan
Individual
Year
Union premium ≈10%; OLS overstates due to ability selection
airfare
Route
Year
Concentration raises fares ≈20%; distance confounding in OLS
Exercises — Estimation
Extend the DGP to include heterogeneous treatment effects: set \(\beta_i = 2 + 0.5\cdot\alpha_i\) (larger effects for high-FE units). Re-estimate with TWFE. Is \(\hat{\beta}^{TWFE}\) close to \(\bar{\beta} = 2\)? Why or why not?
Add a covariate endogenous to time: generate \(z_{it} = \lambda_t + w_{it}\) (correlated with the time FE). Show that entity-only FE is biased for \(z\)’s effect but TWFE is not.
Compare estimators on crime4: estimate three specifications — (a) entity FE only, (b) time FE only, (c) TWFE — and produce a coefficient plot comparing prbarr across specifications. Interpret any differences.
wagepan robustness: replicate the union wage premium using (a) first differences, (b) TWFE, and (c) the Mundlak correction. Compare estimates and discuss which identification strategy is most credible.
airfare event study: create a relative-time variable based on entry/exit of a low-cost carrier (use y98–y00 as pseudo-events). Run an event study specification and test for pre-trends.
Exercises — Testing
Simulate under \(H_0\) for F-tests: set all \(\lambda_t = 0\) in the DGP. Re-run the F-test for time FE. Does the test correctly fail to reject? Repeat 500 times and plot the empirical distribution of the \(F\)-statistic against \(F(9, 1791)\).
Serial correlation in crime4: run the Wooldridge test (xtserial / pbgtest) on the crime4 TWFE residuals. Compare the TWFE coefficients under iid SEs vs. clustered SEs. How large is the difference in standard errors?
Test for time FE on wagepan: the NLSY panel (1980–1987) spans a period of significant macro shifts (recessions, wage compression). Formally test whether year dummies are jointly significant. Interpret the result in terms of identification.
Bacon decomposition on wagepan: union status changes within individuals over time — a staggered adoption setting. Perform a Bacon decomposition. Are any 2×2 weights negative? What does this imply for the aggregate TWFE estimate?
Callaway-Sant’Anna vs TWFE on crime4: construct a first-treated year for each county (assume treatment is the introduction of a policing program at different times). Compare the simple aggregate ATT from CS to the TWFE estimate.