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