Who Else Was Treated?

Three synthetic controls, one policy, and the interval that was 33 times too narrow

−16.87packs per capita, leak modelled
−5.50Nevada, from a law it never passed
33×how much too narrow the interval was

Carlos Mendez

Nagoya University (GSID)

August 4, 2026

Two Assumptions

Act I

The most replicated result in causal inference rests on two untested assumptions

California’s Proposition 99, 1988. A 25-cent cigarette tax. The canonical synthetic control study.

One. The donor weights must be non-negative and sum to one — the simplex.

Two. No donor state absorbed any part of the treatment — SUTVA.

Neither is a fact about the world. Both are modelling choices, and both are testable.

This deck relaxes both. The effect survives. The claim that the donor pool was clean does not.

Thirty-nine states, thirty-one years, and one line that leaves the pack

California's per-capita cigarette sales against all 38 donor states, 1970-2000, with a vertical rule at the 1988 treatment year.

39 states · 1,209 rows · 18 pre-treatment years · 38 donors

Nevada is California’s only neighbour inside the donor pool

Oregon and Arizona border California too.

Both ran their own tobacco programmes, so neither is a donor.

Eleven states are excluded altogether: AK, AZ, FL, HI, MD, MA, MI, NJ, NY, OR, WA.

The result: exactly one leak channel, and we can name it.

spatial_w has one non-zero entry. That is the whole spatial story, and it is why \(\rho\) is hard to pin down.

The simplex and SUTVA, written down

\[\widehat{\alpha} = \arg\min_{\alpha \in \Delta} \sum_{t \le T_0}\Big(Y_{1t} - \alpha^{\top}\mathbf{Y}^{c}_{t}\Big)^2, \qquad \Delta = \Big\{\alpha : \alpha_j \ge 0,\ \textstyle\sum_j \alpha_j = 1\Big\}\]

\[\text{SUTVA:} \qquad Y_{jt}(\mathbf{D}) = Y_{jt}(D_j) \quad \text{for every donor } j\]

Relax the first and the donor pool changes. Relax the second and the counterfactual changes.

Python can now do all three stages without leaving one language

pip install "scspill[numba]==0.2.1"
pip install "mlsynth[bayes] @ git+https://github.com/jgreathouse9/mlsynth.git@15f168bb"

mlsynth — 46 synthetic-control estimators behind one config dictionary.

scspill — the Bayesian spatial spillover model of Sakaguchi & Tagawa (2026), which returns two estimands.

Both are young. Both are pinned — one to a release, one to a commit.

Three relaxations, then two post-mortems

  • Stage 1 — the simplex, as Abadie wrote it
  • Stage 2 — the simplex replaced by a prior
  • Stage 3 — SUTVA dropped, and a second estimand appears
  • Reconcile and diagnose — why the R edition reports a very different interval, and what an effective sample size of 3 means

Three Stages on One Panel

Act II

Each stage drops exactly one restriction from the stage before

Stage 1 — hard constraint: \(\qquad \alpha \in \Delta\)

Stage 2 — soft prior: \(\qquad \alpha_j \mid \lambda_j \sim \mathcal{N}(0, \lambda_j^2), \quad \lambda_j \mid \tau \sim \mathcal{C}^{+}(0,\tau)\)

Stage 3 — same \(\alpha\), but the donors are no longer clean:

\[\mathbf{Y}^{c}_{t} = \rho\big(\mathbf{w}\,Y_{1t} + W\mathbf{Y}^{c}_{t}\big) + X_t\beta + \mathbf{u}_t\]

One regression, not three models. When a number moves, we know which restriction moved it.

Stage 1 — the simplex picks five donors and stops

common = dict(df=df, outcome="cigsale", treat="treated",
              unitid="state", time="year", display_graphs=False)

sc = mlsynth.VanillaSC(dict(common)).fit()
ATT (effect on California) −18.43
pre-treatment RMSE 1.60
active donors 5 of 38
top-4 share of weight 98.6%

Utah 0.343 · Montana 0.254 · Nevada 0.242 · Connecticut 0.146 — and exactly zero for the other 33.

Is that sparsity in the data, or sparsity in the constraint? From the outside they look identical.

California and its synthetic are indistinguishable until 1988

Top: California against its classical synthetic control. Bottom: the gap between them, near zero before 1988 and widening to −26.7 packs by 2000.

Zero gap before 1988; −26.7 packs by 2000.

The horseshoe makes zero the default without making it compulsory

\[\alpha_j \mid \lambda_j \sim \mathcal{N}\big(0, \lambda_j^2\big), \qquad \lambda_j \mid \tau \sim \mathcal{C}^{+}(0, \tau), \qquad \tau \sim \mathcal{C}^{+}(0, \sigma)\]

