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}