Skip to main content

sklears_python/preprocessing/
minmax_scaler.rs

1//! Python bindings for MinMaxScaler
2//!
3//! This module provides Python bindings for MinMaxScaler,
4//! offering scikit-learn compatible min-max normalization.
5
6use super::common::*;
7use scirs2_core::ndarray::Array1;
8
9/// MinMaxScaler state after fitting
10#[derive(Debug, Clone)]
11struct MinMaxScalerState {
12    data_min: Array1<f64>,
13    data_max: Array1<f64>,
14    data_range: Array1<f64>,
15    scale: Array1<f64>,
16    min_: Array1<f64>,
17    n_features: usize,
18    n_samples_seen: usize,
19    feature_range: (f64, f64),
20}
21
22/// Transform features by scaling each feature to a given range.
23///
24/// This estimator scales and translates each feature individually such
25/// that it is in the given range on the training set, e.g. between
26/// zero and one.
27///
28/// The transformation is given by:
29///
30/// ```text
31/// X_std = (X - X.min(axis=0)) / (X.max(axis=0) - X.min(axis=0))
32/// X_scaled = X_std * (max - min) + min
33/// ```
34///
35/// where min, max = feature_range.
36///
37/// This transformation is often used as an alternative to zero mean,
38/// unit variance scaling.
39///
40/// Parameters
41/// ----------
42/// feature_range : tuple (min, max), default=(0, 1)
43///     Desired range of transformed data.
44///
45/// copy : bool, default=True
46///     Set to False to perform inplace row normalization and avoid a
47///     copy (if the input is already a numpy array).
48///
49/// clip : bool, default=False
50///     Set to True to clip transformed values of held-out data to
51///     provided `feature range`.
52///
53/// Attributes
54/// ----------
55/// min_ : ndarray of shape (n_features,)
56///     Per feature adjustment for minimum. Equivalent to
57///     ``min - X.min(axis=0) * self.scale_``
58///
59/// scale_ : ndarray of shape (n_features,)
60///     Per feature relative scaling of the data. Equivalent to
61///     ``(max - min) / (X.max(axis=0) - X.min(axis=0))``
62///
63/// data_min_ : ndarray of shape (n_features,)
64///     Per feature minimum seen in the data
65///
66/// data_max_ : ndarray of shape (n_features,)
67///     Per feature maximum seen in the data
68///
69/// data_range_ : ndarray of shape (n_features,)
70///     Per feature range ``(data_max_ - data_min_)`` seen in the data
71///
72/// n_features_in_ : int
73///     Number of features seen during :term:`fit`.
74///
75/// n_samples_seen_ : int
76///     The number of samples processed by the estimator.
77///     It will be reset on new calls to fit, but increments across
78///     ``partial_fit`` calls.
79///
80/// Examples
81/// --------
82/// >>> from sklears_python import MinMaxScaler
83/// >>> import numpy as np
84/// >>> data = [[-1, 2], [-0.5, 6], [0, 10], [1, 18]]
85/// >>> scaler = MinMaxScaler()
86/// >>> scaler.fit(data)
87/// MinMaxScaler()
88/// >>> print(scaler.data_max_)
89/// [ 1. 18.]
90/// >>> print(scaler.transform(data))
91/// [[0.   0.  ]
92///  [0.25 0.25]
93///  [0.5  0.5 ]
94///  [1.   1.  ]]
95/// >>> print(scaler.transform([[2, 2]]))
96/// [[1.5 0. ]]
97#[pyclass(name = "MinMaxScaler")]
98pub struct PyMinMaxScaler {
99    feature_range: (f64, f64),
100    copy: bool,
101    clip: bool,
102    state: Option<MinMaxScalerState>,
103}
104
105#[pymethods]
106impl PyMinMaxScaler {
107    #[new]
108    #[pyo3(signature = (feature_range=(0.0, 1.0), copy=true, clip=false))]
109    fn new(feature_range: (f64, f64), copy: bool, clip: bool) -> Self {
110        Self {
111            feature_range,
112            copy,
113            clip,
114            state: None,
115        }
116    }
117
118    /// Compute the minimum and maximum to be used for later scaling.
119    ///
120    /// Parameters
121    /// ----------
122    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
123    ///     The data used to compute the per-feature minimum and maximum
124    ///     used for later scaling along the features axis.
125    ///
126    /// y : None
127    ///     Ignored.
128    ///
129    /// Returns
130    /// -------
131    /// self : object
132    ///     Fitted scaler.
133    fn fit(&mut self, x: PyReadonlyArray2<f64>) -> PyResult<()> {
134        let x_array = pyarray_to_core_array2(&x)?;
135        validate_fit_array(&x_array)?;
136
137        let n_samples = x_array.nrows();
138        let n_features = x_array.ncols();
139
140        // Compute min and max for each feature
141        let mut data_min = Array1::zeros(n_features);
142        let mut data_max = Array1::zeros(n_features);
143
144        for j in 0..n_features {
145            let col = x_array.column(j);
146            data_min[j] = col.iter().cloned().fold(f64::INFINITY, |a, b| a.min(b));
147            data_max[j] = col.iter().cloned().fold(f64::NEG_INFINITY, |a, b| a.max(b));
148        }
149
150        // Compute data range
151        let data_range = &data_max - &data_min;
152
153        // Compute scale and min_
154        let (feature_min, feature_max) = self.feature_range;
155        let feature_range = feature_max - feature_min;
156
157        let mut scale = Array1::zeros(n_features);
158        let mut min_ = Array1::zeros(n_features);
159
160        for j in 0..n_features {
161            if data_range[j].abs() < 1e-10 {
162                // Handle constant features
163                scale[j] = 1.0;
164                min_[j] = feature_min - data_min[j];
165            } else {
166                scale[j] = feature_range / data_range[j];
167                min_[j] = feature_min - data_min[j] * scale[j];
168            }
169        }
170
171        self.state = Some(MinMaxScalerState {
172            data_min,
173            data_max,
174            data_range,
175            scale,
176            min_,
177            n_features,
178            n_samples_seen: n_samples,
179            feature_range: self.feature_range,
180        });
181
182        Ok(())
183    }
184
185    /// Scale features of X according to feature_range.
186    ///
187    /// Parameters
188    /// ----------
189    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
190    ///     Input data that will be transformed.
191    ///
192    /// Returns
193    /// -------
194    /// Xt : ndarray of shape (n_samples, n_features)
195    ///     Transformed data.
196    fn transform<'py>(
197        &self,
198        py: Python<'py>,
199        x: PyReadonlyArray2<f64>,
200    ) -> PyResult<Py<PyArray2<f64>>> {
201        let state = self
202            .state
203            .as_ref()
204            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
205
206        let x_array = pyarray_to_core_array2(&x)?;
207        validate_transform_array(&x_array, state.n_features)?;
208
209        let mut transformed = x_array.clone();
210
211        // Apply scaling: X_scaled = X * scale + min_
212        for j in 0..state.n_features {
213            for i in 0..transformed.nrows() {
214                transformed[[i, j]] = transformed[[i, j]] * state.scale[j] + state.min_[j];
215
216                // Clip values if requested
217                if self.clip {
218                    let (min_val, max_val) = state.feature_range;
219                    transformed[[i, j]] = transformed[[i, j]].clamp(min_val, max_val);
220                }
221            }
222        }
223
224        core_array2_to_py(py, &transformed)
225    }
226
227    /// Fit to data, then transform it.
228    ///
229    /// Fits transformer to `X` and returns a transformed version of `X`.
230    ///
231    /// Parameters
232    /// ----------
233    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
234    ///     Input samples.
235    ///
236    /// y :  array-like of shape (n_samples,) or (n_samples, n_outputs), default=None
237    ///     Target values (None for unsupervised transformations).
238    ///
239    /// Returns
240    /// -------
241    /// X_new : ndarray array of shape (n_samples, n_features_new)
242    ///     Transformed array.
243    fn fit_transform<'py>(
244        &mut self,
245        py: Python<'py>,
246        x: PyReadonlyArray2<f64>,
247    ) -> PyResult<Py<PyArray2<f64>>> {
248        let x_array = pyarray_to_core_array2(&x)?;
249        self.fit(x)?;
250
251        // Transform using the saved x_array
252        let state = self
253            .state
254            .as_ref()
255            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
256
257        let mut transformed = x_array.clone();
258
259        // Apply scaling: X_scaled = X * scale + min_
260        for j in 0..state.n_features {
261            for i in 0..transformed.nrows() {
262                transformed[[i, j]] = transformed[[i, j]] * state.scale[j] + state.min_[j];
263
264                // Clip values if requested
265                if self.clip {
266                    let (min_val, max_val) = state.feature_range;
267                    transformed[[i, j]] = transformed[[i, j]].clamp(min_val, max_val);
268                }
269            }
270        }
271
272        core_array2_to_py(py, &transformed)
273    }
274
275    /// Undo the scaling of X according to feature_range.
276    ///
277    /// Parameters
278    /// ----------
279    /// X : {array-like, sparse matrix} of shape (n_samples, n_features)
280    ///     Input data that will be transformed. It cannot be sparse.
281    ///
282    /// Returns
283    /// -------
284    /// Xt : ndarray of shape (n_samples, n_features)
285    ///     Transformed data.
286    fn inverse_transform<'py>(
287        &self,
288        py: Python<'py>,
289        x: PyReadonlyArray2<f64>,
290    ) -> PyResult<Py<PyArray2<f64>>> {
291        let state = self
292            .state
293            .as_ref()
294            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
295
296        let x_array = pyarray_to_core_array2(&x)?;
297        validate_transform_array(&x_array, state.n_features)?;
298
299        let mut inverse = x_array.clone();
300
301        // Reverse scaling: X = (X_scaled - min_) / scale
302        for j in 0..state.n_features {
303            for i in 0..inverse.nrows() {
304                inverse[[i, j]] = (inverse[[i, j]] - state.min_[j]) / state.scale[j];
305            }
306        }
307
308        core_array2_to_py(py, &inverse)
309    }
310
311    /// Per feature minimum seen in the data
312    #[getter]
313    fn data_min_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
314        let state = self
315            .state
316            .as_ref()
317            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
318
319        Ok(core_array1_to_py(py, &state.data_min))
320    }
321
322    /// Per feature maximum seen in the data
323    #[getter]
324    fn data_max_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
325        let state = self
326            .state
327            .as_ref()
328            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
329
330        Ok(core_array1_to_py(py, &state.data_max))
331    }
332
333    /// Per feature range (data_max_ - data_min_) seen in the data
334    #[getter]
335    fn data_range_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
336        let state = self
337            .state
338            .as_ref()
339            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
340
341        Ok(core_array1_to_py(py, &state.data_range))
342    }
343
344    /// Per feature relative scaling of the data
345    #[getter]
346    fn scale_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
347        let state = self
348            .state
349            .as_ref()
350            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
351
352        Ok(core_array1_to_py(py, &state.scale))
353    }
354
355    /// Per feature adjustment for minimum
356    #[getter]
357    fn min_<'py>(&self, py: Python<'py>) -> PyResult<Py<PyArray1<f64>>> {
358        let state = self
359            .state
360            .as_ref()
361            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
362
363        Ok(core_array1_to_py(py, &state.min_))
364    }
365
366    /// Number of features seen during fit.
367    #[getter]
368    fn n_features_in_(&self) -> PyResult<usize> {
369        let state = self
370            .state
371            .as_ref()
372            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
373
374        Ok(state.n_features)
375    }
376
377    /// The number of samples processed by the estimator.
378    #[getter]
379    fn n_samples_seen_(&self) -> PyResult<usize> {
380        let state = self
381            .state
382            .as_ref()
383            .ok_or_else(|| PyValueError::new_err("Scaler not fitted. Call fit() first."))?;
384
385        Ok(state.n_samples_seen)
386    }
387
388    /// String representation
389    fn __repr__(&self) -> String {
390        format!(
391            "MinMaxScaler(feature_range=({}, {}), copy={}, clip={})",
392            self.feature_range.0, self.feature_range.1, self.copy, self.clip
393        )
394    }
395}