Estimands: ATT, ATE, ATC

ebalance() reweights one or both groups to match a target moment distribution. Which target you pick determines the estimand — the average treatment effect on a particular subpopulation. As of 0.3-0, ebalance(..., estimand = "ATT" / "ATE" / "ATC") selects:

estimand reweighted target moments answers
"ATT" (default) controls treated group means “what was the effect on those who actually got treatment?”
"ATC" treated control group means “what would the effect have been on the control population if it had been treated?”
"ATE" both overall sample means “what is the average effect across the whole population?”

This vignette builds the same toy panel and shows what changes across the three estimands.

A toy panel

library(ebal)
set.seed(20260505)
n0 <- 200; n1 <- 100
X <- rbind(
  replicate(3, rnorm(n0, mean = 0)),       # controls
  replicate(3, rnorm(n1, mean = 0.5))      # treated, shifted
)
colnames(X) <- c("x1", "x2", "x3")
treatment <- c(rep(0, n0), rep(1, n1))

c(treated_mean = colMeans(X[treatment == 1, ])[1],
  control_mean = colMeans(X[treatment == 0, ])[1],
  overall_mean = mean(X[, 1]))
#> treated_mean.x1 control_mean.x1    overall_mean 
#>      0.51489188      0.06796959      0.21694369

The treated group means are above zero; the controls are at zero; the overall mean is somewhere between.

Three fits

fit_att <- ebalance(Treatment = treatment, X = X, estimand = "ATT")
fit_ate <- ebalance(Treatment = treatment, X = X, estimand = "ATE")
fit_atc <- ebalance(Treatment = treatment, X = X, estimand = "ATC")

weights(fit) returns a length-n vector aligned to the original treatment indicator. The shape changes with the estimand:

table(treatment, sign(weights(fit_att)))   # ATT: treated = 1, controls reweighted
#>          
#> treatment   1
#>         0 200
#>         1 100
table(treatment, sign(weights(fit_ate)))   # ATE: both reweighted
#>          
#> treatment   1
#>         0 200
#>         1 100
table(treatment, sign(weights(fit_atc)))   # ATC: treated reweighted, controls = 1
#>          
#> treatment   1
#>         0 200
#>         1 100

Where the weight goes

For ATT, the controls are reweighted toward the treated mean (so their weighted mean equals the treated mean):

weighted.mean(X[treatment == 0, 1], w = weights(fit_att)[treatment == 0])
#> [1] 0.5151681
mean(X[treatment == 1, 1])
#> [1] 0.5148919

For ATE, both groups are reweighted toward the overall mean:

weighted.mean(X[treatment == 0, 1], w = weights(fit_ate)[treatment == 0])
#> [1] 0.2169915
weighted.mean(X[treatment == 1, 1], w = weights(fit_ate)[treatment == 1])
#> [1] 0.2152382
mean(X[, 1])
#> [1] 0.2169437

For ATC, the treated are reweighted toward the control mean:

weighted.mean(X[treatment == 1, 1], w = weights(fit_atc)[treatment == 1])
#> [1] 0.06780325
mean(X[treatment == 0, 1])
#> [1] 0.06796959

Diagnostics

glance() returns a one-row “is this fit usable?” summary, with per-side ESS / max-weight ratios and the worst pre/post standardized difference:

library(generics)
do.call(rbind, lapply(list(ATT = fit_att, ATE = fit_ate, ATC = fit_atc),
                      glance))[, c("estimand", "ess_control", "ess_treated",
                                   "max_weight_ratio_control",
                                   "max_weight_ratio_treated",
                                   "max_abs_std_diff_post")]
#>     estimand ess_control ess_treated max_weight_ratio_control
#> ATT      ATT    115.0302   100.00000                 8.805591
#> ATE      ATE    188.8541    79.50143                 2.244101
#> ATC      ATC    200.0000    59.85559                 1.000000
#>     max_weight_ratio_treated max_abs_std_diff_post
#> ATT                 1.000000          0.0002689209
#> ATE                 2.924031          0.0030481091
#> ATC                 4.900253          0.0004691477

Read these top-down: ATT keeps the treated side trivial (ESS = 100, ratio = 1) and concentrates weight on a subset of controls. ATE reweights both sides, so both ESS values fall below their group sizes. ATC mirrors ATT with roles swapped.

Plots

autoplot(fit, type = "balance") is the Love plot of standardized differences before vs. after weighting. autoplot(fit, type = "weights") is a histogram of the per-unit weights with the Kish ESS and max-weight ratio in the subtitle:

library(ggplot2)
autoplot(fit_ate, type = "weights")

Identical interfaces are available in base graphics via plot(fit, type = ...).

Choosing an estimand

A short rule of thumb:

  • ATT when the policy question is about people who actually got treated (most program-evaluation work). Controls are a tool for imputing the counterfactual; you don’t need to extrapolate to them.
  • ATE when you want a population-level claim (“if everyone got this treatment…”) and you trust that the treatment effect is similar across the covariate distribution.
  • ATC when the policy question is about the controls (“would extending this program to non-recipients have helped?”); be honest about the extrapolation involved.

ebalance() doesn’t make this choice for you — it just gives you the weights once you’ve made it. The associated standard error / inference question is independent (see vignette("outcome-models", package = "ebal")).