Skip to main content

plotters_statistical/stats/
precision_recall.rs

1//! Precision–recall curve: threshold sweep producing (recall, precision) points
2//! and average precision (AP).
3
4use super::StatsError;
5
6/// A single operating point on a precision–recall curve.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct PrPoint {
9    /// Recall (true positive rate), `TP / P`.
10    pub recall: f64,
11    /// Precision, `TP / (TP + FP)`.
12    pub precision: f64,
13    /// The score threshold at which this point is reached.
14    pub threshold: f64,
15}
16
17/// A precision–recall curve: operating points, average precision, and the
18/// positive-class prevalence (the curve's chance baseline).
19#[derive(Debug, Clone, PartialEq)]
20pub struct PrCurve {
21    /// Operating points, ascending in recall.
22    pub points: Vec<PrPoint>,
23    /// Average precision — the step-function area under the PR curve, using the
24    /// `sum_n (R_n - R_{n-1}) * P_n` definition (matching scikit-learn's
25    /// `average_precision_score`), **not** a trapezoidal interpolation.
26    pub average_precision: f64,
27    /// Positive-class prevalence, `P / (P + N)`. A no-skill classifier's PR
28    /// curve is a horizontal line at this height — the correct PR baseline (a
29    /// diagonal, which is the ROC baseline, would be wrong here).
30    pub baseline: f64,
31}
32
33/// Compute a precision–recall curve from predicted `scores` and true binary
34/// `labels` (`true` = positive class). Higher scores are "more positive".
35///
36/// # Errors
37///
38/// * [`StatsError::LengthMismatch`] if the inputs differ in length.
39/// * [`StatsError::EmptyInput`] if empty.
40/// * [`StatsError::NoPositiveLabels`] if there are no positives, since recall is
41///   `0/0`. (Unlike ROC, PR does not require any negatives.)
42pub fn precision_recall_curve(scores: &[f64], labels: &[bool]) -> Result<PrCurve, StatsError> {
43    if scores.len() != labels.len() {
44        return Err(StatsError::LengthMismatch {
45            scores: scores.len(),
46            labels: labels.len(),
47        });
48    }
49    if scores.is_empty() {
50        return Err(StatsError::EmptyInput);
51    }
52    let total_p = labels.iter().filter(|&&l| l).count();
53    if total_p == 0 {
54        return Err(StatsError::NoPositiveLabels);
55    }
56    let baseline = total_p as f64 / labels.len() as f64;
57
58    let mut order: Vec<usize> = (0..scores.len()).collect();
59    order.sort_by(|&a, &b| {
60        scores[b]
61            .partial_cmp(&scores[a])
62            .unwrap_or(std::cmp::Ordering::Equal)
63    });
64
65    let p = total_p as f64;
66    let mut points = Vec::new();
67    let mut tp = 0usize;
68    let mut fp = 0usize;
69    let mut i = 0usize;
70    let mut prev_recall = 0.0;
71    let mut average_precision = 0.0;
72
73    while i < order.len() {
74        let score = scores[order[i]];
75        while i < order.len() && scores[order[i]] == score {
76            if labels[order[i]] {
77                tp += 1;
78            } else {
79                fp += 1;
80            }
81            i += 1;
82        }
83        let recall = tp as f64 / p;
84        let precision = if tp + fp == 0 {
85            1.0
86        } else {
87            tp as f64 / (tp + fp) as f64
88        };
89        average_precision += (recall - prev_recall) * precision;
90        prev_recall = recall;
91        points.push(PrPoint {
92            recall,
93            precision,
94            threshold: score,
95        });
96    }
97
98    Ok(PrCurve {
99        points,
100        average_precision,
101        baseline,
102    })
103}