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.
- skxperiments/__init__.py +5 -0
- skxperiments/core/__init__.py +42 -0
- skxperiments/core/assignment.py +589 -0
- skxperiments/core/base.py +512 -0
- skxperiments/core/exceptions.py +145 -0
- skxperiments/core/potential_outcomes.py +168 -0
- skxperiments/core/results.py +624 -0
- skxperiments/design/__init__.py +22 -0
- skxperiments/design/balance.py +182 -0
- skxperiments/design/blocked_crd.py +157 -0
- skxperiments/design/crd.py +162 -0
- skxperiments/design/factorial.py +174 -0
- skxperiments/design/power.py +233 -0
- skxperiments/design/rerandomized_crd.py +319 -0
- skxperiments/diagnostics/__init__.py +21 -0
- skxperiments/diagnostics/aa_test.py +277 -0
- skxperiments/diagnostics/balance_report.py +224 -0
- skxperiments/diagnostics/srm.py +327 -0
- skxperiments/estimators/__init__.py +23 -0
- skxperiments/estimators/blocked_difference_in_means.py +197 -0
- skxperiments/estimators/cuped.py +280 -0
- skxperiments/estimators/difference_in_means.py +161 -0
- skxperiments/estimators/factorial_estimator.py +213 -0
- skxperiments/estimators/lin_estimator.py +298 -0
- skxperiments/inference/__init__.py +17 -0
- skxperiments/inference/bootstrap.py +450 -0
- skxperiments/inference/multiple.py +365 -0
- skxperiments/inference/neyman.py +386 -0
- skxperiments/inference/randomization_test.py +319 -0
- skxperiments/pipeline.py +366 -0
- skxperiments/reporting/__init__.py +30 -0
- skxperiments/reporting/plots.py +411 -0
- skxperiments/reporting/summary.py +185 -0
- skxperiments-0.1.0.dev0.dist-info/METADATA +272 -0
- skxperiments-0.1.0.dev0.dist-info/RECORD +36 -0
- 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
|
+
)
|