Skip to main content

rill_ml/preprocessing/
min_max_scaler.rs

1//! Online min-max scaler.
2//!
3//! Tracks per-feature minimum and maximum. Time complexity per update:
4//! `O(d)`. Space complexity: `O(d)`.
5
6use crate::error::{RillError, ensure_finite, validate_features};
7use crate::traits::Transformer;
8
9/// Online min-max scaler that scales features to a configurable range.
10///
11/// When a feature has not been observed, `transform` returns the original
12/// value. When a feature is constant (min == max), the output is the
13/// midpoint of the target range to avoid NaN.
14#[derive(Debug, Clone)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct MinMaxScaler {
17    feature_count: usize,
18    mins: Vec<Option<f64>>,
19    maxs: Vec<Option<f64>>,
20    target_min: f64,
21    target_max: f64,
22}
23
24impl MinMaxScaler {
25    /// Create a new min-max scaler scaling to `[0, 1]`.
26    pub fn new(feature_count: usize) -> Result<Self, RillError> {
27        Self::with_range(feature_count, 0.0, 1.0)
28    }
29
30    /// Create a new min-max scaler scaling to `[target_min, target_max]`.
31    pub fn with_range(
32        feature_count: usize,
33        target_min: f64,
34        target_max: f64,
35    ) -> Result<Self, RillError> {
36        if feature_count == 0 {
37            return Err(RillError::EmptyFeatures);
38        }
39        ensure_finite("target_min", target_min)?;
40        ensure_finite("target_max", target_max)?;
41        if target_min >= target_max {
42            return Err(RillError::InvalidParameter {
43                name: "target_min",
44                value: target_min,
45            });
46        }
47        Ok(Self {
48            feature_count,
49            mins: vec![None; feature_count],
50            maxs: vec![None; feature_count],
51            target_min,
52            target_max,
53        })
54    }
55
56    /// The per-feature minima (`None` if not yet observed).
57    pub fn mins(&self) -> Vec<Option<f64>> {
58        self.mins.clone()
59    }
60
61    /// The per-feature maxima (`None` if not yet observed).
62    pub fn maxs(&self) -> Vec<Option<f64>> {
63        self.maxs.clone()
64    }
65}
66
67impl Transformer for MinMaxScaler {
68    fn input_dim(&self) -> usize {
69        self.feature_count
70    }
71
72    fn output_dim(&self) -> usize {
73        self.feature_count
74    }
75
76    fn transform(&self, features: &[f64]) -> Result<Vec<f64>, RillError> {
77        validate_features(self.feature_count, features)?;
78        let range = self.target_max - self.target_min;
79        Ok(features
80            .iter()
81            .enumerate()
82            .map(|(i, &x)| {
83                match (self.mins[i], self.maxs[i]) {
84                    (Some(min), Some(max)) => {
85                        if (max - min).abs() < f64::EPSILON {
86                            // constant feature -> return midpoint of target range
87                            self.target_min + range / 2.0
88                        } else {
89                            self.target_min + (x - min) / (max - min) * range
90                        }
91                    }
92                    _ => x,
93                }
94            })
95            .collect())
96    }
97
98    fn update(&mut self, features: &[f64]) -> Result<(), RillError> {
99        validate_features(self.feature_count, features)?;
100        for (i, &x) in features.iter().enumerate() {
101            ensure_finite("feature", x)?;
102            self.mins[i] = Some(match self.mins[i] {
103                None => x,
104                Some(m) => m.min(x),
105            });
106            self.maxs[i] = Some(match self.maxs[i] {
107                None => x,
108                Some(m) => m.max(x),
109            });
110        }
111        Ok(())
112    }
113
114    fn samples_seen(&self) -> u64 {
115        self.mins.iter().filter(|m| m.is_some()).count() as u64
116    }
117
118    fn reset(&mut self) {
119        for m in &mut self.mins {
120            *m = None;
121        }
122        for m in &mut self.maxs {
123            *m = None;
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn minmax_scales_to_zero_one() {
134        let mut s = MinMaxScaler::new(1).unwrap();
135        s.update(&[0.0]).unwrap();
136        s.update(&[10.0]).unwrap();
137        let out = s.transform(&[5.0]).unwrap();
138        assert!((out[0] - 0.5).abs() < 1e-12);
139    }
140
141    #[test]
142    fn constant_feature_returns_midpoint() {
143        let mut s = MinMaxScaler::new(1).unwrap();
144        s.update(&[5.0]).unwrap();
145        s.update(&[5.0]).unwrap();
146        let out = s.transform(&[5.0]).unwrap();
147        assert!((out[0] - 0.5).abs() < 1e-12);
148    }
149
150    #[test]
151    fn custom_range() {
152        let mut s = MinMaxScaler::with_range(1, -1.0, 1.0).unwrap();
153        s.update(&[0.0]).unwrap();
154        s.update(&[10.0]).unwrap();
155        let out = s.transform(&[5.0]).unwrap();
156        assert!((out[0] - 0.0).abs() < 1e-12);
157    }
158
159    #[test]
160    fn invalid_range_rejected() {
161        assert!(MinMaxScaler::with_range(1, 1.0, 1.0).is_err());
162        assert!(MinMaxScaler::with_range(1, 2.0, 1.0).is_err());
163    }
164
165    #[test]
166    fn unobserved_feature_returns_original() {
167        let s = MinMaxScaler::new(1).unwrap();
168        let out = s.transform(&[7.0]).unwrap();
169        assert!((out[0] - 7.0).abs() < 1e-12);
170    }
171}