Skip to content

Commit 27953e2

Browse files
committed
feat: add multiple testing methods (Holm, Sidak, Benjamini-Yekutelli)
feat: add multiple testing methods (Holm, Sidak, Benjamini-Yekutelli) Added Holm, Sidak-Holm and Benjamini-Yekutieli methods
1 parent 2939868 commit 27953e2

File tree

7 files changed

+2056
-9
lines changed

7 files changed

+2056
-9
lines changed

poetry.lock

Lines changed: 1782 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .fwer import Holm
2+
from .fdr import BenjaminiYekutieli
Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
11
from abc import ABC, abstractmethod
2-
3-
42
class AbstractMultipleTesting(ABC):
53
@staticmethod
6-
@abstractmethod
7-
def test(p_values: list[float], threshold: float) -> tuple[list[bool], list[float]]:
4+
def _validate_p_values(p_values: list[float]) -> None:
5+
"""
6+
Validate that all p-values are in [0,1] range.
7+
:param p_values: List of p-values for hypothesis testing
8+
"""
9+
if not all(0 <= x <= 1 for x in p_values):
10+
raise ValueError("All p-values must be in range [0,1].")
11+
12+
@classmethod
13+
def test(cls, p_values: list[float], threshold: float = 0.05) -> tuple[list[bool], list[float]]:
814
"""
915
Perform multiple testing correction and return both rejection decisions
1016
and adjusted p-values.
1117
:param p_values: List of raw p-values for hypothesis testing
1218
:param threshold: Significance level for controlling FWER (Family-Wise Error Rate)
13-
or FDR (False Discovery Rate)
19+
or FDR (False Discovery Rate) (default is 0.05)
1420
:return: Tuple containing:
1521
- Boolean list indicating rejected hypotheses (True where rejected)
1622
- List of adjusted p-values after multiple testing correction
1723
"""
18-
raise NotImplementedError("Method is not implemented")
24+
cls._validate_p_values(p_values)
25+
p_values_adjusted = cls.adjust(p_values)
26+
rejected = [p_value < threshold for p_value in p_values_adjusted]
27+
return rejected, p_values_adjusted
1928

