dataset_ml/preprocessing.rs
1//! Preprocessing helpers for the loaded datasets.
2//!
3//! Every loader in this crate returns a [`Table`](crate::table::Table) of raw
4//! [`ndarray`] columns: numbers exactly as the source published them, and
5//! categorical values as strings. Model input usually needs four steps: split
6//! off an evaluation set, scale the numeric columns, encode the categorical
7//! columns, and encode the labels. This module provides those steps, so a user
8//! does not need to reimplement them or add a framework dependency just to run a
9//! baseline.
10//!
11//! # Splitting is index-based
12//!
13//! The splitting functions ([`train_test_split`](crate::preprocessing::train_test_split),
14//! [`stratified_split`](crate::preprocessing::stratified_split),
15//! [`k_fold_indices`](crate::preprocessing::k_fold_indices),
16//! [`shuffled_indices`](crate::preprocessing::shuffled_indices)) return **row indices**, not arrays.
17//! That is deliberate. A sample spans every column of the loader's
18//! [`Table`](crate::table::Table). One index list keeps them aligned. Convert
19//! indices to arrays with ndarray's own `select`. The example below also needs the
20//! `dataset` feature:
21//!
22//! ```no_run
23//! use dataset_ml::Iris;
24//! use dataset_ml::preprocessing::train_test_split;
25//! use ndarray::Axis;
26//!
27//! let dataset = Iris::new("./data");
28//! let table = dataset.data().unwrap();
29//!
30//! let features = table.numeric_matrix(&Iris::FEATURE_NAMES).unwrap();
31//! let species = table.column(Iris::TARGET).unwrap().as_string().unwrap();
32//!
33//! let (train, test) = train_test_split(features.nrows(), 0.2, 42).unwrap();
34//!
35//! let train_x = features.select(Axis(0), &train);
36//! let train_y = species.select(Axis(0), &train);
37//! let test_x = features.select(Axis(0), &test);
38//! let test_y = species.select(Axis(0), &test);
39//!
40//! assert_eq!(train_x.nrows(), 120);
41//! assert_eq!(test_x.nrows(), 30);
42//! ```
43//!
44//! # Determinism
45//!
46//! Everything that shuffles takes an explicit `u64` seed and uses a
47//! [SplitMix64](https://doi.org/10.1145/2714064.2660195) generator built into this
48//! crate. It has no `rand` dependency and no hidden global state. The same seed
49//! and the same inputs always produce the same split, on every platform and every
50//! release of this crate.
51//!
52//! # Missing values
53//!
54//! Several loaders encode a missing number as `NaN` (`titanic`, `palmer_penguins`,
55//! `heart_disease`). The scalers here compute their statistics over the
56//! **finite** values of each column. Non-finite entries stay untouched, so a
57//! missing value stays missing instead of corrupting the whole column's
58//! statistics. Decide how to impute it yourself.
59
60use dataset_core::DatasetError;
61use ndarray::{Array1, Array2, ArrayView2};
62use std::collections::HashMap;
63
64/// The name this module uses to tag its errors.
65const MODULE_NAME: &str = "preprocessing";
66
67/// A pair of disjoint row-index lists that a splitting function produces.
68///
69/// The first list is the training side, the second is the held-out side (the test
70/// set for [`train_test_split`] / [`stratified_split`], the validation fold for
71/// [`k_fold_indices`]). Use them to index into your arrays with ndarray's
72/// `select(Axis(0), &indices)`.
73pub type IndexSplit = (Vec<usize>, Vec<usize>);
74
75/// A small, fast, fully deterministic pseudo-random number generator.
76///
77/// This is Steele, Lea, and Flood's SplitMix64, the finalizer that seeds many
78/// modern generators. It is not cryptographically secure. It does not need to
79/// be: it exists to make shuffling and splitting reproducible across platforms
80/// without adding a dependency. It works well enough for choosing which rows
81/// land in which fold.
82struct SplitMix64 {
83 state: u64,
84}
85
86impl SplitMix64 {
87 /// The odd constant this generator adds to its state at each step (the 64-bit
88 /// golden ratio).
89 const GAMMA: u64 = 0x9E37_79B9_7F4A_7C15;
90
91 /// Seed a generator. Every `u64` is a valid seed, including `0`.
92 fn new(seed: u64) -> Self {
93 Self { state: seed }
94 }
95
96 /// Produce the next 64-bit output and advance the state.
97 fn next_u64(&mut self) -> u64 {
98 self.state = self.state.wrapping_add(Self::GAMMA);
99
100 let mut z = self.state;
101 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
102 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
103
104 z ^ (z >> 31)
105 }
106
107 /// Produce a uniformly distributed value in `0..bound`.
108 ///
109 /// This uses rejection sampling rather than a plain modulo. Every value in the
110 /// range is then equally likely: a modulo would over-represent the low values
111 /// whenever `bound` does not divide 2^64.
112 ///
113 /// # Panics
114 ///
115 /// Panics if `bound` is 0. Callers in this module guarantee it is not.
116 fn below(&mut self, bound: u64) -> u64 {
117 assert!(bound > 0, "bound must be positive");
118
119 // The largest multiple of `bound` that fits in a u64. Draws at or above it
120 // would bias the result, so the generator discards and redraws them.
121 let limit = u64::MAX - (u64::MAX % bound) - (bound - 1);
122
123 loop {
124 let value = self.next_u64();
125 if value <= limit {
126 return value % bound;
127 }
128 }
129 }
130
131 /// Shuffle a slice in place with an unbiased Fisher-Yates pass.
132 fn shuffle<T>(&mut self, items: &mut [T]) {
133 for i in (1..items.len()).rev() {
134 let j = self.below(i as u64 + 1) as usize;
135 items.swap(i, j);
136 }
137 }
138}
139
140/// Return `0..n_samples` in a deterministic pseudo-random order.
141///
142/// The other functions in this module build on this shuffling primitive. Use it
143/// directly to reorder a dataset without splitting it. For example, use it before
144/// a pass over data that arrived grouped by class, as `iris`, `covtype`, and
145/// `movie_review_polarity` do.
146///
147/// # Parameters
148///
149/// - `n_samples` - How many indices to produce.
150/// - `seed` - Seed for the internal generator. The same seed always yields the same order.
151///
152/// # Returns
153///
154/// - `Vec<usize>` - A permutation of `0..n_samples`.
155///
156/// # Example
157/// ```rust
158/// use dataset_ml::preprocessing::shuffled_indices;
159///
160/// let order = shuffled_indices(5, 42);
161/// assert_eq!(order.len(), 5);
162///
163/// // Every index appears exactly once...
164/// let mut sorted = order.clone();
165/// sorted.sort_unstable();
166/// assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
167///
168/// // ...and the same seed reproduces the same order.
169/// assert_eq!(shuffled_indices(5, 42), order);
170/// ```
171pub fn shuffled_indices(n_samples: usize, seed: u64) -> Vec<usize> {
172 let mut indices: Vec<usize> = (0..n_samples).collect();
173 SplitMix64::new(seed).shuffle(&mut indices);
174
175 indices
176}
177
178/// Split `0..n_samples` into shuffled train and test index lists.
179///
180/// The test set gets `round(n_samples * test_ratio)` rows, clamped so that neither
181/// side is empty whenever there are at least two samples. The train set gets the
182/// rest. Both lists are in shuffled order, so a dataset stored grouped by class
183/// (the common case) does not produce a train set missing a class.
184///
185/// To keep each class's proportion intact, use [`stratified_split`] instead.
186///
187/// # Parameters
188///
189/// - `n_samples` - Total number of samples to split.
190/// - `test_ratio` - Fraction of samples to place in the test set, in `0.0..=1.0`.
191/// - `seed` - Seed for the internal generator. The same seed always yields the same split.
192///
193/// # Returns
194///
195/// - `IndexSplit` - The `(train, test)` row indices. Together they are
196/// a permutation of `0..n_samples`, and they never overlap.
197///
198/// # Errors
199///
200/// - `DatasetError::ValidationError` - Returns this when `n_samples` is 0, or when
201/// `test_ratio` is not a finite value in `0.0..=1.0`.
202///
203/// # Example
204/// ```rust
205/// use dataset_ml::preprocessing::train_test_split;
206///
207/// let (train, test) = train_test_split(150, 0.2, 42).unwrap();
208/// assert_eq!(train.len(), 120);
209/// assert_eq!(test.len(), 30);
210///
211/// // The two sides are disjoint and cover everything.
212/// let mut all: Vec<usize> = train.iter().chain(test.iter()).copied().collect();
213/// all.sort_unstable();
214/// assert_eq!(all, (0..150).collect::<Vec<_>>());
215/// ```
216pub fn train_test_split(
217 n_samples: usize,
218 test_ratio: f64,
219 seed: u64,
220) -> Result<IndexSplit, DatasetError> {
221 if n_samples == 0 {
222 return Err(DatasetError::empty_dataset(MODULE_NAME));
223 }
224 validate_ratio(test_ratio)?;
225
226 let n_test = test_size(n_samples, test_ratio);
227
228 let mut indices = shuffled_indices(n_samples, seed);
229 let test = indices.split_off(n_samples - n_test);
230
231 Ok((indices, test))
232}
233
234/// Split into train and test index lists that preserve each class's proportion.
235///
236/// Like [`train_test_split`], but this function draws the split **within** each
237/// class rather than over the whole dataset. A class that holds 10% of the
238/// samples then holds about 10% of the train set and 10% of the test set. This
239/// matters for the imbalanced loaders: `sms_spam` (13% spam), `covtype` (its
240/// rarest cover type is under 0.5%), and `kddcup99`. An unstratified split can
241/// omit a rare class from the test set completely.
242///
243/// Every class with at least two members contributes at least one row to each side.
244/// A class with a single member contributes it to the train set.
245///
246/// # Parameters
247///
248/// - `labels` - The per-sample class labels, of any comparable, hashable type
249/// (`&str`, `String`, `u8`, `char`, and so on). This covers every label type
250/// this crate produces.
251/// - `test_ratio` - Fraction of each class to place in the test set, in `0.0..=1.0`.
252/// - `seed` - Seed for the internal generator. The same seed always yields the same split.
253///
254/// # Returns
255///
256/// - `IndexSplit` - The `(train, test)` row indices, each in shuffled
257/// order. Together they are a permutation of `0..labels.len()`.
258///
259/// # Errors
260///
261/// - `DatasetError::ValidationError` - Returns this when `labels` is empty, or when
262/// `test_ratio` is not a finite value in `0.0..=1.0`.
263///
264/// # Example
265/// ```rust
266/// use dataset_ml::preprocessing::stratified_split;
267///
268/// // Nine samples of class "a", one of class "b".
269/// let labels = ["a", "a", "a", "a", "a", "a", "a", "a", "a", "b"];
270/// let (train, test) = stratified_split(&labels, 0.5, 7).unwrap();
271///
272/// // The lone "b" cannot be in both sides, so it stays in the train set.
273/// assert!(train.contains(&9));
274/// assert_eq!(train.len() + test.len(), 10);
275/// ```
276pub fn stratified_split<T: std::hash::Hash + Eq>(
277 labels: &[T],
278 test_ratio: f64,
279 seed: u64,
280) -> Result<IndexSplit, DatasetError> {
281 if labels.is_empty() {
282 return Err(DatasetError::empty_dataset(MODULE_NAME));
283 }
284 validate_ratio(test_ratio)?;
285
286 // Group row indices by class, keeping first-appearance order so the result does
287 // not depend on `HashMap` iteration order.
288 let mut order: Vec<&T> = Vec::new();
289 let mut groups: HashMap<&T, Vec<usize>> = HashMap::new();
290 for (index, label) in labels.iter().enumerate() {
291 groups.entry(label).or_insert_with(|| {
292 order.push(label);
293 Vec::new()
294 });
295 // The line above creates the entry if it was missing, so this lookup cannot fail.
296 groups.get_mut(label).expect("group exists").push(index);
297 }
298
299 let mut rng = SplitMix64::new(seed);
300 let mut train = Vec::with_capacity(labels.len());
301 let mut test = Vec::new();
302
303 for label in order {
304 let mut group = groups.remove(label).expect("group exists");
305 rng.shuffle(&mut group);
306
307 // A single-member class cannot be split, so it goes to the train set.
308 let n_test = if group.len() < 2 {
309 0
310 } else {
311 test_size(group.len(), test_ratio)
312 };
313
314 let group_test = group.split_off(group.len() - n_test);
315 train.extend(group);
316 test.extend(group_test);
317 }
318
319 // Re-shuffle so the classes do not stay grouped in the result.
320 rng.shuffle(&mut train);
321 rng.shuffle(&mut test);
322
323 Ok((train, test))
324}
325
326/// Partition `0..n_samples` into `k` cross-validation folds.
327///
328/// This function shuffles the samples once and deals them into `k` folds whose
329/// sizes differ by at most one. Each entry of the result is the
330/// `(train, validation)` pair for one fold. The validation list is that fold, and
331/// the train list is everything else. Every sample serves as validation exactly
332/// once across the `k` rounds.
333///
334/// # Parameters
335///
336/// - `n_samples` - Total number of samples to partition.
337/// - `k` - Number of folds. Must be at least 2 and at most `n_samples`.
338/// - `seed` - Seed for the internal generator. The same seed always yields the same folds.
339///
340/// # Returns
341///
342/// - `Vec<IndexSplit>` - `k` pairs of `(train, validation)` row indices.
343///
344/// # Errors
345///
346/// - `DatasetError::ValidationError` - Returns this when `n_samples` is 0, or when `k`
347/// is less than 2 or greater than `n_samples`.
348///
349/// # Example
350/// ```rust
351/// use dataset_ml::preprocessing::k_fold_indices;
352///
353/// let folds = k_fold_indices(10, 5, 42).unwrap();
354/// assert_eq!(folds.len(), 5);
355///
356/// for (train, validation) in &folds {
357/// assert_eq!(validation.len(), 2);
358/// assert_eq!(train.len(), 8);
359/// }
360///
361/// // Each sample is validated exactly once.
362/// let mut validated: Vec<usize> = folds.iter().flat_map(|(_, v)| v.clone()).collect();
363/// validated.sort_unstable();
364/// assert_eq!(validated, (0..10).collect::<Vec<_>>());
365/// ```
366pub fn k_fold_indices(
367 n_samples: usize,
368 k: usize,
369 seed: u64,
370) -> Result<Vec<IndexSplit>, DatasetError> {
371 if n_samples == 0 {
372 return Err(DatasetError::empty_dataset(MODULE_NAME));
373 }
374 if k < 2 || k > n_samples {
375 return Err(DatasetError::ValidationError(format!(
376 "[{MODULE_NAME}] k must be between 2 and n_samples ({n_samples}), got {k}"
377 )));
378 }
379
380 let indices = shuffled_indices(n_samples, seed);
381
382 // Deal the samples into folds of size `n / k`. The first `n % k` folds get one
383 // extra sample, so every sample is used and no fold is more than one larger
384 // than another.
385 let base = n_samples / k;
386 let remainder = n_samples % k;
387
388 let mut folds = Vec::with_capacity(k);
389 let mut start = 0;
390 for fold in 0..k {
391 let len = base + usize::from(fold < remainder);
392 let end = start + len;
393
394 let validation = indices[start..end].to_vec();
395 let train = indices[..start]
396 .iter()
397 .chain(indices[end..].iter())
398 .copied()
399 .collect();
400
401 folds.push((train, validation));
402 start = end;
403 }
404
405 Ok(folds)
406}
407
408/// Map labels of any type to consecutive integer codes.
409///
410/// Turns a label vector into the `0..n_classes` codes most training code
411/// expects. A loader holds its labels in an `Array1<String>`, and this function
412/// also accepts any other comparable type. It returns the class list needed to
413/// decode a prediction. This function numbers classes in **sorted** order, so the
414/// encoding depends only on the set of labels present, never on their order in
415/// the file.
416///
417/// # Parameters
418///
419/// - `labels` - The per-sample labels to encode.
420///
421/// # Returns
422///
423/// - `(Array1<usize>, Vec<T>)` - The per-sample codes, and the sorted class list
424/// where index `i` is the class encoded as `i`.
425///
426/// # Errors
427///
428/// - `DatasetError::ValidationError` - Returns this when `labels` is empty.
429///
430/// # Example
431/// ```rust
432/// use dataset_ml::preprocessing::label_encode;
433/// use ndarray::array;
434///
435/// let labels = array!["virginica", "setosa", "setosa", "versicolor"];
436/// let (codes, classes) = label_encode(&labels).unwrap();
437///
438/// // Classes get numbers alphabetically, not in order of appearance.
439/// assert_eq!(classes, vec!["setosa", "versicolor", "virginica"]);
440/// assert_eq!(codes, array![2, 0, 0, 1]);
441///
442/// // Decode a prediction through the class list.
443/// assert_eq!(classes[codes[0]], "virginica");
444/// ```
445pub fn label_encode<T: Clone + Ord>(
446 labels: &Array1<T>,
447) -> Result<(Array1<usize>, Vec<T>), DatasetError> {
448 if labels.is_empty() {
449 return Err(DatasetError::empty_dataset(MODULE_NAME));
450 }
451
452 let mut classes: Vec<T> = labels.iter().cloned().collect();
453 classes.sort_unstable();
454 classes.dedup();
455
456 let codes = labels.mapv(|label| {
457 classes
458 .binary_search(&label)
459 .expect("every label is in the class list it was built from")
460 });
461
462 Ok((codes, classes))
463}
464
465/// Count how many samples carry each label.
466///
467/// This is a quick way to see how balanced a dataset is before choosing between
468/// [`train_test_split`] and [`stratified_split`]. This function returns counts in
469/// sorted class order, matching the numbering [`label_encode`] assigns.
470///
471/// # Parameters
472///
473/// - `labels` - The per-sample labels to count.
474///
475/// # Returns
476///
477/// - `Vec<(T, usize)>` - Each distinct class and its sample count, sorted by class.
478///
479/// # Example
480/// ```rust
481/// use dataset_ml::preprocessing::class_counts;
482/// use ndarray::array;
483///
484/// let labels = array!["spam", "ham", "ham", "ham"];
485/// assert_eq!(class_counts(&labels), vec![("ham", 3), ("spam", 1)]);
486/// ```
487pub fn class_counts<T: Clone + Ord>(labels: &Array1<T>) -> Vec<(T, usize)> {
488 let mut sorted: Vec<T> = labels.iter().cloned().collect();
489 sorted.sort_unstable();
490
491 let mut counts: Vec<(T, usize)> = Vec::new();
492 for label in sorted {
493 match counts.last_mut() {
494 Some((class, count)) if *class == label => *count += 1,
495 _ => counts.push((label, 1)),
496 }
497 }
498
499 counts
500}
501
502/// Fitting a scaler produces these per-column statistics. Reuse them to replay the
503/// same transform on new data.
504///
505/// Fit a scaler on the **training** rows only. Then apply it unchanged to the test
506/// rows. Fitting it on everything leaks information about the test set into
507/// training. That is why [`standardize`] and [`min_max_scale`] return this struct.
508/// Keep it. Pass it to [`apply_scaler`] for every later batch.
509///
510/// The two field names describe the general shape of the transform,
511/// `(value - center) / scale`:
512///
513/// - [`standardize`] sets `center` to the column mean and `scale` to its standard
514/// deviation.
515/// - [`min_max_scale`] sets `center` to the column minimum and `scale` to its range.
516#[derive(Debug, Clone, PartialEq)]
517pub struct Scaler {
518 /// Per-column value that [`apply_scaler`] subtracts before scaling (mean, or minimum).
519 pub center: Array1<f64>,
520 /// Per-column divisor (standard deviation, or range). Never 0: a constant
521 /// column gets a scale of 1, so it maps to all-zeros instead of `NaN`.
522 pub scale: Array1<f64>,
523}
524
525/// Standardize each feature column to zero mean and unit variance.
526///
527/// This is the classic z-score transform, `(value - mean) / std_dev`, applied per
528/// column. It is what distance-based and gradient-based models want from the raw
529/// numeric matrices these loaders return. Those columns routinely differ by orders
530/// of magnitude: for example, `adult`'s `fnlwgt` runs to the hundreds of thousands,
531/// while `education-num` goes no higher than 16.
532///
533/// This function computes the mean and standard deviation (population, that is,
534/// divided by `n`) over the **finite** values of each column. Non-finite entries
535/// stay untouched, so a `NaN` marking a missing value stays a `NaN`. A column with
536/// no variation gets a scale of 1 and maps to all zeros rather than dividing by 0.
537///
538/// # Parameters
539///
540/// - `features` - The numeric feature matrix, shape `(n_samples, n_features)`.
541///
542/// # Returns
543///
544/// - `(Array2<f64>, Scaler)` - The standardized matrix, and the fitted per-column
545/// statistics to replay on later data with [`apply_scaler`].
546///
547/// # Errors
548///
549/// - `DatasetError::ValidationError` - Returns this when `features` has no rows or no columns.
550///
551/// # Example
552/// ```rust
553/// use dataset_ml::preprocessing::standardize;
554/// use ndarray::array;
555///
556/// let features = array![[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]];
557/// let (scaled, scaler) = standardize(&features).unwrap();
558///
559/// assert_eq!(scaler.center, array![2.0, 20.0]);
560/// assert_eq!(scaled[[1, 0]], 0.0); // the mean row maps to 0
561/// assert!((scaled[[0, 0]] + scaled[[2, 0]]).abs() < 1e-12); // symmetric about it
562/// ```
563pub fn standardize(features: &Array2<f64>) -> Result<(Array2<f64>, Scaler), DatasetError> {
564 let scaler = fit_scaler(features.view(), |column| {
565 let (sum, count) = finite_sum(column);
566 if count == 0 {
567 // Nothing finite to learn from: leave the column alone.
568 return (0.0, 1.0);
569 }
570
571 let mean = sum / count as f64;
572 let variance = column
573 .iter()
574 .filter(|value| value.is_finite())
575 .map(|value| (value - mean).powi(2))
576 .sum::<f64>()
577 / count as f64;
578
579 (mean, variance.sqrt())
580 })?;
581
582 let scaled = apply_scaler(features, &scaler)?;
583
584 Ok((scaled, scaler))
585}
586
587/// Rescale each feature column into the `[0, 1]` range.
588///
589/// This is min-max scaling, `(value - min) / (max - min)`, applied per column.
590/// Prefer it over [`standardize`] when a bounded range matters more than a
591/// comparable spread. Use it for pixel-like features (`digits`), or as input to a
592/// model that expects `[0, 1]`.
593///
594/// As with [`standardize`], the minimum and maximum come from the **finite**
595/// values of each column. Non-finite entries stay untouched. A constant column
596/// maps to all zeros rather than dividing by 0.
597///
598/// # Parameters
599///
600/// - `features` - The numeric feature matrix, shape `(n_samples, n_features)`.
601///
602/// # Returns
603///
604/// - `(Array2<f64>, Scaler)` - The rescaled matrix, and the fitted per-column
605/// statistics to replay on later data with [`apply_scaler`].
606///
607/// # Errors
608///
609/// - `DatasetError::ValidationError` - Returns this when `features` has no rows or no columns.
610///
611/// # Example
612/// ```rust
613/// use dataset_ml::preprocessing::min_max_scale;
614/// use ndarray::array;
615///
616/// let features = array![[1.0, -5.0], [3.0, 5.0]];
617/// let (scaled, _scaler) = min_max_scale(&features).unwrap();
618///
619/// assert_eq!(scaled, array![[0.0, 0.0], [1.0, 1.0]]);
620/// ```
621pub fn min_max_scale(features: &Array2<f64>) -> Result<(Array2<f64>, Scaler), DatasetError> {
622 let scaler = fit_scaler(features.view(), |column| {
623 let mut min = f64::INFINITY;
624 let mut max = f64::NEG_INFINITY;
625 for &value in column {
626 if value.is_finite() {
627 min = min.min(value);
628 max = max.max(value);
629 }
630 }
631
632 if min > max {
633 // No finite values at all: leave the column alone.
634 return (0.0, 1.0);
635 }
636
637 (min, max - min)
638 })?;
639
640 let scaled = apply_scaler(features, &scaler)?;
641
642 Ok((scaled, scaler))
643}
644
645/// Apply an already-fitted [`Scaler`] to a feature matrix.
646///
647/// Use this to replay a training-fitted scaler onto the test rows, or onto later
648/// data, without refitting. Refitting would give the two sets different transforms
649/// and leak test statistics into training.
650///
651/// Non-finite entries stay untouched, matching the fitting functions.
652///
653/// # Parameters
654///
655/// - `features` - The numeric feature matrix to transform, shape `(n_samples, n_features)`.
656/// - `scaler` - Statistics from a previous [`standardize`] or [`min_max_scale`] call.
657///
658/// # Returns
659///
660/// - `Array2<f64>` - The transformed matrix, with the same shape as `features`.
661///
662/// # Errors
663///
664/// - `DatasetError::LengthMismatch` - Returns this when `features` has a different
665/// number of columns than the scaler expects.
666///
667/// # Example
668/// ```rust
669/// use dataset_ml::preprocessing::{apply_scaler, standardize};
670/// use ndarray::array;
671///
672/// let train = array![[1.0], [2.0], [3.0]];
673/// let (_scaled_train, scaler) = standardize(&train).unwrap();
674///
675/// // This transforms the test rows with the training statistics, not their own.
676/// let test = array![[2.0], [4.0]];
677/// let scaled_test = apply_scaler(&test, &scaler).unwrap();
678/// assert_eq!(scaled_test[[0, 0]], 0.0); // 2.0 was the training mean
679/// ```
680pub fn apply_scaler(features: &Array2<f64>, scaler: &Scaler) -> Result<Array2<f64>, DatasetError> {
681 if features.ncols() != scaler.center.len() {
682 return Err(DatasetError::length_mismatch(
683 MODULE_NAME,
684 "scaler columns",
685 scaler.center.len(),
686 features.ncols(),
687 ));
688 }
689
690 let mut scaled = features.clone();
691 for (column_index, mut column) in scaled.columns_mut().into_iter().enumerate() {
692 let center = scaler.center[column_index];
693 let scale = scaler.scale[column_index];
694
695 for value in column.iter_mut() {
696 // A missing or infinite value has no meaningful scaled counterpart, so
697 // this loop leaves it unchanged instead of turning it into nonsense.
698 if value.is_finite() {
699 *value = (*value - center) / scale;
700 }
701 }
702 }
703
704 Ok(scaled)
705}
706
707/// One-hot encode a matrix of categorical string features.
708///
709/// The mixed-type loaders (`adult`, `titanic`, `bank_marketing`, `abalone`,
710/// `kddcup99`, `palmer_penguins`) and the all-categorical ones (`mushroom`,
711/// `car_evaluation`) keep their categorical values in
712/// [`ColumnData::String`](crate::table::ColumnData::String) columns. Read each
713/// column by name with `as_string`, then stack the columns into an
714/// `Array2<String>`. No numeric model can consume strings directly. This function
715/// expands each column into one indicator column per level it takes. A row gets
716/// `1.0` in the column for its own level, and `0.0` everywhere else.
717///
718/// This function sorts levels within a column, so the output layout depends only
719/// on the values present. The returned names identify the columns as
720/// `<column>=<level>`, using `column_names` when supplied, and `column_0`,
721/// `column_1`, and so on otherwise.
722///
723/// This widens the matrix by however many distinct levels the data holds. That is
724/// harmless for `mushroom` (22 columns become 117). Before running it on
725/// `kddcup99`'s `service` column (70 levels over millions of rows), check the
726/// resulting width.
727///
728/// # Parameters
729///
730/// - `categorical` - The categorical matrix, shape `(n_samples, n_features)`.
731/// - `column_names` - Optional names for the source columns, used to build the
732/// output names. Must have one entry per column when supplied.
733///
734/// # Returns
735///
736/// - `(Array2<f64>, Vec<String>)` - The indicator matrix, shape
737/// `(n_samples, total_levels)`, and one name per output column.
738///
739/// # Errors
740///
741/// - `DatasetError::ValidationError` - Returns this when `categorical` has no rows or no columns.
742/// - `DatasetError::LengthMismatch` - Returns this when `column_names` is `Some` but
743/// its length does not match the column count.
744///
745/// # Example
746/// ```rust
747/// use dataset_ml::preprocessing::one_hot_encode;
748/// use ndarray::array;
749///
750/// let categorical = array![
751/// ["male".to_string(), "S".to_string()],
752/// ["female".to_string(), "C".to_string()],
753/// ["male".to_string(), "C".to_string()],
754/// ];
755/// let (encoded, names) = one_hot_encode(&categorical, Some(&["sex", "port"])).unwrap();
756///
757/// assert_eq!(names, vec!["sex=female", "sex=male", "port=C", "port=S"]);
758/// assert_eq!(encoded.row(0).to_vec(), vec![0.0, 1.0, 0.0, 1.0]); // male, S
759/// ```
760pub fn one_hot_encode(
761 categorical: &Array2<String>,
762 column_names: Option<&[&str]>,
763) -> Result<(Array2<f64>, Vec<String>), DatasetError> {
764 let n_samples = categorical.nrows();
765 let n_columns = categorical.ncols();
766
767 if n_samples == 0 || n_columns == 0 {
768 return Err(DatasetError::empty_dataset(MODULE_NAME));
769 }
770 if let Some(names) = column_names
771 && names.len() != n_columns
772 {
773 return Err(DatasetError::length_mismatch(
774 MODULE_NAME,
775 "column_names",
776 n_columns,
777 names.len(),
778 ));
779 }
780
781 // Collect each column's sorted levels first, so the output width is known before
782 // allocating the result matrix.
783 let mut levels_per_column: Vec<Vec<&String>> = Vec::with_capacity(n_columns);
784 for column_index in 0..n_columns {
785 // `into_iter` on the view yields references borrowed from `categorical`
786 // itself, so the collected levels outlive this loop iteration.
787 let mut levels: Vec<&String> = categorical.column(column_index).into_iter().collect();
788 levels.sort_unstable();
789 levels.dedup();
790 levels_per_column.push(levels);
791 }
792
793 let total_levels: usize = levels_per_column.iter().map(Vec::len).sum();
794
795 let mut names = Vec::with_capacity(total_levels);
796 for (column_index, levels) in levels_per_column.iter().enumerate() {
797 let column_name = match column_names {
798 Some(supplied) => supplied[column_index].to_string(),
799 None => format!("column_{column_index}"),
800 };
801 for level in levels {
802 names.push(format!("{column_name}={level}"));
803 }
804 }
805
806 let mut encoded = Array2::<f64>::zeros((n_samples, total_levels));
807 let mut offset = 0;
808 for (column_index, levels) in levels_per_column.iter().enumerate() {
809 for row in 0..n_samples {
810 let value = &categorical[[row, column_index]];
811 let level_index = levels
812 .binary_search(&value)
813 .expect("every value is in the level list it was built from");
814 encoded[[row, offset + level_index]] = 1.0;
815 }
816 offset += levels.len();
817 }
818
819 Ok((encoded, names))
820}
821
822/// Reject a ratio that is not a finite fraction.
823fn validate_ratio(ratio: f64) -> Result<(), DatasetError> {
824 if !ratio.is_finite() || !(0.0..=1.0).contains(&ratio) {
825 return Err(DatasetError::ValidationError(format!(
826 "[{MODULE_NAME}] test_ratio must be a finite value in 0.0..=1.0, got {ratio}"
827 )));
828 }
829
830 Ok(())
831}
832
833/// How many of `n` samples go to the test side. Both sides stay non-empty
834/// whenever more than one sample exists.
835fn test_size(n: usize, ratio: f64) -> usize {
836 let requested = (n as f64 * ratio).round() as usize;
837
838 requested.clamp(usize::from(n > 1), n.saturating_sub(1))
839}
840
841/// Sum a column's finite values, and count how many there were.
842fn finite_sum(column: &ndarray::ArrayView1<f64>) -> (f64, usize) {
843 column
844 .iter()
845 .filter(|value| value.is_finite())
846 .fold((0.0, 0), |(sum, count), value| (sum + value, count + 1))
847}
848
849/// Build a [`Scaler`] by applying `statistics` to every column of `features`.
850///
851/// The closure returns that column's `(center, scale)`. If the scale is 0 (a
852/// constant column), the function replaces it with 1, so the transform maps that
853/// column to zeros instead of `NaN`.
854fn fit_scaler(
855 features: ArrayView2<f64>,
856 statistics: impl Fn(&ndarray::ArrayView1<f64>) -> (f64, f64),
857) -> Result<Scaler, DatasetError> {
858 if features.nrows() == 0 || features.ncols() == 0 {
859 return Err(DatasetError::empty_dataset(MODULE_NAME));
860 }
861
862 let mut center = Vec::with_capacity(features.ncols());
863 let mut scale = Vec::with_capacity(features.ncols());
864
865 for column in features.columns() {
866 let (column_center, column_scale) = statistics(&column);
867 center.push(column_center);
868 scale.push(if column_scale > 0.0 {
869 column_scale
870 } else {
871 1.0
872 });
873 }
874
875 Ok(Scaler {
876 center: Array1::from_vec(center),
877 scale: Array1::from_vec(scale),
878 })
879}