Skip to main content

rill_ml/preprocessing/
standard_scaler.rs

1//! Online standard scaler.
2//!
3//! Maintains per-feature Welford variance and mean. Time complexity per
4//! update/transform: `O(d)`. Space complexity: `O(d)`.
5
6use crate::error::{RillError, checked_increment, ensure_finite, validate_features};
7use crate::traits::Transformer;
8
9/// Configuration for [`StandardScaler`].
10#[derive(Debug, Clone)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct StandardScalerConfig {
13    /// Whether to subtract the running mean. Default: `true`.
14    pub with_mean: bool,
15    /// Whether to divide by the running standard deviation. Default: `true`.
16    pub with_std: bool,
17    /// Variance threshold below which the scale is treated as `1.0` to avoid
18    /// division by zero. Default: `1e-12`.
19    pub epsilon: f64,
20}
21
22impl Default for StandardScalerConfig {
23    fn default() -> Self {
24        Self {
25            with_mean: true,
26            with_std: true,
27            epsilon: 1e-12,
28        }
29    }
30}
31
32/// Online standard scaler that standardizes features to approximately zero
33/// mean and unit variance.
34///
35/// - When `with_mean = false`, the mean subtraction is skipped.
36/// - When `with_std = false`, the scaling is skipped.
37/// - When a feature has seen zero samples, its mean is `0` and scale is `1`,
38///   so the original value is returned unchanged.
39/// - When a feature's variance is below `epsilon`, the scale is `1` to avoid
40///   NaN or Infinity.
41///
42/// `transform` does not update state; only `update` does.
43#[derive(Debug, Clone)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize))]
45pub struct StandardScaler {
46    feature_count: usize,
47    config: StandardScalerConfig,
48    counts: Vec<u64>,
49    means: Vec<f64>,
50    m2s: Vec<f64>,
51}
52
53impl StandardScaler {
54    /// Create a new scaler for `feature_count` features with default config.
55    pub fn new(feature_count: usize) -> Result<Self, RillError> {
56        Self::with_config(feature_count, StandardScalerConfig::default())
57    }
58
59    /// Create a new scaler with a custom configuration.
60    pub fn with_config(
61        feature_count: usize,
62        config: StandardScalerConfig,
63    ) -> Result<Self, RillError> {
64        if feature_count == 0 {
65            return Err(RillError::EmptyFeatures);
66        }
67        ensure_finite("epsilon", config.epsilon)?;
68        if config.epsilon < 0.0 {
69            return Err(RillError::InvalidParameter {
70                name: "epsilon",
71                value: config.epsilon,
72            });
73        }
74        Ok(Self {
75            feature_count,
76            config,
77            counts: vec![0; feature_count],
78            means: vec![0.0; feature_count],
79            m2s: vec![0.0; feature_count],
80        })
81    }
82
83    /// The per-feature means.
84    pub fn means(&self) -> &[f64] {
85        &self.means
86    }
87
88    /// The per-feature variances (population).
89    pub fn variances(&self) -> Vec<f64> {
90        self.m2s
91            .iter()
92            .zip(&self.counts)
93            .map(|(&m2, &n)| if n == 0 { 0.0 } else { m2 / n as f64 })
94            .collect()
95    }
96
97    /// The per-feature standard deviations.
98    pub fn std_devs(&self) -> Vec<f64> {
99        self.variances().iter().map(|v| v.sqrt()).collect()
100    }
101
102    /// The per-feature scales used during transformation.
103    pub fn scales(&self) -> Vec<f64> {
104        self.variances()
105            .iter()
106            .map(|&var| {
107                if var < self.config.epsilon {
108                    1.0
109                } else {
110                    var.sqrt()
111                }
112            })
113            .collect()
114    }
115
116    /// Validate all configuration and persisted-state invariants.
117    ///
118    /// This is run automatically during deserialization and before operations
119    /// that index per-feature state. The hot path (`transform_into` /
120    /// `transform`) relies on the invariants established here and by the
121    /// private constructor, and only re-checks them under `debug_assert!`.
122    pub fn validate(&self) -> Result<(), RillError> {
123        if self.feature_count == 0 {
124            return Err(RillError::EmptyFeatures);
125        }
126        ensure_finite("epsilon", self.config.epsilon)?;
127        if self.config.epsilon < 0.0 {
128            return Err(RillError::InvalidParameter {
129                name: "epsilon",
130                value: self.config.epsilon,
131            });
132        }
133        if self.counts.len() != self.feature_count
134            || self.means.len() != self.feature_count
135            || self.m2s.len() != self.feature_count
136        {
137            return Err(RillError::InvalidState(
138                "standard scaler feature_count does not match state lengths".to_owned(),
139            ));
140        }
141        if self.means.iter().any(|value| !value.is_finite())
142            || self.m2s.iter().any(|value| !value.is_finite())
143        {
144            return Err(RillError::InvalidState(
145                "standard scaler state must contain only finite values".to_owned(),
146            ));
147        }
148        if self.counts.windows(2).any(|pair| pair[0] != pair[1]) {
149            return Err(RillError::InvalidState(
150                "standard scaler feature counts must stay synchronized".to_owned(),
151            ));
152        }
153        Ok(())
154    }
155
156    /// Transform `features` into the provided `output` buffer, reusing its
157    /// allocation instead of allocating a fresh `Vec` on every call.
158    ///
159    /// This is the hot-path entry point. Compared to [`transform`](Transformer::transform)
160    /// it avoids two temporary allocations (the `variances()` and `scales()`
161    /// vectors) by fusing the scale computation into the single output loop.
162    /// The trust-boundary checks (dimension validation and finite-output
163    /// enforcement) are preserved; the internal-state invariants established
164    /// by [`validate`](Self::validate) and the private constructor are only
165    /// re-checked under `debug_assert!` because they are guaranteed by the
166    /// private fields and the validated deserialization path.
167    ///
168    /// `output` is truncated to the feature count and then filled; callers
169    /// that reuse the same buffer across iterations avoid allocation
170    /// entirely after the first call.
171    pub fn transform_into(&self, features: &[f64], output: &mut Vec<f64>) -> Result<(), RillError> {
172        // Trust-boundary: dimension must match. This is the public input
173        // boundary and must always be enforced.
174        validate_features(self.feature_count, features)?;
175
176        // Internal invariants are guaranteed by the private constructor and
177        // the validated Deserialize impl; re-check only in debug builds so
178        // release-mode hot paths do not pay for repeated O(d) scans.
179        debug_assert!(
180            self.counts.len() == self.feature_count
181                && self.means.len() == self.feature_count
182                && self.m2s.len() == self.feature_count,
183            "standard scaler state lengths must match feature_count"
184        );
185        debug_assert!(
186            self.counts.windows(2).all(|pair| pair[0] == pair[1]),
187            "standard scaler feature counts must stay synchronized"
188        );
189        debug_assert!(
190            self.means.iter().all(|v| v.is_finite()) && self.m2s.iter().all(|v| v.is_finite()),
191            "standard scaler state must contain only finite values"
192        );
193
194        output.clear();
195        output.reserve(self.feature_count);
196
197        let iter = features
198            .iter()
199            .zip(&self.counts)
200            .zip(&self.means)
201            .zip(&self.m2s);
202        for (((&x, &n), &mean_storage), &m2) in iter {
203            // Population variance = m2 / n; if n == 0 the scale is 1.0 so
204            // the original value is returned unchanged.
205            let scale = if !self.config.with_std || n == 0 {
206                1.0
207            } else {
208                let variance = m2 / n as f64;
209                if variance < self.config.epsilon {
210                    1.0
211                } else {
212                    variance.sqrt()
213                }
214            };
215            let mean = if self.config.with_mean {
216                mean_storage
217            } else {
218                0.0
219            };
220            let transformed = (x - mean) / scale;
221            // Trust-boundary: output must be finite. This catches NaN/Inf
222            // introduced by adversarial input even when internal state is
223            // already validated.
224            ensure_finite("transformed feature", transformed)?;
225            output.push(transformed);
226        }
227        Ok(())
228    }
229}
230
231impl Transformer for StandardScaler {
232    fn input_dim(&self) -> usize {
233        self.feature_count
234    }
235
236    fn output_dim(&self) -> usize {
237        self.feature_count
238    }
239
240    fn transform(&self, features: &[f64]) -> Result<Vec<f64>, RillError> {
241        // Validate the full state on the public Transformer entry point so
242        // a corrupted scaler (e.g. one built via unsafe or a future struct
243        // literal) cannot proceed. The hot path `transform_into` relies on
244        // the same invariants but only re-checks them under `debug_assert!`.
245        self.validate()?;
246        let mut output = Vec::with_capacity(self.feature_count);
247        self.transform_into(features, &mut output)?;
248        Ok(output)
249    }
250
251    fn update(&mut self, features: &[f64]) -> Result<(), RillError> {
252        self.validate()?;
253        validate_features(self.feature_count, features)?;
254        let mut next_counts = self.counts.clone();
255        let mut next_means = self.means.clone();
256        let mut next_m2s = self.m2s.clone();
257        for (i, &x) in features.iter().enumerate() {
258            let count = checked_increment(self.counts[i], "standard scaler sample")?;
259            let delta = x - self.means[i];
260            ensure_finite("standard scaler delta", delta)?;
261            let mean = self.means[i] + delta / count as f64;
262            ensure_finite("standard scaler mean", mean)?;
263            let delta2 = x - mean;
264            ensure_finite("standard scaler delta", delta2)?;
265            let m2 = self.m2s[i] + delta * delta2;
266            ensure_finite("standard scaler M2", m2)?;
267            next_counts[i] = count;
268            next_means[i] = mean;
269            next_m2s[i] = m2;
270        }
271        self.counts = next_counts;
272        self.means = next_means;
273        self.m2s = next_m2s;
274        Ok(())
275    }
276
277    fn samples_seen(&self) -> u64 {
278        self.counts.iter().copied().max().unwrap_or(0)
279    }
280
281    fn reset(&mut self) {
282        for c in &mut self.counts {
283            *c = 0;
284        }
285        for m in &mut self.means {
286            *m = 0.0;
287        }
288        for m2 in &mut self.m2s {
289            *m2 = 0.0;
290        }
291    }
292}
293
294#[cfg(feature = "serde")]
295#[derive(serde::Deserialize)]
296struct StandardScalerState {
297    feature_count: usize,
298    config: StandardScalerConfig,
299    counts: Vec<u64>,
300    means: Vec<f64>,
301    m2s: Vec<f64>,
302}
303
304#[cfg(feature = "serde")]
305impl<'de> serde::Deserialize<'de> for StandardScaler {
306    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
307    where
308        D: serde::Deserializer<'de>,
309    {
310        let state = StandardScalerState::deserialize(deserializer)?;
311        let scaler = Self {
312            feature_count: state.feature_count,
313            config: state.config,
314            counts: state.counts,
315            means: state.means,
316            m2s: state.m2s,
317        };
318        scaler.validate().map_err(serde::de::Error::custom)?;
319        Ok(scaler)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn scaler_zero_state_returns_original() {
329        let s = StandardScaler::new(3).unwrap();
330        let out = s.transform(&[1.0, 2.0, 3.0]).unwrap();
331        // count == 0 -> mean=0, scale=1 -> original
332        assert!((out[0] - 1.0).abs() < 1e-12);
333        assert!((out[1] - 2.0).abs() < 1e-12);
334        assert!((out[2] - 3.0).abs() < 1e-12);
335    }
336
337    #[test]
338    fn scaler_standardizes_after_updates() {
339        let mut s = StandardScaler::new(2).unwrap();
340        // feature 0: values [1, 3] -> mean 2, var 1, std 1
341        // feature 1: values [10, 20] -> mean 15, var 25, std 5
342        s.update(&[1.0, 10.0]).unwrap();
343        s.update(&[3.0, 20.0]).unwrap();
344        let out = s.transform(&[3.0, 20.0]).unwrap();
345        // (3-2)/1 = 1, (20-15)/5 = 1
346        assert!((out[0] - 1.0).abs() < 1e-9);
347        assert!((out[1] - 1.0).abs() < 1e-9);
348    }
349
350    #[test]
351    fn transform_does_not_update_state() {
352        let mut s = StandardScaler::new(1).unwrap();
353        s.update(&[10.0]).unwrap();
354        let mean_before = s.means()[0];
355        let _ = s.transform(&[5.0]).unwrap();
356        assert_eq!(s.means()[0], mean_before);
357        assert_eq!(s.counts[0], 1);
358    }
359
360    #[test]
361    fn update_rejects_overflow_without_mutating_state() {
362        let mut scaler = StandardScaler::new(1).unwrap();
363        scaler.update(&[f64::MAX]).unwrap();
364        let before = scaler.clone();
365        assert!(scaler.update(&[-f64::MAX]).is_err());
366        assert_eq!(scaler.counts, before.counts);
367        assert_eq!(scaler.means, before.means);
368        assert_eq!(scaler.m2s, before.m2s);
369    }
370
371    #[cfg(feature = "serde")]
372    #[test]
373    fn serde_rejects_malformed_state() {
374        let malformed = r#"{
375            "feature_count":2,
376            "config":{"with_mean":true,"with_std":true,"epsilon":1e-12},
377            "counts":[1],
378            "means":[0.0],
379            "m2s":[0.0]
380        }"#;
381        assert!(serde_json::from_str::<StandardScaler>(malformed).is_err());
382    }
383
384    #[test]
385    fn constant_feature_uses_scale_one() {
386        let mut s = StandardScaler::new(1).unwrap();
387        for _ in 0..10 {
388            s.update(&[5.0]).unwrap();
389        }
390        // var = 0 < epsilon -> scale = 1, mean = 5 -> (5-5)/1 = 0
391        let out = s.transform(&[5.0]).unwrap();
392        assert!(out[0].abs() < 1e-12);
393        assert!(!out[0].is_nan());
394    }
395
396    #[test]
397    fn with_mean_false_keeps_offset() {
398        let mut s = StandardScaler::with_config(
399            1,
400            StandardScalerConfig {
401                with_mean: false,
402                with_std: true,
403                epsilon: 1e-12,
404            },
405        )
406        .unwrap();
407        s.update(&[1.0]).unwrap();
408        s.update(&[3.0]).unwrap();
409        // mean=2, var=1, std=1, but with_mean=false so x/1 = x
410        let out = s.transform(&[3.0]).unwrap();
411        assert!((out[0] - 3.0).abs() < 1e-9);
412    }
413
414    #[test]
415    fn dimension_mismatch_rejected() {
416        let mut s = StandardScaler::new(3).unwrap();
417        assert!(s.transform(&[1.0, 2.0]).is_err());
418        assert!(s.update(&[1.0, 2.0]).is_err());
419    }
420
421    #[test]
422    fn zero_features_rejected() {
423        assert!(matches!(
424            StandardScaler::new(0),
425            Err(RillError::EmptyFeatures)
426        ));
427    }
428
429    #[test]
430    fn non_finite_rejected() {
431        let mut s = StandardScaler::new(2).unwrap();
432        assert!(s.update(&[1.0, f64::NAN]).is_err());
433    }
434
435    #[test]
436    fn reset_clears_state() {
437        let mut s = StandardScaler::new(1).unwrap();
438        s.update(&[1.0]).unwrap();
439        s.update(&[2.0]).unwrap();
440        s.reset();
441        assert_eq!(s.counts[0], 0);
442        assert_eq!(s.means()[0], 0.0);
443    }
444
445    #[test]
446    fn transform_into_matches_transform_output() {
447        let mut scaler = StandardScaler::new(4).unwrap();
448        // Feed a few samples so means/variances are non-trivial.
449        scaler.update(&[1.0, 10.0, 100.0, 1000.0]).unwrap();
450        scaler.update(&[3.0, 20.0, 300.0, 3000.0]).unwrap();
451        scaler.update(&[5.0, 30.0, 500.0, 5000.0]).unwrap();
452        let features = [2.0, 15.0, 200.0, 2000.0];
453        let via_transform = scaler.transform(&features).unwrap();
454        let mut via_into = Vec::new();
455        scaler.transform_into(&features, &mut via_into).unwrap();
456        assert_eq!(via_transform, via_into);
457    }
458
459    #[test]
460    fn transform_into_reuses_buffer_capacity() {
461        let mut scaler = StandardScaler::new(3).unwrap();
462        scaler.update(&[1.0, 2.0, 3.0]).unwrap();
463        scaler.update(&[4.0, 5.0, 6.0]).unwrap();
464        let features = [2.5, 3.5, 4.5];
465        let mut buffer = Vec::with_capacity(64);
466        // Prime the buffer with sentinel content to prove clear() is called.
467        buffer.extend_from_slice(&[-1.0, -2.0, -3.0, -4.0]);
468        scaler.transform_into(&features, &mut buffer).unwrap();
469        assert_eq!(buffer.len(), 3);
470        // Capacity must be preserved (no reallocation).
471        assert!(buffer.capacity() >= 64);
472        // Content must match the public transform() output.
473        assert_eq!(buffer, scaler.transform(&features).unwrap());
474    }
475
476    #[test]
477    fn transform_into_rejects_dimension_mismatch() {
478        let scaler = StandardScaler::new(3).unwrap();
479        let mut buffer = Vec::new();
480        assert!(scaler.transform_into(&[1.0, 2.0], &mut buffer).is_err());
481        // Buffer must remain empty after the dimension error.
482        assert!(buffer.is_empty());
483    }
484
485    #[test]
486    fn transform_into_rejects_non_finite_output() {
487        // with_std = false and a non-finite input must still be caught by
488        // the finite-output trust-boundary check.
489        let scaler = StandardScaler::with_config(
490            1,
491            StandardScalerConfig {
492                with_mean: false,
493                with_std: false,
494                epsilon: 1e-12,
495            },
496        )
497        .unwrap();
498        let mut buffer = Vec::new();
499        assert!(scaler.transform_into(&[f64::NAN], &mut buffer).is_err());
500    }
501
502    #[test]
503    fn transform_into_with_zero_state_returns_original() {
504        let scaler = StandardScaler::new(3).unwrap();
505        let features = [1.5, 2.5, 3.5];
506        let mut buffer = Vec::new();
507        scaler.transform_into(&features, &mut buffer).unwrap();
508        // count == 0 → mean = 0, scale = 1 → original values.
509        assert_eq!(buffer, vec![1.5, 2.5, 3.5]);
510    }
511}