20-
@staticmethod
29+
@classmethod
2130
@abstractmethod
22-
def adjust(p_values: list[float]) -> list[float]:
31+
def adjust(cls, p_values: list[float]) -> list[float]:
2332
"""
2433
Compute adjusted p-values for multiple testing correction.
2534
:param p_values: List of raw p-values for hypothesis testing
26-
:return: List of adjusted p-values after multiple testing correction
35+
:return: List of adjusted p-values after multiple testing correction
2736
"""
2837
raise NotImplementedError("Method is not implemented")
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from .abstract_multiple_testing import AbstractMultipleTesting
2+
import math
3+
4+
class BenjaminiYekutieli(AbstractMultipleTesting):
5+
"""
6+
Adjust p-values using Benjamini-Yekutieli correction for controlling FDR.
7+
8+
This method controls the false discovery rate (FDR) under arbitrary dependence.
9+
It uses a harmonic series correction factor.
10+
11+
Steps:
12+
1. Compute harmonic factor c = Σ(1/i) for i=1 to n
13+
2. Sort p-values and calculate adjusted values: p_adjusted[i] = p[i] * n * c / rank
14+
3. Enforce monotonicity using step-up procedure
15+
16+
Parameters
17+
----------
18+
p_values : list[float]
19+
List of raw p-values between 0 and 1
20+
21+
Returns
22+
-------
23+
list[float]
24+
Adjusted p-values in original order
25+
"""
26+
27+
@classmethod
28+
def adjust(cls, p_values: list[float]) -> list[float]:
29+
n = len(p_values)
30+
if n == 0:
31+
return []
32+
33+
if n > 10000:
34+
c = math.log(n) + 0.5772156649015328606065120900824024310421
35+
else:
36+
c = sum(1.0 / i for i in range(1, n + 1))
37+
38+
sorted_indices = sorted(range(n), key=lambda i: p_values[i])
39+
adjusted = [0.0] * n
40+
41+
for i, idx in enumerate(sorted_indices):
42+
rank = i + 1
43+
adjusted_value = p_values[idx] * n * c / rank
44+
adjusted[idx] = min(1.0, adjusted_value)
45+
46+
current_min = adjusted[sorted_indices[-1]]
47+
for i in range(n - 2, -1, -1):
48+
idx = sorted_indices[i]
49+
if adjusted[idx] > current_min:
50+
adjusted[idx] = current_min
51+
else:
52+
current_min = adjusted[idx]
53+
54+
return adjusted
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from .abstract_multiple_testing import AbstractMultipleTesting
2+
3+
class Holm(AbstractMultipleTesting):
4+
"""
5+
Adjust p-values using the Holm-Bonferroni method for multiple testing correction.
6+
7+
This method controls the family-wise error rate (FWER) and is more powerful than
8+
the standard Bonferroni correction. It uses a step-down procedure.
9+
10+
Steps:
11+
1. Sort p-values and keep original indices
12+
2. Calculate adjusted p-values: p_adjusted[i] = p[i] * (n - i)
13+
3. Enforce monotonicity using step-up procedure
14+
15+
Parameters
16+
----------
17+
p_values : list[float]
18+
List of raw p-values between 0 and 1
19+
20+
Returns
21+
-------
22+
list[float]
23+
Adjusted p-values in original order
24+
"""
25+
26+
@classmethod
27+
def adjust(cls, p_values: list[float]) -> list[float]:
28+
n = len(p_values)
29+
if n == 0:
30+
return []
31+
32+
sorted_indices = sorted(range(n), key=lambda i: p_values[i])
33+
adjusted = [0.0] * n
34+
35+
for i, idx in enumerate(sorted_indices):
36+
adjusted_value = p_values[idx] * (n - i)
37+
adjusted[idx] = min(1.0, adjusted_value)
38+
39+
for i in range(n - 2, -1, -1):
40+
if adjusted[sorted_indices[i]] > adjusted[sorted_indices[i + 1]]:
41+
adjusted[sorted_indices[i]] = adjusted[sorted_indices[i + 1]]
42+
43+
return adjusted
44+
45+
46+
class SidakHolm(AbstractMultipleTesting):
47+
@classmethod
48+
def adjust(cls, p_values: list[float]) -> list[float]:
49+
"""
50+
Adjust p-values using the Šidák correction for multiple hypothesis testing.
51+
52+
Parameters
53+
----------
54+
p_values : list[float]
55+
List of raw p-values for hypothesis testing. Must be in range [0, 1].
56+
57+
Returns
58+
-------
59+
list[float]
60+
List of adjusted p-values, each in range [0, 1].
61+
"""
62+
n = len(p_values)
63+
if n == 0:
64+
return []
65+
return [min(1.0, 1 - (1 - p) ** n) for p in p_values]

