model_selection_rs/evaluate/
learning_curve.rs1use ndarray::{Array1, Array2, Axis};
4
5use crate::error::Result;
6use crate::scoring::Scorer;
7use crate::splitters::CvSplitter;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
12pub enum TrainSize {
13 Count(usize),
15 Fraction(f64),
17}
18
19#[derive(Debug, Clone)]
25pub struct LearningCurve {
26 pub train_sizes: Vec<usize>,
28 pub train_scores: Vec<Vec<f64>>,
30 pub val_scores: Vec<Vec<f64>>,
32}
33
34impl LearningCurve {
35 #[must_use]
37 pub fn mean_train_scores(&self) -> Vec<f64> {
38 self.train_scores.iter().map(|row| mean(row)).collect()
39 }
40
41 #[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
52struct Job {
54 size_idx: usize,
55 fold_idx: usize,
56 train_score: f64,
57 val_score: f64,
58}
59
60pub 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 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 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 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); }
197
198 #[test]
199 fn high_bias_curves_are_both_mediocre() {
200 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}