Skip to main content

stats_claw/resampling/
cross_validation.rs

1//! k-fold cross-validation evaluator for the
2//! [`CrossValidation`].
3//!
4//! The raw fold-index partition is produced by
5//! [`kfold_indices`]; this module adds the
6//! evaluation loop that runs a user `fit_score` closure over every
7//! `(train, test)` split and aggregates the per-fold scores into a mean and a
8//! standard error. Every split is driven by the deterministic
9//! [`SplitMix64`] PRNG, so a fixed seed reproduces the
10//! folds — and therefore the scores — bit-for-bit.
11
12use super::schemes::kfold_indices;
13use crate::error::{Error, Result};
14use crate::numeric::count_to_f64;
15use crate::resampling::CrossValidation;
16use crate::rng::SplitMix64;
17
18/// Aggregated cross-validation scores over the `k` folds.
19///
20/// Produced by [`cross_validate`] (and [`CrossValidation::run`]): the raw per-fold
21/// scores together with their mean and standard error. The fields are private;
22/// read them through the [`fold_scores`](Self::fold_scores), [`mean`](Self::mean),
23/// and [`std_error`](Self::std_error) accessors.
24///
25/// # Examples
26///
27/// ```
28/// use stats_claw::resampling::cross_validate;
29/// use stats_claw::rng::SplitMix64;
30///
31/// let mut rng = SplitMix64::new(1);
32/// let scores = cross_validate(12, 4, &mut rng, |_train, _test| 0.5)?;
33/// assert_eq!(scores.fold_scores(), &[0.5; 4]);
34/// assert!((scores.mean() - 0.5).abs() < 1e-12);
35/// # Ok::<(), stats_claw::error::Error>(())
36/// ```
37#[derive(Debug, Clone, PartialEq)]
38pub struct CvScores {
39    /// The score returned by `fit_score` for each fold, in fold order.
40    fold_scores: Vec<f64>,
41    /// The arithmetic mean of the per-fold scores.
42    mean: f64,
43    /// The standard error of the mean: `sd(fold_scores, ddof=1) / sqrt(k)`.
44    std_error: f64,
45}
46
47impl CvScores {
48    /// Aggregates raw per-fold scores into a [`CvScores`], computing the mean and
49    /// standard error once.
50    ///
51    /// This is the single aggregation site shared by k-fold [`cross_validate`] and
52    /// leave-one-out [`loo_cross_validate`](super::loocv::loo_cross_validate), so
53    /// both report identically-defined summaries. The standard error is
54    /// `sd(fold_scores, ddof=1) / sqrt(k)`.
55    ///
56    /// # Arguments
57    ///
58    /// * `scores` — the per-fold scores; callers guarantee at least two entries so
59    ///   the `ddof = 1` variance denominator (`k − 1`) is `>= 1`.
60    ///
61    /// # Returns
62    ///
63    /// A [`CvScores`] holding `scores` alongside their mean and standard error.
64    pub(crate) fn new(scores: Vec<f64>) -> Self {
65        let kf = count_to_f64(scores.len());
66        // Callers guarantee k >= 2, so the mean and the ddof=1 variance
67        // (`kf - 1 >= 1`) are both well defined. Shared helpers keep this
68        // aggregation identical to the Monte-Carlo estimator's.
69        let mean = crate::numeric::mean(&scores);
70        let variance = crate::numeric::sample_variance(&scores);
71        let std_error = variance.sqrt() / kf.sqrt();
72        Self {
73            fold_scores: scores,
74            mean,
75            std_error,
76        }
77    }
78
79    /// Returns the per-fold scores, in fold order.
80    ///
81    /// # Returns
82    ///
83    /// A borrowed slice of the `k` scores, one per fold, as returned by the
84    /// user's `fit_score` closure.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use stats_claw::resampling::cross_validate;
90    /// use stats_claw::rng::SplitMix64;
91    ///
92    /// let mut rng = SplitMix64::new(1);
93    /// let scores = cross_validate(9, 3, &mut rng, |_train, _test| 0.7)?;
94    /// assert_eq!(scores.fold_scores(), &[0.7, 0.7, 0.7]);
95    /// # Ok::<(), stats_claw::error::Error>(())
96    /// ```
97    #[must_use]
98    pub fn fold_scores(&self) -> &[f64] {
99        &self.fold_scores
100    }
101
102    /// Returns the arithmetic mean of the per-fold scores.
103    ///
104    /// # Returns
105    ///
106    /// The mean cross-validated score.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use stats_claw::resampling::cross_validate;
112    /// use stats_claw::rng::SplitMix64;
113    ///
114    /// let mut rng = SplitMix64::new(1);
115    /// let scores = cross_validate(9, 3, &mut rng, |_train, _test| 0.7)?;
116    /// assert!((scores.mean() - 0.7).abs() < 1e-12);
117    /// # Ok::<(), stats_claw::error::Error>(())
118    /// ```
119    #[must_use]
120    pub const fn mean(&self) -> f64 {
121        self.mean
122    }
123
124    /// Returns the standard error of the mean: `sd(fold_scores, ddof=1) / sqrt(k)`.
125    ///
126    /// # Returns
127    ///
128    /// The standard error of the cross-validated mean; `0.0` when every fold
129    /// scored identically.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use stats_claw::resampling::cross_validate;
135    /// use stats_claw::rng::SplitMix64;
136    ///
137    /// let mut rng = SplitMix64::new(1);
138    /// let scores = cross_validate(9, 3, &mut rng, |_train, _test| 0.7)?;
139    /// assert!(scores.std_error().abs() < 1e-12);
140    /// # Ok::<(), stats_claw::error::Error>(())
141    /// ```
142    #[must_use]
143    pub const fn std_error(&self) -> f64 {
144        self.std_error
145    }
146}
147
148/// Runs k-fold cross-validation over `n` observations.
149///
150/// Partitions `0..n` into `k` folds via [`kfold_indices`], calls
151/// `fit_score(train_idx, test_idx)` once per fold, and aggregates the returned
152/// scores into their mean and standard error. The folds are drawn from `rng`, so
153/// two calls with identically seeded generators produce identical scores.
154/// The standard error is `sd(fold_scores, ddof=1) / sqrt(k)`, the
155/// usual estimate of the uncertainty in the cross-validated mean.
156///
157/// # Arguments
158///
159/// * `n` — number of observations to partition.
160/// * `k` — number of folds; must be in `2..=n`.
161/// * `rng` — the deterministic generator driving the fold shuffle.
162/// * `fit_score` — invoked once per fold as `fit_score(train_idx, test_idx)`,
163///   returning that fold's score (e.g. a validation accuracy). Taken as `FnMut`
164///   so it may carry mutable state across folds.
165///
166/// # Returns
167///
168/// A [`CvScores`] holding the per-fold scores, their mean, and the standard error.
169///
170/// # Errors
171///
172/// * [`Error::InvalidInput`] when `k < 2`: a single fold has no validation split,
173///   so it is a bad *parameter* rather than a data shortage (matching
174///   [`stratified_kfold_indices`](super::stratified::stratified_kfold_indices)).
175/// * [`Error::InsufficientData`] when `k > n`: there are fewer observations than
176///   requested folds, so [`kfold_indices`] cannot give every fold at least one
177///   observation.
178///
179/// # Examples
180///
181/// ```
182/// use stats_claw::resampling::cross_validate;
183/// use stats_claw::rng::SplitMix64;
184///
185/// let mut rng = SplitMix64::new(42);
186/// // A constant scorer: every fold reports 0.9, so the mean is 0.9 with no spread.
187/// let scores = cross_validate(20, 5, &mut rng, |_train, _test| 0.9)?;
188/// assert_eq!(scores.fold_scores().len(), 5);
189/// assert!((scores.mean() - 0.9).abs() < 1e-12);
190/// assert!(scores.std_error().abs() < 1e-12);
191/// # Ok::<(), stats_claw::error::Error>(())
192/// ```
193pub fn cross_validate(
194    n: usize,
195    k: usize,
196    rng: &mut SplitMix64,
197    mut fit_score: impl FnMut(&[usize], &[usize]) -> f64,
198) -> Result<CvScores> {
199    if k < 2 {
200        return Err(Error::InvalidInput("k must be >= 2".to_owned()));
201    }
202    if k > n {
203        return Err(Error::InsufficientData);
204    }
205    let fold_scores: Vec<f64> = kfold_indices(n, k, rng)
206        .iter()
207        .map(|(train, test)| fit_score(train, test))
208        .collect();
209    Ok(CvScores::new(fold_scores))
210}
211
212impl CrossValidation {
213    /// Runs k-fold cross-validation using this scheme's own configuration.
214    ///
215    /// Reads the fold count from [`number_of_folds`](Self::number_of_folds) and
216    /// seeds the deterministic PRNG from [`random_seed`](Self::random_seed), then
217    /// delegates to [`cross_validate`]. The `i64` seed is reinterpreted to `u64`
218    /// bit-for-bit via [`i64::cast_unsigned`] (not a numeric `as` cast, which the
219    /// `style.rs` guard bans), so a positive seed maps to the same magnitude.
220    /// This makes the parameter struct itself executable against the numerics.
221    ///
222    /// # Arguments
223    ///
224    /// * `n` — number of observations to partition into folds.
225    /// * `fit_score` — invoked once per fold as `fit_score(train_idx, test_idx)`,
226    ///   returning that fold's score.
227    ///
228    /// # Returns
229    ///
230    /// The aggregated [`CvScores`] over the configured number of folds.
231    ///
232    /// # Errors
233    ///
234    /// * [`Error::InvalidInput`] when `number_of_folds` is negative or
235    ///   unrepresentable as `usize`, or when the resolved fold count is `< 2`
236    ///   (matching [`StratifiedCrossValidation::folds`](crate::resampling::StratifiedCrossValidation::folds)).
237    /// * [`Error::InsufficientData`] when the fold count exceeds `n`.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use stats_claw::resampling::CrossValidation;
243    ///
244    /// let cv = CrossValidation { number_of_folds: 5, random_seed: 42, ..Default::default() };
245    /// let scores = cv.run(20, |_train, _test| 1.0)?;
246    /// assert_eq!(scores.fold_scores().len(), 5);
247    /// // Every fold scored 1.0, so the mean is 1.0 and the spread is zero.
248    /// assert!((scores.mean() - 1.0).abs() < 1e-12);
249    /// assert!(scores.std_error().abs() < 1e-12);
250    /// # Ok::<(), stats_claw::error::Error>(())
251    /// ```
252    pub fn run(
253        &self,
254        n: usize,
255        fit_score: impl FnMut(&[usize], &[usize]) -> f64,
256    ) -> Result<CvScores> {
257        let k = usize::try_from(self.number_of_folds)
258            .map_err(|_| Error::InvalidInput("number_of_folds must be non-negative".to_owned()))?;
259        let mut rng = SplitMix64::new(self.random_seed.cast_unsigned());
260        cross_validate(n, k, &mut rng, fit_score)
261    }
262}
263
264// Kani note (dropped harness): a `resampling_cross_validate_rejects_out_of_domain`
265// harness — proving `cross_validate` returns `Err(InsufficientData)` for every
266// out-of-domain `(n, k)` — was attempted but dropped. `cross_validate` guards its
267// fold count (`k < 2 || k > n`) and then, on the in-domain branch, delegates to
268// `kfold_indices`. CBMC structurally unwinds that in-domain branch's
269// `permutation`/`kfold_indices` loops over the symbolic `n` even though the guard
270// makes them unreachable on the feasible out-of-domain paths, and the resulting
271// unwinding assertions do not discharge within the ~3-minute per-harness budget.
272// The one-line domain guard's downstream is already fully verified elsewhere:
273// `schemes::verification::resampling_kfold_test_sets_partition` and
274// `resampling_kfold_zero_k_is_empty` prove the k-fold index-safety and partition
275// invariants over all generator states, and `index::verification` proves the
276// underlying index arithmetic. The guard itself is exercised by the
277// `#[cfg(test)]` unit suite (`rejects_fewer_than_two_folds`,
278// `rejects_more_folds_than_observations`).
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn exposes_scores_via_accessors() -> Result<()> {
286        let mut rng = SplitMix64::new(5);
287        let scores = cross_validate(8, 4, &mut rng, |_train, _test| 0.5)?;
288        assert_eq!(
289            scores.fold_scores(),
290            &[0.5, 0.5, 0.5, 0.5],
291            "fold_scores() must return the per-fold scores slice"
292        );
293        assert!(
294            (scores.mean() - 0.5).abs() < 1e-12,
295            "mean() was {}",
296            scores.mean()
297        );
298        assert!(
299            scores.std_error().abs() < 1e-12,
300            "std_error() was {}",
301            scores.std_error()
302        );
303        Ok(())
304    }
305
306    #[test]
307    fn rejects_fewer_than_two_folds() {
308        let mut rng = SplitMix64::new(1);
309        let result = cross_validate(10, 1, &mut rng, |_train, _test| 0.0);
310        assert!(
311            matches!(result, Err(Error::InvalidInput(_))),
312            "k < 2 is a bad parameter and must be InvalidInput, got {result:?}"
313        );
314    }
315
316    #[test]
317    fn rejects_more_folds_than_observations() {
318        let mut rng = SplitMix64::new(1);
319        let result = cross_validate(3, 5, &mut rng, |_train, _test| 0.0);
320        assert_eq!(
321            result,
322            Err(Error::InsufficientData),
323            "k > n must be rejected, got {result:?}"
324        );
325    }
326
327    #[test]
328    fn folds_partition_the_observations() -> Result<()> {
329        let n = 10;
330        let k = 5;
331        let mut splits: Vec<(Vec<usize>, Vec<usize>)> = Vec::new();
332        let mut rng = SplitMix64::new(7);
333        let scores = cross_validate(n, k, &mut rng, |train, test| {
334            splits.push((train.to_vec(), test.to_vec()));
335            0.0
336        })?;
337
338        assert_eq!(scores.fold_scores().len(), k, "one score per fold");
339        assert_eq!(splits.len(), k, "fit_score must run once per fold");
340
341        // Test sets partition 0..n: every index appears exactly once across folds.
342        let mut seen = vec![0u32; n];
343        for (train, test) in &splits {
344            for &i in test {
345                if let Some(count) = seen.get_mut(i) {
346                    *count += 1;
347                }
348            }
349            // train and test are disjoint within a fold.
350            for &t in test {
351                assert!(!train.contains(&t), "index {t} in both train and test");
352            }
353            // train is the complement of test.
354            assert_eq!(train.len() + test.len(), n, "train ∪ test must cover all n");
355        }
356        assert!(
357            seen.iter().all(|&c| c == 1),
358            "each index must land in exactly one test fold, counts: {seen:?}"
359        );
360        Ok(())
361    }
362
363    #[test]
364    fn aggregates_mean_and_standard_error() -> Result<()> {
365        // Predetermined per-fold scores drive the aggregation independently of the
366        // fold assignment: fit_score returns the next value on each call (FnMut).
367        // Golden values (python3):
368        //   import numpy as np
369        //   s = np.array([0.80, 0.75, 0.82, 0.79, 0.85])
370        //   s.mean()                       -> 0.8019999999999999
371        //   s.std(ddof=1)/np.sqrt(len(s))  -> 0.016552945357246843
372        let predetermined = [0.80, 0.75, 0.82, 0.79, 0.85];
373        let mut supply = predetermined.iter().copied();
374        let mut rng = SplitMix64::new(3);
375        let scores = cross_validate(10, 5, &mut rng, |_train, _test| {
376            supply.next().unwrap_or(f64::NAN)
377        })?;
378
379        assert_eq!(
380            scores.fold_scores(),
381            &predetermined,
382            "scores recorded in order"
383        );
384        assert!(
385            (scores.mean() - 0.801_999_999_999_999_9).abs() < 1e-12,
386            "mean was {}",
387            scores.mean()
388        );
389        assert!(
390            (scores.std_error() - 0.016_552_945_357_246_843).abs() < 1e-12,
391            "std_error was {}",
392            scores.std_error()
393        );
394        Ok(())
395    }
396
397    #[test]
398    fn identical_seeds_reproduce_scores() -> Result<()> {
399        // fit_score depends on the split (sum of test indices), so identical
400        // scores imply identical folds — the determinism contract.
401        let score_of =
402            |_train: &[usize], test: &[usize]| -> f64 { count_to_f64(test.iter().sum::<usize>()) };
403        let a = cross_validate(12, 4, &mut SplitMix64::new(2024), score_of)?;
404        let b = cross_validate(12, 4, &mut SplitMix64::new(2024), score_of)?;
405        assert_eq!(a, b, "identical seeds must reproduce the CV scores");
406
407        let c = cross_validate(12, 4, &mut SplitMix64::new(99), score_of)?;
408        assert_ne!(
409            a.fold_scores(),
410            c.fold_scores(),
411            "different seeds should generally yield different folds"
412        );
413        Ok(())
414    }
415
416    #[test]
417    fn run_rejects_negative_fold_count_as_invalid_input() {
418        let cv = CrossValidation {
419            number_of_folds: -3,
420            random_seed: 1,
421            ..Default::default()
422        };
423        let result = cv.run(10, |_train, _test| 0.0);
424        assert!(
425            matches!(result, Err(Error::InvalidInput(_))),
426            "a negative number_of_folds is a bad parameter and must be InvalidInput, got {result:?}"
427        );
428    }
429
430    #[test]
431    fn run_matches_free_function_with_equivalent_seed() -> Result<()> {
432        let score_of =
433            |_train: &[usize], test: &[usize]| -> f64 { count_to_f64(test.iter().sum::<usize>()) };
434        let cv = CrossValidation {
435            number_of_folds: 5,
436            random_seed: 42,
437            ..Default::default()
438        };
439        let via_run = cv.run(10, score_of)?;
440        // random_seed 42 (i64) reinterprets bit-for-bit to 42u64.
441        let via_free = cross_validate(10, 5, &mut SplitMix64::new(42), score_of)?;
442        assert_eq!(
443            via_run, via_free,
444            "run() must match cross_validate() with the equivalent seed"
445        );
446        Ok(())
447    }
448}