tests/multiple_testing/test_fdr.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
from pysatl_criterion.multiple_testing.fdr import BenjaminiYekutieli
3+
4+
def test_benjamini_yekutieli_complex():
5+
p_values = [0.001, 0.002, 0.01, 0.03, 0.04, 0.1, 0.15, 0.2, 0.25, 0.5]
6+
alpha = 0.05
7+
8+
rejected, _ = BenjaminiYekutieli.test(p_values, alpha)
9+
10+
assert rejected == [True, True, False, False, False, False, False, False, False, False]
11+
12+
13+
def test_benjamini_yekutieli_adjust_complex():
14+
p_values = [0.001, 0.005, 0.05, 0.1, 0.15]
15+
adjusted = BenjaminiYekutieli.adjust(p_values)
16+
17+
n = 5
18+
c = sum(1.0 / i for i in range(1, n + 1))
19+
sorted_p = sorted(p_values)
20+
expected = [
21+
min(1.0, sorted_p[0] * n * c / 1),
22+
min(1.0, sorted_p[1] * n * c / 2),
23+
min(1.0, sorted_p[2] * n * c / 3),
24+
min(1.0, sorted_p[3] * n * c / 4),
25+
min(1.0, sorted_p[4] * n * c / 5)
26+
]
27+
28+
for i in range(n - 2, -1, -1):
29+
if expected[i] > expected[i + 1]:
30+
expected[i] = expected[i + 1]
31+
32+
assert adjusted == pytest.approx(expected, abs=1e-4)
33+
34+
35+
def test_benjamini_yekutieli_edge_cases():
36+
p_values = [0.00001, 0.99999]
37+
rejected, _ = BenjaminiYekutieli.test(p_values, 0.05)
38+
assert rejected == [True, False]
39+
40+
41+
def test_benjamini_yekutieli_large_input():
42+
import numpy as np
43+
np.random.seed(42)
44+
p_values = np.random.rand(1000).tolist()
45+
46+
rejected, adjusted = BenjaminiYekutieli.test(p_values, 0.05)
47+
48+
assert len(rejected) == 1000
49+
assert len(adjusted) == 1000
50+
assert all(0 <= p <= 1 for p in adjusted)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
from pysatl_criterion.multiple_testing.fwer import Holm, SidakHolm
3+
4+
def test_holm_correction():
5+
p_values = [0.04, 0.001, 0.7, 0.02]
6+
alpha = 0.05
7+
8+
rejected, adjusted = Holm.test(p_values, alpha)
9+
10+
expected_adjusted = [0.08, 0.004, 0.7, 0.06]
11+
12+
assert rejected == [False, True, False, False]
13+
assert adjusted == pytest.approx(expected_adjusted, abs=1e-3)
14+
15+
16+
def test_holm_adjust():
17+
p_values = [0.01, 0.04, 0.03]
18+
19+
expected = [0.03, 0.04, 0.04]
20+
21+
adjusted = Holm.adjust(p_values)
22+
assert adjusted == pytest.approx(expected, abs=1e-3)
23+
24+
25+
def test_holm_with_early_stop():
26+
p_values = [0.1, 0.2, 0.3]
27+
alpha = 0.05
28+
29+
rejected, adjusted = Holm.test(p_values, alpha)
30+
31+
expected_adjusted = [0.3, 0.3, 0.3]
32+
33+
assert rejected == [False, False, False]
34+
assert adjusted == pytest.approx(expected_adjusted, abs=1e-3)
35+
36+
37+
def test_holm_all_rejected():
38+
p_values = [0.001, 0.002, 0.003]
39+
alpha = 0.05
40+
41+
rejected, adjusted = Holm.test(p_values, alpha)
42+
43+
expected_adjusted = [0.003, 0.003, 0.003]
44+
45+
assert rejected == [True, True, True]
46+
assert adjusted == pytest.approx(expected_adjusted, abs=1e-3)
47+
48+
49+
def test_sidak_correction():
50+
p_values = [0.01, 0.02, 0.1]
51+
alpha = 0.05
52+
53+
rejected, adjusted = SidakHolm.test(p_values, alpha)
54+
55+
assert rejected == [True, False, False]
56+
57+
expected = [
58+
1 - (1 - 0.01) ** 3,
59+
1 - (1 - 0.02) ** 3,
60+
1 - (1 - 0.1) ** 3
61+
]
62+
assert adjusted == pytest.approx(expected, abs=1e-10)
63+
64+
65+
def test_sidak_edge_cases():
66+
p_values = [0.0, 0.0, 0.0]
67+
rejected, adjusted = SidakHolm.test(p_values, 0.05001)
68+
assert adjusted == [0.0, 0.0, 0.0]
69+
assert all(rejected)
70+
71+
p_values = [1.0, 1.0, 1.0]
72+
rejected, adjusted = SidakHolm.test(p_values, 0.05001)
73+
assert adjusted == [1.0, 1.0, 1.0]
74+
assert not any(rejected)
75+
76+
p_values = [0.05]
77+
rejected, adjusted = SidakHolm.test(p_values, 0.05001)
78+
assert adjusted == pytest.approx([0.05], abs=1e-10)
79+
assert rejected == [True]
80+
81+
p_values = [0.05, 0.1, 0.15]
82+
n = len(p_values)
83+
expected = [1 - (1 - p) ** n for p in p_values]
84+
rejected, adjusted = SidakHolm.test(p_values, 0.05001)
85+
assert adjusted == pytest.approx(expected, abs=1e-10)

0 commit comments

Comments
 (0)