SDID Estimator - Unit and Time Weights

Summary

SDID is computed in four steps (Algorithm 1): (1) set a ridge penalty from the scale of one-period outcome changes; (2) solve a penalised, intercept-augmented synthetic-control problem for unit weights on the simplex; (3) solve the transposed problem — regress each control unit’s post-period average on its pre-period outcomes — for time weights on the simplex; (4) run TWFE DiD weighted by . Equivalently, is a weighted double difference of the four blocks of . The intercepts are what distinguish SDID weights from SC weights: they only need to make trends parallel, not identical, because the fixed effects absorb level gaps.

Overview

Partition the outcome matrix by control/treated rows and pre/post columns:

SC uses to learn how to combine rows (units) to mimic the treated rows. SDID does that and uses to learn how to combine columns (pre-periods) to mimic the post-period columns. The symmetry is the key idea: unit weights are a “vertical regression”, time weights are a “horizontal regression”, and both are fit only on cells that are never treated.

Main Content

Unit weights (eq. 2.1) ^def-sdid-unit-weights

Two differences from Abadie–Diamond–Hainmueller weights: (a) the intercept — the weighted controls need only be parallel to the treated average, since in the final regression absorbs constant gaps; (b) the ridge penalty, following Doudchenko & Imbens (2016), which disperses the weights and makes them unique. With and , (2.1) is exactly an ADH weight choice for .

Regularisation parameter (eq. 2.2) ^def-sdid-zeta

is the standard deviation of first differences among controls in the pre-period — “the size of a typical one-period outcome change” — and is a theoretically motivated scaling (Theorem 1 requires ). No cross-validation is involved.

Time weights (eq. 2.3) ^def-sdid-time-weights

No ridge penalty here (only a numerical for uniqueness, fn. 3). The asymmetry “reflects the fact we allow for correlated observations within time periods for the same unit, but not across units within a time period” beyond the factor structure (p. 7). Interpretation: the weighted average of a control unit’s history should predict its post-period average up to a constant .

Algorithm 1 — SDID ^alg-sdid

Input: , . Output: .

  1. Compute via (2.2).
  2. Compute unit weights via (2.1).
  3. Compute time weights via (2.3).
  4. Solve the weighted TWFE regression

Covariates (fn. 4): apply SDID to residuals from a regression of on time-varying exogenous .

Weighted double-differencing representation (eqs. 2.4-2.5, 4.3) ^thm-sdid-double-diff

For any , define

Then and . Equivalently, every estimator in the family is

with unit-level “adjusted outcomes”

So the final regression never has to be run as a regression: SDID is four weighted block means.

What the intercepts and fixed effects buy: invariance

Because of , and the two-way fixed effects, is unchanged if for any (§4, p. 20). DiD shares this invariance; SC is invariant to shifts only (fn. 8). This is why SDID does not require the treated unit to lie in the convex hull of control levels — a classic SC failure mode discussed in Synthetic Control Requirements.

What the time weights do

Contrast three conventions for the pre-period baseline (p. 9):

  • DiD: all pre-periods equally, .
  • Event studies: only the last pre-period (), since coefficients are normalised to — see Event Study Designs and Dynamic Treatment Effects.
  • SDID: data-driven. Periods are chosen so that “the weighted average of historical outcomes predict average treatment period outcomes for control units, up to a constant.”

With serially correlated noise, the oracle time weights shrink not toward zero but toward the autoregression vector (eq. 4.9) — the population coefficient from regressing the post-period average error on pre-period errors. This is why SDID can be more precise than DiD even when TWFE is correctly specified: it differences out the predictable part of the post-period noise.

Staggered adoption (Appendix §8)

“With staggered adoption the weighted DID regression approach in SDID does not work directly.” The paper’s fix: for each adoption date , form a block-assignment sub-panel of the never-treated units plus the cohort adopting at ; run SDID on each; average the estimates with weights equal to each sub-panel’s share of treated unit-period cells. (Alternatively split by time periods.) This mirrors the cohort-by-cohort logic of Group-Time Average Treatment Effects.

Examples

The California weights (Appendix §7.2). Time weights put all mass on the last three pre-years:

YearDID SDID
19880.0530.427
19870.0530.206
19860.0530.366
1970–19850.053 each0.000

Unit weights: SC is sparse — Utah 0.396, Montana 0.232, Nevada 0.204, Connecticut 0.104, New Hampshire 0.045, Colorado 0.013, Delaware 0.004, everything else 0. SDID spreads mass across roughly 30 of 38 states (largest: Nevada 0.124, New Hampshire 0.105, Connecticut 0.078, Delaware 0.070), a consequence of the ridge penalty and the intercept. Figure 1 shows that under DiD and SC a single state (New Hampshire) has very high influence , whereas “SDID does not give any state particularly high influence.”

Code sketch (NumPy/CVXPY; the double-difference form avoids fitting the regression):

import numpy as np, cvxpy as cp
 
def sdid(Y, N0, T0):
    N, T = Y.shape; N1, T1 = N - N0, T - T0
    d = np.diff(Y[:N0, :T0], axis=1)
    sigma = d.std()                               # eq. (2.2): divisor N_co (T_pre - 1)
    zeta = (N1 * T1) ** 0.25 * sigma
 
    # unit weights: rows = pre-periods, cols = control units
    w, w0 = cp.Variable(N0, nonneg=True), cp.Variable()
    target = Y[N0:, :T0].mean(axis=0)
    cp.Problem(cp.Minimize(cp.sum_squares(w0 + Y[:N0, :T0].T @ w - target)
                           + zeta**2 * T0 * cp.sum_squares(w)),
               [cp.sum(w) == 1]).solve()
 
    # time weights: rows = control units, cols = pre-periods
    l, l0 = cp.Variable(T0, nonneg=True), cp.Variable()
    target_t = Y[:N0, T0:].mean(axis=1)
    cp.Problem(cp.Minimize(cp.sum_squares(l0 + Y[:N0, :T0] @ l - target_t)
                           + (1e-6 * sigma)**2 * N0 * cp.sum_squares(l)),
               [cp.sum(l) == 1]).solve()
 
    omega = np.r_[-w.value, np.ones(N1) / N1]     # signed unit contrast
    lam   = np.r_[-l.value, np.ones(T1) / T1]     # signed time contrast
    return omega @ Y @ lam, w.value, l.value      # eq. (4.3)

Connections

See Also