Getting started with staggeredGMM

library(staggeredGMM)

Overview

staggeredGMM estimates cohort-by-time average treatment effects (CATTs) under staggered treatment adoption by the generalized method of moments, as proposed in

Arora, P. and Bijani, R. (2026). “Estimating Treatment Effects under Staggered Timing and Non-Spherical Errors.” Available at SSRN: https://doi.org/10.2139/ssrn.6558759

Only clean two-by-two difference-in-differences comparisons enter the moment system: a treated cohort against a never-treated group, or against a cohort not yet treated at the relevant period.

This vignette covers how to call the estimator and what it returns. For the methodology, the choice among the three weighting schemes, and the simulation and empirical evidence, see the paper.

Data coding conventions

gmm_staggered() has the following input requirements:

A missing outcome is fine, and is what an unbalanced panel looks like. A missing unit id, period or cohort is an error, since the row cannot be placed in the panel at all.

Two conventions are worth stating explicitly. First, a unit whose cohort falls after the last observed period is untreated throughout the window, so it is pooled with the never-treated group as a clean control rather than discarded. Second, a unit treated at or before the first observed period has no pre-treatment period at all, so none of its effects is identified; rather than return zeros for those cells and contaminate the aggregate, the estimator stops and names the offending cohort.

A first estimate

sim_panel is a balanced synthetic panel: 60 units over 33 periods, five treated cohorts and a never-treated group.

fit <- gmm_staggered(sim_panel, yname = "y", tname = "year",
                     idname = "unit_id", gname = "cohort")
fit
#> Staggered-adoption GMM estimator
#> Weighting: pooled Toeplitz (GMM-T)
#> 
#>   Units                       60
#>   Periods                     33
#>   Treated cohorts              5
#>   CATTs (identified)     90 (90)
#>   Clean comparisons         1935
#>   Moment-space rank          160
#>   Iterations                   7
#>   Converged                  yes
#> 
#> Treated-observation ATT       -16.6321  (se 0.2522) 
#> Cohort-equal ATT              -15.5701  (se 0.2521)

The never-treated group was detected automatically. never_treated = TRUE requires one and errors if none exists; never_treated = FALSE excludes them.

What is returned

head(fit$catt)
#>    g  t event_time  estimate std_error identified n_comparisons
#> 1 10 10          0 -16.01541 0.3200093       TRUE            45
#> 2 10 11          1 -15.67617 0.3786223       TRUE            45
#> 3 10 12          2 -15.98558 0.4040038       TRUE            45
#> 4 10 13          3 -16.42134 0.4216487       TRUE            36
#> 5 10 14          4 -16.48369 0.4310857       TRUE            36
#> 6 10 15          5 -16.92851 0.4391031       TRUE            36

catt gives one row per cohort-by-time cell, with the event time, the estimate and its standard error, whether the cell is identified, and how many clean comparisons anchor it. coef(), vcov() and confint() work as usual:

head(coef(fit))
#>   g10:t10   g10:t11   g10:t12   g10:t13   g10:t14   g10:t15 
#> -16.01541 -15.67617 -15.98558 -16.42134 -16.48369 -16.92851
head(confint(fit))
#>             2.5 %    97.5 %
#> g10:t10 -16.64262 -15.38821
#> g10:t11 -16.41826 -14.93408
#> g10:t12 -16.77741 -15.19375
#> g10:t13 -17.24775 -15.59492
#> g10:t14 -17.32860 -15.63877
#> g10:t15 -17.78914 -16.06789

The two aggregates

Two scalar summaries are reported, and they answer different questions.

c(CW = fit$aggregate$CW$estimate, EW = fit$aggregate$EW$estimate)
#>        CW        EW 
#> -16.63214 -15.57012

On this design the true values are -16.79375 and -15.82020 respectively; they differ because earlier cohorts are observed for more post-treatment periods. Neither is more correct than the other. For any other target, weight fit$catt$estimate directly.

Choosing a weighting

fit_ht <- gmm_staggered(sim_panel, yname = "y", tname = "year",
                        idname = "unit_id", gname = "cohort",
                        weighting = "cohort_toeplitz")
c(pooled = fit$aggregate$CW$estimate, cohort = fit_ht$aggregate$CW$estimate)
#>    pooled    cohort 
#> -16.63214 -16.61543

All three weightings target the same CATT vector using the same moment conditions. They do not generally return the same numbers: the system is over-identified, so a different weighting matrix gives a different estimate. Which to prefer is a methodological question the paper addresses.

