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}