Skip to main content

stats_claw/resampling/
loocv.rs

1//! Leave-one-out cross-validation (LOO-CV) splits and scoring, layered onto the
2//! [`LeaveOneOutCrossValidation`].
3//!
4//! LOO-CV is the deterministic `k = n` limit of k-fold CV: each of the `n`
5//! observations is held out as a singleton test set exactly once while the
6//! remaining `n − 1` train it. There is no RNG anywhere in this module — the
7//! folds are a fixed function of `n`, so results are trivially reproducible.
8
9use super::cross_validation::CvScores;
10use crate::error::{Error, Result};
11use crate::resampling::LeaveOneOutCrossValidation;
12
13/// Builds the `n` leave-one-out folds for a dataset of size `n`.
14///
15/// Fold `i` holds out observation `i` as the sole test index and trains on every
16/// other index in ascending order, so the returned vector has exactly `n` entries
17/// and the test singletons partition `0..n`. The split is a pure function of `n`
18/// — no randomness is involved.
19///
20/// # Arguments
21///
22/// * `n` — the number of observations; must be at least 2 (a singleton has no
23///   held-out complement to train on).
24///
25/// # Returns
26///
27/// An `n`-element vector of `(train_indices, test_indices)` pairs where each
28/// `test_indices` is `[i]` and each `train_indices` is `0..n` with `i` removed,
29/// order preserved.
30///
31/// # Errors
32///
33/// Returns [`Error::InsufficientData`] when `n < 2`.
34///
35/// # Examples
36///
37/// ```
38/// use stats_claw::resampling::loo_indices;
39///
40/// let folds = loo_indices(3)?;
41/// assert_eq!(folds[0], (vec![1, 2], vec![0]));
42/// assert_eq!(folds[2], (vec![0, 1], vec![2]));
43/// # Ok::<(), stats_claw::error::Error>(())
44/// ```
45pub fn loo_indices(n: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
46    if n < 2 {
47        return Err(Error::InsufficientData);
48    }
49    let folds = (0..n)
50        .map(|i| {
51            let train: Vec<usize> = (0..n).filter(|&j| j != i).collect();
52            (train, vec![i])
53        })
54        .collect();
55    Ok(folds)
56}
57
58/// Runs leave-one-out cross-validation over `n` observations.
59///
60/// Builds the `n` LOO folds (via [`loo_indices`]), calls `fit_score(train, test)`
61/// once per fold to obtain that fold's score, then summarises the scores by their
62/// mean and standard error. The evaluator receives the ordered train complement
63/// and the singleton test index of each fold, so every observation is scored as
64/// the held-out point exactly once.
65///
66/// # Arguments
67///
68/// * `n` — the number of observations; must be at least 2.
69/// * `fit_score` — invoked as `fit_score(train_idx, test_idx)` for each fold and
70///   returning that fold's score (e.g. a held-out error). Takes `FnMut` so the
71///   evaluator may carry mutable state across folds.
72///
73/// # Returns
74///
75/// A [`CvScores`] with the `n` per-fold scores, their mean, and their standard
76/// error (`sd` with `ddof = 1`, divided by `sqrt(n)`) — the same score type
77/// k-fold [`cross_validate`](super::cross_validation::cross_validate) returns.
78///
79/// # Errors
80///
81/// Returns [`Error::InsufficientData`] when `n < 2` (propagated from
82/// [`loo_indices`]).
83///
84/// # Examples
85///
86/// ```
87/// use stats_claw::resampling::loo_cross_validate;
88///
89/// // Constant score per fold: mean equals it, standard error is zero.
90/// let scores = loo_cross_validate(5, |_train, _test| 3.0)?;
91/// assert_eq!(scores.fold_scores().len(), 5);
92/// assert!((scores.mean() - 3.0).abs() < 1e-12, "mean was {}", scores.mean());
93/// assert!(scores.std_error().abs() < 1e-12, "std_error was {}", scores.std_error());
94/// # Ok::<(), stats_claw::error::Error>(())
95/// ```
96pub fn loo_cross_validate(
97    n: usize,
98    mut fit_score: impl FnMut(&[usize], &[usize]) -> f64,
99) -> Result<CvScores> {
100    let folds = loo_indices(n)?;
101    let fold_scores: Vec<f64> = folds
102        .iter()
103        .map(|(train, test)| fit_score(train, test))
104        .collect();
105    Ok(CvScores::new(fold_scores))
106}
107
108impl LeaveOneOutCrossValidation {
109    /// Runs leave-one-out cross-validation for this scheme over `n` observations.
110    ///
111    /// A thin inherent wrapper over [`loo_cross_validate`] so the
112    /// [`LeaveOneOutCrossValidation`] type carries its own numerics: it ignores
113    /// the scheme's descriptive fields and forwards `n` and `fit_score` unchanged.
114    ///
115    /// # Arguments
116    ///
117    /// * `n` — the number of observations; must be at least 2.
118    /// * `fit_score` — invoked as `fit_score(train_idx, test_idx)` per fold,
119    ///   returning that fold's score. `FnMut` so it may carry state across folds.
120    ///
121    /// # Returns
122    ///
123    /// A [`CvScores`] with the per-fold scores, their mean, and their standard
124    /// error.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`Error::InsufficientData`] when `n < 2`.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use stats_claw::resampling::LeaveOneOutCrossValidation;
134    ///
135    /// let scheme = LeaveOneOutCrossValidation::default();
136    /// let scores = scheme.run(4, |_train, _test| 1.0)?;
137    /// assert_eq!(scores.fold_scores().len(), 4);
138    /// assert!((scores.mean() - 1.0).abs() < 1e-12);
139    /// # Ok::<(), stats_claw::error::Error>(())
140    /// ```
141    pub fn run(
142        &self,
143        n: usize,
144        fit_score: impl FnMut(&[usize], &[usize]) -> f64,
145    ) -> Result<CvScores> {
146        loo_cross_validate(n, fit_score)
147    }
148}
149
150/// Kani formal-verification harnesses for the leave-one-out fold construction.
151///
152/// [`loo_indices`] is a pure function of `n` (no RNG), so these prove its
153/// input-validation and partition invariants over a symbolic `n` and a small fixed
154/// `n`, rather than the sampled fixtures the `#[cfg(test)]` suite uses. Compiled
155/// only under `cargo kani` (behind `#[cfg(kani)]`); invisible to normal
156/// build/test/clippy. Run e.g. with
157/// `cargo kani -Z stubbing -p stats-claw --harness resampling_loo_rejects_small_n`.
158#[cfg(kani)]
159mod verification {
160    use super::{Error, loo_indices};
161
162    /// Proves the input-validation path: for *every* symbolic `n < 2`,
163    /// [`loo_indices`] returns [`Error::InsufficientData`] and never panics — a
164    /// singleton has no held-out complement to train on.
165    ///
166    /// The `#[kani::unwind(2)]` bound caps the (unreachable-on-feasible-paths)
167    /// fold-building loop: with `n < 2` the guard returns before it, and CBMC
168    /// discharges the over-unwinding of the infeasible `n >= 2` branch vacuously.
169    #[kani::proof]
170    #[kani::unwind(2)]
171    fn resampling_loo_rejects_small_n() {
172        let n: usize = kani::any();
173        kani::assume(n < 2);
174        let result = loo_indices(n);
175        assert!(
176            matches!(result, Err(Error::InsufficientData)),
177            "n < 2 must be rejected with InsufficientData"
178        );
179    }
180
181    /// Proves the LOO folds partition `0..N` and are in bounds for `n = 3`: exactly
182    /// `N` folds, each test set the singleton `[i]`, each train set the ordered
183    /// complement (size `N - 1`, every index `< N`, none equal to `i`), and the
184    /// test singletons covering every observation exactly once.
185    #[kani::proof]
186    #[kani::unwind(5)]
187    fn resampling_loo_indices_partition() {
188        const N: usize = 3;
189        let result = loo_indices(N);
190        assert!(result.is_ok(), "n >= 2 must produce LOO folds");
191        if let Ok(folds) = result {
192            assert!(folds.len() == N, "expected one fold per observation");
193            let mut seen = [0u8; N];
194            for (i, (train, test)) in folds.iter().enumerate() {
195                assert!(test.len() == 1, "each test set must be a singleton");
196                for &t in test {
197                    assert!(t == i, "fold {i} must test its own index");
198                    assert!(t < N, "test index {t} escaped 0..N");
199                    seen[t] += 1;
200                }
201                assert!(train.len() == N - 1, "train must be the complement");
202                for &tr in train {
203                    assert!(tr < N, "train index {tr} escaped 0..N");
204                    assert!(tr != i, "train must not contain the held-out index");
205                }
206            }
207            assert!(
208                seen.iter().all(|&c| c == 1),
209                "test singletons must partition 0..N"
210            );
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::error::Error;
219    use crate::resampling::LeaveOneOutCrossValidation;
220
221    #[test]
222    fn loo_indices_rejects_fewer_than_two() {
223        assert_eq!(
224            loo_indices(1),
225            Err(Error::InsufficientData),
226            "n < 2 must be rejected: a singleton has no held-out complement"
227        );
228    }
229
230    #[test]
231    fn loo_indices_three_gives_each_singleton_test() -> Result<()> {
232        let folds = loo_indices(3)?;
233        assert_eq!(
234            folds,
235            vec![
236                (vec![1, 2], vec![0]),
237                (vec![0, 2], vec![1]),
238                (vec![0, 1], vec![2]),
239            ],
240            "each fold i must test [i] and train on the ordered complement"
241        );
242        Ok(())
243    }
244
245    #[test]
246    fn cross_validate_calls_each_index_once_as_the_test_point() -> Result<()> {
247        let mut seen: Vec<(Vec<usize>, Vec<usize>)> = Vec::new();
248        let scores = loo_cross_validate(4, |train, test| {
249            seen.push((train.to_vec(), test.to_vec()));
250            0.0
251        })?;
252        assert_eq!(scores.fold_scores().len(), 4, "one score per fold");
253        assert_eq!(
254            seen,
255            vec![
256                (vec![1, 2, 3], vec![0]),
257                (vec![0, 2, 3], vec![1]),
258                (vec![0, 1, 3], vec![2]),
259                (vec![0, 1, 2], vec![3]),
260            ],
261            "evaluator must receive each index once with its ordered complement for training"
262        );
263        Ok(())
264    }
265
266    #[test]
267    fn cross_validate_computes_mean_and_std_error() -> Result<()> {
268        // Predetermined fold scores fed in fold order; summaries checked against
269        // numpy: np.mean([0.5,1.5,2.5,3.5]) == 2.0,
270        // np.std(..., ddof=1)/np.sqrt(4) == 0.6454972243679028.
271        let predetermined = [0.5_f64, 1.5, 2.5, 3.5];
272        let mut next = predetermined.into_iter();
273        let scores = loo_cross_validate(4, |_train, _test| next.next().unwrap_or(f64::NAN))?;
274        for (i, (&got, &want)) in scores
275            .fold_scores()
276            .iter()
277            .zip(predetermined.iter())
278            .enumerate()
279        {
280            assert!(
281                (got - want).abs() < 1e-12,
282                "fold {i} score was {got}, want {want}"
283            );
284        }
285        assert!(
286            (scores.mean() - 2.0).abs() < 1e-12,
287            "mean was {}",
288            scores.mean()
289        );
290        assert!(
291            (scores.std_error() - 0.645_497_224_367_902_8).abs() < 1e-12,
292            "std_error was {}",
293            scores.std_error()
294        );
295        Ok(())
296    }
297
298    #[test]
299    fn cross_validate_predict_train_mean_squared_error_golden() -> Result<()> {
300        // Analytic golden: LOO-CV predicting the training mean, scored by squared
301        // error, over data = [2,4,6,8,10]. Fold i error = (train_mean - x_i)^2
302        // with train_mean = (n*xbar - x_i)/(n-1). numpy reference:
303        //   fold errors = [25.0, 6.25, 0.0, 6.25, 25.0]
304        //   mean = 12.5, std(ddof=1)/sqrt(5) = 5.229125165837972.
305        let data = [2.0_f64, 4.0, 6.0, 8.0, 10.0];
306        let scores = loo_cross_validate(data.len(), |train, test| {
307            let train_sum: f64 = train
308                .iter()
309                .map(|&j| data.get(j).copied().unwrap_or(f64::NAN))
310                .sum();
311            let train_count = f64::from(u32::try_from(train.len()).unwrap_or(0));
312            let prediction = train_sum / train_count;
313            let held_out = test
314                .first()
315                .and_then(|&i| data.get(i).copied())
316                .unwrap_or(f64::NAN);
317            (prediction - held_out).powi(2)
318        })?;
319        let expected = [25.0_f64, 6.25, 0.0, 6.25, 25.0];
320        for (i, (&got, &want)) in scores.fold_scores().iter().zip(expected.iter()).enumerate() {
321            assert!(
322                (got - want).abs() < 1e-10,
323                "fold {i} error was {got}, want {want}"
324            );
325        }
326        assert!(
327            (scores.mean() - 12.5).abs() < 1e-10,
328            "mean was {}",
329            scores.mean()
330        );
331        assert!(
332            (scores.std_error() - 5.229_125_165_837_972).abs() < 1e-10,
333            "std_error was {}",
334            scores.std_error()
335        );
336        Ok(())
337    }
338
339    #[test]
340    fn loo_cross_validate_returns_unified_cv_scores() -> Result<()> {
341        // LOO-CV must report the shared k-fold score type, not a bespoke struct.
342        let scores: CvScores = loo_cross_validate(4, |_train, _test| 1.0)?;
343        assert_eq!(scores.fold_scores().len(), 4, "one score per fold");
344        assert!(
345            (scores.mean() - 1.0).abs() < 1e-12,
346            "mean was {}",
347            scores.mean()
348        );
349        Ok(())
350    }
351
352    #[test]
353    fn run_delegates_to_cross_validate() -> Result<()> {
354        let scheme = LeaveOneOutCrossValidation::default();
355        let scores = scheme.run(5, |_train, _test| 3.0)?;
356        assert_eq!(scores.fold_scores().len(), 5, "one score per fold");
357        assert!(
358            scores
359                .fold_scores()
360                .iter()
361                .all(|&s| (s - 3.0).abs() < 1e-12),
362            "every fold score should be the constant 3.0"
363        );
364        assert!(
365            (scores.mean() - 3.0).abs() < 1e-12,
366            "mean was {}",
367            scores.mean()
368        );
369        assert!(
370            scores.std_error().abs() < 1e-12,
371            "std_error was {}",
372            scores.std_error()
373        );
374        Ok(())
375    }
376
377    #[test]
378    fn accessors_expose_the_stored_summaries() -> Result<()> {
379        let scores = loo_cross_validate(5, |_train, _test| 3.0)?;
380        assert_eq!(
381            scores.fold_scores().len(),
382            5,
383            "fold_scores accessor exposes one score per fold"
384        );
385        assert!(
386            scores
387                .fold_scores()
388                .iter()
389                .all(|&s| (s - 3.0).abs() < 1e-12),
390            "fold_scores accessor returns the stored slice"
391        );
392        assert!(
393            (scores.mean() - 3.0).abs() < 1e-12,
394            "mean accessor was {}",
395            scores.mean()
396        );
397        assert!(
398            scores.std_error().abs() < 1e-12,
399            "std_error accessor was {}",
400            scores.std_error()
401        );
402        Ok(())
403    }
404
405    /// D4 boundary: `loo_indices(2)` yields both singleton-test folds in order.
406    #[test]
407    fn loo_indices_two_gives_both_singleton_folds() -> Result<()> {
408        assert_eq!(
409            loo_indices(2)?,
410            vec![(vec![1], vec![0]), (vec![0], vec![1])],
411            "n=2 LOO folds must be ([1],[0]) then ([0],[1])"
412        );
413        Ok(())
414    }
415
416    /// D4 boundary: at `n = 2`, LOO-CV aggregates predetermined fold scores into
417    /// the exact mean and standard error.
418    ///
419    /// Scores `[1.0, 3.0]`: mean `2.0`; `sd(ddof=1) = sqrt(2)` and
420    /// `SE = sd / sqrt(2) = 1.0`.
421    #[test]
422    fn n_two_aggregates_mean_and_standard_error() -> Result<()> {
423        let predetermined = [1.0_f64, 3.0];
424        let mut next = predetermined.into_iter();
425        let scores = loo_cross_validate(2, |_train, _test| next.next().unwrap_or(f64::NAN))?;
426        assert_eq!(
427            scores.fold_scores(),
428            &predetermined,
429            "fold scores recorded in order"
430        );
431        assert!(
432            (scores.mean() - 2.0).abs() < 1e-12,
433            "n=2 mean was {}, expected 2.0",
434            scores.mean()
435        );
436        assert!(
437            (scores.std_error() - 1.0).abs() < 1e-12,
438            "n=2 std_error was {}, expected sd(ddof=1)/sqrt(2) = 1.0",
439            scores.std_error()
440        );
441        Ok(())
442    }
443}