model_selection_rs/splitters/
stratified_group_kfold.rs1use 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#[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 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
81fn 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 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 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 for total in &mut class_totals {
134 if *total == 0.0 {
135 *total = 1.0;
136 }
137 }
138
139 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 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 for c in 0..n_classes {
162 fold_class_counts[fold][c] += counts[c];
163 }
164 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 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 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 let mut y = Vec::new();
243 let mut groups = Vec::new();
244 for g in 0..12 {
245 let class = g % 2; 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}