Skip to content

Commit ae1a11a

Browse files
authored
Merge pull request #374 from fslaborg/repo-assist/improve-dunn-sidak-20260412-ff9a8aac-3956323be622a182
[Repo Assist] feat: add dunnSidakFWER and holmSidakFWER corrections to MultipleTesting
2 parents 49ea43c + 6a7945d commit ae1a11a

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

‎src/FSharp.Stats/Testing/MultipleTesting.fs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,52 @@ module MultipleTesting =
7878
result
7979

8080

81+
/// Šidák single-step FWER adjustment (NaN-safe).
82+
/// Each p-value is adjusted independently as p_adj = 1 - (1 - p)^m, where m is the number of
83+
/// non-NaN tests. This is slightly less conservative than Bonferroni.
84+
/// NaN p-values are preserved at their original indices.
85+
let inline dunnSidakFWER (p : float[]) : float[] =
86+
let total = p.Length
87+
let indexed = p |> Array.mapi (fun i v -> i, v)
88+
let valid = indexed |> Array.filter (fun (_, v) -> not (Double.IsNaN v))
89+
let m = valid.Length
90+
let result = Array.create total Double.NaN
91+
if m = 0 then result else
92+
valid |> Array.iter (fun (i, pv) ->
93+
result.[i] <- min 1.0 (1.0 - (1.0 - pv) ** float m))
94+
result
95+
96+
/// Holm–Šidák (step-down) FWER adjustment (NaN-safe).
97+
/// Uses the Šidák formula at each step of the Holm procedure, giving a slightly more
98+
/// powerful test than Holm–Bonferroni while still controlling the FWER.
99+
/// NaN p-values are ignored in the computation and preserved at their original indices.
100+
let inline holmSidakFWER (p : float[]) : float[] =
101+
let total = p.Length
102+
let indexed = p |> Array.mapi (fun i v -> i, v)
103+
let valid = indexed |> Array.filter (fun (_, v) -> not (Double.IsNaN v))
104+
let m = valid.Length
105+
let result = Array.create total Double.NaN
106+
if m = 0 then result else
107+
let sorted = valid |> Array.sortBy snd
108+
109+
// raw[i] = 1 - (1 - p_(i+1))^(m-i) for i = 0..m-1 (rank in 1-indexed sorted order = i+1)
110+
let raw =
111+
sorted
112+
|> Array.mapi (fun i (_, pv) -> 1.0 - (1.0 - pv) ** float (m - i))
113+
114+
// running max from the left, capped at 1.0
115+
let adjAsc =
116+
raw
117+
|> Array.scan (fun runningMax r -> max runningMax r) 0.0
118+
|> Array.tail
119+
|> Array.map (min 1.0)
120+
121+
Array.zip (sorted |> Array.map fst) adjAsc
122+
|> Array.iter (fun (i, adj) -> result.[i] <- adj)
123+
124+
result
125+
126+
81127
/// Benjamini-Hochberg Correction (BH)
82128
/// 'projection' should return a tuple of any identifier and the pValues as float, when applied to 'rawP'
83129
/// This function applies the Benjamini-Hochberg multiple testing correcture and returns all False Discovery Rates to which the given p-values are still

‎tests/FSharp.Stats.Tests/Testing.fs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,50 @@ let hochbergTests =
497497

498498
]
499499

500+
[<Tests>]
501+
let dunnSidakTests =
502+
// p = [0.01; 0.04; 0.03; 0.1; 0.5], m=5
503+
// expected values verified with Python: 1 - (1-p)^m
504+
let pValues = [| 0.01; 0.04; 0.03; 0.1; 0.5 |]
505+
let pNaN = [| 0.01; nan; 0.03; 0.1; 0.5 |]
506+
507+
testList "Testing.MultipleTesting.DunnSidak" [
508+
testCase "singleStepBasic" <| fun () ->
509+
let result = MultipleTesting.dunnSidakFWER pValues
510+
// 1 - (1-p)^5 for each p
511+
let expected = [| 0.049010; 0.184627; 0.141266; 0.40951; 0.96875 |]
512+
Array.iter2 (fun r e ->
513+
Expect.floatClose Accuracy.low r e "Single-step Šidák adjusted p-values should match."
514+
) result expected
515+
testCase "singleStepNaN" <| fun () ->
516+
let result = MultipleTesting.dunnSidakFWER pNaN
517+
// m=4 valid values; 1 - (1-p)^4
518+
Expect.floatClose Accuracy.low result.[0] 0.039404 "p[0] with NaN should match."
519+
Expect.isTrue (Double.IsNaN result.[1]) "NaN position should remain NaN."
520+
Expect.floatClose Accuracy.low result.[2] 0.114707 "p[2] with NaN should match."
521+
Expect.floatClose Accuracy.low result.[3] 0.34390 "p[3] with NaN should match."
522+
Expect.floatClose Accuracy.low result.[4] 0.93750 "p[4] with NaN should match."
523+
testCase "holmSidakBasic" <| fun () ->
524+
let result = MultipleTesting.holmSidakFWER pValues
525+
// sorted p: [0.01, 0.03, 0.04, 0.1, 0.5]
526+
// raw: [1-0.99^5, 1-0.97^4, 1-0.96^3, 1-0.9^2, 1-0.5^1]
527+
// running max (already monotone): [0.04901, 0.11471, 0.11526, 0.19, 0.5]
528+
// back to original order: [0.04901, 0.11526, 0.11471, 0.19, 0.5]
529+
let expected = [| 0.049010; 0.115264; 0.114707; 0.19; 0.5 |]
530+
Array.iter2 (fun r e ->
531+
Expect.floatClose Accuracy.low r e "Holm–Šidák adjusted p-values should match."
532+
) result expected
533+
testCase "holmSidakNaN" <| fun () ->
534+
let result = MultipleTesting.holmSidakFWER pNaN
535+
// m=4 valid values; sorted: [0.01, 0.03, 0.1, 0.5]
536+
// raw: [1-0.99^4, 1-0.97^3, 1-0.9^2, 1-0.5^1] = [0.039404, 0.087327, 0.19, 0.5]
537+
Expect.floatClose Accuracy.low result.[0] 0.039404 "p[0] Holm-Šidák NaN"
538+
Expect.isTrue (Double.IsNaN result.[1]) "NaN position should remain NaN."
539+
Expect.floatClose Accuracy.low result.[2] 0.087327 "p[2] Holm-Šidák NaN"
540+
Expect.floatClose Accuracy.low result.[3] 0.19000 "p[3] Holm-Šidák NaN"
541+
Expect.floatClose Accuracy.low result.[4] 0.50000 "p[4] Holm-Šidák NaN"
542+
]
543+
500544
[<Tests>]
501545
let benjaminiHochbergTests =
502546

0 commit comments

Comments
 (0)