Skip to main content

stats_claw/resampling/
stratified.rs

1//! Stratified k-fold cross-validation splits, for the
2//! [`StratifiedCrossValidation`].
3//!
4//! Equivalent to `sklearn.model_selection.StratifiedKFold(shuffle=True)`: each
5//! fold's per-class counts match the overall label proportions as closely as
6//! possible — for every class `c`, a fold holds either `floor(m_c / k)` or
7//! `ceil(m_c / k)` members of `c`, so any two folds differ by at most one. The
8//! shuffle draws from the deterministic [`SplitMix64`] PRNG (the same Fisher–Yates
9//! idiom as [`permutation`](super::schemes::permutation)), so a fixed seed
10//! reproduces the split bit-for-bit.
11
12use std::collections::HashMap;
13
14use super::index::uniform_index;
15use crate::error::{Error, Result};
16use crate::resampling::StratifiedCrossValidation;
17use crate::rng::SplitMix64;
18
19/// Partitions labelled observations into `k` stratified cross-validation folds.
20///
21/// Groups the observation indices by class, shuffles each class in place with the
22/// Fisher–Yates idiom driven by `rng` (the same shuffle
23/// [`permutation`](super::schemes::permutation) uses), then deals each class's
24/// members round-robin across the `k` folds. Round-robin dealing keeps every
25/// fold's count of a class within one of the ideal `m_c / k`, matching
26/// scikit-learn's `StratifiedKFold` semantics: the class proportions of each fold
27/// track the overall label proportions as closely as possible. Class ids are
28/// arbitrary `usize` values (need not be contiguous). The split is deterministic
29/// for a fixed seed and label slice.
30///
31/// # Arguments
32///
33/// * `labels` — the class id of each observation; observation `i` has label
34///   `labels[i]`. Must be non-empty.
35/// * `k` — the number of folds; must be `>= 2` and no greater than the smallest
36///   class count.
37/// * `rng` — the deterministic generator driving the per-class shuffle.
38///
39/// # Returns
40///
41/// A `k`-element vector of `(train_indices, test_indices)` pairs. The test sets
42/// form a partition of `0..labels.len()`, and each train set is its complement.
43///
44/// # Errors
45///
46/// * [`Error::InvalidInput`] if `k < 2`.
47/// * [`Error::InsufficientData`] if `labels` is empty, or if `k` exceeds the
48///   smallest class count (a fold would then lack a member of that class —
49///   scikit-learn raises here too).
50///
51/// # Examples
52///
53/// ```
54/// use stats_claw::resampling::stratified_kfold_indices;
55/// use stats_claw::rng::SplitMix64;
56///
57/// // 15 of class 0, 10 of class 1; k = 5 divides both evenly.
58/// let mut labels = vec![0usize; 15];
59/// labels.extend(std::iter::repeat_n(1usize, 10));
60/// let mut rng = SplitMix64::new(7);
61/// let folds = stratified_kfold_indices(&labels, 5, &mut rng)?;
62///
63/// // Every test fold holds exactly 3 of class 0 and 2 of class 1.
64/// for (_, test) in &folds {
65///     let c0 = test.iter().filter(|&&i| labels.get(i) == Some(&0)).count();
66///     let c1 = test.iter().filter(|&&i| labels.get(i) == Some(&1)).count();
67///     assert_eq!((c0, c1), (3, 2));
68/// }
69/// # Ok::<(), stats_claw::error::Error>(())
70/// ```
71pub fn stratified_kfold_indices(
72    labels: &[usize],
73    k: usize,
74    rng: &mut SplitMix64,
75) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
76    if k < 2 {
77        return Err(Error::InvalidInput("k must be >= 2".to_owned()));
78    }
79    if labels.is_empty() {
80        return Err(Error::InsufficientData);
81    }
82
83    // Group observation indices by class, preserving first-seen class order so
84    // the per-class shuffle consumes `rng` in a seed-deterministic sequence.
85    let mut group_of: HashMap<usize, usize> = HashMap::new();
86    let mut groups: Vec<Vec<usize>> = Vec::new();
87    for (i, &class) in labels.iter().enumerate() {
88        let slot = *group_of.entry(class).or_insert_with(|| {
89            groups.push(Vec::new());
90            groups.len() - 1
91        });
92        if let Some(members) = groups.get_mut(slot) {
93            members.push(i);
94        }
95    }
96
97    let min_count = groups
98        .iter()
99        .map(Vec::len)
100        .min()
101        .ok_or(Error::InsufficientData)?;
102    if k > min_count {
103        return Err(Error::InsufficientData);
104    }
105
106    // Deal each class round-robin across the folds. Record (fold, observation)
107    // so the complement (train) can be built without index-into-slice access.
108    let mut assignments: Vec<(usize, usize)> = Vec::with_capacity(labels.len());
109    for mut members in groups {
110        // Fisher–Yates shuffle in place (the idiom `permutation` uses).
111        for i in (1..members.len()).rev() {
112            let j = uniform_index(rng, i + 1);
113            members.swap(i, j);
114        }
115        for (position, observation) in members.into_iter().enumerate() {
116            assignments.push((position % k, observation));
117        }
118    }
119
120    Ok((0..k)
121        .map(|fold| {
122            let mut train = Vec::new();
123            let mut test = Vec::new();
124            for &(assigned, observation) in &assignments {
125                if assigned == fold {
126                    test.push(observation);
127                } else {
128                    train.push(observation);
129                }
130            }
131            (train, test)
132        })
133        .collect())
134}
135
136impl StratifiedCrossValidation {
137    /// Splits labelled observations into stratified folds using this scheme's
138    /// configured [`number_of_folds`](Self::number_of_folds) and
139    /// [`random_seed`](Self::random_seed).
140    ///
141    /// Seeds a fresh [`SplitMix64`] from `random_seed` (reinterpreting the signed
142    /// seed's bits as unsigned) and delegates to [`stratified_kfold_indices`], so
143    /// two calls on structs with equal fields yield identical splits.
144    ///
145    /// # Arguments
146    ///
147    /// * `labels` — the class id of each observation; see
148    ///   [`stratified_kfold_indices`] for the partitioning contract.
149    ///
150    /// # Returns
151    ///
152    /// A vector of `(train_indices, test_indices)` pairs, one per fold.
153    ///
154    /// # Errors
155    ///
156    /// * [`Error::InvalidInput`] if `number_of_folds` is negative or `< 2`.
157    /// * [`Error::InsufficientData`] if `labels` is empty or `number_of_folds`
158    ///   exceeds the smallest class count.
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use stats_claw::resampling::StratifiedCrossValidation;
164    ///
165    /// let cv = StratifiedCrossValidation {
166    ///     number_of_folds: 2,
167    ///     random_seed: 42,
168    ///     ..Default::default()
169    /// };
170    /// let labels = [0usize, 1, 0, 1];
171    /// let folds = cv.folds(&labels)?;
172    /// assert_eq!(folds.len(), 2);
173    /// # Ok::<(), stats_claw::error::Error>(())
174    /// ```
175    pub fn folds(&self, labels: &[usize]) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
176        let k = usize::try_from(self.number_of_folds)
177            .map_err(|_| Error::InvalidInput("number_of_folds must be non-negative".to_owned()))?;
178        let mut rng = SplitMix64::new(self.random_seed.cast_unsigned());
179        stratified_kfold_indices(labels, k, &mut rng)
180    }
181}
182
183/// Kani formal-verification harnesses for stratified k-fold input validation.
184///
185/// [`stratified_kfold_indices`] guards `k` and the label slice before building any
186/// per-class groups, so these prove the two rejection paths over every `k < 2` (or
187/// an empty label slice) and every generator state, rather than the sampled sizes
188/// the `#[cfg(test)]` suite uses. The interior per-class shuffle draws through the
189/// same [`uniform_index`] proven in-bounds in [`super::index`], so the full
190/// partition proof is not re-derived here (the `HashMap` grouping is left to the
191/// unit suite to keep the symbolic model tractable). Compiled only under
192/// `cargo kani` (behind `#[cfg(kani)]`); invisible to normal build/test/clippy. Run
193/// e.g. with
194/// `cargo kani -Z stubbing -p stats-claw --harness resampling_stratified_rejects_small_k`.
195///
196/// # Keeping the `HashMap` grouping out of symbolic execution
197///
198/// Leaving the grouping to the unit suite is not only a modelling preference — on
199/// Linux it is what makes these harnesses terminate at all. CBMC's symbolic
200/// execution walks *both* sides of a branch it cannot fold at symex time; a
201/// `kani::assume` prunes the impossible side only later, at the solver. So a
202/// harness that reaches [`stratified_kfold_indices`] with a symbolic `k` still has
203/// the `k >= 2` branch executed symbolically, and that branch constructs a
204/// `HashMap`, whose `RandomState` seeds itself from OS entropy. On Linux that is
205/// `std::sys::random::linux::getrandom`, a "retry until the buffer is filled" loop
206/// whose trip count depends on a foreign call CBMC cannot model, so CBMC unwinds it
207/// without bound and never returns. macOS reaches entropy through a single
208/// non-looping call, so the same harness completes there in under a second — the
209/// divergence is in the platform's `std`, not in this crate. Both harnesses below
210/// therefore pass `k` concretely, which lets CBMC fold the guard and never reach
211/// the `HashMap` at all.
212#[cfg(kani)]
213mod verification {
214    use super::{Error, SplitMix64, stratified_kfold_indices};
215
216    /// Proves the fold-count guard: for *every* `k < 2` and *every* generator
217    /// state, [`stratified_kfold_indices`] returns [`Error::InvalidInput`] and never
218    /// panics — a stratified split needs at least two folds.
219    ///
220    /// `k` is enumerated concretely instead of drawn with `kani::any()` under
221    /// `assume(k < 2)`. That is a complete enumeration, not a weakening: `k: usize`
222    /// with `k < 2` has exactly the two inhabitants `0` and `1`, so the two cases
223    /// below cover precisely the same input set, and the generator state stays
224    /// fully symbolic in each. Passing `k` as a const parameter guarantees it
225    /// reaches CBMC as a literal, which is what keeps the dead `k >= 2` branch — and
226    /// the entropy-seeded `HashMap` behind it — out of symbolic execution; see the
227    /// module docs for why that matters on Linux.
228    #[kani::proof]
229    fn resampling_stratified_rejects_small_k() {
230        rejects_k::<0>();
231        rejects_k::<1>();
232    }
233
234    /// Asserts the `k < 2` rejection for one concrete fold count `K` over a fully
235    /// symbolic generator state.
236    ///
237    /// Called once per admissible `K` by
238    /// [`resampling_stratified_rejects_small_k`], which is where the enumeration is
239    /// justified.
240    fn rejects_k<const K: usize>() {
241        let labels = [0usize, 1usize];
242        let state: u64 = kani::any();
243        let mut rng = SplitMix64::new(state);
244        let result = stratified_kfold_indices(&labels, K, &mut rng);
245        assert!(
246            matches!(result, Err(Error::InvalidInput(_))),
247            "k < 2 must be rejected with InvalidInput"
248        );
249    }
250
251    /// Proves the empty-input guard: an empty label slice yields
252    /// [`Error::InsufficientData`] for every valid `k` and generator state, with no
253    /// panic — there is nothing to partition.
254    #[kani::proof]
255    fn resampling_stratified_empty_labels_insufficient() {
256        let labels: [usize; 0] = [];
257        let state: u64 = kani::any();
258        let mut rng = SplitMix64::new(state);
259        let result = stratified_kfold_indices(&labels, 2, &mut rng);
260        assert!(
261            matches!(result, Err(Error::InsufficientData)),
262            "empty labels must be rejected with InsufficientData"
263        );
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// `k < 2` is rejected as invalid input, matching scikit-learn's requirement
272    /// that a k-fold split have at least two folds.
273    #[test]
274    fn k_below_two_is_invalid() {
275        let labels = [0usize, 1, 0, 1];
276        let mut rng = SplitMix64::new(1);
277        assert!(
278            matches!(
279                stratified_kfold_indices(&labels, 1, &mut rng),
280                Err(Error::InvalidInput(_))
281            ),
282            "k = 1 should be InvalidInput"
283        );
284    }
285
286    /// Empty labels have nothing to partition, so the split reports insufficient
287    /// data rather than returning empty folds.
288    #[test]
289    fn empty_labels_is_insufficient_data() {
290        let labels: [usize; 0] = [];
291        let mut rng = SplitMix64::new(1);
292        assert_eq!(
293            stratified_kfold_indices(&labels, 2, &mut rng),
294            Err(Error::InsufficientData),
295            "empty labels should be InsufficientData"
296        );
297    }
298
299    /// `k` greater than the smallest class count cannot stratify (a fold would be
300    /// left without a member of that class), so it reports insufficient data —
301    /// scikit-learn raises here too.
302    #[test]
303    fn k_above_smallest_class_is_insufficient_data() {
304        // class 0 has 3 members, class 1 has 2 — smallest class count is 2.
305        let labels = [0usize, 0, 0, 1, 1];
306        let mut rng = SplitMix64::new(1);
307        assert_eq!(
308            stratified_kfold_indices(&labels, 3, &mut rng),
309            Err(Error::InsufficientData),
310            "k = 3 exceeds smallest class count 2"
311        );
312    }
313
314    /// The test folds partition `0..n`: each observation lands in exactly one test
315    /// fold, and each train set is precisely the complement of its test set.
316    #[test]
317    fn test_folds_partition_and_train_is_complement() -> Result<()> {
318        let labels = [0usize, 0, 0, 1, 1, 1, 0, 1, 0, 1];
319        let n = labels.len();
320        let mut rng = SplitMix64::new(99);
321        let folds = stratified_kfold_indices(&labels, 3, &mut rng)?;
322
323        let mut seen = vec![0usize; n];
324        for (train, test) in &folds {
325            for &t in test {
326                if let Some(count) = seen.get_mut(t) {
327                    *count += 1;
328                }
329            }
330            // train is the exact complement: sizes sum to n and sets are disjoint.
331            assert_eq!(train.len() + test.len(), n, "train+test must cover all n");
332            for &tr in train {
333                assert!(
334                    !test.contains(&tr),
335                    "index {tr} appears in both train and test of a fold"
336                );
337            }
338        }
339        assert!(
340            seen.iter().all(|&c| c == 1),
341            "every index must appear in exactly one test fold, got {seen:?}"
342        );
343        Ok(())
344    }
345
346    /// Counts how many test-fold members belong to `class`.
347    fn class_count(test: &[usize], labels: &[usize], class: usize) -> usize {
348        test.iter()
349            .filter(|&&i| labels.get(i) == Some(&class))
350            .count()
351    }
352
353    /// With class counts divisible by `k`, every fold holds exactly the ideal
354    /// per-class share. 15 of class 0 and 10 of class 1 over k=5 → exactly 3 and 2
355    /// per fold. This is the scikit-learn `StratifiedKFold` guarantee in the
356    /// evenly-divisible case (hand-computed: 15/5 = 3, 10/5 = 2); it holds for any
357    /// seed because round-robin dealing distributes m divisible-by-k members
358    /// exactly m/k per fold.
359    #[test]
360    fn exact_stratification_when_divisible() -> Result<()> {
361        let mut labels = vec![0usize; 15];
362        labels.extend(std::iter::repeat_n(1usize, 10));
363        let mut rng = SplitMix64::new(2024);
364        let folds = stratified_kfold_indices(&labels, 5, &mut rng)?;
365        assert_eq!(folds.len(), 5, "expected 5 folds");
366        for (_, test) in &folds {
367            assert_eq!(
368                (class_count(test, &labels, 0), class_count(test, &labels, 1)),
369                (3, 2),
370                "each fold must hold exactly 3 of class 0 and 2 of class 1"
371            );
372        }
373        Ok(())
374    }
375
376    /// General shape: for a 60/40 split of n=20 (12 of class 0, 8 of class 1) with
377    /// k=5, no fold's class-c count differs from the ideal `m_c / k` by more than
378    /// one. Hand-computed folds: class 0 (12 = 5·2+2) → two folds get 3, three get
379    /// 2; class 1 (8 = 5·1+3) → three folds get 2, two get 1. So every fold's
380    /// count is `floor` or `ceil` of the ideal — scikit-learn's "as balanced as
381    /// possible" guarantee.
382    #[test]
383    fn general_shape_within_one_of_ideal() -> Result<()> {
384        let mut labels = vec![0usize; 12];
385        labels.extend(std::iter::repeat_n(1usize, 8));
386        let k = 5;
387        let mut rng = SplitMix64::new(7);
388        let folds = stratified_kfold_indices(&labels, k, &mut rng)?;
389        for (class, m) in [(0usize, 12usize), (1usize, 8usize)] {
390            let floor = m / k;
391            let ceil = m.div_ceil(k);
392            for (_, test) in &folds {
393                let c = class_count(test, &labels, class);
394                assert!(
395                    c == floor || c == ceil,
396                    "class {class} fold count {c} not in {{{floor}, {ceil}}}"
397                );
398            }
399        }
400        Ok(())
401    }
402
403    /// The split is reproducible: identical seeds give identical folds, and
404    /// different seeds give a different assignment (for data large enough that the
405    /// shuffle can differ).
406    #[test]
407    fn deterministic_by_seed() -> Result<()> {
408        let mut labels = vec![0usize; 30];
409        labels.extend(std::iter::repeat_n(1usize, 20));
410        let same_a = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(11))?;
411        let same_b = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(11))?;
412        assert_eq!(same_a, same_b, "same seed must reproduce the split");
413
414        let different = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(999))?;
415        assert_ne!(
416            same_a, different,
417            "different seeds should shuffle to a different assignment"
418        );
419        Ok(())
420    }
421
422    /// Class ids are arbitrary `usize` values, not dense `0..c`. Non-contiguous
423    /// ids like {7, 42} stratify exactly as contiguous ones would.
424    #[test]
425    fn non_contiguous_class_ids() -> Result<()> {
426        let labels = [7usize, 42, 7, 42, 7, 42, 7, 42];
427        let mut rng = SplitMix64::new(5);
428        let folds = stratified_kfold_indices(&labels, 2, &mut rng)?;
429        assert_eq!(folds.len(), 2, "expected 2 folds");
430        for (_, test) in &folds {
431            // 4 of each class over 2 folds → exactly 2 of each per fold.
432            assert_eq!(
433                (
434                    class_count(test, &labels, 7),
435                    class_count(test, &labels, 42)
436                ),
437                (2, 2),
438                "each fold must hold 2 of class 7 and 2 of class 42"
439            );
440        }
441        Ok(())
442    }
443
444    /// The inherent [`StratifiedCrossValidation::folds`] delegates to the free
445    /// function using its configured fields, so it produces the same split as
446    /// calling the free function with a matching seed.
447    #[test]
448    fn inherent_folds_matches_free_function() -> Result<()> {
449        let labels = [0usize, 1, 0, 1, 0, 1];
450        let cv = StratifiedCrossValidation {
451            number_of_folds: 3,
452            random_seed: 123,
453            ..Default::default()
454        };
455        let via_method = cv.folds(&labels)?;
456        let via_free = stratified_kfold_indices(&labels, 3, &mut SplitMix64::new(123))?;
457        assert_eq!(
458            via_method, via_free,
459            "folds() must match the free function with the same seed"
460        );
461        Ok(())
462    }
463}