use std::time::{Duration, Instant};
use ndarray::{Array1, Array2, Axis};
use crate::error::Result;
use crate::scoring::Scorer;
use crate::splitters::CvSplitter;
pub type BoxedScorer = Box<dyn Scorer + Send + Sync>;
#[derive(Debug, Clone)]
pub struct CvResults {
pub scorer_names: Vec<String>,
pub test_scores: Vec<Vec<f64>>,
pub train_scores: Option<Vec<Vec<f64>>>,
pub fit_times: Vec<Duration>,
pub score_times: Vec<Duration>,
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return f64::NAN;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn std(xs: &[f64]) -> f64 {
if xs.len() < 2 {
return 0.0;
}
let m = mean(xs);
let var = xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / xs.len() as f64;
var.sqrt()
}
impl CvResults {
#[must_use]
pub fn n_splits(&self) -> usize {
self.fit_times.len()
}
#[must_use]
pub fn mean_test_score(&self, idx: usize) -> f64 {
mean(&self.test_scores[idx])
}
#[must_use]
pub fn std_test_score(&self, idx: usize) -> f64 {
std(&self.test_scores[idx])
}
#[must_use]
pub fn mean_test_score_by_name(&self, name: &str) -> Option<f64> {
self.scorer_names
.iter()
.position(|n| n == name)
.map(|i| self.mean_test_score(i))
}
#[must_use]
pub fn total_fit_time(&self) -> Duration {
self.fit_times.iter().sum()
}
}
struct FoldScores {
test: Vec<f64>,
train: Option<Vec<f64>>,
fit_time: Duration,
score_time: Duration,
}
fn evaluate_fold<F, M>(
x: &Array2<f64>,
y: &Array1<f64>,
train: &[usize],
test: &[usize],
fit_fn: &F,
scorers: &[BoxedScorer],
return_train_scores: bool,
) -> FoldScores
where
F: Fn(&Array2<f64>, &Array1<f64>) -> M,
M: Fn(&Array2<f64>) -> Array1<f64>,
{
let x_train = x.select(Axis(0), train);
let y_train = y.select(Axis(0), train);
let x_test = x.select(Axis(0), test);
let y_test = y.select(Axis(0), test);
let fit_start = Instant::now();
let model = fit_fn(&x_train, &y_train);
let fit_time = fit_start.elapsed();
let score_start = Instant::now();
let pred_test = model(&x_test);
let test: Vec<f64> = scorers
.iter()
.map(|s| s.score(&y_test, &pred_test))
.collect();
let score_time = score_start.elapsed();
let train = if return_train_scores {
let pred_train = model(&x_train);
Some(
scorers
.iter()
.map(|s| s.score(&y_train, &pred_train))
.collect(),
)
} else {
None
};
FoldScores {
test,
train,
fit_time,
score_time,
}
}
pub fn cross_validate<S, F, M>(
splitter: &S,
x: &Array2<f64>,
y: &Array1<f64>,
fit_fn: F,
scorers: &[BoxedScorer],
return_train_scores: bool,
) -> Result<CvResults>
where
S: CvSplitter,
F: Fn(&Array2<f64>, &Array1<f64>) -> M + Sync,
M: Fn(&Array2<f64>) -> Array1<f64>,
{
let splits = splitter.split(x.nrows())?;
#[cfg(feature = "parallel")]
let fold_scores: Vec<FoldScores> = {
use rayon::prelude::*;
splits
.par_iter()
.map(|(train, test)| {
evaluate_fold(x, y, train, test, &fit_fn, scorers, return_train_scores)
})
.collect()
};
#[cfg(not(feature = "parallel"))]
let fold_scores: Vec<FoldScores> = splits
.iter()
.map(|(train, test)| {
evaluate_fold(x, y, train, test, &fit_fn, scorers, return_train_scores)
})
.collect();
Ok(assemble(fold_scores, scorers, return_train_scores))
}
fn assemble(
fold_scores: Vec<FoldScores>,
scorers: &[BoxedScorer],
return_train_scores: bool,
) -> CvResults {
let n_scorers = scorers.len();
let mut test_scores = vec![Vec::with_capacity(fold_scores.len()); n_scorers];
let mut train_scores = if return_train_scores {
Some(vec![Vec::with_capacity(fold_scores.len()); n_scorers])
} else {
None
};
let mut fit_times = Vec::with_capacity(fold_scores.len());
let mut score_times = Vec::with_capacity(fold_scores.len());
for fold in fold_scores {
for (s, v) in fold.test.into_iter().enumerate() {
test_scores[s].push(v);
}
if let (Some(dst), Some(src)) = (train_scores.as_mut(), fold.train) {
for (s, v) in src.into_iter().enumerate() {
dst[s].push(v);
}
}
fit_times.push(fold.fit_time);
score_times.push(fold.score_time);
}
CvResults {
scorer_names: scorers.iter().map(|s| s.name().to_string()).collect(),
test_scores,
train_scores,
fit_times,
score_times,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scoring::{MeanSquaredError, R2Score};
use crate::splitters::KFold;
use ndarray::array;
fn ols_fit(x: &Array2<f64>, y: &Array1<f64>) -> impl Fn(&Array2<f64>) -> Array1<f64> {
let n = x.nrows() as f64;
let xs: Vec<f64> = x.column(0).to_vec();
let ys: Vec<f64> = y.to_vec();
let mean_x = xs.iter().sum::<f64>() / n;
let mean_y = ys.iter().sum::<f64>() / n;
let cov: f64 = xs
.iter()
.zip(&ys)
.map(|(a, b)| (a - mean_x) * (b - mean_y))
.sum();
let var: f64 = xs.iter().map(|a| (a - mean_x).powi(2)).sum();
let slope = if var == 0.0 { 0.0 } else { cov / var };
let intercept = mean_y - slope * mean_x;
move |xq: &Array2<f64>| xq.column(0).mapv(|v| slope * v + intercept)
}
#[test]
fn multiple_scorers_in_one_pass() {
let x = Array2::from_shape_fn((20, 1), |(i, _)| i as f64);
let y = x.column(0).mapv(|v| 2.0 * v + 1.0);
let kf = KFold::new(4).unwrap();
let scorers: Vec<BoxedScorer> = vec![Box::new(MeanSquaredError), Box::new(R2Score)];
let res = cross_validate(&kf, &x, &y, ols_fit, &scorers, true).unwrap();
assert_eq!(res.scorer_names, vec!["mse", "r2"]);
assert_eq!(res.n_splits(), 4);
assert!(res.mean_test_score(0) < 1e-9);
assert!((res.mean_test_score(1) - 1.0).abs() < 1e-9);
assert!(res.train_scores.is_some());
}
#[test]
fn lookup_by_name() {
let x = Array2::from_shape_fn((12, 1), |(i, _)| i as f64);
let y = x.column(0).to_owned();
let kf = KFold::new(3).unwrap();
let scorers: Vec<BoxedScorer> = vec![Box::new(MeanSquaredError)];
let res = cross_validate(&kf, &x, &y, ols_fit, &scorers, false).unwrap();
assert!(res.mean_test_score_by_name("mse").is_some());
assert!(res.mean_test_score_by_name("nope").is_none());
}
#[test]
fn array_example_in_docs() {
let x = array![[0.0], [1.0], [2.0], [3.0]];
let y = array![0.0, 1.0, 2.0, 3.0];
let kf = KFold::new(2).unwrap();
let scorers: Vec<BoxedScorer> = vec![Box::new(MeanSquaredError)];
let res = cross_validate(&kf, &x, &y, ols_fit, &scorers, false).unwrap();
assert_eq!(res.n_splits(), 2);
}
}