model_selection_rs/splitters/
group_kfold.rs1use 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#[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 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
61pub(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 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 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
104pub(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 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}