Skip to main content

faster_fishers/fishers/
mod.rs

1use statrs::distribution::{Discrete, DiscreteCDF, Hypergeometric};
2use statrs::StatsError;
3
4#[derive(Debug, Clone, Copy)]
5pub enum Alternative {
6    TwoSided,
7    Less,
8    Greater,
9}
10
11const EPSILON: f64 = 1.0 - 1e-4;
12/// Binary search in two-sided test with starting bound as argument
13fn binary_search<F>(
14    min_val: u64,
15    max_val: u64,
16    p_exact: f64,
17    epsilon: f64,
18    upper: bool,
19    func: F,
20) -> u64
21where
22    F: Fn(u64) -> f64,
23{
24    let (mut min_val, mut max_val) = (min_val, max_val);
25
26    let mut guess = 0;
27    loop {
28        if max_val - min_val <= 1 {
29            break;
30        }
31        guess = {
32            if max_val == min_val + 1 && guess == min_val {
33                max_val
34            } else {
35                (max_val + min_val) / 2
36            }
37        };
38
39        let ng = {
40            if upper {
41                guess - 1
42            } else {
43                guess + 1
44            }
45        };
46
47        let pmf_comp = func(ng);
48        let p_guess = func(guess);
49        if p_guess <= p_exact && p_guess < pmf_comp {
50            break;
51        }
52        if p_guess < p_exact {
53            max_val = guess
54        } else {
55            min_val = guess
56        }
57    }
58
59    if guess == 0 {
60        guess = min_val
61    }
62    if upper {
63        while guess > 0 && func(guess) < p_exact * epsilon {
64            guess -= 1;
65        }
66        while func(guess) > p_exact / epsilon {
67            guess += 1;
68        }
69    } else {
70        while func(guess) < p_exact * epsilon {
71            guess += 1;
72        }
73        while guess > 0 && func(guess) > p_exact / epsilon {
74            guess -= 1;
75        }
76    }
77    guess
78}
79
80/// Perform a Fisher exact test on a 2x2 contingency table.
81/// Based on scipy's fishers test: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.fisher_exact.html#scipy-stats-fisher-exact
82/// Returns the odds ratio and p_value
83/// # Examples
84///
85/// ```
86/// use faster_fishers::{Alternative, fishers_exact_with_odds_ratio};
87/// let table = [3, 5, 4, 50];
88/// let (odds_ratio, p_value) = fishers_exact_with_odds_ratio(&table, Alternative::Less).unwrap();
89/// ```
90pub fn fishers_exact_with_odds_ratio(
91    table: &[u64; 4],
92    alternative: Alternative,
93) -> Result<(f64, f64), StatsError> {
94    if (table[0] == 0 && table[2] == 0) || (table[1] == 0 && table[3] == 0) {
95        // If both values in a row or column are zero, p-value is 1 and odds ratio is NaN.
96        return Ok((f64::NAN, 1.0));
97    }
98
99    let odds_ratio = {
100        if table[1] * table[2] == 0 {
101            // Prevent division by zero
102            f64::INFINITY
103        } else {
104            (table[0] * table[3]) as f64 / (table[1] * table[2]) as f64
105        }
106    };
107
108    let p_value = fishers_exact(table, alternative)?;
109    Ok((odds_ratio, p_value))
110}
111
112/// Perform a Fisher exact test on a 2x2 contingency table.
113/// Based on scipy's fishers test: https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.fisher_exact.html#scipy-stats-fisher-exact
114/// Returns only the p_value
115/// # Examples
116///
117/// ```
118/// use faster_fishers::{Alternative, fishers_exact};
119/// let table = [3, 5, 4, 50];
120/// let p_value = fishers_exact(&table, Alternative::Less).unwrap();
121/// ```
122pub fn fishers_exact(table: &[u64; 4], alternative: Alternative) -> Result<f64, StatsError> {
123    // If both values in a row or column are zero, the p-value is 1 and
124    // the odds ratio is NaN.
125    if (table[0] == 0 && table[2] == 0) || (table[1] == 0 && table[3] == 0) {
126        return Ok(1.0);
127    }
128
129    let n1 = table[0] + table[1];
130    let n2 = table[2] + table[3];
131    let n = table[0] + table[2];
132
133    let population = n1 + n2;
134    let successes = n1;
135    let draws = n;
136    let dist = Hypergeometric::new(population, successes, draws)?;
137
138    match alternative {
139        Alternative::Less => Ok(dist.cdf(table[0])),
140        Alternative::Greater => {
141            let draws = table[1] + table[3];
142            let dist = Hypergeometric::new(population, successes, draws)?;
143            Ok(dist.cdf(table[1]))
144        }
145        Alternative::TwoSided => {
146            let p_exact = dist.pmf(table[0]);
147            let mode = ((n + 1) * (n1 + 1)) / (n1 + n2 + 2) as u64; // todo: check floor?
148            let p_mode = dist.pmf(mode);
149
150            if (p_exact - p_mode).abs() / p_exact.max(p_mode) <= 1.0 - EPSILON {
151                return Ok(1.0);
152            }
153
154            let func = |x| dist.pmf(x);
155            if table[0] < mode {
156                let p_lower = dist.cdf(table[0]);
157                if dist.pmf(n) > p_exact / EPSILON {
158                    Ok(p_lower)
159                } else {
160                    let guess = binary_search(mode, n, p_exact, EPSILON, true, func);
161                    Ok(p_lower + 1.0 - dist.cdf(guess - 1))
162                }
163            } else {
164                let p_upper = 1.0 - dist.cdf(table[0] - 1);
165                if dist.pmf(0) > p_exact / EPSILON {
166                    Ok(p_upper)
167                } else {
168                    let guess = binary_search(0, mode, p_exact, EPSILON, false, func);
169                    Ok(p_upper + dist.cdf(guess))
170                }
171            }
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::{fishers_exact, fishers_exact_with_odds_ratio, Alternative};
179    use float_cmp::assert_approx_eq;
180
181    /// Test fishers_exact by comparing against values from scipy.
182    #[test]
183    fn test_fishers_exact() {
184        let cases = [
185            (
186                [3, 5, 4, 50],
187                0.9963034765672599,
188                0.03970749246529277,
189                0.03970749246529276,
190            ),
191            (
192                [61, 118, 2, 1],
193                0.27535061623455315,
194                0.9598172545684959,
195                0.27535061623455315,
196            ),
197            (
198                [172, 46, 90, 127],
199                1.0,
200                6.662405187351769e-16,
201                9.041009036528785e-16,
202            ),
203            (
204                [127, 38, 112, 43],
205                0.8637599357870167,
206                0.20040942958644145,
207                0.3687862842650179,
208            ),
209            (
210                [186, 177, 111, 154],
211                0.9918518696328176,
212                0.012550663906725129,
213                0.023439141644624434,
214            ),
215            (
216                [137, 49, 135, 183],
217                0.999999999998533,
218                5.6517533666400615e-12,
219                8.870999836202932e-12,
220            ),
221            (
222                [37, 115, 37, 152],
223                0.8834621182590621,
224                0.17638403366123565,
225                0.29400927608021704,
226            ),
227            (
228                [124, 117, 119, 175],
229                0.9956704915461392,
230                0.007134712391455461,
231                0.011588218284387445,
232            ),
233            (
234                [70, 114, 41, 118],
235                0.9945558498544903,
236                0.010384865876586255,
237                0.020438291037108678,
238            ),
239            (
240                [173, 21, 89, 7],
241                0.2303739114068352,
242                0.8808002774812677,
243                0.4027047267306024,
244            ),
245            (
246                [18, 147, 123, 58],
247                4.077820702304103e-29,
248                0.9999999999999817,
249                0.0,
250            ),
251            (
252                [116, 20, 92, 186],
253                0.9999999999998267,
254                6.598118571034892e-25,
255                8.164831402188242e-25,
256            ),
257            (
258                [9, 22, 44, 38],
259                0.01584272038710196,
260                0.9951463496539362,
261                0.021581786662999272,
262            ),
263            (
264                [9, 101, 135, 7],
265                3.3336213533847776e-50,
266                1.0,
267                3.3336213533847776e-50,
268            ),
269            (
270                [153, 27, 191, 144],
271                0.9999999999950817,
272                2.473736787266208e-11,
273                3.185816623300107e-11,
274            ),
275            (
276                [111, 195, 189, 69],
277                6.665245982898848e-19,
278                0.9999999999994574,
279                1.0735744915712542e-18,
280            ),
281            (
282                [125, 21, 31, 131],
283                0.99999999999974,
284                9.720661317939016e-34,
285                1.0352129312860277e-33,
286            ),
287            (
288                [201, 192, 69, 179],
289                0.9999999988714893,
290                3.1477232259550017e-09,
291                4.761075937088169e-09,
292            ),
293            (
294                [124, 138, 159, 160],
295                0.30153826772785475,
296                0.7538974235759873,
297                0.5601766196310243,
298            ),
299        ];
300
301        for (table, less_expected, greater_expected, two_sided_expected) in cases.iter() {
302            for (alternative, expected) in [
303                Alternative::Less,
304                Alternative::Greater,
305                Alternative::TwoSided,
306            ]
307            .into_iter()
308            .zip(vec![less_expected, greater_expected, two_sided_expected])
309            {
310                let p_value = fishers_exact(&table, alternative).unwrap();
311                assert_approx_eq!(f64, p_value, *expected, epsilon = 1e-12);
312            }
313        }
314    }
315
316    #[test]
317    fn test_fishers_exact_with_odds() {
318        let table = [3, 5, 4, 50];
319        let (odds_ratio, p_value) =
320            fishers_exact_with_odds_ratio(&table, Alternative::Less).unwrap();
321        assert_approx_eq!(f64, p_value, 0.9963034765672599, epsilon = 1e-12);
322        assert_approx_eq!(f64, odds_ratio, 7.5, epsilon = 1e-1);
323    }
324}