Partially pooled entropy synthetic controls

A standalone balnet implementation for complete and incomplete panels

Author

balmultisynth development note

Entropy synthetic control uses positive donor weights that sum to one. This implementation balances each adoption cohort’s pre-treatment outcomes and the average imbalance across cohorts, using the calibration solver in balnet. Missing outcomes enter through explicit observation masks. It is a separate estimator and repository, not a replacement for the published TWFE replication archive.

Fitting objective

For group g, let w_g be its donor simplex, q_g its positive prior weights, and a_g its share of treated units, with \sum_g a_g=1. Groups can be adoption cohorts or individual treated units. Donors are never-treated units with an observed pre-treatment baseline. Only pre-treatment data determine weights.

The objective is

\frac{1-\nu}{2}\sum_g a_g\frac{1}{|E_g|}\sum_{e\in E_g}\delta_{ge}^2 +\frac{\nu}{2|E|}\sum_{e\in E} \left(\sum_{g:e\in E_g}\frac{a_g}{A_e}\delta_{ge}\right)^2 +\lambda\sum_g a_g\sum_j w_{gj}\log\frac{w_{gj}}{q_{gj}},

where E_g contains the group’s usable pre-treatment event times, E=\bigcup_g E_g, and A_e=\sum_{g:e\in E_g}a_g.

  • \nu=0 fits separate group-specific balance objectives.
  • \nu=1 balances the pooled event-time trajectory.
  • \lambda>0 trades balance against departure from the donor prior.

Entropy replaces a squared-weight/minimum-variance penalty. Ridge in the balnet call penalizes dual balance coefficients, not donor weights. The resulting donor weights are positive and sum to one. The parameter scaling is explicit above; it is not numerically interchangeable with augsynth’s normalized pooling parameter or regularization.

With fixed effects enabled, each donor and treated unit is demeaned using its own observed outcomes in the group’s pre-treatment window. A treated unit’s baseline is added back after predicting its demeaned outcome. This makes the fit invariant to unit-specific constant shifts.

Missing outcomes

Let M_{jt} indicate an observed donor outcome and Z_{jt} its demeaned value. For a usable group-period (g,t), compute the observed treated-group mean \bar Z_{gt} and fix the observed donor mass to its prior value:

c_{gt}=\sum_j q_{gj}M_{jt}>0,\qquad \sum_j w_{gj}M_{jt}=c_{gt}.

The balancing moment is then

\delta_{g,t-g}=\sum_j w_{gj} \frac{M_{jt}(Z_{jt}-\bar Z_{gt})}{c_{gt}} =\frac{\sum_j w_{gj}M_{jt}Z_{jt}}{\sum_j w_{gj}M_{jt}}-\bar Z_{gt}.

A missing entry contributes nothing, not a zero-valued outcome. Fixing coverage keeps the objective convex and prevents reducing measured imbalance by moving weight onto missing donors. It is a deliberate extra restriction: elaborate missingness patterns can constrain weights strongly, even forcing the prior. The fit reports these constraints, effective donor counts and balance residuals.

Post-treatment predictions renormalize weights over donors observed in that period. If an observed treated outcome has insufficient donor support, the fit fails explicitly. Missing treated outcomes are excluded from the observed cell ATT; they are not recovered by this procedure. Identifying a full-panel ATT under informative missingness requires additional assumptions or a model for observation probabilities.

Treatment timing is supplied as a known adoption schedule, not inferred from missing treatment indicators. This implementation accepts missing outcomes, not unknown adoption dates or treatment reversals.

Why this is actually a balnet fit

Each donor is represented once per group, with prior sample mass a_gq_{gj}; one target row per group has mass a_g. Unpenalized group indicators enforce the group simplex, and unpenalized mask moments enforce observed donor mass. Scaled local and pooled balance columns encode the two squared losses.

balnet(..., target="ATT", alpha=0, lambda=lambda, standardize=FALSE) solves this joint calibration problem. Its scaled loss is

\sum_{g,j} a_gq_{gj}\exp(-\eta_{gj}) +\sum_g a_g\eta_g+\frac{\lambda}{2}\|\beta\|_2^2.

