Skip to content

Commit 7a26980

Browse files
author
bgeurten
committed
feat(resampling): make the null distribution reproducible
GetNofK drew its index sets from the global `random` module, so there was no way to reproduce a p-value and no way to seed one test without disturbing whatever else the caller was drawing from the same stream. It now owns a private random.Random, and `seed` threads down from FisherResamplingTest and MultiGroupTest. Random resampling covers only a finite sample of the permutation space, so two unseeded runs of the same comparison return slightly different p-values. Measured spread on a 20 000-draw medianDiff test is about 0.002 — small, but enough to move a borderline result across alpha between runs, which is not a property a reported p-value should have. seed=None remains the default and keeps the previous behaviour exactly, so nothing downstream changes unless it opts in. Exhaustive mode (combination_n='all') was always deterministic and is unaffected.
1 parent 3f42b93 commit 7a26980

3 files changed

Lines changed: 32 additions & 4 deletions

File tree

rerandomstats/fisher_resampling.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ class FisherResamplingTest:
3535
``'medianDiff'``, or ``'sumDiff'``.
3636
combination_n: ``'all'`` for exhaustive permutation or an
3737
integer for random resampling draws.
38+
seed: Seed for the resampling draws. ``None`` (the default)
39+
keeps the historical behaviour, in which repeated runs of
40+
the same comparison give slightly different p-values —
41+
random resampling covers only a finite sample of the
42+
permutation space. Pass an integer when the p-value goes
43+
into a manuscript and has to be reproducible; exhaustive
44+
mode (``combination_n='all'``) is deterministic regardless.
3845
3946
Attributes:
4047
p_value: Two-sided p-value after :meth:`main` has been called.
@@ -54,11 +61,13 @@ def __init__(
5461
data_b: Sequence[float],
5562
func: Literal["meanDiff", "medianDiff", "sumDiff"],
5663
combination_n: Union[int, str] = 10_000,
64+
seed: int | None = None,
5765
) -> None:
5866
self.data_a = data_a
5967
self.data_b = data_b
6068
self.func = func
6169
self.combination_n = combination_n
70+
self.seed = seed
6271

6372
self.p_value: float | None = None
6473
self.original_test_result: float | None = None
@@ -70,7 +79,8 @@ def __init__(
7079

7180
def get_shuffled_indices(self) -> None:
7281
"""Build the combinatorial index sets via :class:`GetNofK`."""
73-
self.n_of_k = GetNofK(self.data_a, self.data_b, self.combination_n)
82+
self.n_of_k = GetNofK(self.data_a, self.data_b, self.combination_n,
83+
seed=self.seed)
7484
self.n_of_k.main()
7585
self.resample_n = self.n_of_k.combination_n
7686

rerandomstats/multi_group_test.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class MultiGroupTest:
5757
:func:`statsmodels.stats.multipletests`.
5858
combination_set: Optional list of ``(groupA, groupB)`` tuples
5959
restricting which pairs are tested.
60+
seed: Passed to :class:`FisherResamplingTest` so a whole
61+
pairwise sweep is reproducible. ``None`` (the default)
62+
keeps the historical run-to-run variation.
6063
6164
Attributes:
6265
df: :class:`pandas.DataFrame` with results (available after
@@ -80,13 +83,15 @@ def __init__(
8083
combination_n: Union[int, str] = 10_000,
8184
correction_type: str = "fdr_bh",
8285
combination_set: Sequence[Tuple[str, str]] = (),
86+
seed: int | None = None,
8387
) -> None:
8488
self.data = data
8589
self.group = group
8690
self.test = test
8791
self.correction_type = correction_type
8892
self.combination_n = combination_n
8993
self.combination_set = combination_set
94+
self.seed = seed
9095

9196
# populated by main()
9297
self.group_names: Tuple[str, ...] = ()
@@ -215,7 +220,8 @@ def _choose_test(self):
215220
if family == "Fisher":
216221
if name == "exact":
217222
return FisherExactTest((), ())
218-
return FisherResamplingTest([], [], name, self.combination_n)
223+
return FisherResamplingTest([], [], name, self.combination_n,
224+
seed=self.seed)
219225
elif family == "Binomial":
220226
return MultipleBinomialTests((), (), name)
221227
elif family == "hypo":

rerandomstats/resample_n_of_k.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ class GetNofK:
3838
length, exhaustive mode falls back to resampling.
3939
resampling_n: Default number of random draws when
4040
*combination_n* triggers resampling mode.
41+
seed: Seed for this instance's private random generator.
42+
``None`` (the default) seeds from OS entropy, so results
43+
vary between runs exactly as before. Pass an integer when
44+
a reported p-value has to be reproducible — resampling
45+
draws a finite sample of the permutation space, so two
46+
unseeded runs of the same comparison differ slightly.
4147
4248
Attributes:
4349
combined_data: Concatenation of both data sets.
@@ -54,7 +60,13 @@ def __init__(
5460
mode: Literal["combinations", "resampling", "resample_unique"] = "resampling",
5561
max_len_possible_4_perms: int = 10,
5662
resampling_n: int = 100_000,
63+
seed: int | None = None,
5764
) -> None:
65+
# A private generator rather than the global `random` module, so a
66+
# seeded test cannot be perturbed by unrelated code drawing from the
67+
# shared stream, and seeding here cannot disturb the caller's.
68+
self.seed = seed
69+
self._rng = random.Random(seed)
5870
self.data_set_a: List = list(data_set_a)
5971
self.data_set_b: List = list(data_set_b)
6072
self.combination_n = combination_n
@@ -118,7 +130,7 @@ def get_unique_random_combinations(self) -> List[Tuple[int, ...]]:
118130
tries = 0
119131
desperation = False
120132
while len(combis) < self.resampling_n and not desperation:
121-
combis.add(tuple(sorted(random.sample(all_indices, self.short_len))))
133+
combis.add(tuple(sorted(self._rng.sample(all_indices, self.short_len))))
122134
tries += 1
123135
if tries > self.resampling_n * 10:
124136
desperation = True
@@ -136,7 +148,7 @@ def get_random_combinations(self) -> List[Tuple[int, ...]]:
136148
"""
137149
all_indices = list(range(self.combined_len))
138150
return [
139-
tuple(sorted(random.sample(all_indices, self.short_len)))
151+
tuple(sorted(self._rng.sample(all_indices, self.short_len)))
140152
for _ in range(self.resampling_n)
141153
]
142154

0 commit comments

Comments
 (0)