rill_ml/preprocessing/
one_hot.rs1use crate::error::RillError;
8
9#[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 pub fn new() -> Self {
23 Self {
24 categories: Vec::new(),
25 samples_seen: 0,
26 }
27 }
28
29 pub fn categories(&self) -> &[String] {
31 &self.categories
32 }
33
34 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 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 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 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 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(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 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 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 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}