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}