use core::cmp::Ordering;
const FINFO_F64_EPS: f64 = f64::EPSILON;
#[must_use]
pub fn roc_auc_score(y_true: &[usize], y_score: &[f32]) -> f32 {
assert_eq!(
y_true.len(),
y_score.len(),
"roc_auc_score: y_true/y_score length mismatch"
);
let n = y_true.len();
if n == 0 {
return f32::NAN;
}
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
y_score[a]
.partial_cmp(&y_score[b])
.unwrap_or(Ordering::Equal)
});
let mut ranks = vec![0.0f32; n];
let mut i = 0;
while i < n {
let mut j = i;
while j + 1 < n && y_score[idx[j + 1]] == y_score[idx[i]] {
j += 1;
}
let avg_rank = (i + j) as f32 / 2.0 + 1.0; for &orig in &idx[i..=j] {
ranks[orig] = avg_rank;
}
i = j + 1;
}
let n_pos = y_true.iter().filter(|&&y| y == 1).count();
let n_neg = n - n_pos;
if n_pos == 0 || n_neg == 0 {
return f32::NAN;
}
let sum_ranks_pos: f32 = (0..n).filter(|&k| y_true[k] == 1).map(|k| ranks[k]).sum();
(sum_ranks_pos - (n_pos * (n_pos + 1)) as f32 / 2.0) / (n_pos as f32 * n_neg as f32)
}
#[must_use]
pub fn log_loss(y_true: &[usize], y_prob: &[f32]) -> f32 {
assert_eq!(
y_true.len(),
y_prob.len(),
"log_loss: y_true/y_prob length mismatch"
);
let n = y_true.len();
if n == 0 {
return 0.0;
}
let mut sum = 0.0f64;
for k in 0..n {
let p = f64::from(y_prob[k]).clamp(FINFO_F64_EPS, 1.0 - FINFO_F64_EPS);
let y = y_true[k] as f64;
sum += -(y * p.ln() + (1.0 - y) * (1.0 - p).ln());
}
(sum / n as f64) as f32
}
#[must_use]
pub fn average_precision_score(y_true: &[usize], y_score: &[f32]) -> f32 {
assert_eq!(
y_true.len(),
y_score.len(),
"average_precision_score: y_true/y_score length mismatch"
);
let n = y_true.len();
let n_pos = y_true.iter().filter(|&&y| y == 1).count();
if n == 0 || n_pos == 0 {
return 0.0;
}
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
y_score[b]
.partial_cmp(&y_score[a])
.unwrap_or(Ordering::Equal)
});
let (mut tp, mut fp) = (0usize, 0usize);
let mut ap = 0.0f64;
let mut prev_recall = 0.0f64;
let mut i = 0;
while i < n {
let mut j = i;
while j < n && y_score[idx[j]] == y_score[idx[i]] {
if y_true[idx[j]] == 1 {
tp += 1;
} else {
fp += 1;
}
j += 1;
}
let recall = tp as f64 / n_pos as f64;
let precision = tp as f64 / (tp + fp) as f64;
ap += (recall - prev_recall) * precision;
prev_recall = recall;
i = j;
}
ap as f32
}
fn binary_clf_curve(y_true: &[usize], y_score: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
let n = y_true.len();
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| {
y_score[b]
.partial_cmp(&y_score[a])
.unwrap_or(Ordering::Equal)
});
let (mut fps, mut tps, mut thr) = (Vec::new(), Vec::new(), Vec::new());
let (mut tp, mut fp) = (0.0f32, 0.0f32);
let mut i = 0;
while i < n {
let s = y_score[idx[i]];
while i < n && y_score[idx[i]] == s {
if y_true[idx[i]] == 1 {
tp += 1.0;
} else {
fp += 1.0;
}
i += 1;
}
fps.push(fp);
tps.push(tp);
thr.push(s);
}
(fps, tps, thr)
}
#[must_use]
pub fn roc_curve(y_true: &[usize], y_score: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
assert_eq!(
y_true.len(),
y_score.len(),
"roc_curve: y_true/y_score length mismatch"
);
let (fps, tps, thr) = binary_clf_curve(y_true, y_score);
let m = fps.len();
let apply_drop = m > 2;
let (mut kfps, mut ktps, mut kthr) = (Vec::new(), Vec::new(), Vec::new());
for i in 0..m {
let keep = !apply_drop
|| i == 0
|| i == m - 1
|| (fps[i + 1] - fps[i]) - (fps[i] - fps[i - 1]) != 0.0
|| (tps[i + 1] - tps[i]) - (tps[i] - tps[i - 1]) != 0.0;
if keep {
kfps.push(fps[i]);
ktps.push(tps[i]);
kthr.push(thr[i]);
}
}
let n_neg = fps.last().copied().unwrap_or(0.0);
let n_pos = tps.last().copied().unwrap_or(0.0);
let mut fpr = vec![0.0f32];
let mut tpr = vec![0.0f32];
let mut thresholds = vec![f32::INFINITY];
for i in 0..kfps.len() {
fpr.push(if n_neg > 0.0 {
kfps[i] / n_neg
} else {
f32::NAN
});
tpr.push(if n_pos > 0.0 {
ktps[i] / n_pos
} else {
f32::NAN
});
thresholds.push(kthr[i]);
}
(fpr, tpr, thresholds)
}
#[must_use]
pub fn precision_recall_curve(y_true: &[usize], y_score: &[f32]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
assert_eq!(
y_true.len(),
y_score.len(),
"precision_recall_curve: y_true/y_score length mismatch"
);
let (fps, tps, thr) = binary_clf_curve(y_true, y_score);
let n_pos = tps.last().copied().unwrap_or(0.0);
let m = fps.len();
let mut precision = Vec::with_capacity(m + 1);
let mut recall = Vec::with_capacity(m + 1);
for i in (0..m).rev() {
let denom = tps[i] + fps[i];
precision.push(if denom > 0.0 { tps[i] / denom } else { 1.0 });
recall.push(if n_pos > 0.0 { tps[i] / n_pos } else { 0.0 });
}
precision.push(1.0);
recall.push(0.0);
let thresholds: Vec<f32> = thr.iter().rev().copied().collect();
(precision, recall, thresholds)
}
#[cfg(test)]
mod tests {
use super::*;
const YT: [usize; 8] = [0, 0, 1, 1, 1, 0, 1, 0];
const YS: [f32; 8] = [0.1, 0.4, 0.35, 0.8, 0.7, 0.2, 0.9, 0.55];
#[test]
fn roc_auc_matches_sklearn() {
assert!((roc_auc_score(&YT, &YS) - 0.875).abs() < 1e-4);
assert!((roc_auc_score(&[0, 0, 1, 1], &[0.1, 0.2, 0.8, 0.9]) - 1.0).abs() < 1e-4);
assert!((roc_auc_score(&[0, 1, 0, 1], &[0.5, 0.5, 0.5, 0.9]) - 0.75).abs() < 1e-4);
assert!(roc_auc_score(&[1, 1], &[0.5, 0.6]).is_nan());
}
#[test]
fn log_loss_matches_sklearn() {
assert!((log_loss(&YT, &YS) - 0.421_605).abs() < 1e-4);
assert!(log_loss(&[0, 1], &[1e-9, 1.0 - 1e-9]) < 1e-3);
}
#[test]
fn log_loss_clamps_finfo_eps_at_bounds() {
let got = log_loss(&[0, 1], &[0.0, 1.0]);
assert!(got.is_finite(), "log(0) leaked inf/nan: {got}");
let sklearn = 2.220_446e-16_f32;
assert!(
(got - sklearn).abs() < 1e-18,
"log_loss bound-clamp diverges from sklearn: apr={got}, sklearn={sklearn}"
);
}
#[test]
fn average_precision_matches_sklearn() {
assert!((average_precision_score(&YT, &YS) - 0.916_667).abs() < 1e-4);
assert!((average_precision_score(&[0, 0, 1, 1], &[0.1, 0.2, 0.8, 0.9]) - 1.0).abs() < 1e-4);
assert!((average_precision_score(&[1, 1, 0, 0], &[0.9, 0.8, 0.2, 0.1]) - 1.0).abs() < 1e-4);
}
#[test]
fn average_precision_no_positives_is_zero() {
let got = average_precision_score(&[0, 0, 0], &[0.1, 0.2, 0.3]);
assert!(!got.is_nan(), "AP with no positives returned nan, not 0.0");
assert!(
(got - 0.0).abs() < 1e-7,
"AP no-positive diverges from sklearn (expected 0.0): apr={got}"
);
assert_eq!(average_precision_score(&[0, 0], &[0.1, 0.2]), 0.0);
}
fn approx_vec(got: &[f32], want: &[f32], tol: f32, what: &str) {
assert_eq!(got.len(), want.len(), "{what}: length {got:?} != {want:?}");
for (i, (&g, &w)) in got.iter().zip(want).enumerate() {
if w.is_infinite() {
assert!(
g.is_infinite() && g.signum() == w.signum(),
"{what}[{i}]: {g} != {w}"
);
} else {
assert!((g - w).abs() < tol, "{what}[{i}]: {g} != {w}");
}
}
}
#[test]
fn roc_curve_matches_sklearn() {
let (fpr, tpr, thr) = roc_curve(&YT, &YS);
approx_vec(&fpr, &[0.0, 0.0, 0.0, 0.5, 0.5, 1.0], 1e-4, "fpr");
approx_vec(&tpr, &[0.0, 0.25, 0.75, 0.75, 1.0, 1.0], 1e-4, "tpr");
approx_vec(
&thr,
&[f32::INFINITY, 0.9, 0.7, 0.4, 0.35, 0.1],
1e-4,
"thresholds",
);
let (fpr, tpr, _) = roc_curve(&[0, 0, 1, 1], &[0.1, 0.2, 0.8, 0.9]);
approx_vec(&fpr, &[0.0, 0.0, 0.0, 1.0], 1e-4, "sep fpr");
approx_vec(&tpr, &[0.0, 0.5, 1.0, 1.0], 1e-4, "sep tpr");
}
#[test]
fn precision_recall_curve_matches_sklearn() {
let (prec, rec, thr) = precision_recall_curve(&YT, &YS);
approx_vec(
&prec,
&[0.5, 0.571_429, 0.666_667, 0.6, 0.75, 1.0, 1.0, 1.0, 1.0],
1e-4,
"precision",
);
approx_vec(
&rec,
&[1.0, 1.0, 1.0, 0.75, 0.75, 0.75, 0.5, 0.25, 0.0],
1e-4,
"recall",
);
approx_vec(
&thr,
&[0.1, 0.2, 0.35, 0.4, 0.55, 0.7, 0.8, 0.9],
1e-4,
"thresholds",
);
assert_eq!(prec.len(), rec.len(), "precision/recall length parity");
assert_eq!(prec.len(), thr.len() + 1, "sentinel: len(P) == len(thr)+1");
}
}