okunfa 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
okunfa-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: okunfa
3
+ Version: 0.1.0
4
+ Summary: Synthetic control from scratch: the ADH estimator with the placebo inference that makes it honest
5
+ Author-email: Kenny Obidele <obidelek19@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Kenny0bi/okunfa
8
+ Project-URL: Issues, https://github.com/Kenny0bi/okunfa/issues
9
+ Keywords: causal-inference,synthetic-control,econometrics,policy-evaluation,placebo-tests
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: scipy>=1.10
19
+
20
+ # okunfa
21
+
22
+ Synthetic control from scratch, verified against the paper that defined
23
+ the method, shipped as a library.
24
+
25
+ ```
26
+ pip install okunfa
27
+ ```
28
+
29
+ *Okunfa* is Yoruba for cause. The question causal inference keeps asking
30
+ is "compared to what?", and the synthetic control method (Abadie, Diamond,
31
+ Hainmueller 2010) gives the most transparent answer in the toolbox: build
32
+ the counterfactual as a weighted average of untreated units, weights
33
+ non-negative and summing to one, chosen to match the treated unit before
34
+ the intervention. The counterfactual comes with an ingredients list you
35
+ can argue with.
36
+
37
+ I implemented the full estimator on numpy and scipy (no causal libraries
38
+ anywhere), then pointed it at the method's founding result: California's
39
+ Proposition 99 tobacco program.
40
+
41
+ ## The reproduction
42
+
43
+ ![The ghost](assets/ghost.svg)
44
+
45
+ ADH 2010, Table 2, says synthetic California is Utah .334, Nevada .234,
46
+ Montana .199, Colorado .164, Connecticut .069. This implementation, run
47
+ on the same public panel with the same predictors, finds:
48
+
49
+ | state | published | okunfa |
50
+ |---|---|---|
51
+ | Utah | 0.334 | 0.335 |
52
+ | Nevada | 0.234 | 0.236 |
53
+ | Montana | 0.199 | 0.201 |
54
+ | Colorado | 0.164 | 0.160 |
55
+ | Connecticut | 0.069 | 0.068 |
56
+
57
+ All five within 0.005, every other state at zero, and the headline
58
+ effect lands where the paper put it: California's cigarette sales about
59
+ 26 packs per capita below its ghost by 2000. A test pins each published
60
+ weight to within 0.01, so the reproduction cannot silently rot.
61
+
62
+ ![The animation](assets/recipe.gif)
63
+
64
+ (Source: [assets/manim_recipe.py](assets/manim_recipe.py), video in
65
+ [assets/recipe.mp4](assets/recipe.mp4).)
66
+
67
+ ## The inference is the honest part
68
+
69
+ A synthetic control without placebos is just a curve that goes down.
70
+ The method's own significance test is to run the identical procedure on
71
+ every state that never passed the law:
72
+
73
+ ![The placebo storm](assets/storm.svg)
74
+
75
+ ![One in thirty-nine](assets/rank.svg)
76
+
77
+ California's post/pre RMSPE ratio is the most extreme of all 39 states,
78
+ a permutation p-value of 1/39 = 0.026, matching the paper's inference
79
+ exactly. Two more checks that the story is structural, not lucky:
80
+
81
+ ![Stress tests](assets/stress.svg)
82
+
83
+ Leave-one-out refits drop each ingredient state in turn (the gap
84
+ survives losing any single leg), and an in-time placebo pretends the law
85
+ passed in 1980 using only pre-1980 information (the design finds almost
86
+ nothing where nothing happened).
87
+
88
+ ## The estimator
89
+
90
+ The paper's nested optimization, implemented directly:
91
+
92
+ - **Inner problem**: given predictor importances V, the weights solve a
93
+ small quadratic program on the simplex, min (X1 - X0 w)' V (X1 - X0 w),
94
+ by SLSQP with an analytic gradient.
95
+ - **Outer problem**: V itself is chosen to minimize pre-period outcome
96
+ MSPE, by Nelder-Mead over a softmax parameterization, so every
97
+ candidate V is a valid distribution by construction, with multiple
98
+ restarts because the surface is not convex.
99
+
100
+ The chosen V is itself interpretable, and slightly deflating: the data
101
+ puts 87% of the matching weight on retail price and most of the rest on
102
+ the 1975 sales lag. The covariates everyone lists (income, beer, age
103
+ structure) barely matter once the outcome lags are in, which is a known
104
+ property of the method worth seeing with your own eyes.
105
+
106
+ ## Using it
107
+
108
+ ```python
109
+ from okunfa import SyntheticControl, in_space_placebos, permutation_pvalue
110
+ from okunfa.datasets import load_prop99, adh_design
111
+
112
+ panel, years = load_prop99() # fetches the public panel on demand
113
+ design = adh_design(panel, years)
114
+
115
+ sc = SyntheticControl()
116
+ fit = sc.fit(**design)
117
+ print(fit.nonzero()) # [('Utah', 0.335), ('Nevada', 0.236), ...]
118
+ print(fit.gap[-1]) # about -26 packs in 2000
119
+
120
+ fits = in_space_placebos(sc, **design)
121
+ p, ratios = permutation_pvalue(fits, "California")
122
+ print(p) # 0.026
123
+ ```
124
+
125
+ The API takes plain dicts and numpy arrays (unit -> outcome path,
126
+ predictor -> unit -> value), so it works on any panel, not just this one.
127
+
128
+ ## Honest limits
129
+
130
+ - Permutation inference with 39 units cannot say anything smaller than
131
+ p = 1/39. That is a property of the design, not a bug, and it is why
132
+ the paper reports exactly this number.
133
+ - The outer optimization is non-convex; different software finds slightly
134
+ different V (mine concentrates it more than the paper's table shows)
135
+ while landing on the same weights and the same gap. The reproduction
136
+ target is the weights and the effect, which are the identified objects.
137
+ - Synthetic control needs a long pre-period, a treated unit inside the
138
+ donor convex hull, and no spillovers onto donors. None of that is
139
+ checked for you; the placebo and leave-one-out tools exist so you
140
+ check it.
141
+ - One fit takes about three minutes on my 2014 CPU (the placebo sweep is
142
+ 39 of them). SLSQP in the inner loop is the cost of writing it plainly.
143
+
144
+ ## Reproduce it
145
+
146
+ ```bash
147
+ python -m venv .venv && .venv/bin/pip install numpy scipy pytest
148
+ bash data/get_data.sh # or let the loader fetch it
149
+
150
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
151
+ .venv/bin/python benchmarks/repro_prop99.py # all stages, resumable
152
+ .venv/bin/python assets/make_visuals.py # the four figures
153
+ ```
154
+
155
+ ## Layout
156
+
157
+ - [okunfa/core.py](okunfa/core.py) the nested estimator: simplex QP
158
+ inside, Nelder-Mead over V outside
159
+ - [okunfa/inference.py](okunfa/inference.py) in-space placebos,
160
+ permutation p-values, leave-one-out, in-time placebo
161
+ - [okunfa/datasets.py](okunfa/datasets.py) the Prop 99 panel and the
162
+ exact ADH predictor design
163
+ - [benchmarks/repro_prop99.py](benchmarks/repro_prop99.py) the
164
+ reproduction, staged and resumable, everything into repro.json
165
+
166
+ ## Papers
167
+
168
+ - Abadie, Diamond, Hainmueller (2010), *Synthetic Control Methods for
169
+ Comparative Case Studies: Estimating the Effect of California's
170
+ Tobacco Control Program*, JASA. The method and the target result.
171
+ - Abadie & Gardeazabal (2003), *The Economic Costs of Conflict*. Where
172
+ the idea started.
173
+ - Abadie (2021), *Using Synthetic Controls: Feasibility, Data
174
+ Requirements, and Methodological Aspects*, JEL. The honest-limits
175
+ survey.
okunfa-0.1.0/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # okunfa
2
+
3
+ Synthetic control from scratch, verified against the paper that defined
4
+ the method, shipped as a library.
5
+
6
+ ```
7
+ pip install okunfa
8
+ ```
9
+
10
+ *Okunfa* is Yoruba for cause. The question causal inference keeps asking
11
+ is "compared to what?", and the synthetic control method (Abadie, Diamond,
12
+ Hainmueller 2010) gives the most transparent answer in the toolbox: build
13
+ the counterfactual as a weighted average of untreated units, weights
14
+ non-negative and summing to one, chosen to match the treated unit before
15
+ the intervention. The counterfactual comes with an ingredients list you
16
+ can argue with.
17
+
18
+ I implemented the full estimator on numpy and scipy (no causal libraries
19
+ anywhere), then pointed it at the method's founding result: California's
20
+ Proposition 99 tobacco program.
21
+
22
+ ## The reproduction
23
+
24
+ ![The ghost](assets/ghost.svg)
25
+
26
+ ADH 2010, Table 2, says synthetic California is Utah .334, Nevada .234,
27
+ Montana .199, Colorado .164, Connecticut .069. This implementation, run
28
+ on the same public panel with the same predictors, finds:
29
+
30
+ | state | published | okunfa |
31
+ |---|---|---|
32
+ | Utah | 0.334 | 0.335 |
33
+ | Nevada | 0.234 | 0.236 |
34
+ | Montana | 0.199 | 0.201 |
35
+ | Colorado | 0.164 | 0.160 |
36
+ | Connecticut | 0.069 | 0.068 |
37
+
38
+ All five within 0.005, every other state at zero, and the headline
39
+ effect lands where the paper put it: California's cigarette sales about
40
+ 26 packs per capita below its ghost by 2000. A test pins each published
41
+ weight to within 0.01, so the reproduction cannot silently rot.
42
+
43
+ ![The animation](assets/recipe.gif)
44
+
45
+ (Source: [assets/manim_recipe.py](assets/manim_recipe.py), video in
46
+ [assets/recipe.mp4](assets/recipe.mp4).)
47
+
48
+ ## The inference is the honest part
49
+
50
+ A synthetic control without placebos is just a curve that goes down.
51
+ The method's own significance test is to run the identical procedure on
52
+ every state that never passed the law:
53
+
54
+ ![The placebo storm](assets/storm.svg)
55
+
56
+ ![One in thirty-nine](assets/rank.svg)
57
+
58
+ California's post/pre RMSPE ratio is the most extreme of all 39 states,
59
+ a permutation p-value of 1/39 = 0.026, matching the paper's inference
60
+ exactly. Two more checks that the story is structural, not lucky:
61
+
62
+ ![Stress tests](assets/stress.svg)
63
+
64
+ Leave-one-out refits drop each ingredient state in turn (the gap
65
+ survives losing any single leg), and an in-time placebo pretends the law
66
+ passed in 1980 using only pre-1980 information (the design finds almost
67
+ nothing where nothing happened).
68
+
69
+ ## The estimator
70
+
71
+ The paper's nested optimization, implemented directly:
72
+
73
+ - **Inner problem**: given predictor importances V, the weights solve a
74
+ small quadratic program on the simplex, min (X1 - X0 w)' V (X1 - X0 w),
75
+ by SLSQP with an analytic gradient.
76
+ - **Outer problem**: V itself is chosen to minimize pre-period outcome
77
+ MSPE, by Nelder-Mead over a softmax parameterization, so every
78
+ candidate V is a valid distribution by construction, with multiple
79
+ restarts because the surface is not convex.
80
+
81
+ The chosen V is itself interpretable, and slightly deflating: the data
82
+ puts 87% of the matching weight on retail price and most of the rest on
83
+ the 1975 sales lag. The covariates everyone lists (income, beer, age
84
+ structure) barely matter once the outcome lags are in, which is a known
85
+ property of the method worth seeing with your own eyes.
86
+
87
+ ## Using it
88
+
89
+ ```python
90
+ from okunfa import SyntheticControl, in_space_placebos, permutation_pvalue
91
+ from okunfa.datasets import load_prop99, adh_design
92
+
93
+ panel, years = load_prop99() # fetches the public panel on demand
94
+ design = adh_design(panel, years)
95
+
96
+ sc = SyntheticControl()
97
+ fit = sc.fit(**design)
98
+ print(fit.nonzero()) # [('Utah', 0.335), ('Nevada', 0.236), ...]
99
+ print(fit.gap[-1]) # about -26 packs in 2000
100
+
101
+ fits = in_space_placebos(sc, **design)
102
+ p, ratios = permutation_pvalue(fits, "California")
103
+ print(p) # 0.026
104
+ ```
105
+
106
+ The API takes plain dicts and numpy arrays (unit -> outcome path,
107
+ predictor -> unit -> value), so it works on any panel, not just this one.
108
+
109
+ ## Honest limits
110
+
111
+ - Permutation inference with 39 units cannot say anything smaller than
112
+ p = 1/39. That is a property of the design, not a bug, and it is why
113
+ the paper reports exactly this number.
114
+ - The outer optimization is non-convex; different software finds slightly
115
+ different V (mine concentrates it more than the paper's table shows)
116
+ while landing on the same weights and the same gap. The reproduction
117
+ target is the weights and the effect, which are the identified objects.
118
+ - Synthetic control needs a long pre-period, a treated unit inside the
119
+ donor convex hull, and no spillovers onto donors. None of that is
120
+ checked for you; the placebo and leave-one-out tools exist so you
121
+ check it.
122
+ - One fit takes about three minutes on my 2014 CPU (the placebo sweep is
123
+ 39 of them). SLSQP in the inner loop is the cost of writing it plainly.
124
+
125
+ ## Reproduce it
126
+
127
+ ```bash
128
+ python -m venv .venv && .venv/bin/pip install numpy scipy pytest
129
+ bash data/get_data.sh # or let the loader fetch it
130
+
131
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
132
+ .venv/bin/python benchmarks/repro_prop99.py # all stages, resumable
133
+ .venv/bin/python assets/make_visuals.py # the four figures
134
+ ```
135
+
136
+ ## Layout
137
+
138
+ - [okunfa/core.py](okunfa/core.py) the nested estimator: simplex QP
139
+ inside, Nelder-Mead over V outside
140
+ - [okunfa/inference.py](okunfa/inference.py) in-space placebos,
141
+ permutation p-values, leave-one-out, in-time placebo
142
+ - [okunfa/datasets.py](okunfa/datasets.py) the Prop 99 panel and the
143
+ exact ADH predictor design
144
+ - [benchmarks/repro_prop99.py](benchmarks/repro_prop99.py) the
145
+ reproduction, staged and resumable, everything into repro.json
146
+
147
+ ## Papers
148
+
149
+ - Abadie, Diamond, Hainmueller (2010), *Synthetic Control Methods for
150
+ Comparative Case Studies: Estimating the Effect of California's
151
+ Tobacco Control Program*, JASA. The method and the target result.
152
+ - Abadie & Gardeazabal (2003), *The Economic Costs of Conflict*. Where
153
+ the idea started.
154
+ - Abadie (2021), *Using Synthetic Controls: Feasibility, Data
155
+ Requirements, and Methodological Aspects*, JEL. The honest-limits
156
+ survey.
@@ -0,0 +1,18 @@
1
+ """okunfa: synthetic control from scratch, with the inference that makes it
2
+ honest.
3
+
4
+ *Okunfa* is Yoruba for cause. The method (Abadie, Diamond, Hainmueller
5
+ 2010) answers "what would California's smoking have looked like without
6
+ Proposition 99?" by building a weighted combination of untreated states
7
+ that matches California before the law, then reading the difference after
8
+ it. The weights are the transparency: you can see exactly who the
9
+ counterfactual is made of.
10
+ """
11
+
12
+ from .core import SyntheticControl, SCFit
13
+ from .inference import (in_space_placebos, in_time_placebo, leave_one_out,
14
+ rmspe, rmspe_ratios)
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = ["SyntheticControl", "SCFit", "in_space_placebos",
18
+ "in_time_placebo", "leave_one_out", "rmspe", "rmspe_ratios"]
@@ -0,0 +1,143 @@
1
+ """The estimator: nested optimization, exactly as the paper frames it.
2
+
3
+ Inner problem w*(V) = argmin_{w in simplex} (X1 - X0 w)' V (X1 - X0 w)
4
+ Outer problem V* = argmin_V || Z1 - Z0 w*(V) ||^2 (pre-period MSPE)
5
+
6
+ X are predictors (covariate averages plus outcome lags), Z the pre-period
7
+ outcome path. V is a diagonal matrix saying how much matching each
8
+ predictor matters; the outer loop chooses it so the resulting weights
9
+ track the treated unit's actual pre-period outcome as closely as
10
+ possible. The inner problem is a small QP solved with SLSQP and an
11
+ analytic gradient; the outer is Nelder-Mead over a softmax
12
+ parameterization of the simplex, so every candidate V is valid by
13
+ construction.
14
+ """
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ import numpy as np
19
+ from scipy.optimize import minimize
20
+
21
+
22
+ def _simplex_qp(H, f, n, x0=None):
23
+ """min 0.5 w'Hw + f'w s.t. w >= 0, sum w = 1."""
24
+ w0 = np.full(n, 1.0 / n) if x0 is None else x0
25
+
26
+ def obj(w):
27
+ return 0.5 * w @ H @ w + f @ w
28
+
29
+ def grad(w):
30
+ return H @ w + f
31
+
32
+ res = minimize(obj, w0, jac=grad, method="SLSQP",
33
+ bounds=[(0.0, 1.0)] * n,
34
+ constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1,
35
+ "jac": lambda w: np.ones_like(w)}],
36
+ options={"maxiter": 500, "ftol": 1e-12})
37
+ w = np.clip(res.x, 0, None)
38
+ return w / w.sum()
39
+
40
+
41
+ def _inner_weights(X1, X0, v):
42
+ """w*(V) for diagonal V given as a vector v."""
43
+ A = X0 * np.sqrt(v)[:, None]
44
+ b = X1 * np.sqrt(v)
45
+ H = 2.0 * (A.T @ A)
46
+ f = -2.0 * (A.T @ b)
47
+ return _simplex_qp(H, f, X0.shape[1])
48
+
49
+
50
+ @dataclass
51
+ class SCFit:
52
+ weights: np.ndarray # donor weights, sum to 1
53
+ v: np.ndarray # predictor importances, sum to 1
54
+ donors: list # donor names, aligned with weights
55
+ predictors: list # predictor names, aligned with v
56
+ synthetic: np.ndarray # synthetic outcome path, all periods
57
+ treated: np.ndarray # treated outcome path, all periods
58
+ periods: np.ndarray
59
+ pre_mask: np.ndarray
60
+ pre_rmspe: float = field(init=False)
61
+ post_rmspe: float = field(init=False)
62
+
63
+ def __post_init__(self):
64
+ gap = self.treated - self.synthetic
65
+ self.pre_rmspe = float(np.sqrt(np.mean(gap[self.pre_mask] ** 2)))
66
+ self.post_rmspe = float(np.sqrt(np.mean(gap[~self.pre_mask] ** 2)))
67
+
68
+ @property
69
+ def gap(self):
70
+ return self.treated - self.synthetic
71
+
72
+ def nonzero(self, tol=1e-3):
73
+ """The donors the counterfactual is actually made of."""
74
+ order = np.argsort(-self.weights)
75
+ return [(self.donors[i], float(self.weights[i]))
76
+ for i in order if self.weights[i] > tol]
77
+
78
+
79
+ class SyntheticControl:
80
+ """Fit a synthetic control for one treated unit.
81
+
82
+ Parameters
83
+ ----------
84
+ X1, X0 : predictor vector (k,) for the treated unit and matrix (k, J)
85
+ for the donors. Rows are standardized internally so V weighs
86
+ comparable quantities.
87
+ Z1, Z0 : pre-period outcome path (T0,) and matrix (T0, J) used by the
88
+ outer loop.
89
+ """
90
+
91
+ def __init__(self, outer_restarts=4, seed=0):
92
+ self.outer_restarts = outer_restarts
93
+ self.seed = seed
94
+
95
+ def fit_weights(self, X1, X0, Z1, Z0):
96
+ k = X1.shape[0]
97
+ # standardize predictor rows over (treated + donors)
98
+ allx = np.column_stack([X1, X0])
99
+ mu, sd = allx.mean(1), allx.std(1)
100
+ sd[sd == 0] = 1.0
101
+ X1s = (X1 - mu) / sd
102
+ X0s = (X0 - mu[:, None]) / sd[:, None]
103
+
104
+ def outer_loss(theta):
105
+ v = np.exp(theta - theta.max())
106
+ v = v / v.sum()
107
+ w = _inner_weights(X1s, X0s, v)
108
+ return float(np.mean((Z1 - Z0 @ w) ** 2))
109
+
110
+ rng = np.random.default_rng(self.seed)
111
+ best = None
112
+ starts = [np.zeros(k)] + [rng.normal(0, 1, k)
113
+ for _ in range(self.outer_restarts - 1)]
114
+ for th0 in starts:
115
+ res = minimize(outer_loss, th0, method="Nelder-Mead",
116
+ options={"maxiter": 2000, "xatol": 1e-6,
117
+ "fatol": 1e-10})
118
+ if best is None or res.fun < best.fun:
119
+ best = res
120
+ v = np.exp(best.x - best.x.max())
121
+ v = v / v.sum()
122
+ w = _inner_weights(X1s, X0s, v)
123
+ return w, v
124
+
125
+ def fit(self, *, outcome, periods, treated, donors, pre_mask,
126
+ predictors):
127
+ """outcome: dict name -> path over periods; predictors: dict
128
+ name -> dict unit -> scalar."""
129
+ periods = np.asarray(periods)
130
+ pre_mask = np.asarray(pre_mask, dtype=bool)
131
+ Z1 = np.asarray(outcome[treated], float)[pre_mask]
132
+ Z0 = np.column_stack([np.asarray(outcome[d], float)[pre_mask]
133
+ for d in donors])
134
+ pnames = list(predictors)
135
+ X1 = np.array([predictors[p][treated] for p in pnames], float)
136
+ X0 = np.array([[predictors[p][d] for d in donors]
137
+ for p in pnames], float)
138
+ w, v = self.fit_weights(X1, X0, Z1, Z0)
139
+ synth = np.column_stack([outcome[d] for d in donors]) @ w
140
+ return SCFit(weights=w, v=v, donors=list(donors),
141
+ predictors=pnames, synthetic=synth,
142
+ treated=np.asarray(outcome[treated], float),
143
+ periods=periods, pre_mask=pre_mask)
@@ -0,0 +1,98 @@
1
+ """The Proposition 99 panel: cigarette sales in 39 states, 1970-2000.
2
+
3
+ The canonical dataset of Abadie, Diamond and Hainmueller (2010),
4
+ originally from Orzechowski and Walker's *Tax Burden on Tobacco*
5
+ (public government data), as distributed with the R Synth package and
6
+ mirrored in Facure's *Causal Inference for the Brave and True*. State
7
+ codes are the 39 donor-pool states in alphabetical order; the paper drops
8
+ states with their own large tobacco programs and DC.
9
+ """
10
+
11
+ import csv
12
+ import io
13
+ import urllib.request
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ URL = ("https://raw.githubusercontent.com/matheusfacure/"
20
+ "python-causality-handbook/master/"
21
+ "causal-inference-for-the-brave-and-true/data/smoking.csv")
22
+
23
+ STATES = [
24
+ "Alabama", "Arkansas", "California", "Colorado", "Connecticut",
25
+ "Delaware", "Georgia", "Idaho", "Illinois", "Indiana", "Iowa",
26
+ "Kansas", "Kentucky", "Louisiana", "Maine", "Minnesota",
27
+ "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada",
28
+ "New Hampshire", "New Mexico", "North Carolina", "North Dakota",
29
+ "Ohio", "Oklahoma", "Pennsylvania", "Rhode Island", "South Carolina",
30
+ "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia",
31
+ "West Virginia", "Wisconsin", "Wyoming",
32
+ ]
33
+
34
+ TREATMENT_YEAR = 1989 # Prop 99 took effect January 1989
35
+
36
+
37
+ def _cache_path():
38
+ d = Path.home() / ".cache" / "okunfa"
39
+ d.mkdir(parents=True, exist_ok=True)
40
+ return d / "smoking.csv"
41
+
42
+
43
+ def load_prop99(path=None):
44
+ """Return the panel as {column: {state: {year: value}}} plus years."""
45
+ p = Path(path) if path else _cache_path()
46
+ if not p.exists():
47
+ with urllib.request.urlopen(URL, timeout=60) as r:
48
+ p.write_bytes(r.read())
49
+ rows = list(csv.DictReader(io.StringIO(p.read_text())))
50
+
51
+ cols = ["cigsale", "lnincome", "beer", "age15to24", "retprice"]
52
+ panel = {c: defaultdict(dict) for c in cols}
53
+ years = set()
54
+ for r in rows:
55
+ st = STATES[int(r["state"]) - 1]
56
+ yr = int(r["year"])
57
+ years.add(yr)
58
+ for c in cols:
59
+ v = r[c]
60
+ if v not in ("", "NA"):
61
+ panel[c][st][yr] = float(v)
62
+ return panel, np.array(sorted(years))
63
+
64
+
65
+ def adh_design(panel, years):
66
+ """The exact design of ADH 2010, Table 1.
67
+
68
+ Predictors: 1980-88 averages of ln income, retail price, share aged
69
+ 15-24; 1984-88 average beer consumption; and cigarette sales in 1975,
70
+ 1980 and 1988 as outcome lags. Pre-period for the outer loop:
71
+ 1970-1988.
72
+ """
73
+ treated = "California"
74
+ donors = [s for s in STATES if s != treated]
75
+ outcome = {s: np.array([panel["cigsale"][s][y] for y in years])
76
+ for s in STATES}
77
+ pre_mask = years <= 1988
78
+
79
+ def avg(col, lo, hi):
80
+ return {s: float(np.mean([panel[col][s][y]
81
+ for y in range(lo, hi + 1)
82
+ if y in panel[col][s]]))
83
+ for s in STATES}
84
+
85
+ predictors = {
86
+ "ln income": avg("lnincome", 1980, 1988),
87
+ "retail price": avg("retprice", 1980, 1988),
88
+ "share aged 15-24": avg("age15to24", 1980, 1988),
89
+ "beer consumption": avg("beer", 1984, 1988),
90
+ "cigarette sales 1975": {s: panel["cigsale"][s][1975]
91
+ for s in STATES},
92
+ "cigarette sales 1980": {s: panel["cigsale"][s][1980]
93
+ for s in STATES},
94
+ "cigarette sales 1988": {s: panel["cigsale"][s][1988]
95
+ for s in STATES},
96
+ }
97
+ return dict(outcome=outcome, periods=years, treated=treated,
98
+ donors=donors, pre_mask=pre_mask, predictors=predictors)
@@ -0,0 +1,80 @@
1
+ """The inference: synthetic control without placebos is just a curve.
2
+
3
+ The method's own authors built its test: run the identical procedure on
4
+ every untreated unit (in-space placebos). If the treated unit's post/pre
5
+ RMSPE ratio towers over the placebo distribution, the effect is unlikely
6
+ to be luck; the permutation p-value is the rank of that ratio. A second
7
+ check runs the procedure at a fake earlier treatment date (in-time
8
+ placebo), where a real design should find nothing.
9
+ """
10
+
11
+ import numpy as np
12
+
13
+
14
+ def rmspe(gap, mask):
15
+ return float(np.sqrt(np.mean(np.asarray(gap)[mask] ** 2)))
16
+
17
+
18
+ def rmspe_ratios(fits):
19
+ """post/pre RMSPE ratio per fit dict {unit: SCFit}."""
20
+ return {u: f.post_rmspe / f.pre_rmspe for u, f in fits.items()}
21
+
22
+
23
+ def in_space_placebos(sc, *, outcome, periods, treated, donors, pre_mask,
24
+ predictors, verbose=False):
25
+ """Run the identical estimator with each donor cast as pseudo-treated.
26
+
27
+ Returns {unit: SCFit} including the genuinely treated unit.
28
+ """
29
+ fits = {}
30
+ units = [treated] + list(donors)
31
+ for u in units:
32
+ # a placebo's donor pool is the other CONTROL units only: the
33
+ # genuinely treated unit is post-treatment data and would
34
+ # contaminate every placebo counterfactual
35
+ pool = [d for d in donors if d != u]
36
+ fits[u] = sc.fit(outcome=outcome, periods=periods, treated=u,
37
+ donors=pool, pre_mask=pre_mask,
38
+ predictors=_reindex(predictors, units))
39
+ if verbose:
40
+ print(f" placebo {u}: pre {fits[u].pre_rmspe:.2f} "
41
+ f"post {fits[u].post_rmspe:.2f}")
42
+ return fits
43
+
44
+
45
+ def _reindex(predictors, units):
46
+ return {p: {u: predictors[p][u] for u in units if u in predictors[p]}
47
+ for p in predictors}
48
+
49
+
50
+ def permutation_pvalue(fits, treated):
51
+ """P(ratio >= treated's) under random permutation: rank / N."""
52
+ ratios = rmspe_ratios(fits)
53
+ r_t = ratios[treated]
54
+ rank = sum(1 for v in ratios.values() if v >= r_t)
55
+ return rank / len(ratios), ratios
56
+
57
+
58
+ def leave_one_out(sc, fit, *, outcome, periods, treated, pre_mask,
59
+ predictors, tol=1e-3):
60
+ """Refit dropping each nonzero-weight donor in turn: does the story
61
+ survive losing any single leg of the counterfactual?"""
62
+ out = {}
63
+ keep = [d for d, _ in fit.nonzero(tol)]
64
+ for drop in keep:
65
+ pool = [d for d in fit.donors if d != drop]
66
+ out[drop] = sc.fit(outcome=outcome, periods=periods,
67
+ treated=treated, donors=pool, pre_mask=pre_mask,
68
+ predictors=predictors)
69
+ return out
70
+
71
+
72
+ def in_time_placebo(sc, *, outcome, periods, treated, donors,
73
+ true_pre_mask, fake_treatment_period, predictors):
74
+ """Pretend treatment happened earlier; a sound design finds ~nothing
75
+ in the window that is actually untreated."""
76
+ periods = np.asarray(periods)
77
+ fake_pre = periods < fake_treatment_period
78
+ fake_pre &= true_pre_mask # never let real post-treatment data in
79
+ return sc.fit(outcome=outcome, periods=periods, treated=treated,
80
+ donors=donors, pre_mask=fake_pre, predictors=predictors)
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: okunfa
3
+ Version: 0.1.0
4
+ Summary: Synthetic control from scratch: the ADH estimator with the placebo inference that makes it honest
5
+ Author-email: Kenny Obidele <obidelek19@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Kenny0bi/okunfa
8
+ Project-URL: Issues, https://github.com/Kenny0bi/okunfa/issues
9
+ Keywords: causal-inference,synthetic-control,econometrics,policy-evaluation,placebo-tests
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: scipy>=1.10
19
+
20
+ # okunfa
21
+
22
+ Synthetic control from scratch, verified against the paper that defined
23
+ the method, shipped as a library.
24
+
25
+ ```
26
+ pip install okunfa
27
+ ```
28
+
29
+ *Okunfa* is Yoruba for cause. The question causal inference keeps asking
30
+ is "compared to what?", and the synthetic control method (Abadie, Diamond,
31
+ Hainmueller 2010) gives the most transparent answer in the toolbox: build
32
+ the counterfactual as a weighted average of untreated units, weights
33
+ non-negative and summing to one, chosen to match the treated unit before
34
+ the intervention. The counterfactual comes with an ingredients list you
35
+ can argue with.
36
+
37
+ I implemented the full estimator on numpy and scipy (no causal libraries
38
+ anywhere), then pointed it at the method's founding result: California's
39
+ Proposition 99 tobacco program.
40
+
41
+ ## The reproduction
42
+
43
+ ![The ghost](assets/ghost.svg)
44
+
45
+ ADH 2010, Table 2, says synthetic California is Utah .334, Nevada .234,
46
+ Montana .199, Colorado .164, Connecticut .069. This implementation, run
47
+ on the same public panel with the same predictors, finds:
48
+
49
+ | state | published | okunfa |
50
+ |---|---|---|
51
+ | Utah | 0.334 | 0.335 |
52
+ | Nevada | 0.234 | 0.236 |
53
+ | Montana | 0.199 | 0.201 |
54
+ | Colorado | 0.164 | 0.160 |
55
+ | Connecticut | 0.069 | 0.068 |
56
+
57
+ All five within 0.005, every other state at zero, and the headline
58
+ effect lands where the paper put it: California's cigarette sales about
59
+ 26 packs per capita below its ghost by 2000. A test pins each published
60
+ weight to within 0.01, so the reproduction cannot silently rot.
61
+
62
+ ![The animation](assets/recipe.gif)
63
+
64
+ (Source: [assets/manim_recipe.py](assets/manim_recipe.py), video in
65
+ [assets/recipe.mp4](assets/recipe.mp4).)
66
+
67
+ ## The inference is the honest part
68
+
69
+ A synthetic control without placebos is just a curve that goes down.
70
+ The method's own significance test is to run the identical procedure on
71
+ every state that never passed the law:
72
+
73
+ ![The placebo storm](assets/storm.svg)
74
+
75
+ ![One in thirty-nine](assets/rank.svg)
76
+
77
+ California's post/pre RMSPE ratio is the most extreme of all 39 states,
78
+ a permutation p-value of 1/39 = 0.026, matching the paper's inference
79
+ exactly. Two more checks that the story is structural, not lucky:
80
+
81
+ ![Stress tests](assets/stress.svg)
82
+
83
+ Leave-one-out refits drop each ingredient state in turn (the gap
84
+ survives losing any single leg), and an in-time placebo pretends the law
85
+ passed in 1980 using only pre-1980 information (the design finds almost
86
+ nothing where nothing happened).
87
+
88
+ ## The estimator
89
+
90
+ The paper's nested optimization, implemented directly:
91
+
92
+ - **Inner problem**: given predictor importances V, the weights solve a
93
+ small quadratic program on the simplex, min (X1 - X0 w)' V (X1 - X0 w),
94
+ by SLSQP with an analytic gradient.
95
+ - **Outer problem**: V itself is chosen to minimize pre-period outcome
96
+ MSPE, by Nelder-Mead over a softmax parameterization, so every
97
+ candidate V is a valid distribution by construction, with multiple
98
+ restarts because the surface is not convex.
99
+
100
+ The chosen V is itself interpretable, and slightly deflating: the data
101
+ puts 87% of the matching weight on retail price and most of the rest on
102
+ the 1975 sales lag. The covariates everyone lists (income, beer, age
103
+ structure) barely matter once the outcome lags are in, which is a known
104
+ property of the method worth seeing with your own eyes.
105
+
106
+ ## Using it
107
+
108
+ ```python
109
+ from okunfa import SyntheticControl, in_space_placebos, permutation_pvalue
110
+ from okunfa.datasets import load_prop99, adh_design
111
+
112
+ panel, years = load_prop99() # fetches the public panel on demand
113
+ design = adh_design(panel, years)
114
+
115
+ sc = SyntheticControl()
116
+ fit = sc.fit(**design)
117
+ print(fit.nonzero()) # [('Utah', 0.335), ('Nevada', 0.236), ...]
118
+ print(fit.gap[-1]) # about -26 packs in 2000
119
+
120
+ fits = in_space_placebos(sc, **design)
121
+ p, ratios = permutation_pvalue(fits, "California")
122
+ print(p) # 0.026
123
+ ```
124
+
125
+ The API takes plain dicts and numpy arrays (unit -> outcome path,
126
+ predictor -> unit -> value), so it works on any panel, not just this one.
127
+
128
+ ## Honest limits
129
+
130
+ - Permutation inference with 39 units cannot say anything smaller than
131
+ p = 1/39. That is a property of the design, not a bug, and it is why
132
+ the paper reports exactly this number.
133
+ - The outer optimization is non-convex; different software finds slightly
134
+ different V (mine concentrates it more than the paper's table shows)
135
+ while landing on the same weights and the same gap. The reproduction
136
+ target is the weights and the effect, which are the identified objects.
137
+ - Synthetic control needs a long pre-period, a treated unit inside the
138
+ donor convex hull, and no spillovers onto donors. None of that is
139
+ checked for you; the placebo and leave-one-out tools exist so you
140
+ check it.
141
+ - One fit takes about three minutes on my 2014 CPU (the placebo sweep is
142
+ 39 of them). SLSQP in the inner loop is the cost of writing it plainly.
143
+
144
+ ## Reproduce it
145
+
146
+ ```bash
147
+ python -m venv .venv && .venv/bin/pip install numpy scipy pytest
148
+ bash data/get_data.sh # or let the loader fetch it
149
+
150
+ .venv/bin/python -m pytest tests/ -q # 6 contracts
151
+ .venv/bin/python benchmarks/repro_prop99.py # all stages, resumable
152
+ .venv/bin/python assets/make_visuals.py # the four figures
153
+ ```
154
+
155
+ ## Layout
156
+
157
+ - [okunfa/core.py](okunfa/core.py) the nested estimator: simplex QP
158
+ inside, Nelder-Mead over V outside
159
+ - [okunfa/inference.py](okunfa/inference.py) in-space placebos,
160
+ permutation p-values, leave-one-out, in-time placebo
161
+ - [okunfa/datasets.py](okunfa/datasets.py) the Prop 99 panel and the
162
+ exact ADH predictor design
163
+ - [benchmarks/repro_prop99.py](benchmarks/repro_prop99.py) the
164
+ reproduction, staged and resumable, everything into repro.json
165
+
166
+ ## Papers
167
+
168
+ - Abadie, Diamond, Hainmueller (2010), *Synthetic Control Methods for
169
+ Comparative Case Studies: Estimating the Effect of California's
170
+ Tobacco Control Program*, JASA. The method and the target result.
171
+ - Abadie & Gardeazabal (2003), *The Economic Costs of Conflict*. Where
172
+ the idea started.
173
+ - Abadie (2021), *Using Synthetic Controls: Feasibility, Data
174
+ Requirements, and Methodological Aspects*, JEL. The honest-limits
175
+ survey.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ okunfa/__init__.py
4
+ okunfa/core.py
5
+ okunfa/datasets.py
6
+ okunfa/inference.py
7
+ okunfa.egg-info/PKG-INFO
8
+ okunfa.egg-info/SOURCES.txt
9
+ okunfa.egg-info/dependency_links.txt
10
+ okunfa.egg-info/requires.txt
11
+ okunfa.egg-info/top_level.txt
12
+ tests/test_okunfa.py
@@ -0,0 +1,2 @@
1
+ numpy>=1.24
2
+ scipy>=1.10
@@ -0,0 +1 @@
1
+ okunfa
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "okunfa"
7
+ version = "0.1.0"
8
+ description = "Synthetic control from scratch: the ADH estimator with the placebo inference that makes it honest"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Kenny Obidele", email = "obidelek19@gmail.com"}]
13
+ keywords = ["causal-inference", "synthetic-control", "econometrics",
14
+ "policy-evaluation", "placebo-tests"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Information Analysis",
21
+ ]
22
+ dependencies = ["numpy>=1.24", "scipy>=1.10"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Kenny0bi/okunfa"
26
+ Issues = "https://github.com/Kenny0bi/okunfa/issues"
27
+
28
+ [tool.setuptools]
29
+ packages = ["okunfa"]
okunfa-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,103 @@
1
+ """Contracts on the estimator, on synthetic ground truth, and on the
2
+ reproduction targets."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ from okunfa.core import SyntheticControl, _simplex_qp # noqa: E402
15
+ from okunfa.inference import permutation_pvalue # noqa: E402
16
+
17
+ REPRO = ROOT / "benchmarks" / "repro.json"
18
+ needs_repro = pytest.mark.skipif(not REPRO.exists(),
19
+ reason="run benchmarks/repro_prop99.py")
20
+
21
+
22
+ def test_simplex_qp_solves_a_known_projection():
23
+ # projecting the point (2, 0) onto the simplex in least squares:
24
+ # H = 2I, f = -2*(2,0): the answer is the vertex (1, 0)
25
+ H = 2 * np.eye(2)
26
+ f = -2 * np.array([2.0, 0.0])
27
+ w = _simplex_qp(H, f, 2)
28
+ assert np.allclose(w, [1.0, 0.0], atol=1e-6)
29
+ assert abs(w.sum() - 1) < 1e-9
30
+
31
+
32
+ def test_recovers_planted_weights_on_synthetic_truth():
33
+ # build a treated unit that IS a known mixture of donors; the
34
+ # estimator must find the mixture and a ~zero gap everywhere
35
+ rng = np.random.default_rng(3)
36
+ T, J = 30, 8
37
+ donors = [f"unit{j}" for j in range(J)]
38
+ paths = {d: 100 + rng.normal(0, 1, T).cumsum() for d in donors}
39
+ true_w = np.array([0.5, 0.3, 0.2] + [0.0] * (J - 3))
40
+ treated_path = sum(true_w[j] * paths[d]
41
+ for j, d in enumerate(donors))
42
+ outcome = dict(paths, Ile=treated_path)
43
+ periods = np.arange(T)
44
+ pre = periods < 20
45
+ # every pre-period point as a predictor: the classic special case in
46
+ # which the inner problem alone identifies the mixture
47
+ predictors = {f"lag{k}": {u: float(outcome[u][k]) for u in outcome}
48
+ for k in range(20)}
49
+ fit = SyntheticControl(outer_restarts=2).fit(
50
+ outcome=outcome, periods=periods, treated="Ile", donors=donors,
51
+ pre_mask=pre, predictors=predictors)
52
+ got = dict(fit.nonzero(0.01))
53
+ assert abs(got.get("unit0", 0) - 0.5) < 0.05
54
+ assert abs(got.get("unit1", 0) - 0.3) < 0.05
55
+ assert abs(got.get("unit2", 0) - 0.2) < 0.05
56
+ assert fit.post_rmspe < 1.0 # no treatment was planted
57
+
58
+
59
+ def test_weights_are_a_distribution_and_null_effect_reads_null():
60
+ rng = np.random.default_rng(9)
61
+ donors = [f"d{j}" for j in range(6)]
62
+ outcome = {d: 50 + rng.normal(0, 2, 25) for d in donors}
63
+ outcome["Oyo"] = 50 + rng.normal(0, 2, 25)
64
+ periods = np.arange(25)
65
+ predictors = {"m": {u: float(np.mean(outcome[u][:15]))
66
+ for u in outcome}}
67
+ fit = SyntheticControl(outer_restarts=1).fit(
68
+ outcome=outcome, periods=periods, treated="Oyo", donors=donors,
69
+ pre_mask=periods < 15, predictors=predictors)
70
+ assert abs(fit.weights.sum() - 1) < 1e-6
71
+ assert (fit.weights >= -1e-9).all()
72
+
73
+
74
+ @needs_repro
75
+ def test_reproduces_published_weights():
76
+ # ADH 2010, Table 2: the synthetic California
77
+ published = {"Utah": 0.334, "Nevada": 0.234, "Montana": 0.199,
78
+ "Colorado": 0.164, "Connecticut": 0.069}
79
+ got = json.loads(REPRO.read_text())["california"]["weights"]
80
+ for state, w in published.items():
81
+ assert abs(got.get(state, 0.0) - w) < 0.01, (state, got.get(state))
82
+ # and nothing substantial outside the published five
83
+ extra = {s: w for s, w in got.items() if s not in published}
84
+ assert all(w < 0.01 for w in extra.values())
85
+
86
+
87
+ @needs_repro
88
+ def test_reproduces_the_headline_effect():
89
+ ca = json.loads(REPRO.read_text())["california"]
90
+ # the paper: annual per-capita sales about 26 packs lower by 2000
91
+ assert -28 < ca["gap"]["2000"] < -23
92
+ assert ca["pre_rmspe"] < 2.0
93
+
94
+
95
+ @needs_repro
96
+ def test_california_is_the_extreme_placebo():
97
+ out = json.loads(REPRO.read_text())
98
+ if "placebos" not in out:
99
+ pytest.skip("placebo stage not yet run")
100
+ p = out["placebos"]["p_value"]
101
+ ratios = out["placebos"]["ratios"]
102
+ assert ratios["California"] == max(ratios.values())
103
+ assert p <= 1 / 39 + 1e-9