Skip to main content

rill_ml/preprocessing/
ordinal.rs

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