1use ndarray::{Array1, ArrayView1, ArrayView2, Axis};
2use rand::seq::SliceRandom;
3use rand::SeedableRng;
4use rand_chacha::ChaCha8Rng;
5
6use crate::data::gather;
7use crate::error::{DmlError, Result};
8use crate::learner::{Classifier, Regressor};
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct Fold {
13 pub train: Vec<usize>,
14 pub test: Vec<usize>,
15}
16
17#[derive(Debug, Clone)]
19pub struct KFold {
20 n_folds: usize,
21 seed: u64,
22}
23
24impl KFold {
25 pub fn new(n_folds: usize, seed: u64) -> Result<Self> {
26 if n_folds < 2 {
27 return Err(DmlError::InvalidArgument(
28 "n_folds must be at least 2".into(),
29 ));
30 }
31 Ok(Self { n_folds, seed })
32 }
33
34 pub fn n_folds(&self) -> usize {
35 self.n_folds
36 }
37
38 pub fn split(&self, n: usize) -> Result<Vec<Fold>> {
40 if n < self.n_folds {
41 return Err(DmlError::InvalidArgument(format!(
42 "{n} observations is fewer than {} folds",
43 self.n_folds
44 )));
45 }
46 let mut idx: Vec<usize> = (0..n).collect();
47 idx.shuffle(&mut ChaCha8Rng::seed_from_u64(self.seed));
48
49 let base = n / self.n_folds;
50 let extra = n % self.n_folds;
51 let mut folds = Vec::with_capacity(self.n_folds);
52 let mut start = 0;
53 for k in 0..self.n_folds {
54 let size = base + usize::from(k < extra);
55 let mut test = idx[start..start + size].to_vec();
56 let mut train: Vec<usize> = idx[..start]
57 .iter()
58 .chain(&idx[start + size..])
59 .copied()
60 .collect();
61 test.sort_unstable();
62 train.sort_unstable();
63 folds.push(Fold { train, test });
64 start += size;
65 }
66 Ok(folds)
67 }
68
69 pub fn split_stratified(&self, strata: &[usize]) -> Result<Vec<Fold>> {
74 let n = strata.len();
75 if n < self.n_folds {
76 return Err(DmlError::InvalidArgument(format!(
77 "{n} observations is fewer than {} folds",
78 self.n_folds
79 )));
80 }
81 let mut groups: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
82 for (i, &s) in strata.iter().enumerate() {
83 groups.entry(s).or_default().push(i);
84 }
85 let mut rng = ChaCha8Rng::seed_from_u64(self.seed);
86 let mut tests: Vec<Vec<usize>> = vec![Vec::new(); self.n_folds];
87 let mut next = 0;
88 for (stratum, mut idx) in groups {
89 if idx.len() < self.n_folds {
90 return Err(DmlError::InvalidArgument(format!(
91 "stratum {stratum} has {} observations, fewer than {} folds",
92 idx.len(),
93 self.n_folds
94 )));
95 }
96 idx.shuffle(&mut rng);
97 for i in idx {
98 tests[next].push(i);
99 next = (next + 1) % self.n_folds;
100 }
101 }
102 Ok(tests
103 .into_iter()
104 .map(|mut test| {
105 test.sort_unstable();
106 let mut in_test = vec![false; n];
107 for &i in &test {
108 in_test[i] = true;
109 }
110 let train = (0..n).filter(|&i| !in_test[i]).collect();
111 Fold { train, test }
112 })
113 .collect())
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct SampleSplitting {
121 pub n_folds: usize,
122 pub n_rep: usize,
123 pub seed: u64,
124}
125
126impl Default for SampleSplitting {
127 fn default() -> Self {
128 Self {
129 n_folds: 5,
130 n_rep: 1,
131 seed: 0,
132 }
133 }
134}
135
136impl SampleSplitting {
137 pub fn draw(&self, n: usize) -> Result<Vec<Vec<Fold>>> {
140 if self.n_rep == 0 {
141 return Err(DmlError::InvalidArgument("n_rep must be at least 1".into()));
142 }
143 (0..self.n_rep as u64)
144 .map(|r| {
145 let seed = self
147 .seed
148 .wrapping_add(r.wrapping_mul(0x9E37_79B9_7F4A_7C15));
149 KFold::new(self.n_folds, seed)?.split(n)
150 })
151 .collect()
152 }
153
154 pub fn draw_stratified(&self, strata: &[usize]) -> Result<Vec<Vec<Fold>>> {
157 if self.n_rep == 0 {
158 return Err(DmlError::InvalidArgument("n_rep must be at least 1".into()));
159 }
160 (0..self.n_rep as u64)
161 .map(|r| {
162 let seed = self
163 .seed
164 .wrapping_add(r.wrapping_mul(0x9E37_79B9_7F4A_7C15));
165 KFold::new(self.n_folds, seed)?.split_stratified(strata)
166 })
167 .collect()
168 }
169
170 pub fn draw_clustered(&self, clusters: &[Array1<i64>]) -> Result<Vec<Vec<crate::ClusterFold>>> {
172 if self.n_rep == 0 {
173 return Err(DmlError::InvalidArgument("n_rep must be at least 1".into()));
174 }
175 (0..self.n_rep as u64)
176 .map(|r| {
177 let seed = self
178 .seed
179 .wrapping_add(r.wrapping_mul(0x9E37_79B9_7F4A_7C15));
180 crate::cluster::split_clusters(clusters, self.n_folds, seed)
181 })
182 .collect()
183 }
184}
185
186pub fn cross_fit_predict(
190 learner: &dyn Regressor,
191 x: ArrayView2<'_, f64>,
192 target: ArrayView1<'_, f64>,
193 folds: &[Fold],
194) -> Result<Array1<f64>> {
195 if target.len() != x.nrows() {
196 return Err(DmlError::DimensionMismatch(
197 "cross_fit_predict x/target rows".into(),
198 ));
199 }
200 cross_fit_with(x.nrows(), folds, |fold| {
201 let mut model = learner.fresh();
202 model.fit(
203 x.select(Axis(0), &fold.train).view(),
204 gather(target, &fold.train).view(),
205 )?;
206 model.predict(x.select(Axis(0), &fold.test).view())
207 })
208}
209
210pub fn cross_fit_predict_subset(
214 learner: &dyn Regressor,
215 x: ArrayView2<'_, f64>,
216 target: ArrayView1<'_, f64>,
217 folds: &[Fold],
218 keep: &[bool],
219) -> Result<Array1<f64>> {
220 cross_fit_predict(learner, x, target, &subset_folds(folds, keep, x.nrows())?)
221}
222
223pub fn cross_fit_predict_proba_subset(
226 learner: &dyn Classifier,
227 x: ArrayView2<'_, f64>,
228 labels: ArrayView1<'_, f64>,
229 folds: &[Fold],
230 keep: &[bool],
231) -> Result<Array1<f64>> {
232 cross_fit_predict_proba(learner, x, labels, &subset_folds(folds, keep, x.nrows())?)
233}
234
235fn subset_folds(folds: &[Fold], keep: &[bool], n: usize) -> Result<Vec<Fold>> {
237 if keep.len() != n {
238 return Err(DmlError::DimensionMismatch("subgroup mask length".into()));
239 }
240 let subset: Vec<Fold> = folds
241 .iter()
242 .map(|f| Fold {
243 train: f.train.iter().copied().filter(|&i| keep[i]).collect(),
244 test: f.test.clone(),
245 })
246 .collect();
247 if subset.iter().any(|f| f.train.is_empty()) {
248 return Err(DmlError::InvalidArgument(
249 "a training fold has no observations in the requested subgroup".into(),
250 ));
251 }
252 Ok(subset)
253}
254
255pub fn cross_fit_predict_proba(
257 learner: &dyn Classifier,
258 x: ArrayView2<'_, f64>,
259 labels: ArrayView1<'_, f64>,
260 folds: &[Fold],
261) -> Result<Array1<f64>> {
262 if labels.len() != x.nrows() {
263 return Err(DmlError::DimensionMismatch(
264 "cross_fit_predict_proba x/labels rows".into(),
265 ));
266 }
267 cross_fit_with(x.nrows(), folds, |fold| {
268 let mut model = learner.fresh();
269 model.fit(
270 x.select(Axis(0), &fold.train).view(),
271 gather(labels, &fold.train).view(),
272 )?;
273 model.predict_proba(x.select(Axis(0), &fold.test).view())
274 })
275}
276
277static PARALLEL_FOLDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
284
285pub fn set_parallel_folds(on: bool) {
290 PARALLEL_FOLDS.store(on, std::sync::atomic::Ordering::SeqCst);
291}
292
293pub(crate) fn map_folds<T, F>(count: usize, f: F) -> Result<Vec<T>>
296where
297 T: Send,
298 F: Fn(usize) -> Result<T> + Sync,
299{
300 #[cfg(feature = "parallel")]
301 if PARALLEL_FOLDS.load(std::sync::atomic::Ordering::SeqCst) {
302 use rayon::prelude::*;
303 return (0..count).into_par_iter().map(&f).collect();
304 }
305 (0..count).map(f).collect()
306}
307
308pub(crate) fn cross_fit_with<F>(n: usize, folds: &[Fold], fit_predict: F) -> Result<Array1<f64>>
309where
310 F: Fn(&Fold) -> Result<Array1<f64>> + Sync,
311{
312 let preds: Vec<Array1<f64>> = map_folds(folds.len(), |k| fit_predict(&folds[k]))?;
313
314 let mut out = Array1::<f64>::from_elem(n, f64::NAN);
315 for (fold, pred) in folds.iter().zip(preds) {
316 if pred.len() != fold.test.len() {
317 return Err(DmlError::DimensionMismatch(
318 "learner returned wrong number of predictions".into(),
319 ));
320 }
321 for (&i, &p) in fold.test.iter().zip(pred.iter()) {
322 out[i] = p;
323 }
324 }
325 if out.iter().any(|v| v.is_nan()) {
326 return Err(DmlError::InvalidArgument(
327 "folds do not cover every observation, or a learner returned NaN".into(),
328 ));
329 }
330 Ok(out)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::learner::Ols;
337 use ndarray::Array2;
338
339 #[test]
340 fn folds_partition_all_indices() {
341 let folds = KFold::new(3, 7).unwrap().split(10).unwrap();
342 let mut seen: Vec<usize> = folds.iter().flat_map(|f| f.test.clone()).collect();
343 seen.sort_unstable();
344 assert_eq!(seen, (0..10).collect::<Vec<_>>());
345 for f in &folds {
346 assert!(f.test.iter().all(|i| !f.train.contains(i)));
347 assert_eq!(f.train.len() + f.test.len(), 10);
348 }
349 let sizes: Vec<usize> = folds.iter().map(|f| f.test.len()).collect();
350 assert_eq!(sizes, vec![4, 3, 3]);
351 }
352
353 #[test]
354 fn same_seed_same_split() {
355 let a = KFold::new(5, 42).unwrap().split(50).unwrap();
356 let b = KFold::new(5, 42).unwrap().split(50).unwrap();
357 let c = KFold::new(5, 43).unwrap().split(50).unwrap();
358 assert_eq!(a, b);
359 assert_ne!(a, c);
360 }
361
362 #[test]
363 fn repetitions_differ_and_first_matches_kfold() {
364 let reps = SampleSplitting {
365 n_folds: 4,
366 n_rep: 3,
367 seed: 9,
368 }
369 .draw(40)
370 .unwrap();
371 assert_eq!(reps.len(), 3);
372 assert_eq!(reps[0], KFold::new(4, 9).unwrap().split(40).unwrap());
373 assert_ne!(reps[0], reps[1]);
374 assert_ne!(reps[1], reps[2]);
375 }
376
377 #[test]
378 fn subset_training_uses_only_kept_rows() {
379 let x = Array2::<f64>::zeros((20, 0));
382 let keep: Vec<bool> = (0..20).map(|i| i % 2 == 0).collect();
383 let y: Array1<f64> = keep.iter().map(|&k| if k { 0.0 } else { 100.0 }).collect();
384 let folds = KFold::new(4, 1).unwrap().split(20).unwrap();
385 let pred =
386 cross_fit_predict_subset(&Ols::new(), x.view(), y.view(), &folds, &keep).unwrap();
387 assert!(pred.iter().all(|p| p.abs() < 1e-12));
388 }
389
390 #[test]
391 fn stratified_folds_balance_each_stratum() {
392 let strata: Vec<usize> = (0..53).map(|i| usize::from(i % 5 == 0)).collect(); let folds = KFold::new(5, 3).unwrap().split_stratified(&strata).unwrap();
394 let mut seen: Vec<usize> = folds.iter().flat_map(|f| f.test.clone()).collect();
395 seen.sort_unstable();
396 assert_eq!(seen, (0..53).collect::<Vec<_>>());
397 for f in &folds {
398 let treated = f.test.iter().filter(|&&i| strata[i] == 1).count();
399 assert!((2..=3).contains(&treated), "treated per fold: {treated}");
400 assert_eq!(f.train.len() + f.test.len(), 53);
401 }
402 assert!(KFold::new(5, 0)
403 .unwrap()
404 .split_stratified(&[0, 0, 0, 0, 0, 1])
405 .is_err());
406 }
407
408 #[test]
409 fn rejects_bad_settings() {
410 assert!(KFold::new(1, 0).is_err());
411 assert!(KFold::new(5, 0).unwrap().split(3).is_err());
412 }
413
414 #[test]
415 fn cross_fit_recovers_linear_signal() {
416 let n = 100;
417 let x = Array2::from_shape_fn((n, 2), |(i, j)| ((i * 7 + j * 3) % 11) as f64);
418 let y: Array1<f64> = x
419 .rows()
420 .into_iter()
421 .map(|r| 1.0 + 0.5 * r[0] - 2.0 * r[1])
422 .collect();
423 let folds = KFold::new(5, 1).unwrap().split(n).unwrap();
424 let pred = cross_fit_predict(&Ols::new(), x.view(), y.view(), &folds).unwrap();
425 assert!((&pred - &y).iter().all(|e| e.abs() < 1e-8));
426 }
427}