okunfa
Synthetic control from scratch, verified against the paper that defined the method, shipped as a library.
pip install okunfa
Okunfa is Yoruba for cause. The question causal inference keeps asking is "compared to what?", and the synthetic control method (Abadie, Diamond, Hainmueller 2010) gives the most transparent answer in the toolbox: build the counterfactual as a weighted average of untreated units, weights non-negative and summing to one, chosen to match the treated unit before the intervention. The counterfactual comes with an ingredients list you can argue with.
I implemented the full estimator on numpy and scipy (no causal libraries anywhere), then pointed it at the method's founding result: California's Proposition 99 tobacco program.
The reproduction
ADH 2010, Table 2, says synthetic California is Utah .334, Nevada .234, Montana .199, Colorado .164, Connecticut .069. This implementation, run on the same public panel with the same predictors, finds:
| state | published | okunfa |
|---|---|---|
| Utah | 0.334 | 0.335 |
| Nevada | 0.234 | 0.236 |
| Montana | 0.199 | 0.201 |
| Colorado | 0.164 | 0.160 |
| Connecticut | 0.069 | 0.068 |
All five within 0.005, every other state at zero, and the headline effect lands where the paper put it: California's cigarette sales about 26 packs per capita below its ghost by 2000. A test pins each published weight to within 0.01, so the reproduction cannot silently rot.
(Source: assets/manim_recipe.py, video in assets/recipe.mp4.)
The inference is the honest part
A synthetic control without placebos is just a curve that goes down. The method's own significance test is to run the identical procedure on every state that never passed the law:
California's post/pre RMSPE ratio is the most extreme of all 39 states, a permutation p-value of 1/39 = 0.026, matching the paper's inference exactly. Two more checks that the story is structural, not lucky:
Leave-one-out refits drop each ingredient state in turn (the gap survives losing any single leg), and an in-time placebo pretends the law passed in 1980 using only pre-1980 information (the design finds almost nothing where nothing happened).
The estimator
The paper's nested optimization, implemented directly:
- Inner problem: given predictor importances V, the weights solve a small quadratic program on the simplex, min (X1 - X0 w)' V (X1 - X0 w), by SLSQP with an analytic gradient.
- Outer problem: V itself is chosen to minimize pre-period outcome MSPE, by Nelder-Mead over a softmax parameterization, so every candidate V is a valid distribution by construction, with multiple restarts because the surface is not convex.
The chosen V is itself interpretable, and slightly deflating: the data puts 87% of the matching weight on retail price and most of the rest on the 1975 sales lag. The covariates everyone lists (income, beer, age structure) barely matter once the outcome lags are in, which is a known property of the method worth seeing with your own eyes.
Using it
from okunfa import SyntheticControl, in_space_placebos, permutation_pvalue
from okunfa.datasets import load_prop99, adh_design
panel, years = load_prop99() # fetches the public panel on demand
design = adh_design(panel, years)
sc = SyntheticControl()
fit = sc.fit(**design)
print(fit.nonzero()) # [('Utah', 0.335), ('Nevada', 0.236), ...]
print(fit.gap[-1]) # about -26 packs in 2000
fits = in_space_placebos(sc, **design)
p, ratios = permutation_pvalue(fits, "California")
print(p) # 0.026
The API takes plain dicts and numpy arrays (unit -> outcome path, predictor -> unit -> value), so it works on any panel, not just this one.
Honest limits
- Permutation inference with 39 units cannot say anything smaller than p = 1/39. That is a property of the design, not a bug, and it is why the paper reports exactly this number.
- The outer optimization is non-convex; different software finds slightly different V (mine concentrates it more than the paper's table shows) while landing on the same weights and the same gap. The reproduction target is the weights and the effect, which are the identified objects.
- Synthetic control needs a long pre-period, a treated unit inside the donor convex hull, and no spillovers onto donors. None of that is checked for you; the placebo and leave-one-out tools exist so you check it.
- One fit takes about three minutes on my 2014 CPU (the placebo sweep is 39 of them). SLSQP in the inner loop is the cost of writing it plainly.
Reproduce it
python -m venv .venv && .venv/bin/pip install numpy scipy pytest
bash data/get_data.sh # or let the loader fetch it
.venv/bin/python -m pytest tests/ -q # 6 contracts
.venv/bin/python benchmarks/repro_prop99.py # all stages, resumable
.venv/bin/python assets/make_visuals.py # the four figures
Layout
- okunfa/core.py the nested estimator: simplex QP inside, Nelder-Mead over V outside
- okunfa/inference.py in-space placebos, permutation p-values, leave-one-out, in-time placebo
- okunfa/datasets.py the Prop 99 panel and the exact ADH predictor design
- benchmarks/repro_prop99.py the reproduction, staged and resumable, everything into repro.json
Papers
- Abadie, Diamond, Hainmueller (2010), Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California's Tobacco Control Program, JASA. The method and the target result.
- Abadie & Gardeazabal (2003), The Economic Costs of Conflict. Where the idea started.
- Abadie (2021), Using Synthetic Controls: Feasibility, Data Requirements, and Methodological Aspects, JEL. The honest-limits survey.
Metadata
Release files for okunfa 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| okunfa-0.1.1.tar.gz | 13.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| okunfa-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.8 kB
Release files / okunfa-0.1.1.tar.gz
| Download URL | okunfa-0.1.1.tar.gz |
|---|---|
| Size | 13.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
592ff42ac50a5099980e09aa8384e1021e1fc470f56319398fb04250b5055905
|
|
BLAKE2b-256 checksum How to use checksums |
21fba8ea58981e13b787efe8b098f5e9e642263ae229d223dd4c01cfb4377d5d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.11
|
Release files / okunfa-0.1.1-py3-none-any.whl
| Download URL | okunfa-0.1.1-py3-none-any.whl |
|---|---|
| Size | 10.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1440f30c19967934ef10450f10a1cc58aa3e8be737c6374c6e5665a2a95b2e47
|
|
BLAKE2b-256 checksum How to use checksums |
3c78349b81776242fbbd7615be15546b7af53b3e3e030f0862a132f8ed4ec3d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.10.11
|