Getting Started with cdid

cdid estimates group-time average treatment effects (att_gt()) and their aggregations (aggte()) in panel data with missing outcomes using the chained difference-in-differences of Bellégo, Benatia and Dortet-Bernadet (2025) (https://doi.org/10.1016/j.jeconom.2024.105783). It builds on the did interface of Callaway and Sant’Anna (2021) and uses chained estimation by default for panel data (mode = "chained"), while mode = "ordinary" delegates unchanged to did.

1. Synthetic incomplete panel and unit table

We simulate a self-contained long panel in base R with \(N = 900\) units across \(T = 5\) periods (1 to 5), a binary covariate x, a never-treated control group (g = 0), and a cohort first treated in period 3 (g = 3). Treatment cohort membership depends on x through a binary logit, and the latent outcome combines a unit fixed effect \(\alpha_i\), a common trend plus an x-specific trend, independent normal noise, and a homogeneous post-treatment effect \(1 + 0.25(t - 3)\) for \(g = 3\) at \(t \ge 3\) (population target values 0 at pre-treatment period 2, and 1.00, 1.25, 1.50 at periods 3, 4, 5, with post-treatment average 1.25).

Each unit is randomly assigned, independently of (g, x) and outcome shocks, to one of four consecutive observation windows {1, 2}, {2, 3}, {3, 4}, or {4, 5}. Every unit therefore has two observed outcomes and three NA outcomes, and no unit is observed at all five dates. We retain all long panel rows (with y = NA outside the unit’s window) and pass the full unit table units as frame.

library(cdid)
set.seed(20261029)

n_units <- 900
n_periods <- 5

units <- data.frame(
  id = seq_len(n_units),
  x = rbinom(n_units, size = 1, prob = 0.5)
)
units$g <- ifelse(
  runif(n_units) < plogis(-0.2 + 0.4 * units$x),
  3,
  0
)
unit_alpha <- rnorm(n_units)
window_start <- sample.int(n_periods - 1L, size = n_units, replace = TRUE)

panel_data <- data.frame(
  id = rep(units$id, each = n_periods),
  time = rep(seq_len(n_periods), times = n_units),
  g = rep(units$g, each = n_periods),
  x = rep(units$x, each = n_periods)
)

treated_post <- panel_data$g == 3 & panel_data$time >= 3
true_effect <- ifelse(treated_post, 1 + 0.25 * (panel_data$time - 3), 0)
y_latent <- rep(unit_alpha, each = n_periods) +
  (0.4 + 0.2 * panel_data$x) * panel_data$time +
  true_effect +
  rnorm(nrow(panel_data), sd = 0.5)

in_window <- panel_data$time == rep(window_start, each = n_periods) |
  panel_data$time == rep(window_start + 1L, each = n_periods)
panel_data$y <- ifelse(in_window, y_latent, NA_real_)

We verify that (id, time) pairs are unique and count observed outcomes per unit across the unit table units:

stopifnot(!anyDuplicated(panel_data[c("id", "time")]))
obs_per_unit <- tabulate(
  match(panel_data$id[!is.na(panel_data$y)], units$id),
  nbins = nrow(units)
)
table(cohort = units$g, observed_dates = obs_per_unit)
#>       observed_dates
#> cohort   2
#>      0 461
#>      3 439

2. Data requirements and identification conditions

In chained mode, data must have unique (idname, tname) rows, finite numeric calendar dates tname, numeric outcomes yname that are finite when observed and kept as NA when missing (never replaced by 0), and a constant known treatment cohort gname per unit (0 for never treated). No unit is required to have a complete history or an observed outcome at the cohort baseline date (\(t = 2\)). The unit table frame defines the study population \(F\) of size \(N = |F|\): units observed at two or more dates (\(A\)) supply within-unit changes \(\Delta Y_{s,t} = Y_t - Y_s\), while singletons (one observed outcome) and units with no observed outcomes supply no differences but remain in \(F\) for population counts, cohort shares, active selection or imputation models, and influence functions for all study units.

With the default selection = "none", no observation-probability or imputation model is fitted. Instead, for each cohort and each date pair \((s, t)\) used by the estimator, the subset of units observed at both \(s\) and \(t\) is assumed representative of its cohort in the study population with respect to the mean outcome change: \[E[Y_t - Y_s \mid G = g, S_{s,t} = 1] = E[Y_t - Y_s \mid G = g].\] This is a population expectation condition in the study population; it does not require individual units to have identical outcome changes, realized sample means to agree across observation windows, or arbitrary missingness patterns to identify every target. Observation may depend on the time-invariant unit fixed effect \(\alpha_i\) removed by within-unit differencing, provided it does not select on the mean change.

Identification and inference also require: no treatment anticipation before \(g\) (anticipation = 0, so \(t = 2\) is cohort 3’s reference date); conditional parallel trends from the reference period (pt_assumption = "post"); overlap in the active comparison risk sets; for doubly robust estimation (est_method = "dr"), either a correctly specified treatment propensity logit or a correctly specified control outcome-change regression given xformla; and catalog support connecting each target date to the reference date with eligible controls.

In this design, the untreated conditional mean change from \(s\) to \(t\) is \((t - s)(0.4 + 0.2x)\) in both cohorts, so conditional parallel trends and the control regressions hold under either outcome-pooling choice. By default, outcome_pooling = "separate" lets each comparison have its own outcome-regression slopes. Set outcome_pooling = "pooled" to share slopes per calendar step when that restriction is credible. The treatment logit is also correctly specified by ~ x, and the independently assigned observation windows preserve the cohort mean changes in population.

3. Estimating and inspecting group-time effects

We fit chained doubly robust estimation with xformla = ~ x and selection = "none", combining the overlapping adjacent-period comparisons by feasible two-step GMM (gmm_weighting = "optimal"). Here we set bstrap = FALSE and cband = FALSE to compute fast analytical standard errors and pointwise normal confidence intervals; by default (bstrap = TRUE, cband = TRUE), cdid computes multiplier-bootstrap standard errors and simultaneous confidence bands.

fit <- cdid::att_gt(
  yname = "y",
  tname = "time",
  idname = "id",
  gname = "g",
  xformla = ~ x,
  data = panel_data,
  frame = units,
  control_group = "nevertreated",
  est_method = "dr",
  mode = "chained",
  selection = "none",
  pt_assumption = "post",
  bstrap = FALSE,
  cband = FALSE
)
summary(fit)
#> 
#> cdid chained difference-in-differences (chained doubly robust GMM): cdid_MP
#>   cdid 1.2.0.1 result built on did 2.5.1 (tested: did 2.5.1); did's MP methods tabulate the estimates below.
#>   Chained estimators: Bellego C., Benatia D. and V. Dortet-Bernadet (2025). "The Chained Difference-in-Differences." Journal of Econometrics 248, 105783. doi:10.1016/j.jeconom.2024.105783.
#>   Ordinary estimators: Callaway B. and P. H. C. Sant'Anna (2021). "Difference-in-Differences with Multiple Time Periods." Journal of Econometrics 225(2), 200-230. doi:10.1016/j.jeconom.2020.12.001.
#> 
#> Call:
#> cdid::att_gt(yname = "y", tname = "time", idname = "id", gname = "g", 
#>     xformla = ~x, data = panel_data, frame = units, control_group = "nevertreated", 
#>     est_method = "dr", mode = "chained", selection = "none", 
#>     pt_assumption = "post", bstrap = FALSE, cband = FALSE)
#> 
#> Group-Time Average Treatment Effects:
#>  Group Time ATT(g,t) Std. Error [95% Pointwise  Conf. Band]  
#>      3    2   0.0496     0.0917         -0.1302      0.2293  
#>      3    3   0.7934     0.0932          0.6107      0.9761 *
#>      3    4   1.2238     0.1356          0.9579      1.4896 *
#>      3    5   1.4039     0.1659          1.0788      1.7290 *
#> ---
#> Signif. codes: `*' confidence band does not cover 0
#> 
#> P-value for pre-test of parallel trends assumption:  0.588958075147391
#> Control Group:  Never Treated,  Anticipation Periods:  0
#> Estimation Method:  Doubly Robust
#> 
#> Chained support: 4 of 4 requested cells available.
#>   Units: 900 in the unit table, 0 observed at all 5 dates, 900 with at least two observed outcomes.
#>   Observation correction: none (selection = "none").
#>   Parallel trends: PT-post; base_period = "varying".
#>   GMM weighting: optimal (two-step); covariance regularization: not applied (moment covariance well conditioned).
#>   Inference: analytic standard errors, unit-level; pointwise intervals.
#>   Parallel-trends pre-test: W = 0.291974, df = 1, p = 0.589 (Wald test of the pretrend cell).

Compare the estimates with the population targets: 0 for the pre-treatment comparison reported at period 2, and 1.00, 1.25, 1.50 at periods 3, 4, 5. Realized estimates vary across samples; one draw does not establish bias, coverage, or relative efficiency. tidy() exposes cell-level estimates, availability and support reasons; glance() gives a one-row fit summary:

cells <- cdid::tidy(fit)
cells[, c("target.id", "role", "estimate", "std.error",
          "conf.low", "conf.high", "available",
          "support.status", "support.reason")]
#>      target.id role   estimate  std.error   conf.low conf.high available
#> 1 pre(g=3,t=2)  pre 0.04955105 0.09170237 -0.1301823 0.2292844      TRUE
#> 2 ATT(g=3,t=3) post 0.79343581 0.09322092  0.6107262 0.9761455      TRUE
#> 3 ATT(g=3,t=4) post 1.22375601 0.13563479  0.9579167 1.4895953      TRUE
#> 4 ATT(g=3,t=5) post 1.40388077 0.16587612  1.0787695 1.7289920      TRUE
#>              support.status
#> 1 supported_direct_pretrend
#> 2                 estimable
#> 3                 estimable
#> 4                 estimable
#>                                                                         support.reason
#> 1                                 direct auxiliary comparison 1->2 with controls {Inf}
#> 2 level anchored to the cohort reference through retained comparison rows (prop:graph)
#> 3 level anchored to the cohort reference through retained comparison rows (prop:graph)
#> 4 level anchored to the cohort reference through retained comparison rows (prop:graph)
cdid::glance(fit)
#>   nobs ngroup ntime control.group est.method                 estimator
#> 1  900      1     5  nevertreated         dr chained doubly robust GMM
#>   n.requested n.available                         band.family inference
#> 1           4           4 pointwise_normal_over_supported_ids  analytic
#>   clustered
#> 1     FALSE

4. Aggregating effects and event-study plotting

cdid::aggte() aggregates the retained cdid_MP result without refitting and inherits its inference settings unless overridden. We compute the overall post-treatment average (type = "simple") and event-time estimates (type = "dynamic"), inspect the tidy dynamic table, and plot the event study with cdid::ggdid():

agg_simple <- cdid::aggte(fit, type = "simple")
agg_dynamic <- cdid::aggte(fit, type = "dynamic")

summary(agg_simple)
#> 
#> cdid chained difference-in-differences (chained doubly robust GMM): cdid_AGGTEobj
#>   cdid 1.2.0.1 result built on did 2.5.1 (tested: did 2.5.1); did's AGGTEobj methods tabulate the estimates below.
#>   Chained estimators: Bellego C., Benatia D. and V. Dortet-Bernadet (2025). "The Chained Difference-in-Differences." Journal of Econometrics 248, 105783. doi:10.1016/j.jeconom.2024.105783.
#>   Ordinary estimators: Callaway B. and P. H. C. Sant'Anna (2021). "Difference-in-Differences with Multiple Time Periods." Journal of Econometrics 225(2), 200-230. doi:10.1016/j.jeconom.2020.12.001.
#> 
#> Call:
#> cdid::aggte(fit, type = "simple")
#> 
#>     ATT    Std. Error     [ 95%  Conf. Int.]  
#>  1.1404        0.1184     0.9083      1.3724 *
#> 
#> 
#> ---
#> Signif. codes: `*' confidence band does not cover 0
#> 
#> Control Group:  Never Treated,  Anticipation Periods:  0
#> Estimation Method:  Doubly Robust
#> 
#> Chained simple aggregation: 1 of 1 intended aggregate(s) available; na.rm = FALSE.
#>   Parallel trends: PT-post; base_period = "varying".
#>   Inference: analytic standard errors, unit-level; pointwise intervals.
cdid::tidy(agg_dynamic)[, c("label", "estimate",
                            "std.error", "conf.low", "conf.high",
                            "available", "reason")]
#>      label   estimate  std.error   conf.low conf.high available          reason
#> 1 event_-1 0.04955105 0.09170237 -0.1301823 0.2292844      TRUE intended_target
#> 2  event_0 0.79343581 0.09322092  0.6107262 0.9761455      TRUE intended_target
#> 3  event_1 1.22375601 0.13563479  0.9579167 1.4895953      TRUE intended_target
#> 4  event_2 1.40388077 0.16587612  1.0787695 1.7289920      TRUE intended_target
cdid::ggdid(agg_dynamic)

5. Advanced options and further documentation

Beyond the baseline specification above, cdid supports:

  • Treatment adjustment (est_method): "dr" (default), "reg" (separate control outcome-change regressions by default, with outcome_pooling = "pooled" available), "ipw", or custom did-style IPW callbacks.
  • Covariate-dependent missingness (selection = "conditional_mtar"): observation weighting (selection_est_method = "ipw" with selection_model = "pair_logit" or "independent_period_logit"), actual-change imputation (selection_est_method = "imputation"), or their augmented combination (selection_est_method = "dr" with treatment IPW, OR or DR), with configurable slope pooling (selection_pooling, imputation_pooling), separate selection formulas (selection_xformla), and subset fitting universes (selection_universe).
  • Parallel trends, weights, clustering, and pretests: additional untreated-trend restrictions from an explicit common start date (pt_assumption = "all" with pt_start), reference-date covariates and fix_weights, clustered inference (clustervars), and the chained conditional pretrend test (cdid::conditional_did_pretest()).

See the installed help pages (?cdid::att_gt, ?cdid::aggte, ?cdid::conditional_did_pretest, ?cdid::reexports) and the repository documentation at https://github.com/dvbn/cdid, especially docs/help/prepare-data.md, docs/help/investigate-missingness.md, docs/methods/missing-outcomes.md, and the companion notebook.