Skip to main content

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}