1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct Share {
15 pub count: u64,
16 pub of: u64,
17 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 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
36pub struct AtThreshold {
37 pub threshold: f64,
38 pub ham_called_spam: Share,
40 pub spam_caught: Share,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct Numbers {
47 pub ham: u64,
48 pub spam: u64,
49 pub at_spam: AtThreshold,
51 pub at_unsure: AtThreshold,
53 pub unsure: Share,
55 pub auc: Option<f64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub strict: Option<AtThreshold>,
61}
62
63pub const STRICT: f64 = 0.005;
65
66pub 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
75pub 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 Some(ham.get(allowed).map_or(0.0, |p| f64::from_bits(p.to_bits() + 1)))
86}
87
88pub 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
99pub fn grid(scored: &[(f64, bool)], thresholds: &[f64]) -> Vec<AtThreshold> {
102 thresholds.iter().map(|&t| at(scored, t)).collect()
103}
104
105pub 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 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
131pub 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
143fn 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
153fn 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
166fn 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
210fn 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 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 #[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 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 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 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 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 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}