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
//! Validation curve: score vs. a single hyperparameter (polynomial degree),
//! showing an under-fit → sweet-spot → over-fit pattern.
//!
//! Run with: `cargo run --example validation_curve`

use model_selection_rs::evaluate::validation_curve;
use model_selection_rs::scoring::MeanSquaredError;
use model_selection_rs::splitters::KFold;
use ndarray::{Array1, Array2};

/// Fit a polynomial of the given degree by least squares (normal equations).
fn poly_fit(
    degree: &usize,
    x: &Array2<f64>,
    y: &Array1<f64>,
) -> impl Fn(&Array2<f64>) -> Array1<f64> {
    let d = *degree;
    let xs: Vec<f64> = x.column(0).to_vec();
    let ys: Vec<f64> = y.to_vec();
    let coeffs = fit_poly(&xs, &ys, d);
    move |xq: &Array2<f64>| xq.column(0).mapv(|v| eval_poly(&coeffs, v))
}

fn eval_poly(c: &[f64], x: f64) -> f64 {
    c.iter()
        .enumerate()
        .map(|(i, ci)| ci * x.powi(i as i32))
        .sum()
}

fn fit_poly(xs: &[f64], ys: &[f64], degree: usize) -> Vec<f64> {
    let (n, m) = (xs.len(), degree + 1);
    let vand: Vec<Vec<f64>> = xs
        .iter()
        .map(|&v| (0..m).map(|p| v.powi(p as i32)).collect())
        .collect();
    let mut ata = vec![vec![0.0; m]; m];
    let mut atb = vec![0.0; m];
    for i in 0..n {
        for a in 0..m {
            atb[a] += vand[i][a] * ys[i];
            for b in 0..m {
                ata[a][b] += vand[i][a] * vand[i][b];
            }
        }
    }
    solve(ata, atb)
}

#[allow(clippy::needless_range_loop)] // explicit column indices read clearer here
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
    let n = b.len();
    for col in 0..n {
        let piv = (col..n)
            .max_by(|&r1, &r2| a[r1][col].abs().partial_cmp(&a[r2][col].abs()).unwrap())
            .unwrap();
        a.swap(col, piv);
        b.swap(col, piv);
        let d = a[col][col];
        if d.abs() < 1e-12 {
            continue;
        }
        for row in (col + 1)..n {
            let f = a[row][col] / d;
            for k in col..n {
                a[row][k] -= f * a[col][k];
            }
            b[row] -= f * b[col];
        }
    }
    let mut sol = vec![0.0; n];
    for row in (0..n).rev() {
        let mut s = b[row];
        for k in (row + 1)..n {
            s -= a[row][k] * sol[k];
        }
        sol[row] = if a[row][row].abs() < 1e-12 {
            0.0
        } else {
            s / a[row][row]
        };
    }
    sol
}

fn main() {
    // Quadratic truth with a little noise: degree 1 underfits, high degrees overfit.
    let x = Array2::from_shape_fn((60, 1), |(i, _)| (i as f64) / 10.0 - 3.0);
    let y = x
        .column(0)
        .mapv(|v| v * v - 0.5 * v + 1.0 + (v * 5.0).sin() * 0.2);

    let kf = KFold::new(5).unwrap().with_shuffle(0);
    let degrees = [1usize, 2, 3, 5, 8, 12];
    let vc = validation_curve(&kf, &x, &y, poly_fit, &MeanSquaredError, &degrees).unwrap();

    println!("Validation curve over polynomial degree (MSE, lower is better):\n");
    println!("  {:>6}  {:>12}  {:>12}", "degree", "train MSE", "val MSE");
    let train = vc.mean_train_scores();
    let val = vc.mean_val_scores();
    for i in 0..degrees.len() {
        println!("  {:>6}  {:>12.4}  {:>12.4}", degrees[i], train[i], val[i]);
    }
    println!("\nTraining error falls monotonically with degree, but validation error");
    println!("bottoms out near the true degree (2) and then climbs as the model");
    println!("starts fitting noise — the U-shaped over/under-fit signature.");
}