augsynth-py 0.3.0__py3-none-any.whl
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.
- augsynth_py/__init__.py +7 -0
- augsynth_py/_version.py +3 -0
- augsynth_py/exceptions.py +24 -0
- augsynth_py/inference.py +529 -0
- augsynth_py/py.typed +0 -0
- augsynth_py/synth/__init__.py +12 -0
- augsynth_py/synth/_panel.py +254 -0
- augsynth_py/synth/augmented.py +747 -0
- augsynth_py/synth/classical.py +304 -0
- augsynth_py-0.3.0.dist-info/METADATA +198 -0
- augsynth_py-0.3.0.dist-info/RECORD +13 -0
- augsynth_py-0.3.0.dist-info/WHEEL +4 -0
- augsynth_py-0.3.0.dist-info/licenses/LICENSE +21 -0
augsynth_py/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""augsynth-py: Augmented Synthetic Control Methods for Python."""
|
|
2
|
+
|
|
3
|
+
from augsynth_py._version import __version__
|
|
4
|
+
from augsynth_py.inference import conformal_interval, conformal_pvalue
|
|
5
|
+
from augsynth_py.synth import AugSynth, Synth
|
|
6
|
+
|
|
7
|
+
__all__ = ["AugSynth", "Synth", "__version__", "conformal_interval", "conformal_pvalue"]
|
augsynth_py/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Domain-specific exceptions raised by ``augsynth_py``.
|
|
2
|
+
|
|
3
|
+
Generic input validation (wrong types, malformed arrays) raises ``ValueError``.
|
|
4
|
+
These custom exceptions cover panel-shape problems that are specific to the
|
|
5
|
+
synthetic-control setting and would be ambiguous as plain ``ValueError``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AugsynthError(Exception):
|
|
12
|
+
"""Base class for all augsynth-py domain errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TreatedUnitNotFoundError(AugsynthError):
|
|
16
|
+
"""The unit identified as treated is not present in the panel."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EmptyDonorPoolError(AugsynthError):
|
|
20
|
+
"""No donor units remain after removing the treated unit."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SinglePeriodError(AugsynthError):
|
|
24
|
+
"""The panel has fewer than two time periods on at least one side of treatment."""
|
augsynth_py/inference.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Conformal inference for synthetic controls (Chernozhukov, Wüthrich & Zhu 2021).
|
|
2
|
+
|
|
3
|
+
Implements the permutation-test core of the exact and robust conformal inference
|
|
4
|
+
method of Chernozhukov, Wüthrich & Zhu (2021), "An Exact and Robust Conformal
|
|
5
|
+
Inference Method for Counterfactual and Synthetic Controls", JASA (hereafter
|
|
6
|
+
CWZ 2021).
|
|
7
|
+
|
|
8
|
+
The public conformal p-value and confidence-interval surface is built on top of
|
|
9
|
+
the pure permutation-test core (a residual test statistic and a permutation
|
|
10
|
+
p-value with two schemes, moving block and iid) and the estimator's
|
|
11
|
+
full-window refit-under-null hook (``_conformal_null_residuals``), which
|
|
12
|
+
supplies the exchangeable residuals the permutation test operates on.
|
|
13
|
+
|
|
14
|
+
References
|
|
15
|
+
----------
|
|
16
|
+
Chernozhukov, V., Wüthrich, K., & Zhu, Y. (2021). An Exact and Robust Conformal
|
|
17
|
+
Inference Method for Counterfactual and Synthetic Controls. Journal of the
|
|
18
|
+
American Statistical Association, 116(536), 1849-1864.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import warnings
|
|
24
|
+
from typing import Literal, Protocol
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numpy.typing import NDArray
|
|
28
|
+
|
|
29
|
+
Side = Literal["two-sided", "left", "right"]
|
|
30
|
+
PermutationType = Literal["block", "iid"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _FittedSC(Protocol):
|
|
34
|
+
"""Structural type for a fitted synthetic-control estimator.
|
|
35
|
+
|
|
36
|
+
Both :class:`augsynth_py.synth.classical.Synth` and
|
|
37
|
+
:class:`augsynth_py.synth.augmented.AugSynth` satisfy this Protocol after
|
|
38
|
+
``.fit(...)``.
|
|
39
|
+
|
|
40
|
+
Attributes
|
|
41
|
+
----------
|
|
42
|
+
gap_ : NDArray[np.float64]
|
|
43
|
+
Actual minus synthetic outcome over *all* periods, ordered by period.
|
|
44
|
+
Used only to *place* the confidence-interval grid
|
|
45
|
+
(:func:`conformal_interval`); the p-value itself does not read it.
|
|
46
|
+
pre_mask_ : NDArray[np.bool_]
|
|
47
|
+
Boolean mask, same length as ``gap_``, True for pre-treatment periods
|
|
48
|
+
(post-treatment periods are ``~pre_mask_``).
|
|
49
|
+
att_ : float
|
|
50
|
+
Mean post-treatment gap (the point estimate of the ATT); the CI grid
|
|
51
|
+
centre.
|
|
52
|
+
|
|
53
|
+
Methods
|
|
54
|
+
-------
|
|
55
|
+
_conformal_null_residuals(h0)
|
|
56
|
+
Full-window residuals under the constant-effect null ``h0`` (CWZ 2021),
|
|
57
|
+
obtained by refitting the synthetic control over the entire window with
|
|
58
|
+
the treated post-period adjusted by ``h0``. See the estimator method
|
|
59
|
+
for the exact construction.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
gap_: NDArray[np.float64]
|
|
63
|
+
pre_mask_: NDArray[np.bool_]
|
|
64
|
+
att_: float
|
|
65
|
+
|
|
66
|
+
def _conformal_null_residuals(self, h0: float) -> NDArray[np.float64]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _post_statistic(
|
|
70
|
+
residuals: NDArray[np.float64],
|
|
71
|
+
post_mask: NDArray[np.bool_],
|
|
72
|
+
side: Side,
|
|
73
|
+
) -> float:
|
|
74
|
+
r"""Compute the CWZ test statistic on the post-window residuals.
|
|
75
|
+
|
|
76
|
+
Implements the (unnormalized) test statistic of CWZ 2021, §3, Eq. defining
|
|
77
|
+
:math:`S_q` (the mean-absolute statistic, :math:`q = 1`). The :math:`S_q`
|
|
78
|
+
normalization constant :math:`1/\sqrt{T_1}` of the paper is identical across
|
|
79
|
+
permutations and cancels in the p-value ranking, so it is omitted here. The
|
|
80
|
+
``"right"`` and ``"left"`` variants use the signed sum for one-sided tests.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
residuals : NDArray[np.float64]
|
|
85
|
+
Residual vector over the full window (pre- and post-treatment).
|
|
86
|
+
post_mask : NDArray[np.bool_]
|
|
87
|
+
Boolean mask, same length as ``residuals``, selecting the post-treatment
|
|
88
|
+
positions.
|
|
89
|
+
side : str
|
|
90
|
+
One of ``"two-sided"`` (``sum(abs(post))``), ``"right"``
|
|
91
|
+
(``sum(post)``), or ``"left"`` (``-sum(post)``).
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
float
|
|
96
|
+
The value of the test statistic on the selected post-window residuals.
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
ValueError
|
|
101
|
+
If ``side`` is not one of the recognized values.
|
|
102
|
+
"""
|
|
103
|
+
post = residuals[post_mask]
|
|
104
|
+
if side == "two-sided":
|
|
105
|
+
return float(np.sum(np.abs(post)))
|
|
106
|
+
if side == "right":
|
|
107
|
+
return float(np.sum(post))
|
|
108
|
+
if side == "left":
|
|
109
|
+
return float(-np.sum(post))
|
|
110
|
+
raise ValueError(f"Unknown side {side!r}; expected 'two-sided', 'left', or 'right'.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _permutation_pvalue(
|
|
114
|
+
residuals: NDArray[np.float64],
|
|
115
|
+
post_mask: NDArray[np.bool_],
|
|
116
|
+
side: Side,
|
|
117
|
+
permutation_type: PermutationType,
|
|
118
|
+
ns: int,
|
|
119
|
+
rng: np.random.Generator | None,
|
|
120
|
+
) -> float:
|
|
121
|
+
r"""Permutation p-value for the CWZ conformal test.
|
|
122
|
+
|
|
123
|
+
The full residual vector is permuted and the test statistic is recomputed on
|
|
124
|
+
the *fixed* post positions (``post_mask``), then compared to the observed
|
|
125
|
+
statistic (the statistic on the unpermuted residuals). See CWZ 2021, §3
|
|
126
|
+
(Eq. defining :math:`S_q` and the permutation p-value).
|
|
127
|
+
|
|
128
|
+
Two permutation schemes are supported:
|
|
129
|
+
|
|
130
|
+
- ``"block"``: the moving-block (cyclic-shift) scheme, deterministic. The
|
|
131
|
+
reference set is the :math:`T` cyclic shifts ``np.roll(residuals, j)`` for
|
|
132
|
+
``j = 0, ..., T-1``; ``j = 0`` reproduces the observed statistic. Since the
|
|
133
|
+
observed statistic is always a member of the reference set,
|
|
134
|
+
:math:`p = \#\{j : S_j \ge S_{\mathrm{obs}}\} / T`, which is always at
|
|
135
|
+
least :math:`1/T`. ``ns`` and ``rng`` are ignored.
|
|
136
|
+
- ``"iid"``: ``ns`` random permutations drawn from ``rng``, using the
|
|
137
|
+
finite-sample-valid convention
|
|
138
|
+
:math:`p = (1 + \#\{S_{\mathrm{perm}} \ge S_{\mathrm{obs}}\}) / (1 + ns)`.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
residuals : NDArray[np.float64]
|
|
143
|
+
Residual vector over the full window.
|
|
144
|
+
post_mask : NDArray[np.bool_]
|
|
145
|
+
Boolean mask selecting the post-treatment positions.
|
|
146
|
+
side : str
|
|
147
|
+
Test direction; see :func:`_post_statistic`.
|
|
148
|
+
permutation_type : str
|
|
149
|
+
Either ``"block"`` or ``"iid"``.
|
|
150
|
+
ns : int
|
|
151
|
+
Number of random permutations (``"iid"`` only; ignored for ``"block"``).
|
|
152
|
+
rng : numpy.random.Generator or None
|
|
153
|
+
Random generator, required for ``"iid"``.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
float
|
|
158
|
+
The permutation p-value in ``[0, 1]``.
|
|
159
|
+
|
|
160
|
+
Raises
|
|
161
|
+
------
|
|
162
|
+
ValueError
|
|
163
|
+
If ``permutation_type`` is unknown, ``side`` is unknown, or
|
|
164
|
+
``permutation_type == "iid"`` and ``rng`` is None.
|
|
165
|
+
"""
|
|
166
|
+
s_obs = _post_statistic(residuals, post_mask, side)
|
|
167
|
+
|
|
168
|
+
if permutation_type == "block":
|
|
169
|
+
t = int(residuals.shape[0])
|
|
170
|
+
count = 0
|
|
171
|
+
for j in range(t):
|
|
172
|
+
shifted = np.roll(residuals, j)
|
|
173
|
+
if _post_statistic(shifted, post_mask, side) >= s_obs:
|
|
174
|
+
count += 1
|
|
175
|
+
return count / t
|
|
176
|
+
|
|
177
|
+
if permutation_type == "iid":
|
|
178
|
+
if rng is None:
|
|
179
|
+
raise ValueError("permutation_type='iid' requires a non-None rng.")
|
|
180
|
+
count = 0
|
|
181
|
+
for _ in range(ns):
|
|
182
|
+
permuted = rng.permutation(residuals)
|
|
183
|
+
if _post_statistic(permuted, post_mask, side) >= s_obs:
|
|
184
|
+
count += 1
|
|
185
|
+
return (1 + count) / (1 + ns)
|
|
186
|
+
|
|
187
|
+
raise ValueError(f"Unknown permutation_type {permutation_type!r}; expected 'block' or 'iid'.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def conformal_pvalue(
|
|
191
|
+
fit: _FittedSC,
|
|
192
|
+
h0: float = 0.0,
|
|
193
|
+
*,
|
|
194
|
+
permutation_type: PermutationType = "block",
|
|
195
|
+
side: Side = "two-sided",
|
|
196
|
+
ns: int = 1000,
|
|
197
|
+
rng: np.random.Generator | None = None,
|
|
198
|
+
) -> float:
|
|
199
|
+
r"""Conformal p-value for a constant post-period effect (CWZ 2021).
|
|
200
|
+
|
|
201
|
+
Tests the null hypothesis that the treatment effect equals a constant ``h0``
|
|
202
|
+
over the whole post-treatment window, for an already-fitted
|
|
203
|
+
synthetic-control estimator, via the exact permutation test of CWZ 2021, §3.
|
|
204
|
+
|
|
205
|
+
Refit under the null
|
|
206
|
+
--------------------
|
|
207
|
+
CWZ 2021's exactness rests on the full-window residuals being *exchangeable*
|
|
208
|
+
under :math:`H_0`. To obtain them, the treated unit's post-period outcomes
|
|
209
|
+
are adjusted by ``h0`` and the synthetic control is **refit over the entire
|
|
210
|
+
window** (all ``T`` periods), not the pre-period only. This is delegated to
|
|
211
|
+
the estimator's ``_conformal_null_residuals(h0)`` method (implemented on both
|
|
212
|
+
:class:`~augsynth_py.synth.classical.Synth` and
|
|
213
|
+
:class:`~augsynth_py.synth.augmented.AugSynth`); the resulting length-``T``
|
|
214
|
+
residual vector is handed to the permutation core
|
|
215
|
+
(:func:`_permutation_pvalue`), which recomputes the CWZ test statistic on the
|
|
216
|
+
fixed post positions across the reference permutations.
|
|
217
|
+
|
|
218
|
+
This refit is intentional and distinct from the point-estimate path (whose
|
|
219
|
+
weights are pre-period-anchored): the ATT is a pre-period counterfactual,
|
|
220
|
+
whereas the conformal residual is CWZ's full-window exchangeability
|
|
221
|
+
construct. It matches R ``augsynth``'s conformal inference exactly on the
|
|
222
|
+
``GeoLift_PreTest`` fixture (see
|
|
223
|
+
``tests/validation_against_r/test_conformal.py``).
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
fit : _FittedSC
|
|
228
|
+
A fitted estimator exposing ``pre_mask_``, ``att_`` and the
|
|
229
|
+
``_conformal_null_residuals`` method (any
|
|
230
|
+
:class:`~augsynth_py.synth.classical.Synth` or
|
|
231
|
+
:class:`~augsynth_py.synth.augmented.AugSynth` after ``.fit``).
|
|
232
|
+
h0 : float, optional
|
|
233
|
+
The hypothesized constant post-period effect. Defaults to ``0.0`` (the
|
|
234
|
+
sharp null of no effect).
|
|
235
|
+
permutation_type : {"block", "iid"}, optional
|
|
236
|
+
Permutation scheme. ``"block"`` (default) is the deterministic
|
|
237
|
+
moving-block (cyclic-shift) scheme and needs no ``rng``. ``"iid"`` draws
|
|
238
|
+
``ns`` random permutations and requires ``rng``.
|
|
239
|
+
side : {"two-sided", "left", "right"}, optional
|
|
240
|
+
Direction of the test; see :func:`_post_statistic`.
|
|
241
|
+
ns : int, optional
|
|
242
|
+
Number of random permutations for ``permutation_type="iid"``; ignored
|
|
243
|
+
for ``"block"``. Defaults to ``1000``.
|
|
244
|
+
rng : numpy.random.Generator or None, optional
|
|
245
|
+
Random generator; required for ``permutation_type="iid"``, ignored for
|
|
246
|
+
``"block"``.
|
|
247
|
+
|
|
248
|
+
Returns
|
|
249
|
+
-------
|
|
250
|
+
float
|
|
251
|
+
The conformal p-value in ``[0, 1]``.
|
|
252
|
+
|
|
253
|
+
Notes
|
|
254
|
+
-----
|
|
255
|
+
The sibling ``geolift-py`` package maps its public arguments onto this
|
|
256
|
+
function: ``conformal_type`` -> ``permutation_type`` and ``side_of_test`` ->
|
|
257
|
+
``side``.
|
|
258
|
+
|
|
259
|
+
References
|
|
260
|
+
----------
|
|
261
|
+
Chernozhukov, V., Wüthrich, K., & Zhu, Y. (2021). An Exact and Robust
|
|
262
|
+
Conformal Inference Method for Counterfactual and Synthetic Controls. JASA,
|
|
263
|
+
116(536), 1849-1864.
|
|
264
|
+
"""
|
|
265
|
+
residuals = np.asarray(fit._conformal_null_residuals(h0), dtype=np.float64)
|
|
266
|
+
post_mask = ~np.asarray(fit.pre_mask_, dtype=np.bool_)
|
|
267
|
+
return _permutation_pvalue(residuals, post_mask, side, permutation_type, ns, rng)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def conformal_interval(
|
|
271
|
+
fit: _FittedSC,
|
|
272
|
+
*,
|
|
273
|
+
alpha: float = 0.05,
|
|
274
|
+
grid_size: int = 100,
|
|
275
|
+
permutation_type: PermutationType = "block",
|
|
276
|
+
side: Side = "two-sided",
|
|
277
|
+
ns: int = 1000,
|
|
278
|
+
rng: np.random.Generator | None = None,
|
|
279
|
+
) -> tuple[float, float]:
|
|
280
|
+
r"""Confidence interval for the constant post-period effect by test inversion.
|
|
281
|
+
|
|
282
|
+
Builds a :math:`(1 - \alpha)` confidence interval for the constant
|
|
283
|
+
post-period treatment effect by inverting the conformal test of CWZ 2021,
|
|
284
|
+
§3: the interval is the *acceptance region* of the test,
|
|
285
|
+
|
|
286
|
+
.. math::
|
|
287
|
+
|
|
288
|
+
\mathrm{CI}_{1-\alpha} = \{h_0 : p(h_0) \ge \alpha\},
|
|
289
|
+
|
|
290
|
+
where :math:`p(h_0)` is :func:`conformal_pvalue` evaluated at the null
|
|
291
|
+
constant effect :math:`h_0`. Because the p-value is only available
|
|
292
|
+
pointwise, the acceptance region is approximated on a finite grid and the
|
|
293
|
+
interval is reported as ``(min, max)`` of the accepted grid points.
|
|
294
|
+
|
|
295
|
+
Only the two-sided test can be inverted into a confidence interval: the
|
|
296
|
+
acceptance region of a one-sided statistic is a half-line, not a bounded
|
|
297
|
+
interval, so a finite ``(min, max)`` over the grid would be a meaningless
|
|
298
|
+
grid-edge artifact. ``side`` must therefore be ``"two-sided"`` (the
|
|
299
|
+
default); use :func:`conformal_pvalue` directly for one-sided testing.
|
|
300
|
+
|
|
301
|
+
Grid construction
|
|
302
|
+
-----------------
|
|
303
|
+
The grid is centred on ``center = mean(post gap) == fit.att_`` and spans
|
|
304
|
+
``center ± 6 * sd(post gap)`` (sample standard deviation, ``ddof=1``), with
|
|
305
|
+
``grid_size`` equally spaced points. The value ``0.0`` is always unioned in
|
|
306
|
+
so the sharp null of no effect is evaluated. When the post window has a
|
|
307
|
+
single period, or the post gaps are degenerate with zero variance,
|
|
308
|
+
``sd == 0`` and the span falls back to ``abs(center)`` (or ``1.0`` if
|
|
309
|
+
``center`` is also zero), so the grid is never a single point.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
fit : _FittedSC
|
|
314
|
+
A fitted estimator exposing ``gap_``, ``pre_mask_``, ``att_`` and the
|
|
315
|
+
``_conformal_null_residuals`` method (any
|
|
316
|
+
:class:`~augsynth_py.synth.classical.Synth` or
|
|
317
|
+
:class:`~augsynth_py.synth.augmented.AugSynth` after ``.fit``). ``gap_``
|
|
318
|
+
and ``att_`` place the grid; each grid point is scored by refitting via
|
|
319
|
+
:func:`conformal_pvalue`.
|
|
320
|
+
alpha : float, optional
|
|
321
|
+
Significance level; the interval has nominal coverage :math:`1 - \alpha`.
|
|
322
|
+
Defaults to ``0.05``.
|
|
323
|
+
grid_size : int, optional
|
|
324
|
+
Number of equally spaced grid points before ``0.0`` is unioned in.
|
|
325
|
+
Defaults to ``100``.
|
|
326
|
+
permutation_type : {"block", "iid"}, optional
|
|
327
|
+
Permutation scheme threaded to :func:`conformal_pvalue`. Defaults to
|
|
328
|
+
``"block"``.
|
|
329
|
+
side : {"two-sided"}, optional
|
|
330
|
+
Kept in the signature to make the two-sided requirement explicit. Only
|
|
331
|
+
``"two-sided"`` (the default) is accepted; any other value raises
|
|
332
|
+
``ValueError`` because a one-sided acceptance region is unbounded.
|
|
333
|
+
ns : int, optional
|
|
334
|
+
Number of random permutations for ``permutation_type="iid"``; ignored
|
|
335
|
+
for ``"block"``. Defaults to ``1000``.
|
|
336
|
+
rng : numpy.random.Generator or None, optional
|
|
337
|
+
Random generator threaded to :func:`conformal_pvalue`; required for
|
|
338
|
+
``permutation_type="iid"``. On the iid path the same generator is
|
|
339
|
+
consumed sequentially across grid points, which is intentional.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
tuple[float, float]
|
|
344
|
+
``(lower, upper)`` bounds of the confidence interval, or
|
|
345
|
+
``(nan, nan)`` if *no* grid point is accepted (an empty acceptance
|
|
346
|
+
region). Refining or widening the grid does *not* recover a non-empty
|
|
347
|
+
interval in that case.
|
|
348
|
+
|
|
349
|
+
Empty ``(nan, nan)`` requires the *peak* of the ``p(h0)`` curve to fall
|
|
350
|
+
below ``alpha``. Under the block scheme ``p(h0)`` peaks near a
|
|
351
|
+
well-specified ``h0`` (large — the ``j = 0`` identity shift makes the
|
|
352
|
+
residuals nearly tie, so ``p`` approaches 1) and decays to a **floor of
|
|
353
|
+
``1/T``** (``T`` = total periods; the identity shift always ties itself,
|
|
354
|
+
so the count is at least 1) at extreme ``h0``. An empty region therefore
|
|
355
|
+
arises only from **residual non-exchangeability** — a poor / trending /
|
|
356
|
+
heteroskedastic fit that depresses even the peak below ``alpha``. It is
|
|
357
|
+
*only possible when* ``1/T < alpha`` (i.e. ``T > 1/alpha``, e.g.
|
|
358
|
+
``T > 20`` for a 95% CI); otherwise the ``1/T`` floor keeps every ``h0``
|
|
359
|
+
accepted.
|
|
360
|
+
|
|
361
|
+
The complementary regime, ``T <= 1/alpha`` (e.g. ``T = 14`` at
|
|
362
|
+
``alpha = 0.05``, floor ``1/14 ~ 0.071 >= 0.05``), is *not* empty: the
|
|
363
|
+
floor holds ``p(h0) >= 1/T >= alpha`` at **every** ``h0``, so the
|
|
364
|
+
acceptance region is **unbounded**. That hits the truncation guard
|
|
365
|
+
(widening + ``UserWarning`` + finite truncated bounds; see *Notes*),
|
|
366
|
+
never ``(nan, nan)``.
|
|
367
|
+
|
|
368
|
+
Notes
|
|
369
|
+
-----
|
|
370
|
+
The reported bounds are the innermost accepted grid points, so each bound is
|
|
371
|
+
accurate only to about one grid spacing, ``2 * spread / (grid_size - 1)``;
|
|
372
|
+
increasing ``grid_size`` tightens this resolution.
|
|
373
|
+
|
|
374
|
+
**Truncation guard.** The grid span starts at ``6 * sd(post gap)``, derived
|
|
375
|
+
from the *point-estimate* ``gap_`` dispersion, but each grid point is scored
|
|
376
|
+
by the *full-window refit* acceptance region — a different, often wider
|
|
377
|
+
quantity. When the donors fit tightly (small ``sd``) yet the conformal test
|
|
378
|
+
has little power (a broad range of ``h0`` is accepted), the initial span is
|
|
379
|
+
far narrower than the region and ``min`` / ``max`` would clip to the grid
|
|
380
|
+
edges, silently understating (anti-conservatively) the interval. To avoid
|
|
381
|
+
this, if an accepted endpoint reaches a grid boundary the span is doubled
|
|
382
|
+
(centre, ``grid_size`` and the unioned ``0.0`` preserved) and the grid is
|
|
383
|
+
re-scored, up to 8 doublings, stopping once both endpoints are strictly
|
|
384
|
+
interior. If truncation persists at the cap, a :class:`UserWarning` is
|
|
385
|
+
emitted and the widest computed bounds are returned as a lower bound on the
|
|
386
|
+
true interval. This is distinct from the empty-region ``(nan, nan)`` case in
|
|
387
|
+
*Returns*: there *no* ``h0`` is accepted (peak p-value below ``alpha``),
|
|
388
|
+
which widening cannot fix; here a boundary-reaching region is accepted and
|
|
389
|
+
widening extends it.
|
|
390
|
+
|
|
391
|
+
**Non-contiguity warning.** The acceptance region of the refit-under-null
|
|
392
|
+
test is not guaranteed to be an interval: because the residuals depend on
|
|
393
|
+
``h0`` through the refit, ``p(h0)`` can dip below ``alpha`` on a stretch
|
|
394
|
+
strictly inside the accepted envelope (demonstrated on the Basque panel,
|
|
395
|
+
where the rejected gap contains ``att_`` itself — identically in R; see
|
|
396
|
+
``docs/methodology.md`` §5.5). When the accepted grid points are not
|
|
397
|
+
consecutive, a :class:`UserWarning` is emitted and the returned ``(min,
|
|
398
|
+
max)`` envelope *overstates* (never understates) the acceptance region —
|
|
399
|
+
conservative in direction, but not a connected interval. Under
|
|
400
|
+
``permutation_type="iid"`` the same warning can also fire from Monte-Carlo
|
|
401
|
+
noise at grid points whose p-value sits near ``alpha``.
|
|
402
|
+
|
|
403
|
+
The sibling ``geolift-py`` package consumes this function to populate
|
|
404
|
+
``ConfIntervals(method="conformal")``.
|
|
405
|
+
|
|
406
|
+
References
|
|
407
|
+
----------
|
|
408
|
+
Chernozhukov, V., Wüthrich, K., & Zhu, Y. (2021). An Exact and Robust
|
|
409
|
+
Conformal Inference Method for Counterfactual and Synthetic Controls. JASA,
|
|
410
|
+
116(536), 1849-1864.
|
|
411
|
+
"""
|
|
412
|
+
if side != "two-sided":
|
|
413
|
+
raise ValueError(
|
|
414
|
+
"conformal_interval inverts the two-sided conformal test; one-sided "
|
|
415
|
+
"intervals are not supported. Use conformal_pvalue(..., side=...) for "
|
|
416
|
+
"one-sided testing."
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
gap = np.asarray(fit.gap_, dtype=np.float64)
|
|
420
|
+
pre_mask = np.asarray(fit.pre_mask_, dtype=np.bool_)
|
|
421
|
+
post_gap = gap[~pre_mask]
|
|
422
|
+
|
|
423
|
+
# The grid centre is the point estimate att_, which equals mean(post_gap) by
|
|
424
|
+
# construction on both Synth and AugSynth (att_ = mean(gap[~pre_mask])); this
|
|
425
|
+
# is why att_ is part of the _FittedSC protocol. post_gap still supplies the
|
|
426
|
+
# dispersion for the span.
|
|
427
|
+
center = float(fit.att_)
|
|
428
|
+
sd = float(np.std(post_gap, ddof=1)) if post_gap.size > 1 else 0.0
|
|
429
|
+
# Degenerate / near-perfect pre-fit: the point-estimate post gaps carry no
|
|
430
|
+
# usable dispersion (a single post period, or sd at the solver-noise floor
|
|
431
|
+
# ~1e-14). A 6*sd span then collapses to a point that both mis-locates the
|
|
432
|
+
# interval and cannot be widened by doubling, so fall back to a center-scaled
|
|
433
|
+
# span. The threshold is relative to the outcome scale so it fires on solver
|
|
434
|
+
# noise but never on a genuine (however small) residual spread.
|
|
435
|
+
scale = abs(center) if center != 0.0 else 1.0
|
|
436
|
+
spread = 6.0 * sd
|
|
437
|
+
if spread < 1e-6 * scale:
|
|
438
|
+
spread = scale
|
|
439
|
+
|
|
440
|
+
def _accepted(lin: NDArray[np.float64]) -> tuple[NDArray[np.float64], NDArray[np.bool_]]:
|
|
441
|
+
"""Score the sorted candidate grid (``0.0`` always evaluated).
|
|
442
|
+
|
|
443
|
+
Returns the candidate array and the boolean acceptance mask over it,
|
|
444
|
+
so the caller can recover both the accepted values and their grid
|
|
445
|
+
positions (the positions drive the non-contiguity diagnostic).
|
|
446
|
+
"""
|
|
447
|
+
candidates = np.union1d(lin, [0.0])
|
|
448
|
+
mask = np.fromiter(
|
|
449
|
+
(
|
|
450
|
+
conformal_pvalue(
|
|
451
|
+
fit,
|
|
452
|
+
float(h0),
|
|
453
|
+
permutation_type=permutation_type,
|
|
454
|
+
side=side,
|
|
455
|
+
ns=ns,
|
|
456
|
+
rng=rng,
|
|
457
|
+
)
|
|
458
|
+
>= alpha
|
|
459
|
+
for h0 in candidates
|
|
460
|
+
),
|
|
461
|
+
dtype=bool,
|
|
462
|
+
count=candidates.size,
|
|
463
|
+
)
|
|
464
|
+
return candidates, mask
|
|
465
|
+
|
|
466
|
+
# Test inversion on a finite grid. The grid span is derived from the
|
|
467
|
+
# point-estimate gap_ dispersion, but each point is scored by the
|
|
468
|
+
# full-window refit acceptance region — a *different*, often wider quantity
|
|
469
|
+
# (tight donors -> small sd, yet a low-power conformal test accepts a broad
|
|
470
|
+
# range of h0). If the accepted set reaches a linspace boundary the interval
|
|
471
|
+
# is truncated and would silently understate coverage, so widen the span
|
|
472
|
+
# (double it, keeping the centre, grid_size, and the unioned 0.0) and
|
|
473
|
+
# re-score until both endpoints are strictly interior or the cap is hit.
|
|
474
|
+
max_doublings = 8
|
|
475
|
+
truncated = False
|
|
476
|
+
accepted_idx = np.empty(0, dtype=np.intp)
|
|
477
|
+
candidates = np.empty(0, dtype=np.float64)
|
|
478
|
+
for attempt in range(max_doublings + 1):
|
|
479
|
+
lin = np.linspace(center - spread, center + spread, grid_size)
|
|
480
|
+
candidates, accept_mask = _accepted(lin)
|
|
481
|
+
if not accept_mask.any():
|
|
482
|
+
# Empty acceptance region: widening cannot recover it (the peak
|
|
483
|
+
# p-value over h0 is below alpha — structural, see Returns).
|
|
484
|
+
return (float("nan"), float("nan"))
|
|
485
|
+
accepted_idx = np.flatnonzero(accept_mask)
|
|
486
|
+
lower = float(candidates[accepted_idx[0]])
|
|
487
|
+
upper = float(candidates[accepted_idx[-1]])
|
|
488
|
+
truncated = (lower <= float(lin[0])) or (upper >= float(lin[-1]))
|
|
489
|
+
if not truncated or attempt == max_doublings:
|
|
490
|
+
break
|
|
491
|
+
spread *= 2.0
|
|
492
|
+
|
|
493
|
+
if truncated:
|
|
494
|
+
warnings.warn(
|
|
495
|
+
"conformal_interval may be truncated: the acceptance region still "
|
|
496
|
+
f"reaches a grid boundary after {max_doublings} span doublings "
|
|
497
|
+
f"(final half-width {spread:g} about center {center:g}). The "
|
|
498
|
+
"returned bounds understate the interval; the estimator likely has "
|
|
499
|
+
"little conformal power (the test accepts h0 across a very wide "
|
|
500
|
+
"range). Treat the bounds as a lower bound on the true interval.",
|
|
501
|
+
UserWarning,
|
|
502
|
+
stacklevel=2,
|
|
503
|
+
)
|
|
504
|
+
# min/max recovers the CI only when the two-sided acceptance region is
|
|
505
|
+
# contiguous — which is empirical, not guaranteed by convexity, since under
|
|
506
|
+
# refit-under-null the residuals themselves depend on h0 through the refit
|
|
507
|
+
# (so the statistic is not a fixed convex function of h0). Non-contiguity
|
|
508
|
+
# occurs on real panels: on the Basque panel a rejected 1/T-floor gap sits
|
|
509
|
+
# strictly inside the envelope and contains att_ itself, identically in R
|
|
510
|
+
# (methodology.md §5.5). When that happens the returned bounds are the
|
|
511
|
+
# min/max ENVELOPE, wider than the true acceptance region (conservative
|
|
512
|
+
# direction), and the warning below stops them being misread as a
|
|
513
|
+
# connected region. On the iid path Monte-Carlo noise across grid points
|
|
514
|
+
# can additionally open spurious single-point gaps.
|
|
515
|
+
interior_gaps = int(np.count_nonzero(np.diff(accepted_idx) > 1))
|
|
516
|
+
if interior_gaps > 0:
|
|
517
|
+
warnings.warn(
|
|
518
|
+
f"conformal_interval: the acceptance region is non-contiguous at "
|
|
519
|
+
f"alpha={alpha:g} — {interior_gaps} rejected gap(s) lie strictly "
|
|
520
|
+
"inside the returned bounds. The bounds are the min/max envelope "
|
|
521
|
+
"of the accepted grid points and overstate (never understate) the "
|
|
522
|
+
"true acceptance region; do not read them as a connected interval. "
|
|
523
|
+
"This is a genuine property of CWZ test inversion on some panels "
|
|
524
|
+
"(see docs/methodology.md §5.5); under permutation_type='iid' it "
|
|
525
|
+
"can also be Monte-Carlo noise at grid points with p near alpha.",
|
|
526
|
+
UserWarning,
|
|
527
|
+
stacklevel=2,
|
|
528
|
+
)
|
|
529
|
+
return (float(candidates[accepted_idx[0]]), float(candidates[accepted_idx[-1]]))
|
augsynth_py/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Synthetic control estimators.
|
|
2
|
+
|
|
3
|
+
Implementations live here. Each estimator is in its own module and follows
|
|
4
|
+
the convention: a class with ``fit(X, y, ...)`` and ``predict(...)`` methods,
|
|
5
|
+
following the scikit-learn estimator contract loosely (no fit/transform split,
|
|
6
|
+
since synthetic control fits and predicts jointly).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from augsynth_py.synth.augmented import AugSynth
|
|
10
|
+
from augsynth_py.synth.classical import Synth
|
|
11
|
+
|
|
12
|
+
__all__ = ["AugSynth", "Synth"]
|