1use prospicio_core::{Error, Result};
10
11use crate::design::Design;
12use crate::model::{Fitted, Model};
13use crate::resample::{Split, map_splits};
14
15type FitPredict<'a> = dyn Fn(&Design, &[f64], &Design) -> Result<Vec<f64>> + Sync + 'a;
18
19pub type Metric<'a> = dyn Fn(&[f64], &[f64], &Design) -> f64 + Sync + 'a;
22
23pub struct Candidate<'a> {
25 name: String,
26 fit_predict: Box<FitPredict<'a>>,
27}
28
29impl<'a> Candidate<'a> {
30 pub fn new<M: Model + Sync + 'a>(name: impl Into<String>, model: M) -> Self {
32 Self::from_fn(name, move |train, y, test| {
33 model.fit(train, y)?.predict(test)
34 })
35 }
36
37 pub fn from_fn(
41 name: impl Into<String>,
42 f: impl Fn(&Design, &[f64], &Design) -> Result<Vec<f64>> + Sync + 'a,
43 ) -> Self {
44 Self {
45 name: name.into(),
46 fit_predict: Box::new(f),
47 }
48 }
49
50 pub fn name(&self) -> &str {
52 &self.name
53 }
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct Comparison {
59 models: Vec<String>,
60 metrics: Vec<String>,
61 n_splits: usize,
62 scores: Vec<f64>,
64}
65
66impl Comparison {
67 pub fn models(&self) -> &[String] {
69 &self.models
70 }
71
72 pub fn metrics(&self) -> &[String] {
74 &self.metrics
75 }
76
77 pub fn n_splits(&self) -> usize {
79 self.n_splits
80 }
81
82 pub fn split_scores(&self, model: usize, metric: usize) -> &[f64] {
85 let k = self.n_splits;
86 let start = (model * self.metrics.len() + metric) * k;
87 &self.scores[start..start + k]
88 }
89
90 pub fn mean(&self, model: usize, metric: usize) -> f64 {
92 let s = self.split_scores(model, metric);
93 s.iter().sum::<f64>() / s.len() as f64
94 }
95
96 pub fn std_error(&self, model: usize, metric: usize) -> f64 {
100 let s = self.split_scores(model, metric);
101 let k = s.len() as f64;
102 let m = self.mean(model, metric);
103 let var = s.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (k - 1.0);
104 (var / k).sqrt()
105 }
106
107 pub fn best(&self, metric: usize) -> usize {
109 (0..self.models.len())
110 .min_by(|&a, &b| self.mean(a, metric).total_cmp(&self.mean(b, metric)))
111 .expect("at least one model")
112 }
113
114 pub fn difference_std_error(&self, model: usize, metric: usize) -> f64 {
119 let best = self.best(metric);
120 let (a, b) = (
121 self.split_scores(model, metric),
122 self.split_scores(best, metric),
123 );
124 let d: Vec<f64> = a.iter().zip(b).map(|(x, y)| x - y).collect();
125 let k = d.len() as f64;
126 let m = d.iter().sum::<f64>() / k;
127 let var = d.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (k - 1.0);
128 (var / k).sqrt()
129 }
130}
131
132pub fn compare(
169 candidates: &[Candidate<'_>],
170 design: &Design,
171 y: &[f64],
172 splits: &[Split],
173 metrics: &[(&str, &Metric<'_>)],
174) -> Result<Comparison> {
175 if candidates.is_empty() || metrics.is_empty() || splits.is_empty() {
176 return Err(Error::Data(
177 "compare needs at least one candidate, metric and split".into(),
178 ));
179 }
180 if y.len() != design.n_rows() {
181 return Err(Error::Data(format!(
182 "{} responses for {} design rows",
183 y.len(),
184 design.n_rows()
185 )));
186 }
187 let per_split = map_splits(splits, |s| {
189 let train = design.select(&s.train);
190 let y_train: Vec<f64> = s.train.iter().map(|&i| y[i]).collect();
191 let test = design.select(&s.test);
192 let y_test: Vec<f64> = s.test.iter().map(|&i| y[i]).collect();
193 let mut out = Vec::with_capacity(candidates.len() * metrics.len());
194 for c in candidates {
195 let pred = (c.fit_predict)(&train, &y_train, &test)
196 .map_err(|e| Error::Data(format!("{}: {e}", c.name)))?;
197 out.extend(metrics.iter().map(|(_, m)| m(&y_test, &pred, &test)));
198 }
199 Ok(out)
200 })?;
201 let (n_models, n_metrics, k) = (candidates.len(), metrics.len(), splits.len());
202 let mut scores = vec![0.0; n_models * n_metrics * k];
203 for (s, row) in per_split.iter().enumerate() {
204 for (j, v) in row.iter().enumerate() {
205 scores[j * k + s] = *v;
206 }
207 }
208 Ok(Comparison {
209 models: candidates.iter().map(|c| c.name.clone()).collect(),
210 metrics: metrics.iter().map(|(n, _)| (*n).to_string()).collect(),
211 n_splits: k,
212 scores,
213 })
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::resample::k_fold;
220
221 fn constant(c: f64) -> Candidate<'static> {
222 Candidate::from_fn(format!("c{c}"), move |_, _, test| {
223 Ok(vec![c; test.n_rows()])
224 })
225 }
226
227 #[test]
228 fn scores_are_laid_out_by_model_metric_and_split() {
229 let x = Design::new(vec!["x".into()], vec![vec![1.0; 6]]).unwrap();
230 let y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
231 let splits = k_fold(6, 3, 2).unwrap();
232 let mae = |t: &[f64], p: &[f64], _: &Design| {
233 t.iter().zip(p).map(|(a, b)| (a - b).abs()).sum::<f64>() / t.len() as f64
234 };
235 let bias = |t: &[f64], p: &[f64], _: &Design| {
236 (p.iter().sum::<f64>() - t.iter().sum::<f64>()) / t.len() as f64
237 };
238 let cands = [constant(0.0), constant(3.5)];
239 let table = compare(&cands, &x, &y, &splits, &[("mae", &mae), ("bias", &bias)]).unwrap();
240 assert_eq!(table.models(), ["c0", "c3.5"]);
241 assert_eq!(table.metrics(), ["mae", "bias"]);
242 for (s, split) in splits.iter().enumerate() {
243 let test_mean = split.test.iter().map(|&i| y[i]).sum::<f64>() / 2.0;
244 assert!((table.split_scores(0, 1)[s] + test_mean).abs() < 1e-12);
245 assert!((table.split_scores(1, 1)[s] - (3.5 - test_mean)).abs() < 1e-12);
246 }
247 assert!((table.mean(0, 0) - 3.5).abs() < 1e-12);
248 assert_eq!(table.best(0), 1);
249 assert_eq!(table.difference_std_error(1, 0), 0.0);
250 assert!(table.difference_std_error(0, 1).abs() < 1e-12);
252 assert!(table.std_error(0, 1) > 0.0);
253 }
254
255 #[test]
256 fn errors_name_the_candidate() {
257 let x = Design::new(vec!["x".into()], vec![vec![1.0; 4]]).unwrap();
258 let bad = Candidate::from_fn("broken", |_, _, _| Err(Error::Data("nope".into())));
259 let m = |_: &[f64], _: &[f64], _: &Design| 0.0;
260 let err = compare(
261 &[bad],
262 &x,
263 &[1.0; 4],
264 &k_fold(4, 2, 1).unwrap(),
265 &[("m", &m)],
266 )
267 .unwrap_err();
268 assert!(err.to_string().contains("broken"));
269 assert!(compare(&[], &x, &[1.0; 4], &k_fold(4, 2, 1).unwrap(), &[("m", &m)]).is_err());
270 }
271}