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
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct SampleSplitting {
74 pub n_folds: usize,
75 pub n_rep: usize,
76 pub seed: u64,
77}
78
79impl Default for SampleSplitting {
80 fn default() -> Self {
81 Self {
82 n_folds: 5,
83 n_rep: 1,
84 seed: 0,
85 }
86 }
87}
88
89impl SampleSplitting {
90 pub fn draw(&self, n: usize) -> Result<Vec<Vec<Fold>>> {
93 if self.n_rep == 0 {
94 return Err(DmlError::InvalidArgument("n_rep must be at least 1".into()));
95 }
96 (0..self.n_rep as u64)
97 .map(|r| {
98 let seed = self
100 .seed
101 .wrapping_add(r.wrapping_mul(0x9E37_79B9_7F4A_7C15));
102 KFold::new(self.n_folds, seed)?.split(n)
103 })
104 .collect()
105 }
106}
107
108pub fn cross_fit_predict(
112 learner: &dyn Regressor,
113 x: ArrayView2<'_, f64>,
114 target: ArrayView1<'_, f64>,
115 folds: &[Fold],
116) -> Result<Array1<f64>> {
117 if target.len() != x.nrows() {
118 return Err(DmlError::DimensionMismatch(
119 "cross_fit_predict x/target rows".into(),
120 ));
121 }
122 cross_fit_with(x.nrows(), folds, |fold| {
123 let mut model = learner.fresh();
124 model.fit(
125 x.select(Axis(0), &fold.train).view(),
126 gather(target, &fold.train).view(),
127 )?;
128 model.predict(x.select(Axis(0), &fold.test).view())
129 })
130}
131
132pub fn cross_fit_predict_subset(
136 learner: &dyn Regressor,
137 x: ArrayView2<'_, f64>,
138 target: ArrayView1<'_, f64>,
139 folds: &[Fold],
140 keep: &[bool],
141) -> Result<Array1<f64>> {
142 cross_fit_predict(learner, x, target, &subset_folds(folds, keep, x.nrows())?)
143}
144
145pub fn cross_fit_predict_proba_subset(
148 learner: &dyn Classifier,
149 x: ArrayView2<'_, f64>,
150 labels: ArrayView1<'_, f64>,
151 folds: &[Fold],
152 keep: &[bool],
153) -> Result<Array1<f64>> {
154 cross_fit_predict_proba(learner, x, labels, &subset_folds(folds, keep, x.nrows())?)
155}
156
157fn subset_folds(folds: &[Fold], keep: &[bool], n: usize) -> Result<Vec<Fold>> {
159 if keep.len() != n {
160 return Err(DmlError::DimensionMismatch("subgroup mask length".into()));
161 }
162 let subset: Vec<Fold> = folds
163 .iter()
164 .map(|f| Fold {
165 train: f.train.iter().copied().filter(|&i| keep[i]).collect(),
166 test: f.test.clone(),
167 })
168 .collect();
169 if subset.iter().any(|f| f.train.is_empty()) {
170 return Err(DmlError::InvalidArgument(
171 "a training fold has no observations in the requested subgroup".into(),
172 ));
173 }
174 Ok(subset)
175}
176
177pub fn cross_fit_predict_proba(
179 learner: &dyn Classifier,
180 x: ArrayView2<'_, f64>,
181 labels: ArrayView1<'_, f64>,
182 folds: &[Fold],
183) -> Result<Array1<f64>> {
184 if labels.len() != x.nrows() {
185 return Err(DmlError::DimensionMismatch(
186 "cross_fit_predict_proba x/labels rows".into(),
187 ));
188 }
189 cross_fit_with(x.nrows(), folds, |fold| {
190 let mut model = learner.fresh();
191 model.fit(
192 x.select(Axis(0), &fold.train).view(),
193 gather(labels, &fold.train).view(),
194 )?;
195 model.predict_proba(x.select(Axis(0), &fold.test).view())
196 })
197}
198
199pub(crate) fn cross_fit_with<F>(n: usize, folds: &[Fold], fit_predict: F) -> Result<Array1<f64>>
206where
207 F: Fn(&Fold) -> Result<Array1<f64>> + Sync,
208{
209 #[cfg(feature = "parallel")]
210 let preds: Vec<Array1<f64>> = {
211 use rayon::prelude::*;
212 folds.par_iter().map(&fit_predict).collect::<Result<_>>()?
213 };
214 #[cfg(not(feature = "parallel"))]
215 let preds: Vec<Array1<f64>> = folds.iter().map(&fit_predict).collect::<Result<_>>()?;
216
217 let mut out = Array1::<f64>::from_elem(n, f64::NAN);
218 for (fold, pred) in folds.iter().zip(preds) {
219 if pred.len() != fold.test.len() {
220 return Err(DmlError::DimensionMismatch(
221 "learner returned wrong number of predictions".into(),
222 ));
223 }
224 for (&i, &p) in fold.test.iter().zip(pred.iter()) {
225 out[i] = p;
226 }
227 }
228 if out.iter().any(|v| v.is_nan()) {
229 return Err(DmlError::InvalidArgument(
230 "folds do not cover every observation, or a learner returned NaN".into(),
231 ));
232 }
233 Ok(out)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use crate::learner::Ols;
240 use ndarray::Array2;
241
242 #[test]
243 fn folds_partition_all_indices() {
244 let folds = KFold::new(3, 7).unwrap().split(10).unwrap();
245 let mut seen: Vec<usize> = folds.iter().flat_map(|f| f.test.clone()).collect();
246 seen.sort_unstable();
247 assert_eq!(seen, (0..10).collect::<Vec<_>>());
248 for f in &folds {
249 assert!(f.test.iter().all(|i| !f.train.contains(i)));
250 assert_eq!(f.train.len() + f.test.len(), 10);
251 }
252 let sizes: Vec<usize> = folds.iter().map(|f| f.test.len()).collect();
253 assert_eq!(sizes, vec![4, 3, 3]);
254 }
255
256 #[test]
257 fn same_seed_same_split() {
258 let a = KFold::new(5, 42).unwrap().split(50).unwrap();
259 let b = KFold::new(5, 42).unwrap().split(50).unwrap();
260 let c = KFold::new(5, 43).unwrap().split(50).unwrap();
261 assert_eq!(a, b);
262 assert_ne!(a, c);
263 }
264
265 #[test]
266 fn repetitions_differ_and_first_matches_kfold() {
267 let reps = SampleSplitting {
268 n_folds: 4,
269 n_rep: 3,
270 seed: 9,
271 }
272 .draw(40)
273 .unwrap();
274 assert_eq!(reps.len(), 3);
275 assert_eq!(reps[0], KFold::new(4, 9).unwrap().split(40).unwrap());
276 assert_ne!(reps[0], reps[1]);
277 assert_ne!(reps[1], reps[2]);
278 }
279
280 #[test]
281 fn subset_training_uses_only_kept_rows() {
282 let x = Array2::<f64>::zeros((20, 0));
285 let keep: Vec<bool> = (0..20).map(|i| i % 2 == 0).collect();
286 let y: Array1<f64> = keep.iter().map(|&k| if k { 0.0 } else { 100.0 }).collect();
287 let folds = KFold::new(4, 1).unwrap().split(20).unwrap();
288 let pred =
289 cross_fit_predict_subset(&Ols::new(), x.view(), y.view(), &folds, &keep).unwrap();
290 assert!(pred.iter().all(|p| p.abs() < 1e-12));
291 }
292
293 #[test]
294 fn rejects_bad_settings() {
295 assert!(KFold::new(1, 0).is_err());
296 assert!(KFold::new(5, 0).unwrap().split(3).is_err());
297 }
298
299 #[test]
300 fn cross_fit_recovers_linear_signal() {
301 let n = 100;
302 let x = Array2::from_shape_fn((n, 2), |(i, j)| ((i * 7 + j * 3) % 11) as f64);
303 let y: Array1<f64> = x
304 .rows()
305 .into_iter()
306 .map(|r| 1.0 + 0.5 * r[0] - 2.0 * r[1])
307 .collect();
308 let folds = KFold::new(5, 1).unwrap().split(n).unwrap();
309 let pred = cross_fit_predict(&Ols::new(), x.view(), y.view(), &folds).unwrap();
310 assert!((&pred - &y).iter().all(|e| e.abs() < 1e-8));
311 }
312}