Skip to main content

model_selection_rs/splitters/
group_kfold.rs

1//! Group-aware K-fold — no group appears in both train and test of a fold.
2
3use std::collections::HashMap;
4use std::hash::Hash;
5
6use ndarray::Array1;
7
8use super::{stratified_kfold::test_folds_to_splits, CvSplitter};
9use crate::error::{ModelSelectionError, Result};
10
11/// Group K-fold cross-validation.
12///
13/// Guarantees that the samples of any one group (e.g. a patient id, a user id)
14/// never straddle the train/test boundary within a fold. Group leakage — the
15/// same entity contributing rows to both training and evaluation — is an
16/// easy-to-miss correctness bug in real ML work; this splitter makes it
17/// structurally impossible, complementing what
18/// [`StratifiedKFold`](super::StratifiedKFold) does for class balance.
19///
20/// Groups are assigned whole to folds using a greedy largest-group-first
21/// heuristic (the same idea scikit-learn uses): repeatedly place the largest
22/// remaining group into the fold that currently holds the fewest samples. This
23/// keeps fold sizes close without ever splitting a group.
24///
25/// The number of splits must not exceed the number of distinct groups.
26///
27/// ```
28/// use ndarray::array;
29/// use model_selection_rs::splitters::{CvSplitter, GroupKFold};
30///
31/// let groups = array![1, 1, 2, 2, 3, 3, 4, 4];
32/// let gkf = GroupKFold::new(2, &groups).unwrap();
33/// let splits = gkf.split(groups.len()).unwrap();
34/// assert_eq!(splits.len(), 2);
35/// ```
36#[derive(Debug, Clone)]
37pub struct GroupKFold<G> {
38    n_splits: usize,
39    groups: Vec<G>,
40}
41
42impl<G: Eq + Hash + Clone> GroupKFold<G> {
43    /// Create a `GroupKFold` over the per-sample `groups`.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`ModelSelectionError::InvalidSplitCount`] if `n_splits < 2`.
48    pub fn new(n_splits: usize, groups: &Array1<G>) -> Result<Self> {
49        if n_splits < 2 {
50            return Err(ModelSelectionError::InvalidSplitCount {
51                msg: format!("n_splits must be >= 2, got {n_splits}"),
52            });
53        }
54        Ok(Self {
55            n_splits,
56            groups: groups.to_vec(),
57        })
58    }
59}
60
61/// Assign whole groups to `n_splits` folds, largest group first into the
62/// currently-smallest fold. Returns one test-index vector per fold.
63///
64/// Shared with [`StratifiedGroupKFold`](super::StratifiedGroupKFold)'s tests via
65/// the group-collection helper below.
66pub(crate) fn group_test_folds<G: Eq + Hash + Clone>(
67    groups: &[G],
68    n_splits: usize,
69) -> Result<Vec<Vec<usize>>> {
70    let grouped = collect_groups(groups);
71    if grouped.len() < n_splits {
72        return Err(ModelSelectionError::InvalidSplitCount {
73            msg: format!(
74                "n_splits={n_splits} exceeds the number of distinct groups ({})",
75                grouped.len()
76            ),
77        });
78    }
79
80    // Sort groups by size, descending (ties broken by first-appearance order,
81    // which `collect_groups` already established, for determinism).
82    let mut members: Vec<Vec<usize>> = grouped;
83    members.sort_by_key(|m| std::cmp::Reverse(m.len()));
84
85    let mut test_folds: Vec<Vec<usize>> = vec![Vec::new(); n_splits];
86    let mut fold_sizes = vec![0usize; n_splits];
87    for group_members in members {
88        // Fold with the fewest samples so far (lowest index breaks ties).
89        let target = fold_sizes
90            .iter()
91            .enumerate()
92            .min_by_key(|(_, &size)| size)
93            .map(|(i, _)| i)
94            .unwrap();
95        fold_sizes[target] += group_members.len();
96        test_folds[target].extend(group_members);
97    }
98    for fold in &mut test_folds {
99        fold.sort_unstable();
100    }
101    Ok(test_folds)
102}
103
104/// Group sample indices by group key, preserving first-appearance order.
105pub(crate) fn collect_groups<G: Eq + Hash + Clone>(groups: &[G]) -> Vec<Vec<usize>> {
106    let mut order: Vec<G> = Vec::new();
107    let mut map: HashMap<G, Vec<usize>> = HashMap::new();
108    for (i, g) in groups.iter().enumerate() {
109        map.entry(g.clone()).or_insert_with(|| {
110            order.push(g.clone());
111            Vec::new()
112        });
113        map.get_mut(g).unwrap().push(i);
114    }
115    order.into_iter().map(|g| map.remove(&g).unwrap()).collect()
116}
117
118impl<G: Eq + Hash + Clone> CvSplitter for GroupKFold<G> {
119    fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
120        if n_samples != self.groups.len() {
121            return Err(ModelSelectionError::ShapeMismatch {
122                expected: self.groups.len(),
123                got: n_samples,
124            });
125        }
126        let test_folds = group_test_folds(&self.groups, self.n_splits)?;
127        Ok(test_folds_to_splits(test_folds, n_samples))
128    }
129
130    fn n_splits(&self) -> usize {
131        self.n_splits
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use ndarray::array;
139    use std::collections::HashSet;
140
141    /// Every group must sit entirely on one side of every fold's split.
142    fn assert_no_leakage<G: Eq + Hash + Clone>(groups: &[G], splits: &[(Vec<usize>, Vec<usize>)]) {
143        for (train, test) in splits {
144            let train_groups: HashSet<G> = train.iter().map(|&i| groups[i].clone()).collect();
145            let test_groups: HashSet<G> = test.iter().map(|&i| groups[i].clone()).collect();
146            assert!(
147                train_groups.is_disjoint(&test_groups),
148                "a group leaked across the train/test boundary"
149            );
150        }
151    }
152
153    #[test]
154    fn no_group_leaks() {
155        let groups = array![1, 1, 1, 2, 2, 3, 3, 3, 3, 4, 4, 5];
156        let gkf = GroupKFold::new(3, &groups).unwrap();
157        let splits = gkf.split(groups.len()).unwrap();
158        assert_no_leakage(groups.as_slice().unwrap(), &splits);
159    }
160
161    #[test]
162    fn every_sample_tested_once() {
163        let groups = array![1, 1, 2, 2, 3, 3, 4, 4, 5, 5];
164        let gkf = GroupKFold::new(5, &groups).unwrap();
165        let mut seen: Vec<usize> = gkf
166            .split(10)
167            .unwrap()
168            .iter()
169            .flat_map(|(_, te)| te.clone())
170            .collect();
171        seen.sort_unstable();
172        assert_eq!(seen, (0..10).collect::<Vec<_>>());
173    }
174
175    #[test]
176    fn errors_when_more_folds_than_groups() {
177        let groups = array![1, 1, 2, 2];
178        let gkf = GroupKFold::new(3, &groups).unwrap();
179        assert!(matches!(
180            gkf.split(4),
181            Err(ModelSelectionError::InvalidSplitCount { .. })
182        ));
183    }
184
185    #[test]
186    fn string_group_ids_work() {
187        let groups = array!["a", "a", "b", "c"];
188        let gkf = GroupKFold::new(3, &groups).unwrap();
189        let splits = gkf.split(4).unwrap();
190        assert_no_leakage(groups.as_slice().unwrap(), &splits);
191    }
192}