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.
@@ -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"]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.3.0"
@@ -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."""
@@ -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"]