Skip to main content

scirs2_io/
minibatch.rs

1//! Efficient mini-batch sampler with shuffle and stratified splitting.
2//!
3//! Provides index-based batch sampling for machine learning pipelines:
4//! - Configurable batch size with optional last-batch dropping
5//! - Deterministic or random shuffling via seeded xorshift64 PRNG
6//! - Stratified sampling that preserves class-label distributions
7//! - Train/validation/test split with optional stratification
8//!
9//! # Example
10//! ```rust
11//! use scirs2_io::minibatch::{BatchSampler, BatchSamplerConfig};
12//!
13//! let config = BatchSamplerConfig {
14//!     batch_size: 32,
15//!     shuffle: true,
16//!     seed: Some(42),
17//!     drop_last: false,
18//!     stratified: false,
19//! };
20//! let mut sampler = BatchSampler::new(100, config);
21//! while let Some(indices) = sampler.next_batch() {
22//!     // use `indices` to index into your dataset
23//!     let _ = indices;
24//! }
25//! ```
26
27use std::collections::HashMap;
28
29// ---- Internal PRNG -------------------------------------------------------
30
31/// Minimal xorshift64 PRNG — no external crate dependency.
32struct Xorshift64 {
33    state: u64,
34}
35
36impl Xorshift64 {
37    /// Create a new PRNG.  Falls back to a fixed seed when `seed == 0`.
38    fn new(seed: u64) -> Self {
39        let state = if seed == 0 { 0x853c49e6748fea9b } else { seed };
40        Self { state }
41    }
42
43    /// Return the next pseudo-random `u64`.
44    fn next_u64(&mut self) -> u64 {
45        let mut x = self.state;
46        x ^= x << 13;
47        x ^= x >> 7;
48        x ^= x << 17;
49        self.state = x;
50        x
51    }
52
53    /// Return a value in `[0, n)`.
54    fn next_usize(&mut self, n: usize) -> usize {
55        if n == 0 {
56            return 0;
57        }
58        (self.next_u64() as usize) % n
59    }
60}
61
62/// In-place Fisher–Yates shuffle using the given PRNG.
63fn fisher_yates_shuffle(indices: &mut [usize], rng: &mut Xorshift64) {
64    let n = indices.len();
65    for i in (1..n).rev() {
66        let j = rng.next_usize(i + 1);
67        indices.swap(i, j);
68    }
69}
70
71// ---- Public API -----------------------------------------------------------
72
73/// Configuration for [`BatchSampler`].
74#[derive(Debug, Clone)]
75pub struct BatchSamplerConfig {
76    /// Number of samples per batch.
77    pub batch_size: usize,
78    /// Whether to shuffle the indices at the start of each epoch.
79    pub shuffle: bool,
80    /// Optional seed for the PRNG (reproducible shuffles).
81    pub seed: Option<u64>,
82    /// If `true`, the final incomplete batch is discarded.
83    pub drop_last: bool,
84    /// If `true`, `from_labels` builds a stratified index ordering
85    /// that interleaves class examples to preserve label balance across batches.
86    pub stratified: bool,
87}
88
89impl Default for BatchSamplerConfig {
90    fn default() -> Self {
91        Self {
92            batch_size: 32,
93            shuffle: false,
94            seed: None,
95            drop_last: false,
96            stratified: false,
97        }
98    }
99}
100
101/// Index-based mini-batch sampler.
102///
103/// Produces batches of dataset indices rather than the data itself,
104/// so it can be used with any storage backend (arrays, files, databases).
105pub struct BatchSampler {
106    /// The current permutation of sample indices.
107    indices: Vec<usize>,
108    /// Configuration controlling sampling behaviour.
109    config: BatchSamplerConfig,
110    /// Position in `indices` for the next batch.
111    current_pos: usize,
112    /// Number of completed epochs.
113    epoch: usize,
114    /// PRNG state — kept so epoch seeds are derived deterministically from the
115    /// initial seed rather than being random each epoch.
116    rng: Xorshift64,
117}
118
119impl BatchSampler {
120    /// Create a sampler for `n_samples` samples.
121    pub fn new(n_samples: usize, config: BatchSamplerConfig) -> Self {
122        let seed = config.seed.unwrap_or(0xa1b2c3d4e5f6_u64);
123        let mut rng = Xorshift64::new(seed);
124        let mut indices: Vec<usize> = (0..n_samples).collect();
125        if config.shuffle {
126            fisher_yates_shuffle(&mut indices, &mut rng);
127        }
128        Self {
129            indices,
130            config,
131            current_pos: 0,
132            epoch: 0,
133            rng,
134        }
135    }
136
137    /// Create a stratified sampler from a label array.
138    ///
139    /// The initial `indices` ordering places examples so that each batch
140    /// contains a balanced representation of all classes.  Stratification
141    /// is performed within each per-class group; class groups are then
142    /// interleaved round-robin.
143    pub fn from_labels(labels: &[usize], config: BatchSamplerConfig) -> Self {
144        let n_samples = labels.len();
145        let seed = config.seed.unwrap_or(0xa1b2c3d4e5f6_u64);
146        let mut rng = Xorshift64::new(seed);
147
148        let indices = if config.stratified {
149            // Group indices by class label.
150            let mut class_buckets: HashMap<usize, Vec<usize>> = HashMap::new();
151            for (idx, &label) in labels.iter().enumerate() {
152                class_buckets.entry(label).or_default().push(idx);
153            }
154
155            // Optionally shuffle within each bucket.
156            let mut sorted_classes: Vec<usize> = class_buckets.keys().copied().collect();
157            sorted_classes.sort_unstable();
158
159            let mut buckets: Vec<Vec<usize>> = sorted_classes
160                .into_iter()
161                .map(|cls| {
162                    let mut bucket = class_buckets.remove(&cls).unwrap_or_default();
163                    if config.shuffle {
164                        fisher_yates_shuffle(&mut bucket, &mut rng);
165                    }
166                    bucket
167                })
168                .collect();
169
170            // Interleave round-robin across buckets.
171            let mut interleaved = Vec::with_capacity(n_samples);
172            loop {
173                let mut any_pushed = false;
174                for bucket in &mut buckets {
175                    if let Some(idx) = bucket.first().copied() {
176                        *bucket = bucket[1..].to_vec();
177                        interleaved.push(idx);
178                        any_pushed = true;
179                    }
180                }
181                if !any_pushed {
182                    break;
183                }
184            }
185            interleaved
186        } else {
187            let mut idxs: Vec<usize> = (0..n_samples).collect();
188            if config.shuffle {
189                fisher_yates_shuffle(&mut idxs, &mut rng);
190            }
191            idxs
192        };
193
194        Self {
195            indices,
196            config,
197            current_pos: 0,
198            epoch: 0,
199            rng,
200        }
201    }
202
203    /// Return the next batch of indices, or `None` when the epoch is exhausted.
204    pub fn next_batch(&mut self) -> Option<Vec<usize>> {
205        let remaining = self.indices.len().saturating_sub(self.current_pos);
206        if remaining == 0 {
207            return None;
208        }
209
210        if self.config.drop_last && remaining < self.config.batch_size {
211            return None;
212        }
213
214        let end = (self.current_pos + self.config.batch_size).min(self.indices.len());
215        let batch = self.indices[self.current_pos..end].to_vec();
216        self.current_pos = end;
217        Some(batch)
218    }
219
220    /// Reset for the next epoch, re-shuffling if `config.shuffle` is `true`.
221    ///
222    /// This also increments the epoch counter.
223    pub fn reset(&mut self) {
224        self.epoch += 1;
225        self.current_pos = 0;
226        if self.config.shuffle {
227            // Derive a fresh but deterministic seed for this epoch.
228            // Mix the epoch number into the RNG state for reproducibility.
229            let epoch_mix = self.epoch as u64 * 0x9e3779b97f4a7c15;
230            let mut epoch_rng = Xorshift64::new(self.rng.state ^ epoch_mix);
231            fisher_yates_shuffle(&mut self.indices, &mut epoch_rng);
232            // Advance the main RNG state so subsequent resets differ.
233            self.rng.state = epoch_rng.state;
234        }
235    }
236
237    /// Total number of batches that will be produced per epoch.
238    pub fn n_batches(&self) -> usize {
239        let n = self.indices.len();
240        let bs = self.config.batch_size;
241        if bs == 0 {
242            return 0;
243        }
244        if self.config.drop_last {
245            n / bs
246        } else {
247            n.div_ceil(bs)
248        }
249    }
250
251    /// Return the epoch counter (incremented by each call to [`reset`](Self::reset)).
252    pub fn epoch(&self) -> usize {
253        self.epoch
254    }
255}
256
257// ---- Train/Val/Test split ------------------------------------------------
258
259/// Partition `n_samples` into train, validation, and test index sets.
260///
261/// If `labels` is provided and both `train_frac` + `val_frac` leave room for
262/// stratification, the split preserves the class distribution within each fold.
263/// Otherwise, indices are shuffled uniformly.
264///
265/// `train_frac` and `val_frac` must be in `(0, 1)` and sum to less than 1.0.
266/// The test set takes the remainder.
267///
268/// Returns `(train_indices, val_indices, test_indices)`.
269pub fn train_val_test_split(
270    n_samples: usize,
271    labels: Option<&[usize]>,
272    train_frac: f64,
273    val_frac: f64,
274    seed: Option<u64>,
275) -> (Vec<usize>, Vec<usize>, Vec<usize>) {
276    let rng_seed = seed.unwrap_or(0xdeadbeef_cafebabe);
277    let mut rng = Xorshift64::new(rng_seed);
278
279    let do_stratify = labels.is_some();
280
281    if do_stratify {
282        let labels = labels.expect("checked above");
283        // Group by class.
284        let mut class_buckets: HashMap<usize, Vec<usize>> = HashMap::new();
285        for (idx, &label) in labels.iter().enumerate() {
286            class_buckets.entry(label).or_default().push(idx);
287        }
288
289        let mut train_set = Vec::new();
290        let mut val_set = Vec::new();
291        let mut test_set = Vec::new();
292
293        let mut sorted_classes: Vec<usize> = class_buckets.keys().copied().collect();
294        sorted_classes.sort_unstable();
295
296        for cls in sorted_classes {
297            let mut bucket = class_buckets.remove(&cls).unwrap_or_default();
298            fisher_yates_shuffle(&mut bucket, &mut rng);
299            let n = bucket.len();
300            let n_train = ((n as f64 * train_frac).round() as usize).min(n);
301            let n_val = ((n as f64 * val_frac).round() as usize).min(n.saturating_sub(n_train));
302            train_set.extend_from_slice(&bucket[..n_train]);
303            val_set.extend_from_slice(&bucket[n_train..n_train + n_val]);
304            test_set.extend_from_slice(&bucket[n_train + n_val..]);
305        }
306
307        (train_set, val_set, test_set)
308    } else {
309        let mut all: Vec<usize> = (0..n_samples).collect();
310        fisher_yates_shuffle(&mut all, &mut rng);
311
312        let n_train = ((n_samples as f64 * train_frac).round() as usize).min(n_samples);
313        let n_val =
314            ((n_samples as f64 * val_frac).round() as usize).min(n_samples.saturating_sub(n_train));
315
316        let train_set = all[..n_train].to_vec();
317        let val_set = all[n_train..n_train + n_val].to_vec();
318        let test_set = all[n_train + n_val..].to_vec();
319
320        (train_set, val_set, test_set)
321    }
322}
323
324// ---- Tests ---------------------------------------------------------------
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::collections::HashSet;
330
331    #[test]
332    fn test_batch_sampler_basic() {
333        let config = BatchSamplerConfig {
334            batch_size: 10,
335            shuffle: false,
336            seed: None,
337            drop_last: false,
338            stratified: false,
339        };
340        let mut sampler = BatchSampler::new(100, config);
341        let n = sampler.n_batches();
342        assert_eq!(n, 10, "expected 10 batches for 100 samples / batch_size 10");
343
344        let mut count = 0usize;
345        let mut all_indices: Vec<usize> = Vec::new();
346        while let Some(batch) = sampler.next_batch() {
347            assert_eq!(batch.len(), 10, "each batch should have 10 elements");
348            all_indices.extend_from_slice(&batch);
349            count += 1;
350        }
351        assert_eq!(count, 10);
352        // Verify all 100 indices appear exactly once.
353        let unique: HashSet<usize> = all_indices.into_iter().collect();
354        assert_eq!(unique.len(), 100);
355    }
356
357    #[test]
358    fn test_batch_sampler_drop_last() {
359        let config = BatchSamplerConfig {
360            batch_size: 10,
361            shuffle: false,
362            seed: None,
363            drop_last: true,
364            stratified: false,
365        };
366        let mut sampler = BatchSampler::new(105, config);
367        assert_eq!(sampler.n_batches(), 10, "drop_last should yield 10 batches");
368
369        let mut count = 0usize;
370        while let Some(_batch) = sampler.next_batch() {
371            count += 1;
372        }
373        assert_eq!(
374            count, 10,
375            "should get exactly 10 batches with drop_last=true"
376        );
377    }
378
379    #[test]
380    fn test_batch_sampler_no_drop_last() {
381        // 105 samples / 10 batch_size = 10 full + 1 partial = 11 total
382        let config = BatchSamplerConfig {
383            batch_size: 10,
384            shuffle: false,
385            seed: None,
386            drop_last: false,
387            stratified: false,
388        };
389        let mut sampler = BatchSampler::new(105, config);
390        assert_eq!(sampler.n_batches(), 11);
391
392        let mut count = 0usize;
393        let mut total_items = 0usize;
394        while let Some(batch) = sampler.next_batch() {
395            total_items += batch.len();
396            count += 1;
397        }
398        assert_eq!(count, 11);
399        assert_eq!(total_items, 105);
400    }
401
402    #[test]
403    fn test_batch_sampler_shuffle() {
404        let config = BatchSamplerConfig {
405            batch_size: 10,
406            shuffle: true,
407            seed: Some(12345),
408            drop_last: false,
409            stratified: false,
410        };
411        let mut sampler = BatchSampler::new(50, config);
412
413        // Collect epoch 0 ordering.
414        let mut epoch0: Vec<usize> = Vec::new();
415        while let Some(batch) = sampler.next_batch() {
416            epoch0.extend_from_slice(&batch);
417        }
418
419        // Reset and collect epoch 1 ordering.
420        sampler.reset();
421        let mut epoch1: Vec<usize> = Vec::new();
422        while let Some(batch) = sampler.next_batch() {
423            epoch1.extend_from_slice(&batch);
424        }
425
426        // Both orderings must contain exactly all 50 indices.
427        let set0: HashSet<usize> = epoch0.iter().copied().collect();
428        let set1: HashSet<usize> = epoch1.iter().copied().collect();
429        assert_eq!(set0.len(), 50);
430        assert_eq!(set1.len(), 50);
431
432        // The orderings should differ (they could theoretically be equal by
433        // chance but that's astronomically unlikely for 50 elements).
434        assert_ne!(epoch0, epoch1, "two shuffled epochs should differ");
435    }
436
437    #[test]
438    fn test_batch_sampler_epoch_counter() {
439        let config = BatchSamplerConfig {
440            batch_size: 5,
441            shuffle: false,
442            seed: None,
443            drop_last: false,
444            stratified: false,
445        };
446        let mut sampler = BatchSampler::new(20, config);
447        assert_eq!(sampler.epoch(), 0);
448        // Drain epoch 0.
449        while sampler.next_batch().is_some() {}
450        sampler.reset();
451        assert_eq!(sampler.epoch(), 1);
452        while sampler.next_batch().is_some() {}
453        sampler.reset();
454        assert_eq!(sampler.epoch(), 2);
455    }
456
457    #[test]
458    fn test_batch_sampler_empty() {
459        let config = BatchSamplerConfig {
460            batch_size: 10,
461            shuffle: false,
462            seed: None,
463            drop_last: false,
464            stratified: false,
465        };
466        let mut sampler = BatchSampler::new(0, config);
467        assert_eq!(sampler.n_batches(), 0);
468        assert!(sampler.next_batch().is_none());
469    }
470
471    #[test]
472    fn test_stratified_sampler_balance() {
473        // 3 classes, 30 samples each = 90 total.
474        let mut labels = Vec::new();
475        for cls in 0..3usize {
476            for _ in 0..30 {
477                labels.push(cls);
478            }
479        }
480        let config = BatchSamplerConfig {
481            batch_size: 9,
482            shuffle: false,
483            seed: Some(99),
484            drop_last: false,
485            stratified: true,
486        };
487        let mut sampler = BatchSampler::from_labels(&labels, config);
488
489        // Each batch of 9 should see 3 examples from each class.
490        while let Some(batch) = sampler.next_batch() {
491            if batch.len() < 9 {
492                continue; // skip partial last batch
493            }
494            let mut counts = [0usize; 3];
495            for &idx in &batch {
496                counts[labels[idx]] += 1;
497            }
498            // With round-robin interleaving each class appears batch_size/3 times.
499            assert_eq!(counts[0], 3, "class 0 count in batch");
500            assert_eq!(counts[1], 3, "class 1 count in batch");
501            assert_eq!(counts[2], 3, "class 2 count in batch");
502        }
503    }
504
505    #[test]
506    fn test_train_val_test_split() {
507        let n = 1000;
508        let (train, val, test) = train_val_test_split(n, None, 0.7, 0.15, Some(42));
509
510        // No overlap.
511        let train_set: HashSet<usize> = train.iter().copied().collect();
512        let val_set: HashSet<usize> = val.iter().copied().collect();
513        let test_set: HashSet<usize> = test.iter().copied().collect();
514
515        assert!(
516            train_set.is_disjoint(&val_set),
517            "train and val should not overlap"
518        );
519        assert!(
520            train_set.is_disjoint(&test_set),
521            "train and test should not overlap"
522        );
523        assert!(
524            val_set.is_disjoint(&test_set),
525            "val and test should not overlap"
526        );
527
528        // All indices covered.
529        let total = train.len() + val.len() + test.len();
530        assert_eq!(total, n, "all samples must be assigned to a split");
531
532        // Rough size checks (within 5% of requested fractions).
533        let train_frac = train.len() as f64 / n as f64;
534        let val_frac = val.len() as f64 / n as f64;
535        assert!(
536            (train_frac - 0.7).abs() < 0.05,
537            "train fraction {train_frac:.3} too far from 0.7"
538        );
539        assert!(
540            (val_frac - 0.15).abs() < 0.05,
541            "val fraction {val_frac:.3} too far from 0.15"
542        );
543    }
544
545    #[test]
546    fn test_train_val_test_split_stratified() {
547        let n = 600;
548        // 3 balanced classes of 200 each.
549        let labels: Vec<usize> = (0..n).map(|i| i % 3).collect();
550        let (train, val, test) = train_val_test_split(n, Some(&labels), 0.6, 0.2, Some(7));
551
552        // No overlap.
553        let train_set: HashSet<usize> = train.iter().copied().collect();
554        let val_set: HashSet<usize> = val.iter().copied().collect();
555        let test_set: HashSet<usize> = test.iter().copied().collect();
556
557        assert!(train_set.is_disjoint(&val_set));
558        assert!(train_set.is_disjoint(&test_set));
559        assert!(val_set.is_disjoint(&test_set));
560
561        let total = train.len() + val.len() + test.len();
562        assert_eq!(total, n);
563
564        // Each split should be roughly balanced across classes.
565        for split in [&train, &val, &test] {
566            let mut counts = [0usize; 3];
567            for &idx in split {
568                counts[labels[idx]] += 1;
569            }
570            let min_count = *counts.iter().min().expect("non-empty split");
571            let max_count = *counts.iter().max().expect("non-empty split");
572            // Balanced if ratio >= 0.8 (i.e. not wildly skewed).
573            if split.len() >= 10 {
574                let balance = min_count as f64 / max_count as f64;
575                assert!(
576                    balance >= 0.8,
577                    "split imbalanced: counts {counts:?}, balance {balance:.2}"
578                );
579            }
580        }
581    }
582
583    #[test]
584    fn test_xorshift64_coverage() {
585        // Verify PRNG produces distinct values and the modulo helper is sound.
586        let mut rng = Xorshift64::new(0xfedcba9876543210);
587        let vals: HashSet<u64> = (0..1000).map(|_| rng.next_u64()).collect();
588        // With a quality PRNG, 1000 draws out of 2^64 should all be distinct.
589        assert_eq!(vals.len(), 1000, "PRNG produced duplicate values");
590
591        // next_usize must be in [0, n).
592        for n in [1usize, 2, 5, 100] {
593            for _ in 0..200 {
594                let v = rng.next_usize(n);
595                assert!(v < n, "next_usize({n}) returned {v}");
596            }
597        }
598    }
599}