Author: Gregory Faletto
Date: 2026-05-23

# 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:

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

# 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:

- Smaller design matrix. Without covariates and their cohort/time/treatment interactions, the underlying regression has many fewer columns, so both estimators converge faster and `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).
- Standard errors typically widen. Covariates that explain residual variance are absent, so the idiosyncratic-noise variance `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()`.

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

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

# References

- Faletto, G. (2025). Fused Extended Two-Way Fixed Effects for Difference-in-Differences with Staggered Adoptions. arXiv preprint arXiv:2312.05985.
- Wooldridge, J. M. (2021). Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators. SSRN Working Paper No. 3906345.