14  Interpreting OLS: Outcome Weights

The companion chapter Interpreting OLS: Effect Weights treats the OLS coefficient as a weighted average of unobserved effects and asks whose effect \(\hat\beta\) estimates. We can read the same Frisch–Waugh–Lovell identity as a weighting of observed outcomes. This second reading asks which rows move \(\hat\beta\) and in which direction. Both sets of weights can be calculated in OLS, but only here is the thing being weighted also observed: every contribution \(\omega_i y_i\) is in the data. The outcome representation also extends beyond OLS.

14.1 The other reading of FWL

Residualise the regressor of interest \(V\) on the controls \(X\), write \(\tilde V_i\) for the residual, and recall the residual-on-residual form of the coefficient, Equation 13.2:

\[ \hat\beta = \frac{\sum_{i=1}^n \tilde V_i y_i}{\sum_{i=1}^n \tilde V_i^2}. \tag{14.1}\]

The previous chapter groups \(\tilde V_i\) with \(V_i\) and writes \(\hat\beta\) as a weighting \(w_i=\tilde V_iV_i\) of effects (Equation 13.3). If we instead group each residual with its outcome \(y_i\), the same expression becomes a linear combination of the observed outcomes:

\[ \hat\beta = \sum_{i=1}^n \omega_i y_i, \qquad \omega_i = \frac{\tilde V_i}{\sum_{j=1}^n \tilde V_j^2}. \tag{14.2}\]

Equation 14.2 is the same algebra rearranged, but a different object from the effect weights.

Weights on outcomes \(\omega_i\) Weights on effects \(w_i\)
Weights what the observed \(y_i\) that observation’s own effect \(\beta_i\)
Sign signed, sums to zero can be signed; non-negative for binary \(D\) with saturated controls
Computable weight and weighted outcome weight yes; weighted effect no, because \(\beta_i\) is unseen
Holds when always, it is algebra as a causal average when the error remainder vanishes
Answers which rows drive \(\hat\beta\), any wrong sign is this the effect I meant, whose effect

Two constraints pin \(\omega_i\) down, both from Equation 14.2. An intercept among the controls makes \(\sum_i \tilde V_i = 0\), so the weights sum to zero. And \(\sum_i \tilde V_i V_i = \sum_i \tilde V_i^2\) makes the weighted regressor sum to one, \(\sum_i \omega_i V_i = 1\).

Use this reading when overlap is in doubt and a row may enter with the wrong sign. For binary treatment, the effect weight \(w_i=(D_i-\hat D_i)D_i\) can flag a treated row with \(\hat D_i>1\), but every control has effect weight zero; the outcome weights expose both sides of the realised comparison. Keep the effect reading for questions about the estimand.

14.2 What the outcome weights look like

Take the simple regression of \(y\) on \(x\) with an intercept, so \(\tilde V_i = x_i - \bar x = d_i\) and

\[ \omega_i = \frac{d_i}{\sum_j d_j^2}. \tag{14.3}\]

Unlike the leverage weights \(d_i^2\) on the observed deviation slopes in the companion chapter — non-negative, growing with the square of distance — this is linear in \(d_i\), and so signed: a point left of \(\bar x\) enters \(\hat\beta\) with a negative coefficient on its outcome, a point right of it positive. The structural effect weight is instead \(d_ix_i\), which need not equal \(d_i^2\). The outcome weight is not a slope and does not involve \(\bar y\); it is simply a signed weight on the observed \(y_i\).

Simulation — simple regression. To see it, we simulate \(n = 100\) points — regressor \(x_i \sim N(5, 2^2)\) and outcome \(y_i = 2 x_i + \varepsilon_i\) with \(\varepsilon_i \sim N(0, 3^2)\) — then size each point by \(|\omega_i|\) and colour it by sign.

Code
set.seed(3)
n <- 100; x <- rnorm(n, 5, 2); y <- 2 * x + rnorm(n, 0, 3)
xb <- mean(x); d <- x - xb; om <- d / sum(d^2); col <- ifelse(om >= 0, teal, rust)
R <- d > 0; L <- d < 0
xR <- sum(abs(d[R]) * x[R]) / sum(abs(d[R])); yR <- sum(abs(d[R]) * y[R]) / sum(abs(d[R]))
xL <- sum(abs(d[L]) * x[L]) / sum(abs(d[L])); yL <- sum(abs(d[L]) * y[L]) / sum(abs(d[L]))
plot(x, y, pch = 16, cex = 0.35 + 2 * abs(om) / max(abs(om)),
     col = paste0(col, "99"), xlab = "x", ylab = "y", main = "N = 100 Points")
abline(v = xb, lty = 3, col = "grey55")
abline(lm(y ~ x), lwd = 1.3, lty = 2, col = "grey55")
segments(xL, yL, xR, yR, lwd = 2.6, col = "grey15")
points(c(xL, xR), c(yL, yR), pch = 21, bg = c(rust, teal), col = "white", cex = 2.3, lwd = 2)

Outcome weights in a 100-point sample. Each small point’s size scales with its weight \(|\omega_i|\), coloured by sign — teal (\(\omega_i > 0\)) pushes \(\hat\beta\) up, rust (\(\omega_i < 0\)) pulls it down; the dotted line is the fulcrum \(x = \bar x\). The two large dots are the \(|d|\)-weighted centroids of the points left and right of \(\bar x\); the solid segment joining them has slope \(\hat\beta\), and the dashed line is the OLS fit (same slope, shifted through the grand mean).
Code
vtab(c(`$\\sum_i \\omega_i$`   = sum(om),      `$\\sum_i \\omega_i x_i$` = sum(om * x),
        `positive mass` = sum(om[om > 0]), `negative mass` = sum(om[om < 0]),
        `OLS`          = coef(lm(y ~ x))[[2]], `$\\sum_i \\omega_i y_i$` = sum(om * y)), 4)
value
\(\sum_i \omega_i\) 0.0000
\(\sum_i \omega_i x_i\) 1.0000
positive mass 0.2486
negative mass -0.2486
OLS 1.8648
\(\sum_i \omega_i y_i\) 1.8648

Outcome weights in a 100-point sample. Each small point’s size scales with its weight \(|\omega_i|\), coloured by sign — teal (\(\omega_i > 0\)) pushes \(\hat\beta\) up, rust (\(\omega_i < 0\)) pulls it down; the dotted line is the fulcrum \(x = \bar x\). The two large dots are the \(|d|\)-weighted centroids of the points left and right of \(\bar x\); the solid segment joining them has slope \(\hat\beta\), and the dashed line is the OLS fit (same slope, shifted through the grand mean).

The printed sums confirm Equation 14.2: the weights split at \(\bar x\) into a positive mass (\(\approx +0.25\)) and an equal negative one (\(\approx -0.25\)), and \(\sum_i \omega_i x_i = 1\).

But the figure shows more. Split the points at \(\bar x\), weight each side by \(|d_i|\), and take its centroid — the two large dots. The segment joining them has slope \(\hat\beta\): the OLS slope is the rise over run between the low-\(x\) and high-\(x\) weighted centres.

The dashed fit shares that slope, shifted through the grand mean, so the two coincide only when the sides balance. This collapse to two points falls out of the slope formula.

14.2.1 Why it collapses to two points

Write \(d_i = x_i - \bar x\). Because \(\sum_i d_i = 0\), the positive and negative sides carry equal total weight \(W = \sum_{i: d_i > 0} d_i\). An inner product with \(d\) is therefore a between-group contrast: for any variable \(v\),

\[ d \cdot v = W(\bar v_R - \bar v_L), \tag{14.4}\]

where \(\bar v_R\) and \(\bar v_L\) are the \(|d_i|\)-weighted means on each side of \(\bar x\). The slope \(\hat\beta = d \cdot y\, /\, d \cdot x\) is the ratio of two such contrasts; \(W\) cancels:

\[ \hat\beta = \frac{\bar y_R - \bar y_L}{\bar x_R - \bar x_L}. \tag{14.5}\]

That is the rise over run between the two \(|d|\)-weighted centroids — the two dots in the figure.

14.3 When the sign flips

In simple regression the sign of \(\omega_i\) is just the side of the mean: half the weights are negative, structurally, and a low-\(x\) point should count against a high-\(x\) one. With controls \(\tilde V_i\) becomes a residual, and a row can flip to the sign its raw position denies. Outcome weights reveal that realised extrapolation for treated and control rows alike; binary effect weights omit the control rows because \(D_i=0\) makes their effect weight zero.

With a binary treatment \(D\) and covariate \(x\), the weight \(\omega_i \propto \tilde V_i = D_i - \hat D_i\), with \(\hat D\) the fitted linear projection of \(D\) on \((1, x)\). Regressed on \(D\) alone, every treated unit is positive and every control negative — the structural pattern, producing \(\hat\beta = \bar y_1 - \bar y_0\). Add \(x\) and the fitted \(\hat D_i\) can leave \([0, 1]\), often where treatment and control overlap poorly. A treated unit with \(\hat D_i > 1\) gets \(\tilde V_i < 0\), so its outcome is subtracted from \(\hat\beta\); a control with \(\hat D_i<0\) gets \(\tilde V_i>0\), so its outcome is added. These are wrong-sign outcome weights, evidence that the fitted linear comparison is extrapolating.

Two qualifications keep this diagnostic in its lane. First, out-of-range the other way is not a sign reversal: a treated unit with \(\hat D_i<0\) remains positive and a control with \(\hat D_i>1\) remains negative. Second, \(\hat D_i\) is a sample fitted value. Leaving \([0,1]\) is evidence of realised linear extrapolation, not proof that the population propensity is out of range or that common support literally fails; sampling error and functional-form approximation can also produce it. Conversely, in-range fits do not establish overlap or the conditional mean-independence assumption needed for a design-based causal interpretation. With a continuous regressor there is no bounded range to leave, so leverage rather than sign carries the diagnostic.