Convergence

unlist(fit$convergence[c("converged", "solve_ok", "termination", "n_iter")])
#>   converged    solve_ok termination      n_iter 
#>      "TRUE"      "TRUE" "converged"         "7"

converged is TRUE only when the largest change in any CATT fell below tol. It is deliberately distinct from solve_ok, which records merely that a weighted solve succeeded; a run can solve successfully at every iteration and still exhaust max_iter without converging. termination says why the iteration stopped.

max_iter defaults to 100 because the iteration can converge linearly at a slow rate.

On this design the unrestricted weighting cannot complete a weighted step at all:

fit_u <- gmm_staggered(sim_panel, yname = "y", tname = "year",
                       idname = "unit_id", gname = "cohort",
                       weighting = "unrestricted")
#> Warning: No weighted GMM step could be completed (termination:
#> singular_normal_equations). The reported estimates are the identity-weighted
#> seed, not a unrestricted-weighted estimator, and standard errors use the
#> sandwich fallback. The estimated weighting matrix supports 60 independent
#> moment direction(s), but 90 effect(s) must be identified. A cohort-specific
#> covariance carries more parameters than a short panel with few units per cohort
#> can support; a more parsimonious `weighting`, or more units, is needed for this
#> design.

The estimate returned in that case is the identity-weighted seed, not an unrestricted-weighted GMM estimator, which is why it always warns. Failing to converge after a weighted step did run is a different matter, and is common enough under this weighting that it does not warn.

Unbalanced panels

Missing outcomes are supported. Group-period means are taken over the units observed at that period; autocovariances are divided by the number of residual pairs actually observed at each lag; and the variance of a group-period mean accounts for the overlap between the unit sets contributing at each pair of periods. All three reduce exactly to the complete-data formulas when nothing is missing, so there is one code path rather than two.

gappy <- sim_panel
set.seed(1)
gappy$y[sample(nrow(gappy), 100)] <- NA
fit_gap <- gmm_staggered(gappy, yname = "y", tname = "year",
                         idname = "unit_id", gname = "cohort")
c(balanced = fit$aggregate$CW$estimate,
  unbalanced = fit_gap$aggregate$CW$estimate)
#>   balanced unbalanced 
#>  -16.63214  -16.63035

This is valid when missingness is independent of the outcome given group and period. Selection on outcomes is not addressed by any weighting scheme.

A comparison referencing a group-period at which no unit is observed cannot be formed and is dropped, with a warning giving the count.

Partial identification

A cohort-by-time effect with no clean comparison is not estimable. Such cells are reported as NA with identified = FALSE, never as an estimated zero, and are excluded from the parameter vector so that one unestimable cell does not disable the efficient weighting everywhere else.

Where an unidentified cell carries positive aggregation weight, the aggregate itself is NA, and an identified-subset aggregate renormalised over the estimable cells is reported alongside it:

names(fit$aggregate$CW)
#> [1] "estimate"                "std_error"              
#> [3] "estimate_identified"     "std_error_identified"   
#> [5] "identified_weight_share" "weights"

Baseline covariates

covar takes a character vector of baseline, time-invariant covariate columns and applies the outcome-regression adjustment of Section 4.5 of the paper, so that parallel trends need hold only conditional on them.

fit_cov <- gmm_staggered(sim_panel, yname = "y", tname = "year",
                         idname = "unit_id", gname = "cohort",
                         covar = c("x1", "x2"))
fit_cov$aggregate$CW$estimate
#> [1] -16.66011

If a supplied covariate is not in fact constant within unit, each affected unit’s value at its earliest observed period is used and a warning names the units. Earliest by time, not first by row: the answer does not depend on how the data were sorted.

Where the adjustment cannot be identified for some control cohort and pair of periods – too few complete-case control units, or a rank-deficient fit – those comparisons alone revert to the unconditional form, and the count is reported in fit$controls$cov_n_fallback.

If a covariate has no observed value for any unit of a treated cohort, that cohort’s covariate mean is undefined and the estimator stops, naming the covariate and the cohort.

The specification test below does not adjust for covariates. It tests unconditional parallel trends, not the covariate-conditional parallel trends under which fit_cov is estimated; see the next section.

Testing the identifying assumptions

The over-identifying restrictions are the pre-treatment placebo moments, whose expectation is zero only under parallel trends and no anticipation. gmm_j_test() tests them jointly, weighting them by the estimated serial-correlation model.

