Skip to content

Commit dba163a

Browse files
authored
Merge pull request #376 from fslaborg/repo-assist/improve-mannwhitney-20260415-b912a34050ffbff2
[Repo Assist] feat: add Mann-Whitney U test (Wilcoxon rank-sum) for two independent samples
2 parents c38f329 + 5169712 commit dba163a

3 files changed

Lines changed: 161 additions & 1 deletion

File tree

‎src/FSharp.Stats/FSharp.Stats.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
<Compile Include="Testing\Bartlett.fs" />
130130
<Compile Include="Testing\PostHoc.fs" />
131131
<Compile Include="Testing\Wilcoxon.fs" />
132+
<Compile Include="Testing\MannWhitney.fs" />
132133
<Compile Include="Testing\MultipleTesting.fs" />
133134
<Compile Include="Testing\Outliers.fs" />
134135
<Compile Include="Testing\SAM.fs" />
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
namespace FSharp.Stats.Testing
2+
3+
/// <summary>
4+
/// Mann-Whitney U test (also known as the Wilcoxon rank-sum test) for two independent samples.
5+
/// Uses a normal approximation, which is reliable for combined sample sizes ≥ 20.
6+
/// </summary>
7+
module MannWhitneyTest =
8+
9+
open FSharp.Stats
10+
11+
/// Result of a Mann-Whitney U test for two independent samples.
12+
type MannWhitneyTestStatistics =
13+
{
14+
/// U statistic for sample 1: number of pairs (x₁, x₂) where x₁ > x₂ (counting ties as 0.5).
15+
U1: float
16+
/// U statistic for sample 2: U1 + U2 = n₁ × n₂.
17+
U2: float
18+
/// Z-score from the normal approximation.
19+
Statistic: float
20+
/// One-sided p-value: P(sample 1 is stochastically less than sample 2).
21+
PValueLeft: float
22+
/// One-sided p-value: P(sample 1 is stochastically greater than sample 2).
23+
PValueRight: float
24+
/// Two-sided p-value.
25+
PValue: float
26+
}
27+
28+
/// <summary>
29+
/// Creates a Mann-Whitney U test for two independent samples using a normal approximation.
30+
/// </summary>
31+
/// <param name="sample1">First independent sample.</param>
32+
/// <param name="sample2">Second independent sample.</param>
33+
/// <param name="continuityCorrection">
34+
/// When true, applies a continuity correction (±0.5) to the z-score. Recommended for small samples.
35+
/// Default: true.
36+
/// </param>
37+
/// <remarks>
38+
/// The test is reliable for combined sample sizes ≥ 20. For smaller samples, exact p-values
39+
/// (not yet implemented) should be used instead of the normal approximation.
40+
///
41+
/// Ties are handled via the standard variance correction.
42+
///
43+
/// A large U1 (close to n₁×n₂) indicates that sample 1 tends to have larger values than sample 2,
44+
/// yielding a small PValueRight. Conversely, a small U1 yields a small PValueLeft.
45+
/// </remarks>
46+
let create (sample1: seq<float>) (sample2: seq<float>) (continuityCorrection: bool) : MannWhitneyTestStatistics =
47+
let arr1 = Seq.toArray sample1
48+
let arr2 = Seq.toArray sample2
49+
let n1 = float arr1.Length
50+
let n2 = float arr2.Length
51+
let bigN = n1 + n2
52+
53+
// Rank combined array; first n1 entries correspond to sample1
54+
let combined = Array.append arr1 arr2
55+
let ranks = Rank.RankAverage() combined
56+
57+
// Sum of ranks for sample 1
58+
let r1 = Array.sumBy (fun i -> ranks.[i]) [| 0 .. arr1.Length - 1 |]
59+
60+
// U statistics
61+
let u1 = r1 - n1 * (n1 + 1.) / 2.
62+
let u2 = n1 * n2 - u1
63+
64+
// Variance with tie correction
65+
let tieCorrection =
66+
ranks
67+
|> Array.countBy id
68+
|> Array.sumBy (fun (_, cnt) ->
69+
let t = float cnt
70+
t * t * t - t)
71+
72+
let mu = n1 * n2 / 2.
73+
let var =
74+
if bigN <= 1. then 0.
75+
else (n1 * n2 / (bigN * (bigN - 1.))) * ((bigN * bigN * bigN - bigN - tieCorrection) / 12.)
76+
77+
let sigma = sqrt var
78+
79+
// Z-score using U1 (positive z → sample1 tends to be larger)
80+
let z =
81+
if sigma = 0. then 0.
82+
else
83+
let cc =
84+
if not continuityCorrection then 0.
85+
elif u1 > mu then -0.5
86+
elif u1 < mu then 0.5
87+
else 0.
88+
(u1 - mu + cc) / sigma
89+
90+
let pLeft = Distributions.Continuous.Normal.CDF 0. 1. z
91+
let pRight = 1. - pLeft
92+
let pTwo = 2. * (min pLeft pRight)
93+
94+
{
95+
U1 = u1
96+
U2 = u2
97+
Statistic = z
98+
PValueLeft = pLeft
99+
PValueRight = pRight
100+
PValue = pTwo
101+
}

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1600,4 +1600,62 @@ let anovaTests =
16001600
// Expect.floatClose Accuracy.low (Math.Round (twoWayANOVARandom.Total.SumOfSquares,8)) 0.7815 "Total.SumOfSquares deviated from expected value"
16011601
// Expect.floatClose Accuracy.low (Math.Round (twoWayANOVARandom.Total.MeanSquares,8)) 0.04597 "Total.MeanSquares deviated from expected value"
16021602

