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}