Worked example: the sign flip

Here there are two treated cohorts who have treatment effects of opposite sign. Importantly, their sizes are wildly unequal. On this panel a stacked two-way-fixed-effects regression without corrective weights gets the sign of the average treatment effect wrong, while the corrective weights stacked TWFE estimator recovers the truth. Because there is noise, the numbers below are estimates with standard errors, not exact identities; they reproduce the paper’s Exhibit 2 and Table 1.

The deterministic signflip dataset that ships with the package is a noise-free miniature of this same scenario: 12 units across six years with worked numbers exactly reproduced. This is useful as a tool for seeing the mechanism without sampling variation. Below we use a noisy simulation.

The rest of this page rebuilds the sign-flip argument step by step, in three live figures: the raw group means, the sub-experiment weights that decide the sign, and the two event studies. A nice feature of this is that it walks through most of the functions in the package.

The data

The paper’s simulation ships as a helper on this site (assets/sim-signflip.R). It draws a 100-year panel for three groups: ten units that adopt in year 11 with a true effect of +300, a thousand units that adopt in year 60 with a true effect of −100, and ten never-treated units. Group, unit, and year fixed effects plus idiosyncratic noise are added in here, so recovered estimates may not perfectly reflect the true treatment effect for any given simulation run.

source("../assets/style-web.R")      # loads stacked, data.table, ggplot2
source("../assets/sim-signflip.R")   # the paper's DGP (fixed seed 20240215)
source("../assets/plot-signflip.R")  # sf_fit(), sf_panel_raw/es/mech()
library(fixest)

sim <- simulate_signflip()

# Cohort structure: sizes, adoption years, true effects
sim[, .(n_units = uniqueN(group_unit), adopt_year = unique(time_treated)),
    by = group][order(group)]
   group n_units adopt_year
1:     1      10         11
2:     3    1000         60
3:     5      10         NA
* Regenerate the paper's simulated sign-flip design. Stata's RNG differs from
* R's, so the noise draws (not the design) differ slightly from the numbers here.
clear
set seed 20240215
* 1,020 units: 10 adopt year 11 (effect +300), 1,000 adopt year 60 (effect -100),
* 10 never treated
set obs 1020
gen long group_unit = _n
gen byte group      = cond(_n <= 10, 1, cond(_n <= 1010, 3, 5))
gen int  adopt_year = cond(group == 1, 11, cond(group == 3, 60, .))

* unit- and group-level fixed effects, and group baseline levels
gen double unit_fe = 100 * rnormal()
bysort group (group_unit): gen double group_fe = 100 * rnormal() if _n == 1
bysort group (group_unit): replace group_fe = group_fe[1]
gen double avg = cond(group == 1, 75, cond(group == 3, 50, 100))

* expand to a 100-year panel and add one shared year fixed effect
expand 100
bysort group_unit: gen int year = _n
preserve
    keep year
    duplicates drop
    gen double year_fe = 100 * rnormal()
    tempfile yfe
    save `yfe'
restore
merge m:1 year using `yfe', nogenerate

* idiosyncratic error and the treatment effects
gen double idio = 25 * rnormal()
gen double te   = cond(group == 1 & year >= 11,  300, ///
                  cond(group == 3 & year >= 60, -100, 0))
gen double dep_var = avg + group_fe + unit_fe + year_fe + idio + te

* Cohort structure
table group adopt_year

The target we want is the treated-unit-weighted average effect. Ten units at +300 and a thousand at −100 give

\[ \theta = \frac{10\,(+300) + 1000\,(-100)}{1010} \approx -96 . \]

The large negative cohort dominates the treated mass, so the true average is negative, a fact the naive estimator will miss.

Build the stack

Both cohorts have plenty of room inside a five-period window, so use \(\kappa_{\text{pre}} = \kappa_{\text{post}} = 5\) and draw controls from both the never-treated and the not-yet-treated units.

dd <- sim[, .(group_unit, year, adopt_year = time_treated, dep_var)]

stack <- build_stack(dd, "year", "group_unit", "adopt_year",
                     kappa_pre = KP, kappa_post = KQ,
                     control_type = "both", weight_type = "unit_weights")

# Q-weights: treated obs are 1. The large year-60 cohort has 1,000 treated
# units but shares only a handful of controls, so its controls are upweighted
# ~101x; the small year-11 cohort is swamped by controls, so its are pushed to 0.01.
stack[, .(mean_q_weight = round(mean(q_weight), 2)),
      by = .(treat, sub_exp)][order(treat, sub_exp)]
   treat sub_exp mean_q_weight