A wrong-sign outcome weight means the fitted regression contrast reaches beyond its conventional treated-versus-control comparison, and \(\hat\beta\) can leave the convex hull of the observed outcomes. Whether it matters depends on the data — a sensitivity check is one trim away.

Simulation — thin overlap on a curved baseline. The DGP: 150 controls near \(x = 2.5\) and 150 treated near \(x = 4.5\) (both sd \(1.1\)), with \(y_i = D_i + 0.8\,(x_i - 3.5)^2 + 0.3\,x_i + \varepsilon_i\), \(\varepsilon_i \sim N(0, 0.5^2)\) — a constant true effect of \(1\) sitting on a convex baseline.

Code
set.seed(7)
n <- 150
x <- c(rnorm(n, 2.5, 1.1), rnorm(n, 4.5, 1.1))
d <- c(rep(0, n), rep(1, n))
y <- 1.0 * d + 0.8 * (x - 3.5)^2 + 0.3 * x + rnorm(2 * n, 0, 0.5)
df <- data.frame(y, d, x)
X <- cbind(1, x); dh <- as.vector(X %*% solve(crossprod(X)) %*% t(X) %*% d)
flag <- (d == 1 & dh > 1) | (d == 0 & dh < 0)
pch <- ifelse(d == 1, 17, 16); col <- ifelse(d == 1, teal, rust)
plot(x, y, type = "n", xlab = "x", ylab = "y")
xs <- seq(min(x), max(x), length = 200)
mc <- function(z) 0.8 * (z - 3.5)^2 + 0.3 * z              # true control mean E[y | x, D = 0]
lines(xs, mc(xs),     col = rust, lwd = 2.4)               # control mean
lines(xs, mc(xs) + 1, col = teal, lwd = 2.4, lty = 3)      # treated mean = control + true effect (1)
points(x, y, pch = pch, col = paste0(col, "88"), cex = 0.85)               # all points, by group
points(x[flag], y[flag], pch = 1, col = "grey15", cex = 1.7, lwd = 1.4)    # ring the flagged units
arrows(3.5, mc(3.5), 3.5, mc(3.5) + 1, code = 3, length = 0.05, angle = 90, lwd = 2.4, col = "grey10")
segments(3.5, mc(3.5) + 0.5, 2.05, 13.3, col = "grey55", lwd = 1)
text(2.0, 14, "gap = true effect = 1", pos = 4, cex = 0.82, col = "grey10")
legend("bottomright", bty = "n", cex = 0.82,
       legend = c("control", "treated", "flagged (trimmed)", "control mean", "treated mean"),
       pch = c(16, 17, 1, NA, NA), lty = c(NA, NA, NA, 1, 3),
       col = c(rust, teal, "grey15", rust, teal))

Thin overlap on a convex baseline. The two curves are the true means — control (rust, solid) and treated (teal, dotted), a constant \(1\) apart (arrow). Controls sit low on the curve and treated high; the groups share only the middle. Ringed points have wrong-sign outcome weights: treated units with \(\hat D_i>1\) or controls with \(\hat D_i<0\). The straight \(y \sim D + x\) reports \(\hat\beta = 0.69\); dropping the flagged units, or reweighting to the overlap, restores \(\approx 1\).
Code
w_ato <- WeightIt::weightit(d ~ x, data = df, method = "glm", estimand = "ATO")
vtab(c(`flagged units`      = sum(flag),
        `$\\hat\\beta$ (full)`        = coef(lm(y ~ d + x))[["d"]],
        `$\\hat\\beta$ (trimmed)`     = coef(lm(y ~ d + x, data = df[!flag, ]))[["d"]],
        `$\\hat\\beta$ (overlap-wtd)` = coef(lm(y ~ d + x, weights = w_ato$weights))[["d"]]), 3)
value
flagged units 37.000
\(\hat\beta\) (full) 0.688
\(\hat\beta\) (trimmed) 0.991
\(\hat\beta\) (overlap-wtd) 0.979

Thin overlap on a convex baseline. The two curves are the true means — control (rust, solid) and treated (teal, dotted), a constant \(1\) apart (arrow). Controls sit low on the curve and treated high; the groups share only the middle. Ringed points have wrong-sign outcome weights: treated units with \(\hat D_i>1\) or controls with \(\hat D_i<0\). The straight \(y \sim D + x\) reports \(\hat\beta = 0.69\); dropping the flagged units, or reweighting to the overlap, restores \(\approx 1\).

Thirty-seven units carry the wrong sign, all in the non-overlapping tails. Trim them and \(\hat\beta\) jumps from \(0.69\) to \(0.99\); the overlap weights ("ATO", Li, Morgan, and Zaslavsky 2018) give \(0.98\). Either fix confirms the estimate leaned on the extrapolation, read off the regression itself with no separate model. Had the outcome been linear, the same trim would barely move \(\hat\beta\): the wrong-sign weights are still there, but they are harmless. The check tells you which case you are in.

This is the implied-weights diagnostic of Chattopadhyay and Zubizarreta (2023); their lmw package computes the weights and the balance, extrapolation, and influence diagnostics built on them, and OutcomeWeights (Knaus 2024) carries it to machine-learning estimators. The mainstream overlap check takes the other route, through the propensity score (WeightIt for estimation, cobalt for balance, and Crump et al. 2009 for trimming); both routes reach the same conclusion.

14.4 Out of OLS: the same weights beyond the linear model

