The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

Comparison Estimators: ETWFE and BETWFE

Gregory Faletto

2026-05-23

library(fetwfe)

Introduction

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:

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.

Setup: simulating panel data

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.333333

etwfe(): extended TWFE without penalty

etwfe() 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)        : 62

We can compare the estimated overall ATT to the truth:

cat("True ATT:     ", true_tes$att_true, "\n")
#> True ATT:      3.877778
cat("Estimated ATT:", res_etwfe$att_hat, "\n")
#> Estimated ATT: 3.88994
cat("Squared error:", (res_etwfe$att_hat - true_tes$att_true)^2, "\n")
#> Squared error: 0.0001479292

betwfe(): bridge-penalized ETWFE

betwfe() 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.0986

Comparing 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.981964

The 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().

No-covariate setting

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.866667

etwfe() 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)        : 20

fetwfe() 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.1278

Side-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.104189

Two qualitative differences to note in the no-covariate regime:

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().

Visualizing event-time effects

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-65
plot(res_etwfe)

Event-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-79
plot(res_betwfe)

eventStudy() 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.

When to use which

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.

For a real-data application of the recommended estimator, see the main fetwfe() vignette.

References

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.