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 +175 -0
- okunfa-0.1.0/README.md +156 -0
- okunfa-0.1.0/okunfa/__init__.py +18 -0
- okunfa-0.1.0/okunfa/core.py +143 -0
- okunfa-0.1.0/okunfa/datasets.py +98 -0
- okunfa-0.1.0/okunfa/inference.py +80 -0
- okunfa-0.1.0/okunfa.egg-info/PKG-INFO +175 -0
- okunfa-0.1.0/okunfa.egg-info/SOURCES.txt +12 -0
- okunfa-0.1.0/okunfa.egg-info/dependency_links.txt +1 -0
- okunfa-0.1.0/okunfa.egg-info/requires.txt +2 -0
- okunfa-0.1.0/okunfa.egg-info/top_level.txt +1 -0
- okunfa-0.1.0/pyproject.toml +29 -0
- okunfa-0.1.0/setup.cfg +4 -0
- okunfa-0.1.0/tests/test_okunfa.py +103 -0
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
74
|
+
|
|
75
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
55
|
+
|
|
56
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
74
|
+
|
|
75
|
+

|
|
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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|