Causal Inference VII — Synthetic Control¶

Building a counterfactual from a weighted donor pool: California's Proposition 99¶

Difference-in-differences needs a comparison group that trends in parallel with the treated unit. But some of the most important policy questions involve a single treated unit and no obvious control — one state passes a law, one country adopts a currency, one city runs a program. There is no other California; a simple average of "all other states" trends nothing like it. Synthetic control (Abadie & Gardeazabal 2003; Abadie, Diamond & Hainmueller 2010) solves this by constructing a bespoke control: a weighted average of donor units chosen so that the weighted combination closely reproduces the treated unit's pre-treatment trajectory. If "synthetic California" tracks real California for years before the policy, its post-policy path is a credible estimate of what California would have done without the law — and the gap is the treatment effect.

This is the method's landmark application. In November 1988 California passed Proposition 99, a 25-cent-per-pack cigarette tax plus a large tobacco-control program — the first of its kind. We ask: how much did it reduce smoking? We build the estimator end to end:

  • Constructing the synthetic control — donor weights (non-negative, summing to one) that match California's pre-1988 cigarette-sales path;
  • The estimated effect — the growing gap between real and synthetic California after 1988;
  • Placebo (permutation) inference — the method's substitute for standard errors: re-run the analysis pretending each donor state was treated, and ask whether California's gap is extreme relative to that placebo distribution;
  • Synthetic control vs difference-in-differences — why the weighting matters.

Data: the Abadie Proposition 99 panel (39 states, 1970–2000, per-capita cigarette sales). Python-lead (from-scratch quadratic-program weights + pysyncon); R companion uses Synth and tidysynth.

1. The problem — one treated unit, no ready control¶

The data track annual per-capita cigarette sales (packs) for 39 U.S. states from 1970 to 2000, along with predictors used to match: retail cigarette price, log income per capita, the share aged 15–24, and per-capita beer consumption. California is the treated unit (Proposition 99, effective 1989); the remaining 38 states form the donor pool — states that did not enact large-scale tobacco-control programs over the sample, so their smoking trends reflect the absence of such a policy.

The identification idea: no single state is a good control for California (it is larger, richer, and was already a relatively low-smoking state on a distinctive downward path), but some weighted combination of donor states can reproduce California's pre-1988 smoking trajectory. That weighted combination is the synthetic California. The plot below shows the raw problem: California's cigarette sales against the donor states, with the 1988 policy line.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt, warnings
from scipy.optimize import minimize
warnings.filterwarnings("ignore")
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
d=pd.read_csv("smoking.csv")
piv=d.pivot(index="year",columns="state",values="cigsale"); yrs=piv.index.values
treated="California"; donors=[c for c in piv.columns if c!=treated]
print(f"Abadie Proposition 99 panel: {piv.shape[1]} states x {piv.shape[0]} years ({yrs.min()}-{yrs.max()})")
print(f"treated = California (Prop 99 effective 1989); donor pool = {len(donors)} states with no large tobacco program")
fig,ax=plt.subplots(figsize=(9,5))
for s in donors: ax.plot(yrs,piv[s],color=GREY,lw=.6,alpha=.5)
ax.plot(yrs,piv[treated],color=RED,lw=2.5,label="California")
ax.plot([],[],color=GREY,lw=1,label="donor states")
ax.axvline(1988,color="k",ls="--",lw=1); ax.text(1988.3,30,"Prop 99\n(1988)",fontsize=9)
ax.set_xlabel("year"); ax.set_ylabel("per-capita cigarette sales (packs)"); ax.set_title("California vs the donor pool — no single state is a good control"); ax.legend()
plt.tight_layout(); plt.show()
print("California was already on a distinctive low, declining path -- a plain average of other states would not match it.")
print("Synthetic control finds the weighted mix of donors that does.")
Abadie Proposition 99 panel: 39 states x 31 years (1970-2000)
treated = California (Prop 99 effective 1989); donor pool = 38 states with no large tobacco program
No description has been provided for this image
California was already on a distinctive low, declining path -- a plain average of other states would not match it.
Synthetic control finds the weighted mix of donors that does.

2. Constructing synthetic California¶

