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