Skip to main content

qualia_core_db/solvers/learning/preprocessing/
scaling.rs

1//! Feature scaling — z-score standardization of a row-major feature matrix.
2//! Reuses `statistics::descriptive` for the column mean / std-dev.
3
4use crate::solvers::statistics::descriptive::{mean, std_dev};
5
6/// Standardizes each column to zero mean / unit variance. `fit` learns the column
7/// means and std-devs; `transform` applies `(x − μ)/σ`. A zero-variance column is
8/// left centered (σ treated as 1) rather than producing NaNs.
9#[derive(Debug, Clone)]
10pub struct StandardScaler {
11    means: Vec<f64>,
12    stds: Vec<f64>,
13}
14
15impl StandardScaler {
16    /// Learn per-column statistics from a row-major `n_rows × n_cols` matrix.
17    /// `None` on a shape mismatch or empty input.
18    pub fn fit(x: &[f64], n_rows: usize, n_cols: usize) -> Option<Self> {
19        if n_rows == 0 || n_cols == 0 || x.len() != n_rows * n_cols {
20            return None;
21        }
22        let mut means = vec![0.0; n_cols];
23        let mut stds = vec![0.0; n_cols];
24        let mut col = vec![0.0; n_rows];
25        for j in 0..n_cols {
26            for i in 0..n_rows {
27                col[i] = x[i * n_cols + j];
28            }
29            means[j] = mean(&col)?;
30            let s = std_dev(&col, true).unwrap_or(0.0);
31            stds[j] = if s > 0.0 { s } else { 1.0 };
32        }
33        Some(Self { means, stds })
34    }
35
36    pub fn means(&self) -> &[f64] {
37        &self.means
38    }
39    pub fn stds(&self) -> &[f64] {
40        &self.stds
41    }
42
43    /// Apply standardization in place to a row-major `n_rows × n_cols` matrix using
44    /// the learned statistics. `None` on a shape mismatch.
45    pub fn transform_inplace(&self, x: &mut [f64], n_rows: usize, n_cols: usize) -> Option<()> {
46        if n_cols != self.means.len() || x.len() != n_rows * n_cols {
47            return None;
48        }
49        for i in 0..n_rows {
50            for j in 0..n_cols {
51                x[i * n_cols + j] = (x[i * n_cols + j] - self.means[j]) / self.stds[j];
52            }
53        }
54        Some(())
55    }
56
57    /// Fit then transform a copy of `x`, returning the standardized matrix.
58    pub fn fit_transform(x: &[f64], n_rows: usize, n_cols: usize) -> Option<(Self, Vec<f64>)> {
59        let scaler = Self::fit(x, n_rows, n_cols)?;
60        let mut out = x.to_vec();
61        scaler.transform_inplace(&mut out, n_rows, n_cols)?;
62        Some((scaler, out))
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::solvers::statistics::descriptive::{mean, std_dev};
70
71    #[test]
72    fn standardizes_to_zero_mean_unit_var() {
73        // 4×2 matrix.
74        let x = [1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0];
75        let (_, z) = StandardScaler::fit_transform(&x, 4, 2).unwrap();
76        for j in 0..2 {
77            let col: Vec<f64> = (0..4).map(|i| z[i * 2 + j]).collect();
78            assert!(mean(&col).unwrap().abs() < 1e-12, "col {j} mean");
79            assert!(
80                (std_dev(&col, true).unwrap() - 1.0).abs() < 1e-9,
81                "col {j} std"
82            );
83        }
84    }
85
86    #[test]
87    fn constant_column_is_centered_not_nan() {
88        let x = [5.0, 1.0, 5.0, 2.0, 5.0, 3.0]; // col0 constant
89        let (_, z) = StandardScaler::fit_transform(&x, 3, 2).unwrap();
90        for i in 0..3 {
91            assert_eq!(z[i * 2], 0.0); // constant column → all zeros, no NaN
92        }
93    }
94
95    #[test]
96    fn guards_shape() {
97        assert!(StandardScaler::fit(&[1.0, 2.0], 2, 2).is_none());
98    }
99}