Goal: a headline coefficient is not credible until you've shown it survives reasonable variations. Stata has a particularly rich ecosystem here — boottest for wild-cluster bootstrap, ritest for randomization inference, rwolf for multiple-testing correction, bacondecomp for TWFE diagnosis, honestdid for parallel-trends sensitivity. Use them.
- Progressive specifications (M1 → M6) with
eststo+esttab - Alternative cluster levels (and two-way clustering)
- Wild cluster bootstrap (
boottest) — for few clusters - Subsample splits
- Alternative outcome / treatment definitions
- Alternative sample restrictions (winsorize, trim)
- Placebo — fake timing
- Placebo — randomization inference (
ritest) - Multiple-testing correction (
rwolf,wyoung) - Specification curve (loop over formulas, plot estimates)
- Oster (2019) δ* —
psacalc - TWFE bias diagnosis (
bacondecomp) - HonestDiD — Rambachan–Roth (2023) PT sensitivity
- Influence diagnostics — leave-one-out, drop top-K Cook's D
eststo clear
eststo m1: qui reg log_wage training, ///
vce(cluster firm_id)
eststo m2: qui reg log_wage training age edu, ///
vce(cluster firm_id)
eststo m3: qui reghdfe log_wage training age edu tenure, ///
absorb(worker_id) vce(cluster worker_id)
eststo m4: qui reghdfe log_wage training age edu tenure, ///
absorb(worker_id year) vce(cluster worker_id)
eststo m5: qui reghdfe log_wage training age edu tenure, ///
absorb(worker_id year region) vce(cluster worker_id)
eststo m6: qui reghdfe log_wage training age edu tenure, ///
absorb(worker_id year i.industry#i.year) vce(cluster worker_id)
esttab m1 m2 m3 m4 m5 m6 using "tables/table_main.tex", ///
replace booktabs ///
se star(* 0.10 ** 0.05 *** 0.01) ///
stats(N r2 r2_a, labels("N" "R²" "Adj. R²")) ///
label keep(training) ///
addnotes("All regressions cluster SE by worker_id.")foreach c in worker_id firm_id industry state {
quietly reghdfe log_wage training, ///
absorb(worker_id year) vce(cluster `c')
display "cluster=`c' b=" _b[training] " se=" _se[training] ///
" t=" _b[training]/_se[training]
}
* Two-way clustering
reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster worker_id firm_id)
* Three-way (rare; supported by reghdfe)
reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster worker_id firm_id state)When the number of clusters is small (< 50), classical CRSE under-cover. boottest is the gold standard.
* After reghdfe / reg
quietly reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster state)
boottest training, cluster(state) reps(9999) seed(42)
boottest training = 0.05, cluster(state) reps(9999) // test specific value
* Wild restricted ("WCR") and Webb weights
boottest training, weighttype(webb) reps(9999)
boottest training, nograph reps(9999) bootcluster(state worker_id)
* Confidence interval
boottest training, ci level(95) reps(9999)foreach mask in "female==0" "female==1" "age<40" "age>=40" ///
"industry==1" "industry==2" {
quietly reghdfe log_wage training if `mask', ///
absorb(worker_id year) vce(cluster worker_id)
display "`mask': b=" _b[training] " se=" _se[training] " N=" e(N)
}For testing heterogeneity (not just estimating subsamples), prefer interaction terms — see references/07.
* Alternative outcome forms
foreach y in log_wage ihs_wage wage_w1 wage_real_log {
quietly reghdfe `y' training, absorb(worker_id year) ///
vce(cluster worker_id)
display "`y': b=" _b[training] " se=" _se[training]
}
* Alternative treatment definitions
foreach t in training training_ever training_hours_log training_intense {
quietly reghdfe log_wage `t', absorb(worker_id year) ///
vce(cluster worker_id)
display "`t': b=" _b[`t'] " se=" _se[`t']
}* Winsorization sensitivity
foreach lvl in 0 1 5 {
preserve
if `lvl' > 0 {
winsor2 log_wage, cuts(`lvl' `=100-`lvl'') replace
}
quietly reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster worker_id)
display "winsor `lvl'/`=100-`lvl'': b=" _b[training] ///
" se=" _se[training]
restore
}
* Trim sensitivity
foreach trim in 0.01 0.05 {
preserve
quietly sum log_wage, detail
local lo = r(p`=`trim'*100')
local hi = r(p`=100-`trim'*100')
keep if log_wage >= `lo' & log_wage <= `hi'
quietly reghdfe log_wage training, absorb(worker_id year)
display "trim `=`trim'*100'%: b=" _b[training]
restore
}* Shift treatment 3 years earlier; should produce ~0
gen fake_first = first_treat - 3
gen fake_post = (year >= fake_first) if !missing(fake_first)
replace fake_post = 0 if missing(fake_first)
preserve
keep if year < first_treat // drop real post period
reghdfe log_wage fake_post, absorb(worker_id year) ///
vce(cluster worker_id)
restore
* For event studies: drop real post period, re-estimate pre-period coefs.
* All pre-period coefficients should be ~0.Permutation-based inference — re-shuffles treatment under the null, gives an exact p-value. Especially valuable when:
- Few clusters
- Treatment assignment was randomized
- You want a non-parametric placebo distribution
* After your headline regression, permute treatment and re-estimate
ritest training _b[training], reps(1000) seed(42) ///
strata(industry): reghdfe log_wage training, ///
absorb(worker_id year) vce(cluster worker_id)
* Two-tailed p-value, plus full distribution
ritest training _b[training], reps(1000) seed(42) ///
saving("logs/ritest_dist.dta", replace): ///
reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster worker_id)
* Plot the null distribution
preserve
use "logs/ritest_dist.dta", clear
histogram _b_training, bin(50) ///
xtitle("Permuted coefficient") ///
addplot(scatteri 0 `=_b_training_obs', mcolor(red))
graph export "figures/ritest_dist.pdf", replace
restore
* Permute *within* clusters (preserves cluster structure)
ritest training _b[training], reps(1000) cluster(state) seed(42): ///
reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster state)When you test the effect on multiple outcomes (e.g. employed, hours_worked, log_wage), the family-wise error rate balloons. Correct it:
* Romano-Wolf step-down
rwolf employed hours_worked log_wage, ///
indepvar(training) ///
controls(age edu) ///
reps(500) seed(42) ///
method(reghdfe) ///
fe(worker_id year) ///
cluster(worker_id) ///
bl(0.05)
* Westfall–Young
ssc install wyoung, replace
wyoung, cmd("reghdfe employed training age edu, absorb(worker_id year)" ///
"reghdfe hours_worked training age edu, absorb(worker_id year)" ///
"reghdfe log_wage training age edu, absorb(worker_id year)") ///
family("training") ///
bootstraps(500) seed(42) cluster(worker_id)Run the model across every combination of controls / FE / outcomes / treatments, plot the distribution.
* Loop and collect estimates
tempname M
postfile `M' str30 spec float(b se) using "logs/spec_curve.dta", replace
local outcomes "log_wage wage_w1"
local treatments "training training_ever"
local control_sets `"""" ""age"" ""age edu"" ""age edu tenure"""'
local fe_sets `""worker_id" "worker_id year" "worker_id year industry^year""'
local s = 0
foreach y of local outcomes {
foreach t of local treatments {
foreach c of local control_sets {
foreach fe of local fe_sets {
local ++s
local rhs = trim("`t' `c'")
quietly reghdfe `y' `rhs', absorb(`fe') vce(cluster worker_id)
if e(N) > 0 {
post `M' ("`y'|`t'|`c'|`fe'") (_b[`t']) (_se[`t'])
}
}
}
}
}
postclose `M'
* Plot
preserve
use "logs/spec_curve.dta", clear
sort b
gen idx = _n
gen lb = b - 1.96*se
gen ub = b + 1.96*se
twoway (rcap lb ub idx, lcolor(gs10)) ///
(scatter b idx, mcolor(navy) msize(small)), ///
yline(0, lpattern(dash)) ///
xtitle("Specification (sorted by coefficient)") ///
ytitle("Coefficient on training") legend(off)
graph export "figures/spec_curve.pdf", replace
restoreTests how strong selection on unobservables (relative to observables) would need to be to nullify the effect.
ssc install psacalc, replace
* Long regression (all observable controls)
quietly reghdfe log_wage training age edu tenure female, ///
absorb(worker_id year) vce(cluster worker_id)
psacalc delta training, mcontrol(age edu tenure female) rmax(1.3*e(r2))
* δ* > 1 ⇒ basic robustness
* δ* > 2 ⇒ strong robustness
* |δ*| > 4 ⇒ very strong (used in JOE / AER)
* Bound the bias-adjusted β
psacalc beta training, mcontrol(age edu tenure female) rmax(1.3*e(r2)) delta(1)xtset worker_id year
bacondecomp log_wage training, ///
ddetail /// // print the 2×2 components
nograph
* Reports 4 categories of comparisons + their weights:
* - Earlier vs. later (treated)
* - Later vs. earlier (treated) ← negative-weight problem
* - Treated vs. never-treated
* - Treated vs. always-treated
* If "Later vs. earlier (treated)" weights are non-trivial, TWFE is biased.
* Visualize
bacondecomp log_wage training
graph export "figures/bacon.pdf", replaceAfter estimating an event study, ask: "How big a violation of parallel trends would be needed to change my conclusion?"
* After eventstudyinteract / reghdfe with relative-time dummies,
* save the b and V matrices:
matrix b = e(b)
matrix V = e(V)
* HonestDiD using smoothness-restriction
honestdid, pre(1/4) post(5/9) mvec(0(0.05)0.5) ///
coefplot
graph export "figures/honestdid.pdf", replace
* Bound on M (smoothness budget) at which significance disappears
honestdid, pre(1/4) post(5/9) mvec(0(0.05)0.5) ///
delta(rm) breakdownquietly reghdfe log_wage training, absorb(worker_id year) ///
vce(cluster worker_id)
local b_full = _b[training]
levelsof worker_id, local(units)
local n_units : word count `units'
* For tractability, sample 500 units to drop
local sample : list shuffle units
gettoken first rest : sample, parse(" ")
local i = 0
tempname L
postfile `L' float(b_loo) using "logs/loo.dta", replace
foreach u of local units {
local ++i
if `i' > 500 continue, break
quietly reghdfe log_wage training if worker_id != `u', ///
absorb(worker_id year) vce(cluster worker_id)
post `L' (_b[training])
}
postclose `L'
preserve
use "logs/loo.dta", clear
histogram b_loo, bin(50) ///
addplot(scatteri 0 `b_full', mcolor(red))
graph export "figures/loo.pdf", replace
restore
* Drop top 1% Cook's D
quietly reg log_wage training age edu tenure
predict cd, cooksd
sum cd, detail
preserve
drop if cd > r(p99)
reghdfe log_wage training, absorb(worker_id year) vce(cluster worker_id)
restoreA paper that survives review should include, at minimum:
- Progressive specs (M1–M6) →
tables/table_main.tex - Cluster sensitivity at 3–4 levels +
boottestif few clusters - Placebo (fake timing) event-study estimates ~0 in pre-period
- Randomization inference histogram with observed coef vs. null distribution
- Specification curve of all valid combinations
- Oster δ* computed via
psacalc - Subsample splits at 4–6 pre-defined dimensions
- Alternative outcome / treatment definitions (≥ 2–3 each)
- For DID:
bacondecompweights +honestdidsensitivity - For IV: weak-IV stats (KP rk Wald, AR), overid (Hansen J), Conley if geographic
- For RD: bandwidth sensitivity (×0.5/1/2),
rddensity, covariate smoothness placebos - For PSM/IPW:
pstest/tebalanceSMDs before/after, common-support trimmed re-estimate, entropy-balance version