Skip to main content

model_selection_rs/splitters/
stratified_group_kfold.rs

1//! Stratified **and** group-aware K-fold.
2
3use std::collections::HashMap;
4use std::hash::Hash;
5
6use ndarray::Array1;
7
8use super::group_kfold::collect_groups;
9use super::stratified_kfold::test_folds_to_splits;
10use super::CvSplitter;
11use crate::error::{ModelSelectionError, Result};
12
13/// K-fold that tries to satisfy **two** constraints at once: keep class
14/// proportions balanced across folds *and* never let a group straddle the
15/// train/test boundary.
16///
17/// # Approximation
18///
19/// Perfectly satisfying both constraints simultaneously is not always possible —
20/// a group is indivisible, so its whole class makeup lands in one fold. This
21/// implementation therefore uses the same documented greedy heuristic as
22/// scikit-learn's `StratifiedGroupKFold`, and does not claim exactness:
23///
24/// 1. Count each class within each group.
25/// 2. Visit groups in order of decreasing spread (standard deviation) of their
26///    per-class counts, so the "lumpiest" groups are placed first while there is
27///    still freedom to balance around them.
28/// 3. Place each group in whichever fold keeps the per-fold class distribution
29///    closest to uniform (minimising the mean, over classes, of the standard
30///    deviation of each class's per-fold share); ties break toward the smaller
31///    fold.
32///
33/// Group integrity is exact (groups are never split); only class balance is
34/// approximate. `n_splits` must not exceed the number of distinct groups.
35///
36/// ```
37/// use ndarray::array;
38/// use model_selection_rs::splitters::{CvSplitter, StratifiedGroupKFold};
39///
40/// let y      = array![0, 0, 1, 1, 0, 1, 0, 1];
41/// let groups = array![1, 1, 2, 2, 3, 3, 4, 4];
42/// let sgkf = StratifiedGroupKFold::new(2, &y, &groups).unwrap();
43/// let splits = sgkf.split(y.len()).unwrap();
44/// assert_eq!(splits.len(), 2);
45/// ```
46#[derive(Debug, Clone)]
47pub struct StratifiedGroupKFold<L, G> {
48    n_splits: usize,
49    labels: Vec<L>,
50    groups: Vec<G>,
51}
52
53impl<L: Eq + Hash + Clone, G: Eq + Hash + Clone> StratifiedGroupKFold<L, G> {
54    /// Create a `StratifiedGroupKFold` over class labels `y` and `groups`.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`ModelSelectionError::InvalidSplitCount`] if `n_splits < 2`, or
59    /// [`ModelSelectionError::ShapeMismatch`] if `y` and `groups` differ in
60    /// length.
61    pub fn new(n_splits: usize, y: &Array1<L>, groups: &Array1<G>) -> Result<Self> {
62        if n_splits < 2 {
63            return Err(ModelSelectionError::InvalidSplitCount {
64                msg: format!("n_splits must be >= 2, got {n_splits}"),
65            });
66        }
67        if y.len() != groups.len() {
68            return Err(ModelSelectionError::ShapeMismatch {
69                expected: y.len(),
70                got: groups.len(),
71            });
72        }
73        Ok(Self {
74            n_splits,
75            labels: y.to_vec(),
76            groups: groups.to_vec(),
77        })
78    }
79}
80
81/// Population standard deviation of a slice (0.0 for length < 2).
82fn std_dev(values: &[f64]) -> f64 {
83    let n = values.len();
84    if n < 2 {
85        return 0.0;
86    }
87    let mean = values.iter().sum::<f64>() / n as f64;
88    let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
89    var.sqrt()
90}
91
92impl<L: Eq + Hash + Clone, G: Eq + Hash + Clone> CvSplitter for StratifiedGroupKFold<L, G> {
93    fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
94        if n_samples != self.labels.len() {
95            return Err(ModelSelectionError::ShapeMismatch {
96                expected: self.labels.len(),
97                got: n_samples,
98            });
99        }
100
101        // Index the classes 0..n_classes by first appearance.
102        let mut class_index: HashMap<L, usize> = HashMap::new();
103        for label in &self.labels {
104            let next = class_index.len();
105            class_index.entry(label.clone()).or_insert(next);
106        }
107        let n_classes = class_index.len();
108
109        let group_members = collect_groups(&self.groups);
110        if group_members.len() < self.n_splits {
111            return Err(ModelSelectionError::InvalidSplitCount {
112                msg: format!(
113                    "n_splits={} exceeds the number of distinct groups ({})",
114                    self.n_splits,
115                    group_members.len()
116                ),
117            });
118        }
119
120        // Per-group class counts, and overall per-class totals.
121        let mut group_class_counts: Vec<Vec<f64>> = Vec::with_capacity(group_members.len());
122        let mut class_totals = vec![0.0f64; n_classes];
123        for members in &group_members {
124            let mut counts = vec![0.0f64; n_classes];
125            for &i in members {
126                let c = class_index[&self.labels[i]];
127                counts[c] += 1.0;
128                class_totals[c] += 1.0;
129            }
130            group_class_counts.push(counts);
131        }
132        // Guard against a zero divisor for classes with no samples.
133        for total in &mut class_totals {
134            if *total == 0.0 {
135                *total = 1.0;
136            }
137        }
138
139        // Order groups by decreasing spread of their class counts.
140        let mut order: Vec<usize> = (0..group_members.len()).collect();
141        order.sort_by(|&a, &b| {
142            std_dev(&group_class_counts[b])
143                .partial_cmp(&std_dev(&group_class_counts[a]))
144                .unwrap_or(std::cmp::Ordering::Equal)
145                .then(a.cmp(&b))
146        });
147
148        // Greedy assignment.
149        let mut fold_class_counts = vec![vec![0.0f64; n_classes]; self.n_splits];
150        let mut fold_sizes = vec![0usize; self.n_splits];
151        let mut assignment = vec![0usize; group_members.len()];
152
153        for &g in &order {
154            let counts = &group_class_counts[g];
155            let mut best_fold = 0usize;
156            let mut best_std = f64::INFINITY;
157            let mut best_size = usize::MAX;
158
159            for fold in 0..self.n_splits {
160                // Tentatively add this group's counts to `fold`.
161                for c in 0..n_classes {
162                    fold_class_counts[fold][c] += counts[c];
163                }
164                // Mean over classes of the std (over folds) of each class share.
165                let mut std_sum = 0.0;
166                for c in 0..n_classes {
167                    let shares: Vec<f64> = (0..self.n_splits)
168                        .map(|f| fold_class_counts[f][c] / class_totals[c])
169                        .collect();
170                    std_sum += std_dev(&shares);
171                }
172                let mean_std = std_sum / n_classes as f64;
173                // Undo.
174                for c in 0..n_classes {
175                    fold_class_counts[fold][c] -= counts[c];
176                }
177
178                let size = fold_sizes[fold];
179                if mean_std < best_std - 1e-12
180                    || ((mean_std - best_std).abs() <= 1e-12 && size < best_size)
181                {
182                    best_std = mean_std;
183                    best_fold = fold;
184                    best_size = size;
185                }
186            }
187
188            for c in 0..n_classes {
189                fold_class_counts[best_fold][c] += counts[c];
190            }
191            fold_sizes[best_fold] += group_members[g].len();
192            assignment[g] = best_fold;
193        }
194
195        // Materialise per-fold test indices.
196        let mut test_folds: Vec<Vec<usize>> = vec![Vec::new(); self.n_splits];
197        for (g, members) in group_members.into_iter().enumerate() {
198            test_folds[assignment[g]].extend(members);
199        }
200        for fold in &mut test_folds {
201            fold.sort_unstable();
202        }
203
204        Ok(test_folds_to_splits(test_folds, n_samples))
205    }
206
207    fn n_splits(&self) -> usize {
208        self.n_splits
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use ndarray::array;
216    use std::collections::HashSet;
217
218    fn assert_no_leakage(groups: &[i32], splits: &[(Vec<usize>, Vec<usize>)]) {
219        for (train, test) in splits {
220            let tr: HashSet<i32> = train.iter().map(|&i| groups[i]).collect();
221            let te: HashSet<i32> = test.iter().map(|&i| groups[i]).collect();
222            assert!(tr.is_disjoint(&te), "group leaked across boundary");
223        }
224    }
225
226    #[test]
227    fn no_group_leaks_and_all_tested_once() {
228        let y = array![0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1];
229        let groups = array![1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6];
230        let sgkf = StratifiedGroupKFold::new(3, &y, &groups).unwrap();
231        let splits = sgkf.split(y.len()).unwrap();
232        assert_no_leakage(groups.as_slice().unwrap(), &splits);
233
234        let mut seen: Vec<usize> = splits.iter().flat_map(|(_, te)| te.clone()).collect();
235        seen.sort_unstable();
236        assert_eq!(seen, (0..12).collect::<Vec<_>>());
237    }
238
239    #[test]
240    fn keeps_class_balance_reasonably() {
241        // 12 groups, each purely one class, 6 of each class.
242        let mut y = Vec::new();
243        let mut groups = Vec::new();
244        for g in 0..12 {
245            let class = g % 2; // alternate pure-class groups
246            for _ in 0..3 {
247                y.push(class);
248                groups.push(g);
249            }
250        }
251        let y = Array1::from(y);
252        let groups = Array1::from(groups);
253        let sgkf = StratifiedGroupKFold::new(3, &y, &groups).unwrap();
254        let splits = sgkf.split(y.len()).unwrap();
255        for (_, test) in &splits {
256            let ones = test.iter().filter(|&&i| y[i] == 1).count();
257            let frac = ones as f64 / test.len() as f64;
258            assert!(
259                (frac - 0.5).abs() < 0.2,
260                "fold class-1 share {frac} off balance"
261            );
262        }
263    }
264
265    #[test]
266    fn shape_mismatch_on_unequal_lengths() {
267        let y = array![0, 1, 0];
268        let groups = array![1, 2, 3, 4];
269        assert!(matches!(
270            StratifiedGroupKFold::new(2, &y, &groups),
271            Err(ModelSelectionError::ShapeMismatch { .. })
272        ));
273    }
274}