model-selection-rs 0.1.0

Cross-validation and model-selection utilities for Rust: stratified / group-aware / time-series splitting, nested CV, and learning & validation curves. Dependency-light, composes with any modeling crate.
Documentation
//! Scoring: a lightweight metric abstraction usable standalone or, optionally,
//! backed by `smartcore::metrics`.
//!
//! The [`Scorer`] trait is deliberately tiny — a single `score` call over
//! `y_true` / `y_pred`. Built-in scorers (accuracy, MAE, MSE, RMSE, R²) are
//! implemented directly here so the crate needs no modeling dependency. For
//! metrics that already exist in mature form elsewhere (F1, ROC-AUC, …), enable
//! the `smartcore-metrics` feature to get [`smartcore_adapter`] rather than
//! reimplementing them.
//!
//! User-defined metrics are first-class: wrap any closure with
//! [`make_scorer`].

#[cfg(feature = "smartcore-metrics")]
pub mod smartcore_adapter;

use ndarray::Array1;

/// A scoring metric over true and predicted target vectors.
///
/// Implementors compute a single scalar from aligned `y_true` / `y_pred`.
/// [`greater_is_better`](Scorer::greater_is_better) tells callers (e.g. model
/// selection loops) which direction is an improvement.
pub trait Scorer {
    /// Compute the score. `y_true` and `y_pred` are assumed equal length.
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64;

    /// A short human-readable name (used to label
    /// [`CvResults`](crate::evaluate::CvResults) columns).
    fn name(&self) -> &str;

    /// Whether a larger score is better (e.g. accuracy, R²) or smaller is better
    /// (e.g. MAE, MSE, RMSE). Defaults to `true`.
    fn greater_is_better(&self) -> bool {
        true
    }
}

/// Classification accuracy: the fraction of predictions that exactly match.
///
/// Comparison is exact-equality on the `f64` values, so encode class labels as
/// integral `f64`s (`0.0`, `1.0`, …).
#[derive(Debug, Clone, Copy, Default)]
pub struct Accuracy;

impl Scorer for Accuracy {
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        if y_true.is_empty() {
            return f64::NAN;
        }
        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
    }
    fn name(&self) -> &str {
        "accuracy"
    }
}

/// Mean absolute error.
#[derive(Debug, Clone, Copy, Default)]
pub struct MeanAbsoluteError;

impl Scorer for MeanAbsoluteError {
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        if y_true.is_empty() {
            return f64::NAN;
        }
        let sum: f64 = y_true
            .iter()
            .zip(y_pred.iter())
            .map(|(t, p)| (t - p).abs())
            .sum();
        sum / y_true.len() as f64
    }
    fn name(&self) -> &str {
        "mae"
    }
    fn greater_is_better(&self) -> bool {
        false
    }
}

/// Mean squared error.
#[derive(Debug, Clone, Copy, Default)]
pub struct MeanSquaredError;

impl Scorer for MeanSquaredError {
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        if y_true.is_empty() {
            return f64::NAN;
        }
        let sum: f64 = y_true
            .iter()
            .zip(y_pred.iter())
            .map(|(t, p)| (t - p).powi(2))
            .sum();
        sum / y_true.len() as f64
    }
    fn name(&self) -> &str {
        "mse"
    }
    fn greater_is_better(&self) -> bool {
        false
    }
}

/// Root mean squared error.
#[derive(Debug, Clone, Copy, Default)]
pub struct RootMeanSquaredError;

impl Scorer for RootMeanSquaredError {
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        MeanSquaredError.score(y_true, y_pred).sqrt()
    }
    fn name(&self) -> &str {
        "rmse"
    }
    fn greater_is_better(&self) -> bool {
        false
    }
}

/// Coefficient of determination, R².
///
/// Returns `NaN` if `y_true` has zero variance (the score is undefined).
#[derive(Debug, Clone, Copy, Default)]
pub struct R2Score;

