matten_mlprep/split.rs
1//! Ordered and seeded, deterministic train/test splits (RFC-028 §4.4, RFC-077).
2
3use crate::error::MattenMlprepError;
4use crate::util::matrix_dims;
5use matten::Tensor;
6
7/// Splits the rows of a 2D tensor into `(train, test)` by an ordered,
8/// deterministic partition — **no shuffling**.
9///
10/// ```text
11/// n_train = floor(n_rows * train_ratio)
12/// train = rows[0 .. n_train]
13/// test = rows[n_train .. n_rows]
14/// ```
15///
16/// The split is fully deterministic and reproducible. If you need a randomized
17/// split, see [`train_test_split_seeded`], which reproduces the same rows
18/// in a seed-determined shuffled order rather than the first-N/last-M split
19/// this function performs.
20///
21/// # Errors
22///
23/// - [`MattenMlprepError::ExpectedMatrix`] if `x` is not rank-2.
24/// - [`MattenMlprepError::InvalidRatio`] if `train_ratio` is not finite or not in `(0.0, 1.0)`.
25/// - [`MattenMlprepError::EmptySplit`] if `floor(rows * train_ratio) == 0`.
26/// - [`MattenMlprepError::DynamicTensor`] (with the `dynamic` feature) if `x` is dynamic.
27///
28/// ```
29/// use matten::Tensor;
30/// use matten_mlprep::train_test_split;
31///
32/// // 4 rows, 1 feature; 0.75 -> 3 train rows, 1 test row.
33/// let x = Tensor::new(vec![10.0, 20.0, 30.0, 40.0], &[4, 1]);
34/// let (train, test) = train_test_split(&x, 0.75).unwrap();
35/// assert_eq!(train.shape(), &[3, 1]);
36/// assert_eq!(test.shape(), &[1, 1]);
37/// assert_eq!(train.as_slice(), &[10.0, 20.0, 30.0]);
38/// assert_eq!(test.as_slice(), &[40.0]);
39/// ```
40pub fn train_test_split(
41 x: &Tensor,
42 train_ratio: f64,
43) -> Result<(Tensor, Tensor), MattenMlprepError> {
44 let (rows, cols) = matrix_dims(x)?;
45
46 if !train_ratio.is_finite() || train_ratio <= 0.0 || train_ratio >= 1.0 {
47 return Err(MattenMlprepError::InvalidRatio(train_ratio));
48 }
49
50 let n_train = (rows as f64 * train_ratio).floor() as usize;
51 // For any ratio < 1.0, n_train <= rows - 1, so the test set is never empty.
52 // The only failure is an empty train set.
53 if n_train == 0 {
54 return Err(MattenMlprepError::EmptySplit { rows, train_ratio });
55 }
56 let n_test = rows - n_train;
57
58 let data = x.as_slice();
59 let split = n_train * cols;
60
61 let train = Tensor::try_new(data[..split].to_vec(), &[n_train, cols])
62 .map_err(MattenMlprepError::Matten)?;
63 let test = Tensor::try_new(data[split..].to_vec(), &[n_test, cols])
64 .map_err(MattenMlprepError::Matten)?;
65
66 Ok((train, test))
67}
68
69/// SplitMix64 — a tiny, dependency-free deterministic PRNG (RFC-024 §6).
70///
71/// The constants and advance order are part of the reproducibility contract
72/// (RFC-077 §6): changing them changes every user's split for a given seed.
73struct SplitMix64(u64);
74
75impl SplitMix64 {
76 fn new(seed: u64) -> Self {
77 Self(seed)
78 }
79
80 fn next_u64(&mut self) -> u64 {
81 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
82 let mut z = self.0;
83 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
84 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
85 z ^ (z >> 31)
86 }
87
88 /// Uniform in `[0, bound)`. `bound` must be non-zero.
89 ///
90 /// Uses modulo, which carries a negligible bias for `bound` far below
91 /// `u64::MAX` — acceptable here because row counts are tiny relative to
92 /// `u64`, and rejection sampling would complicate the reproducibility
93 /// contract for no practical gain at this scale.
94 fn next_below(&mut self, bound: usize) -> usize {
95 (self.next_u64() % bound as u64) as usize
96 }
97}
98
99/// Splits the rows of a 2D tensor into `(train, test)` by a seeded, shuffled
100/// partition.
101///
102/// ```text
103/// n_train = floor(n_rows * train_ratio) // identical to train_test_split
104/// ```
105///
106/// Row order is determined by a Fisher-Yates shuffle of the row *indices*
107/// (never the data itself), driven by a [`SplitMix64`] stream seeded from
108/// `seed`. The first `n_train` shuffled indices become `train`; the rest
109/// become `test`. Only row selection and order differ from
110/// [`train_test_split`]; the output sizes match exactly for the same
111/// `(x, train_ratio)`.
112///
113/// # Reproducibility
114///
115/// The same `(x, train_ratio, seed)` always produces byte-identical output,
116/// on every platform and every future release of this crate. The PRNG
117/// constants, the shuffle direction, and the seed-to-state mapping are part
118/// of this function's observable, contract-bearing behavior (RFC-077 §6) and
119/// will not change without a documented breaking change.
120///
121/// # Errors
122///
123/// - [`MattenMlprepError::ExpectedMatrix`] if `x` is not rank-2.
124/// - [`MattenMlprepError::InvalidRatio`] if `train_ratio` is not finite or not in `(0.0, 1.0)`.
125/// - [`MattenMlprepError::EmptySplit`] if `floor(rows * train_ratio) == 0`.
126/// - [`MattenMlprepError::DynamicTensor`] (with the `dynamic` feature) if `x` is dynamic.
127///
128/// ```
129/// use matten::Tensor;
130/// use matten_mlprep::train_test_split_seeded;
131///
132/// let x = Tensor::new(vec![10.0, 20.0, 30.0, 40.0, 50.0], &[5, 1]);
133/// let (train, test) = train_test_split_seeded(&x, 0.6, 42).unwrap();
134/// assert_eq!(train.shape(), &[3, 1]);
135/// assert_eq!(test.shape(), &[2, 1]);
136///
137/// // Same seed -> byte-identical output.
138/// let (train2, test2) = train_test_split_seeded(&x, 0.6, 42).unwrap();
139/// assert_eq!(train.as_slice(), train2.as_slice());
140/// assert_eq!(test.as_slice(), test2.as_slice());
141/// ```
142pub fn train_test_split_seeded(
143 x: &Tensor,
144 train_ratio: f64,
145 seed: u64,
146) -> Result<(Tensor, Tensor), MattenMlprepError> {
147 let (rows, cols) = matrix_dims(x)?;
148
149 if !train_ratio.is_finite() || train_ratio <= 0.0 || train_ratio >= 1.0 {
150 return Err(MattenMlprepError::InvalidRatio(train_ratio));
151 }
152
153 let n_train = (rows as f64 * train_ratio).floor() as usize;
154 if n_train == 0 {
155 return Err(MattenMlprepError::EmptySplit { rows, train_ratio });
156 }
157
158 // Fisher-Yates over row indices, descending. Direction is contract-bearing.
159 let mut order: Vec<usize> = (0..rows).collect();
160 let mut rng = SplitMix64::new(seed);
161 for i in (1..rows).rev() {
162 let j = rng.next_below(i + 1);
163 order.swap(i, j);
164 }
165
166 let data = x.as_slice();
167 let gather = |idx: &[usize]| -> Vec<f64> {
168 let mut out = Vec::with_capacity(idx.len() * cols);
169 for &r in idx {
170 out.extend_from_slice(&data[r * cols..(r + 1) * cols]);
171 }
172 out
173 };
174
175 let train = Tensor::try_new(gather(&order[..n_train]), &[n_train, cols])
176 .map_err(MattenMlprepError::Matten)?;
177 let test = Tensor::try_new(gather(&order[n_train..]), &[rows - n_train, cols])
178 .map_err(MattenMlprepError::Matten)?;
179
180 Ok((train, test))
181}