The outcome reading has one advantage the effect reading lacks: it survives leaving OLS. Any estimator that can be written \(\hat\tau = \sum_i \omega_i Y_i = \boldsymbol\omega' \boldsymbol Y\) — a linear combination of the observed outcomes — has outcome weights, whatever machinery produced them.

Knaus (2024) makes this the organising idea, and his OutcomeWeights package computes \(\boldsymbol\omega\) for AIPW, double machine learning, and the causal forest.

14.4.1 The weights, derived

The OLS outcome weights (Equation 14.2) followed from one fact: in the FWL numerator \(\sum_i \tilde V_i y_i\), the outcome \(y_i\) appears once, linearly, so \(\hat\beta = \sum_i \omega_i y_i\) with \(\omega_i = \tilde V_i / \sum_j \tilde V_j^2\). The same fact holds whenever the outcome model is a smoother — a map \(\hat{\boldsymbol m} = \boldsymbol S \boldsymbol Y\) that is linear in the training outcomes. Substituting \(\boldsymbol S \boldsymbol Y\) for \(\hat{\boldsymbol m}\) keeps \(\boldsymbol Y\) linear in the numerator, and reading off its coefficient gives \(\boldsymbol\omega\). We carry this out for PLR and AIPW.

PLR. Take the partially-linear estimator (the R-learner behind the causal forest). It is FWL with the projections done by a flexible learner: residualise treatment and outcome on the covariates, then regress one residual on the other,

\[ \hat\tau = \frac{\sum_i (D_i - \hat e_i)(Y_i - \hat m_i)}{\sum_i (D_i - \hat e_i)^2}, \tag{14.6}\]

with \(\hat e = \hat E[D \mid X]\) and \(\hat m = \hat E[Y \mid X]\) the fitted nuisances.

The outcome nuisance is a smoother: its fitted values are a linear map of the training outcomes, \(\hat{\boldsymbol m} = \boldsymbol S \boldsymbol Y\) — true for OLS, ridge, kernels, and honest forests alike. The word honest carries weight there. If the learner’s structure is itself chosen using the outcomes — splits grown on the same \(\boldsymbol Y\) being predicted, a lasso whose active set is outcome-selected, boosting — then \(\boldsymbol S\) depends on \(\boldsymbol Y\) and the map stops being linear in it. What follows then holds conditional on the fitted learner, which is the weaker claim: the weights describe the estimate you got rather than a globally linear estimator. Honesty and cross-fitting are what restore the clean reading, by fitting the structure on data other than the outcomes being weighted. So the outcome residual is \(\boldsymbol Y - \hat{\boldsymbol m} = (\mathbf I - \boldsymbol S)\boldsymbol Y\). Writing \(\tilde{\boldsymbol D} = \boldsymbol D - \hat{\boldsymbol e}\) for the treatment residual and substituting,

\[ \hat\tau = \frac{\tilde{\boldsymbol D}'(\mathbf I - \boldsymbol S)\,\boldsymbol Y}{\tilde{\boldsymbol D}'\tilde{\boldsymbol D}}. \tag{14.7}\]

Now \(\boldsymbol Y\) appears once, linearly, in the numerator. Read off its coefficient and the weights are done:

\[ \hat\tau = \boldsymbol\omega'\boldsymbol Y, \qquad \boldsymbol\omega' = \frac{\tilde{\boldsymbol D}'(\mathbf I - \boldsymbol S)}{\tilde{\boldsymbol D}'\tilde{\boldsymbol D}}. \tag{14.8}\]

That is Equation 14.2 — the OLS weights — with the projection annihilator \(\boldsymbol M_X\) replaced by the smoother annihilator \(\mathbf I - \boldsymbol S\). OLS is the case of a linear learner, \(\boldsymbol S = \boldsymbol P_X\): there \(\boldsymbol S\) is a projection, \(\mathbf I - \boldsymbol S = \boldsymbol M_X\), and Equation 14.8 is Equation 14.2 exactly. Component by component, the weight on observation \(i\) is

\[ \omega_i = \frac{\tilde D_i - \sum_j S_{ji}\,\tilde D_j}{\sum_k \tilde D_k^2}: \tag{14.9}\]

the treatment residual of unit \(i\), minus how much other units’ treatment residuals leak to \(i\) through the outcome smoother, normalised by the total treatment-residual variance.

The outcome weights tell which rows drive the estimate; the effect weights tell whose effect it estimates. Substitute \(Y_i = \tau_i D_i + m(X_i) + \varepsilon_i\) into the PLR numerator. With consistent nuisances \(\hat m \approx m\), the noise vanishes in expectation and the numerator reduces to \(\sum_i \tilde D_i D_i \tau_i\), so the effect weight on \(\tau_i\) is \(\tilde D_i D_i = (D_i - e_i) D_i\). Since \(D\) is binary (\(D_i^2 = D_i\)),

\[ E[\tilde D_i D_i \mid X_i] = E[D_i \mid X_i] - e_i\, E[D_i \mid X_i] = e_i - e_i^2 = e_i(1 - e_i). \tag{14.10}\]

The denominator \(\tilde{\boldsymbol D}'\tilde{\boldsymbol D} = \sum_i \tilde D_i^2\) has the same expectation, \(\sum_i e_i(1 - e_i)\), since \(\text{Var}(D_i \mid X_i) = e_i(1 - e_i)\). Both numerator and denominator weight by propensity variance, and the ratio is the variance-weighted average treatment effect,

\[ \tau_{\text{VWATE}} = \frac{\sum_i e(X_i)(1 - e(X_i))\,\tau(X_i)}{\sum_i e(X_i)(1 - e(X_i))}, \tag{14.11}\]

not the plain ATE \(\bar\tau = n^{-1}\sum_i \tau_i\). The two coincide when effects are constant, or when treatment is randomly assigned so that \(e(1-e)\) is the same everywhere. Neither condition is necessary. All that is required is that \(\tau(X)\) be uncorrelated with the weight \(e(X)(1-e(X))\): heterogeneity that happens not to line up with propensity variance leaves the two equal. The weighting is the same \(e(1-e)\) that appeared in the previous chapter’s Equation 13.8, where Angrist (1998) showed OLS with saturated strata weights each stratum by \(n_x \hat D(x)(1 - \hat D(x))\). That result required discrete strata and saturated controls; Equation 14.11 is the same propensity-variance weighting with continuous covariates and a flexible learner. The individual \(\tau(X_i)\) are never observed or estimated — they are the structural unit-level effects in \(Y_i = \tau(X_i) D_i + m(X_i) + \varepsilon_i\). PLR returns one number, \(\hat\tau\), that converges to their \(e(1-e)\)-weighted average; to estimate \(\tau(X_i)\) itself one needs a CATE estimator (a causal forest, say).

Three moves did it: write the estimator, substitute the smoother \(\hat{\boldsymbol m} = \boldsymbol S \boldsymbol Y\), read off the coefficient on \(\boldsymbol Y\). They work for any estimator built from smoothers; only the operator on \(\boldsymbol Y\) changes. AIPW is worth carrying through, because its operator is more than \(\mathbf I - \boldsymbol S\).

AIPW. With outcome smoothers \(\hat{\boldsymbol\mu}_1 = \boldsymbol S_1 \boldsymbol Y\), \(\hat{\boldsymbol\mu}_0 = \boldsymbol S_0 \boldsymbol Y\) and propensity \(\hat{\boldsymbol e}\), the score of unit \(i\) is

\[ \psi_i = \hat\mu_{1i} - \hat\mu_{0i} + \frac{D_i}{\hat e_i}\,(Y_i - \hat\mu_{1i}) - \frac{1 - D_i}{1 - \hat e_i}\,(Y_i - \hat\mu_{0i}), \qquad \hat\tau = \frac1n \sum_i \psi_i. \tag{14.12}\]

Each \(\hat\mu_{ji}\) is \((\boldsymbol S_j \boldsymbol Y)_i\), so \(\boldsymbol Y\) again enters only linearly; collecting its coefficient gives \(\hat\tau = \boldsymbol\omega' \boldsymbol Y\) with

\[ \boldsymbol T = \boldsymbol S_1 - \boldsymbol S_0 + \operatorname{diag}\!\Big(\tfrac{\boldsymbol D}{\hat{\boldsymbol e}}\Big)(\mathbf I - \boldsymbol S_1) - \operatorname{diag}\!\Big(\tfrac{1 - \boldsymbol D}{1 - \hat{\boldsymbol e}}\Big)(\mathbf I - \boldsymbol S_0), \qquad \boldsymbol\omega' = \frac{\mathbf 1' \boldsymbol T}{n}. \tag{14.13}\]

That is, \(\omega_j\) is the \(j\)-th column-sum of \(\boldsymbol T\) divided by \(n\): observation \(j\)’s outcome enters the ATE through its direct IPW term and through its indirect contribution via the two outcome smoothers \(\boldsymbol S_1\), \(\boldsymbol S_0\) to every other unit’s augmentation correction.

Comparing the two:

PLR (Equation 14.8) AIPW
Matrix form \(\boldsymbol\omega' = \tilde{\boldsymbol D}'(\mathbf I - \boldsymbol S)\,/\,\tilde{\boldsymbol D}'\tilde{\boldsymbol D}\) \(\boldsymbol\omega' = \mathbf 1'\boldsymbol T\,/\,n\)
Per unit \(\omega_i = \bigl(\tilde D_i - \sum_j S_{ji}\tilde D_j\bigr)\,/\,\sum_k \tilde D_k^2\) \(\omega_j = \frac{1}{n}\sum_i T_{ij}\)
Smoother one \(\boldsymbol S\) (outcome on \(X\)) two: \(\boldsymbol S_1\) (treated), \(\boldsymbol S_0\) (control)
Normalisation treatment-residual variance sample size \(n\)

Both follow the same recipe — substitute the smoother \(\hat{\boldsymbol m} = \boldsymbol S \boldsymbol Y\), read off \(\boldsymbol Y\)’s coefficient — and differ only in the operator acting on \(\boldsymbol Y\).

Simulation — heterogeneous effects (DML). Under a heterogeneous effect the two estimators diverge, and the weights show why. We simulate \(n = 2000\) units: five standard-normal covariates \(X\), selection \(W_i \sim \text{Bernoulli}(\Lambda(-1 + 1.2\,X_{i1}))\) with \(\Lambda\) the logistic function (treatment is scarce and tied to \(X_1\)), and outcome \(Y_i = \tau_i W_i + X_{i1} + 0.5\,X_{i2} + \varepsilon_i\) with \(\varepsilon_i \sim N(0,1)\) and an effect \(\tau_i = 1 + X_{i1}\) that varies across units — a \(W \times X_1\) interaction. The average effect is \(E[\tau] = 1\), but units with larger \(X_1\) have larger effects. dml_with_smoother returns a partially-linear (PLR) and an AIPW estimator; get_outcome_weights extracts the weight matrix \(\boldsymbol\omega\), one row per estimator.

Code
library(OutcomeWeights)
set.seed(1)
n <- 2000; p <- 5
X <- matrix(rnorm(n * p), n, p)
etrue <- plogis(-1 + 1.2 * X[, 1])                       # asymmetric selection on X1
W <- rbinom(n, 1, etrue)
tau <- 1 + X[, 1]                                        # heterogeneous effect: W x X1
Y <- tau * W + X[, 1] + 0.5 * X[, 2] + rnorm(n)
dml <- dml_with_smoother(Y, W, X, estimators = c("PLR", "AIPW_ATE"))
om  <- get_outcome_weights(dml)                          # om$omega: 2 x n, om$treat = W
invisible(capture.output(s <- summary(dml)))
W1 <- om$treat == 1
tab <- t(sapply(rownames(om$omega), function(nm) {
  w <- om$omega[nm, ]
  c(estimate = s[nm, 1], `$\\boldsymbol\\omega'\\boldsymbol Y$` = sum(w * Y),
    treated = sum(w[W1]), control = sum(w[!W1]))
}))
ov <- etrue * (1 - etrue)                                # e(1-e): PLR's per-unit weight
# ATE = plain mean of tau; overlap-ATE = e(1-e)-weighted mean of tau (PLR's target)
cat(sprintf("true ATE = %.2f   true overlap-ATE = %.2f\n", mean(tau), sum(ov * tau) / sum(ov)))
true ATE = 0.99   true overlap-ATE = 1.31
Code
mtab(tab, 3)
estimate \(\boldsymbol\omega'\boldsymbol Y\) treated control
PLR 1.287 1.287 0.997 -0.997
AIPW-ATE 1.074 1.074 1.000 -1.000

Two things stand out. First, the estimators now disagree — PLR reports \(1.29\), AIPW \(1.07\) — and neither is wrong; they target different populations. PLR targets the VWATE (Equation 14.11), not the ATE. Here treatment selection loads on \(X_1\) through \(e = \Lambda(-1 + 1.2\,X_1)\), and \(\tau = 1 + X_1\) also rises in \(X_1\), so the units PLR overweights — those with \(e\) near \(\tfrac12\), around \(X_1 \approx 0.8\) — carry above-average effects, pushing PLR’s estimate above the true ATE. AIPW instead reweights by \(1/e\) and \(1/(1-e)\) to undo selection, returning the plain average, the ATE (\(\approx 1\)). Same outcomes \(\boldsymbol Y\), weighted two ways, two population-averages of \(\tau\) — and the weights are what make the difference visible.

Second, the weights still reproduce each estimate exactly and sum to zero overall. They part on the other constraint from Equation 14.2, \(\sum_i \omega_i V_i = 1\): for a binary treatment that constraint is the treated weights summing to \(+1\) (and, with the zero total, controls to \(-1\)). AIPW keeps it exactly; the partially-linear estimator sums to \(\pm 0.997\). That shortfall has a direct reading. If the treated weights sum to \(a\) and the controls to \(-a\), then \(\hat\tau = a\,(\bar y^{\,\omega}_{\text{treated}} - \bar y^{\,\omega}_{\text{control}})\), so the estimate is the weighted contrast scaled by \(a\): at \(a = 0.997\) the two differ by \(0.3\%\). Whether that is an understatement depends on treating the normalized contrast as the target, which nothing here establishes — better read as a normalization discrepancy, and as a measure of how far a cross-fitted residual maker sits from a projection. The reason OLS and AIPW hit \(1\) exactly is that their residual maker is a projection, giving \(\tilde{\boldsymbol V}'\boldsymbol V = \tilde{\boldsymbol V}'\tilde{\boldsymbol V}\) and hence a ratio of one. A cross-fitted smoother is not idempotent, so the identity only holds approximately, and the sum reports how far off it is.

That divergence shows what the representation is for. In Knaus’s (2024) framing \(\boldsymbol\omega\) does three things: it puts OLS, IPW, AIPW, DML, and the causal forest on one footing (\(\boldsymbol\omega' \boldsymbol Y\)); it characterises the target population each reports — the PLR-versus-AIPW gap above; and it checks covariate balance via cobalt, flagging when weights break a property usually assumed — the sum-to-\(\pm 1\) within treated and control groups that PLR violates here.

The formulas take a few lines to check against the package:

Code
n  <- length(Y)
S  <- dml$NuPa.hat$smoothers$S[1, , ]
S1 <- dml$NuPa.hat$smoothers$S.d1[1, , ]
S0 <- dml$NuPa.hat$smoothers$S.d0[1, , ]
e  <- dml$NuPa.hat$predictions$D.hat[, 1]
Wtil   <- W - e
om_plr <- as.numeric((Wtil - crossprod(S, Wtil)) / sum(Wtil^2))
Imat    <- diag(n)
Tm      <- S1 - S0 + (W / e) * (Imat - S1) - ((1 - W) / (1 - e)) * (Imat - S0)
om_aipw <- colSums(Tm) / n
vtab(c(`PLR: largest gap vs package` = max(abs(om_plr  - om$omega["PLR", ])),
       `AIPW: largest gap vs package` = max(abs(om_aipw - om$omega["AIPW-ATE", ])),
       `PLR: $\\boldsymbol\\omega'\\boldsymbol Y$`                = sum(om_plr  * Y),
       `AIPW: $\\boldsymbol\\omega'\\boldsymbol Y$`                = sum(om_aipw * Y)), 4)
value
PLR: largest gap vs package 0.0000
AIPW: largest gap vs package 0.0000
PLR: \(\boldsymbol\omega'\boldsymbol Y\) 1.2873
AIPW: \(\boldsymbol\omega'\boldsymbol Y\) 1.0738

14.4.2 Two uses: balance and extrapolation

Having \(\boldsymbol\omega\), two checks run directly on it. Does the estimator’s implied weighting balance the covariates? Hand \(\boldsymbol\omega\) to cobalt and read the largest standardised mean difference (SMD), the between-group difference in a covariate’s weighted mean divided by its standard deviation. And is it extrapolating? Count the wrong-sign weights — a control with \(\omega_i > 0\) or a treated unit with \(\omega_i < 0\), the signature of a comparison reaching off common support.

Code
library(cobalt)
uses <- t(sapply(rownames(om$omega), function(nm) {
  w <- om$omega[nm, ]
  c(`$\\max\\lvert\\mathrm{SMD}\\rvert$`        = max(abs(bal.tab(as.data.frame(X), treat = W, weights = w)$Balance$Diff.Adj)),
    `wrong-sign units` = sum((om$treat == 1 & w < 0) | (om$treat == 0 & w > 0)))
}))
mtab(uses, 3)
\(\max\lvert\mathrm{SMD}\rvert\) wrong-sign units
PLR 0.025 21
AIPW-ATE 0.091 0

Raw, the groups are badly imbalanced — the largest covariate SMD is \(1.11\), since treatment was tied to \(X_1\). Both weightings pull it down to near zero (\(0.03\) for PLR, \(0.09\) for AIPW): each balances the covariates, which for a causal forest you could not otherwise check.

They differ on extrapolation. PLR carries 21 wrong-sign controls — it leans slightly off common support — while AIPW carries none. Neither is disqualifying here; the point is that balance and extrapolation are now checkable, on estimators that never exposed a weighting.

14.5 The DiD setting

When the regressor is a treatment indicator, every unit in a group gets the same weight, so \(\hat\beta\) reduces to a contrast of group means. Regressed on the treatment alone it is the difference in means between treated and control. Add a time period and its interaction with treatment and it becomes a difference-in-differences across four group-by-period cells. We take these in turn, then bring in covariates.

Simulation — treatment alone. Regress \(y\) on the treatment \(D\) alone — here 30 treated drawn \(y \sim N(5, 1)\) and 50 controls \(y \sim N(2, 1)\). The residual \(\tilde V_i = D_i - \bar D\) is constant within each group, so the weight is uniform within a group and \(\hat\beta\) reduces to the two group means: \(\hat\beta = \bar y_1 - \bar y_0\), the difference in means. Every treated unit then carries positive weight, every control negative — the structural pattern of the previous section, with the wrong-sign off-diagonal empty until covariates enter.

Code
set.seed(7)
n1 <- 30; n0 <- 50
d <- c(rep(1, n1), rep(0, n0)); y <- c(rnorm(n1, 5), rnorm(n0, 2))
dev <- d - mean(d); om <- dev / sum(dev^2); col <- ifelse(om >= 0, teal, rust)
y1 <- mean(y[d == 1]); y0 <- mean(y[d == 0])
plot(jitter(d, 0.4), y, pch = 16, col = paste0(col, "55"), cex = 0.9,
     xlim = c(-0.3, 1.3), xaxt = "n", xlab = "", ylab = "y", main = "Regression on treatment")
axis(1, at = c(0, 1), labels = c("control", "treated"))
segments(0, y0, 1, y1, lwd = 2.6, col = "grey15")
points(c(0, 1), c(y0, y1), pch = 21, bg = c(rust, teal), col = "white", cex = 2.5, lwd = 2)

Regression on the treatment alone. Points are the two groups (rust control, teal treated); the large dots are the group means, and the segment joining them has slope \(\hat\beta = \bar y_1 - \bar y_0\), the difference in means.
Code
vtab(c(`$\\hat\\beta$ (lm)` = coef(lm(y ~ d))[["d"]], `$\\bar y_1 - \\bar y_0$` = y1 - y0,
        `off-diagonal weights` = sum((d == 1 & om < 0) | (d == 0 & om > 0))), 3)
value
\(\hat\beta\) (lm) 3.318
\(\bar y_1 - \bar y_0\) 3.318
off-diagonal weights 0.000

Regression on the treatment alone. Points are the two groups (rust control, teal treated); the large dots are the group means, and the segment joining them has slope \(\hat\beta = \bar y_1 - \bar y_0\), the difference in means.

The estimate equals \(\bar y_1 - \bar y_0\), and no weight carries the wrong sign — the off-diagonal count is zero.

Simulation — 2×2 difference-in-differences. Add a period and interact it with the group, and the design has four cells. Each collapses to its mean, and the interaction coefficient is their double contrast,

\[ \widehat{\text{DiD}} = (\bar y_{T,\text{post}} - \bar y_{T,\text{pre}}) - (\bar y_{C,\text{post}} - \bar y_{C,\text{pre}}). \tag{14.14}\]

Now there are four cell means, and with them four slopes — the two group trends over time and the two cross-sectional gaps. The DiD is the difference between either parallel pair, trend minus trend or gap minus gap. We simulate 200 units in each group×period cell from \(y = 3 + 2g + p + 2\,(g \cdot p) + \varepsilon\) with \(\varepsilon \sim N(0,1)\), so the true DiD (the interaction) is \(2\):

Code
set.seed(3)
g <- rep(c(1, 1, 0, 0), each = 200); p <- rep(c(0, 1, 0, 1), each = 200)
y <- 3 + 2 * g + 1 * p + 2 * (g * p) + rnorm(800)
m <- tapply(y, list(g, p), mean)
Tpre <- m["1", "0"]; Tpost <- m["1", "1"]; Cpre <- m["0", "0"]; Cpost <- m["0", "1"]
cf <- Tpre + (Cpost - Cpre)
plot(NA, xlim = c(-0.15, 1.25), ylim = range(y), xaxt = "n", xlab = "", ylab = "y",
     main = "Difference-in-differences")
axis(1, at = c(0, 1), labels = c("pre", "post"))
segments(0, Tpre, 1, Tpost, lwd = 2.4, col = teal)
segments(0, Cpre, 1, Cpost, lwd = 2.4, col = rust)
segments(0, Tpre, 1, cf, lty = 2, lwd = 1.6, col = "grey55")
segments(1, cf, 1, Tpost, lwd = 4, col = "grey15")
points(c(0, 1, 0, 1), c(Tpre, Tpost, Cpre, Cpost),
       pch = 21, bg = c(teal, teal, rust, rust), col = "white", cex = 2.3, lwd = 2)
text(1.02, (cf + Tpost) / 2, "DiD", pos = 4, cex = 0.9, col = "grey15")

The 2×2 difference-in-differences: four cell means (dots), the treated (teal) and control (rust) trends, the parallel-trends counterfactual (dashed), and the DiD (black bar) as the gap between the treated post-mean and that counterfactual.
Code
vtab(c(`treated trend` = Tpost - Tpre, `control trend` = Cpost - Cpre,
        `pre gap` = Tpre - Cpre, `post gap` = Tpost - Cpost,
        `DiD (interaction)` = coef(lm(y ~ g * p))[["g:p"]]), 3)
value
treated trend 3.029
control trend 0.897
pre gap 2.019
post gap 4.151
DiD (interaction) 2.132

The 2×2 difference-in-differences: four cell means (dots), the treated (teal) and control (rust) trends, the parallel-trends counterfactual (dashed), and the DiD (black bar) as the gap between the treated post-mean and that counterfactual.

The estimated interaction recovers the true DiD of \(2\), and the four cell-mean weights carry the structural signs \((+, -, -, +)\) as long as every cell is populated.

The clean \(2 \times 2\) has trivial weights — four cell means, each \(\pm 1\). Real difference-in-differences carries covariates: parallel trends holds only conditional on \(X\), and the comparison has to be modelled. That model is a weighting of outcomes too.

The doubly-robust DiD of Sant’Anna and Zhao (2020) is AIPW read on the outcome change \(\Delta Y = Y_1 - Y_0\): a propensity for treatment, a control-group regression of \(\Delta Y\) on \(X\), combined. By the recipe of the last section its estimate is \(\widehat{\text{ATT}} = \boldsymbol\omega' \Delta Y\), with \(\boldsymbol\omega' = \boldsymbol a'(\mathbf I - \boldsymbol S)\) — the control-trend smoother \(\boldsymbol S\) and the group weights \(\boldsymbol a = \boldsymbol w_{\text{treat}} - \boldsymbol w_{\text{cont}}\).

Simulation — doubly-robust DiD. Two periods and \(n = 2000\), with covariates \(x_1, x_2 \sim N(0,1)\). Treatment is selected on \(x_1\) with thin overlap, \(\Pr(D = 1) = \Lambda(-0.5 + 2 x_1)\). The outcomes are \(y_{i0} = 1 + x_{1i} + 0.5\,x_{2i} + \varepsilon_{i0}\) and \(y_{i1} = y_{i0} + (0.5 + 0.4\,x_{1i}) + D_i + \varepsilon_{i1}\), with both errors \(N(0,1)\). The control trend \(0.5 + 0.4 x_1\) depends on \(x_1\), so trends are parallel only given \(X\), and the true ATT is \(1\). We fit DR-DiD with DRDID, rebuild its weights by hand, and check.

Code
library(DRDID)
set.seed(2)
n  <- 2000
x1 <- rnorm(n); x2 <- rnorm(n); Xcov <- cbind(1, x1, x2)
D  <- rbinom(n, 1, plogis(-0.5 + 2 * x1))        # selection into treatment on x1 (thin overlap)
y0 <- 1 + x1 + 0.5 * x2 + rnorm(n)               # pre-period outcome
y1 <- y0 + (0.5 + 0.4 * x1) + 1 * D + rnorm(n)   # control trend depends on x1; true ATT = 1
dY <- y1 - y0
att <- drdid_panel(y1 = y1, y0 = y0, D = D, covariates = Xcov, boot = FALSE)$ATT

# rebuild the DR-DiD weights: omega = (I - S)' a, acting on the outcome change dY
e    <- pmin(as.vector(glm(D ~ x1 + x2, family = binomial)$fitted), 1 - 1e-6)
keep <- rep(TRUE, n); keep[D == 0] <- e[D == 0] < 0.995     # DRDID's control trimming
Xc   <- Xcov[D == 0, ]
S    <- matrix(0, n, n); S[, D == 0] <- Xcov %*% solve(crossprod(Xc)) %*% t(Xc)
w.t  <- keep * D
w.c  <- keep * e * (1 - D) / (1 - e)
a    <- w.t / sum(w.t) - w.c / sum(w.c)          # group weights, treated minus reweighted control
omega <- as.vector(a - crossprod(S, a))

Xk <- cbind(x1, x2); sds <- apply(Xk, 2, sd)
vtab(c(`DRDID ATT`                = att,
       `$\\boldsymbol\\omega'\\Delta\\boldsymbol Y$`                = sum(omega * dY),
       `wrong-sign weights`       = sum((D == 1 & omega < 0) | (D == 0 & omega > 0)),
       `covariate imbalance, raw` = max(abs((colMeans(Xk[D == 1, ]) - colMeans(Xk[D == 0, ])) / sds)),
       `covariate imbalance, wtd` = max(abs(as.vector(a %*% Xk) / sds))), 3)
value
DRDID ATT 0.980
\(\boldsymbol\omega'\Delta\boldsymbol Y\) 0.980
wrong-sign weights 151.000
covariate imbalance, raw 1.198
covariate imbalance, wtd 0.086

\(\boldsymbol\omega' \Delta Y\) reproduces the DRDID estimate exactly — the difference-in-differences is a signed weighting of observed outcome changes, and the diagnostics of this chapter apply to it. The weighting balances the covariate that drove selection (its standardised imbalance falls from \(1.20\) to \(0.09\)). And \(151\) units carry a wrong-sign weight — a treated unit subtracted, or a control added — the signature of a comparison reaching past common support.

14.5.1 Staggered designs: TWFE at the observation level

Negative weights in staggered designs are usually described at the level of two-by-two comparisons: TWFE averages many such comparisons, and some enter with a negative weight — the forbidden comparisons that use already-treated units as controls (de Chaisemartin and D’Haultfœuille 2020; Goodman-Bacon 2021). That is the effect-weight reading. The outcome-weight reading goes one level down, to the individual \(y_{it}\).

TWFE is still FWL: residualise \(D_{it}\) on unit and time fixed effects to get \(\tilde D_{it}\), and \(\hat\beta = \sum_{it} \omega_{it} y_{it}\) with \(\omega_{it} = \tilde D_{it} / \sum_{jt} \tilde D_{jt}^2\). A treated observation with \(\omega_{it} < 0\) is a cell whose outcome is subtracted from the estimated effect — used as a control even though it is treated. An untreated observation with \(\omega_{it} > 0\) enters as though it were treated. Both are the same sign flip seen earlier under thin overlap, caused here by the unit-and-time residualisation pushing \(\tilde D_{it}\) past zero: treated cells in late periods of early cohorts, where the fixed-effect prediction of \(D_{it}\) exceeds \(1\), and untreated cells in early periods of never-treated units, where it falls below \(0\).

Simulation — staggered panel. \(N = 300\) units over \(T = 8\) periods, in three cohorts: never-treated (\(20\%\)), early-treated at period \(3\) with effect \(\tau = 3\) (\(40\%\)), and late-treated at period \(6\) with effect \(\tau = 1\) (\(40\%\)). Outcomes are \(y_{it} = \alpha_i + \gamma_t + \tau_g D_{it} + \varepsilon_{it}\), with unit effects \(\alpha_i \sim N(0,1)\), a time effect \(\gamma_t\) rising linearly from \(0\) to \(1.4\), and \(\varepsilon_{it} \sim N(0, 0.5^2)\). The high treated share makes the forbidden comparison visible.

Code
library(fixest)
set.seed(5)
N <- 300; TT <- 8
g <- sample(c(0, 3, 6), N, TRUE, prob = c(.2, .4, .4))
panel <- data.frame(id = rep(1:N, each = TT), t = rep(1:TT, N))
panel$g <- g[panel$id]
panel$D <- as.integer(panel$g > 0 & panel$t >= panel$g)
tau <- ifelse(panel$g == 3, 3, ifelse(panel$g == 6, 1, 0))
alpha <- rnorm(N); gamma <- seq(0, 1.4, length.out = TT)
panel$y <- alpha[panel$id] + gamma[panel$t] + tau * panel$D + rnorm(nrow(panel), 0, 0.5)

Dtil <- resid(feols(D ~ 1 | id + t, data = panel))
omega_tw <- Dtil / sum(Dtil^2)
n1 <- sum(panel$D)
vtab(c(`true ATT`              = mean(tau[panel$D == 1]),
       `TWFE`                  = coef(feols(y ~ D | id + t, panel))[["D"]],
       `$\\boldsymbol\\omega'\\boldsymbol y$`              = sum(omega_tw * panel$y),
       `treated, $\\omega_i < 0$`    = sum(panel$D == 1 & omega_tw < 0),
       `untreated, $\\omega_i > 0$`  = sum(panel$D == 0 & omega_tw > 0)), 3)
value
true ATT 2.426
TWFE 1.915
\(\boldsymbol\omega'\boldsymbol y\) 1.915
treated, \(\omega_i < 0\) 384.000
untreated, \(\omega_i > 0\) 551.000

The TWFE coefficient sits well below the true ATT, and \(\sum \omega_{it} y_{it}\) reproduces it exactly. Hundreds of treated cells carry a negative weight: these are the early cohort’s cells in periods \(6\)–\(8\), the ones where the unit-and-time prediction of \(D\) exceeds \(1\) and the residual \(\tilde D_{it}\) flips sign. Their outcomes are subtracted from \(\hat\beta\) — the observation-level signature of the Bacon forbidden comparison.

The wrong-sign diagnostic in a panel is by observation \((i, t)\), not by unit. In the cross-sectional case earlier, the fix was to trim wrong-sign observations and refit. In a panel, trimming specific \((i, t)\) cells removes observations that anchor that unit’s fixed effect and distorts the estimates for its remaining cells. The response in the staggered setting is to switch estimators — imputation (Borusyak, Jaravel, and Spiess 2024), Callaway and Sant’Anna, or Sun and Abraham — that avoid the forbidden comparison by construction. The imputation estimator, for example, fits unit and time fixed effects on untreated observations alone and imputes each treated cell’s counterfactual; because its smoother draws only on untreated outcomes, the weight on every treated cell reduces to \(1/n_1\) and no treated observation can count as a control.

14.5.2 Synthetic difference-in-differences: a rank-one weighting

Synthetic difference-in-differences is a weighted two-way fixed effects regression, from Arkhangelsky, Athey, Hirshberg, Imbens, and Wager (2021). The estimators so far imply their weights. This one chooses them:

\[ (\hat\tau, \hat\mu, \hat\alpha, \hat\beta) = \arg\min \sum_{i}\sum_{t} \big(Y_{it} - \mu - \alpha_i - \beta_t - W_{it}\tau\big)^2\, \omega_i \lambda_t. \tag{14.15}\]

The recipe: fit \(\omega_i\) on the control units so the reweighted control path tracks the treated path over the pre-period, and fit \(\lambda_t\) on the pre-periods so the reweighted pre-period resembles the post-period. Treated units enter at \(1/N_1\), post-periods at \(1/T_1\). Both fitted sets lie on the simplex, non-negative and summing to one.

Equation 14.15 is weighted least squares, so the FWL reading of Equation 14.2 goes through with the weights carried along. Then \(\hat\tau\) is again a weighting of outcomes. Let \(u_i\) be \(1/N_1\) for a treated unit and \(-\omega_i\) for a control. Let \(v_t\) be \(1/T_1\) for a post-period and \(-\lambda_t\) for a pre-period. Then

\[ \hat\tau = \sum_i \sum_t \omega_{it}\, Y_{it}, \qquad \omega_{it} = u_i \, v_t. \tag{14.16}\]

In words, the weight on a cell is a unit part times a time part. Collect the weights into an \(N \times T\) matrix \(\boldsymbol\Omega\), one entry per cell. Then Equation 14.16 reads \(\boldsymbol\Omega = \boldsymbol u \boldsymbol v'\), an outer product, and a matrix of that form has rank one. Every row is a multiple of every other row. So each unit carries the same weight profile over time, scaled by its own \(u_i\), and each period weights the units in the same pattern, scaled by its own \(v_t\). There is no unit-by-period interaction in the weights.

That product structure is what makes this a difference-in-differences. A double difference contrasts units and contrasts periods, and multiplying the two contrasts gives a rank-one weighting. We come back to the rank below, where it separates the block designs from the staggered ones.

Multiply Equation 14.16 out and we get the \(2 \times 2\) of Equation 14.14 with two of its four averages reweighted, the control average by \(\omega\) and the pre-period average by \(\lambda\):

\[ \hat\tau = \Big(\bar Y_{T,\text{post}} - \sum_{t \in \text{pre}} \lambda_t \bar Y_{T,t}\Big) - \Big(\sum_{i \in C} \omega_i \bar Y_{i,\text{post}} - \sum_{i \in C}\sum_{t \in \text{pre}} \omega_i \lambda_t Y_{it}\Big). \tag{14.17}\]

The four blocks keep the signs \((+, -, -, +)\) of Equation 14.14. Only the averages inside two of them change, and they are no longer uniform.

Simulation — the outcome weights of synthetic DiD. A panel of \(40\) control and \(10\) treated units over \(12\) pre- and \(6\) post-periods. Untreated outcomes are

\[ Y_{it}(0) = \alpha_i + \beta_t + \boldsymbol f_i' \boldsymbol g_t + \varepsilon_{it}, \tag{14.18}\]

with a unit effect \(\alpha_i \sim N(0, 0.5^2)\), a time effect \(\beta_t\) rising linearly from \(0\) to \(1\), two factors in \(\boldsymbol g_t\) (one linear in \(t\), one a sine), loadings \(\boldsymbol f_i \sim N(\boldsymbol 0, \mathbf I)\) shifted by \(0.8\) for the treated units, and \(\varepsilon_{it} \sim N(0, 0.3^2)\). The spread in \(\boldsymbol f_i\) makes the control trends differ from each other, and the shift makes the treated trend differ from all of them. The true ATT is \(2\). We fit with synthdid, then rebuild Equation 14.16 by hand from the fitted \(\omega\) and \(\lambda\).

Code
library(synthdid)
set.seed(11)
N0 <- 40; N1 <- 10; N <- N0 + N1; T0 <- 12; T1 <- 6; Tt <- T0 + T1
f <- cbind(rnorm(N), rnorm(N)); f[(N0 + 1):N, ] <- f[(N0 + 1):N, ] + 0.8
g <- cbind(seq(0, 2, length = Tt), sin(seq(0, 3, length = Tt)))
Y <- outer(rnorm(N, 0, .5), rep(1, Tt)) + outer(rep(1, N), seq(0, 1, length = Tt)) +
     f %*% t(g) + matrix(rnorm(N * Tt, 0, .3), N, Tt)
W <- matrix(0, N, Tt); W[(N0 + 1):N, (T0 + 1):Tt] <- 1
Y <- Y + 2 * W                                          # true ATT = 2

est <- synthdid_estimate(Y, N0, T0)
wt  <- attr(est, "weights")
u <- c(-wt$omega,  rep(1 / N1, N1))                     # unit part
v <- c(-wt$lambda, rep(1 / T1, T1))                     # time part
Omega <- outer(u, v)                                    # outcome weight on cell (i, t)

Three routes should give the same number: what synthdid reports, the weighting Equation 14.16 applies to the observed outcomes, and the weighted regression of Equation 14.15.

Code
cw <- outer(c(wt$omega, rep(1 / N1, N1)), c(wt$lambda, rep(1 / T1, T1)))
dd <- data.frame(y = as.vector(Y), Wt = as.vector(W), i = factor(rep(1:N, Tt)),
                 t = factor(rep(1:Tt, each = N)), cw = as.vector(cw))
tau3 <- c(`synthdid_estimate()`              = as.numeric(est),
          `$\\sum_{it} \\omega_{it} Y_{it}$, by hand` = sum(Omega * Y),
          `weighted TWFE regression`        = coef(lm(y ~ Wt + i + t, dd,
                                                      weights = dd$cw))[["Wt"]])
vtab(c(tau3, `largest gap between them` = max(abs(tau3 - tau3[1]))), 6)
value
synthdid_estimate() 2.205444
\(\sum_{it} \omega_{it} Y_{it}\), by hand 2.205444
weighted TWFE regression 2.205444
largest gap between them 0.000000

The gap between them is zero to machine precision. The outer-product form is what synthdid computes.

Writing the weights this way puts three constraints on them. First, the rank is one, since a cell’s weight is a unit part times a time part. Second, each margin sums to zero on its own: \(\omega\) and \(\lambda\) each sum to one, and the treated and post-period blocks each contribute an offsetting one. Third, no cell carries a wrong sign: no treated post-period cell is subtracted, and no control pre-period cell is added. We check all three.

Code
chk <- rbind(`rank of $\\boldsymbol\\Omega$`     = c(sum(svd(Omega)$d > 1e-12), 1),
             `$\\sum_i u_i$`     = c(sum(u), 0),
             `$\\sum_t v_t$`   = c(sum(v), 0),
             `treated post-cells, $\\omega_{it} < 0$` = c(sum(Omega[(N0+1):N, (T0+1):Tt] < 0), 0),
             `control pre-cells, $\\omega_{it} < 0$`  = c(sum(Omega[1:N0, 1:T0] < 0), 0))
colnames(chk) <- c("found", "should be")
mtab(chk, 4)
found should be
rank of \(\boldsymbol\Omega\) 1 1
\(\sum_i u_i\) 0 0
\(\sum_t v_t\) 0 0
treated post-cells, \(\omega_{it} < 0\) 0 0
control pre-cells, \(\omega_{it} < 0\) 0 0

All three hold. Note that the double zero-sum is stronger than the single sum-to-zero of Equation 14.2, which constrains the weights only in total. Here they vanish along each margin. The signs are fixed by construction too: a treated post-period cell gets \(1/(N_1 T_1) > 0\), and a control pre-period cell gets \(\omega_i \lambda_t > 0\), because the simplex rules out a negative \(\omega_i\). The doubly-robust fit above produced \(151\) wrong-sign units. Synthetic DiD cannot produce any.

The remaining question is how much of the panel the comparison uses. Counting the non-zero weights is one answer, but it treats a weight of \(0.4\) and a weight of \(0.001\) alike. The usual summary that does not is the effective sample size, and it is worth deriving rather than asserting. Take \(m\) observations with equal variance \(\sigma^2\). An unweighted mean of them has variance \(\sigma^2/m\). A weighted mean with weights \(w\) summing to one has variance \(\sigma^2 \sum w^2\). Ask which \(m\) makes the two equal:

\[ \frac{\sigma^2}{m} = \sigma^2 \sum_j w_j^2 \qquad\Longrightarrow\qquad m = \frac{1}{\sum_j w_j^2}. \tag{14.19}\]

So Equation 14.19 is the number of equally weighted observations that would deliver the same precision as the weighting actually used. It equals the full count when every weight is equal, and \(1\) when one observation carries everything. We apply it to each margin separately: to the control weights as \(1/\sum_i \omega_i^2\), and to the pre-period weights as \(1/\sum_t \lambda_t^2\). Both are sparse here, so both fall below the nominal \(40\) controls and \(12\) pre-periods.

Code
dsg <- c(sum(wt$omega > 1e-8), 1 / sum(wt$omega^2),
         sum(wt$lambda > 1e-8), 1 / sum(wt$lambda^2), wt$lambda[T0])
names(dsg) <- c(sprintf("controls with non-zero weight (of %d)", N0),
                sprintf("effective controls, $1/\\sum_i \\omega_i^2$ (of %d)", N0),
                sprintf("pre-periods with non-zero weight (of %d)", T0),
                sprintf("effective pre-periods, $1/\\sum_t \\lambda_t^2$ (of %d)", T0),
                "weight on the last pre-period")
vtab(dsg)
value
controls with non-zero weight (of 40) 23.000
effective controls, \(1/\sum_i \omega_i^2\) (of 40) 14.557
pre-periods with non-zero weight (of 12) 2.000
effective pre-periods, \(1/\sum_t \lambda_t^2\) (of 12) 1.091
weight on the last pre-period 0.957

Only \(23\) of the \(40\) controls get any weight, and the effective count is \(14.6\). Three reference points make that number readable. Weighting all \(40\) equally would give \(40\), and that is plain difference-in-differences. Weighting the \(23\) active controls equally, at \(1/23\) each, would give \(23\). The comparison actually delivers \(14.6\), so the drop from \(23\) to \(14.6\) measures nothing but the inequality among those \(23\) weights: a few carry most of the mass and the rest contribute little.

A larger effective size is not automatically better, and it is worth seeing why. By Cauchy–Schwarz, \(\sum_i \omega_i^2 \ge 1/N_0\) with equality only when every weight equals \(1/N_0\), so the effective size reaches \(N_0\) exactly when the weighting is uniform — that is, when no reweighting has happened. Matching the treated path requires tilting toward the controls that resemble it, and any tilt lowers the effective size. The two things we want pull against each other, and the effective count is the price paid for the match rather than a score to be maximised. The one case where both arrive together is when the treated path already equals the unweighted control average, and then there was nothing to reweight for.

Synthetic difference-in-differences prices that trade-off explicitly, which is why the effective count is the number to read. Its weights minimise not the pre-period fit alone but

\[ (\text{pre-period fit error}) \; + \; \zeta^2 \sum_i \omega_i^2 , \tag{14.20}\]

and by Equation 14.19 the penalty \(\zeta^2 \sum_i \omega_i^2\) is exactly \(\zeta^2\) divided by the effective sample size. So the estimator charges itself for concentration at a rate \(\zeta\), which by default rises with the estimated noise in the panel: noisier data is shrunk harder toward uniform, because in noisy data an apparent gain from concentrating is more likely to be fitted noise. The \(14.6\) above is where that trade-off settled on this panel, and it is computable whether or not we know what generated the data. The time margin is sparser: an effective \(1.09\) pre-periods, with \(0.957\) of the mass on the last one. The counterfactual level is anchored on the single period before treatment, so the estimate inherits that period’s noise. We can read all of this off the weights. None of it is visible in \(\hat\tau\).

We plot the two margins to see where the mass sits. The left panel sorts the fitted control weights \(\omega_i\) from largest to smallest, which shows how many controls the comparison uses. The right panel keeps the pre-period weights \(\lambda_t\) in time order, which shows which periods the counterfactual is built from.

Code
par(mfrow = c(1, 2), mar = c(4.2, 4.2, 2.6, 1))
os <- sort(wt$omega, decreasing = TRUE)
barplot(os, col = ifelse(os > 1e-8, teal, "grey85"), border = NA,
        xlab = "control units, sorted", ylab = expression(omega[i]),
        main = "Unit weights")
barplot(wt$lambda, names.arg = 1:T0, border = NA,
        col = ifelse(wt$lambda > 1e-8, rust, "grey85"),
        xlab = "pre-period", ylab = expression(lambda[t]), main = "Time weights")

The two margins of the synthetic DiD weight matrix. Left: fitted control-unit weights \(\omega_i\), sorted. \(23\) of \(40\) are non-zero, and the \(17\) the simplex sets to zero lie flat against the axis. Right: fitted pre-period weights \(\lambda_t\). Almost all the mass is on the last pre-period, so the counterfactual level is anchored just before treatment.

14.5.3 The rank of the weight matrix

Synthetic difference-in-differences has a rank-one \(\boldsymbol\Omega\) by construction. So does the plain \(2 \times 2\): set \(u_i\) and \(v_t\) to the uniform \(\pm 1/N_g\) and \(\pm 1/T_g\) and Equation 14.14 reappears. Staggered adoption gives a higher rank, bounded by the number of cohorts.

Write the staggered treatment matrix as a sum over adoption cohorts, \(\boldsymbol W = \sum_{k=1}^{K} \boldsymbol c_k \boldsymbol s_k'\). Here \(\boldsymbol c_k\) indicates the units of cohort \(k\), and \(\boldsymbol s_k\) the periods from its adoption date on. That is a sum of \(K\) rank-one terms. Residualising on unit and time dummies is a double centring, \((\mathbf I - \boldsymbol P)\, \boldsymbol W \,(\mathbf I - \boldsymbol Q)\), and double centring cannot raise a rank. So \(\operatorname{rank}(\boldsymbol\Omega) \le K\), with equality generically.

We check the bound directly. Each panel has \(60\) units over \(20\) periods, with one to five adoption dates plus a never-treated group. For each we build the staggered TWFE weights, then count the non-zero singular values of \(\boldsymbol\Omega\).

Code
set.seed(4)
Ns <- 60; Ts <- 20
rk <- sapply(list(10, c(8, 14), c(6, 10, 14), c(6, 9, 12, 15), c(5, 8, 11, 14, 17)),
  function(dates) {
    coh <- sample(c(dates, Inf), Ns, replace = TRUE)     # Inf = never treated
    Ws  <- matrix(0, Ns, Ts)
    for (i in 1:Ns) if (is.finite(coh[i])) Ws[i, coh[i]:Ts] <- 1
    ds  <- data.frame(Wt = as.vector(Ws), i = factor(rep(1:Ns, Ts)),
                      t = factor(rep(1:Ts, each = Ns)))
    Wtil <- residuals(lm(Wt ~ i + t, ds))
    Om   <- matrix(Wtil / sum(Wtil^2), Ns, Ts)
    c(`treated cohorts` = length(dates),
      `rank of $\\boldsymbol\\Omega$`   = sum(svd(Om)$d > 1e-9 * max(svd(Om)$d)))
  })
colnames(rk) <- c("1 date", "2 dates", "3 dates", "4 dates", "5 dates")
mtab(rk)
1 date 2 dates 3 dates 4 dates 5 dates
treated cohorts 1 2 3 4 5
rank of \(\boldsymbol\Omega\) 1 2 3 4 5

The rank is at most the number of treated cohorts, and equals it generically. This ties the chapter’s difference-in-differences material together. A block design has one adoption date, so its weight matrix is rank one. The plain \(2 \times 2\) and synthetic difference-in-differences are the same object at two settings of the margins, uniform in the first and fitted in the second. Staggered adoption raises the rank. Once a cell’s weight no longer factors into a unit part times a time part, a treated cell can enter negatively. In the block case it cannot, since every treated post-period cell gets \(u_i v_t = 1/(N_1 T_1) > 0\). The forbidden comparisons need the extra rank.

14.5.4 Which margin does the work

The weight matrix has two margins, \(\boldsymbol u\) over units and \(\boldsymbol v\) over periods, and Equation 14.16 says the whole weighting is their product. So a natural question about the structure is which margin carries the correction when the control trends differ from the treated group’s.

It is worth saying first what such a correction can and cannot be. Parallel trends is a statement about two group means, \(E[\Delta Y(0) \mid D = 1] = E[\Delta Y(0) \mid D = 0]\), and no single observation either has it or lacks it, so in the model behind Equation 14.14 there is nothing at the unit level for a weight to act on. Weighting bites only under a model where trends vary across units: unit-specific slopes, or a factor structure. Then each unit has its own trend, and reweighting controls toward those whose loadings resemble the treated group’s is well defined. That is what synthetic difference-in-differences does, and what the wider family of trajectory-balancing and history-matching estimators does too (Hazlett and Xu 2018; Imai, Kim, and Wang 2023; Doudchenko and Imbens 2016).

We can then hold one margin uniform and fit the other, and read off how much of the correction each supplies.

Simulation — which margin does the work. Two panels of \(40\) controls and \(10\) treated over \(12\) pre- and \(6\) post-periods, with a true ATT of \(2\). Both carry a unit effect \(\alpha_i \sim N(0,1)\), shifted up by \(1\) for the treated units, a time effect \(\beta_t\) rising linearly from \(0\) to \(1.5\), and a serially correlated shock. The shock is a stationary AR(1),

\[ \varepsilon_{it} = \rho\, \varepsilon_{i,t-1} + \nu_{it}, \qquad \nu_{it} \sim N(0, \sigma^2), \qquad \rho = 0.5, \tag{14.21}\]

started from its stationary distribution, so \(\varepsilon_{it}\) has standard deviation \(\sigma/\sqrt{1 - \rho^2}\). We set \(\sigma = 0.4\), which makes the shock’s own standard deviation \(0.46\). Note the distinction: \(\sigma\) is the standard deviation of the innovation \(\nu_{it}\) that arrives each period, not of the shock \(\varepsilon_{it}\) that accumulates.

The shock is serially correlated rather than independent for a reason, and it is not decoration. The weights are fitted on the pre-period path. Under independent noise, averaging across twelve pre-periods removes most of it, so the weights would recover the systematic path almost exactly and there would be little left to study. Serial correlation means twelve periods carry well under twelve periods’ worth of independent information, and that is what leaves the fitted weights short. It is also the realistic case: panel outcomes persist. AR(1) is the smallest model with a persistence parameter, nesting independent noise at \(\rho = 0\) and a random walk as \(\rho \to 1\).

These three terms are common to almost any panel, and none of them breaks parallel trends: \(\alpha_i\) differences away and \(\beta_t\) is shared by both groups, so a difference-in-differences removes them whatever the weights are.

Parallel trends fails only when the treated and control groups have different untreated trends, so a term that varies by unit and moves with \(t\) is what we have to put in. Without one there is nothing for the weights to fix. The two panels are two forms of it. The factor panel uses the \(\boldsymbol f_i' \boldsymbol g_t\) of Equation 14.18. The slopes panel replaces it with a unit-specific linear trend,

\[ Y_{it}(0) = \alpha_i + \beta_t + s_i \, t + \varepsilon_{it}, \qquad s_i \sim N(0,\, 0.08^2), \tag{14.22}\]

with \(s_i\) shifted up by \(0.12\) for the treated units. So each unit has its own growth rate, and the treated units grow faster on average, by about one and a half standard deviations of the control slopes. Note that \(s_i t\) is itself a one-factor model, with loading \(s_i\) and factor \(g_t = t\). It is the simplest member of the class Equation 14.18 describes, which is the class synthetic difference-in-differences is built for, so this is a leading case and not a special one. We run synthetic DiD four ways: both margins uniform, which is plain difference-in-differences; unit weights fitted with time weights uniform; time weights fitted with unit weights uniform; and both fitted. \(400\) replications.

Code
library(parallel)
panel <- function(kind, N0 = 40, N1 = 10, T0 = 12, T1 = 6, sig = .4, rho = .5) {
  N <- N0 + N1; Tt <- T0 + T1
  a <- rnorm(N, 0, 1); a[(N0 + 1):N] <- a[(N0 + 1):N] + 1
  e <- matrix(0, N, Tt); e[, 1] <- rnorm(N, 0, sig / sqrt(1 - rho^2))
  for (t in 2:Tt) e[, t] <- rho * e[, t - 1] + rnorm(N, 0, sig)
  Y0 <- outer(a, rep(1, Tt)) + outer(rep(1, N), seq(0, 1.5, length = Tt)) + e
  if (kind == "factor") {
    f <- cbind(rnorm(N), rnorm(N)); f[(N0 + 1):N, ] <- f[(N0 + 1):N, ] + 0.8
    g <- cbind(seq(0, 2, length = Tt), sin(seq(0, 3, length = Tt)))
    Y0 <- Y0 + f %*% t(g)
  } else {
    s <- rnorm(N, 0, .08); s[(N0 + 1):N] <- s[(N0 + 1):N] + .12   # unit slopes
    Y0 <- Y0 + outer(s, 1:Tt)
  }
  W <- matrix(0, N, Tt); W[(N0 + 1):N, (T0 + 1):Tt] <- 1
  list(Y = Y0 + 2 * W, N0 = N0, N1 = N1, T0 = T0, T1 = T1)
}
margins <- function(r, kind) {
  set.seed(5000 + r); p <- panel(kind); N0 <- p$N0; T0 <- p$T0
  fx <- function(om, lam) as.numeric(synthdid_estimate(p$Y, N0, T0,
          weights = list(omega = om, lambda = lam)))     # NULL = fit this margin
  unif.o <- rep(1 / N0, N0); unif.l <- rep(1 / T0, T0)
  c(`both uniform (DiD)` = fx(unif.o, unif.l), `unit weights only` = fx(NULL, unif.l),
    `time weights only`  = fx(unif.o, NULL),   `both fitted (SDID)` = fx(NULL, NULL))
}
res <- lapply(c("factor", "slopes"), function(kind) {
  # portable core count: mclapply falls back to serial on Windows
  n_cores <- if (.Platform$OS.type == "windows") 1L else
               max(1L, min(6L, parallel::detectCores() - 1L))
  M <- simplify2array(mclapply(1:400, margins, kind = kind, mc.cores = n_cores))
  cbind(bias = rowMeans(M) - 2, sd = apply(M, 1, sd), rmse = sqrt(rowMeans((M - 2)^2)))
})
out <- cbind(res[[1]], res[[2]])
colnames(out) <- paste(rep(c("factor", "slopes"), each = 3), colnames(res[[1]]))
mtab(out)
factor bias factor sd factor rmse slopes bias slopes sd slopes rmse
both uniform (DiD) 0.729 0.403 0.833 1.083 0.284 1.120
unit weights only 0.173 0.337 0.379 0.603 0.231 0.645
time weights only 0.082 0.285 0.296 0.536 0.203 0.573
both fitted (SDID) 0.044 0.299 0.302 0.403 0.208 0.453

Reweighting helps, and by a lot. Under the factor structure the bias falls from \(0.73\) to \(0.04\) with both margins fitted. Each margin helps alone: \(0.17\) from the unit weights, \(0.08\) from the time weights. Which margin matters is not fixed in advance. Here the time weights do slightly more, because the factor paths are smooth, so anchoring on the periods just before treatment removes most of the divergence. The fitted \(\lambda\) above showed the same thing when it put \(0.957\) on the last pre-period.

The slopes panel gains less: \(1.08\) down to \(0.40\). The reason is that the weights are fitted on the observed pre-period path, which carries the shock of Equation 14.21 as well as the trend, so they match a noisy version of what they are aiming at and fall short. The size of that shortfall depends on the pre-period length and the noise level rather than on anything intrinsic to reweighting — with a much cleaner pre-period the same estimator gets the bias under \(0.05\) — so the direction is the finding here and the magnitude is not.

Two limits are worth stating even though they do not bind in this panel. Reweighting controls can only produce a weighted average of the control trends, so a treated trend steeper than every control’s is out of reach; that is the same overlap problem seen earlier, now in the design, and the simplex is what makes it a hard boundary rather than a silent extrapolation. And if we are confident the trends really are unit-specific and linear, the direct response is to put unit-specific linear trends in the regression, not to reweight at all. Reweighting is the tool for when we will not commit to a functional form, which is the case Equation 14.18 stands in for.

14.5.5 Implied versus chosen

One distinction cuts across everything above. OLS, AIPW, DML, TWFE, and the imputation estimator all imply weights — they fall out of the estimation and are diagnosed afterward. Synthetic controls and synthetic difference-in-differences choose weights as part of the design, selecting \(\boldsymbol\omega\) to balance pre-treatment outcomes or covariates. For those estimators \(\hat\tau = \boldsymbol\omega'\boldsymbol Y\) is the definition, not a decomposition.

That difference changes how the weights should be read, in three ways. First, chosen weights are fitted on \(\boldsymbol Y\) itself, so \(\hat\tau\) is not linear in \(\boldsymbol Y\) and Equation 14.16 describes the estimate at the fitted weights rather than decomposing it the way Equation 14.2 decomposes OLS. Inference has to account for the weights having been estimated, which is why synthdid reports standard errors by jackknife or bootstrap rather than treating \(\boldsymbol\omega\) as fixed.

Second, a clean-looking balance diagnostic is weaker evidence here than it looks. When the weights were chosen to make the pre-period match, the match is not a test the design passed — it is what the design optimised. Fitting weights on a noisy pre-period statistic is a close cousin of a pretest. The analogy is with Roth (2022), who shows that conditioning an analysis on passing a parallel-trends test distorts the estimate and undercovers. Weight-fitting is not literally that procedure, since nothing is accepted or rejected, but it shares the mechanism: noise in the pre-period is allowed to select the analysis. Matching on pre-period levels is the sharp case: it induces regression to the mean, with a bias that grows as serial correlation weakens (Daw and Hatfield 2018; Chabé-Ferret 2015, 2017). Synthetic difference-in-differences avoids that particular trap by fitting \(\boldsymbol\omega\) with an intercept, so it never needs to match levels, only trajectories up to a constant.

Third, the effective count of Equation 14.19 is the number that reports the price. A design that matches well by concentrating weight on a few controls has bought that match with effective sample size, and Equation 14.20 shows the estimator charging itself for exactly that. Read the two together rather than either alone.

A closing caution. These weights diagnose estimation. They show which comparisons a difference-in-differences rests on, and whether it uses non-overlapping or forbidden ones. They do not test identification. Parallel trends is an assumption about the treated group’s unobserved untreated path, and a negative weight and a failure of parallel trends stay independent: either can occur without the other. Chosen weights can buy robustness to one class of violations, those the design’s trend model can express, and the weights let us see what that cost. No weighting of observed outcomes tests the assumption. To do that we bound the violation instead of reweighting, using the observed pre-period differences to limit how large the post-period departure can be (Rambachan and Roth 2023).

14.6 Summary

  1. A signed weighting of the outcomes. FWL writes \(\hat\beta = \sum_i \omega_i y_i\) with \(\omega_i = \tilde V_i / \sum_j \tilde V_j^2\) (Equation 14.2): a signed, computable weighting of the observed outcomes, different from the effect weights \(w_i = \tilde V_i V_i\).
  2. Two constraints. The weights sum to zero and the weighted regressor sums to one: \(\sum_i \omega_i = 0\) and \(\sum_i \omega_i V_i = 1\).
  3. The sign shows direction. Because \(\omega_i\) is proportional to \(\tilde V_i\), not to \(\tilde V_i^2\), it carries a sign: it shows which rows push \(\hat\beta\) up and which pull it down, and, under controls with thin overlap, which enter with the sign their raw position would not predict.
  4. The same weights beyond OLS. Any estimator that can be written \(\hat\tau = \boldsymbol\omega' \boldsymbol Y\) has outcome weights, so the same object and its checks reach AIPW, double machine learning, and causal forests (Knaus 2024). PLR’s denominator \(\tilde{\boldsymbol D}'\tilde{\boldsymbol D}\) reveals its estimand as the VWATE (Equation 14.11), not the ATE; and the sum-to-zero of OLS need not become a sum to \(\pm 1\) within the treated and control groups.
  5. From difference in means to difference-in-differences. Regressed on the treatment alone, \(\hat\beta\) is the difference in group means; a \(2 \times 2\) difference-in-differences is a signed contrast of four cell means. With covariates, a doubly-robust DiD weights the outcome changes, \(\widehat{\text{ATT}} = \boldsymbol\omega' \Delta Y\) (Sant’Anna and Zhao 2020), open to the same checks.
  6. Staggered designs. In staggered TWFE the outcome weights flag both treated cells used as controls and untreated cells used as treated — the observation-level version of the Bacon (2021) forbidden comparisons. Estimators that fit counterfactuals on untreated observations alone (Borusyak, Jaravel, and Spiess 2024; Callaway and Sant’Anna 2021) cannot assign negative weight to any treated cell, so the forbidden-comparison diagnostic is clean by construction.
  7. Rank counts the cohorts. As an \(N \times T\) matrix, the outcome weights of a block design have rank one and factor into a unit part times a time part (Equation 14.16). The plain \(2 \times 2\) and synthetic difference-in-differences differ only in whether those margins are uniform or fitted. Staggered adoption raises the rank — to at most the number of treated cohorts, generically to exactly that — and only above rank one can a treated cell enter negatively.
  8. Both margins carry correction, and the effective count reports its price. Weighting can offset differing trends only under a model in which trends vary by unit; then either margin helps alone and both together help most. What it costs is effective sample size: by Equation 14.19 the count reaches \(N_0\) only under uniform weights, so a closer match always means fewer effective controls, and Equation 14.20 shows synthetic difference-in-differences charging itself for exactly that.
  9. Chosen weights are read differently from implied ones. Weights fitted on \(\boldsymbol Y\) make \(\hat\tau\) non-linear in \(\boldsymbol Y\), so inference must account for their estimation, and a good pre-period match is what the design optimised rather than a test it passed. Fitting on a noisy pre-period statistic is a pretest (Roth 2022), and matching on pre-period levels invites regression to the mean (Daw and Hatfield 2018); fitting with an intercept, so only trajectories need to match, avoids that case.