Skip to main content

rill_ml/preprocessing/
one_hot.rs

1//! Online one-hot encoder for categorical string features.
2//!
3//! Categories are discovered online: `update_strs` adds new categories
4//! to the mapping, `transform_strs` produces a one-hot vector using
5//! the current mapping.
6
7use crate::error::RillError;
8#[cfg(feature = "serde")]
9use crate::persistence::ValidateState;
10
11/// Online one-hot encoder for string features.
12///
13/// Categories are discovered incrementally via [`update_strs`](Self::update_strs).
14/// Before any category is seen, `transform_strs` returns an empty vector.
15#[derive(Debug, Clone, Default)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct OneHotEncoder {
18    categories: Vec<String>,
19    samples_seen: u64,
20}
21
22impl OneHotEncoder {
23    /// Create a new empty encoder.
24    pub fn new() -> Self {
25        Self {
26            categories: Vec::new(),
27            samples_seen: 0,
28        }
29    }
30
31    /// The known categories, kept in sorted order.
32    pub fn categories(&self) -> &[String] {
33        &self.categories
34    }
35
36    /// Find the index of `category` using binary search.
37    pub fn category_index(&self, category: &str) -> Option<usize> {
38        self.categories
39            .binary_search_by(|c| c.as_str().cmp(category))
40            .ok()
41    }
42
43    /// Add a category if not already present, keeping the list sorted.
44    pub fn fit_one(&mut self, category: &str) {
45        match self
46            .categories
47            .binary_search_by(|c| c.as_str().cmp(category))
48        {
49            Ok(_) => {}
50            Err(idx) => self.categories.insert(idx, category.to_string()),
51        }
52    }
53
54    /// One-hot encode each string.
55    ///
56    /// Output length = `features.len() * categories.len()`. Each group of
57    /// `categories.len()` consecutive values has a `1.0` at the category
58    /// index and `0.0` elsewhere.
59    ///
60    /// # Errors
61    /// - [`RillError::EmptyFeatures`] if `features` is empty.
62    /// - [`RillError::UnknownCategory`] if any string is not in the known
63    ///   categories.
64    ///
65    /// Before any category has been seen, returns an empty vector.
66    pub fn transform_strs(&self, features: &[&str]) -> Result<Vec<f64>, RillError> {
67        if features.is_empty() {
68            return Err(RillError::EmptyFeatures);
69        }
70        if self.categories.is_empty() {
71            return Ok(Vec::new());
72        }
73        let n_cats = self.categories.len();
74        let mut out = vec![0.0; features.len() * n_cats];
75        for (i, &feat) in features.iter().enumerate() {
76            let idx = self
77                .category_index(feat)
78                .ok_or_else(|| RillError::UnknownCategory(feat.to_string()))?;
79            out[i * n_cats + idx] = 1.0;
80        }
81        Ok(out)
82    }
83
84    /// Add all new categories from `features` and increment `samples_seen`.
85    ///
86    /// # Errors
87    /// Returns [`RillError::EmptyFeatures`] if `features` is empty.
88    pub fn update_strs(&mut self, features: &[&str]) -> Result<(), RillError> {
89        if features.is_empty() {
90            return Err(RillError::EmptyFeatures);
91        }
92        for &feat in features {
93            self.fit_one(feat);
94        }
95        self.samples_seen += 1;
96        Ok(())
97    }
98
99    /// How many samples have been seen.
100    pub fn samples_seen(&self) -> u64 {
101        self.samples_seen
102    }
103
104    /// Reset the encoder to its initial empty state.
105    pub fn reset(&mut self) {
106        self.categories.clear();
107        self.samples_seen = 0;
108    }
109}
110
111#[cfg(feature = "serde")]
112impl ValidateState for OneHotEncoder {
113    fn validate_state(&self) -> Result<(), RillError> {
114        // `fit_one` maintains `categories` in strictly ascending order with
115        // no duplicates via binary search. A violation indicates a corrupted
116        // or maliciously crafted serde payload.
117        for window in self.categories.windows(2) {
118            if window[0] >= window[1] {
119                return Err(RillError::InvalidState(
120                    "one-hot encoder categories must be strictly sorted".to_owned(),
121                ));
122            }
123        }
124        Ok(())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn known_categories_encode_correctly() {
134        let mut enc = OneHotEncoder::new();
135        enc.update_strs(&["b", "a", "c"]).unwrap();
136        // categories are sorted: ["a", "b", "c"]
137        let out = enc.transform_strs(&["a"]).unwrap();
138        assert_eq!(out, vec![1.0, 0.0, 0.0]);
139        let out = enc.transform_strs(&["c"]).unwrap();
140        assert_eq!(out, vec![0.0, 0.0, 1.0]);
141    }
142
143    #[test]
144    fn unknown_category_rejected() {
145        let mut enc = OneHotEncoder::new();
146        enc.update_strs(&["a", "b"]).unwrap();
147        assert!(matches!(
148            enc.transform_strs(&["z"]),
149            Err(RillError::UnknownCategory(_))
150        ));
151    }
152
153    #[test]
154    fn new_category_added_on_update() {
155        let mut enc = OneHotEncoder::new();
156        enc.update_strs(&["a"]).unwrap();
157        assert_eq!(enc.categories(), &["a"]);
158        enc.update_strs(&["b"]).unwrap();
159        assert_eq!(enc.categories(), &["a", "b"]);
160    }
161
162    #[test]
163    fn multiple_features_produce_concatenated_vectors() {
164        let mut enc = OneHotEncoder::new();
165        enc.update_strs(&["a", "b"]).unwrap();
166        // two features, two categories -> length 4
167        let out = enc.transform_strs(&["a", "b"]).unwrap();
168        assert_eq!(out, vec![1.0, 0.0, 0.0, 1.0]);
169    }
170
171    #[test]
172    fn reset_clears_state() {
173        let mut enc = OneHotEncoder::new();
174        enc.update_strs(&["a", "b"]).unwrap();
175        enc.reset();
176        assert!(enc.categories().is_empty());
177        assert_eq!(enc.samples_seen(), 0);
178    }
179
180    #[test]
181    fn samples_seen_tracks_updates() {
182        let mut enc = OneHotEncoder::new();
183        assert_eq!(enc.samples_seen(), 0);
184        enc.update_strs(&["a"]).unwrap();
185        assert_eq!(enc.samples_seen(), 1);
186        enc.update_strs(&["b", "c"]).unwrap();
187        assert_eq!(enc.samples_seen(), 2);
188    }
189
190    #[test]
191    fn empty_input_rejected() {
192        let mut enc = OneHotEncoder::new();
193        assert!(matches!(
194            enc.update_strs(&[]),
195            Err(RillError::EmptyFeatures)
196        ));
197        assert!(matches!(
198            enc.transform_strs(&[]),
199            Err(RillError::EmptyFeatures)
200        ));
201    }
202
203    #[test]
204    fn empty_categories_returns_empty_vec() {
205        let enc = OneHotEncoder::new();
206        // no categories seen yet -> empty vec, not an error
207        let out = enc.transform_strs(&["a"]).unwrap();
208        assert!(out.is_empty());
209    }
210
211    #[test]
212    #[cfg(feature = "serde")]
213    fn serde_roundtrip() {
214        let mut enc = OneHotEncoder::new();
215        enc.update_strs(&["b", "a", "c"]).unwrap();
216        let json = serde_json::to_string(&enc).unwrap();
217        let restored: OneHotEncoder = serde_json::from_str(&json).unwrap();
218        assert_eq!(restored.categories(), enc.categories());
219        assert_eq!(restored.samples_seen(), enc.samples_seen());
220    }
221}