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}