Skip to main content

prospicio_models/
compare.rs

1//! One table of out-of-sample scores across models of any kind.
2//!
3//! Every candidate is fitted and scored on the same splits with the same
4//! metrics, so the table compares like with like. A [`Candidate`] wraps a
5//! [`Model`] or any fit-and-predict function, which lets a GLM, a GAM, an
6//! elastic net and a neural network (each of a different Rust type) sit
7//! in one comparison.
8
9use prospicio_core::{Error, Result};
10
11use crate::design::Design;
12use crate::model::{Fitted, Model};
13use crate::resample::{Split, map_splits};
14
15/// Fits on the training design and response, then predicts the test
16/// design.
17type FitPredict<'a> = dyn Fn(&Design, &[f64], &Design) -> Result<Vec<f64>> + Sync + 'a;
18
19/// A loss on the test rows: `(y_test, predicted, test_design) -> score`,
20/// lower is better.
21pub type Metric<'a> = dyn Fn(&[f64], &[f64], &Design) -> f64 + Sync + 'a;
22
23/// A named model to compare.
24pub struct Candidate<'a> {
25    name: String,
26    fit_predict: Box<FitPredict<'a>>,
27}
28
29impl<'a> Candidate<'a> {
30    /// A candidate that fits `model` to each training set.
31    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    /// A candidate from a function that fits on `(train, y_train)` and
38    /// returns predictions for `test`: for a model that builds its own
39    /// features, or one that is not a [`Model`].
40    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    /// The candidate's name.
51    pub fn name(&self) -> &str {
52        &self.name
53    }
54}
55
56/// The scores from [`compare`]: every candidate on every metric and split.
57#[derive(Debug, Clone, PartialEq)]
58pub struct Comparison {
59    models: Vec<String>,
60    metrics: Vec<String>,
61    n_splits: usize,
62    /// Model-major, then metric, then split.
63    scores: Vec<f64>,
64}
65
66impl Comparison {
67    /// Candidate names, in the order given.
68    pub fn models(&self) -> &[String] {
69        &self.models
70    }
71
72    /// Metric names, in the order given.
73    pub fn metrics(&self) -> &[String] {
74        &self.metrics
75    }
76
77    /// Number of splits.
78    pub fn n_splits(&self) -> usize {
79        self.n_splits
80    }
81
82    /// Scores of `model` on `metric`, one per split (indices into
83    /// [`models`](Self::models) and [`metrics`](Self::metrics)).
84    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    /// Mean score over the splits.
91    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    /// Standard error of [`mean`](Self::mean): the splits' standard
97    /// deviation over `√k` (NaN with one split), as cv.glmnet and tidymodels
98    /// report it.
99    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    /// The candidate with the lowest mean on `metric`.
108    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    /// Standard error of each candidate's per-split difference from the
115    /// best on `metric` (0 for the best itself). Splits are shared, so the
116    /// paired difference is far less noisy than either mean: a candidate
117    /// within about two of these of the best is not clearly worse.
118    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
132/// Fits every candidate on each split's training rows and scores its test
133/// predictions on every metric. Splits run in parallel, as in
134/// [`cross_validate`](crate::resample::cross_validate); within a split the
135/// candidates run in order.
136///
137/// ```
138/// use prospicio_models::compare::{Candidate, compare};
139/// use prospicio_models::resample::k_fold;
140/// # use prospicio_models::{Design, Fitted, Model};
141/// # struct Mean;
142/// # struct MeanFit(f64);
143/// # impl Model for Mean {
144/// #     type Fitted = MeanFit;
145/// #     fn fit(&self, _: &Design, y: &[f64]) -> prospicio_core::Result<MeanFit> {
146/// #         Ok(MeanFit(y.iter().sum::<f64>() / y.len() as f64))
147/// #     }
148/// # }
149/// # impl Fitted for MeanFit {
150/// #     fn predict(&self, d: &Design) -> prospicio_core::Result<Vec<f64>> { Ok(vec![self.0; d.n_rows()]) }
151/// #     fn predict_distribution(&self, _: &Design, _: usize, _: u64)
152/// #         -> prospicio_core::Result<prospicio_prob::PredictiveDistribution> { unimplemented!() }
153/// # }
154/// use prospicio_models::metrics::rmse;
155///
156/// let x = Design::new(vec!["x".into()], vec![(0..8).map(f64::from).collect()]).unwrap();
157/// let y: Vec<f64> = (0..8).map(|i| 2.0 * i as f64).collect();
158/// let candidates = [
159///     Candidate::new("mean", Mean),
160///     // y = 2x exactly.
161///     Candidate::from_fn("double", |_, _, test| Ok(test.column(0).iter().map(|v| 2.0 * v).collect())),
162/// ];
163/// let rmse = |t: &[f64], p: &[f64], _: &Design| rmse(t, p).unwrap();
164/// let table = compare(&candidates, &x, &y, &k_fold(8, 4, 1).unwrap(), &[("rmse", &rmse)]).unwrap();
165/// assert_eq!(table.best(0), 1);
166/// assert_eq!(table.mean(1, 0), 0.0);
167/// ```
168pub 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    // Per split: model-major, then metric.
188    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        // Constant predictions: the difference is the same on every split.
251        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}