import json
from pathlib import Path
import numpy as np
import pandas as pd
from scspill import SCSPILL
from scspill.data import load_california_cd
root = Path.cwd()
while not (root / "benchmarks").exists(): # docs pages execute in docs/articles
root = root.parent
reference = root / "benchmarks" / "reference"
APPLICATION_CODE = {"quantile_method": "linear", "ci_method": "published"}Replicating Cao & Dowd
The cd model against the paper, the authors’ code, and mlsynth
The cd model is checked against three independent sources, each with the settings that source uses:
- The paper (Cao & Dowd, arXiv:1902.07343v3), with scspill’s default settings, which are the paper’s Section 4 definitions.
- The authors’ code (jcao0/synthetic-control-spillover at commit
86004020). Its Proposition 99 application output is checked with the application code’s settings, and its simulation code’s statistics are checked too. - mlsynth’s
SPILLSYNTH(method="cd"), an independent implementation of the same estimator, on frozen fixtures.
The same checks run in the test suite (scspill/tests/test_cd.py, test_cd_features.py, test_cd_mlsynth_parity.py) and in the benchmark suite (python benchmarks/run_benchmarks.py --case california_cd --case cd_vs_mlsynth). This page runs them live.
The paper
Section 6.2 of v3 makes two checkable statements about the 51-unit Proposition 99 fit:
- California’s effects in 1989 and 1990 are not significantly different from zero at 5%, with intervals built by inverting the P test;
- in Figure 3’s sensitivity analysis, California’s largest effect estimate meets the upper bound of the spillover-aware bias set at \(\bar\alpha = 17.07\) with one missed spillover, and at \(9.66\) with two.
fit = SCSPILL({**load_california_cd(51).config_kwargs(), "display_graphs": False}).fit()
fit.effect_ci["CA"].loc[[1989, 1990]].assign(estimate=fit.effects_panel["CA"]).round(2)| lower | upper | estimate | |
|---|---|---|---|
| 1989 | -4.64 | 4.80 | 0.08 |
| 1990 | -1.00 | 8.43 | 3.71 |
sens = fit.cd.sensitivity
largest = fit.effects_panel["CA"].max()
pd.Series(
{f"p = {p}": round(largest / sens.coefficients(p)[0], 2) for p in (1, 2)},
name="break-even alpha_bar",
)p = 1 17.07
p = 2 9.66
Name: break-even alpha_bar, dtype: float64
mlsynth
mlsynth’s SPILLSYNTH(method="cd") implements the same estimator with a different solver, cvxpy with CLARABEL. Its results on six cases are frozen in benchmarks/reference/mlsynth_cd/ by tools/freeze_mlsynth_cd_reference.py:
- the Proposition 99 panel under the per-unit, homogeneous and distance-decay structures;
- a synthetic panel with two treated units, with and without efficient weighting.
Under mlsynth’s inference settings (those of the application code), scspill matches it:
mls = json.loads((reference / "mlsynth_cd" / "reference.json").read_text())
def mlsynth_case(name, case):
options = case["options"]
if name.startswith("ca"):
base = {**load_california_cd(int(name[2:4])).config_kwargs(), "affected_units": None}
else:
base = {"method": "cd", "df": pd.DataFrame(mls["panels"]["synth_two_treated"]),
"outcome": "y", "treat": "treated", "unitid": "unit", "time": "time"}
config = {**base, **APPLICATION_CODE, "display_graphs": False,
"spillover_structure": options.get("spillover_structure", "per_unit")}
for key in ("affected_units", "unit_distances"):
if options.get(key) is not None:
config[key] = options[key]
if options.get("weighting", "identity") != "identity":
config["weighting"] = options["weighting"]
return SCSPILL(config).fit()
rows = {}
for name, case in mls["cases"].items():
out = mlsynth_case(name, case)
units = case["units"]
b = out.scm_matrix.loc[units, units].to_numpy()
tests = {**case["treatment_tests"], **case["spillover_tests"]}
rows[name] = {
"largest B difference": float(
np.abs(b - [[case["B"][u][v] for v in units] for u in units]).max()
),
"largest effect difference": float(
max(np.abs(out.effects_panel[u] - case["alpha"][u]).max() for u in units)
),
"P values equal": all(
np.array_equal(out.pointwise_p_values[u], t["p_value"]) for u, t in tests.items()
),
}
pd.DataFrame(rows).T| largest B difference | largest effect difference | P values equal | |
|---|---|---|---|
| ca51_per_unit | 0.000001 | 0.000019 | True |
| ca41_per_unit | 0.000006 | 0.000012 | True |
| ca41_homogeneous | 0.000006 | 0.000012 | True |
| ca41_distance_decay | 0.000006 | 0.000011 | True |
| synth_two_treated | 0.0 | 0.0 | True |
| synth_efficient | 0.0 | 0.0 | True |
Structure selection. mlsynth’s select_A_by_kappa averages the per-period \(\kappa_A\) norms. scspill offers that statistic as statistic="mean_norm", and it reproduces mlsynth’s choice. scspill’s default is the paper’s multi-period statistic (v3 Section S.1.3), which can rank the candidates differently:
selection = mls["selection"]
synthetic = SCSPILL(
{"method": "cd", "df": pd.DataFrame(mls["panels"]["synth_homogeneous"]), "outcome": "y",
"treat": "treated", "unitid": "unit", "time": "time",
"affected_units": selection["candidates"]["per_unit_3"]["affected_units"],
"display_graphs": False}
).fit()
pd.DataFrame(
{
"mlsynth": selection["mean_kappa"],
"scspill, mean_norm": synthetic.select_structure_by_kappa(
selection["candidates"], statistic="mean_norm"
).statistics,
"scspill, v3": synthetic.select_structure_by_kappa(selection["candidates"]).statistics,
}
).round(3)| mlsynth | scspill, mean_norm | scspill, v3 | |
|---|---|---|---|
| per_unit_3 | 1.617 | 1.617 | 1.085 |
| homogeneous_3 | 2.001 | 2.001 | 1.306 |
| per_unit_1 | 4.679 | 4.679 | 12.061 |
| homogeneous_5 | 4.125 | 4.125 | 10.276 |
The provenance of every fixture (versions, commit, SHA-256 checksums) is recorded in benchmarks/reference/cao_dowd/README.md and benchmarks/reference/mlsynth_cd/README.md.