Infinite spike at zero. Cauchy tails. Most \(\lambda_j\) come out tiny; any single one can escape.

Sampling is closed-form via the Makalic–Schmidt inverse-gamma representation — an exact reparameterisation, not an approximation.

That is why both libraries run hundreds of thousands of iterations in seconds rather than hours.

Relax the simplex and the active donor pool multiplies by five

Posterior donor weights from mlsynth.BSCM with 95% credible intervals, the 24 largest by magnitude; 26 of the 38 donors exceed 0.01 in absolute value, and most credible intervals straddle zero.

5 → 26 active donors. Almost every credible interval straddles zero.

Two Bayesian synthetic controls, one intercept apart

Estimator ATT Intercept Weights sum
mlsynth.BSCM −18.85 16.86 0.758
scspill at \(\rho = 0\) −15.68 0 0.885
R edition, Stage 2 −15.84 0

\[\text{BSCM:}\ \ Y_{1t} = \beta_0 + \sum_j \alpha_j Y_{jt} + \varepsilon_t \qquad \text{scspill:}\ \ Y_{1t} = \sum_j \alpha_j Y_{jt} + \varepsilon_t\]

When two packages disagree about a model they both implement, check the equation before you check the sampler.

Drop SUTVA and the bias has a closed form

\[Y_{1t} - \sum_j \alpha_j Y_{jt} \;=\; \underbrace{\xi_{0t}}_{\text{what we want}} \;-\; \underbrace{\sum_j \alpha_j \xi^{c}_{jt}}_{\text{what we get for free}}\]

The bias is a product of two things: how much weight a donor carries, and how contaminated it is.

A filthy donor with zero weight is harmless. A slightly dirty one carrying half the counterfactual is not.

The simplex is gone; something took its place

\[\exists\, \alpha \in \mathbb{R}^{N} \;:\; Y_{1t}(\mathbf{0}) = \sum_j \alpha_j Y_{jt}(\mathbf{0}) \quad \forall t\]

Weaker than convexity, stronger than approximate fit — and untestable, because it leaves no RMSE behind.

The donors’ untreated path solves in closed form, and the nuisance block cancels

With \(A = W + \mathbf{w}\alpha^{\top}\):

\[\mathbf{Y}^{c}_{t}(\mathbf{0}) = \big(I - \rho A\big)^{-1}\Big[\big(I - \rho W\big)\mathbf{Y}^{c}_{t} - \rho\,\mathbf{w}\,Y_{1t}\Big]\]

No \(\beta\). No factors. No error variances. They cancel.

The effects depend on \((\alpha, \rho, \mathbf{w}, W)\) and the observed data. Nothing else.

So the model’s weak identification has exactly one address: \(\rho\).

Weak identification you can locate is a manageable problem. Weak identification spread across a nuisance block is not.

One call runs both samplers and post-processes the spillovers

result = SCSPILL({
    **panel.config_kwargs(),
    "m_iter": 500_000,
    "burn": 250_000,
    "seed": 20251022,
}).fit()

result.att                      # −16.87
result.effects_detail.att_scm   # −15.68, the ρ = 0 comparator, free
result.rho_hat                  # 0.316
result.spillover_panel          # 31 × 38: who else was treated

Stage 2 comes out of the Stage-3 fit at no extra cost. The model is nested.

The spatial parameter is clearly above zero

0.316

\(\widehat{\rho}\), 95% CrI [0.231, 0.403]. The interval excludes zero. The data reject \(\rho = 0\) — and with it, SUTVA on the donor pool.

The two parameters leaning on one contiguity channel are the hard ones

Parameter Effective sample size (from 250,000 draws)
\(\sigma^2\) 204,095
\(\alpha\) (donor weights) 10,000 – 26,000
\(\beta\) (retail price) 388
\(\rho\) 137

\(\rho\) is the only parameter drawn by random-walk Metropolis rather than from a closed-form conditional, so consecutive draws are heavily correlated. The posterior itself is tight — SD 0.043 on a support 1.9 wide.

Slow mixing is not weak identification, and neither is a defect in the software.

Almost the entire spillover lands on one state

A tile cartogram of the United States shaded by estimated spillover; Nevada is far darker than every other state and the field is otherwise pale.

Horizontal bars for the eight largest spillovers: Nevada at −5.50 packs, then Idaho and Utah near −0.49, then a steep drop to near zero.

Nevada −5.50 · Idaho −0.49 · Utah −0.49 · every other donor under 0.06 packs.

The leak runs the opposite way to the obvious hypothesis

−5.50

Nevada’s sales came in below its no-treatment path. That looks like the campaign crossing the border, not the cigarettes — but the estimate cannot name the mechanism.