The synthetic control is a vector of weights $w=(w_1,\dots,w_{38})$ on the donor states, chosen so the weighted donor average reproduces California before the policy. We keep the weights non-negative and summing to one — the defining constraint that prevents extrapolation (synthetic California is a genuine interpolation of real states, never a state "times 1.5") and delivers sparse, interpretable weights. The from-scratch objective minimizes the pre-1988 distance in cigarette sales: $$\min_{w\ge 0,\ \sum w=1}\ \sum_{t\le 1988}\Big(\text{cigsale}^{CA}_t-\sum_j w_j\,\text{cigsale}^{j}_t\Big)^2.$$ Solving this quadratic program yields a handful of positive weights — a few states that, blended, look just like pre-Prop-99 California. The fit is excellent (pre-treatment root-mean-squared prediction error ≈ 1.7 packs on sales near 120), which is what licenses the counterfactual.

In [2]:
pre=yrs<=1988; post=yrs>=1989
def sc_weights(y1, y0):                                  # y1: (Tpre,), y0: (Tpre, ndonor)
    n=y0.shape[1]
    res=minimize(lambda w:((y1-y0@w)**2).sum(), np.ones(n)/n, method="SLSQP",
                 bounds=[(0,1)]*n, constraints=[{"type":"eq","fun":lambda w:w.sum()-1}],
                 options={"maxiter":1000,"ftol":1e-12})
    w=res.x; w[w<1e-6]=0; return w/w.sum()
w=sc_weights(piv.loc[pre,treated].values, piv.loc[pre,donors].values)
synth=piv[donors].values@w
rmspe_pre=np.sqrt(np.mean((piv[treated].values[pre]-synth[pre])**2))
wt=pd.Series(w,index=donors).sort_values(ascending=False)
print(f"Pre-treatment RMSPE = {rmspe_pre:.2f} packs (excellent fit)\nDonor weights forming synthetic California:")
for s,v in wt[wt>0.01].items(): print(f"   {s:16s} {v:.3f}")
fig,ax=plt.subplots(1,2,figsize=(13,4.6))
ax[0].plot(yrs,piv[treated],color=RED,lw=2.5,label="California (actual)")
ax[0].plot(yrs,synth,color=BLUE,lw=2.5,ls="--",label="synthetic California")
ax[0].axvline(1988,color="k",ls=":",lw=1); ax[0].set_xlabel("year"); ax[0].set_ylabel("cigarette sales (packs)")
ax[0].set_title("Synthetic California tracks the real state before 1988, diverges after"); ax[0].legend()
top=wt[wt>0.01]; ax[1].barh(top.index[::-1],top.values[::-1],color=GREEN); ax[1].set_xlabel("weight"); ax[1].set_title("Donor weights (the rest are zero)")
plt.tight_layout(); plt.show()
print("A sparse blend -- Utah, Montana, Nevada, Connecticut, New Hampshire, Colorado -- reproduces pre-1988 California. The tight pre-period")
print("match is the credibility check: if synthetic CA mirrors real CA for 18 years, its post-1988 path is a fair counterfactual.")
Pre-treatment RMSPE = 1.66 packs (excellent fit)
Donor weights forming synthetic California:
   Utah             0.394
   Montana          0.232
   Nevada           0.205
   Connecticut      0.109
   New Hampshire    0.045
   Colorado         0.015
No description has been provided for this image
A sparse blend -- Utah, Montana, Nevada, Connecticut, New Hampshire, Colorado -- reproduces pre-1988 California. The tight pre-period
match is the credibility check: if synthetic CA mirrors real CA for 18 years, its post-1988 path is a fair counterfactual.

3. The effect, and placebo (permutation) inference¶

After 1988, real California falls steadily below synthetic California — the gap is the estimated effect of Proposition 99, and it grows over time as the program matures, reaching roughly 26 fewer packs per capita by 2000 (about a quarter of baseline consumption). But synthetic control has one treated unit, so there is no conventional standard error. Abadie's inference is a placebo (permutation) test: pretend, in turn, that each donor state was the treated one, build its synthetic control, and record its gap. If the gap for genuinely-treated California is extreme relative to the distribution of placebo gaps, the effect is unlikely to be chance.

We run all 38 placebos and compare. California's post-treatment gap is larger than almost every placebo's, and its post/pre RMSPE ratio — how much the model's error blows up after treatment relative to its pre-treatment fit — ranks 3rd of 39, giving a permutation $p$ of $0.077$. That does not clear the conventional 5%, and with 38 placebos the smallest attainable $p$ is $1/39 = 0.026$: this test has little room to be decisive even in principle. That it is suggestive rather than decisive here is itself an honest lesson: this from-scratch synthetic control matches on the pre-treatment outcome path only, whereas Abadie's published analysis also matches on economic predictors (price, income, age, beer) with an optimally weighted distance — which sharpens the placebo distribution and pushes California to the very top. The R companion (Synth/tidysynth) uses that predictor-based matching; the effect size (~26 packs) is robust across both.

