The {fetwfe} package implements fetwfe() as
its recommended estimator for difference-in-differences with staggered
adoptions. It also exports two related estimators that serve as
comparison baselines:
etwfe() — Wooldridge-style extended two-way fixed
effects.betwfe() — bridge-penalized ETWFE; like
fetwfe() but without the fusion transformation.This vignette demonstrates both, on simulated data so we can compare each estimator against the known true treatment effects.
For background on staggered-adoption DiD and a real-data application
of the recommended fetwfe() estimator, see the main vignette.
For the underlying simulation pipeline used here, see the simulation
vignette. For methodological details, see Faletto (2025).
All three estimators (fetwfe(), etwfe(),
betwfe()) accept the same call signature, so a user
familiar with fetwfe() can drop in etwfe() or
betwfe() simply by changing the function name. Below we use
the *WithSimulatedData() wrappers to keep the simulation
flow concise.
We use the genCoefs() + simulateData()
pipeline (the same approach as the simulation vignette). The parameters
below are chosen so that both etwfe() and
betwfe() are well-conditioned: enough cohorts and units
that etwfe() doesn’t run into rank-deficiency, and enough
density in the true coefficient vector that betwfe()’s
bridge regularization shrinks toward zero without zeroing everything
out.
sim_coefs <- genCoefs(
R = 3,
T = 6,
d = 2,
density = 0.5,
eff_size = 2,
seed = 20260510
)
sim_data <- simulateData(
sim_coefs,
N = 120,
sig_eps_sq = 1,
sig_eps_c_sq = 1
)
# True treatment effects (we'll compare estimator output to these):
true_tes <- getTes(sim_coefs)
cat("True overall ATT:", true_tes$att_true, "\n")
#> True overall ATT: 3.877778
print(true_tes$actual_cohort_tes)
#> [1] 4.800000 3.500000 3.333333etwfe(): extended TWFE without penaltyetwfe() implements the Wooldridge-style extended two-way
fixed effects estimator: cohort-time dummy interactions estimated by
OLS, with no regularization. Under the model’s assumptions it produces
unbiased point estimates and asymptotically exact standard errors. The
trade-off compared to fetwfe() is variance: with no fusion
penalty, the estimator can be high-variance in over-parameterized
regimes, and it errors out entirely when cohorts are small relative to
the number of covariates ((d + 1) units per cohort is the
floor).
res_etwfe <- etwfeWithSimulatedData(sim_data)
summary(res_etwfe)
#> Summary of Extended Two-Way Fixed Effects
#> ========================================
#>
#> Overall ATT: 3.8899 (SE = 0.1791, p = 1.498e-104, 95% CI = [3.5388, 4.2410])
#>
#> CATT (preview):
#> Cohort Estimated TE SE ConfIntLow ConfIntHigh P_value
#> 2 4.658986 0.2422635 4.184158 5.133813 2.034096e-82
#> 3 3.773047 0.2080983 3.365181 4.180912 1.811603e-73
#> 4 3.104032 0.2240861 2.664831 3.543233 1.237808e-43
#>
#> Model Details:
#> Units (N) : 120
#> Time periods (T) : 6
#> Treated cohorts (R) : 3
#> Covariates (d) : 2
#> Features (p) : 62We can compare the estimated overall ATT to the truth:
betwfe(): bridge-penalized ETWFEbetwfe() extends etwfe() by adding a bridge
(L_q, 0 < q < 1) regularization penalty
on the cohort-time effects. Compared to etwfe(), this
trades a small amount of bias for lower variance — the same idea as
fetwfe(), but without the fusion transformation that
fetwfe() applies. So betwfe() is essentially
“fetwfe minus the fusion.”
res_betwfe <- betwfeWithSimulatedData(sim_data)
summary(res_betwfe)
#> Summary of Bridge-Penalized Extended Two-Way Fixed Effects
#> ==========================================================
#>
#> Overall ATT: 2.4700 (SE = 0.1193, p = 3.369e-95, 95% CI = [2.2361, 2.7038])
#> Selected: TRUE
#>
#> CATT (preview):
#> Cohort Estimated TE SE ConfIntLow ConfIntHigh P_value selected
#> 2 2.438498 0.1327080 2.178395 2.698601 2.086704e-75 TRUE
#> 3 2.851401 0.1655918 2.526847 3.175955 1.897451e-66 TRUE
#> 4 2.097538 0.1883218 1.728434 2.466642 8.189004e-29 TRUE
#>
#> Model Details:
#> Units (N) : 120
#> Time periods (T) : 6
#> Treated cohorts (R) : 3
#> Covariates (d) : 2
#> Features (p) : 62
#> Selected size : 30
#> Lambda* : 0.0986Comparing against the truth and against etwfe():
cat("True ATT: ", true_tes$att_true, "\n")
#> True ATT: 3.877778
cat("etwfe() ATT: ", res_etwfe$att_hat, "\n")
#> etwfe() ATT: 3.88994
cat("betwfe() ATT: ", res_betwfe$att_hat, "\n")
#> betwfe() ATT: 2.469955
cat("etwfe sq. error: ", (res_etwfe$att_hat - true_tes$att_true)^2, "\n")
#> etwfe sq. error: 0.0001479292
cat("betwfe sq. error:", (res_betwfe$att_hat - true_tes$att_true)^2, "\n")
#> betwfe sq. error: 1.981964The bridge penalty in betwfe() shrinks the estimate
toward zero relative to etwfe(). On this regime, that
produces a noticeable bias — the textbook bias-variance trade-off in
action. In other regimes (sparser true effects, or noisier data), the
bias from regularization is more than offset by reduced variance, and
betwfe() outperforms etwfe().
The examples above use a panel with d = 2 time-invariant
covariates. The package equally supports the no-covariate case by
passing covs = c() to any estimator (or by generating data
with genCoefs(d = 0, ...)). This section runs the same
simulated regime with no covariates, side-by-side, so a user can see
what etwfe() and fetwfe() look like in the
simpler setting.
sim_coefs_d0 <- genCoefs(
R = 3,
T = 6,
d = 0,
density = 0.5,
eff_size = 2,
seed = 20260510
)
sim_data_d0 <- simulateData(
sim_coefs_d0,
N = 120,
sig_eps_sq = 1,
sig_eps_c_sq = 1
)
true_tes_d0 <- getTes(sim_coefs_d0)
cat("True overall ATT (no covariates):", true_tes_d0$att_true, "\n")
#> True overall ATT (no covariates): 2.866667etwfe() in the no-covariate setting:
res_etwfe_d0 <- etwfeWithSimulatedData(sim_data_d0)
summary(res_etwfe_d0)
#> Summary of Extended Two-Way Fixed Effects
#> ========================================
#>
#> Overall ATT: 2.8106 (SE = 0.2865, p = 1.005e-22, 95% CI = [2.2491, 3.3720])
#>
#> CATT (preview):
#> Cohort Estimated TE SE ConfIntLow ConfIntHigh P_value
#> 2 1.655517 0.2397239 1.185667 2.125367 4.987498e-12
#> 3 0.986391 0.2045793 0.585423 1.387359 1.424406e-06
#> 4 6.007912 0.2204793 5.575781 6.440044 1.692525e-163
#>
#> Model Details:
#> Units (N) : 120
#> Time periods (T) : 6
#> Treated cohorts (R) : 3
#> Covariates (d) : 0
#> Features (p) : 20fetwfe() in the no-covariate setting:
res_fetwfe_d0 <- fetwfeWithSimulatedData(sim_data_d0)
summary(res_fetwfe_d0)
#> Summary of Fused Extended Two-Way Fixed Effects
#> ================================================
#>
#> Overall ATT: 2.5439 (SE = 0.2121, p = 3.831e-33, 95% CI = [2.1282, 2.9596])
#> Selected: TRUE
#>
#> CATT (preview):
#> Cohort Estimated TE SE ConfIntLow ConfIntHigh P_value selected
#> 2 1.463502 0.1347729 1.199352 1.727652 1.806634e-27 TRUE
#> 3 1.274514 0.1249856 1.029547 1.519482 2.038824e-24 TRUE
#> 4 5.007244 0.1370463 4.738638 5.275850 2.882918e-292 TRUE
#>
#> Model Details:
#> Units (N) : 120
#> Time periods (T) : 6
#> Treated cohorts (R) : 3
#> Covariates (d) : 0
#> Features (p) : 20
#> Selected size : 10
#> Lambda* : 0.1278Side-by-side overall ATT estimates against the truth:
cat("True ATT: ", true_tes_d0$att_true, "\n")
#> True ATT: 2.866667
cat("etwfe() ATT: ", res_etwfe_d0$att_hat, "\n")
#> etwfe() ATT: 2.810574
cat("fetwfe() ATT: ", res_fetwfe_d0$att_hat, "\n")
#> fetwfe() ATT: 2.543883
cat("etwfe sq. error: ", (res_etwfe_d0$att_hat - true_tes_d0$att_true)^2, "\n")
#> etwfe sq. error: 0.003146414
cat("fetwfe sq. error:", (res_fetwfe_d0$att_hat - true_tes_d0$att_true)^2, "\n")
#> fetwfe sq. error: 0.104189Two qualitative differences to note in the no-covariate regime:
etwfe() becomes rank-stable at smaller cohort sizes (the
(d + 1)-units-per-cohort floor that etwfe()
enforces drops to just one unit per cohort).sig_eps_sq enters the SEs without that explanatory
cushion. The signal-to-noise ratio on the treatment-effect coefficients
drops, which is the trade-off for the simpler model.The package handles covs = c() end-to-end without any
special-casing on the user’s side: the data-prep pipeline
(prep_for_etwfe_core in R/core_funcs.R)
dispatches on d == 0 and skips the covariate-interaction
columns automatically. The same applies to betwfe() and
twfeCovs().
Each of the three estimator outputs supports plot()
(which dispatches to a method) and a companion eventStudy()
helper that returns a tidy data frame of pooled-event-time
treatment-effect estimates. The plot is an event study: x-axis is event
time e = t - r (calendar time minus the cohort’s
first-treated time), y-axis is the cohort-weighted average of cell-level
treatment-effect estimates at each event time, with confidence
intervals. Pooling weights are sample-cohort-size weights (matching
did::aggte(type = "dynamic") convention). The variance
combines a regression-coefficient term and a cohort-probability term,
mirroring the package’s existing overall-ATT SE machinery.
Event-time estimates from etwfe():
eventStudy(res_etwfe)
#> event_time n_cohorts estimate se ci_low ci_high p_value
#> 1 0 3 3.368180 0.1994002 2.977363 3.758997 5.191193e-64
#> 2 1 3 3.855024 0.1926492 3.477439 4.232610 4.453268e-89
#> 3 2 3 3.447353 0.2684186 2.921263 3.973444 9.391175e-38
#> 4 3 2 5.046964 0.2749486 4.508075 5.585853 2.954337e-75
#> 5 4 1 5.864494 0.3432443 5.191747 6.537240 1.903702e-65Event-time estimates from betwfe() on the same simulated
panel:
eventStudy(res_betwfe)
#> event_time n_cohorts estimate se ci_low ci_high p_value
#> 1 0 3 2.028822 0.2175525 1.602427 2.455217 1.102913e-20
#> 2 1 3 2.362191 0.1715023 2.026053 2.698330 3.678355e-43
#> 3 2 3 1.817902 0.1788957 1.467273 2.168531 2.936071e-24
#> 4 3 2 3.514455 0.1833198 3.155155 3.873756 6.444355e-82
#> 5 4 1 4.064262 0.2159801 3.640949 4.487575 5.401542e-79eventStudy() returns the underlying data;
plot() returns a ggplot2 object you can further customize.
ggplot2 is in Suggests:, so it must be
installed to use the plot() methods; the estimators
themselves work without it.
fetwfe() is the recommended estimator for production
use; etwfe() and betwfe() are useful as
comparisons or as building blocks for understanding what
fetwfe() is doing.
fetwfe() — default choice. Combines
bridge regularization with the fusion transformation for both bias and
variance reduction. See the main fetwfe() vignette for a
real-data application and the simulation vignette for the simulation
workflow.betwfe() — alternative when you want
regularization but not the fusion transformation. Useful for inspecting
the effect of fusion alone — compare betwfe()
vs. fetwfe() on the same data and the difference is what
fusion adds.etwfe() — useful as a baseline. On
well-conditioned data it produces unbiased point estimates with valid
standard errors. On small or over-parameterized data it can fail with
rank-deficient cohort errors, which fetwfe()’s
regularization avoids.For a real-data application of the recommended estimator, see the
main fetwfe() vignette.