Which means the classical estimate was biased toward zero

Donor \(\alpha_j\) \(\xi^{c}_j\) \(\alpha_j \xi^{c}_j\)
Nevada 0.200 −5.50 −1.098
Utah 0.036 −0.49 −0.018
Idaho 0.012 −0.49 −0.006
Sum (all 38 donors) −1.130

Purged minus contaminated: −1.186. The identity holds to 0.06 packs.

The residual is bookkeeping: the plug-in uses \(\widehat{\alpha}\); the purged ATT averages over paired draws.

Negative spillovers on positively-weighted donors push the estimate toward zero. Modelling the leak makes the effect larger.

The R edition’s interval is 33 times too narrow — and the R specification reproduces that exactly

ATT 95% CrI Width
R edition (published) −16.59 [−16.78, −16.39] 0.384
This post, corrected −16.87 [−23.05, −10.33] 12.713
beta_prior="ridge"        # departure 2
propagate_alpha=False     # departure 3
adapt_rho=False           # departure 4

Back in the R specification: ATT −16.286 against −16.590, \(\widehat{\rho}\) 0.2282 against 0.2226, effective sample size 3.27 against 2.93.

Independent code, different language, reproducing the pathology — including an effective sample size of 3.

Chain length was never the problem — the interval was incomplete

Specification Iterations Width ESS(\(\rho\))
scspill, R spec 5,000 0.482 3.3
scspill, R spec 500,000 0.702 66.9
scspill, corrected 500,000 12.713 136.8

A hundred times the iterations widens the interval by 45%. Propagating \(\alpha\) widens it by 1,700%.

Effective sample size asks whether the interval is reliable. propagate_alpha asks whether it is complete. The published interval failed both.

Report the effective sample size beside every credible interval, or the interval is decoration.

A memory layout was doing part of the modelling

Departure 1: the covariate array was indexed as \((N, T, K)\) when it was laid out as \((T, N, K)\).

Nothing crashes. Nothing looks wrong. Each state’s price is silently matched to another state’s sales.

It is also the one departure with no escape hatch worth using.

The only way out is to drop the covariates — one specification error for another.

Departures 5 and 6 were different: nobody spotted them. A Geweke joint-distribution test did.

The most dangerous bugs are the ones that return plausible numbers.

Artefacts shrink; errors do not

Geweke joint-distribution test z-scores at two chain lengths, with the maximum falling from 3.48 to 2.50 as the chain grows tenfold.

max \(|z|\) falls 3.48 → 2.50 and rejections go 1 → 0 as the chain grows tenfold. That is slow mixing, not an incoherent conditional.

The only prior that moves the answer is the one nobody calls a prior

Posterior mean and interval for rho across six prior configurations, flat except for the row where the support is truncated to plus or minus 0.5.

Sweeping \(a_0\), \(b_0\) and the step size moves \(\rho\) by 0.07. Truncating the support moves it by 0.32.

The strongest objection — and the answer

Objection. The SAR layer does not make anything causal that was not causal before. You supplied the graph; a different graph gives different spillovers.

Response. Correct, and the post says so. But the alternative is not “no assumption” — it is \(\rho = 0\), imposed silently and never reported. Making the assumption a parameter is what lets the data reject it.

The choice is not between assuming and not assuming. It is between an assumption you can test and one you cannot.

What Survives Every Relaxation

Act III

Every stage of the ladder agrees on the sign and the scale

Point estimates and credible intervals for every stage of the ladder, with hollow diamonds marking the R edition's published values.

Two libraries, four prior structures, one spatial layer, one independent third implementation.

What Proposition 99 cost, and who else paid

−16.87

packs per capita per year for California — and −5.50 for a state that never voted on it

Four things survive this deck

  1. The effect is robust — −15.7 to −18.8 packs, every relaxation.
  2. The donor pool is not — 5 donors or 26, depending on the constraint.
  3. SUTVA is false here — 1.19 packs for California, 5.50 for Nevada.
  4. The interval was the real error, not the point estimate.

Everything here is runnable today

  • Full tutorial — carlos-mendez.org/post/python_sc_bayes_spatial
  • R edition — carlos-mendez.org/post/r_sc_bayes_spatial
  • Run it — Colab notebook, or the Quarto bundle with a hermetic .venv
  • Web app — interactive \(\rho\) slider, donor comparator, trace diagnostics
  • Data dictionary — the panel and both spatial objects
pip install "scspill[numba]==0.2.1"
pip install "mlsynth[bayes] @ git+https://github.com/jgreathouse9/mlsynth.git@15f168bb"

Let the data choose the donors, let the map say who else was treated, and let the ESS say whether the interval means anything.