The implied donor masses are a_gq_{gj}\exp(-\eta_{gj}); eliminating the dual coefficients gives the entropy objective above. Redundant hard constraints are removed by a rank-revealing QR, but all original coverage constraints are checked after fitting. Independent KKT residuals must pass before a fit is returned. No separate optimizer supplies production weights.

Executable staggered-panel example

Code
.libPaths(c(normalizePath("../.Rlib"), .libPaths()))
library(balmultisynth)
set.seed(417)
N <- 100; T <- 24
g <- c(rep(Inf, 70), rep(13, 15), rep(17, 15))
u <- rnorm(N); loading <- runif(N, -.6, .6)
Y0 <- outer(u, rep(1, T)) + outer(rep(1, N), sin(seq_len(T) / 4)) +
  outer(loading, seq_len(T) / T) + matrix(rnorm(N * T, sd = .12), N, T)
W <- outer(g, seq_len(T), `<=`)
Y <- Y0 + 1.5 * W
M <- matrix(runif(N * T) > .12, N, T)
Ym <- Y; Ym[!M] <- NA_real_
fits <- list(
  complete = bal_multisynth(Y, g, nu = .5, lambda = .05),
  missing = bal_multisynth(Ym, g, nu = .5, lambda = .05)
)
knitr::kable(do.call(rbind, lapply(fits, summary)), digits = 6)
estimate treated_cells fits nu lambda kkt_max coverage_error minimum_effective_donors
complete 1.446399 300 2 0.5 0.05 0 0 69.89941
missing 1.466282 257 2 0.5 0.05 0 0 69.84728

The treatment effect is 1.5 in this simulation. Outcomes are in original units; the missingness mechanism is independent of the generated data.

Code
a <- fits$complete$event_study; b <- fits$missing$event_study
plot(a$event_time, a$estimate, type = "b", pch = 16, col = "#156a8a",
     xlab = "Event time", ylab = "Estimated effect", ylim = range(a$estimate, b$estimate, 0, 1.5))
lines(b$event_time, b$estimate, type = "b", pch = 1, col = "#a85721")
lines(c(min(a$event_time), -.5, -.5, max(a$event_time)), c(0, 0, 1.5, 1.5), lty = 2)
legend("topleft", c("Complete", "Missing outcomes", "True effect"),
       col = c("#156a8a", "#a85721", "black"), lty = c(1, 1, 2), pch = c(16, 1, NA), bty = "n")

Estimated event paths. The dashed line is the simulated effect; points use observed treated outcomes.
Code
matplot(do.call(cbind, fits$missing$weights), type = "h", lty = 1,
        col = c("#156a8a", "#a85721"), xlab = "Never-treated donor", ylab = "Weight")
legend("topright", c("Adoption 13", "Adoption 17"),
       col = c("#156a8a", "#a85721"), lty = 1, bty = "n")

Donor weights for the two cohorts in the incomplete panel.

Validation and scope

The tests compare fitted weights and objective values to an independent primal optimization on small panels, verify the unpooled limit, check invariance to masked stored values and unit offsets, and confirm that changing treated post-outcomes changes effects but not weights. Hard coverage and KKT checks run on every fitted model, not only in tests.

Small problems use one dense balnet design. When the configurable allocation limit would be exceeded, balnet solves successive conditional group problems for the same joint objective. Every sweep updates the pooled residual, and the final fit must satisfy the global primal stationarity and coverage checks. This permits individual fits without storing the mostly-zero joint matrix. Centering the calibration features and jointly rescaling the soft features and penalty improves numerical conditioning without changing the primal minimizer. Pooling and penalty parameters can be supplied explicitly, or selected with the balance-first continuation interface. The Credit comparison adds fixed-fit country-clustered wild-bootstrap intervals using the same draws as augsynth. Cross-validation and a general full-refit bootstrap API are not implemented.

Credit Markets comparison: the same real-data sample and scalar target used by augsynth, with selected tuning, pre-treatment RMSE, bootstrap intervals and event paths.

Source and tests · Design specification · Development log