In [3]:
gap=piv[treated].values-synth
# placebos: treat each donor as if it were the policy state
placebo_gaps={}
for s in donors:
    pool=[c for c in donors if c!=s]
    ws=sc_weights(piv.loc[pre,s].values, piv.loc[pre,pool].values)
    placebo_gaps[s]=piv[s].values-piv[pool].values@ws
# post/pre RMSPE ratio for ranking (Abadie's test statistic)
def ratio(g): return np.sqrt(np.mean(g[post]**2))/np.sqrt(np.mean(g[pre]**2))
ratios=pd.Series({**{s:ratio(g) for s,g in placebo_gaps.items()}, treated:ratio(gap)}).sort_values(ascending=False)
rank=list(ratios.index).index(treated)+1; pval=rank/len(ratios)
print(f"California effect: {gap[post][0]:+.1f} packs in 1989 growing to {gap[-1]:+.1f} packs by 2000")
print(f"post/pre RMSPE ratio: California = {ratios[treated]:.1f}, rank {rank}/{len(ratios)} -> permutation p = {pval:.3f}")
fig,ax=plt.subplots(1,2,figsize=(13,4.6))
for s,g in placebo_gaps.items(): ax[0].plot(yrs,g,color=GREY,lw=.7,alpha=.5)
ax[0].plot(yrs,gap,color=RED,lw=2.5,label="California"); ax[0].plot([],[],color=GREY,label="placebo (donor) states")
ax[0].axhline(0,color="k",lw=.6); ax[0].axvline(1988,color="k",ls=":"); ax[0].set_xlabel("year"); ax[0].set_ylabel("gap: actual − synthetic (packs)")
ax[0].set_title("Placebo test: California's gap is extreme vs donor placebos"); ax[0].legend(fontsize=8)
colors=[RED if s==treated else GREY for s in ratios.index]
ax[1].bar(range(len(ratios)),ratios.values,color=colors); ax[1].set_xlabel("state (ranked)"); ax[1].set_ylabel("post/pre RMSPE ratio")
ax[1].set_title(f"California ranks #{rank}/{len(ratios)} (permutation p={pval:.3f})")
plt.tight_layout(); plt.show()
print()
print(f"Read the rank before the ratio. California's error-inflation ratio of {ratios[treated]:.1f} is high, but it places")
print(f"{rank} of {len(ratios)} -- two placebo states blow up more after their pretend treatment date -- and the")
print(f"permutation p of {pval:.3f} does not clear the conventional 5%. Suggestive, not decisive.")
print()
print("The reason is instructive rather than damning. This from-scratch implementation matches on the")
print("pre-treatment OUTCOME PATH ONLY. Abadie's published analysis also matches on economic predictors")
print("-- price, income, age structure, beer consumption -- with an optimally weighted distance, which")
print("tightens California's pre-period fit relative to the placebos and lifts it to the top of the")
print("ranking. The R companion runs that version.")
print()
print(f"So the EFFECT SIZE is robust across implementations at roughly 26 packs, while the INFERENCE depends")
print(f"on how the donors were matched. Note also the ceiling: with {len(ratios)-1} placebos the smallest attainable")
print(f"p-value is 1/{len(ratios)} = {1/len(ratios):.3f}, so this test has little room to be decisive even in principle.")
print()
print("The permutation logic itself descends from Fisher's exact test: hand the treatment label to each")
print("unit in turn, and ask where the real one falls in the distribution that produces.")
California effect: -8.4 packs in 1989 growing to -26.6 packs by 2000
post/pre RMSPE ratio: California = 12.4, rank 3/39 -> permutation p = 0.077
No description has been provided for this image
Read the rank before the ratio. California's error-inflation ratio of 12.4 is high, but it places
3 of 39 -- two placebo states blow up more after their pretend treatment date -- and the
permutation p of 0.077 does not clear the conventional 5%. Suggestive, not decisive.

The reason is instructive rather than damning. This from-scratch implementation matches on the
pre-treatment OUTCOME PATH ONLY. Abadie's published analysis also matches on economic predictors
-- price, income, age structure, beer consumption -- with an optimally weighted distance, which
tightens California's pre-period fit relative to the placebos and lifts it to the top of the
ranking. The R companion runs that version.

So the EFFECT SIZE is robust across implementations at roughly 26 packs, while the INFERENCE depends
on how the donors were matched. Note also the ceiling: with 38 placebos the smallest attainable
p-value is 1/39 = 0.026, so this test has little room to be decisive even in principle.

