Skip to main content

qualia_core_db/solvers/learning/resampling/
mod.rs

1//! Resampling methods (ISL ch 5) — cross-validation and the bootstrap. The
2//! generic harness every later chapter reuses to estimate test error / variability
3//! without a separate validation set.
4//!
5//! [`folds`] generates the index splits; [`bootstrap`] resamples a statistic;
6//! [`cross_val_score`] runs a caller-supplied fit→predict over the folds and scores
7//! each with a caller-supplied metric (so it is estimator-agnostic — works with the
8//! `regression`, `glm`, … estimators or any closure).
9
10pub mod bootstrap;
11pub mod folds;
12pub mod permutation;
13
14pub use bootstrap::{
15    bootstrap_ci, bootstrap_estimate, bootstrap_indices, BootstrapCi, BootstrapResult, CiMethod,
16};
17pub use folds::{k_fold, loocv, train_test_split, Fold};
18pub use permutation::{two_sample_test, PermutationResult};
19
20/// Gather the rows named by `idx` from a row-major `_ × p` matrix into a fresh
21/// contiguous `idx.len() × p` matrix.
22fn gather_rows(x: &[f64], p: usize, idx: &[usize]) -> Vec<f64> {
23    let mut out = vec![0.0; idx.len() * p];
24    for (r, &i) in idx.iter().enumerate() {
25        out[r * p..(r + 1) * p].copy_from_slice(&x[i * p..(i + 1) * p]);
26    }
27    out
28}
29
30/// Cross-validated score of an estimator across `folds`.
31///
32/// `fit_predict(train_x, train_y, n_train, test_x, n_test) -> predictions` trains on
33/// the fold's training rows and predicts its test rows; `metric(y_true, preds) ->
34/// score` scores that fold (e.g. `metrics::mse` or a negated error). Returns one
35/// score per fold, or `None` on a shape mismatch / empty folds.
36pub fn cross_val_score<F, M>(
37    x: &[f64],
38    y: &[f64],
39    n: usize,
40    p: usize,
41    folds: &[Fold],
42    mut fit_predict: F,
43    metric: M,
44) -> Option<Vec<f64>>
45where
46    F: FnMut(&[f64], &[f64], usize, &[f64], usize) -> Vec<f64>,
47    M: Fn(&[f64], &[f64]) -> Option<f64>,
48{
49    if x.len() != n * p || y.len() != n || folds.is_empty() {
50        return None;
51    }
52    let mut scores = Vec::with_capacity(folds.len());
53    for fold in folds {
54        let n_tr = fold.train.len();
55        let n_te = fold.test.len();
56        if n_tr == 0 || n_te == 0 {
57            return None;
58        }
59        let train_x = gather_rows(x, p, &fold.train);
60        let train_y: Vec<f64> = fold.train.iter().map(|&i| y[i]).collect();
61        let test_x = gather_rows(x, p, &fold.test);
62        let test_y: Vec<f64> = fold.test.iter().map(|&i| y[i]).collect();
63        let preds = fit_predict(&train_x, &train_y, n_tr, &test_x, n_te);
64        if preds.len() != n_te {
65            return None;
66        }
67        scores.push(metric(&test_y, &preds)?);
68    }
69    Some(scores)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::solvers::learning::metrics::regression::mse;
76    use crate::solvers::learning::regression::linear;
77
78    #[test]
79    fn cross_validates_a_linear_model() {
80        // y = 2 + 3x + small noise; CV MSE should be small for OLS.
81        let n = 20;
82        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
83        let y: Vec<f64> = x
84            .iter()
85            .map(|&xi| 2.0 + 3.0 * xi + ((xi as i64 % 3) as f64 - 1.0) * 0.1)
86            .collect();
87        let folds = k_fold(n, 5, true, 1);
88        let scores = cross_val_score(
89            &x,
90            &y,
91            n,
92            1,
93            &folds,
94            |tx, ty, ntr, tex, nte| {
95                let m = linear::fit(tx, ty, ntr, 1, true).unwrap();
96                m.predict(tex, nte, 1)
97            },
98            |yt, yp| mse(yt, yp),
99        )
100        .unwrap();
101        assert_eq!(scores.len(), 5);
102        // Mean CV MSE is small (the model fits the near-linear data).
103        let mean_mse: f64 = scores.iter().sum::<f64>() / scores.len() as f64;
104        assert!(mean_mse < 0.1, "CV MSE too large: {mean_mse}");
105    }
106
107    #[test]
108    fn loocv_runs_n_folds() {
109        let n = 8;
110        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
111        let y: Vec<f64> = x.iter().map(|&xi| 1.0 + 0.5 * xi).collect();
112        let folds = loocv(n);
113        let scores = cross_val_score(
114            &x,
115            &y,
116            n,
117            1,
118            &folds,
119            |tx, ty, ntr, tex, nte| {
120                linear::fit(tx, ty, ntr, 1, true)
121                    .unwrap()
122                    .predict(tex, nte, 1)
123            },
124            |yt, yp| mse(yt, yp),
125        )
126        .unwrap();
127        assert_eq!(scores.len(), n);
128        // Exact line → ~zero error on every held-out point.
129        assert!(scores.iter().all(|&s| s < 1e-9));
130    }
131}