Skip to main content

model_selection_rs/evaluate/
learning_curve.rs

1//! Learning curves: score vs. training-set size.
2
3use ndarray::{Array1, Array2, Axis};
4
5use crate::error::Result;
6use crate::scoring::Scorer;
7use crate::splitters::CvSplitter;
8
9/// A training-set size, given either absolutely or as a fraction of the largest
10/// usable training set.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum TrainSize {
13    /// A fixed number of training samples.
14    Count(usize),
15    /// A fraction in `(0.0, 1.0]` of the largest usable training set.
16    Fraction(f64),
17}
18
19/// Output of [`learning_curve`].
20///
21/// Both train and validation scores are returned as `[size][fold]`: the gap
22/// between them at each size is the actual diagnostic (high bias vs. high
23/// variance), so neither can be dropped.
24#[derive(Debug, Clone)]
25pub struct LearningCurve {
26    /// Absolute training-set sizes actually used, ascending.
27    pub train_sizes: Vec<usize>,
28    /// Training scores as `[size][fold]`.
29    pub train_scores: Vec<Vec<f64>>,
30    /// Validation scores as `[size][fold]`.
31    pub val_scores: Vec<Vec<f64>>,
32}
33
34impl LearningCurve {
35    /// Mean training score at each size (one value per size).
36    #[must_use]
37    pub fn mean_train_scores(&self) -> Vec<f64> {
38        self.train_scores.iter().map(|row| mean(row)).collect()
39    }
40
41    /// Mean validation score at each size (one value per size).
42    #[must_use]
43    pub fn mean_val_scores(&self) -> Vec<f64> {
44        self.val_scores.iter().map(|row| mean(row)).collect()
45    }
46}
47
48fn mean(xs: &[f64]) -> f64 {
49    xs.iter().sum::<f64>() / xs.len() as f64
50}
51
52/// One (size, fold) evaluation.
53struct Job {
54    size_idx: usize,
55    fold_idx: usize,
56    train_score: f64,
57    val_score: f64,
58}
59
60/// Compute a learning curve: for each training size and each fold, fit on a
61/// prefix of the fold's training set and score on both that prefix and the
62/// held-out validation set.
63///
64/// `train_sizes` are resolved against the *smallest* training set across folds
65/// (so every fold can supply every size), then clamped to `1..=min_train` and
66/// sorted ascending.
67///
68/// With the `parallel` feature the `size × fold` grid — which can be much larger
69/// than a plain cross-validation — is fanned out over `rayon`.
70///
71/// # Errors
72///
73/// Propagates any error from `splitter.split(x.nrows())`.
74pub fn learning_curve<S, F, M>(
75    splitter: &S,
76    x: &Array2<f64>,
77    y: &Array1<f64>,
78    fit_fn: F,
79    scorer: &(dyn Scorer + Sync),
80    train_sizes: &[TrainSize],
81) -> Result<LearningCurve>
82where
83    S: CvSplitter,
84    F: Fn(&Array2<f64>, &Array1<f64>) -> M + Sync,
85    M: Fn(&Array2<f64>) -> Array1<f64>,
86{
87    let splits = splitter.split(x.nrows())?;
88    let min_train = splits.iter().map(|(tr, _)| tr.len()).min().unwrap_or(0);
89
90    // Resolve, clamp, sort, dedup.
91    let mut abs_sizes: Vec<usize> = train_sizes
92        .iter()
93        .map(|ts| match ts {
94            TrainSize::Count(c) => (*c).clamp(1, min_train.max(1)),
95            TrainSize::Fraction(f) => {
96                ((f * min_train as f64).round() as usize).clamp(1, min_train.max(1))
97            }
98        })
99        .collect();
100    abs_sizes.sort_unstable();
101    abs_sizes.dedup();
102
103    let eval = |size_idx: usize, fold_idx: usize| -> Job {
104        let (train, val) = &splits[fold_idx];
105        let size = abs_sizes[size_idx];
106        let sub = &train[..size];
107
108        let x_sub = x.select(Axis(0), sub);
109        let y_sub = y.select(Axis(0), sub);
110        let x_val = x.select(Axis(0), val);
111        let y_val = y.select(Axis(0), val);
112
113        let model = fit_fn(&x_sub, &y_sub);
114        let train_score = scorer.score(&y_sub, &model(&x_sub));
115        let val_score = scorer.score(&y_val, &model(&x_val));
116        Job {
117            size_idx,
118            fold_idx,
119            train_score,
120            val_score,
121        }
122    };
123
124    let coords: Vec<(usize, usize)> = (0..abs_sizes.len())
125        .flat_map(|s| (0..splits.len()).map(move |f| (s, f)))
126        .collect();
127
128    #[cfg(feature = "parallel")]
129    let jobs: Vec<Job> = {
130        use rayon::prelude::*;
131        coords.par_iter().map(|&(s, f)| eval(s, f)).collect()
132    };
133    #[cfg(not(feature = "parallel"))]
134    let jobs: Vec<Job> = coords.iter().map(|&(s, f)| eval(s, f)).collect();
135
136    let mut train_scores = vec![vec![0.0; splits.len()]; abs_sizes.len()];
137    let mut val_scores = vec![vec![0.0; splits.len()]; abs_sizes.len()];
138    for job in jobs {
139        train_scores[job.size_idx][job.fold_idx] = job.train_score;
140        val_scores[job.size_idx][job.fold_idx] = job.val_score;
141    }
142
143    Ok(LearningCurve {
144        train_sizes: abs_sizes,
145        train_scores,
146        val_scores,
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::scoring::R2Score;
154    use crate::splitters::KFold;
155
156    /// OLS on one feature (see cross_validate tests).
157    fn ols_fit(x: &Array2<f64>, y: &Array1<f64>) -> impl Fn(&Array2<f64>) -> Array1<f64> {
158        let n = x.nrows() as f64;
159        let xs: Vec<f64> = x.column(0).to_vec();
160        let ys: Vec<f64> = y.to_vec();
161        let mx = xs.iter().sum::<f64>() / n;
162        let my = ys.iter().sum::<f64>() / n;
163        let cov: f64 = xs.iter().zip(&ys).map(|(a, b)| (a - mx) * (b - my)).sum();
164        let var: f64 = xs.iter().map(|a| (a - mx).powi(2)).sum();
165        let slope = if var == 0.0 { 0.0 } else { cov / var };
166        let intercept = my - slope * mx;
167        move |xq: &Array2<f64>| xq.column(0).mapv(|v| slope * v + intercept)
168    }
169
170    /// A high-bias (under-capacity) model: always predicts a constant 0.
171    fn constant_zero(_x: &Array2<f64>, _y: &Array1<f64>) -> impl Fn(&Array2<f64>) -> Array1<f64> {
172        |xq: &Array2<f64>| Array1::zeros(xq.nrows())
173    }
174
175    #[test]
176    fn shapes_are_correct() {
177        let x = Array2::from_shape_fn((30, 1), |(i, _)| i as f64);
178        let y = x.column(0).mapv(|v| 2.0 * v + 1.0);
179        let kf = KFold::new(3).unwrap();
180        let lc = learning_curve(
181            &kf,
182            &x,
183            &y,
184            ols_fit,
185            &R2Score,
186            &[
187                TrainSize::Fraction(0.3),
188                TrainSize::Fraction(0.6),
189                TrainSize::Fraction(1.0),
190            ],
191        )
192        .unwrap();
193        assert_eq!(lc.train_sizes.len(), 3);
194        assert_eq!(lc.train_scores.len(), 3);
195        assert_eq!(lc.train_scores[0].len(), 3); // folds
196    }
197
198    #[test]
199    fn high_bias_curves_are_both_mediocre() {
200        // Linear data, but a constant model can't capture it: both train and
201        // validation R2 stay low and close together (high-bias signature).
202        let x = Array2::from_shape_fn((40, 1), |(i, _)| i as f64);
203        let y = x.column(0).mapv(|v| 3.0 * v + 2.0);
204        let kf = KFold::new(4).unwrap();
205        let lc = learning_curve(
206            &kf,
207            &x,
208            &y,
209            constant_zero,
210            &R2Score,
211            &[TrainSize::Fraction(0.5), TrainSize::Fraction(1.0)],
212        )
213        .unwrap();
214        let train = lc.mean_train_scores();
215        let val = lc.mean_val_scores();
216        for (t, v) in train.iter().zip(&val) {
217            assert!(*t < 0.5, "train R2 should be poor, got {t}");
218            assert!(*v < 0.5, "val R2 should be poor, got {v}");
219        }
220    }
221}