Skip to main content

rill_ml/metrics/
classification.rs

1//! Classification metrics: Accuracy, Precision, Recall, F1, LogLoss.
2
3use crate::error::{RillError, checked_finite_add, checked_increment, ensure_finite};
4use crate::loss::log_loss::BinaryLogLoss;
5use crate::traits::Metric;
6
7/// Accuracy for binary classification.
8#[derive(Debug, Clone, Default)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub struct Accuracy {
11    correct: u64,
12    count: u64,
13}
14
15impl Metric for Accuracy {
16    type Truth = bool;
17    type Prediction = bool;
18
19    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
20        let next_count = checked_increment(self.count, "accuracy sample")?;
21        let next_correct = if truth == prediction {
22            checked_increment(self.correct, "accuracy correct")?
23        } else {
24            self.correct
25        };
26        self.count = next_count;
27        self.correct = next_correct;
28        Ok(())
29    }
30
31    fn value(&self) -> Option<f64> {
32        if self.count == 0 {
33            None
34        } else {
35            Some(self.correct as f64 / self.count as f64)
36        }
37    }
38
39    fn samples_seen(&self) -> u64 {
40        self.count
41    }
42
43    fn reset(&mut self) {
44        self.correct = 0;
45        self.count = 0;
46    }
47}
48
49/// Precision for the positive class.
50#[derive(Debug, Clone, Default)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct Precision {
53    true_positive: u64,
54    false_positive: u64,
55}
56
57impl Metric for Precision {
58    type Truth = bool;
59    type Prediction = bool;
60
61    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
62        match (truth, prediction) {
63            (true, true) => {
64                self.true_positive =
65                    checked_increment(self.true_positive, "precision true positive")?
66            }
67            (false, true) => {
68                self.false_positive =
69                    checked_increment(self.false_positive, "precision false positive")?
70            }
71            _ => {}
72        }
73        Ok(())
74    }
75
76    fn value(&self) -> Option<f64> {
77        let denominator = self.true_positive as f64 + self.false_positive as f64;
78        if denominator == 0.0 {
79            None
80        } else {
81            Some(self.true_positive as f64 / denominator)
82        }
83    }
84
85    fn samples_seen(&self) -> u64 {
86        self.true_positive.saturating_add(self.false_positive)
87    }
88
89    fn reset(&mut self) {
90        self.true_positive = 0;
91        self.false_positive = 0;
92    }
93}
94
95/// Recall for the positive class.
96#[derive(Debug, Clone, Default)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub struct Recall {
99    true_positive: u64,
100    false_negative: u64,
101}
102
103impl Metric for Recall {
104    type Truth = bool;
105    type Prediction = bool;
106
107    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
108        match (truth, prediction) {
109            (true, true) => {
110                self.true_positive = checked_increment(self.true_positive, "recall true positive")?
111            }
112            (true, false) => {
113                self.false_negative =
114                    checked_increment(self.false_negative, "recall false negative")?
115            }
116            _ => {}
117        }
118        Ok(())
119    }
120
121    fn value(&self) -> Option<f64> {
122        let denominator = self.true_positive as f64 + self.false_negative as f64;
123        if denominator == 0.0 {
124            None
125        } else {
126            Some(self.true_positive as f64 / denominator)
127        }
128    }
129
130    fn samples_seen(&self) -> u64 {
131        self.true_positive.saturating_add(self.false_negative)
132    }
133
134    fn reset(&mut self) {
135        self.true_positive = 0;
136        self.false_negative = 0;
137    }
138}
139
140/// F1 score, the harmonic mean of precision and recall.
141#[derive(Debug, Clone, Default)]
142#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
143pub struct F1Score {
144    true_positive: u64,
145    false_positive: u64,
146    false_negative: u64,
147}
148
149impl Metric for F1Score {
150    type Truth = bool;
151    type Prediction = bool;
152
153    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
154        match (truth, prediction) {
155            (true, true) => {
156                self.true_positive = checked_increment(self.true_positive, "F1 true positive")?
157            }
158            (false, true) => {
159                self.false_positive = checked_increment(self.false_positive, "F1 false positive")?
160            }
161            (true, false) => {
162                self.false_negative = checked_increment(self.false_negative, "F1 false negative")?
163            }
164            _ => {}
165        }
166        Ok(())
167    }
168
169    fn value(&self) -> Option<f64> {
170        let denominator = 2.0 * self.true_positive as f64
171            + self.false_positive as f64
172            + self.false_negative as f64;
173        if denominator == 0.0 {
174            None
175        } else {
176            Some(2.0 * self.true_positive as f64 / denominator)
177        }
178    }
179
180    fn samples_seen(&self) -> u64 {
181        self.true_positive
182            .saturating_add(self.false_positive)
183            .saturating_add(self.false_negative)
184    }
185
186    fn reset(&mut self) {
187        self.true_positive = 0;
188        self.false_positive = 0;
189        self.false_negative = 0;
190    }
191}
192
193/// Binary log loss (cross-entropy).
194#[derive(Debug, Clone)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct LogLoss {
197    loss: BinaryLogLoss,
198    sum_loss: f64,
199    count: u64,
200}
201
202impl Default for LogLoss {
203    fn default() -> Self {
204        Self {
205            loss: BinaryLogLoss::new(),
206            sum_loss: 0.0,
207            count: 0,
208        }
209    }
210}
211
212impl Metric for LogLoss {
213    type Truth = bool;
214    type Prediction = f64;
215
216    fn update(&mut self, truth: bool, prediction: f64) -> Result<(), RillError> {
217        ensure_finite("probability", prediction)?;
218        if !(0.0..=1.0).contains(&prediction) {
219            return Err(RillError::InvalidProbability(prediction));
220        }
221        let loss = self.loss.loss(prediction, truth);
222        ensure_finite("log loss", loss)?;
223        let next_sum = checked_finite_add(self.sum_loss, loss, "log loss sum")?;
224        let next_count = checked_increment(self.count, "log loss sample")?;
225        self.sum_loss = next_sum;
226        self.count = next_count;
227        Ok(())
228    }
229
230    fn value(&self) -> Option<f64> {
231        if self.count == 0 {
232            None
233        } else {
234            Some(self.sum_loss / self.count as f64)
235        }
236    }
237
238    fn samples_seen(&self) -> u64 {
239        self.count
240    }
241
242    fn reset(&mut self) {
243        self.sum_loss = 0.0;
244        self.count = 0;
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn accuracy_basic() {
254        let mut m = Accuracy::default();
255        m.update(true, true).unwrap();
256        m.update(false, false).unwrap();
257        m.update(true, false).unwrap();
258        assert!((m.value().unwrap() - 2.0 / 3.0).abs() < 1e-12);
259    }
260
261    #[test]
262    fn precision_basic() {
263        let mut m = Precision::default();
264        m.update(true, true).unwrap(); // tp
265        m.update(false, true).unwrap(); // fp
266        m.update(true, false).unwrap(); // fn
267        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
268    }
269
270    #[test]
271    fn recall_basic() {
272        let mut m = Recall::default();
273        m.update(true, true).unwrap(); // tp
274        m.update(false, true).unwrap(); // fp
275        m.update(true, false).unwrap(); // fn
276        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
277    }
278
279    #[test]
280    fn f1_basic() {
281        let mut m = F1Score::default();
282        m.update(true, true).unwrap(); // tp=1
283        m.update(false, true).unwrap(); // fp=1
284        m.update(true, false).unwrap(); // fn=1
285        // F1 = 2*1 / (2*1 + 1 + 1) = 0.5
286        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
287    }
288
289    #[test]
290    fn f1_perfect_is_one() {
291        let mut m = F1Score::default();
292        m.update(true, true).unwrap();
293        m.update(false, false).unwrap();
294        assert!((m.value().unwrap() - 1.0).abs() < 1e-12);
295    }
296
297    #[test]
298    fn log_loss_basic() {
299        let mut m = LogLoss::default();
300        m.update(true, 0.9).unwrap();
301        m.update(false, 0.1).unwrap();
302        let expected = (-0.9_f64.ln() + -0.9_f64.ln()) / 2.0;
303        assert!((m.value().unwrap() - expected).abs() < 1e-9);
304    }
305
306    #[test]
307    fn log_loss_rejects_invalid_probability() {
308        let mut m = LogLoss::default();
309        assert!(m.update(true, 1.5).is_err());
310        assert!(m.update(true, -0.1).is_err());
311        assert!(m.update(true, f64::NAN).is_err());
312    }
313
314    #[test]
315    fn empty_metrics_return_none() {
316        assert!(Accuracy::default().value().is_none());
317        assert!(Precision::default().value().is_none());
318        assert!(Recall::default().value().is_none());
319        assert!(F1Score::default().value().is_none());
320        assert!(LogLoss::default().value().is_none());
321    }
322
323    #[test]
324    fn precision_no_predictions_returns_none() {
325        let mut m = Precision::default();
326        m.update(true, false).unwrap();
327        m.update(false, false).unwrap();
328        assert!(m.value().is_none());
329    }
330}