pub mod advanced;
pub mod curves;
pub mod one_vs_one;
pub mod threshold;
pub mod threshold_analyzer;
use scirs2_core::ndarray::{Array1, Array2, ArrayBase, Data, Dimension};
use scirs2_core::numeric::NumCast;
use crate::error::{MetricsError, Result};
#[allow(dead_code)]
pub fn accuracy_score<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
) -> Result<f64>
where
T: PartialEq + NumCast + Clone,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_pred.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_pred have different shapes: {:?} vs {:?}",
y_true.shape(),
y_pred.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let mut n_correct = 0;
for (yt, yp) in y_true.iter().zip(y_pred.iter()) {
if yt == yp {
n_correct += 1;
}
}
Ok(n_correct as f64 / n_samples as f64)
}
#[allow(dead_code)]
pub fn confusion_matrix<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
labels: Option<&[T]>,
) -> Result<(Array2<u64>, Array1<T>)>
where
T: PartialEq + NumCast + Clone + Ord + std::hash::Hash + std::fmt::Debug,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_pred.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_pred have different shapes: {:?} vs {:?}",
y_true.shape(),
y_pred.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let classes = if let Some(labels) = labels {
let mut cls = Vec::with_capacity(labels.len());
for label in labels {
cls.push(label.clone());
}
cls
} else {
let mut cls = std::collections::BTreeSet::new();
for yt in y_true.iter() {
cls.insert(yt.clone());
}
for yp in y_pred.iter() {
cls.insert(yp.clone());
}
cls.into_iter().collect()
};
let n_classes = classes.len();
let mut cm = Array2::zeros((n_classes, n_classes));
let mut class_to_idx = std::collections::HashMap::new();
for (i, c) in classes.iter().enumerate() {
class_to_idx.insert(c, i);
}
for (yt, yp) in y_true.iter().zip(y_pred.iter()) {
if let (Some(&i), Some(&j)) = (class_to_idx.get(yt), class_to_idx.get(yp)) {
cm[[i, j]] += 1;
}
}
Ok((cm, Array1::from(classes)))
}
#[allow(dead_code)]
pub fn precision_score<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
pos_label: T,
) -> Result<f64>
where
T: PartialEq + NumCast + Clone,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_pred.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_pred have different shapes: {:?} vs {:?}",
y_true.shape(),
y_pred.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let mut true_positives = 0;
let mut false_positives = 0;
for (yt, yp) in y_true.iter().zip(y_pred.iter()) {
if yp == &pos_label {
if yt == yp {
true_positives += 1;
} else {
false_positives += 1;
}
}
}
if true_positives + false_positives == 0 {
Ok(0.0) } else {
Ok(true_positives as f64 / (true_positives + false_positives) as f64)
}
}
#[allow(dead_code)]
pub fn recall_score<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
pos_label: T,
) -> Result<f64>
where
T: PartialEq + NumCast + Clone,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_pred.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_pred have different shapes: {:?} vs {:?}",
y_true.shape(),
y_pred.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let mut true_positives = 0;
let mut false_negatives = 0;
for (yt, yp) in y_true.iter().zip(y_pred.iter()) {
if yt == &pos_label {
if yp == yt {
true_positives += 1;
} else {
false_negatives += 1;
}
}
}
if true_positives + false_negatives == 0 {
Ok(0.0) } else {
Ok(true_positives as f64 / (true_positives + false_negatives) as f64)
}
}
#[allow(dead_code)]
pub fn f1_score<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
pos_label: T,
) -> Result<f64>
where
T: PartialEq + NumCast + Clone,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
fbeta_score(y_true, y_pred, pos_label, 1.0)
}
#[allow(dead_code)]
pub fn fbeta_score<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
pos_label: T,
beta: f64,
) -> Result<f64>
where
T: PartialEq + NumCast + Clone,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
if beta <= 0.0 {
return Err(MetricsError::InvalidInput(format!(
"beta must be positive, got {beta}"
)));
}
let precision = precision_score(y_true, y_pred, pos_label.clone())?;
let recall = recall_score(y_true, y_pred, pos_label)?;
if precision + recall == 0.0 {
return Ok(0.0);
}
let beta_squared = beta * beta;
Ok((1.0 + beta_squared) * precision * recall / ((beta_squared * precision) + recall))
}
#[allow(dead_code)]
pub fn binary_log_loss<S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_prob: &ArrayBase<S2, D2>,
eps: f64,
) -> Result<f64>
where
S1: Data<Elem = u32>,
S2: Data<Elem = f64>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_prob.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_prob have different shapes: {:?} vs {:?}",
y_true.shape(),
y_prob.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let mut loss = 0.0;
for (yt, yp) in y_true.iter().zip(y_prob.iter()) {
let clipped_yp = yp.max(eps).min(1.0 - eps);
if *yt == 1 {
loss -= (clipped_yp).ln();
} else {
loss -= (1.0 - clipped_yp).ln();
}
}
Ok(loss / n_samples as f64)
}
#[allow(dead_code)]
pub fn roc_auc_score<S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_score: &ArrayBase<S2, D2>,
) -> Result<f64>
where
S1: Data<Elem = u32>,
S2: Data<Elem = f64>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_score.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_score have different shapes: {:?} vs {:?}",
y_true.shape(),
y_score.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
let mut n_pos = 0;
let mut n_neg = 0;
for &yt in y_true.iter() {
if yt == 1 {
n_pos += 1;
} else {
n_neg += 1;
}
}
if n_pos == 0 || n_neg == 0 {
return Err(MetricsError::InvalidInput(
"ROC AUC _score is not defined when only one class is present".to_string(),
));
}
let mut scores_and_labels = Vec::with_capacity(n_samples);
for (yt, ys) in y_true.iter().zip(y_score.iter()) {
scores_and_labels.push((*ys, *yt));
}
scores_and_labels.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut auc = 0.0;
let mut false_positive = 0;
let mut true_positive = 0;
let mut last_false_positive = 0;
let mut last_true_positive = 0;
let mut last_score = f64::INFINITY;
for (score, label) in scores_and_labels {
if score != last_score {
auc += (false_positive - last_false_positive) as f64
* (true_positive + last_true_positive) as f64
/ 2.0;
last_score = score;
last_false_positive = false_positive;
last_true_positive = true_positive;
}
if label == 1 {
true_positive += 1;
} else {
false_positive += 1;
}
}
auc += (n_neg - last_false_positive) as f64 * (true_positive + last_true_positive) as f64 / 2.0;
auc /= (n_pos * n_neg) as f64;
Ok(auc)
}
#[allow(dead_code)]
pub fn lift_chart<S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_score: &ArrayBase<S2, D2>,
n_bins: usize,
) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>)>
where
S1: Data<Elem = u32>,
S2: Data<Elem = f64>,
D1: Dimension,
D2: Dimension,
{
if y_true.shape() != y_score.shape() {
return Err(MetricsError::InvalidInput(format!(
"y_true and y_score have different shapes: {:?} vs {:?}",
y_true.shape(),
y_score.shape()
)));
}
let n_samples = y_true.len();
if n_samples == 0 {
return Err(MetricsError::InvalidInput(
"Empty arrays provided".to_string(),
));
}
for yt in y_true.iter() {
if *yt != 0 && *yt != 1 {
return Err(MetricsError::InvalidInput(
"y_true must contain only binary values (0 or 1)".to_string(),
));
}
}
if n_bins < 1 {
return Err(MetricsError::InvalidInput(
"n_bins must be at least 1".to_string(),
));
}
let n_positives = y_true.iter().filter(|&&y| y == 1).count();
if n_positives == 0 || n_positives == n_samples {
return Err(MetricsError::InvalidInput(
"y_true must contain both positive and negative samples".to_string(),
));
}
let baseline_rate = n_positives as f64 / n_samples as f64;
let mut paired_data: Vec<(f64, u32)> = y_score
.iter()
.zip(y_true.iter())
.map(|(&_score, &label)| (_score, label))
.collect();
paired_data.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let bin_size = n_samples / n_bins;
let mut percentiles = Vec::with_capacity(n_bins);
let mut lift_values = Vec::with_capacity(n_bins);
let mut cum_gains = Vec::with_capacity(n_bins);
for i in 0..n_bins {
let percentile = (i + 1) as f64 * 100.0 / n_bins as f64;
let n_considered = if i == n_bins - 1 {
n_samples
} else {
(i + 1) * bin_size
};
let positives_in_bin = paired_data[0..n_considered]
.iter()
.filter(|(_, label)| *label == 1)
.count();
let bin_rate = positives_in_bin as f64 / n_considered as f64;
let lift = bin_rate / baseline_rate;
let cum_gain = positives_in_bin as f64 / n_positives as f64;
percentiles.push(percentile);
lift_values.push(lift);
cum_gains.push(cum_gain);
}
Ok((
Array1::from(percentiles),
Array1::from(lift_values),
Array1::from(cum_gains),
))
}
#[allow(dead_code)]
pub fn gain_chart<S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_score: &ArrayBase<S2, D2>,
n_bins: usize,
) -> Result<(Array1<f64>, Array1<f64>)>
where
S1: Data<Elem = u32>,
S2: Data<Elem = f64>,
D1: Dimension,
D2: Dimension,
{
let (percentiles, _lift_values, cum_gains) = lift_chart(y_true, y_score, n_bins)?;
Ok((percentiles, cum_gains))
}
#[allow(dead_code)]
pub fn classification_report<T, S1, S2, D1, D2>(
y_true: &ArrayBase<S1, D1>,
y_pred: &ArrayBase<S2, D2>,
labels: Option<&[T]>,
) -> Result<String>
where
T: PartialEq + NumCast + Clone + Ord + std::hash::Hash + std::fmt::Debug,
S1: Data<Elem = T>,
S2: Data<Elem = T>,
D1: Dimension,
D2: Dimension,
{
let (cm, classes) = confusion_matrix(y_true, y_pred, labels)?;
let mut report = String::new();
report.push_str(" precision recall f1-score support\n\n");
let n_classes = classes.len();
let mut total_precision = 0.0;
let mut total_recall = 0.0;
let mut total_f1 = 0.0;
let mut total_support = 0;
for i in 0..n_classes {
let class_label = format!("{:?}", classes[i]);
let true_positives = cm[[i, i]];
let false_positives = cm.column(i).sum() - true_positives;
let false_negatives = cm.row(i).sum() - true_positives;
let support = cm.row(i).sum();
let precision = if true_positives + false_positives == 0 {
0.0
} else {
true_positives as f64 / (true_positives + false_positives) as f64
};
let recall = if true_positives + false_negatives == 0 {
0.0
} else {
true_positives as f64 / (true_positives + false_negatives) as f64
};
let f1 = if precision + recall == 0.0 {
0.0
} else {
2.0 * precision * recall / (precision + recall)
};
total_precision += precision;
total_recall += recall;
total_f1 += f1;
total_support += support as usize;
report.push_str(&format!(
"{class_label:>14} {precision:9.2} {recall:9.2} {f1:9.2} {support:9}\n"
));
}
report.push('\n');
let avg_precision = total_precision / n_classes as f64;
let avg_recall = total_recall / n_classes as f64;
let avg_f1 = total_f1 / n_classes as f64;
report.push_str(&format!(
" avg / total {avg_precision:9.2} {avg_recall:9.2} {avg_f1:9.2} {total_support:9}\n"
));
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use scirs2_core::ndarray::array;
#[test]
fn test_accuracy_score() {
let y_true = array![0, 1, 2, 3];
let y_pred = array![0, 2, 1, 3];
let acc = accuracy_score(&y_true, &y_pred).expect("Operation failed");
assert_abs_diff_eq!(acc, 0.5, epsilon = 1e-10); }
#[test]
fn test_confusion_matrix() {
let y_true = array![0, 1, 2, 0, 1, 2];
let y_pred = array![0, 2, 1, 0, 0, 2];
let (cm, classes) = confusion_matrix(&y_true, &y_pred, None).expect("Operation failed");
assert_eq!(cm.shape(), &[3, 3]);
assert_eq!(classes.len(), 3);
assert_eq!(cm[[0, 0]], 2); assert_eq!(cm[[1, 0]], 1); assert_eq!(cm[[1, 2]], 1); assert_eq!(cm[[2, 1]], 1); assert_eq!(cm[[2, 2]], 1); }
#[test]
fn test_precision_recall_f1() {
let y_true = array![0, 1, 0, 0, 1, 1];
let y_pred = array![0, 0, 1, 0, 1, 1];
let precision = precision_score(&y_true, &y_pred, 1).expect("Operation failed");
let recall = recall_score(&y_true, &y_pred, 1).expect("Operation failed");
let f1 = f1_score(&y_true, &y_pred, 1).expect("Operation failed");
assert_abs_diff_eq!(precision, 2.0 / 3.0, epsilon = 1e-10);
assert_abs_diff_eq!(recall, 2.0 / 3.0, epsilon = 1e-10);
assert_abs_diff_eq!(f1, 2.0 / 3.0, epsilon = 1e-10);
}
#[test]
fn test_fbeta_score() {
let y_true = array![0, 1, 0, 0, 1, 1];
let y_pred = array![0, 0, 1, 0, 1, 1];
let f1 = fbeta_score(&y_true, &y_pred, 1, 1.0).expect("Operation failed");
assert_abs_diff_eq!(f1, 2.0 / 3.0, epsilon = 1e-10);
let f_half = fbeta_score(&y_true, &y_pred, 1, 0.5).expect("Operation failed");
assert_abs_diff_eq!(f_half, 2.0 / 3.0, epsilon = 1e-10);
let f_two = fbeta_score(&y_true, &y_pred, 1, 2.0).expect("Operation failed");
assert_abs_diff_eq!(f_two, 2.0 / 3.0, epsilon = 1e-10);
let y_true = array![1, 1, 1, 1, 1, 0, 0, 0, 0, 0];
let y_pred = array![1, 1, 1, 0, 0, 0, 0, 0, 1, 1];
let f1 = fbeta_score(&y_true, &y_pred, 1, 1.0).expect("Operation failed");
assert_abs_diff_eq!(f1, 0.6, epsilon = 1e-10);
let f_half = fbeta_score(&y_true, &y_pred, 1, 0.5).expect("Operation failed");
assert_abs_diff_eq!(f_half, 0.6, epsilon = 1e-10);
let f_two = fbeta_score(&y_true, &y_pred, 1, 2.0).expect("Operation failed");
assert_abs_diff_eq!(f_two, 0.6, epsilon = 1e-10);
let y_true = array![1, 1, 1, 1, 0, 0, 0, 0];
let y_pred = array![1, 1, 0, 0, 0, 0, 1, 1];
let f_half = fbeta_score(&y_true, &y_pred, 1, 0.5).expect("Operation failed");
assert_abs_diff_eq!(f_half, 0.5, epsilon = 1e-10);
}
#[test]
fn test_log_loss() {
let y_true = array![0, 1, 1, 0];
let y_prob = array![0.1, 0.9, 0.8, 0.3];
let loss = binary_log_loss(&y_true, &y_prob, 1e-15).expect("Operation failed");
let expected =
-(((1.0_f64 - 0.1).ln() + 0.9_f64.ln() + 0.8_f64.ln() + (1.0_f64 - 0.3).ln()) / 4.0);
assert_abs_diff_eq!(loss, expected, epsilon = 1e-10);
}
#[test]
fn test_roc_auc() {
let y_true = array![0, 0, 1, 1];
let y_score = array![0.1, 0.2, 0.8, 0.9];
let auc = roc_auc_score(&y_true, &y_score).expect("Operation failed");
assert_abs_diff_eq!(auc, 1.0, epsilon = 1e-10);
let y_true = array![0, 1, 0, 1];
let y_score = array![0.5, 0.5, 0.5, 0.5];
let auc = roc_auc_score(&y_true, &y_score).expect("Operation failed");
assert_abs_diff_eq!(auc, 0.5, epsilon = 1e-10);
}
#[test]
fn test_lift_chart() {
let y_true = array![0, 0, 1, 0, 1, 1, 0, 1, 0, 1];
let y_score = array![0.1, 0.2, 0.7, 0.3, 0.8, 0.9, 0.4, 0.6, 0.2, 0.5];
let (percentiles, lift_values, cum_gains) =
lift_chart(&y_true, &y_score, 5).expect("Operation failed");
assert_eq!(percentiles.len(), 5);
assert_eq!(lift_values.len(), 5);
assert_eq!(cum_gains.len(), 5);
assert_abs_diff_eq!(percentiles[0], 20.0, epsilon = 1e-10);
assert_abs_diff_eq!(percentiles[4], 100.0, epsilon = 1e-10);
assert!(lift_values[0] > 1.0);
assert_abs_diff_eq!(cum_gains[4], 1.0, epsilon = 1e-10);
}
#[test]
fn test_gain_chart() {
let y_true = array![0, 0, 1, 0, 1, 1, 0, 1, 0, 1];
let y_score = array![0.1, 0.2, 0.7, 0.3, 0.8, 0.9, 0.4, 0.6, 0.2, 0.5];
let (percentiles, cum_gains) = gain_chart(&y_true, &y_score, 5).expect("Operation failed");
assert_eq!(percentiles.len(), 5);
assert_eq!(cum_gains.len(), 5);
assert_abs_diff_eq!(percentiles[0], 20.0, epsilon = 1e-10);
assert_abs_diff_eq!(percentiles[4], 100.0, epsilon = 1e-10);
for i in 1..cum_gains.len() {
assert!(cum_gains[i] >= cum_gains[i - 1]);
}
assert_abs_diff_eq!(cum_gains[4], 1.0, epsilon = 1e-10);
}
}