1603-
]
1603+
]
1604+
1605+
[<Tests>]
1606+
let testMannWhitneyTest =
1607+
// Reference values cross-checked against R: wilcox.test(..., exact=FALSE, correct=FALSE)
1608+
// and R: wilcox.test(..., exact=FALSE, correct=TRUE)
1609+
1610+
// Test case 1: clear separation (sample1 >> sample2) -- no ties
1611+
// R: wilcox.test(c(6,7,8,9,10), c(1,2,3,4,5), exact=FALSE, correct=FALSE) → W=25, p=0.004516
1612+
// R: wilcox.test(c(6,7,8,9,10), c(1,2,3,4,5), exact=FALSE, correct=TRUE) → W=25, p=0.01220
1613+
let s1a = [| 6.; 7.; 8.; 9.; 10. |]
1614+
let s2a = [| 1.; 2.; 3.; 4.; 5. |]
1615+
let resultNoCC = MannWhitneyTest.create s1a s2a false
1616+
let resultCC = MannWhitneyTest.create s1a s2a true
1617+
1618+
// Test case 2: moderate overlap -- no ties
1619+
// sample1=[2;4;6;8;10], sample2=[1;3;5;7;9]
1620+
// U1=15, U2=10; z(no cc)=(15-12.5)/4.787=0.5222; pRight=0.3008; pTwo=0.6016
1621+
let s1b = [| 2.; 4.; 6.; 8.; 10. |]
1622+
let s2b = [| 1.; 3.; 5.; 7.; 9. |]
1623+
let resultB = MannWhitneyTest.create s1b s2b false
1624+
1625+
// Test case 3: data with ties
1626+
// sample1=[1;2;3;4], sample2=[2;3;5;6]
1627+
// U1=4, U2=12, mu=8, var=11.714, sigma=3.4225
1628+
// z=-1.1693, pLeft=0.1212, pRight=0.8788, pTwo=0.2423
1629+
let s1c = [| 1.; 2.; 3.; 4. |]
1630+
let s2c = [| 2.; 3.; 5.; 6. |]
1631+
let resultC = MannWhitneyTest.create s1c s2c false
1632+
1633+
testList "Testing.MannWhitneyTest" [
1634+
testCase "U statistics - clear separation" <| fun () ->
1635+
Expect.floatClose Accuracy.medium resultNoCC.U1 25. "U1 should be 25 (sample1 dominates)"
1636+
Expect.floatClose Accuracy.medium resultNoCC.U2 0. "U2 should be 0"
1637+
testCase "U1 + U2 = n1*n2" <| fun () ->
1638+
Expect.floatClose Accuracy.medium (resultNoCC.U1 + resultNoCC.U2) 25. "U1+U2 must equal n1*n2=25"
1639+
Expect.floatClose Accuracy.medium (resultB.U1 + resultB.U2) 25. "U1+U2 must equal n1*n2=25"
1640+
Expect.floatClose Accuracy.medium (resultC.U1 + resultC.U2) 16. "U1+U2 must equal n1*n2=16"
1641+
testCase "p-values - clear separation, no continuity correction" <| fun () ->
1642+
// R: p-value = 0.004516
1643+
Expect.isTrue (resultNoCC.PValueRight < 0.01) "PValueRight should be < 0.01 (sample1 >> sample2)"
1644+
Expect.isTrue (resultNoCC.PValueLeft > 0.99) "PValueLeft should be > 0.99"
1645+
Expect.floatClose Accuracy.low resultNoCC.PValue (2. * resultNoCC.PValueRight) "Two-sided = 2 × one-sided min"
1646+
testCase "p-values - clear separation, continuity correction" <| fun () ->
1647+
// R: p-value ≈ 0.01220
1648+
Expect.floatClose Accuracy.low (Math.Round(resultCC.PValue, 4)) 0.0122 "PValue with CC should ≈ 0.0122"
1649+
testCase "U statistics - moderate overlap" <| fun () ->
1650+
Expect.floatClose Accuracy.medium resultB.U1 15. "U1 should be 15"
1651+
Expect.floatClose Accuracy.medium resultB.U2 10. "U2 should be 10"
1652+
testCase "p-value not significant - moderate overlap" <| fun () ->
1653+
Expect.isTrue (resultB.PValue > 0.05) "p-value should not be significant for nearly identical distributions"
1654+
testCase "U statistics with ties" <| fun () ->
1655+
Expect.floatClose Accuracy.medium resultC.U1 4. "U1 should be 4 (with ties)"
1656+
Expect.floatClose Accuracy.medium resultC.U2 12. "U2 should be 12"
1657+
testCase "p-values with ties" <| fun () ->
1658+
// z ≈ -1.169, pLeft ≈ 0.121, pTwo ≈ 0.242
1659+
Expect.floatClose Accuracy.low (Math.Round(resultC.PValueLeft, 3)) 0.121 "PValueLeft with ties should ≈ 0.121"
1660+
Expect.floatClose Accuracy.low (Math.Round(resultC.PValue, 3)) 0.243 "PValueTwoTailed with ties should ≈ 0.243"
1661+
]

0 commit comments

Comments
 (0)