skxperiments 0.1.0.dev0__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.
Files changed (36) hide show
  1. skxperiments/__init__.py +5 -0
  2. skxperiments/core/__init__.py +42 -0
  3. skxperiments/core/assignment.py +589 -0
  4. skxperiments/core/base.py +512 -0
  5. skxperiments/core/exceptions.py +145 -0
  6. skxperiments/core/potential_outcomes.py +168 -0
  7. skxperiments/core/results.py +624 -0
  8. skxperiments/design/__init__.py +22 -0
  9. skxperiments/design/balance.py +182 -0
  10. skxperiments/design/blocked_crd.py +157 -0
  11. skxperiments/design/crd.py +162 -0
  12. skxperiments/design/factorial.py +174 -0
  13. skxperiments/design/power.py +233 -0
  14. skxperiments/design/rerandomized_crd.py +319 -0
  15. skxperiments/diagnostics/__init__.py +21 -0
  16. skxperiments/diagnostics/aa_test.py +277 -0
  17. skxperiments/diagnostics/balance_report.py +224 -0
  18. skxperiments/diagnostics/srm.py +327 -0
  19. skxperiments/estimators/__init__.py +23 -0
  20. skxperiments/estimators/blocked_difference_in_means.py +197 -0
  21. skxperiments/estimators/cuped.py +280 -0
  22. skxperiments/estimators/difference_in_means.py +161 -0
  23. skxperiments/estimators/factorial_estimator.py +213 -0
  24. skxperiments/estimators/lin_estimator.py +298 -0
  25. skxperiments/inference/__init__.py +17 -0
  26. skxperiments/inference/bootstrap.py +450 -0
  27. skxperiments/inference/multiple.py +365 -0
  28. skxperiments/inference/neyman.py +386 -0
  29. skxperiments/inference/randomization_test.py +319 -0
  30. skxperiments/pipeline.py +366 -0
  31. skxperiments/reporting/__init__.py +30 -0
  32. skxperiments/reporting/plots.py +411 -0
  33. skxperiments/reporting/summary.py +185 -0
  34. skxperiments-0.1.0.dev0.dist-info/METADATA +272 -0
  35. skxperiments-0.1.0.dev0.dist-info/RECORD +36 -0
  36. skxperiments-0.1.0.dev0.dist-info/WHEEL +4 -0