The permutation logic itself descends from Fisher's exact test: hand the treatment label to each
unit in turn, and ask where the real one falls in the distribution that produces.

4. Synthetic control vs difference-in-differences¶

Why not just run difference-in-differences — California versus the average of all donor states? Because DiD imposes equal weights on the donors and assumes they collectively trend in parallel with California. They do not: the simple donor average sits well above California and on a different slope before 1988, so a naive DiD is built on a violated parallel-trends assumption and mis-estimates the effect. Synthetic control is DiD with data-driven weights that enforce pre-treatment parallelism by construction. The plot contrasts the two counterfactuals; the honest synthetic control matches California's history, the equal-weighted DiD does not. (The formal hybrid, synthetic difference-in-differences — Arkhangelsky et al. 2021 — combines both, adding unit and time weights; the R companion runs it.)

In [4]:
did_cf=piv.loc[pre,treated].values.mean()+(piv[donors].mean(1).values-piv.loc[pre,donors].mean(1).values.mean())  # DiD counterfactual: CA pre-mean + donor deviation
# clearer: DiD effect = (CA post-pre change) - (donor-average post-pre change)
ca_change=piv[treated].values[post].mean()-piv[treated].values[pre].mean()
donor_change=piv[donors].mean(1).values[post].mean()-piv[donors].mean(1).values[pre].mean()
did_effect=ca_change-donor_change
sc_effect=gap[post].mean()
print(f"Naive DiD effect (CA vs equal-weighted donor average) = {did_effect:+.1f} packs")
print(f"Synthetic control effect (avg post-1988 gap)          = {sc_effect:+.1f} packs")
fig,ax=plt.subplots(figsize=(9,5))
ax.plot(yrs,piv[treated],color=RED,lw=2.5,label="California (actual)")
ax.plot(yrs,synth,color=BLUE,lw=2.5,ls="--",label="synthetic control (weighted)")
ax.plot(yrs,piv[donors].mean(1),color=ORANGE,lw=2,ls=":",label="donor average (naive DiD control)")
ax.axvline(1988,color="k",ls=":"); ax.set_xlabel("year"); ax.set_ylabel("cigarette sales (packs)")
ax.set_title("Synthetic control matches pre-1988 California; the equal-weighted DiD control does not"); ax.legend()
plt.tight_layout(); plt.show()
print("The donor average (orange) is far above California and mis-trending -- naive DiD would violate parallel trends. Synthetic")
print("control's weighting restores it, which is why the two effect estimates differ. SC = DiD earning its comparison group.")
Naive DiD effect (CA vs equal-weighted donor average) = -27.3 packs
Synthetic control effect (avg post-1988 gap)          = -19.5 packs
No description has been provided for this image
The donor average (orange) is far above California and mis-trending -- naive DiD would violate parallel trends. Synthetic
control's weighting restores it, which is why the two effect estimates differ. SC = DiD earning its comparison group.

5. Summary¶

When only one unit is treated and no natural control exists, synthetic control constructs one — a non-negative, sum-to-one weighted average of donor units that reproduces the treated unit's pre-treatment path. On California's Proposition 99, a sparse blend of Utah, Montana, Nevada, Connecticut, New Hampshire and Colorado matched pre-1988 California closely, and the post-1988 divergence estimated that the program cut smoking by a growing margin, reaching about 26 packs per capita by 2000. Because there is no standard error for a single treated unit, inference came from a placebo permutation test: California's gap and its post/pre error ratio ranked near the top of all donor placebos (3rd of 39, $p\approx0.08$ under pure-outcome matching; sharper with Abadie's predictor-based matching in the R companion).

The method's logic and its cross-links:

  • Non-negative sum-to-one weights prevent extrapolation and yield interpretable, sparse controls — the transparency that made synthetic control a favourite for policy evaluation.
  • Synthetic control is difference-in-differences with data-driven weights that enforce pre-treatment parallelism, rather than assuming it for an equal-weighted average — the direct link to the previous notebook. Synthetic DiD generalizes both.
  • Placebo inference is the same permutation reasoning as Fisher's randomization test from the first notebook, transported to a comparative case study.
  • The weighting connects to matching (subsection 2): both build a comparison from weighted control units, one across time-series trajectories, the other across covariate profiles.

Next: DAGs, Mediation & the Structural Causal Model, which steps back from specific estimators to the language of identification — Pearl's graphical framework — and reconciles it with the potential-outcomes view that has run through the whole arc.