1:     0      11          0.01
2:     0      60        100.99
3:     1      11          1.00
4:     1      60          1.00
stacked build, time(year) unit(group_unit) adopt(adopt_year) ///
    kpre(5) kpost(5) controltype(both) weighttype(unit_weights)

* Q-weights by treatment status and cohort
table treat sub_exp, statistic(mean q_weight)

Naive versus Q-weighted

stackreg() applies the Q-weights automatically and returns the target. Running a plain, unweighted stacked TWFE on the same stack — treatment interacted with event time, absorbing unit-by-cohort and time-by-cohort fixed effects — gives the naive estimate.

# Q-weighted: the corrective estimator
q_model <- stackreg(stack, "dep_var", cluster_var = "group_unit")
q_att    <- attr(q_model, "avg_post_att")$estimate
q_se     <- attr(q_model, "avg_post_att")$se

# Naive: unweighted stacked TWFE (interacted fixed effects), post-period average
st <- copy(stack); setnames(st, "dep_var", "y")
m_fe <- feols(y ~ i(event_time, treat, ref = -1) | group_unit^sub_exp + year^sub_exp,
              st, cluster = ~group_unit)
pn   <- grep("event_time::[0-9]", names(coef(m_fe)))
Lfe  <- rep(0, length(coef(m_fe))); Lfe[pn] <- 1 / length(pn)
naive_att <- sum(Lfe * coef(m_fe))
naive_se  <- sqrt(drop(t(Lfe) %*% vcov(m_fe) %*% Lfe))

round(c(target = -96.04, q_weighted = q_att, naive = naive_att), 2)
    target q_weighted      naive 
    -96.04    -101.45      92.66 
* Q-weighted: the corrective estimator
stacked reg dep_var, cluster(group_unit) model(att)

* Naive: unweighted stacked TWFE (interacted fixed effects)
gen post          = event_time >= 0
gen stack_treated = treat * post
reghdfe dep_var stack_treated, ///
    absorb(group_unit#sub_exp year#sub_exp) vce(cluster group_unit)

The Q-weighted estimate is about −101 (SE 12.1) — right on the target of −96, within sampling noise. The naive stacked TWFE is about +93 (SE 44.5): not just biased, but the wrong sign. It overweights the small year-11 cohort, whose sub-experiment carries a disproportionate share of the treatment variance even though it holds barely 1% of the treated units.

Which weights, which sign

The choice of weights is the sign. Figure (b) plots each cohort’s own ATT against the weight its sub-experiment carries under the two schemes. Precision weighting (grey squares) splits the mass roughly 0.50/0.50 between the tiny +291 year-11 cohort and the huge −105 year-60 cohort, so its average lands above zero. Corrective weighting (pink circles) puts 0.99 on the large negative cohort — in proportion to its share of the treated units — and its average lands on the truth. The arrows show corrective weighting moving the mass from the precision to the treated-share allocation.

# Figure (b): each cohort's ATT against its sub-experiment weight (with error)
p_mech <- sf_panel_mech(fx)

Each cohort's ATT plotted against its sub-experiment weight: precision weighting splits the mass 0.50/0.50, corrective weighting loads 0.99 on the large negative cohort, and that choice decides the sign.

* stacked plot, bygroup overlays each cohort's ATT with marker size = weight
stacked reg dep_var, cluster(group_unit) bygroup
stacked plot, bygroup

Event study: only the corrective weights track the truth

Finally, the full event studies (figure c), with confidence intervals. The Q-weighted series (pink) is flat before adoption and settles near the truth of −96; the unweighted series (grey) sits on the wrong side of zero throughout the post-period.

# Figure (c): event study, both estimators, with confidence intervals
p_es <- sf_panel_es(fx)

Event-study comparison: the Q-weighted series is flat pre-adoption and settles near -96; the unweighted series is positive after adoption.

* Q-weighted event study
stacked reg dep_var, cluster(group_unit)
stacked plot

Takeaway

Building a clean stack removed the negative-weights problem, but the unweighted regression on that clean stack still landed on the wrong side of zero. The Q-weights are what turn a clean design into a correct estimate. See stacking, aggregation issues, and corrective weights for the general argument and diagnostic divergence statistic, for the design diagnostic that flags this risk in advance.