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///
51/// `samples_seen()` reports the total number of successfully incorporated
52/// observations (including true negatives), not `TP + FP`. The confusion
53/// counts are kept separately so the metric remains computable.
54#[derive(Debug, Clone, Default)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize))]
56pub struct Precision {
57    true_positive: u64,
58    false_positive: u64,
59    /// Total observations successfully incorporated via `update`.
60    /// Restored from serde as-is; older states without this field are
61    /// rejected because the true-negative count cannot be reconstructed
62    /// from `TP`/`FP` alone.
63    samples_seen: u64,
64}
65
66#[cfg(feature = "serde")]
67impl<'de> serde::Deserialize<'de> for Precision {
68    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69    where
70        D: serde::Deserializer<'de>,
71    {
72        #[derive(serde::Deserialize)]
73        struct PrecisionState {
74            true_positive: u64,
75            false_positive: u64,
76            samples_seen: u64,
77        }
78
79        let state = PrecisionState::deserialize(deserializer)?;
80        // Internal consistency: samples_seen must be at least the
81        // confusion counts, since every TP/FP contributes one observation.
82        // Use checked_add (not saturating_add) so an illegal huge state
83        // cannot be silently accepted after saturation.
84        let confusion = state
85            .true_positive
86            .checked_add(state.false_positive)
87            .ok_or_else(|| serde::de::Error::custom("precision tp + fp overflow"))?;
88        if state.samples_seen < confusion {
89            return Err(serde::de::Error::custom("precision samples_seen < tp + fp"));
90        }
91        Ok(Precision {
92            true_positive: state.true_positive,
93            false_positive: state.false_positive,
94            samples_seen: state.samples_seen,
95        })
96    }
97}
98
99impl Metric for Precision {
100    type Truth = bool;
101    type Prediction = bool;
102
103    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
104        let next_samples = checked_increment(self.samples_seen, "precision samples_seen")?;
105        let next_tp = if truth && prediction {
106            checked_increment(self.true_positive, "precision true positive")?
107        } else {
108            self.true_positive
109        };
110        let next_fp = if !truth && prediction {
111            checked_increment(self.false_positive, "precision false positive")?
112        } else {
113            self.false_positive
114        };
115        self.samples_seen = next_samples;
116        self.true_positive = next_tp;
117        self.false_positive = next_fp;
118        Ok(())
119    }
120
121    fn value(&self) -> Option<f64> {
122        let denominator = self.true_positive as f64 + self.false_positive 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.samples_seen
132    }
133
134    fn reset(&mut self) {
135        self.true_positive = 0;
136        self.false_positive = 0;
137        self.samples_seen = 0;
138    }
139}
140
141/// Recall for the positive class.
142///
143/// `samples_seen()` reports the total number of successfully incorporated
144/// observations (including true negatives), not `TP + FN`.
145#[derive(Debug, Clone, Default)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize))]
147pub struct Recall {
148    true_positive: u64,
149    false_negative: u64,
150    samples_seen: u64,
151}
152
153#[cfg(feature = "serde")]
154impl<'de> serde::Deserialize<'de> for Recall {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        #[derive(serde::Deserialize)]
160        struct RecallState {
161            true_positive: u64,
162            false_negative: u64,
163            samples_seen: u64,
164        }
165
166        let state = RecallState::deserialize(deserializer)?;
167        // Use checked_add (not saturating_add) so an illegal huge state
168        // cannot be silently accepted after saturation.
169        let confusion = state
170            .true_positive
171            .checked_add(state.false_negative)
172            .ok_or_else(|| serde::de::Error::custom("recall tp + fn overflow"))?;
173        if state.samples_seen < confusion {
174            return Err(serde::de::Error::custom("recall samples_seen < tp + fn"));
175        }
176        Ok(Recall {
177            true_positive: state.true_positive,
178            false_negative: state.false_negative,
179            samples_seen: state.samples_seen,
180        })
181    }
182}
183
184impl Metric for Recall {
185    type Truth = bool;
186    type Prediction = bool;
187
188    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
189        let next_samples = checked_increment(self.samples_seen, "recall samples_seen")?;
190        let next_tp = if truth && prediction {
191            checked_increment(self.true_positive, "recall true positive")?
192        } else {
193            self.true_positive
194        };
195        let next_fn = if truth && !prediction {
196            checked_increment(self.false_negative, "recall false negative")?
197        } else {
198            self.false_negative
199        };
200        self.samples_seen = next_samples;
201        self.true_positive = next_tp;
202        self.false_negative = next_fn;
203        Ok(())
204    }
205
206    fn value(&self) -> Option<f64> {
207        let denominator = self.true_positive as f64 + self.false_negative as f64;
208        if denominator == 0.0 {
209            None
210        } else {
211            Some(self.true_positive as f64 / denominator)
212        }
213    }
214
215    fn samples_seen(&self) -> u64 {
216        self.samples_seen
217    }
218
219    fn reset(&mut self) {
220        self.true_positive = 0;
221        self.false_negative = 0;
222        self.samples_seen = 0;
223    }
224}
225
226/// F1 score, the harmonic mean of precision and recall.
227///
228/// `samples_seen()` reports the total number of successfully incorporated
229/// observations (including true negatives), not `TP + FP + FN`.
230#[derive(Debug, Clone, Default)]
231#[cfg_attr(feature = "serde", derive(serde::Serialize))]
232pub struct F1Score {
233    true_positive: u64,
234    false_positive: u64,
235    false_negative: u64,
236    samples_seen: u64,
237}
238
239#[cfg(feature = "serde")]
240impl<'de> serde::Deserialize<'de> for F1Score {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: serde::Deserializer<'de>,
244    {
245        #[derive(serde::Deserialize)]
246        struct F1State {
247            true_positive: u64,
248            false_positive: u64,
249            false_negative: u64,
250            samples_seen: u64,
251        }
252
253        let state = F1State::deserialize(deserializer)?;
254        // Use checked_add (not saturating_add) so an illegal huge state
255        // cannot be silently accepted after saturation. The chained add
256        // (tp + fp + fn) must be checked at every step; saturating_add
257        // could hide overflow at the first add and produce a wrong sum.
258        let tp_fp = state
259            .true_positive
260            .checked_add(state.false_positive)
261            .ok_or_else(|| serde::de::Error::custom("f1 tp + fp overflow"))?;
262        let confusion = tp_fp
263            .checked_add(state.false_negative)
264            .ok_or_else(|| serde::de::Error::custom("f1 tp + fp + fn overflow"))?;
265        if state.samples_seen < confusion {
266            return Err(serde::de::Error::custom("f1 samples_seen < tp + fp + fn"));
267        }
268        Ok(F1Score {
269            true_positive: state.true_positive,
270            false_positive: state.false_positive,
271            false_negative: state.false_negative,
272            samples_seen: state.samples_seen,
273        })
274    }
275}
276
277impl Metric for F1Score {
278    type Truth = bool;
279    type Prediction = bool;
280
281    fn update(&mut self, truth: bool, prediction: bool) -> Result<(), RillError> {
282        let next_samples = checked_increment(self.samples_seen, "F1 samples_seen")?;
283        let next_tp = if truth && prediction {
284            checked_increment(self.true_positive, "F1 true positive")?
285        } else {
286            self.true_positive
287        };
288        let next_fp = if !truth && prediction {
289            checked_increment(self.false_positive, "F1 false positive")?
290        } else {
291            self.false_positive
292        };
293        let next_fn = if truth && !prediction {
294            checked_increment(self.false_negative, "F1 false negative")?
295        } else {
296            self.false_negative
297        };
298        self.samples_seen = next_samples;
299        self.true_positive = next_tp;
300        self.false_positive = next_fp;
301        self.false_negative = next_fn;
302        Ok(())
303    }
304
305    fn value(&self) -> Option<f64> {
306        let denominator = 2.0 * self.true_positive as f64
307            + self.false_positive as f64
308            + self.false_negative as f64;
309        if denominator == 0.0 {
310            None
311        } else {
312            Some(2.0 * self.true_positive as f64 / denominator)
313        }
314    }
315
316    fn samples_seen(&self) -> u64 {
317        self.samples_seen
318    }
319
320    fn reset(&mut self) {
321        self.true_positive = 0;
322        self.false_positive = 0;
323        self.false_negative = 0;
324        self.samples_seen = 0;
325    }
326}
327
328/// Binary log loss (cross-entropy).
329#[derive(Debug, Clone)]
330#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
331pub struct LogLoss {
332    loss: BinaryLogLoss,
333    sum_loss: f64,
334    count: u64,
335}
336
337impl Default for LogLoss {
338    fn default() -> Self {
339        Self {
340            loss: BinaryLogLoss::new(),
341            sum_loss: 0.0,
342            count: 0,
343        }
344    }
345}
346
347impl Metric for LogLoss {
348    type Truth = bool;
349    type Prediction = f64;
350
351    fn update(&mut self, truth: bool, prediction: f64) -> Result<(), RillError> {
352        ensure_finite("probability", prediction)?;
353        if !(0.0..=1.0).contains(&prediction) {
354            return Err(RillError::InvalidProbability(prediction));
355        }
356        let loss = self.loss.loss(prediction, truth);
357        ensure_finite("log loss", loss)?;
358        let next_sum = checked_finite_add(self.sum_loss, loss, "log loss sum")?;
359        let next_count = checked_increment(self.count, "log loss sample")?;
360        self.sum_loss = next_sum;
361        self.count = next_count;
362        Ok(())
363    }
364
365    fn value(&self) -> Option<f64> {
366        if self.count == 0 {
367            None
368        } else {
369            Some(self.sum_loss / self.count as f64)
370        }
371    }
372
373    fn samples_seen(&self) -> u64 {
374        self.count
375    }
376
377    fn reset(&mut self) {
378        self.sum_loss = 0.0;
379        self.count = 0;
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn accuracy_basic() {
389        let mut m = Accuracy::default();
390        m.update(true, true).unwrap();
391        m.update(false, false).unwrap();
392        m.update(true, false).unwrap();
393        assert!((m.value().unwrap() - 2.0 / 3.0).abs() < 1e-12);
394    }
395
396    #[test]
397    fn precision_basic() {
398        let mut m = Precision::default();
399        m.update(true, true).unwrap(); // tp
400        m.update(false, true).unwrap(); // fp
401        m.update(true, false).unwrap(); // fn
402        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
403    }
404
405    #[test]
406    fn recall_basic() {
407        let mut m = Recall::default();
408        m.update(true, true).unwrap(); // tp
409        m.update(false, true).unwrap(); // fp
410        m.update(true, false).unwrap(); // fn
411        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
412    }
413
414    #[test]
415    fn f1_basic() {
416        let mut m = F1Score::default();
417        m.update(true, true).unwrap(); // tp=1
418        m.update(false, true).unwrap(); // fp=1
419        m.update(true, false).unwrap(); // fn=1
420        // F1 = 2*1 / (2*1 + 1 + 1) = 0.5
421        assert!((m.value().unwrap() - 0.5).abs() < 1e-12);
422    }
423
424    #[test]
425    fn f1_perfect_is_one() {
426        let mut m = F1Score::default();
427        m.update(true, true).unwrap();
428        m.update(false, false).unwrap();
429        assert!((m.value().unwrap() - 1.0).abs() < 1e-12);
430    }
431
432    #[test]
433    fn log_loss_basic() {
434        let mut m = LogLoss::default();
435        m.update(true, 0.9).unwrap();
436        m.update(false, 0.1).unwrap();
437        let expected = (-0.9_f64.ln() + -0.9_f64.ln()) / 2.0;
438        assert!((m.value().unwrap() - expected).abs() < 1e-9);
439    }
440
441    #[test]
442    fn log_loss_rejects_invalid_probability() {
443        let mut m = LogLoss::default();
444        assert!(m.update(true, 1.5).is_err());
445        assert!(m.update(true, -0.1).is_err());
446        assert!(m.update(true, f64::NAN).is_err());
447    }
448
449    #[test]
450    fn empty_metrics_return_none() {
451        assert!(Accuracy::default().value().is_none());
452        assert!(Precision::default().value().is_none());
453        assert!(Recall::default().value().is_none());
454        assert!(F1Score::default().value().is_none());
455        assert!(LogLoss::default().value().is_none());
456    }
457
458    #[test]
459    fn precision_no_predictions_returns_none() {
460        let mut m = Precision::default();
461        m.update(true, false).unwrap();
462        m.update(false, false).unwrap();
463        assert!(m.value().is_none());
464    }
465
466    // -----------------------------------------------------------------
467    // Metric::samples_seen() contract: every successful update must
468    // increment the count by exactly one, including true negatives.
469    // -----------------------------------------------------------------
470
471    #[test]
472    fn samples_seen_counts_all_observations() {
473        let mut p = Precision::default();
474        let mut r = Recall::default();
475        let mut f = F1Score::default();
476        let mut a = Accuracy::default();
477
478        // All four confusion-matrix cells.
479        let cases = [(true, true), (true, false), (false, true), (false, false)];
480        for (truth, pred) in cases {
481            p.update(truth, pred).unwrap();
482            r.update(truth, pred).unwrap();
483            f.update(truth, pred).unwrap();
484            a.update(truth, pred).unwrap();
485        }
486
487        assert_eq!(p.samples_seen(), 4);
488        assert_eq!(r.samples_seen(), 4);
489        assert_eq!(f.samples_seen(), 4);
490        assert_eq!(a.samples_seen(), 4);
491    }
492
493    #[test]
494    #[cfg(feature = "serde")]
495    fn samples_seen_overflow_is_atomic() {
496        // Restore a near-overflow Precision via serde, then attempt one
497        // more update. The counter must overflow without mutating state.
498        let json = format!(
499            "{{\"true_positive\":1,\"false_positive\":1,\"samples_seen\":{}}}",
500            u64::MAX
501        );
502        let mut p: Precision = serde_json::from_str(&json).unwrap();
503        let result = p.update(true, true);
504        assert!(result.is_err(), "expected overflow");
505        assert_eq!(p.samples_seen(), u64::MAX);
506        assert_eq!(p.true_positive, 1);
507        assert_eq!(p.false_positive, 1);
508    }
509
510    #[test]
511    #[cfg(feature = "serde")]
512    fn precision_serde_rejects_missing_samples_seen() {
513        // Old state without samples_seen must be rejected: true-negative
514        // count cannot be reconstructed from TP/FP alone.
515        let json = "{\"true_positive\":1,\"false_positive\":1}";
516        assert!(serde_json::from_str::<Precision>(json).is_err());
517    }
518
519    #[test]
520    #[cfg(feature = "serde")]
521    fn precision_serde_rejects_inconsistent_samples_seen() {
522        // samples_seen < tp + fp is internally inconsistent.
523        let json = "{\"true_positive\":5,\"false_positive\":5,\"samples_seen\":3}";
524        assert!(serde_json::from_str::<Precision>(json).is_err());
525    }
526
527    #[test]
528    #[cfg(feature = "serde")]
529    fn recall_serde_rejects_missing_samples_seen() {
530        let json = "{\"true_positive\":1,\"false_negative\":1}";
531        assert!(serde_json::from_str::<Recall>(json).is_err());
532    }
533
534    #[test]
535    #[cfg(feature = "serde")]
536    fn f1_serde_rejects_missing_samples_seen() {
537        let json = "{\"true_positive\":1,\"false_positive\":1,\"false_negative\":1}";
538        assert!(serde_json::from_str::<F1Score>(json).is_err());
539    }
540
541    #[test]
542    #[cfg(feature = "serde")]
543    fn metric_serde_roundtrip_preserves_samples_seen() {
544        let mut p = Precision::default();
545        for _ in 0..10 {
546            p.update(true, true).unwrap();
547        }
548        let json = serde_json::to_string(&p).unwrap();
549        let restored: Precision = serde_json::from_str(&json).unwrap();
550        assert_eq!(restored.samples_seen(), 10);
551        assert_eq!(restored.true_positive, 10);
552    }
553
554    #[test]
555    fn reset_clears_samples_seen() {
556        let mut p = Precision::default();
557        p.update(true, true).unwrap();
558        p.update(false, false).unwrap();
559        assert_eq!(p.samples_seen(), 2);
560        p.reset();
561        assert_eq!(p.samples_seen(), 0);
562        assert_eq!(p.true_positive, 0);
563        assert_eq!(p.false_positive, 0);
564    }
565
566    // -----------------------------------------------------------------
567    // serde overflow rejection: checked_add must reject illegal huge
568    // states instead of silently saturating them to u64::MAX.
569    // -----------------------------------------------------------------
570
571    #[test]
572    #[cfg(feature = "serde")]
573    fn precision_serde_rejects_tp_fp_overflow() {
574        // TP + FP overflows u64; saturating_add would hide this and
575        // compare against u64::MAX, wrongly rejecting only because
576        // samples_seen < u64::MAX rather than because of overflow.
577        let json = format!(
578            "{{\"true_positive\":{},\"false_positive\":{},\"samples_seen\":{}}}",
579            u64::MAX,
580            1u64,
581            u64::MAX
582        );
583        assert!(serde_json::from_str::<Precision>(&json).is_err());
584    }
585
586    #[test]
587    #[cfg(feature = "serde")]
588    fn recall_serde_rejects_tp_fn_overflow() {
589        let json = format!(
590            "{{\"true_positive\":{},\"false_negative\":{},\"samples_seen\":{}}}",
591            u64::MAX,
592            1u64,
593            u64::MAX
594        );
595        assert!(serde_json::from_str::<Recall>(&json).is_err());
596    }
597
598    #[test]
599    #[cfg(feature = "serde")]
600    fn f1_serde_rejects_tp_fp_overflow() {
601        // First add (tp + fp) overflows.
602        let json = format!(
603            "{{\"true_positive\":{},\"false_positive\":{},\"false_negative\":0,\"samples_seen\":{}}}",
604            u64::MAX,
605            1u64,
606            u64::MAX
607        );
608        assert!(serde_json::from_str::<F1Score>(&json).is_err());
609    }
610
611    #[test]
612    #[cfg(feature = "serde")]
613    fn f1_serde_rejects_tp_fp_fn_overflow() {
614        // tp + fp = u64::MAX (does not overflow), then + 1 overflows.
615        let json = format!(
616            "{{\"true_positive\":{},\"false_positive\":0,\"false_negative\":{},\"samples_seen\":{}}}",
617            u64::MAX,
618            1u64,
619            u64::MAX
620        );
621        assert!(serde_json::from_str::<F1Score>(&json).is_err());
622    }
623
624    #[test]
625    #[cfg(feature = "serde")]
626    fn metric_serde_accepts_max_boundary() {
627        // samples_seen == u64::MAX with TP = u64::MAX, FP/FN = 0 is the
628        // largest legal boundary state and must be accepted.
629        let p_json = format!(
630            "{{\"true_positive\":{},\"false_positive\":0,\"samples_seen\":{}}}",
631            u64::MAX,
632            u64::MAX
633        );
634        let p: Precision = serde_json::from_str(&p_json).unwrap();
635        assert_eq!(p.samples_seen(), u64::MAX);
636
637        let r_json = format!(
638            "{{\"true_positive\":{},\"false_negative\":0,\"samples_seen\":{}}}",
639            u64::MAX,
640            u64::MAX
641        );
642        let r: Recall = serde_json::from_str(&r_json).unwrap();
643        assert_eq!(r.samples_seen(), u64::MAX);
644
645        let f_json = format!(
646            "{{\"true_positive\":{},\"false_positive\":0,\"false_negative\":0,\"samples_seen\":{}}}",
647            u64::MAX,
648            u64::MAX
649        );
650        let f: F1Score = serde_json::from_str(&f_json).unwrap();
651        assert_eq!(f.samples_seen(), u64::MAX);
652    }
653
654    #[test]
655    #[cfg(feature = "serde")]
656    fn recall_serde_rejects_inconsistent_samples_seen() {
657        // samples_seen < tp + fn is internally inconsistent.
658        let json = "{\"true_positive\":5,\"false_negative\":5,\"samples_seen\":3}";
659        assert!(serde_json::from_str::<Recall>(json).is_err());
660    }
661
662    #[test]
663    #[cfg(feature = "serde")]
664    fn f1_serde_rejects_inconsistent_samples_seen() {
665        // samples_seen < tp + fp + fn is internally inconsistent.
666        let json =
667            "{\"true_positive\":2,\"false_positive\":2,\"false_negative\":2,\"samples_seen\":3}";
668        assert!(serde_json::from_str::<F1Score>(json).is_err());
669    }
670}