---
title: "Partially pooled entropy synthetic controls"
subtitle: "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](https://github.com/erikcs/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
```{r}
.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)
```
The treatment effect is 1.5 in this simulation. Outcomes are in original units;
the missingness mechanism is independent of the generated data.
```{r}
#| fig-width: 8
#| fig-height: 4.5
#| fig-cap: "Estimated event paths. The dashed line is the simulated effect; points use observed treated outcomes."
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")
```
```{r}
#| fig-width: 8
#| fig-height: 4
#| fig-cap: "Donor weights for the two cohorts in the incomplete panel."
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")
```
## 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](tuning.html). The [Credit comparison](credit.html) 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](credit.qmd): 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](https://github.com/apoorvalal/balmultisynth) ·
[Design specification](https://github.com/apoorvalal/balmultisynth/blob/feat/entropy-multisynth/devspec.md) ·
[Development log](https://github.com/apoorvalal/balmultisynth/blob/feat/entropy-multisynth/devlog.md)