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, checked_increment};
8#[cfg(feature = "serde")]
9use crate::persistence::ValidateState;
10
11/// Online ordinal encoder for string features.
12///
13/// Categories are discovered incrementally via [`update_strs`](Self::update_strs)
14/// and kept in sorted order. [`transform_strs`](Self::transform_strs) maps each
15/// string to its index in the sorted category list.
16#[derive(Debug, Clone, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct OrdinalEncoder {
19    categories: Vec<String>,
20    samples_seen: u64,
21}
22
23impl OrdinalEncoder {
24    /// Create a new empty encoder.
25    pub fn new() -> Self {
26        Self {
27            categories: Vec::new(),
28            samples_seen: 0,
29        }
30    }
31
32    /// The known categories, kept in sorted order.
33    pub fn categories(&self) -> &[String] {
34        &self.categories
35    }
36
37    /// Find the index of `category` using binary search.
38    pub fn category_index(&self, category: &str) -> Option<usize> {
39        self.categories
40            .binary_search_by(|c| c.as_str().cmp(category))
41            .ok()
42    }
43
44    /// Add a category if not already present, keeping the list sorted.
45    pub fn fit_one(&mut self, category: &str) {
46        match self
47            .categories
48            .binary_search_by(|c| c.as_str().cmp(category))
49        {
50            Ok(_) => {}
51            Err(idx) => self.categories.insert(idx, category.to_string()),
52        }
53    }
54
55    /// Encode each string as its category index (as `f64`).
56    ///
57    /// Output length = `features.len()` (one value per input string).
58    ///
59    /// # Errors
60    /// - [`RillError::EmptyFeatures`] if `features` is empty.
61    /// - [`RillError::UnknownCategory`] if any string is not in the known
62    ///   categories.
63    ///
64    /// Before any category has been seen, returns an empty vector.
65    pub fn transform_strs(&self, features: &[&str]) -> Result<Vec<f64>, RillError> {
66        if features.is_empty() {
67            return Err(RillError::EmptyFeatures);
68        }
69        if self.categories.is_empty() {
70            return Ok(Vec::new());
71        }
72        features
73            .iter()
74            .map(|&feat| {
75                self.category_index(feat)
76                    .map(|idx| idx as f64)
77                    .ok_or_else(|| RillError::UnknownCategory(feat.to_string()))
78            })
79            .collect()
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 = checked_increment(self.samples_seen, "samples_seen")?;
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(feature = "serde")]
110impl ValidateState for OrdinalEncoder {
111    fn validate_state(&self) -> Result<(), RillError> {
112        // `fit_one` maintains `categories` in strictly ascending order with
113        // no duplicates via binary search. A violation indicates a corrupted
114        // or maliciously crafted serde payload.
115        for window in self.categories.windows(2) {
116            if window[0] >= window[1] {
117                return Err(RillError::InvalidState(
118                    "ordinal encoder categories must be strictly sorted".to_owned(),
119                ));
120            }
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn known_category_returns_correct_index() {
132        let mut enc = OrdinalEncoder::new();
133        enc.update_strs(&["b", "a", "c"]).unwrap();
134        // sorted categories: ["a", "b", "c"] -> indices 0, 1, 2
135        let out = enc.transform_strs(&["a"]).unwrap();
136        assert_eq!(out, vec![0.0]);
137        let out = enc.transform_strs(&["b"]).unwrap();
138        assert_eq!(out, vec![1.0]);
139        let out = enc.transform_strs(&["c"]).unwrap();
140        assert_eq!(out, vec![2.0]);
141    }
142
143    #[test]
144    fn unknown_category_rejected() {
145        let mut enc = OrdinalEncoder::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 = OrdinalEncoder::new();
156        enc.update_strs(&["b"]).unwrap();
157        assert_eq!(enc.categories(), &["b"]);
158        enc.update_strs(&["a"]).unwrap();
159        assert_eq!(enc.categories(), &["a", "b"]);
160    }
161
162    #[test]
163    fn multiple_features_return_one_index_each() {
164        let mut enc = OrdinalEncoder::new();
165        enc.update_strs(&["a", "b", "c"]).unwrap();
166        let out = enc.transform_strs(&["c", "a"]).unwrap();
167        assert_eq!(out, vec![2.0, 0.0]);
168    }
169
170    #[test]
171    fn reset_clears_state() {
172        let mut enc = OrdinalEncoder::new();
173        enc.update_strs(&["a", "b"]).unwrap();
174        enc.reset();
175        assert!(enc.categories().is_empty());
176        assert_eq!(enc.samples_seen(), 0);
177    }
178
179    #[test]
180    fn empty_input_rejected() {
181        let mut enc = OrdinalEncoder::new();
182        assert!(matches!(
183            enc.update_strs(&[]),
184            Err(RillError::EmptyFeatures)
185        ));
186        assert!(matches!(
187            enc.transform_strs(&[]),
188            Err(RillError::EmptyFeatures)
189        ));
190    }
191
192    #[test]
193    #[cfg(feature = "serde")]
194    fn serde_roundtrip() {
195        let mut enc = OrdinalEncoder::new();
196        enc.update_strs(&["b", "a", "c"]).unwrap();
197        let json = serde_json::to_string(&enc).unwrap();
198        let restored: OrdinalEncoder = serde_json::from_str(&json).unwrap();
199        assert_eq!(restored.categories(), enc.categories());
200        assert_eq!(restored.samples_seen(), enc.samples_seen());
201    }
202}