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