Skip to main content

rill_ml/preprocessing/
constant_imputer.rs

1//! Constant value imputer for missing data.
2//!
3//! Replaces `NaN` values with a fixed fill value. Non-NaN values are
4//! passed through unchanged. This imputer has no learnable state.
5
6use crate::error::{RillError, checked_increment, ensure_finite};
7use crate::traits::Transformer;
8
9/// Configuration for [`ConstantImputer`].
10#[derive(Debug, Clone)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct ConstantImputerConfig {
13    /// The value to replace NaN with.
14    pub fill_value: f64,
15}
16
17impl Default for ConstantImputerConfig {
18    fn default() -> Self {
19        Self { fill_value: 0.0 }
20    }
21}
22
23/// Replaces `NaN` values with a constant.
24///
25/// This transformer accepts `NaN` in its input, unlike most other
26/// transformers. The fill value must be finite.
27#[derive(Debug, Clone)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct ConstantImputer {
30    feature_count: usize,
31    config: ConstantImputerConfig,
32    samples_seen: u64,
33}
34
35impl ConstantImputer {
36    /// Create a new imputer for `feature_count` features with default config.
37    ///
38    /// # Errors
39    /// Returns [`RillError::EmptyFeatures`] if `feature_count` is `0`.
40    pub fn new(feature_count: usize) -> Result<Self, RillError> {
41        Self::with_config(feature_count, ConstantImputerConfig::default())
42    }
43
44    /// Create a new imputer with a custom configuration.
45    ///
46    /// # Errors
47    /// Returns [`RillError::EmptyFeatures`] if `feature_count` is `0`.
48    /// Returns [`RillError::NonFiniteValue`] if `fill_value` is not finite.
49    pub fn with_config(
50        feature_count: usize,
51        config: ConstantImputerConfig,
52    ) -> Result<Self, RillError> {
53        if feature_count == 0 {
54            return Err(RillError::EmptyFeatures);
55        }
56        ensure_finite("fill_value", config.fill_value)?;
57        Ok(Self {
58            feature_count,
59            config,
60            samples_seen: 0,
61        })
62    }
63
64    /// The fill value used to replace NaN.
65    pub fn fill_value(&self) -> f64 {
66        self.config.fill_value
67    }
68
69    /// Validate only the dimension, allowing NaN values.
70    fn check_dimension(&self, features: &[f64]) -> Result<(), RillError> {
71        if features.is_empty() {
72            return Err(RillError::EmptyFeatures);
73        }
74        if features.len() != self.feature_count {
75            return Err(RillError::DimensionMismatch {
76                expected: self.feature_count,
77                actual: features.len(),
78            });
79        }
80        Ok(())
81    }
82}
83
84impl Transformer for ConstantImputer {
85    fn input_dim(&self) -> usize {
86        self.feature_count
87    }
88
89    fn output_dim(&self) -> usize {
90        self.feature_count
91    }
92
93    fn transform(&self, features: &[f64]) -> Result<Vec<f64>, RillError> {
94        self.check_dimension(features)?;
95        let mut out = Vec::with_capacity(features.len());
96        for &x in features {
97            if x.is_nan() {
98                out.push(self.config.fill_value);
99            } else {
100                ensure_finite("feature", x)?;
101                out.push(x);
102            }
103        }
104        Ok(out)
105    }
106
107    fn update(&mut self, features: &[f64]) -> Result<(), RillError> {
108        self.check_dimension(features)?;
109        for &x in features {
110            if !x.is_nan() {
111                ensure_finite("feature", x)?;
112            }
113        }
114        self.samples_seen = checked_increment(self.samples_seen, "samples_seen")?;
115        Ok(())
116    }
117
118    fn samples_seen(&self) -> u64 {
119        self.samples_seen
120    }
121
122    fn reset(&mut self) {
123        self.samples_seen = 0;
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn nan_replaced_with_fill_value() {
133        let imp = ConstantImputer::new(3).unwrap();
134        let out = imp.transform(&[1.0, f64::NAN, 3.0]).unwrap();
135        assert_eq!(out, vec![1.0, 0.0, 3.0]);
136    }
137
138    #[test]
139    fn non_nan_passed_through() {
140        let imp = ConstantImputer::new(3).unwrap();
141        let out = imp.transform(&[1.5, -2.0, 3.0]).unwrap();
142        assert_eq!(out, vec![1.5, -2.0, 3.0]);
143    }
144
145    #[test]
146    fn custom_fill_value() {
147        let imp =
148            ConstantImputer::with_config(2, ConstantImputerConfig { fill_value: -1.0 }).unwrap();
149        let out = imp.transform(&[f64::NAN, 5.0]).unwrap();
150        assert_eq!(out, vec![-1.0, 5.0]);
151    }
152
153    #[test]
154    fn dimension_mismatch_rejected() {
155        let imp = ConstantImputer::new(3).unwrap();
156        assert!(matches!(
157            imp.transform(&[1.0, 2.0]),
158            Err(RillError::DimensionMismatch { .. })
159        ));
160        let mut imp = imp;
161        assert!(matches!(
162            imp.update(&[1.0, 2.0, 3.0, 4.0]),
163            Err(RillError::DimensionMismatch { .. })
164        ));
165    }
166
167    #[test]
168    fn reset_clears_state() {
169        let mut imp = ConstantImputer::new(2).unwrap();
170        imp.update(&[1.0, f64::NAN]).unwrap();
171        imp.update(&[f64::NAN, 2.0]).unwrap();
172        assert_eq!(imp.samples_seen(), 2);
173        imp.reset();
174        assert_eq!(imp.samples_seen(), 0);
175    }
176
177    #[test]
178    #[cfg(feature = "serde")]
179    fn serde_roundtrip() {
180        let mut imp =
181            ConstantImputer::with_config(3, ConstantImputerConfig { fill_value: 7.0 }).unwrap();
182        imp.update(&[1.0, f64::NAN, 3.0]).unwrap();
183        let json = serde_json::to_string(&imp).unwrap();
184        let restored: ConstantImputer = serde_json::from_str(&json).unwrap();
185        assert_eq!(restored.input_dim(), imp.input_dim());
186        assert_eq!(restored.output_dim(), imp.output_dim());
187        assert_eq!(restored.samples_seen(), imp.samples_seen());
188        assert_eq!(restored.fill_value(), imp.fill_value());
189    }
190}