@@ -0,0 +1,450 @@
1
+ """Bootstrap confidence intervals for superpopulation inference.
2
+
3
+ Implements ``BootstrapCI``: wraps any scalar estimator and approximates
4
+ the sampling distribution of its ATE by resampling units **with
5
+ replacement within each treatment arm** (within each block-by-arm
6
+ stratum for blocked designs), then forms a percentile or BCa confidence
7
+ interval.
8
+
9
+ Unlike ``RandomizationTest`` (which permutes the treatment assignment
10
+ under Fisher's sharp null) and ``NeymanCI`` (finite-population variance),
11
+ the bootstrap treats the observed arms as samples drawn from
12
+ superpopulations and ignores the randomization mechanism. It is therefore
13
+ the library's explicit superpopulation method.
14
+
15
+ References
16
+ ----------
17
+ Efron, B. (1987). Better bootstrap confidence intervals. Journal of the
18
+ American Statistical Association, 82(397), 171-185.
19
+ Efron, B., & Tibshirani, R. J. (1993). An Introduction to the Bootstrap.
20
+ Chapman & Hall.
21
+ """
22
+
23
+ import numpy as np
24
+ from scipy.stats import norm
25
+
26
+ from skxperiments.core.assignment import (
27
+ BlockedAssignment,
28
+ CRDAssignment,
29
+ )
30
+ from skxperiments.core.base import BaseEstimator, BaseInference
31
+ from skxperiments.core.exceptions import (
32
+ InsufficientDataError,
33
+ InvalidDesignError,
34
+ )
35
+ from skxperiments.core.results import Results
36
+
37
+
38
+ class BootstrapCI(BaseInference):
39
+ """Bootstrap confidence intervals for superpopulation inference.
40
+
41
+ Wraps a scalar estimator and approximates the sampling distribution of
42
+ its ATE by resampling units with replacement, preserving the size of
43
+ each treatment arm (and, for blocked designs, of each block-by-arm
44
+ stratum). Two interval methods are supported:
45
+
46
+ - ``"percentile"``: the ``alpha/2`` and ``1 - alpha/2`` empirical
47
+ quantiles of the bootstrap distribution.
48
+ - ``"bca"`` (default): bias-corrected and accelerated interval
49
+ (Efron 1987), correcting for median bias (``z0``) and for a
50
+ non-constant standard error (acceleration ``a``, estimated by the
51
+ leave-one-out jackknife).
52
+
53
+ The estimator is treated as a black box: each resample is turned back
54
+ into an ``Assignment`` of the original type and the estimator is
55
+ refitted on it. Any estimator producing a scalar ``Results.ate`` is
56
+ therefore supported (``DifferenceInMeans``, ``BlockedDifferenceInMeans``,
57
+ ``LinEstimator``, ``CUPED``).
58
+
59
+ Parameters
60
+ ----------
61
+ estimator : BaseEstimator
62
+ Causal estimator producing a scalar ATE (``Results.ate``). Need
63
+ not be pre-fitted: ``fit`` refits it on the supplied assignment
64
+ and on every resample. Estimators producing multi-effect
65
+ ``Results`` (``Results.ate is None``) are rejected.
66
+ method : {"bca", "percentile"}, optional
67
+ Interval method, by default ``"bca"``.
68
+ n_resamples : int, optional
69
+ Number of bootstrap resamples, by default 10_000. Must be a
70
+ positive integer.
71
+ alpha : float, optional
72
+ Significance level; the interval is two-sided at level
73
+ ``1 - alpha``, by default 0.05.
74
+ seed : int or None, optional
75
+ Random seed for reproducibility. The same ``seed`` produces the
76
+ same ``bootstrap_distribution_`` and interval. By default None.
77
+
78
+ Attributes
79
+ ----------
80
+ assignment_ : CRDAssignment or BlockedAssignment
81
+ The assignment passed to ``fit``.
82
+ observed_statistic_ : float
83
+ ATE on the original assignment, captured before resampling.
84
+ bootstrap_distribution_ : np.ndarray
85
+ Array of resampled ATEs. Length equals ``n_resamples``.
86
+ se_ : float
87
+ Bootstrap standard error: ``std(bootstrap_distribution_, ddof=1)``.
88
+ ci_ : tuple of float
89
+ The ``(1 - alpha)`` confidence interval bounds.
90
+ p_value_ : float
91
+ Approximate two-sided bootstrap p-value (achieved significance
92
+ level) for ``H0: ATE = 0``.
93
+
94
+ Notes
95
+ -----
96
+ **Superpopulation scope.** ``BootstrapCI`` always reports
97
+ ``inference_mode="superpopulation"`` in ``Results.extra``, regardless
98
+ of any ``inference_mode`` the wrapped estimator may have written: the
99
+ bootstrap is a superpopulation procedure by construction.
100
+
101
+ **Resampling scheme.** Resampling is stratified by arm (CRD) or by
102
+ block-by-arm (blocked), preserving each stratum's size and so the
103
+ fixed-margin structure of the design. Each block-by-arm stratum must
104
+ contain at least 2 units; matched-pair blocked designs (one treated
105
+ and one control per block) are not supported by the within-stratum
106
+ bootstrap in v1 and raise ``InsufficientDataError``.
107
+
108
+ **Rerandomization.** A ``CRDAssignment`` from ``ReRandomizedCRD`` is
109
+ accepted, but the bootstrap ignores the Mahalanobis acceptance
110
+ criterion (it does not re-randomize). The resulting interval may be
111
+ conservative; for inference that respects rerandomization use
112
+ ``RandomizationTest``.
113
+
114
+ **BCa cost.** The acceleration estimate adds a leave-one-out jackknife
115
+ of ``n_obs`` estimator refits on top of the ``n_resamples`` bootstrap
116
+ refits. The bias-correction is undefined when no (or every) resampled
117
+ estimate falls below the observed estimate; in that degenerate case
118
+ ``fit`` raises ``InvalidDesignError`` suggesting ``method="percentile"``.
119
+
120
+ Examples
121
+ --------
122
+ >>> from skxperiments.design.crd import CRD
123
+ >>> from skxperiments.estimators.difference_in_means import (
124
+ ... DifferenceInMeans,
125
+ ... )
126
+ >>> from skxperiments.inference.bootstrap import BootstrapCI
127
+ >>> design = CRD(p=0.5, seed=42)
128
+ >>> assignment = design.randomize(df) # doctest: +SKIP
129
+ >>> dim = DifferenceInMeans(outcome_col="y")
130
+ >>> ci = BootstrapCI(estimator=dim, method="bca", seed=0)
131
+ >>> result = ci.fit(assignment).estimate() # doctest: +SKIP
132
+ >>> result.ci # doctest: +SKIP
133
+ """
134
+
135
+ _VALID_METHODS = ("bca", "percentile")
136
+
137
+ def __init__(
138
+ self,
139
+ estimator: BaseEstimator,
140
+ method: str = "bca",
141
+ n_resamples: int = 10_000,
142
+ alpha: float = 0.05,
143
+ seed: int | None = None,
144
+ ) -> None:
145
+ if not isinstance(estimator, BaseEstimator):
146
+ raise InvalidDesignError(
147
+ f"estimator must be an instance of BaseEstimator, got "
148
+ f"{type(estimator).__name__}."
149
+ )
150
+
151
+ if method not in self._VALID_METHODS:
152
+ raise InvalidDesignError(
153
+ f"method must be one of {self._VALID_METHODS}, got "
154
+ f"{method!r}."
155
+ )
156
+
157
+ if not isinstance(n_resamples, int) or isinstance(n_resamples, bool):
158
+ raise InvalidDesignError(
159
+ f"n_resamples must be an integer, got "
160
+ f"{type(n_resamples).__name__}."
161
+ )
162
+ if n_resamples <= 0:
163
+ raise InvalidDesignError(
164
+ f"n_resamples must be > 0, got {n_resamples}."
165
+ )
166
+
167
+ if not isinstance(alpha, (int, float)) or isinstance(alpha, bool):
168
+ raise InvalidDesignError(
169
+ f"alpha must be a float in (0, 1), got "
170
+ f"{type(alpha).__name__}."
171
+ )
172
+ if not (0.0 < alpha < 1.0):
173
+ raise InvalidDesignError(
174
+ f"alpha must be in (0, 1), got {alpha}."
175
+ )
176
+
177
+ self.estimator = estimator
178
+ self.method = method
179
+ self.n_resamples = n_resamples
180
+ self.alpha = alpha
181
+ self.seed = seed
182
+
183
+ def fit(
184
+ self,
185
+ assignment: CRDAssignment | BlockedAssignment,
186
+ ) -> "BootstrapCI":
187
+ """Run the bootstrap and compute the confidence interval.
188
+
189
+ Parameters
190
+ ----------
191
+ assignment : CRDAssignment or BlockedAssignment
192
+ Observed assignment. ``FactorialAssignment`` is rejected with
193
+ ``DesignEstimatorMismatch``.
194
+
195
+ Returns
196
+ -------
197
+ BootstrapCI
198
+ Returns self.
199
+
200
+ Raises
201
+ ------
202
+ DesignEstimatorMismatch
203
+ If ``assignment`` is not a ``CRDAssignment`` or
204
+ ``BlockedAssignment``.
205
+ InvalidDesignError
206
+ If the estimator returns a multi-effect ``Results``
207
+ (``Results.ate is None``), or if the BCa bias-correction is
208
+ undefined (use ``method="percentile"``).
209
+ InsufficientDataError
210
+ If any arm (CRD) or block-by-arm stratum (blocked) has fewer
211
+ than 2 units.
212
+ """
213
+ self._validate_assignment_type(
214
+ assignment, (CRDAssignment, BlockedAssignment)
215
+ )
216
+
217
+ # Refit on the original assignment for the observed statistic.
218
+ self.estimator.fit(assignment)
219
+ base_results = self.estimator.estimate()
220
+
221
+ if base_results.ate is None:
222
+ raise InvalidDesignError(
223
+ "BootstrapCI supports only estimators producing a scalar "
224
+ "ATE (Results.ate). The supplied estimator "
225
+ f"({type(self.estimator).__name__}) produced a multi-effect "
226
+ "Results (Results.effects). Multi-effect support is planned "
227
+ "for v2."
228
+ )
229
+
230
+ observed = float(base_results.ate)
231
+
232
+ # Capture metadata BEFORE the resampling loop, so estimate() does
233
+ # not depend on the post-loop state of self.estimator.
234
+ self._n_obs = base_results.n_obs
235
+ self._n_treated = base_results.n_treated
236
+ self._n_control = base_results.n_control
237
+ self._estimator_name = base_results.estimator_name
238
+ self._design_name = base_results.design_name
239
+
240
+ strata = self._get_strata(assignment)
241
+
242
+ rng = np.random.default_rng(self.seed)
243
+ distribution = np.empty(self.n_resamples, dtype=float)
244
+ for b in range(self.n_resamples):
245
+ idx = np.concatenate(
246
+ [rng.choice(s, size=len(s), replace=True) for s in strata]
247
+ )
248
+ distribution[b] = self._compute_statistic(assignment, idx)
249
+
250
+ se = float(np.std(distribution, ddof=1))
251
+
252
+ if self.method == "bca":
253
+ ci_lower, ci_upper, z0, accel = self._bca_interval(
254
+ assignment, distribution, observed
255
+ )
256
+ self._bias_correction = z0
257
+ self._acceleration = accel
258
+ else:
259
+ lo, hi = np.quantile(
260
+ distribution, [self.alpha / 2.0, 1.0 - self.alpha / 2.0]
261
+ )
262
+ ci_lower, ci_upper = float(lo), float(hi)
263
+ self._bias_correction = None
264
+ self._acceleration = None
265
+
266
+ # Two-sided achieved significance level for H0: ATE = 0.
267
+ p_less = float(np.mean(distribution <= 0.0))
268
+ p_greater = float(np.mean(distribution >= 0.0))
269
+ p_value = min(1.0, 2.0 * min(p_less, p_greater))
270
+
271
+ self.assignment_: CRDAssignment | BlockedAssignment = assignment
272
+ self.observed_statistic_: float = observed
273
+ self.bootstrap_distribution_: np.ndarray = distribution
274
+ self.se_: float = se
275
+ self.ci_: tuple[float, float] = (ci_lower, ci_upper)
276
+ self.p_value_: float = p_value
277
+
278
+ return self
279
+
280
+ def _get_strata(
281
+ self,
282
+ assignment: CRDAssignment | BlockedAssignment,
283
+ ) -> list[np.ndarray]:
284
+ """Return iloc positions for each resampling stratum.
285
+
286
+ CRD: [treated, control]. Blocked: one stratum per block-by-arm.
287
+ Each stratum must contain at least 2 units.
288
+ """
289
+ if isinstance(assignment, BlockedAssignment):
290
+ data = assignment.data_
291
+ treat = data[assignment.treatment_col_].values
292
+ blocks = data[assignment.block_col_].values
293
+ strata: list[np.ndarray] = []
294
+ for block_val in assignment.block_sizes_:
295
+ for arm in (1, 0):
296
+ idx = np.where((blocks == block_val) & (treat == arm))[0]
297
+ arm_name = "treated" if arm == 1 else "control"
298
+ if len(idx) < 2:
299
+ raise InsufficientDataError(
300
+ context=(
301
+ f"BootstrapCI resampling ({arm_name} arm in "
302
+ f"block '{block_val}')"
303
+ ),
304
+ minimum=2,
305
+ received=len(idx),
306
+ )
307
+ strata.append(idx)
308
+ return strata
309
+
310
+ treated = assignment.treated_ids()
311
+ control = assignment.control_ids()
312
+ if len(treated) < 2:
313
+ raise InsufficientDataError(
314
+ context="BootstrapCI resampling (treated arm)",
315
+ minimum=2,
316
+ received=len(treated),
317
+ )
318
+ if len(control) < 2:
319
+ raise InsufficientDataError(
320
+ context="BootstrapCI resampling (control arm)",
321
+ minimum=2,
322
+ received=len(control),
323
+ )
324
+ return [treated, control]
325
+
326
+ def _compute_statistic(
327
+ self,
328
+ assignment: CRDAssignment | BlockedAssignment,
329
+ idx: np.ndarray,
330
+ ) -> float:
331
+ """Refit the estimator on the units selected by ``idx`` (iloc)."""
332
+ df_rs = assignment.data_.iloc[idx].reset_index(drop=True)
333
+
334
+ if isinstance(assignment, BlockedAssignment):
335
+ block_sizes = (
336
+ df_rs.groupby(assignment.block_col_).size().to_dict()
337
+ )
338
+ resampled: CRDAssignment | BlockedAssignment = BlockedAssignment(
339
+ data=df_rs,
340
+ treatment_col=assignment.treatment_col_,
341
+ design=None,
342
+ block_col=assignment.block_col_,
343
+ block_sizes=block_sizes,
344
+ )
345
+ else:
346
+ resampled = CRDAssignment(
347
+ data=df_rs,
348
+ treatment_col=assignment.treatment_col_,
349
+ design=None,
350
+ )
351
+
352
+ self.estimator.fit(resampled)
353
+ return float(self.estimator.estimate().ate)
354
+
355
+ def _bca_interval(
356
+ self,
357
+ assignment: CRDAssignment | BlockedAssignment,
358
+ distribution: np.ndarray,
359
+ observed: float,
360
+ ) -> tuple[float, float, float, float]:
361
+ """Compute the BCa interval bounds, bias-correction, and acceleration."""
362
+ # Bias-correction z0 from the fraction of resamples below observed.
363
+ prop_less = float(np.mean(distribution < observed))
364
+ if prop_less <= 0.0 or prop_less >= 1.0:
365
+ raise InvalidDesignError(
366
+ "BootstrapCI: the BCa bias-correction is undefined because "
367
+ "no (or every) bootstrap estimate falls below the observed "
368
+ "estimate. This usually means a degenerate or tiny sample. "
369
+ "Use method='percentile' instead."
370
+ )
371
+ z0 = float(norm.ppf(prop_less))
372
+
373
+ # Acceleration via the leave-one-out jackknife.
374
+ n = assignment.n_units_
375
+ all_idx = np.arange(n)
376
+ jack = np.empty(n, dtype=float)
377
+ for i in range(n):
378
+ jack[i] = self._compute_statistic(
379
+ assignment, np.delete(all_idx, i)
380
+ )
381
+ jack_mean = jack.mean()
382
+ diffs = jack_mean - jack
383
+ denom = 6.0 * float(np.sum(diffs**2)) ** 1.5
384
+ accel = 0.0 if denom == 0.0 else float(np.sum(diffs**3)) / denom
385
+
386
+ z_lo = norm.ppf(self.alpha / 2.0)
387
+ z_hi = norm.ppf(1.0 - self.alpha / 2.0)
388
+
389
+ def _adjust(z: float) -> float:
390
+ denom_z = 1.0 - accel * (z0 + z)
391
+ return float(norm.cdf(z0 + (z0 + z) / denom_z))
392
+
393
+ p_lo = _adjust(z_lo)
394
+ p_hi = _adjust(z_hi)
395
+
396
+ if not (np.isfinite(p_lo) and np.isfinite(p_hi)):
397
+ raise InvalidDesignError(
398
+ "BootstrapCI: the BCa adjustment produced non-finite "
399
+ "percentiles (acceleration drove the denominator to zero). "
400
+ "Use method='percentile' instead."
401
+ )
402
+
403
+ lo, hi = np.quantile(distribution, sorted([p_lo, p_hi]))
404
+ return float(lo), float(hi), z0, accel
405
+
406
+ def estimate(self) -> Results:
407
+ """Return a Results object with the ATE, SE, CI, and p-value.
408
+
409
+ Returns
410
+ -------
411
+ Results
412
+ Results with ``ate`` (observed), ``se`` (bootstrap standard
413
+ error), ``ci`` (percentile or BCa bounds), ``p_value``
414
+ (two-sided achieved significance level), ``alpha``,
415
+ ``inference_name="BootstrapCI"``, and ``extra`` containing
416
+ ``method``, ``n_resamples``, ``bootstrap_distribution``,
417
+ ``inference_mode="superpopulation"`` and, for BCa,
418
+ ``bias_correction`` and ``acceleration``.
419
+
420
+ Raises
421
+ ------
422
+ NotFittedError
423
+ If ``fit`` has not been called.
424
+ """
425
+ self._check_is_fitted()
426
+
427
+ extra: dict = {
428
+ "method": self.method,
429
+ "n_resamples": self.n_resamples,
430
+ "bootstrap_distribution": self.bootstrap_distribution_,
431
+ "inference_mode": "superpopulation",
432
+ }
433
+ if self.method == "bca":
434
+ extra["bias_correction"] = self._bias_correction
435
+ extra["acceleration"] = self._acceleration
436
+
437
+ return Results(
438
+ ate=self.observed_statistic_,
439
+ se=self.se_,
440
+ ci=self.ci_,
441
+ p_value=self.p_value_,
442
+ alpha=self.alpha,
443
+ n_obs=self._n_obs,
444
+ n_treated=self._n_treated,
445
+ n_control=self._n_control,
446
+ estimator_name=self._estimator_name,
447
+ design_name=self._design_name,
448
+ inference_name=type(self).__name__,
449
+ extra=extra,
450
+ )