use super::schemes::kfold_indices;
use crate::error::{Error, Result};
use crate::numeric::count_to_f64;
use crate::resampling::CrossValidation;
use crate::rng::SplitMix64;
#[derive(Debug, Clone, PartialEq)]
pub struct CvScores {
fold_scores: Vec<f64>,
mean: f64,
std_error: f64,
}
impl CvScores {
pub(crate) fn new(scores: Vec<f64>) -> Self {
let kf = count_to_f64(scores.len());
let mean = crate::numeric::mean(&scores);
let variance = crate::numeric::sample_variance(&scores);
let std_error = variance.sqrt() / kf.sqrt();
Self {
fold_scores: scores,
mean,
std_error,
}
}
#[must_use]
pub fn fold_scores(&self) -> &[f64] {
&self.fold_scores
}
#[must_use]
pub const fn mean(&self) -> f64 {
self.mean
}
#[must_use]
pub const fn std_error(&self) -> f64 {
self.std_error
}
}
pub fn cross_validate(
n: usize,
k: usize,
rng: &mut SplitMix64,
mut fit_score: impl FnMut(&[usize], &[usize]) -> f64,
) -> Result<CvScores> {
if k < 2 {
return Err(Error::InvalidInput("k must be >= 2".to_owned()));
}
if k > n {
return Err(Error::InsufficientData);
}
let fold_scores: Vec<f64> = kfold_indices(n, k, rng)
.iter()
.map(|(train, test)| fit_score(train, test))
.collect();
Ok(CvScores::new(fold_scores))
}
impl CrossValidation {
pub fn run(
&self,
n: usize,
fit_score: impl FnMut(&[usize], &[usize]) -> f64,
) -> Result<CvScores> {
let k = usize::try_from(self.number_of_folds)
.map_err(|_| Error::InvalidInput("number_of_folds must be non-negative".to_owned()))?;
let mut rng = SplitMix64::new(self.random_seed.cast_unsigned());
cross_validate(n, k, &mut rng, fit_score)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exposes_scores_via_accessors() -> Result<()> {
let mut rng = SplitMix64::new(5);
let scores = cross_validate(8, 4, &mut rng, |_train, _test| 0.5)?;
assert_eq!(
scores.fold_scores(),
&[0.5, 0.5, 0.5, 0.5],
"fold_scores() must return the per-fold scores slice"
);
assert!(
(scores.mean() - 0.5).abs() < 1e-12,
"mean() was {}",
scores.mean()
);
assert!(
scores.std_error().abs() < 1e-12,
"std_error() was {}",
scores.std_error()
);
Ok(())
}
#[test]
fn rejects_fewer_than_two_folds() {
let mut rng = SplitMix64::new(1);
let result = cross_validate(10, 1, &mut rng, |_train, _test| 0.0);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"k < 2 is a bad parameter and must be InvalidInput, got {result:?}"
);
}
#[test]
fn rejects_more_folds_than_observations() {
let mut rng = SplitMix64::new(1);
let result = cross_validate(3, 5, &mut rng, |_train, _test| 0.0);
assert_eq!(
result,
Err(Error::InsufficientData),
"k > n must be rejected, got {result:?}"
);
}
#[test]
fn folds_partition_the_observations() -> Result<()> {
let n = 10;
let k = 5;
let mut splits: Vec<(Vec<usize>, Vec<usize>)> = Vec::new();
let mut rng = SplitMix64::new(7);
let scores = cross_validate(n, k, &mut rng, |train, test| {
splits.push((train.to_vec(), test.to_vec()));
0.0
})?;
assert_eq!(scores.fold_scores().len(), k, "one score per fold");
assert_eq!(splits.len(), k, "fit_score must run once per fold");
let mut seen = vec![0u32; n];
for (train, test) in &splits {
for &i in test {
if let Some(count) = seen.get_mut(i) {
*count += 1;
}
}
for &t in test {
assert!(!train.contains(&t), "index {t} in both train and test");
}
assert_eq!(train.len() + test.len(), n, "train ∪ test must cover all n");
}
assert!(
seen.iter().all(|&c| c == 1),
"each index must land in exactly one test fold, counts: {seen:?}"
);
Ok(())
}
#[test]
fn aggregates_mean_and_standard_error() -> Result<()> {
let predetermined = [0.80, 0.75, 0.82, 0.79, 0.85];
let mut supply = predetermined.iter().copied();
let mut rng = SplitMix64::new(3);
let scores = cross_validate(10, 5, &mut rng, |_train, _test| {
supply.next().unwrap_or(f64::NAN)
})?;
assert_eq!(
scores.fold_scores(),
&predetermined,
"scores recorded in order"
);
assert!(
(scores.mean() - 0.801_999_999_999_999_9).abs() < 1e-12,
"mean was {}",
scores.mean()
);
assert!(
(scores.std_error() - 0.016_552_945_357_246_843).abs() < 1e-12,
"std_error was {}",
scores.std_error()
);
Ok(())
}
#[test]
fn identical_seeds_reproduce_scores() -> Result<()> {
let score_of =
|_train: &[usize], test: &[usize]| -> f64 { count_to_f64(test.iter().sum::<usize>()) };
let a = cross_validate(12, 4, &mut SplitMix64::new(2024), score_of)?;
let b = cross_validate(12, 4, &mut SplitMix64::new(2024), score_of)?;
assert_eq!(a, b, "identical seeds must reproduce the CV scores");
let c = cross_validate(12, 4, &mut SplitMix64::new(99), score_of)?;
assert_ne!(
a.fold_scores(),
c.fold_scores(),
"different seeds should generally yield different folds"
);
Ok(())
}
#[test]
fn run_rejects_negative_fold_count_as_invalid_input() {
let cv = CrossValidation {
number_of_folds: -3,
random_seed: 1,
..Default::default()
};
let result = cv.run(10, |_train, _test| 0.0);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"a negative number_of_folds is a bad parameter and must be InvalidInput, got {result:?}"
);
}
#[test]
fn run_matches_free_function_with_equivalent_seed() -> Result<()> {
let score_of =
|_train: &[usize], test: &[usize]| -> f64 { count_to_f64(test.iter().sum::<usize>()) };
let cv = CrossValidation {
number_of_folds: 5,
random_seed: 42,
..Default::default()
};
let via_run = cv.run(10, score_of)?;
let via_free = cross_validate(10, 5, &mut SplitMix64::new(42), score_of)?;
assert_eq!(
via_run, via_free,
"run() must match cross_validate() with the equivalent seed"
);
Ok(())
}
}