Skip to main content

qualia_core_db/solvers/learning/metrics/
classification.rs

1//! Classification metrics — accuracy, the binary confusion matrix and its derived
2//! rates, ROC AUC (rank form, reusing the statistics ranker), and log-loss.
3
4use crate::solvers::statistics::correlation::rank_into;
5
6/// Fraction of exact matches between predicted and true class labels. `None` if
7/// lengths differ or are empty.
8pub fn accuracy(y_true: &[usize], y_pred: &[usize]) -> Option<f64> {
9    let n = y_true.len();
10    if n == 0 || n != y_pred.len() {
11        return None;
12    }
13    let correct = y_true.iter().zip(y_pred).filter(|(a, b)| a == b).count();
14    Some(correct as f64 / n as f64)
15}
16
17/// Binary confusion matrix (positive class = `true`).
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct ConfusionBinary {
20    pub tp: u64,
21    pub fp: u64,
22    pub tn: u64,
23    pub fn_: u64,
24}
25
26impl ConfusionBinary {
27    pub fn total(&self) -> u64 {
28        self.tp + self.fp + self.tn + self.fn_
29    }
30    pub fn accuracy(&self) -> f64 {
31        let t = self.total();
32        if t == 0 {
33            return 0.0;
34        }
35        (self.tp + self.tn) as f64 / t as f64
36    }
37    /// TP/(TP+FP); 0 when no positives are predicted.
38    pub fn precision(&self) -> f64 {
39        let d = self.tp + self.fp;
40        if d == 0 {
41            0.0
42        } else {
43            self.tp as f64 / d as f64
44        }
45    }
46    /// TP/(TP+FN) (sensitivity / true-positive rate).
47    pub fn recall(&self) -> f64 {
48        let d = self.tp + self.fn_;
49        if d == 0 {
50            0.0
51        } else {
52            self.tp as f64 / d as f64
53        }
54    }
55    /// TN/(TN+FP) (specificity).
56    pub fn specificity(&self) -> f64 {
57        let d = self.tn + self.fp;
58        if d == 0 {
59            0.0
60        } else {
61            self.tn as f64 / d as f64
62        }
63    }
64    /// Harmonic mean of precision and recall.
65    pub fn f1(&self) -> f64 {
66        let (p, r) = (self.precision(), self.recall());
67        if p + r == 0.0 {
68            0.0
69        } else {
70            2.0 * p * r / (p + r)
71        }
72    }
73}
74
75/// Build the binary confusion matrix from predicted/true boolean labels.
76pub fn confusion_binary(y_true: &[bool], y_pred: &[bool]) -> Option<ConfusionBinary> {
77    let n = y_true.len();
78    if n == 0 || n != y_pred.len() {
79        return None;
80    }
81    let mut c = ConfusionBinary {
82        tp: 0,
83        fp: 0,
84        tn: 0,
85        fn_: 0,
86    };
87    for (&t, &p) in y_true.iter().zip(y_pred) {
88        match (t, p) {
89            (true, true) => c.tp += 1,
90            (false, true) => c.fp += 1,
91            (false, false) => c.tn += 1,
92            (true, false) => c.fn_ += 1,
93        }
94    }
95    Some(c)
96}
97
98/// ROC AUC via the Mann–Whitney rank statistic:
99/// `AUC = (R₊ − n₊(n₊+1)/2) / (n₊·n₋)`, where `R₊` is the sum of the (tie-averaged)
100/// ranks of the positive-class scores. Reuses the statistics ranker. `None` if the
101/// inputs mismatch or a class is empty.
102pub fn roc_auc(scores: &[f64], labels: &[bool]) -> Option<f64> {
103    let n = scores.len();
104    if n == 0 || n != labels.len() {
105        return None;
106    }
107    let n_pos = labels.iter().filter(|&&l| l).count();
108    let n_neg = n - n_pos;
109    if n_pos == 0 || n_neg == 0 {
110        return None; // AUC undefined with only one class present
111    }
112    let mut idx = vec![0usize; n];
113    let mut ranks = vec![0.0f64; n];
114    rank_into(scores, &mut idx, &mut ranks)?;
115    let sum_pos_ranks: f64 = labels
116        .iter()
117        .zip(ranks.iter())
118        .filter(|(&l, _)| l)
119        .map(|(_, &r)| r)
120        .sum();
121    let n_pos_f = n_pos as f64;
122    Some((sum_pos_ranks - n_pos_f * (n_pos_f + 1.0) / 2.0) / (n_pos_f * n_neg as f64))
123}
124
125/// Binary cross-entropy (log-loss): `−(1/n)Σ[yᵢln pᵢ + (1−yᵢ)ln(1−pᵢ)]`, with `p`
126/// clamped away from 0/1 for numerical safety. `None` on a length mismatch.
127pub fn log_loss(probs: &[f64], labels: &[bool]) -> Option<f64> {
128    let n = probs.len();
129    if n == 0 || n != labels.len() {
130        return None;
131    }
132    const EPS: f64 = 1e-15;
133    let mut s = 0.0;
134    for (&p, &y) in probs.iter().zip(labels) {
135        let p = p.clamp(EPS, 1.0 - EPS);
136        s += if y { -p.ln() } else { -(1.0 - p).ln() };
137    }
138    Some(s / n as f64)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn accuracy_basic() {
147        assert!((accuracy(&[0, 1, 2, 1], &[0, 1, 1, 1]).unwrap() - 0.75).abs() < 1e-12);
148        assert_eq!(accuracy(&[], &[]), None);
149    }
150
151    #[test]
152    fn confusion_rates() {
153        // 2 TP, 1 FP, 2 TN, 1 FN.
154        let t = [true, true, false, false, false, true];
155        let p = [true, true, true, false, false, false];
156        let c = confusion_binary(&t, &p).unwrap();
157        assert_eq!((c.tp, c.fp, c.tn, c.fn_), (2, 1, 2, 1));
158        assert!((c.precision() - 2.0 / 3.0).abs() < 1e-12);
159        assert!((c.recall() - 2.0 / 3.0).abs() < 1e-12);
160        assert!((c.f1() - 2.0 / 3.0).abs() < 1e-12);
161        assert!((c.accuracy() - 4.0 / 6.0).abs() < 1e-12);
162    }
163
164    #[test]
165    fn auc_perfect_and_random() {
166        // Perfectly separable: all positives score above all negatives → AUC 1.
167        let scores = [0.1, 0.2, 0.3, 0.8, 0.9, 1.0];
168        let labels = [false, false, false, true, true, true];
169        assert!((roc_auc(&scores, &labels).unwrap() - 1.0).abs() < 1e-12);
170        // Reversed → AUC 0.
171        let rev = [true, true, true, false, false, false];
172        assert!(roc_auc(&scores, &rev).unwrap().abs() < 1e-12);
173        // Single class → undefined.
174        assert_eq!(roc_auc(&scores, &[true; 6]), None);
175    }
176
177    #[test]
178    fn auc_known_value() {
179        // scores/labels with a known AUC of 0.75.
180        let scores = [0.2, 0.4, 0.6, 0.8];
181        let labels = [false, true, false, true];
182        assert!((roc_auc(&scores, &labels).unwrap() - 0.75).abs() < 1e-12);
183    }
184
185    #[test]
186    fn log_loss_rewards_confident_correct() {
187        let confident = log_loss(&[0.99, 0.01], &[true, false]).unwrap();
188        let unsure = log_loss(&[0.5, 0.5], &[true, false]).unwrap();
189        assert!(confident < unsure);
190    }
191}