Skip to main content

matten_mlprep/
scale.rs

1//! Per-column scaling (RFC-028 §4.1, §4.2).
2//!
3//! Both functions follow the `rows = samples`, `columns = features` convention
4//! and reject constant columns explicitly via [`MattenMlprepError::ZeroVariance`]
5//! rather than silently emitting a zero column. Per-column statistics reuse core
6//! `matten` axis reductions (RFC-019), so `NaN`/`Inf` propagate exactly as in
7//! core (`mean_axis` / `min_axis` / `max_axis`).
8
9use crate::error::MattenMlprepError;
10use crate::util::matrix_dims;
11use matten::Tensor;
12
13#[inline]
14fn at(data: &[f64], i: usize, j: usize, cols: usize) -> f64 {
15    data[i * cols + j]
16}
17
18/// Standardizes each column to zero mean and unit (population) standard
19/// deviation: `out[i,j] = (x[i,j] - mean_j) / std_j`.
20///
21/// `std_j` uses the population formula (divide by `n`), matching scikit-learn's
22/// `StandardScaler`.
23///
24/// # Errors
25///
26/// - [`MattenMlprepError::ExpectedMatrix`] if `x` is not rank-2.
27/// - [`MattenMlprepError::ZeroVariance`] if any column is constant.
28/// - [`MattenMlprepError::DynamicTensor`] (with the `dynamic` feature) if `x` is dynamic.
29/// - [`MattenMlprepError::Matten`] if `x` has zero rows — the per-column mean is
30///   undefined over an empty column (RFC-112).
31///
32/// ```
33/// use matten::Tensor;
34/// use matten_mlprep::standardize_columns;
35///
36/// // Column 0: [1, 3] -> mean 2, std 1 -> [-1, 1]; column 1: [10, 20] -> [-1, 1].
37/// let x = Tensor::new(vec![1.0, 10.0, 3.0, 20.0], &[2, 2]);
38/// let z = standardize_columns(&x).unwrap();
39/// assert_eq!(z.as_slice(), &[-1.0, -1.0, 1.0, 1.0]);
40/// ```
41pub fn standardize_columns(x: &Tensor) -> Result<Tensor, MattenMlprepError> {
42    let (rows, cols) = matrix_dims(x)?;
43    let data = x.as_slice();
44
45    // Per-column means via core axis reduction (NaN propagates as in core).
46    let means = x.try_mean_axis(0).map_err(MattenMlprepError::Matten)?;
47    let means = means.as_slice();
48
49    let mut out = vec![0.0f64; rows * cols];
50    for j in 0..cols {
51        let mean = means[j];
52        let var = (0..rows)
53            .map(|i| {
54                let d = at(data, i, j, cols) - mean;
55                d * d
56            })
57            .sum::<f64>()
58            / rows as f64;
59        let std = var.sqrt();
60        if std == 0.0 {
61            return Err(MattenMlprepError::ZeroVariance { column: j });
62        }
63        for i in 0..rows {
64            out[i * cols + j] = (at(data, i, j, cols) - mean) / std;
65        }
66    }
67
68    Tensor::try_new(out, &[rows, cols]).map_err(MattenMlprepError::Matten)
69}
70
71/// Scales each column to the `[0, 1]` range:
72/// `out[i,j] = (x[i,j] - min_j) / (max_j - min_j)`.
73///
74/// # Errors
75///
76/// - [`MattenMlprepError::ExpectedMatrix`] if `x` is not rank-2.
77/// - [`MattenMlprepError::ZeroVariance`] if any column is constant (zero range).
78/// - [`MattenMlprepError::DynamicTensor`] (with the `dynamic` feature) if `x` is dynamic.
79/// - [`MattenMlprepError::Matten`] if `x` has zero rows — the per-column min/max
80///   is undefined over an empty column (RFC-112).
81///
82/// ```
83/// use matten::Tensor;
84/// use matten_mlprep::minmax_scale_columns;
85///
86/// // Column 0: [0, 5, 10] -> [0, 0.5, 1].
87/// let x = Tensor::new(vec![0.0, 5.0, 10.0], &[3, 1]);
88/// let s = minmax_scale_columns(&x).unwrap();
89/// assert_eq!(s.as_slice(), &[0.0, 0.5, 1.0]);
90/// ```
91pub fn minmax_scale_columns(x: &Tensor) -> Result<Tensor, MattenMlprepError> {
92    let (rows, cols) = matrix_dims(x)?;
93    let data = x.as_slice();
94
95    // Per-column min/max via core axis reductions (NaN propagates as in core).
96    let mins = x.try_min_axis(0).map_err(MattenMlprepError::Matten)?;
97    let maxs = x.try_max_axis(0).map_err(MattenMlprepError::Matten)?;
98    let mins = mins.as_slice();
99    let maxs = maxs.as_slice();
100
101    let mut out = vec![0.0f64; rows * cols];
102    for j in 0..cols {
103        let range = maxs[j] - mins[j];
104        if range == 0.0 {
105            return Err(MattenMlprepError::ZeroVariance { column: j });
106        }
107        for i in 0..rows {
108            out[i * cols + j] = (at(data, i, j, cols) - mins[j]) / range;
109        }
110    }
111
112    Tensor::try_new(out, &[rows, cols]).map_err(MattenMlprepError::Matten)
113}