#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PrPoint {
pub rec_thr_idx: usize,
pub precision: f64,
pub detection_rank: usize,
}
pub fn precision_recall_curve(
tp_cum: &[f64],
fp_cum: &[f64],
num_gt: usize,
rec_thrs: &[f64],
) -> (f64, Vec<PrPoint>) {
let mut scratch = PrCurveScratch::default();
let mut out = Vec::new();
let final_recall =
precision_recall_curve_into(tp_cum, fp_cum, num_gt, rec_thrs, &mut scratch, &mut out);
(
final_recall,
out.iter()
.map(|&(rec_thr_idx, precision, detection_rank)| PrPoint {
rec_thr_idx,
precision,
detection_rank,
})
.collect(),
)
}
#[derive(Debug, Default)]
pub struct PrCurveScratch {
rc: Vec<f64>,
pr: Vec<f64>,
}
pub fn precision_recall_curve_into(
tp_cum: &[f64],
fp_cum: &[f64],
num_gt: usize,
rec_thrs: &[f64],
scratch: &mut PrCurveScratch,
out: &mut Vec<(usize, f64, usize)>,
) -> f64 {
out.clear();
assert_eq!(
tp_cum.len(),
fp_cum.len(),
"precision_recall_curve: tp_cum and fp_cum must be parallel arrays \
(got {} vs {})",
tp_cum.len(),
fp_cum.len()
);
let nd = tp_cum.len();
if nd == 0 || num_gt == 0 {
return 0.0;
}
let num_gt_f = num_gt as f64;
let (rc, pr) = (&mut scratch.rc, &mut scratch.pr);
rc.clear();
pr.clear();
rc.reserve(nd);
pr.reserve(nd);
for d in 0..nd {
rc.push(tp_cum[d] / num_gt_f);
let total = tp_cum[d] + fp_cum[d];
pr.push(if total > 0.0 { tp_cum[d] / total } else { 0.0 });
}
let final_recall = rc[nd - 1];
for d in (0..nd.saturating_sub(1)).rev() {
pr[d] = pr[d].max(pr[d + 1]);
}
out.reserve(rec_thrs.len());
let mut rc_ptr = 0;
for (r_idx, &rec_thr) in rec_thrs.iter().enumerate() {
while rc_ptr < nd && rc[rc_ptr] < rec_thr {
rc_ptr += 1;
}
if rc_ptr < nd {
out.push((r_idx, pr[rc_ptr], rc_ptr));
}
}
final_recall
}
pub fn cumulative_tp_fp(
order: impl IntoIterator<Item = usize>,
matched: &[bool],
ignored: Option<&[bool]>,
tp_cum: &mut Vec<f64>,
fp_cum: &mut Vec<f64>,
) {
tp_cum.clear();
fp_cum.clear();
let (mut tp, mut fp) = (0.0f64, 0.0f64);
for i in order {
if !ignored.is_some_and(|ig| ig[i]) {
if matched[i] {
tp += 1.0;
} else {
fp += 1.0;
}
}
tp_cum.push(tp);
fp_cum.push(fp);
}
}
fn mean_precision_into(
tp_cum: &[f64],
fp_cum: &[f64],
num_gt: usize,
rec_thrs: &[f64],
pr_curve: &mut PrCurveScratch,
curve_out: &mut Vec<(usize, f64, usize)>,
) -> f64 {
precision_recall_curve_into(tp_cum, fp_cum, num_gt, rec_thrs, pr_curve, curve_out);
curve_out
.iter()
.map(|&(_, precision, _)| precision)
.sum::<f64>()
/ rec_thrs.len() as f64
}
#[derive(Debug, Default)]
pub(crate) struct ApScratch {
pr_curve: PrCurveScratch,
tp_cum: Vec<f64>,
fp_cum: Vec<f64>,
curve_out: Vec<(usize, f64, usize)>,
}
fn average_precision_of_order_into(
order: impl IntoIterator<Item = usize>,
matched: &[bool],
ignored: Option<&[bool]>,
num_gt: usize,
rec_thrs: &[f64],
scratch: &mut ApScratch,
) -> f64 {
cumulative_tp_fp(
order,
matched,
ignored,
&mut scratch.tp_cum,
&mut scratch.fp_cum,
);
mean_precision_into(
&scratch.tp_cum,
&scratch.fp_cum,
num_gt,
rec_thrs,
&mut scratch.pr_curve,
&mut scratch.curve_out,
)
}
pub fn average_precision(
scores: &[f64],
matched: &[bool],
ignored: Option<&[bool]>,
num_gt: usize,
rec_thrs: &[f64],
) -> f64 {
assert_eq!(
scores.len(),
matched.len(),
"average_precision: scores and matched must be parallel arrays (got {} vs {})",
scores.len(),
matched.len()
);
if let Some(ig) = ignored {
assert_eq!(
scores.len(),
ig.len(),
"average_precision: scores and ignored must be parallel arrays (got {} vs {})",
scores.len(),
ig.len()
);
}
let nd = scores.len();
if nd == 0 || num_gt == 0 || rec_thrs.is_empty() {
return 0.0;
}
let mut order: Vec<usize> = (0..nd).collect();
order.sort_by(|&a, &b| scores[b].total_cmp(&scores[a]));
let mut scratch = ApScratch::default();
average_precision_of_order_into(order, matched, ignored, num_gt, rec_thrs, &mut scratch)
}
pub fn average_precision_ranked(
matched: &[bool],
ignored: Option<&[bool]>,
num_gt: usize,
rec_thrs: &[f64],
) -> f64 {
let mut scratch = ApScratch::default();
average_precision_ranked_into(matched, ignored, num_gt, rec_thrs, &mut scratch)
}
pub(crate) fn average_precision_ranked_into(
matched: &[bool],
ignored: Option<&[bool]>,
num_gt: usize,
rec_thrs: &[f64],
scratch: &mut ApScratch,
) -> f64 {
if let Some(ig) = ignored {
assert_eq!(
matched.len(),
ig.len(),
"matched and ignored must be parallel arrays (got {} vs {})",
matched.len(),
ig.len()
);
}
let nd = matched.len();
if nd == 0 || num_gt == 0 || rec_thrs.is_empty() {
return 0.0;
}
average_precision_of_order_into(0..nd, matched, ignored, num_gt, rec_thrs, scratch)
}
pub fn average_precision_all_points(tp_cum: &[f64], fp_cum: &[f64], num_gt: usize) -> f64 {
let nd = tp_cum.len();
if nd == 0 || num_gt == 0 {
return 0.0;
}
let n = num_gt as f64;
let mut ap = 0.0;
let mut envelope = 0.0f64;
for i in (0..nd).rev() {
let denom = tp_cum[i] + fp_cum[i];
let precision = if denom > 0.0 { tp_cum[i] / denom } else { 0.0 };
envelope = envelope.max(precision);
let recall_prev = if i == 0 { 0.0 } else { tp_cum[i - 1] / n };
ap += (tp_cum[i] / n - recall_prev) * envelope;
}
ap
}
pub fn f_beta(precision: f64, recall: f64, beta: f64) -> f64 {
let beta2 = beta * beta;
let denom = beta2 * precision + recall;
if denom < f64::EPSILON {
return 0.0;
}
(1.0 + beta2) * precision * recall / denom
}
pub fn max_f_beta(precisions: &[f64], recalls: &[f64], beta: f64) -> Option<f64> {
assert_eq!(
precisions.len(),
recalls.len(),
"max_f_beta: precisions and recalls must be parallel arrays (got {} vs {})",
precisions.len(),
recalls.len()
);
let mut best = f64::NEG_INFINITY;
for (&p, &r) in precisions.iter().zip(recalls) {
if crate::metrics::is_missing(p) || crate::metrics::is_missing(r) {
continue;
}
best = best.max(f_beta(p, r, beta));
}
(best > f64::NEG_INFINITY).then_some(best)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
#[test]
fn precision_recall_curve_is_well_formed() {
let mut rng = StdRng::seed_from_u64(0xC0_1174);
let rec_thrs = crate::params::default_rec_thrs();
for case in 0..5000 {
let nd = rng.random_range(1..=40);
let num_gt = rng.random_range(1..=25);
let (mut tp_cum, mut fp_cum) = (Vec::with_capacity(nd), Vec::with_capacity(nd));
let (mut tp, mut fp) = (0.0f64, 0.0f64);
for _ in 0..nd {
match rng.random_range(0..3) {
0 => tp += 1.0,
1 => fp += 1.0,
_ => {} }
tp_cum.push(tp);
fp_cum.push(fp);
}
let (final_recall, curve) = precision_recall_curve(&tp_cum, &fp_cum, num_gt, &rec_thrs);
let ctx = format!("case {case}: nd={nd} num_gt={num_gt}");
assert!(
(final_recall - tp_cum[nd - 1] / num_gt as f64).abs() < 1e-12,
"{ctx}: final_recall {final_recall} disagrees with tp_cum/num_gt"
);
let mut prev_r_idx: Option<usize> = None;
let mut prev_precision = f64::INFINITY;
let mut prev_ptr = 0usize;
for &PrPoint {
rec_thr_idx: r_idx,
precision,
detection_rank: ptr,
} in &curve
{
assert!(r_idx < rec_thrs.len(), "{ctx}: r_idx {r_idx} out of range");
assert!(ptr < nd, "{ctx}: detection_ptr {ptr} out of range");
assert!(
(0.0..=1.0).contains(&precision),
"{ctx}: precision {precision} outside [0,1]"
);
if let Some(prev) = prev_r_idx {
assert!(r_idx > prev, "{ctx}: r_idx went {prev} -> {r_idx}");
assert!(
precision <= prev_precision + 1e-12,
"{ctx}: precision rose {prev_precision} -> {precision} at r_idx {r_idx}"
);
assert!(
ptr >= prev_ptr,
"{ctx}: detection_ptr went backwards {prev_ptr} -> {ptr}"
);
}
assert!(
tp_cum[ptr] / num_gt as f64 >= rec_thrs[r_idx] - 1e-12,
"{ctx}: r_idx {r_idx} emitted at ptr {ptr} which does not reach it"
);
prev_r_idx = Some(r_idx);
prev_precision = precision;
prev_ptr = ptr;
}
let reachable = rec_thrs.iter().filter(|&&t| final_recall >= t).count();
assert_eq!(
curve.len(),
reachable,
"{ctx}: emitted {} points for {reachable} reachable thresholds \
(final_recall {final_recall})",
curve.len()
);
}
}
#[test]
fn all_points_ap_matches_hand_derived_values() {
assert_eq!(average_precision_all_points(&[1.0], &[0.0], 2), 0.5);
assert_eq!(
average_precision_all_points(&[1.0, 2.0], &[0.0, 0.0], 2),
1.0
);
assert_eq!(
average_precision_all_points(&[0.0, 1.0], &[1.0, 1.0], 1),
0.5
);
let grid = average_precision(&[0.9], &[true], None, 2, &crate::params::default_rec_thrs());
assert!((grid - 51.0 / 101.0).abs() < 1e-12);
assert!(
(grid - 0.5).abs() > 1e-3,
"the two integrations must actually differ"
);
assert_eq!(average_precision_all_points(&[], &[], 5), 0.0);
assert_eq!(average_precision_all_points(&[1.0], &[0.0], 0), 0.0);
}
#[test]
fn f_beta_algebraic_properties() {
let mut rng = StdRng::seed_from_u64(0xFBE7A);
for case in 0..20000 {
let p: f64 = rng.random_range(0.0..=1.0);
let r: f64 = rng.random_range(0.0..=1.0);
let beta: f64 = rng.random_range(0.1..=5.0);
let f = f_beta(p, r, beta);
let ctx = format!("case {case}: p={p} r={r} beta={beta}");
assert!((0.0..=1.0).contains(&f), "{ctx}: f_beta {f} outside [0,1]");
assert!(f <= p.max(r) + 1e-12, "{ctx}: f_beta {f} above max(p,r)");
assert!(f >= p.min(r) - 1e-12, "{ctx}: f_beta {f} below min(p,r)");
let equal = f_beta(p, p, beta);
assert!(
(equal - p).abs() < 1e-12,
"{ctx}: f_beta(p, p, beta) = {equal}, expected {p}"
);
if let Some(best) = max_f_beta(&[p], &[r], beta) {
assert!(
(best - f).abs() < 1e-12,
"{ctx}: max over one point != that point"
);
}
}
}
#[test]
fn f_beta_at_one_is_the_harmonic_mean() {
assert!((f_beta(0.5, 0.5, 1.0) - 0.5).abs() < 1e-12);
assert!((f_beta(1.0, 0.5, 1.0) - 2.0 / 3.0).abs() < 1e-12);
assert_eq!(f_beta(0.0, 0.0, 1.0), 0.0);
}
#[test]
fn beta_shifts_the_weight_between_precision_and_recall() {
let (p, r) = (0.9, 0.3);
assert!(f_beta(p, r, 0.5) > f_beta(p, r, 1.0));
assert!(f_beta(p, r, 2.0) < f_beta(p, r, 1.0));
}
#[test]
fn max_f_beta_sweeps_the_curve_for_the_best_point() {
let precisions = [1.0, 0.6, 0.2];
let recalls = [0.1, 0.6, 0.9];
let best = max_f_beta(&precisions, &recalls, 1.0).expect("a valid point exists");
assert!((best - 0.6).abs() < 1e-12);
}
#[test]
fn max_f_beta_skips_the_missing_data_sentinel() {
assert_eq!(max_f_beta(&[-1.0, -1.0], &[0.5, 0.5], 1.0), None);
let best = max_f_beta(&[-1.0, 0.5], &[0.1, 0.5], 1.0).expect("one valid point");
assert!((best - 0.5).abs() < 1e-12);
}
#[test]
fn empty_or_no_gt_is_zero() {
assert_eq!(precision_recall_curve(&[], &[], 5, &[0.5]), (0.0, vec![]));
assert_eq!(
precision_recall_curve(&[1.0], &[0.0], 0, &[0.5]),
(0.0, vec![])
);
}
#[test]
fn perfect_detections_precision_one() {
let tp = [1.0, 2.0, 3.0, 4.0];
let fp = [0.0, 0.0, 0.0, 0.0];
let (final_recall, curve) = precision_recall_curve(&tp, &fp, 4, &[0.0, 0.5, 1.0]);
assert!((final_recall - 1.0).abs() < 1e-12);
assert_eq!(curve.len(), 3);
for p in &curve {
assert!((p.precision - 1.0).abs() < 1e-12);
}
}
#[test]
fn unreachable_recall_thresholds_omitted() {
let tp = [1.0, 1.0];
let fp = [0.0, 1.0];
let (final_recall, curve) = precision_recall_curve(&tp, &fp, 4, &[0.1, 0.25, 0.5, 1.0]);
assert!((final_recall - 0.25).abs() < 1e-12);
assert_eq!(
curve.iter().map(|c| c.rec_thr_idx).collect::<Vec<_>>(),
vec![0, 1]
);
}
#[test]
fn voc_interpolation_makes_precision_monotone() {
let tp = [1.0, 1.0, 2.0];
let fp = [0.0, 1.0, 1.0];
let (_, curve) = precision_recall_curve(&tp, &fp, 3, &[0.5]);
assert_eq!(curve.len(), 1);
let p = curve[0];
assert_eq!(p.detection_rank, 2);
assert!((p.precision - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn nan_scores_rank_deterministically_instead_of_scrambling() {
let rec_thrs = crate::params::default_rec_thrs();
let ap = average_precision(&[f64::NAN, 0.9], &[false, true], None, 1, &rec_thrs);
assert!((ap - 0.5).abs() < 1e-12, "got {ap}");
let ap = average_precision(&[f64::NAN, 0.9], &[true, false], None, 1, &rec_thrs);
assert!((ap - 1.0).abs() < 1e-12, "got {ap}");
let ap = average_precision(&[-f64::NAN, 0.9], &[false, true], None, 1, &rec_thrs);
assert!((ap - 1.0).abs() < 1e-12, "got {ap}");
let scores: Vec<f64> = (0..50)
.map(|i| {
if i % 7 == 0 {
f64::NAN
} else {
i as f64 / 50.0
}
})
.collect();
let matched: Vec<bool> = (0..50).map(|i| i % 2 == 0).collect();
let ap = average_precision(&scores, &matched, None, 25, &rec_thrs);
assert!(ap.is_finite());
}
#[test]
fn sorted_and_ranked_entry_points_agree() {
let mut rng = StdRng::seed_from_u64(0xAB5EED);
let rec_thrs = crate::params::default_rec_thrs();
for _ in 0..500 {
let nd = rng.random_range(1..=20);
let num_gt = rng.random_range(1..=10);
let mut scores: Vec<f64> = (0..nd).map(|_| rng.random_range(0.0..=1.0)).collect();
scores.sort_by(|a, b| b.total_cmp(a));
let matched: Vec<bool> = (0..nd).map(|_| rng.random_bool(0.5)).collect();
let ignored: Vec<bool> = (0..nd).map(|_| rng.random_bool(0.2)).collect();
let a = average_precision(&scores, &matched, Some(&ignored), num_gt, &rec_thrs);
let b = average_precision_ranked(&matched, Some(&ignored), num_gt, &rec_thrs);
assert_eq!(a, b, "sorted input must make the two forms bit-identical");
}
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn average_precision_rejects_mismatched_lengths() {
average_precision(&[0.9, 0.8], &[true], None, 1, &[0.5]);
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn average_precision_rejects_mismatched_ignored() {
average_precision(&[0.9], &[true], Some(&[false, true]), 1, &[0.5]);
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn average_precision_ranked_rejects_mismatched_ignored() {
average_precision_ranked(&[true, false], Some(&[false]), 1, &[0.5]);
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn precision_recall_curve_rejects_mismatched_lengths() {
precision_recall_curve(&[1.0, 2.0], &[0.0], 2, &[0.5]);
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn max_f_beta_rejects_mismatched_lengths() {
max_f_beta(&[0.5, 0.6], &[0.5], 1.0);
}
#[test]
fn max_f_beta_skips_the_sentinel_in_recalls() {
assert_eq!(max_f_beta(&[0.5, 0.5], &[-1.0, -1.0], 1.0), None);
let best = max_f_beta(&[0.5, 0.8], &[-1.0, 0.8], 1.0).expect("one valid point");
assert!((best - 0.8).abs() < 1e-12);
let best = max_f_beta(&[-1.0, 0.6, 0.9], &[0.4, -1.0, 0.9], 1.0).expect("one valid point");
assert!((best - 0.9).abs() < 1e-12);
}
}