Skip to main content

sklears_python/preprocessing/
standard_scaler.rs

1//! Python bindings for StandardScaler
2//!
3//! This module provides Python bindings for StandardScaler,
4//! offering scikit-learn compatible standardization (z-score normalization).
5
6use super::common::*;
7use scirs2_core::ndarray::{Array1, Axis};
8
9/// StandardScaler state after fitting
10#[derive(Debug, Clone)]
11struct StandardScalerState {
12    mean: Array1<f64>,
13    scale: Array1<f64>,
14    var: Array1<f64>,
15    n_features: usize,
16    n_samples_seen: usize,
17}
18
19/// Standardize features by removing the mean and scaling to unit variance.
20///
21/// The standard score of a sample `x` is calculated as:
22///
23/// ```text
24/// z = (x - u) / s
25/// ```
26///
27/// where `u` is the mean of the training samples or zero if `with_mean=False`,
28/// and `s` is the standard deviation of the training samples or one if
29/// `with_std=False`.
30///
31/// Centering and scaling happen independently on each feature by computing
32/// the relevant statistics on the samples in the training set. Mean and
33/// standard deviation are then stored to be used on later data using
34/// :meth:`transform`.
35///
36/// Standardization of a dataset is a common requirement for many
37/// machine learning estimators: they might behave badly if the
38/// individual features do not more or less look like standard normally
39/// distributed data (e.g. Gaussian with 0 mean and unit variance).
40///
41/// Parameters
42/// ----------
43/// copy : bool, default=True
44///     If False, try to avoid a copy and do inplace scaling instead.
45///     This is not guaranteed to always work inplace; e.g. if the data is
46///     not a NumPy array or scipy.sparse CSR matrix, a copy may still be
47///     returned.
48///
49/// with_mean : bool, default=True
50///     If True, center the data before scaling.
51///     This does not work (and will raise an exception) when attempted on
52///     sparse matrices, because centering them entails building a dense
53///     matrix which in common use cases is likely to be too large to fit in
54///     memory.
55///
56/// with_std : bool, default=True
57///     If True, scale the data to unit variance (or equivalently,
58///     unit standard deviation).
59///
60/// Attributes
61/// ----------
62/// scale_ : ndarray of shape (n_features,) or None
63///     Per feature relative scaling of the data to achieve zero mean and unit
64///     variance. Generally this is calculated using `np.sqrt(var_)`. If a
65///     variance is zero, we can't achieve unit variance, and the data is left
66///     as-is, giving a scaling factor of 1. `scale_` is equal to `None`
67///     when `with_std=False`.
68///
69/// mean_ : ndarray of shape (n_features,) or None
70///     The mean value for each feature in the training set.
71///     Equal to ``None`` when ``with_mean=False``.
72///
73/// var_ : ndarray of shape (n_features,) or None
74///     The variance for each feature in the training set. Used to compute
75///     `scale_`. Equal to ``None`` when ``with_std=False``.
76///
77/// n_features_in_ : int
78///     Number of features seen during :term:`fit`.
79///
80/// n_samples_seen_ : int
81///     The number of samples processed by the estimator.
82///     It will be reset on new calls to fit, but increments across
83///     ``partial_fit`` calls.
84///
85/// Examples
86/// --------
87/// >>> from sklears_python import StandardScaler
88/// >>> import numpy as np
89/// >>> data = [[0, 0], [0, 0], [1, 1], [1, 1]]
90/// >>> scaler = StandardScaler()
91/// >>> scaler.fit(data)
92/// StandardScaler()
93/// >>> print(scaler.mean_)
94/// [0.5 0.5]
95/// >>> print(scaler.transform(data))
96/// [[-1. -1.]
97///  [-1. -1.]
98///  [ 1.  1.]
99///  [ 1.  1.]]
100/// >>> print(scaler.transform([[2, 2]]))
101/// [[3. 3.]]
102#[pyclass(name = "StandardScaler")]
103pub struct PyStandardScaler {
104    copy: bool,
105    with_mean: bool,
106    with_std: bool,
107    state: Option<StandardScalerState>,
108}
109
110#[pymethods]
111impl PyStandardScaler {
112    #[new]
113    #[pyo3(signature = (copy=true, with_mean=true, with_std=true))]
114    fn new(copy: bool, with_mean: bool, with_std: bool) -> Self {
115        Self {
116            copy,
117            with_mean,
118            with_std,
119            state: None,
120        }
121    }
122
123    /// Compute the mean and std to be used for later scaling.
124    ///
125    /// Parameters
126    /// ----------
127    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
128    ///     The data used to compute the mean and standard deviation
129    ///     used for later scaling along the features axis.
130    ///
131    /// y : None
132    ///     Ignored.
133    ///
134    /// sample_weight : array-like of shape (n_samples,), default=None
135    ///     Individual weights for each sample.
136    ///
137    /// Returns
138    /// -------
139    /// self : object
140    ///     Fitted scaler.
141    fn fit(&mut self, x: PyReadonlyArray2<f64>) -> PyResult<()> {
142        let x_array = pyarray_to_core_array2(&x)?;
143        validate_fit_array(&x_array)?;
144
145        let n_samples = x_array.nrows();
146        let n_features = x_array.ncols();
147
148        // Compute mean
149        let mean = if self.with_mean {
150            x_array
151                .mean_axis(Axis(0))
152                .expect("array should have elements for mean computation")
153        } else {
154            Array1::zeros(n_features)
155        };
156
157        // Compute variance and scale
158        let (var, scale) = if self.with_std {
159            // Calculate variance: E[(X - mean)^2]
160            let mut var = Array1::zeros(n_features);
161            for j in 0..n_features {
162                let col = x_array.column(j);
163                let mean_j = mean[j];
164                let sum_sq_diff: f64 = col.iter().map(|&x| (x - mean_j).powi(2)).sum();
165                var[j] = sum_sq_diff / n_samples as f64;
166            }
167
168            // Calculate scale (std dev), but avoid division by zero
169            let scale = var.mapv(|v| {
170                let std = v.sqrt();
171                if std < 1e-10 {
172                    1.0 // Avoid division by zero
173                } else {
174                    std
175                }
176            });
177
178            (var, scale)
179        } else {
180            (Array1::ones(n_features), Array1::ones(n_features))
181        };
182
183        self.state = Some(StandardScalerState {
184            mean,
185            scale,
186            var,
187            n_features,
188            n_samples_seen: n_samples,
189        });
190
191        Ok(())
192    }
193
194    /// Perform standardization by centering and scaling.
195    ///
196    /// Parameters
197    /// ----------
198    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
199    ///     The data used to scale along the features axis.
200    ///
201    /// copy : bool, default=None
202    ///     Copy the input X or not.
203    ///
204    /// Returns
205    /// -------
206    /// X_tr : {ndarray, sparse matrix} of shape (n_samples, n_features)
207    ///     Transformed array.
208    fn transform<'py>(
209        &self,
210        py: Python<'py>,
211        x: PyReadonlyArray2<f64>,
212    ) -> PyResult<Py<PyArray2<f64>>> {
213        let state = self
214            .state
215            .as_ref()
216            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
217
218        let x_array = pyarray_to_core_array2(&x)?;
219        validate_transform_array(&x_array, state.n_features)?;
220
221        let mut transformed = x_array.clone();
222
223        // Center the data
224        if self.with_mean {
225            for j in 0..state.n_features {
226                for i in 0..transformed.nrows() {
227                    transformed[[i, j]] -= state.mean[j];
228                }
229            }
230        }
231
232        // Scale the data
233        if self.with_std {
234            for j in 0..state.n_features {
235                for i in 0..transformed.nrows() {
236                    transformed[[i, j]] /= state.scale[j];
237                }
238            }
239        }
240
241        core_array2_to_py(py, &transformed)
242    }
243
244    /// Fit to data, then transform it.
245    ///
246    /// Fits transformer to `X` and returns a transformed version of `X`.
247    ///
248    /// Parameters
249    /// ----------
250    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
251    ///     Input samples.
252    ///
253    /// y :  array-like of shape (n_samples,) or (n_samples, n_outputs), default=None
254    ///     Target values (None for unsupervised transformations).
255    ///
256    /// Returns
257    /// -------
258    /// X_new : ndarray array of shape (n_samples, n_features_new)
259    ///     Transformed array.
260    fn fit_transform<'py>(
261        &mut self,
262        py: Python<'py>,
263        x: PyReadonlyArray2<f64>,
264    ) -> PyResult<Py<PyArray2<f64>>> {
265        // Create copy of x for transform since fit consumes x
266        let x_array = pyarray_to_core_array2(&x)?;
267        self.fit(x)?;
268
269        // Transform using the saved x_array
270        let state = self
271            .state
272            .as_ref()
273            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
274
275        let mut transformed = x_array.clone();
276
277        // Center the data
278        if self.with_mean {
279            for j in 0..state.n_features {
280                for i in 0..transformed.nrows() {
281                    transformed[[i, j]] -= state.mean[j];
282                }
283            }
284        }
285
286        // Scale the data
287        if self.with_std {
288            for j in 0..state.n_features {
289                for i in 0..transformed.nrows() {
290                    transformed[[i, j]] /= state.scale[j];
291                }
292            }
293        }
294
295        core_array2_to_py(py, &transformed)
296    }
297
298    /// Scale back the data to the original representation.
299    ///
300    /// Parameters
301    /// ----------
302    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
303    ///     The data used to scale along the features axis.
304    ///
305    /// copy : bool, default=None
306    ///     Copy the input X or not.
307    ///
308    /// Returns
309    /// -------
310    /// X_tr : {ndarray, sparse matrix} of shape (n_samples, n_features)
311    ///     Transformed array.
312    fn inverse_transform<'py>(
313        &self,
314        py: Python<'py>,
315        x: PyReadonlyArray2<f64>,
316    ) -> PyResult<Py<PyArray2<f64>>> {
317        let state = self
318            .state
319            .as_ref()
320            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
321
322        let x_array = pyarray_to_core_array2(&x)?;
323        validate_transform_array(&x_array, state.n_features)?;
324
325        let mut inverse = x_array.clone();
326
327        // Reverse scaling
328        if self.with_std {
329            for j in 0..state.n_features {
330                for i in 0..inverse.nrows() {
331                    inverse[[i, j]] *= state.scale[j];
332                }
333            }
334        }
335
336        // Reverse centering
337        if self.with_mean {
338            for j in 0..state.n_features {
339                for i in 0..inverse.nrows() {
340                    inverse[[i, j]] += state.mean[j];
341                }
342            }
343        }
344
345        core_array2_to_py(py, &inverse)
346    }
347
348    /// The mean value for each feature in the training set.
349    #[getter]
350    fn mean_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
351        let state = self
352            .state
353            .as_ref()
354            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
355
356        Ok(core_array1_to_py(py, &state.mean))
357    }
358
359    /// Per feature relative scaling of the data.
360    #[getter]
361    fn scale_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
362        let state = self
363            .state
364            .as_ref()
365            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
366
367        Ok(core_array1_to_py(py, &state.scale))
368    }
369
370    /// The variance for each feature in the training set.
371    #[getter]
372    fn var_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
373        let state = self
374            .state
375            .as_ref()
376            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
377
378        Ok(core_array1_to_py(py, &state.var))
379    }
380
381    /// Number of features seen during fit.
382    #[getter]
383    fn n_features_in_(&self) -> PyResult<usize> {
384        let state = self
385            .state
386            .as_ref()
387            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
388
389        Ok(state.n_features)
390    }
391
392    /// The number of samples processed by the estimator.
393    #[getter]
394    fn n_samples_seen_(&self) -> PyResult<usize> {
395        let state = self
396            .state
397            .as_ref()
398            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
399
400        Ok(state.n_samples_seen)
401    }
402
403    /// String representation
404    fn __repr__(&self) -> String {
405        format!(
406            "StandardScaler(copy={}, with_mean={}, with_std={})",
407            self.copy, self.with_mean, self.with_std
408        )
409    }
410}