gmm_j_test(fit)
#> Specification test for unconditional parallel trends and no anticipation
#> Hansen J on the pre-treatment placebo restrictions
#> 
#>   Restriction set   local pre-window (3 period(s) before adoption)
#>   Base period       9
#>   Weighting         pooled Toeplitz (GMM-T)
#>   Moments           14
#> 
#>   J = 9.0396, df = 14, p = 0.8285

The test is unconditional. Its placebo moments are unadjusted group means, whether or not the fitted model adjusts for covariates. After a fit with covar, it therefore tests unconditional parallel trends and no anticipation, not the covariate-conditional parallel trends assumption under which the effects were estimated. If covariates are distributed differently across cohorts, the test can reject although conditional parallel trends hold, and a non-rejection does not validate the conditional assumption. The printed result says so whenever the fit used covariates:

gmm_j_test(fit_cov)
#> Specification test for unconditional parallel trends and no anticipation
#> Hansen J on the pre-treatment placebo restrictions
#> 
#>   Restriction set   local pre-window (3 period(s) before adoption)
#>   Base period       9
#>   Weighting         pooled Toeplitz (GMM-T)
#>   Moments           14
#> 
#>   J = 8.7510, df = 14, p = 0.8467
#> 
#> ------------------------------------------------------------------------------
#> IMPORTANT: this model adjusts for covariates, but this test does not.
#> The placebo moments are unadjusted group means, so the test checks
#> UNCONDITIONAL parallel trends and no anticipation. It does NOT test the
#> covariate-conditional parallel trends assumption under which the effects were
#> estimated. A rejection can reflect differences in covariates across cohorts
#> rather than a failure of that assumption, and a non-rejection does not
#> validate it.
#> ------------------------------------------------------------------------------

By default the test uses a local pre-window of three periods before each cohort’s adoption. Using every placebo restriction is also available, but a large number of restrictions degrades the chi-squared approximation when the number of units is small:

gmm_j_test(fit, type = "full")
#> Specification test for unconditional parallel trends and no anticipation
#> Hansen J on the pre-treatment placebo restrictions
#> 
#>   Restriction set   all pre-treatment placebo moments
#>   Base period       9
#>   Weighting         pooled Toeplitz (GMM-T)
#>   Moments           70
#> 
#>   J = 73.1373, df = 70, p = 0.3754

The test requires a never-treated group, since the event-study basis it uses differences every cohort against a common never-treated reference.

Two coding pitfalls

A unit treated at or before the first observed period has no pre-treatment period. Recoding the first cohort of sim_panel to adopt in period 1 shows what happens:

bad <- sim_panel
bad$cohort[bad$cohort == 10] <- 1
gmm_staggered(bad, yname = "y", tname = "year",
              idname = "unit_id", gname = "cohort")
#> Error:
#> ! 10 unit(s) in cohort(s) 1 are treated at or before the first observed period (1). Such units have no pre-treatment period, so none of their cohort-by-time effects is identified, and including them would silently contaminate the aggregate.

Silently including such units would return a plausible-looking aggregate contaminated by cells that were never estimated, so the estimator stops and names the cohort. Drop those units, or restrict to cohorts adopting inside the window, before estimating.

Without a never-treated group, identification rests entirely on not-yet-treated controls. Dropping the never-treated units of sim_panel:

fit_nyt <- gmm_staggered(sim_panel[sim_panel$cohort != 0, ], yname = "y",
                         tname = "year", idname = "unit_id",
                         gname = "cohort")
#> Warning: 60 of 90 cohort-by-time effect(s) have no clean comparison and are
#> reported as NA, not as an estimated zero. Affected: (g=10, t=22), (g=10, t=23),
#> (g=10, t=24), (g=10, t=25), (g=10, t=26), (g=10, t=27), (g=10, t=28), (g=10,
#> t=29), ... (60 total). A never-treated control group, if available, would
#> identify more.
table(fit_nyt$catt$identified)
#> 
#> FALSE  TRUE 
#>    60    30
fit_nyt$aggregate$CW$identified_weight_share
#> [1] 0.3333333

The last cohort to adopt has no later cohort to serve as its control, so none of its effects is identified, and neither is any cell at or after its adoption period: only 30 of the 90 cells are estimable. Because unidentified cells carry positive weight, the aggregate itself is NA and the identified-subset figure is reported with the share of weight it covers. An aggregate over a set of cells two thirds of which were never estimated would not mean what its name suggests.

Citation

citation("staggeredGMM")