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,365 @@
1
+ """Multiple testing correction for p-values from estimators or inference.
2
+
3
+ Implements ``MultipleTestingCorrection``, a utility class that applies
4
+ Bonferroni, Holm, or Benjamini-Hochberg correction to a family of
5
+ p-values. Accepts either a multi-effect ``Results`` (typical output of
6
+ ``FactorialEstimator`` after inference) or a list of scalar ``Results``
7
+ (typical when comparing multiple independent experiments).
8
+
9
+ The correction is purely post-processing: ``correct()`` produces new
10
+ ``Results`` objects with adjusted ``p_value`` and ``alpha`` (set to
11
+ ``self.alpha``); ``effects``, ``ate``, ``se``, and ``ci`` are preserved
12
+ unchanged.
13
+
14
+ References
15
+ ----------
16
+ Bonferroni, C. E. (1936). Teoria statistica delle classi e calcolo delle
17
+ probabilità. Pubblicazioni del R Istituto Superiore di Scienze
18
+ Economiche e Commerciali di Firenze.
19
+ Holm, S. (1979). A simple sequentially rejective multiple test
20
+ procedure. Scandinavian Journal of Statistics, 6(2), 65-70.
21
+ Benjamini, Y., & Hochberg, Y. (1995). Controlling the false discovery
22
+ rate: a practical and powerful approach to multiple testing. Journal
23
+ of the Royal Statistical Society: Series B, 57(1), 289-300.
24
+ """
25
+
26
+ import numpy as np
27
+
28
+ from skxperiments.core.exceptions import InvalidDesignError
29
+ from skxperiments.core.results import Results
30
+
31
+
32
+ def _apply_correction(
33
+ p_values: np.ndarray,
34
+ method: str,
35
+ m: int,
36
+ ) -> np.ndarray:
37
+ """Apply the specified correction to a flat array of p-values.
38
+
39
+ Parameters
40
+ ----------
41
+ p_values : np.ndarray
42
+ Array of raw p-values, shape (m,).
43
+ method : str
44
+ One of "bonferroni", "holm", "bh".
45
+ m : int
46
+ Family size. Equal to len(p_values), passed explicitly for
47
+ clarity.
48
+
49
+ Returns
50
+ -------
51
+ np.ndarray
52
+ Corrected p-values, clipped to [0, 1], in the same order as
53
+ the input.
54
+ """
55
+ if method == "bonferroni":
56
+ return np.clip(p_values * m, 0.0, 1.0)
57
+
58
+ # Holm and BH both need sort-then-unsort.
59
+ order = np.argsort(p_values)
60
+ p_sorted = p_values[order]
61
+
62
+ if method == "holm":
63
+ # p_holm[i] = p_sorted[i] * (m - i), then enforce
64
+ # non-decreasing monotonicity (cumulative max).
65
+ multipliers = m - np.arange(m)
66
+ p_adj_sorted = p_sorted * multipliers
67
+ p_adj_sorted = np.maximum.accumulate(p_adj_sorted)
68
+ elif method == "bh":
69
+ # p_bh[i] = p_sorted[i] * m / (i + 1), then enforce
70
+ # non-increasing monotonicity from the right (cumulative min
71
+ # reversed).
72
+ ranks = np.arange(1, m + 1)
73
+ p_adj_sorted = p_sorted * m / ranks
74
+ # Reverse cumulative min.
75
+ p_adj_sorted = np.minimum.accumulate(p_adj_sorted[::-1])[::-1]
76
+ else:
77
+ # Should be unreachable given __init__ validation.
78
+ raise InvalidDesignError(
79
+ f"Unknown correction method: {method!r}."
80
+ )
81
+
82
+ p_adj_sorted = np.clip(p_adj_sorted, 0.0, 1.0)
83
+
84
+ # Reorder to original positions.
85
+ p_corrected = np.empty_like(p_adj_sorted)
86
+ p_corrected[order] = p_adj_sorted
87
+ return p_corrected
88
+
89
+
90
+ class MultipleTestingCorrection:
91
+ """Apply multiple-testing correction to a family of p-values.
92
+
93
+ Accepts a multi-effect ``Results`` (with ``effects: dict`` and
94
+ ``p_value: dict``) or a list of scalar ``Results`` (each with
95
+ ``ate: float`` and ``p_value: float``). Returns the same format,
96
+ with corrected p-values, ``alpha`` set to ``self.alpha``, and
97
+ family-level metadata recorded in ``Results.extra``.
98
+
99
+ Parameters
100
+ ----------
101
+ method : {"bonferroni", "holm", "bh"}, optional
102
+ Correction method, by default ``"holm"``.
103
+
104
+ - ``"bonferroni"`` and ``"holm"`` control the **family-wise
105
+ error rate** (FWER): the probability of at least one false
106
+ positive across the family. Holm uniformly dominates
107
+ Bonferroni in power and is the recommended default.
108
+ - ``"bh"`` (Benjamini-Hochberg) controls the **false discovery
109
+ rate** (FDR): the expected proportion of false positives
110
+ among rejections. FDR is a fundamentally different criterion
111
+ from FWER; choose consciously based on the inferential goal.
112
+
113
+ alpha : float, optional
114
+ Family-wise alpha level, by default 0.05. Must be in (0, 1).
115
+ Overrides the ``alpha`` of input ``Results`` objects in the
116
+ output.
117
+
118
+ Notes
119
+ -----
120
+ Reserved keys written to ``Results.extra`` (see ``Results``
121
+ docstring for the full schema):
122
+
123
+ - ``"correction_method"``
124
+ - ``"original_p_values"`` (dict in multi-effect mode, list in
125
+ scalar-list mode)
126
+ - ``"family_wise_alpha"``
127
+ - ``"n_tests"``
128
+
129
+ Applying ``correct()`` twice to the same ``Results`` raises
130
+ ``InvalidDesignError``: the presence of any of the four reserved
131
+ keys in ``extra`` is treated as evidence of a prior correction.
132
+ Apply correction to the original (uncorrected) ``Results`` instead.
133
+
134
+ The correction does not touch ``effects``, ``ate``, ``se``, or
135
+ ``ci``. Only ``p_value``, ``alpha``, and ``extra`` are modified
136
+ in the output.
137
+
138
+ Future work (v2)
139
+ ----------------
140
+ Benjamini-Yekutieli (``"by"``) for FDR under arbitrary dependence
141
+ is deferred to v2. See ``ROADMAP.md``.
142
+
143
+ Examples
144
+ --------
145
+ Multi-effect input (typical from FactorialEstimator):
146
+
147
+ >>> from skxperiments.core.results import Results
148
+ >>> r = Results(
149
+ ... effects={("A",): 0.5, ("B",): 0.3, ("A", "B"): 0.1},
150
+ ... p_value={("A",): 0.01, ("B",): 0.04, ("A", "B"): 0.20},
151
+ ... )
152
+ >>> mtc = MultipleTestingCorrection(method="holm", alpha=0.05)
153
+ >>> corrected = mtc.correct(r) # doctest: +SKIP
154
+ >>> corrected.p_value # doctest: +SKIP
155
+
156
+ Scalar-list input (typical when comparing experiments):
157
+
158
+ >>> r1 = Results(ate=0.5, p_value=0.01)
159
+ >>> r2 = Results(ate=0.3, p_value=0.04)
160
+ >>> r3 = Results(ate=0.1, p_value=0.20)
161
+ >>> corrected = mtc.correct([r1, r2, r3]) # doctest: +SKIP
162
+ """
163
+
164
+ _VALID_METHODS = ("bonferroni", "holm", "bh")
165
+ _RESERVED_KEYS = (
166
+ "correction_method",
167
+ "original_p_values",
168
+ "family_wise_alpha",
169
+ "n_tests",
170
+ )
171
+
172
+ def __init__(
173
+ self,
174
+ method: str = "holm",
175
+ alpha: float = 0.05,
176
+ ) -> None:
177
+ if method not in self._VALID_METHODS:
178
+ raise InvalidDesignError(
179
+ f"method must be one of {self._VALID_METHODS}, got "
180
+ f"{method!r}."
181
+ )
182
+
183
+ if not isinstance(alpha, (int, float)) or isinstance(alpha, bool):
184
+ raise InvalidDesignError(
185
+ f"alpha must be a float, got {type(alpha).__name__}."
186
+ )
187
+ if not (0.0 < float(alpha) < 1.0):
188
+ raise InvalidDesignError(
189
+ f"alpha must be in (0, 1), got {alpha}."
190
+ )
191
+
192
+ self.method = method
193
+ self.alpha = float(alpha)
194
+
195
+ def correct(
196
+ self,
197
+ results: Results | list[Results],
198
+ ) -> Results | list[Results]:
199
+ """Apply the configured correction to a Results or list of Results.
200
+
201
+ Parameters
202
+ ----------
203
+ results : Results or list of Results
204
+ Multi-effect ``Results`` (with ``p_value: dict``) or list
205
+ of scalar ``Results`` (each with ``p_value: float``).
206
+
207
+ Returns
208
+ -------
209
+ Results or list of Results
210
+ Same format as input. Corrected p-values; ``alpha``
211
+ overridden by ``self.alpha``; ``extra`` populated with
212
+ family-level metadata.
213
+
214
+ Raises
215
+ ------
216
+ InvalidDesignError
217
+ If input format is invalid, p-values are missing, or any
218
+ of the reserved keys are already present in
219
+ ``Results.extra`` (indicating a prior correction).
220
+ """
221
+ if isinstance(results, list):
222
+ return self._correct_list(results)
223
+ return self._correct_multi_effect(results)
224
+
225
+ def _correct_multi_effect(self, results: Results) -> Results:
226
+ """Correct a single multi-effect Results."""
227
+ if results.effects is None:
228
+ raise InvalidDesignError(
229
+ "MultipleTestingCorrection on a single Results requires "
230
+ "multi-effect mode (Results.effects). For a list of "
231
+ "scalar Results, pass them as a list."
232
+ )
233
+
234
+ if results.p_value is None or not isinstance(results.p_value, dict):
235
+ raise InvalidDesignError(
236
+ "p_value dict is missing or not a dict; cannot correct."
237
+ )
238
+
239
+ self._check_no_reserved_keys(results.extra, location="results")
240
+
241
+ # Sort keys alphabetically for defensive determinism.
242
+ keys_sorted = sorted(results.p_value.keys())
243
+ p_array = np.array(
244
+ [results.p_value[k] for k in keys_sorted], dtype=float
245
+ )
246
+ m = len(p_array)
247
+
248
+ p_corrected = _apply_correction(p_array, self.method, m=m)
249
+
250
+ p_value_corrected = {
251
+ k: float(p) for k, p in zip(keys_sorted, p_corrected)
252
+ }
253
+
254
+ new_extra = dict(results.extra) if results.extra is not None else {}
255
+ new_extra["correction_method"] = self.method
256
+ new_extra["original_p_values"] = dict(results.p_value)
257
+ new_extra["family_wise_alpha"] = self.alpha
258
+ new_extra["n_tests"] = m
259
+
260
+ return Results(
261
+ effects=dict(results.effects),
262
+ p_value=p_value_corrected,
263
+ se=results.se,
264
+ ci=results.ci,
265
+ alpha=self.alpha,
266
+ n_obs=results.n_obs,
267
+ n_treated=results.n_treated,
268
+ n_control=results.n_control,
269
+ estimator_name=results.estimator_name,
270
+ design_name=results.design_name,
271
+ inference_name=results.inference_name,
272
+ extra=new_extra,
273
+ )
274
+
275
+ def _correct_list(self, results: list[Results]) -> list[Results]:
276
+ """Correct a list of scalar Results."""
277
+ if len(results) == 0:
278
+ raise InvalidDesignError(
279
+ "MultipleTestingCorrection received an empty list; "
280
+ "nothing to correct."
281
+ )
282
+
283
+ for i, r in enumerate(results):
284
+ if not isinstance(r, Results):
285
+ raise InvalidDesignError(
286
+ f"List element at index {i} is not a Results "
287
+ f"instance, got {type(r).__name__}."
288
+ )
289
+ if r.ate is None or r.effects is not None:
290
+ raise InvalidDesignError(
291
+ f"List element at index {i} is not in scalar mode "
292
+ f"(Results.ate must be set, Results.effects must "
293
+ f"be None). Mixed lists are not supported."
294
+ )
295
+ if r.p_value is None or not isinstance(r.p_value, (int, float)):
296
+ raise InvalidDesignError(
297
+ f"List element at index {i} has missing or "
298
+ f"non-scalar p_value; cannot correct."
299
+ )
300
+ self._check_no_reserved_keys(
301
+ r.extra, location=f"results[{i}]"
302
+ )
303
+
304
+ p_array = np.array([r.p_value for r in results], dtype=float)
305
+ m = len(p_array)
306
+
307
+ p_corrected = _apply_correction(p_array, self.method, m=m)
308
+
309
+ original_p_list = [float(p) for p in p_array]
310
+
311
+ out: list[Results] = []
312
+ for i, r in enumerate(results):
313
+ new_extra = dict(r.extra) if r.extra is not None else {}
314
+ new_extra["correction_method"] = self.method
315
+ new_extra["original_p_values"] = list(original_p_list)
316
+ new_extra["family_wise_alpha"] = self.alpha
317
+ new_extra["n_tests"] = m
318
+
319
+ out.append(
320
+ Results(
321
+ ate=r.ate,
322
+ p_value=float(p_corrected[i]),
323
+ se=r.se,
324
+ ci=r.ci,
325
+ alpha=self.alpha,
326
+ n_obs=r.n_obs,
327
+ n_treated=r.n_treated,
328
+ n_control=r.n_control,
329
+ estimator_name=r.estimator_name,
330
+ design_name=r.design_name,
331
+ inference_name=r.inference_name,
332
+ extra=new_extra,
333
+ )
334
+ )
335
+
336
+ return out
337
+
338
+ def _check_no_reserved_keys(
339
+ self,
340
+ extra: dict | None,
341
+ location: str,
342
+ ) -> None:
343
+ """Reject Results whose extra already contains reserved keys.
344
+
345
+ Detects an earlier correction (any of the 4 reserved keys
346
+ present) and refuses to apply correction a second time.
347
+
348
+ Parameters
349
+ ----------
350
+ extra : dict or None
351
+ The ``Results.extra`` dict to inspect.
352
+ location : str
353
+ Human-readable location string used in the error message
354
+ (e.g., "results" or "results[3]").
355
+ """
356
+ if extra is None:
357
+ return
358
+ present = [k for k in self._RESERVED_KEYS if k in extra]
359
+ if present:
360
+ raise InvalidDesignError(
361
+ f"{location}.extra already contains reserved key(s) "
362
+ f"{present!r}; cannot apply MultipleTestingCorrection "
363
+ f"twice. Apply correction to the original "
364
+ f"(uncorrected) Results."
365
+ )