use ahash::AHashMap;
use ndarray::{Array1, Array2, ArrayBase, ArrayView2, Axis, Data, Ix1, Ix2};
use super::validate_pair;
const POSITIVE_THRESHOLD: f64 = 0.5;
const DEGENERATE_DENOM: f64 = 1e-10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Average {
Macro,
Micro,
Weighted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConfusionMatrix {
tp: usize,
fp: usize,
tn: usize,
fn_: usize,
}
impl ConfusionMatrix {
pub fn new<S>(y_true: &ArrayBase<S, Ix1>, y_pred: &ArrayBase<S, Ix1>) -> Self
where
S: Data<Elem = f64>,
{
validate_pair(y_true.len(), y_pred.len(), "y_true and y_pred");
let mut tp = 0;
let mut fp = 0;
let mut tn = 0;
let mut fn_ = 0;
for (&t, &p) in y_true.iter().zip(y_pred.iter()) {
let true_positive = t >= POSITIVE_THRESHOLD;
let pred_positive = p >= POSITIVE_THRESHOLD;
match (true_positive, pred_positive) {
(true, true) => tp += 1,
(false, true) => fp += 1,
(true, false) => fn_ += 1,
(false, false) => tn += 1,
}
}
Self { tp, fp, tn, fn_ }
}
pub fn get_counts(&self) -> (usize, usize, usize, usize) {
(self.tp, self.fp, self.tn, self.fn_)
}
pub fn accuracy(&self) -> f64 {
let total = self.tp + self.tn + self.fp + self.fn_;
if total == 0 {
return 0.0;
}
(self.tp + self.tn) as f64 / total as f64
}
pub fn error_rate(&self) -> f64 {
1.0 - self.accuracy()
}
pub fn precision(&self) -> f64 {
if self.tp + self.fp == 0 {
return 0.0;
}
self.tp as f64 / (self.tp + self.fp) as f64
}
pub fn recall(&self) -> f64 {
if self.tp + self.fn_ == 0 {
return 0.0;
}
self.tp as f64 / (self.tp + self.fn_) as f64
}
pub fn specificity(&self) -> f64 {
if self.tn + self.fp == 0 {
return 1.0;
}
self.tn as f64 / (self.tn + self.fp) as f64
}
pub fn f1_score(&self) -> f64 {
let precision = self.precision();
let recall = self.recall();
if precision + recall == 0.0 {
return 0.0;
}
2.0 * (precision * recall) / (precision + recall)
}
pub fn mcc(&self) -> f64 {
let tp = self.tp as f64;
let tn = self.tn as f64;
let fp = self.fp as f64;
let fn_ = self.fn_ as f64;
let numerator = tp * tn - fp * fn_;
let denominator = ((tp + fp) * (tp + fn_) * (tn + fp) * (tn + fn_)).sqrt();
if denominator == 0.0 {
0.0
} else {
numerator / denominator
}
}
pub fn balanced_accuracy(&self) -> f64 {
(self.recall() + self.specificity()) / 2.0
}
pub fn summary(&self) -> String {
let sep = "+-----------------+--------------------+--------------------+";
format!(
"Confusion Matrix:\n\
{sep}\n\
| {:<15} | {:<18} | {:<18} |\n\
{sep}\n\
| {:<15} | {:<18} | {:<18} |\n\
| {:<15} | {:<18} | {:<18} |\n\
{sep}\n\
\n\
Performance Metrics:\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}\n\
- {:<18} {:.4}",
"",
"Predicted Positive",
"Predicted Negative",
"Actual Positive",
format!("TP: {}", self.tp),
format!("FN: {}", self.fn_),
"Actual Negative",
format!("FP: {}", self.fp),
format!("TN: {}", self.tn),
"Accuracy:",
self.accuracy(),
"Balanced Accuracy:",
self.balanced_accuracy(),
"Error Rate:",
self.error_rate(),
"Precision:",
self.precision(),
"Recall:",
self.recall(),
"Specificity:",
self.specificity(),
"F1 Score:",
self.f1_score(),
"MCC:",
self.mcc(),
)
}
}
pub fn accuracy<S>(y_true: &ArrayBase<S, Ix1>, y_pred: &ArrayBase<S, Ix1>) -> f64
where
S: Data<Elem = f64>,
{
validate_pair(y_true.len(), y_pred.len(), "y_true and y_pred");
let correct = y_true
.iter()
.zip(y_pred.iter())
.filter(|&(t, p)| (t - p).abs() < f64::EPSILON)
.count();
correct as f64 / y_true.len() as f64
}
#[inline]
fn reject_nan_scores<S>(scores: &ArrayBase<S, Ix1>)
where
S: Data<Elem = f64>,
{
if scores.iter().any(|s| s.is_nan()) {
panic!("invalid input: scores must not contain NaN");
}
}
pub fn roc_auc<S1, S2>(labels: &ArrayBase<S1, Ix1>, scores: &ArrayBase<S2, Ix1>) -> f64
where
S1: Data<Elem = bool>,
S2: Data<Elem = f64>,
{
validate_pair(labels.len(), scores.len(), "labels and scores");
reject_nan_scores(scores);
let mut pairs: Vec<(f64, bool)> = scores
.iter()
.zip(labels.iter())
.map(|(&s, &l)| (s, l))
.collect();
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut pos_count = 0usize;
let mut neg_count = 0usize;
let mut sum_positive_ranks = 0.0;
let n = pairs.len();
let mut i = 0;
let mut rank = 1.0;
while i < n {
let current_score = pairs[i].0;
let mut j = i;
while j < n && pairs[j].0 == current_score {
j += 1;
}
let count = (j - i) as f64;
let avg_rank = (2.0 * rank + count - 1.0) / 2.0;
for pair in &pairs[i..j] {
if pair.1 {
sum_positive_ranks += avg_rank;
pos_count += 1;
} else {
neg_count += 1;
}
}
rank += count;
i = j;
}
if pos_count == 0 || neg_count == 0 {
panic!("invalid input: ROC AUC requires at least one positive and one negative label");
}
let u = sum_positive_ranks - (pos_count as f64 * (pos_count as f64 + 1.0) / 2.0);
u / (pos_count as f64 * neg_count as f64)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MulticlassConfusionMatrix {
matrix: Array2<usize>,
labels: Vec<usize>,
}
impl MulticlassConfusionMatrix {
pub fn new<S>(y_true: &ArrayBase<S, Ix1>, y_pred: &ArrayBase<S, Ix1>) -> Self
where
S: Data<Elem = usize>,
{
validate_pair(y_true.len(), y_pred.len(), "y_true and y_pred");
let mut labels: Vec<usize> = y_true.iter().chain(y_pred.iter()).copied().collect();
labels.sort_unstable();
labels.dedup();
let index: AHashMap<usize, usize> =
labels.iter().enumerate().map(|(i, &l)| (l, i)).collect();
let k = labels.len();
let mut matrix = Array2::<usize>::zeros((k, k));
for (&t, &p) in y_true.iter().zip(y_pred.iter()) {
matrix[[index[&t], index[&p]]] += 1;
}
Self { matrix, labels }
}
pub fn matrix(&self) -> ArrayView2<'_, usize> {
self.matrix.view()
}
pub fn labels(&self) -> &[usize] {
&self.labels
}
pub fn n_classes(&self) -> usize {
self.labels.len()
}
pub fn support(&self) -> Vec<usize> {
self.matrix.sum_axis(Axis(1)).to_vec()
}
pub fn accuracy(&self) -> f64 {
let total: usize = self.matrix.sum();
if total == 0 {
return 0.0;
}
let correct: usize = (0..self.labels.len()).map(|i| self.matrix[[i, i]]).sum();
correct as f64 / total as f64
}
pub fn per_class_precision(&self) -> Vec<f64> {
let predicted = self.matrix.sum_axis(Axis(0));
(0..self.labels.len())
.map(|i| {
let tp = self.matrix[[i, i]];
if predicted[i] == 0 {
0.0
} else {
tp as f64 / predicted[i] as f64
}
})
.collect()
}
pub fn per_class_recall(&self) -> Vec<f64> {
let actual = self.matrix.sum_axis(Axis(1));
(0..self.labels.len())
.map(|i| {
let tp = self.matrix[[i, i]];
if actual[i] == 0 {
0.0
} else {
tp as f64 / actual[i] as f64
}
})
.collect()
}
pub fn per_class_f1(&self) -> Vec<f64> {
self.per_class_precision()
.iter()
.zip(self.per_class_recall().iter())
.map(|(&p, &r)| {
if p + r == 0.0 {
0.0
} else {
2.0 * p * r / (p + r)
}
})
.collect()
}
pub fn precision(&self, average: Average) -> f64 {
self.aggregate(&self.per_class_precision(), average)
}
pub fn recall(&self, average: Average) -> f64 {
self.aggregate(&self.per_class_recall(), average)
}
pub fn f1(&self, average: Average) -> f64 {
self.aggregate(&self.per_class_f1(), average)
}
fn aggregate(&self, per_class: &[f64], average: Average) -> f64 {
match average {
Average::Micro => self.accuracy(),
Average::Macro => {
if per_class.is_empty() {
0.0
} else {
per_class.iter().sum::<f64>() / per_class.len() as f64
}
}
Average::Weighted => {
let support = self.support();
let total: usize = support.iter().sum();
if total == 0 {
return 0.0;
}
per_class
.iter()
.zip(support.iter())
.map(|(&m, &s)| m * s as f64)
.sum::<f64>()
/ total as f64
}
}
}
pub fn summary(&self) -> String {
let width = self
.labels
.iter()
.map(|label| label.to_string().len())
.chain(self.matrix.iter().map(|count| count.to_string().len()))
.max()
.unwrap_or(1);
let mut border = String::from("+");
for _ in 0..=self.labels.len() {
border.push_str(&"-".repeat(width + 2));
border.push('+');
}
let make_row = |header: &str, cells: Vec<String>| -> String {
let mut line = format!("| {header:>width$} |");
for cell in cells {
line.push_str(&format!(" {cell:>width$} |"));
}
line
};
let mut out = String::from("Confusion Matrix (rows = true, columns = predicted):\n");
out.push_str(&border);
out.push('\n');
out.push_str(&make_row(
"",
self.labels.iter().map(|l| l.to_string()).collect(),
));
out.push('\n');
out.push_str(&border);
out.push('\n');
for (i, &label) in self.labels.iter().enumerate() {
let counts = self
.matrix
.row(i)
.iter()
.map(|count| count.to_string())
.collect();
out.push_str(&make_row(&label.to_string(), counts));
out.push('\n');
}
out.push_str(&border);
out.push('\n');
out.push('\n');
let precision = self.per_class_precision();
let recall = self.per_class_recall();
let f1 = self.per_class_f1();
let support = self.support();
let total: usize = support.iter().sum();
out.push_str(&format!(
"{:>12} {:>10} {:>10} {:>10} {:>10}\n\n",
"", "precision", "recall", "f1-score", "support"
));
for (i, &label) in self.labels.iter().enumerate() {
out.push_str(&format!(
"{:>12} {:>10.4} {:>10.4} {:>10.4} {:>10}\n",
label, precision[i], recall[i], f1[i], support[i]
));
}
out.push('\n');
out.push_str(&format!(
"{:>12} {:>10} {:>10} {:>10.4} {:>10}\n",
"accuracy",
"",
"",
self.accuracy(),
total
));
out.push_str(&format!(
"{:>12} {:>10.4} {:>10.4} {:>10.4} {:>10}\n",
"macro avg",
self.precision(Average::Macro),
self.recall(Average::Macro),
self.f1(Average::Macro),
total
));
out.push_str(&format!(
"{:>12} {:>10.4} {:>10.4} {:>10.4} {:>10}",
"weighted avg",
self.precision(Average::Weighted),
self.recall(Average::Weighted),
self.f1(Average::Weighted),
total
));
out
}
}
pub fn log_loss<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_prob: &ArrayBase<S2, Ix2>) -> f64
where
S1: Data<Elem = usize>,
S2: Data<Elem = f64>,
{
let n = y_true.len();
if n != y_prob.nrows() {
panic!("dimension mismatch: expected {n}, found {}", y_prob.nrows());
}
if n == 0 {
panic!("input is empty: y_true and y_prob");
}
const EPS: f64 = f64::EPSILON;
let n_classes = y_prob.ncols();
let mut total = 0.0;
for (i, &label) in y_true.iter().enumerate() {
if label >= n_classes {
panic!(
"invalid input: label {label} is out of range for {n_classes} probability columns"
);
}
let row_sum: f64 = y_prob.row(i).sum();
let p = (y_prob[[i, label]] / row_sum.max(EPS)).clamp(EPS, 1.0 - EPS);
total -= p.ln();
}
total / n as f64
}
pub fn cohen_kappa<S>(y_true: &ArrayBase<S, Ix1>, y_pred: &ArrayBase<S, Ix1>) -> f64
where
S: Data<Elem = usize>,
{
let cm = MulticlassConfusionMatrix::new(y_true, y_pred);
let n = cm.matrix.sum() as f64;
let actual = cm.matrix.sum_axis(Axis(1));
let predicted = cm.matrix.sum_axis(Axis(0));
let observed = cm.accuracy();
let expected: f64 = actual
.iter()
.zip(predicted.iter())
.map(|(&a, &p)| (a as f64 / n) * (p as f64 / n))
.sum();
if (1.0 - expected).abs() < DEGENERATE_DENOM {
1.0
} else {
(observed - expected) / (1.0 - expected)
}
}
pub fn top_k_accuracy<S1, S2>(
y_true: &ArrayBase<S1, Ix1>,
y_prob: &ArrayBase<S2, Ix2>,
k: usize,
) -> f64
where
S1: Data<Elem = usize>,
S2: Data<Elem = f64>,
{
let n = y_true.len();
if n != y_prob.nrows() {
panic!("dimension mismatch: expected {n}, found {}", y_prob.nrows());
}
if n == 0 {
panic!("input is empty: y_true and y_prob");
}
if k == 0 {
panic!("invalid parameter `k`: must be at least 1");
}
let n_classes = y_prob.ncols();
let mut correct = 0;
for (i, &label) in y_true.iter().enumerate() {
if label >= n_classes {
panic!(
"invalid input: label {label} is out of range for {n_classes} probability columns"
);
}
let true_prob = y_prob[[i, label]];
let mut n_greater = 0;
for &p in y_prob.row(i) {
if p.is_nan() {
panic!("invalid input: y_prob must not contain NaN");
}
if p > true_prob {
n_greater += 1;
}
}
if n_greater < k {
correct += 1;
}
}
correct as f64 / n as f64
}
fn ranked_cumulative<S1, S2>(
labels: &ArrayBase<S1, Ix1>,
scores: &ArrayBase<S2, Ix1>,
) -> (Vec<(f64, usize, usize)>, usize, usize)
where
S1: Data<Elem = bool>,
S2: Data<Elem = f64>,
{
validate_pair(labels.len(), scores.len(), "labels and scores");
reject_nan_scores(scores);
let mut pairs: Vec<(f64, bool)> = scores
.iter()
.zip(labels.iter())
.map(|(&s, &l)| (s, l))
.collect();
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
let total_pos = pairs.iter().filter(|p| p.1).count();
let total_neg = pairs.len() - total_pos;
let mut points = Vec::new();
let (mut tp, mut fp) = (0usize, 0usize);
let n = pairs.len();
let mut i = 0;
while i < n {
let score = pairs[i].0;
while i < n && pairs[i].0 == score {
if pairs[i].1 {
tp += 1;
} else {
fp += 1;
}
i += 1;
}
points.push((score, tp, fp));
}
(points, total_pos, total_neg)
}
pub fn average_precision<S1, S2>(labels: &ArrayBase<S1, Ix1>, scores: &ArrayBase<S2, Ix1>) -> f64
where
S1: Data<Elem = bool>,
S2: Data<Elem = f64>,
{
let (points, total_pos, _) = ranked_cumulative(labels, scores);
if total_pos == 0 {
panic!("invalid input: average precision requires at least one positive label");
}
let mut ap = 0.0;
let mut prev_recall = 0.0;
for &(_, tp, fp) in &points {
let precision = tp as f64 / (tp + fp) as f64;
let recall = tp as f64 / total_pos as f64;
ap += (recall - prev_recall) * precision;
prev_recall = recall;
}
ap
}
pub fn roc_curve<S1, S2>(
labels: &ArrayBase<S1, Ix1>,
scores: &ArrayBase<S2, Ix1>,
) -> (Array1<f64>, Array1<f64>, Array1<f64>)
where
S1: Data<Elem = bool>,
S2: Data<Elem = f64>,
{
let (points, total_pos, total_neg) = ranked_cumulative(labels, scores);
if total_pos == 0 || total_neg == 0 {
panic!("invalid input: ROC curve requires at least one positive and one negative label");
}
let mut fpr = Vec::with_capacity(points.len() + 1);
let mut tpr = Vec::with_capacity(points.len() + 1);
let mut thresholds = Vec::with_capacity(points.len() + 1);
fpr.push(0.0);
tpr.push(0.0);
thresholds.push(points[0].0 + 1.0);
for &(score, tp, fp) in &points {
tpr.push(tp as f64 / total_pos as f64);
fpr.push(fp as f64 / total_neg as f64);
thresholds.push(score);
}
(
Array1::from(fpr),
Array1::from(tpr),
Array1::from(thresholds),
)
}
pub fn precision_recall_curve<S1, S2>(
labels: &ArrayBase<S1, Ix1>,
scores: &ArrayBase<S2, Ix1>,
) -> (Array1<f64>, Array1<f64>, Array1<f64>)
where
S1: Data<Elem = bool>,
S2: Data<Elem = f64>,
{
let (points, total_pos, _) = ranked_cumulative(labels, scores);
if total_pos == 0 {
panic!("invalid input: precision-recall curve requires at least one positive label");
}
let mut precision = Vec::with_capacity(points.len() + 1);
let mut recall = Vec::with_capacity(points.len() + 1);
let mut thresholds = Vec::with_capacity(points.len());
for &(score, tp, fp) in &points {
precision.push(tp as f64 / (tp + fp) as f64);
recall.push(tp as f64 / total_pos as f64);
thresholds.push(score);
}
precision.push(1.0);
recall.push(0.0);
(
Array1::from(precision),
Array1::from(recall),
Array1::from(thresholds),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_cm() -> MulticlassConfusionMatrix {
MulticlassConfusionMatrix {
matrix: Array2::<usize>::zeros((0, 0)),
labels: Vec::new(),
}
}
#[test]
fn test_aggregate_macro_empty_per_class_is_zero() {
let cm = empty_cm();
assert_eq!(cm.aggregate(&[], Average::Macro), 0.0);
}
#[test]
fn test_aggregate_weighted_zero_support_is_zero() {
let cm = empty_cm();
assert_eq!(cm.aggregate(&[], Average::Weighted), 0.0);
}
}