use std::collections::HashMap;
use super::index::uniform_index;
use crate::error::{Error, Result};
use crate::resampling::StratifiedCrossValidation;
use crate::rng::SplitMix64;
pub fn stratified_kfold_indices(
labels: &[usize],
k: usize,
rng: &mut SplitMix64,
) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
if k < 2 {
return Err(Error::InvalidInput("k must be >= 2".to_owned()));
}
if labels.is_empty() {
return Err(Error::InsufficientData);
}
let mut group_of: HashMap<usize, usize> = HashMap::new();
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, &class) in labels.iter().enumerate() {
let slot = *group_of.entry(class).or_insert_with(|| {
groups.push(Vec::new());
groups.len() - 1
});
if let Some(members) = groups.get_mut(slot) {
members.push(i);
}
}
let min_count = groups
.iter()
.map(Vec::len)
.min()
.ok_or(Error::InsufficientData)?;
if k > min_count {
return Err(Error::InsufficientData);
}
let mut assignments: Vec<(usize, usize)> = Vec::with_capacity(labels.len());
for mut members in groups {
for i in (1..members.len()).rev() {
let j = uniform_index(rng, i + 1);
members.swap(i, j);
}
for (position, observation) in members.into_iter().enumerate() {
assignments.push((position % k, observation));
}
}
Ok((0..k)
.map(|fold| {
let mut train = Vec::new();
let mut test = Vec::new();
for &(assigned, observation) in &assignments {
if assigned == fold {
test.push(observation);
} else {
train.push(observation);
}
}
(train, test)
})
.collect())
}
impl StratifiedCrossValidation {
pub fn folds(&self, labels: &[usize]) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
let k = usize::try_from(self.number_of_folds)
.map_err(|_| Error::InvalidInput("number_of_folds must be non-negative".to_owned()))?;
let mut rng = SplitMix64::new(self.random_seed.cast_unsigned());
stratified_kfold_indices(labels, k, &mut rng)
}
}
#[cfg(kani)]
mod verification {
use super::{Error, SplitMix64, stratified_kfold_indices};
#[kani::proof]
fn resampling_stratified_rejects_small_k() {
rejects_k::<0>();
rejects_k::<1>();
}
fn rejects_k<const K: usize>() {
let labels = [0usize, 1usize];
let state: u64 = kani::any();
let mut rng = SplitMix64::new(state);
let result = stratified_kfold_indices(&labels, K, &mut rng);
assert!(
matches!(result, Err(Error::InvalidInput(_))),
"k < 2 must be rejected with InvalidInput"
);
}
#[kani::proof]
fn resampling_stratified_empty_labels_insufficient() {
let labels: [usize; 0] = [];
let state: u64 = kani::any();
let mut rng = SplitMix64::new(state);
let result = stratified_kfold_indices(&labels, 2, &mut rng);
assert!(
matches!(result, Err(Error::InsufficientData)),
"empty labels must be rejected with InsufficientData"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn k_below_two_is_invalid() {
let labels = [0usize, 1, 0, 1];
let mut rng = SplitMix64::new(1);
assert!(
matches!(
stratified_kfold_indices(&labels, 1, &mut rng),
Err(Error::InvalidInput(_))
),
"k = 1 should be InvalidInput"
);
}
#[test]
fn empty_labels_is_insufficient_data() {
let labels: [usize; 0] = [];
let mut rng = SplitMix64::new(1);
assert_eq!(
stratified_kfold_indices(&labels, 2, &mut rng),
Err(Error::InsufficientData),
"empty labels should be InsufficientData"
);
}
#[test]
fn k_above_smallest_class_is_insufficient_data() {
let labels = [0usize, 0, 0, 1, 1];
let mut rng = SplitMix64::new(1);
assert_eq!(
stratified_kfold_indices(&labels, 3, &mut rng),
Err(Error::InsufficientData),
"k = 3 exceeds smallest class count 2"
);
}
#[test]
fn test_folds_partition_and_train_is_complement() -> Result<()> {
let labels = [0usize, 0, 0, 1, 1, 1, 0, 1, 0, 1];
let n = labels.len();
let mut rng = SplitMix64::new(99);
let folds = stratified_kfold_indices(&labels, 3, &mut rng)?;
let mut seen = vec![0usize; n];
for (train, test) in &folds {
for &t in test {
if let Some(count) = seen.get_mut(t) {
*count += 1;
}
}
assert_eq!(train.len() + test.len(), n, "train+test must cover all n");
for &tr in train {
assert!(
!test.contains(&tr),
"index {tr} appears in both train and test of a fold"
);
}
}
assert!(
seen.iter().all(|&c| c == 1),
"every index must appear in exactly one test fold, got {seen:?}"
);
Ok(())
}
fn class_count(test: &[usize], labels: &[usize], class: usize) -> usize {
test.iter()
.filter(|&&i| labels.get(i) == Some(&class))
.count()
}
#[test]
fn exact_stratification_when_divisible() -> Result<()> {
let mut labels = vec![0usize; 15];
labels.extend(std::iter::repeat_n(1usize, 10));
let mut rng = SplitMix64::new(2024);
let folds = stratified_kfold_indices(&labels, 5, &mut rng)?;
assert_eq!(folds.len(), 5, "expected 5 folds");
for (_, test) in &folds {
assert_eq!(
(class_count(test, &labels, 0), class_count(test, &labels, 1)),
(3, 2),
"each fold must hold exactly 3 of class 0 and 2 of class 1"
);
}
Ok(())
}
#[test]
fn general_shape_within_one_of_ideal() -> Result<()> {
let mut labels = vec![0usize; 12];
labels.extend(std::iter::repeat_n(1usize, 8));
let k = 5;
let mut rng = SplitMix64::new(7);
let folds = stratified_kfold_indices(&labels, k, &mut rng)?;
for (class, m) in [(0usize, 12usize), (1usize, 8usize)] {
let floor = m / k;
let ceil = m.div_ceil(k);
for (_, test) in &folds {
let c = class_count(test, &labels, class);
assert!(
c == floor || c == ceil,
"class {class} fold count {c} not in {{{floor}, {ceil}}}"
);
}
}
Ok(())
}
#[test]
fn deterministic_by_seed() -> Result<()> {
let mut labels = vec![0usize; 30];
labels.extend(std::iter::repeat_n(1usize, 20));
let same_a = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(11))?;
let same_b = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(11))?;
assert_eq!(same_a, same_b, "same seed must reproduce the split");
let different = stratified_kfold_indices(&labels, 5, &mut SplitMix64::new(999))?;
assert_ne!(
same_a, different,
"different seeds should shuffle to a different assignment"
);
Ok(())
}
#[test]
fn non_contiguous_class_ids() -> Result<()> {
let labels = [7usize, 42, 7, 42, 7, 42, 7, 42];
let mut rng = SplitMix64::new(5);
let folds = stratified_kfold_indices(&labels, 2, &mut rng)?;
assert_eq!(folds.len(), 2, "expected 2 folds");
for (_, test) in &folds {
assert_eq!(
(
class_count(test, &labels, 7),
class_count(test, &labels, 42)
),
(2, 2),
"each fold must hold 2 of class 7 and 2 of class 42"
);
}
Ok(())
}
#[test]
fn inherent_folds_matches_free_function() -> Result<()> {
let labels = [0usize, 1, 0, 1, 0, 1];
let cv = StratifiedCrossValidation {
number_of_folds: 3,
random_seed: 123,
..Default::default()
};
let via_method = cv.folds(&labels)?;
let via_free = stratified_kfold_indices(&labels, 3, &mut SplitMix64::new(123))?;
assert_eq!(
via_method, via_free,
"folds() must match the free function with the same seed"
);
Ok(())
}
}