impl Scorer for R2Score {
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        if y_true.is_empty() {
            return f64::NAN;
        }
        let mean = y_true.sum() / y_true.len() as f64;
        let ss_tot: f64 = y_true.iter().map(|t| (t - mean).powi(2)).sum();
        if ss_tot == 0.0 {
            return f64::NAN;
        }
        let ss_res: f64 = y_true
            .iter()
            .zip(y_pred.iter())
            .map(|(t, p)| (t - p).powi(2))
            .sum();
        1.0 - ss_res / ss_tot
    }
    fn name(&self) -> &str {
        "r2"
    }
}

/// A [`Scorer`] backed by an arbitrary closure — the analogue of scikit-learn's
/// `make_scorer`, so users are never limited to the built-ins.
pub struct ClosureScorer<F> {
    name: String,
    greater_is_better: bool,
    f: F,
}

impl<F> Scorer for ClosureScorer<F>
where
    F: Fn(&Array1<f64>, &Array1<f64>) -> f64,
{
    fn score(&self, y_true: &Array1<f64>, y_pred: &Array1<f64>) -> f64 {
        (self.f)(y_true, y_pred)
    }
    fn name(&self) -> &str {
        &self.name
    }
    fn greater_is_better(&self) -> bool {
        self.greater_is_better
    }
}

/// Wrap a closure as a [`Scorer`].
///
/// ```
/// use ndarray::array;
/// use model_selection_rs::scoring::{make_scorer, Scorer};
///
/// // Max absolute error, lower is better.
/// let max_ae = make_scorer("max_ae", false, |t, p| {
///     t.iter().zip(p.iter()).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max)
/// });
/// let s = max_ae.score(&array![1.0, 2.0, 3.0], &array![1.0, 2.0, 5.0]);
/// assert_eq!(s, 2.0);
/// assert!(!max_ae.greater_is_better());
/// ```
pub fn make_scorer<F>(name: impl Into<String>, greater_is_better: bool, f: F) -> ClosureScorer<F>
where
    F: Fn(&Array1<f64>, &Array1<f64>) -> f64,
{
    ClosureScorer {
        name: name.into(),
        greater_is_better,
        f,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use approx::assert_relative_eq;
    use ndarray::array;

    #[test]
    fn accuracy_matches_hand_count() {
        let t = array![0.0, 1.0, 1.0, 0.0];
        let p = array![0.0, 1.0, 0.0, 0.0];
        assert_relative_eq!(Accuracy.score(&t, &p), 0.75);
    }

    #[test]
    fn regression_metrics_match_hand_values() {
        let t = array![1.0, 2.0, 3.0];
        let p = array![1.0, 2.0, 5.0]; // errors: 0, 0, 2
        assert_relative_eq!(MeanAbsoluteError.score(&t, &p), 2.0 / 3.0);
        assert_relative_eq!(MeanSquaredError.score(&t, &p), 4.0 / 3.0);
        assert_relative_eq!(RootMeanSquaredError.score(&t, &p), (4.0f64 / 3.0).sqrt());
    }

    #[test]
    fn r2_is_one_for_perfect_fit() {
        let t = array![1.0, 2.0, 3.0, 4.0];
        assert_relative_eq!(R2Score.score(&t, &t), 1.0);
    }

    #[test]
    fn r2_is_zero_for_mean_predictor() {
        let t = array![1.0, 2.0, 3.0, 4.0];
        let mean = array![2.5, 2.5, 2.5, 2.5];
        assert_relative_eq!(R2Score.score(&t, &mean), 0.0);
    }

    #[test]
    fn greater_is_better_flags() {
        assert!(Accuracy.greater_is_better());
        assert!(R2Score.greater_is_better());
        assert!(!MeanAbsoluteError.greater_is_better());
        assert!(!MeanSquaredError.greater_is_better());
        assert!(!RootMeanSquaredError.greater_is_better());
    }
}