SDID — Synthetic Difference-in-Differences (Arkhangelsky et al. 2021)#
- Estimator:
Synthetic Difference-in-Differences (SDID) —
mlsynth.SDID- Source:
Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021), “Synthetic Difference-in-Differences,” American Economic Review 111(12):4088-4118.
- Replication type:
Path A — the paper’s Proposition 99 empirical, with a cross-validation against the
causaltensorreference implementation.- Status:
Fully verified — empirical headline reproduced and matched cell-for-cell to an independent implementation.
Validation strategy#
Arkhangelsky et al.’s headline application is California’s Proposition 99
tobacco-control program, estimated on the canonical Abadie-Diamond-Hainmueller
smoking panel (39 states, 1970-2000; California treated from 1989). The paper
reports an SDID ATT of about -15.6 packs per capita, matched by the
authors’ R synthdid package (-15.604). mlsynth reproduces that number to
three significant figures, and we cross-validate the implementation against
causaltensor.SDID — a fully independent Python port of the
\(\widehat\tau^{\text{sdid}}\) estimator — on the same matrix.
Path A — Proposition 99#
The panel ships as basedata/smoking_data.csv with a ready-made
Proposition 99 indicator flagging the treated unit/period cells.
import pandas as pd
from mlsynth import SDID
df = pd.read_csv("basedata/smoking_data.csv")
df["treat"] = df["Proposition 99"].astype(int)
res = SDID({"df": df[["state", "year", "cigsale", "treat"]],
"outcome": "cigsale", "treat": "treat",
"unitid": "state", "time": "year",
"display_graphs": False}).fit()
res.inference.att # -15.605
mlsynth returns \(\widehat{\mathrm{ATT}} = -15.605\), matching the
AER headline (-15.6) and the synthdid value (-15.604).
Cross-validation against causaltensor#
The same outcome matrix \(O\) (39 × 31) and treatment mask \(Z\)
are handed to causaltensor.SDID:
import numpy as np, causaltensor as ct
wide = df.pivot(index="state", columns="year", values="cigsale").sort_index()
states, years = wide.index.tolist(), wide.columns.tolist()
O = wide.values.astype(float)
ti, sc = states.index("California"), years.index(1989)
Z = np.zeros_like(O); Z[ti, sc:] = 1
ct.SDID(O, Z, treat_units=[ti], starting_time=sc) # -15.602
The two implementations agree to \(|\Delta| = 3.1 \times 10^{-3}\) packs. The residual is the unit-weight ridge (\(\zeta\)) optimiser, not a methodological difference — the SDID weight QPs and the final regression are identical.
Quantity |
mlsynth |
causaltensor |
AER / synthdid |
|---|---|---|---|
Overall ATT |
-15.605 |
-15.602 |
-15.6 / -15.604 |
Durable check#
The benchmark lives in benchmarks/cases/sdid_prop99.py and runs in the
default suite (skipping gracefully if causaltensor is absent):
pip install causaltensor
python benchmarks/run_benchmarks.py --case sdid_prop99
It asserts the ATT lands on the published -15.604 (tol 0.05) and matches
causaltensor to within \(5 \times 10^{-3}\).
References#
Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021). “Synthetic Difference-in-Differences.” American Economic Review 111(12):4088-4118.