rill_ml/preprocessing/
ordinal.rs1use crate::error::{RillError, checked_increment};
8#[cfg(feature = "serde")]
9use crate::persistence::ValidateState;
10
11#[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 pub fn new() -> Self {
26 Self {
27 categories: Vec::new(),
28 samples_seen: 0,
29 }
30 }
31
32 pub fn categories(&self) -> &[String] {
34 &self.categories
35 }
36
37 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 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 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 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 pub fn samples_seen(&self) -> u64 {
99 self.samples_seen
100 }
101
102 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 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 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}