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}