model_selection_rs/evaluate/
validation_curve.rs1use ndarray::{Array1, Array2, Axis};
4
5use crate::error::Result;
6use crate::scoring::Scorer;
7use crate::splitters::CvSplitter;
8
9#[derive(Debug, Clone)]
16pub struct ValidationCurve<P> {
17 pub param_values: Vec<P>,
19 pub train_scores: Vec<Vec<f64>>,
21 pub val_scores: Vec<Vec<f64>>,
23}
24
25impl<P> ValidationCurve<P> {
26 #[must_use]
28 pub fn mean_train_scores(&self) -> Vec<f64> {
29 self.train_scores.iter().map(|r| mean(r)).collect()
30 }
31
32 #[must_use]
34 pub fn mean_val_scores(&self) -> Vec<f64> {
35 self.val_scores.iter().map(|r| mean(r)).collect()
36 }
37}
38
39fn mean(xs: &[f64]) -> f64 {
40 xs.iter().sum::<f64>() / xs.len() as f64
41}
42
43struct Job {
44 param_idx: usize,
45 fold_idx: usize,
46 train_score: f64,
47 val_score: f64,
48}
49
50pub fn validation_curve<S, F, M, P>(
63 splitter: &S,
64 x: &Array2<f64>,
65 y: &Array1<f64>,
66 fit_fn: F,
67 scorer: &(dyn Scorer + Sync),
68 param_range: &[P],
69) -> Result<ValidationCurve<P>>
70where
71 S: CvSplitter,
72 F: Fn(&P, &Array2<f64>, &Array1<f64>) -> M + Sync,
73 M: Fn(&Array2<f64>) -> Array1<f64>,
74 P: Clone + Sync,
75{
76 let splits = splitter.split(x.nrows())?;
77
78 let eval = |param_idx: usize, fold_idx: usize| -> Job {
79 let (train, val) = &splits[fold_idx];
80 let x_train = x.select(Axis(0), train);
81 let y_train = y.select(Axis(0), train);
82 let x_val = x.select(Axis(0), val);
83 let y_val = y.select(Axis(0), val);
84
85 let model = fit_fn(¶m_range[param_idx], &x_train, &y_train);
86 let train_score = scorer.score(&y_train, &model(&x_train));
87 let val_score = scorer.score(&y_val, &model(&x_val));
88 Job {
89 param_idx,
90 fold_idx,
91 train_score,
92 val_score,
93 }
94 };
95
96 let coords: Vec<(usize, usize)> = (0..param_range.len())
97 .flat_map(|p| (0..splits.len()).map(move |f| (p, f)))
98 .collect();
99
100 #[cfg(feature = "parallel")]
101 let jobs: Vec<Job> = {
102 use rayon::prelude::*;
103 coords.par_iter().map(|&(p, f)| eval(p, f)).collect()
104 };
105 #[cfg(not(feature = "parallel"))]
106 let jobs: Vec<Job> = coords.iter().map(|&(p, f)| eval(p, f)).collect();
107
108 let mut train_scores = vec![vec![0.0; splits.len()]; param_range.len()];
109 let mut val_scores = vec![vec![0.0; splits.len()]; param_range.len()];
110 for job in jobs {
111 train_scores[job.param_idx][job.fold_idx] = job.train_score;
112 val_scores[job.param_idx][job.fold_idx] = job.val_score;
113 }
114
115 Ok(ValidationCurve {
116 param_values: param_range.to_vec(),
117 train_scores,
118 val_scores,
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::scoring::MeanSquaredError;
126 use crate::splitters::KFold;
127
128 fn poly_fit(
136 degree: &usize,
137 x: &Array2<f64>,
138 y: &Array1<f64>,
139 ) -> impl Fn(&Array2<f64>) -> Array1<f64> {
140 let d = *degree;
141 let xs: Vec<f64> = x.column(0).to_vec();
142 let ys: Vec<f64> = y.to_vec();
143 let coeffs = fit_poly(&xs, &ys, d);
144 move |xq: &Array2<f64>| xq.column(0).mapv(|v| eval_poly(&coeffs, v))
145 }
146
147 fn eval_poly(coeffs: &[f64], x: f64) -> f64 {
148 coeffs
149 .iter()
150 .enumerate()
151 .map(|(i, c)| c * x.powi(i as i32))
152 .sum()
153 }
154
155 fn fit_poly(xs: &[f64], ys: &[f64], degree: usize) -> Vec<f64> {
157 let n = xs.len();
158 let m = degree + 1;
159 let x: Vec<Vec<f64>> = xs
161 .iter()
162 .map(|&v| (0..m).map(|p| v.powi(p as i32)).collect())
163 .collect();
164 let mut xtx = vec![vec![0.0; m]; m];
166 let mut xty = vec![0.0; m];
167 for i in 0..n {
168 for a in 0..m {
169 xty[a] += x[i][a] * ys[i];
170 for b in 0..m {
171 xtx[a][b] += x[i][a] * x[i][b];
172 }
173 }
174 }
175 solve(xtx, xty)
176 }
177
178 #[allow(clippy::needless_range_loop)] fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
181 let n = b.len();
182 for col in 0..n {
183 let pivot = (col..n)
184 .max_by(|&r1, &r2| a[r1][col].abs().partial_cmp(&a[r2][col].abs()).unwrap())
185 .unwrap();
186 a.swap(col, pivot);
187 b.swap(col, pivot);
188 let d = a[col][col];
189 if d.abs() < 1e-12 {
190 continue;
191 }
192 for row in (col + 1)..n {
193 let f = a[row][col] / d;
194 for k in col..n {
195 a[row][k] -= f * a[col][k];
196 }
197 b[row] -= f * b[col];
198 }
199 }
200 let mut sol = vec![0.0; n];
201 for row in (0..n).rev() {
202 let mut s = b[row];
203 for k in (row + 1)..n {
204 s -= a[row][k] * sol[k];
205 }
206 sol[row] = if a[row][row].abs() < 1e-12 {
207 0.0
208 } else {
209 s / a[row][row]
210 };
211 }
212 sol
213 }
214
215 #[test]
216 fn degree_sweet_spot_shows_up() {
217 let x = Array2::from_shape_fn((40, 1), |(i, _)| (i as f64) / 10.0 - 2.0);
219 let y = x.column(0).mapv(|v| v * v - 0.5 * v + 1.0);
220 let kf = KFold::new(4).unwrap().with_shuffle(0);
221 let vc =
222 validation_curve(&kf, &x, &y, poly_fit, &MeanSquaredError, &[1usize, 2, 3]).unwrap();
223 let val = vc.mean_val_scores(); assert!(
226 val[1] < val[0],
227 "degree 2 MSE {} should beat degree 1 {}",
228 val[1],
229 val[0]
230 );
231 }
232}