Skip to main content

sioul_learn/
eval.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The filter's numbers, aggregate only: how much ham it would call spam and
5//! how much spam it would catch at your two thresholds, each with its 95 %
6//! exact binomial interval (Clopper–Pearson), the share it calls unsure, and
7//! the area under the ROC curve. Nothing here names a message: these numbers
8//! may be read by an AI agent, so no subject, sender or word ever enters them.
9
10use serde::{Deserialize, Serialize};
11
12/// `count` of `of`, with its 95 % interval.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct Share {
15    pub count: u64,
16    pub of: u64,
17    /// The interval's ends, as fractions (0.0123 for 1.23 %).
18    pub low: f64,
19    pub high: f64,
20}
21
22impl Share {
23    pub fn new(count: u64, of: u64) -> Share {
24        let (low, high) = clopper_pearson(count, of, 0.95);
25        Share { count, of, low, high }
26    }
27
28    /// The fraction itself; none of nothing is 0.
29    pub fn fraction(&self) -> f64 {
30        if self.of == 0 { 0.0 } else { self.count as f64 / self.of as f64 }
31    }
32}
33
34/// What one threshold does: messages at or above it are called spam.
35#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
36pub struct AtThreshold {
37    pub threshold: f64,
38    /// Ham at or above the threshold: lost (set aside) or marked unsure.
39    pub ham_called_spam: Share,
40    /// Spam at or above the threshold.
41    pub spam_caught: Share,
42}
43
44/// The evaluation of probabilities against labels.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct Numbers {
47    pub ham: u64,
48    pub spam: u64,
49    /// At `threshold_spam`: set aside.
50    pub at_spam: AtThreshold,
51    /// At `threshold_unsure`: unsure or set aside.
52    pub at_unsure: AtThreshold,
53    /// Messages between the thresholds (unsure), of all.
54    pub unsure: Share,
55    /// Area under the ROC curve; none without both kinds.
56    pub auc: Option<f64>,
57    /// The lowest threshold that calls at most `STRICT` of the ham spam,
58    /// and what it catches there; none without ham.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub strict: Option<AtThreshold>,
61}
62
63/// The share of ham a strict threshold may call spam: one in two hundred.
64pub const STRICT: f64 = 0.005;
65
66/// Evaluates `(probability of spam, is spam)` pairs at the two thresholds.
67pub fn evaluate(scored: &[(f64, bool)], threshold_spam: f64, threshold_unsure: f64) -> Numbers {
68    let spam = scored.iter().filter(|(_, s)| *s).count() as u64;
69    let ham = scored.len() as u64 - spam;
70    let unsure = scored.iter().filter(|&&(p, _)| p >= threshold_unsure && p < threshold_spam).count() as u64;
71    let strict = strict(scored, STRICT).map(|threshold| at(scored, threshold));
72    Numbers { ham, spam, at_spam: at(scored, threshold_spam), at_unsure: at(scored, threshold_unsure), unsure: Share::new(unsure, scored.len() as u64), auc: auc(scored), strict }
73}
74
75/// The lowest threshold at which at most `rate` of the ham is called spam:
76/// just above the score of the ham that would be one too many. None without ham.
77pub fn strict(scored: &[(f64, bool)], rate: f64) -> Option<f64> {
78    let mut ham: Vec<f64> = scored.iter().filter(|(_, spam)| !spam).map(|(p, _)| *p).collect();
79    if ham.is_empty() {
80        return None;
81    }
82    ham.sort_by(|a, b| b.total_cmp(a));
83    let allowed = (rate * ham.len() as f64).floor() as usize;
84    // The (allowed + 1)-th surest ham must stay below: the next number above its score.
85    Some(ham.get(allowed).map_or(0.0, |p| f64::from_bits(p.to_bits() + 1)))
86}
87
88/// What one threshold does to `(probability of spam, is spam)` pairs.
89pub fn at(scored: &[(f64, bool)], threshold: f64) -> AtThreshold {
90    let spam = scored.iter().filter(|(_, s)| *s).count() as u64;
91    let ham = scored.len() as u64 - spam;
92    AtThreshold {
93        threshold,
94        ham_called_spam: Share::new(scored.iter().filter(|&&(p, s)| !s && p >= threshold).count() as u64, ham),
95        spam_caught: Share::new(scored.iter().filter(|&&(p, s)| s && p >= threshold).count() as u64, spam),
96    }
97}
98
99/// The same at each of `thresholds`, so that a threshold can be chosen
100/// (`sioul spam eval`'s grid): what each would set aside, and catch.
101pub fn grid(scored: &[(f64, bool)], thresholds: &[f64]) -> Vec<AtThreshold> {
102    thresholds.iter().map(|&t| at(scored, t)).collect()
103}
104
105/// The area under the ROC curve: the chance a random spam scores above a
106/// random ham (ties count half), by ranks (Mann–Whitney).
107pub fn auc(scored: &[(f64, bool)]) -> Option<f64> {
108    let spam = scored.iter().filter(|(_, s)| *s).count();
109    let ham = scored.len() - spam;
110    if spam == 0 || ham == 0 {
111        return None;
112    }
113    let mut sorted: Vec<(f64, bool)> = scored.to_vec();
114    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
115    let mut rank_sum = 0.0;
116    let mut i = 0;
117    while i < sorted.len() {
118        let mut j = i;
119        while j < sorted.len() && sorted[j].0 == sorted[i].0 {
120            j += 1;
121        }
122        // Ranks i+1..=j share their mean.
123        let mean_rank = (i + 1 + j) as f64 / 2.0;
124        rank_sum += mean_rank * sorted[i..j].iter().filter(|(_, s)| *s).count() as f64;
125        i = j;
126    }
127    let (spam, ham) = (spam as f64, ham as f64);
128    Some((rank_sum - spam * (spam + 1.0) / 2.0) / (spam * ham))
129}
130
131/// The exact (Clopper–Pearson) interval of `k` successes in `n` trials.
132pub fn clopper_pearson(k: u64, n: u64, confidence: f64) -> (f64, f64) {
133    if n == 0 {
134        return (0.0, 1.0);
135    }
136    let tail = (1.0 - confidence) / 2.0;
137    let (k, n) = (k as f64, n as f64);
138    let low = if k == 0.0 { 0.0 } else { beta_quantile(tail, k, n - k + 1.0) };
139    let high = if k == n { 1.0 } else { beta_quantile(1.0 - tail, k + 1.0, n - k) };
140    (low, high)
141}
142
143/// The x where the Beta(a, b) distribution reaches `p`, by bisection.
144fn beta_quantile(p: f64, a: f64, b: f64) -> f64 {
145    let (mut low, mut high) = (0.0f64, 1.0f64);
146    for _ in 0..200 {
147        let mid = (low + high) / 2.0;
148        if incomplete_beta(mid, a, b) < p { low = mid } else { high = mid }
149    }
150    (low + high) / 2.0
151}
152
153/// The regularized incomplete beta function I_x(a, b), by its continued
154/// fraction (Numerical Recipes, `betai`), with the symmetry for large x.
155fn incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
156    if x <= 0.0 {
157        return 0.0;
158    }
159    if x >= 1.0 {
160        return 1.0;
161    }
162    let front = (ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln()).exp();
163    if x < (a + 1.0) / (a + b + 2.0) { front * beta_fraction(x, a, b) / a } else { 1.0 - front * beta_fraction(1.0 - x, b, a) / b }
164}
165
166/// The continued fraction of the incomplete beta function (modified Lentz).
167fn beta_fraction(x: f64, a: f64, b: f64) -> f64 {
168    let tiny = 1e-300;
169    let (qab, qap, qam) = (a + b, a + 1.0, a - 1.0);
170    let mut c = 1.0;
171    let mut d = 1.0 - qab * x / qap;
172    if d.abs() < tiny {
173        d = tiny;
174    }
175    d = 1.0 / d;
176    let mut h = d;
177    for m in 1..1000 {
178        let m = f64::from(m);
179        let m2 = 2.0 * m;
180        let aa = m * (b - m) * x / ((qam + m2) * (a + m2));
181        d = 1.0 + aa * d;
182        if d.abs() < tiny {
183            d = tiny;
184        }
185        c = 1.0 + aa / c;
186        if c.abs() < tiny {
187            c = tiny;
188        }
189        d = 1.0 / d;
190        h *= d * c;
191        let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
192        d = 1.0 + aa * d;
193        if d.abs() < tiny {
194            d = tiny;
195        }
196        c = 1.0 + aa / c;
197        if c.abs() < tiny {
198            c = tiny;
199        }
200        d = 1.0 / d;
201        let step = d * c;
202        h *= step;
203        if (step - 1.0).abs() < 1e-15 {
204            break;
205        }
206    }
207    h
208}
209
210/// ln Γ(x) for x > 0 (Lanczos, g = 7, nine terms).
211fn ln_gamma(x: f64) -> f64 {
212    const G: [f64; 9] = [
213        0.999_999_999_999_809_9,
214        676.520_368_121_885_1,
215        -1_259.139_216_722_402_8,
216        771.323_428_777_653_1,
217        -176.615_029_162_140_6,
218        12.507_343_278_686_905,
219        -0.138_571_095_265_720_12,
220        9.984_369_578_019_572e-6,
221        1.505_632_735_149_311_6e-7,
222    ];
223    if x < 0.5 {
224        // Reflection: Γ(x)Γ(1 − x) = π / sin(πx).
225        return (std::f64::consts::PI / (std::f64::consts::PI * x).sin()).ln() - ln_gamma(1.0 - x);
226    }
227    let x = x - 1.0;
228    let t = x + 7.5;
229    let series = G[1..].iter().enumerate().fold(G[0], |sum, (i, g)| sum + g / (x + i as f64 + 1.0));
230    0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + series.ln()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn close(a: f64, b: f64) -> bool {
238        (a - b).abs() < 1e-4
239    }
240
241    /// Known Clopper–Pearson intervals (R's binom.test).
242    #[test]
243    fn exact_intervals() {
244        let (low, high) = clopper_pearson(0, 10, 0.95);
245        assert!(low == 0.0 && close(high, 0.308_5), "{high}");
246        let (low, high) = clopper_pearson(10, 10, 0.95);
247        assert!(close(low, 0.691_5) && high == 1.0, "{low}");
248        let (low, high) = clopper_pearson(5, 10, 0.95);
249        assert!(close(low, 0.187_1) && close(high, 0.812_9), "{low} {high}");
250        let (low, high) = clopper_pearson(3, 1000, 0.95);
251        assert!(close(low, 0.000_619) && close(high, 0.008_742), "{low} {high}");
252        // The rule of three: no error in 3000 is under about 0.1 %.
253        let (_, high) = clopper_pearson(0, 3000, 0.95);
254        assert!(high > 0.000_99 && high < 0.001_23, "{high}");
255        assert_eq!(clopper_pearson(0, 0, 0.95), (0.0, 1.0));
256    }
257
258    #[test]
259    fn area_under_the_curve() {
260        let perfect = [(0.9, true), (0.8, true), (0.2, false), (0.1, false)];
261        assert_eq!(auc(&perfect), Some(1.0));
262        let reversed = [(0.1, true), (0.9, false)];
263        assert_eq!(auc(&reversed), Some(0.0));
264        // One tie between a spam and a ham counts half: (1 + 0.5) / 2.
265        let tied = [(0.5, true), (0.5, false), (0.9, true), (0.1, false)];
266        assert_eq!(auc(&tied), Some(0.875));
267        assert_eq!(auc(&[(0.5, true)]), None);
268    }
269
270    #[test]
271    fn the_thresholds_count_what_they_should() {
272        let scored = [(0.99, true), (0.96, false), (0.7, true), (0.6, false), (0.2, false), (0.1, true)];
273        let numbers = evaluate(&scored, 0.95, 0.5);
274        assert_eq!((numbers.ham, numbers.spam), (3, 3));
275        assert_eq!((numbers.at_spam.ham_called_spam.count, numbers.at_spam.spam_caught.count), (1, 1));
276        assert_eq!((numbers.at_unsure.ham_called_spam.count, numbers.at_unsure.spam_caught.count), (2, 2));
277        assert_eq!((numbers.unsure.count, numbers.unsure.of), (2, 6));
278        assert!(close(numbers.at_spam.spam_caught.fraction(), 1.0 / 3.0));
279        // The grid: each threshold as one of the two would count.
280        let grid = grid(&scored, &[0.5, 0.95, 0.99]);
281        assert_eq!(grid.iter().map(|a| (a.ham_called_spam.count, a.spam_caught.count)).collect::<Vec<_>>(), vec![(2, 2), (1, 1), (0, 1)]);
282        assert_eq!((grid[0], grid[1]), (numbers.at_unsure, numbers.at_spam));
283        // Strict: no ham of three may be called spam (0.5 % of 3 is none): just above the surest ham, 0.96.
284        let strict = numbers.strict.unwrap();
285        assert!(strict.threshold > 0.96 && strict.threshold < 0.961, "{strict:?}");
286        assert_eq!((strict.ham_called_spam.count, strict.spam_caught.count), (0, 1));
287        // Of four hundred ham, two may be (0.5 %): the third surest sets it.
288        let many: Vec<(f64, bool)> = (0..400).map(|k| (f64::from(k) / 400.0, false)).chain([(0.999, true), (0.5, true)]).collect();
289        let threshold = super::strict(&many, STRICT).unwrap();
290        assert_eq!(many.iter().filter(|(p, s)| !s && *p >= threshold).count(), 2, "{threshold}");
291        assert!(super::strict(&[(0.9, true)], STRICT).is_none(), "no ham, no threshold");
292    }
293}