Skip to main content

model_selection_rs/evaluate/
validation_curve.rs

1//! Validation curves: score vs. a single hyperparameter.
2
3use ndarray::{Array1, Array2, Axis};
4
5use crate::error::Result;
6use crate::scoring::Scorer;
7use crate::splitters::CvSplitter;
8
9/// Output of [`validation_curve`].
10///
11/// The companion diagnostic to a [learning curve](super::learning_curve): here
12/// the training-set size is fixed and a single hyperparameter varies. Kept
13/// deliberately distinct from the learning curve, which the two are easy to
14/// conflate. Scores are `[param][fold]`.
15#[derive(Debug, Clone)]
16pub struct ValidationCurve<P> {
17    /// The parameter values, in the order supplied.
18    pub param_values: Vec<P>,
19    /// Training scores as `[param][fold]`.
20    pub train_scores: Vec<Vec<f64>>,
21    /// Validation scores as `[param][fold]`.
22    pub val_scores: Vec<Vec<f64>>,
23}
24
25impl<P> ValidationCurve<P> {
26    /// Mean training score at each parameter value.
27    #[must_use]
28    pub fn mean_train_scores(&self) -> Vec<f64> {
29        self.train_scores.iter().map(|r| mean(r)).collect()
30    }
31
32    /// Mean validation score at each parameter value.
33    #[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
50/// Compute a validation curve: for each hyperparameter value and each fold, fit
51/// with that value and score on train and validation.
52///
53/// `fit_fn` here takes the parameter value as its first argument (e.g. a closure
54/// over "fit a decision tree with this `max_depth`").
55///
56/// With the `parallel` feature the `param × fold` grid is fanned out over
57/// `rayon`.
58///
59/// # Errors
60///
61/// Propagates any error from `splitter.split(x.nrows())`.
62pub 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(&param_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    /// Polynomial-ish toy: fit y with a ridge-regularised constant+slope where
129    /// `lambda` shrinks the slope. Too much shrinkage underfits; none overfits
130    /// noise. Here we just check the mechanics + a sweet-spot shape using a
131    /// hyperparameter `degree` on clearly nonlinear data.
132    ///
133    /// Model: predict with a polynomial of the given `degree` on one feature,
134    /// fit by least squares via normal equations on a Vandermonde matrix.
135    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    /// Least-squares polynomial fit via normal equations (small, dense).
156    fn fit_poly(xs: &[f64], ys: &[f64], degree: usize) -> Vec<f64> {
157        let n = xs.len();
158        let m = degree + 1;
159        // Vandermonde X (n x m).
160        let x: Vec<Vec<f64>> = xs
161            .iter()
162            .map(|&v| (0..m).map(|p| v.powi(p as i32)).collect())
163            .collect();
164        // Normal equations: (XtX) c = Xt y.
165        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    /// Gaussian elimination with partial pivoting.
179    #[allow(clippy::needless_range_loop)] // explicit column indices read clearer here
180    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        // Quadratic truth: degree 1 underfits, degree 2 is ideal.
218        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(); // MSE, lower is better
224                                        // degree 2 should beat degree 1 clearly.
225        assert!(
226            val[1] < val[0],
227            "degree 2 MSE {} should beat degree 1 {}",
228            val[1],
229            val[0]
230        );
231    }
232}