use prospicio_core::{Error, Result};
use crate::design::Design;
use crate::model::{Fitted, Model};
use crate::resample::{Split, map_splits};
type FitPredict<'a> = dyn Fn(&Design, &[f64], &Design) -> Result<Vec<f64>> + Sync + 'a;
pub type Metric<'a> = dyn Fn(&[f64], &[f64], &Design) -> f64 + Sync + 'a;
pub struct Candidate<'a> {
name: String,
fit_predict: Box<FitPredict<'a>>,
}
impl<'a> Candidate<'a> {
pub fn new<M: Model + Sync + 'a>(name: impl Into<String>, model: M) -> Self {
Self::from_fn(name, move |train, y, test| {
model.fit(train, y)?.predict(test)
})
}
pub fn from_fn(
name: impl Into<String>,
f: impl Fn(&Design, &[f64], &Design) -> Result<Vec<f64>> + Sync + 'a,
) -> Self {
Self {
name: name.into(),
fit_predict: Box::new(f),
}
}
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Comparison {
models: Vec<String>,
metrics: Vec<String>,
n_splits: usize,
scores: Vec<f64>,
}
impl Comparison {
pub fn models(&self) -> &[String] {
&self.models
}
pub fn metrics(&self) -> &[String] {
&self.metrics
}
pub fn n_splits(&self) -> usize {
self.n_splits
}
pub fn split_scores(&self, model: usize, metric: usize) -> &[f64] {
let k = self.n_splits;
let start = (model * self.metrics.len() + metric) * k;
&self.scores[start..start + k]
}
pub fn mean(&self, model: usize, metric: usize) -> f64 {
let s = self.split_scores(model, metric);
s.iter().sum::<f64>() / s.len() as f64
}
pub fn std_error(&self, model: usize, metric: usize) -> f64 {
let s = self.split_scores(model, metric);
let k = s.len() as f64;
let m = self.mean(model, metric);
let var = s.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (k - 1.0);
(var / k).sqrt()
}
pub fn best(&self, metric: usize) -> usize {
(0..self.models.len())
.min_by(|&a, &b| self.mean(a, metric).total_cmp(&self.mean(b, metric)))
.expect("at least one model")
}
pub fn difference_std_error(&self, model: usize, metric: usize) -> f64 {
let best = self.best(metric);
let (a, b) = (
self.split_scores(model, metric),
self.split_scores(best, metric),
);
let d: Vec<f64> = a.iter().zip(b).map(|(x, y)| x - y).collect();
let k = d.len() as f64;
let m = d.iter().sum::<f64>() / k;
let var = d.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (k - 1.0);
(var / k).sqrt()
}
}
pub fn compare(
candidates: &[Candidate<'_>],
design: &Design,
y: &[f64],
splits: &[Split],
metrics: &[(&str, &Metric<'_>)],
) -> Result<Comparison> {
if candidates.is_empty() || metrics.is_empty() || splits.is_empty() {
return Err(Error::Data(
"compare needs at least one candidate, metric and split".into(),
));
}
if y.len() != design.n_rows() {
return Err(Error::Data(format!(
"{} responses for {} design rows",
y.len(),
design.n_rows()
)));
}
let per_split = map_splits(splits, |s| {
let train = design.select(&s.train);
let y_train: Vec<f64> = s.train.iter().map(|&i| y[i]).collect();
let test = design.select(&s.test);
let y_test: Vec<f64> = s.test.iter().map(|&i| y[i]).collect();
let mut out = Vec::with_capacity(candidates.len() * metrics.len());
for c in candidates {
let pred = (c.fit_predict)(&train, &y_train, &test)
.map_err(|e| Error::Data(format!("{}: {e}", c.name)))?;
out.extend(metrics.iter().map(|(_, m)| m(&y_test, &pred, &test)));
}
Ok(out)
})?;
let (n_models, n_metrics, k) = (candidates.len(), metrics.len(), splits.len());
let mut scores = vec![0.0; n_models * n_metrics * k];
for (s, row) in per_split.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
scores[j * k + s] = *v;
}
}
Ok(Comparison {
models: candidates.iter().map(|c| c.name.clone()).collect(),
metrics: metrics.iter().map(|(n, _)| (*n).to_string()).collect(),
n_splits: k,
scores,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resample::k_fold;
fn constant(c: f64) -> Candidate<'static> {
Candidate::from_fn(format!("c{c}"), move |_, _, test| {
Ok(vec![c; test.n_rows()])
})
}
#[test]
fn scores_are_laid_out_by_model_metric_and_split() {
let x = Design::new(vec!["x".into()], vec![vec![1.0; 6]]).unwrap();
let y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let splits = k_fold(6, 3, 2).unwrap();
let mae = |t: &[f64], p: &[f64], _: &Design| {
t.iter().zip(p).map(|(a, b)| (a - b).abs()).sum::<f64>() / t.len() as f64
};
let bias = |t: &[f64], p: &[f64], _: &Design| {
(p.iter().sum::<f64>() - t.iter().sum::<f64>()) / t.len() as f64
};
let cands = [constant(0.0), constant(3.5)];
let table = compare(&cands, &x, &y, &splits, &[("mae", &mae), ("bias", &bias)]).unwrap();
assert_eq!(table.models(), ["c0", "c3.5"]);
assert_eq!(table.metrics(), ["mae", "bias"]);
for (s, split) in splits.iter().enumerate() {
let test_mean = split.test.iter().map(|&i| y[i]).sum::<f64>() / 2.0;
assert!((table.split_scores(0, 1)[s] + test_mean).abs() < 1e-12);
assert!((table.split_scores(1, 1)[s] - (3.5 - test_mean)).abs() < 1e-12);
}
assert!((table.mean(0, 0) - 3.5).abs() < 1e-12);
assert_eq!(table.best(0), 1);
assert_eq!(table.difference_std_error(1, 0), 0.0);
assert!(table.difference_std_error(0, 1).abs() < 1e-12);
assert!(table.std_error(0, 1) > 0.0);
}
#[test]
fn errors_name_the_candidate() {
let x = Design::new(vec!["x".into()], vec![vec![1.0; 4]]).unwrap();
let bad = Candidate::from_fn("broken", |_, _, _| Err(Error::Data("nope".into())));
let m = |_: &[f64], _: &[f64], _: &Design| 0.0;
let err = compare(
&[bad],
&x,
&[1.0; 4],
&k_fold(4, 2, 1).unwrap(),
&[("m", &m)],
)
.unwrap_err();
assert!(err.to_string().contains("broken"));
assert!(compare(&[], &x, &[1.0; 4], &k_fold(4, 2, 1).unwrap(), &[("m", &m)]).is_err());
}
}