use crate::error::RillError;
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OneHotEncoder {
categories: Vec<String>,
samples_seen: u64,
}
impl OneHotEncoder {
pub fn new() -> Self {
Self {
categories: Vec::new(),
samples_seen: 0,
}
}
pub fn categories(&self) -> &[String] {
&self.categories
}
pub fn category_index(&self, category: &str) -> Option<usize> {
self.categories
.binary_search_by(|c| c.as_str().cmp(category))
.ok()
}
pub fn fit_one(&mut self, category: &str) {
match self
.categories
.binary_search_by(|c| c.as_str().cmp(category))
{
Ok(_) => {}
Err(idx) => self.categories.insert(idx, category.to_string()),
}
}
pub fn transform_strs(&self, features: &[&str]) -> Result<Vec<f64>, RillError> {
if features.is_empty() {
return Err(RillError::EmptyFeatures);
}
if self.categories.is_empty() {
return Ok(Vec::new());
}
let n_cats = self.categories.len();
let mut out = vec![0.0; features.len() * n_cats];
for (i, &feat) in features.iter().enumerate() {
let idx = self
.category_index(feat)
.ok_or_else(|| RillError::UnknownCategory(feat.to_string()))?;
out[i * n_cats + idx] = 1.0;
}
Ok(out)
}
pub fn update_strs(&mut self, features: &[&str]) -> Result<(), RillError> {
if features.is_empty() {
return Err(RillError::EmptyFeatures);
}
for &feat in features {
self.fit_one(feat);
}
self.samples_seen += 1;
Ok(())
}
pub fn samples_seen(&self) -> u64 {
self.samples_seen
}
pub fn reset(&mut self) {
self.categories.clear();
self.samples_seen = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_categories_encode_correctly() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["b", "a", "c"]).unwrap();
let out = enc.transform_strs(&["a"]).unwrap();
assert_eq!(out, vec![1.0, 0.0, 0.0]);
let out = enc.transform_strs(&["c"]).unwrap();
assert_eq!(out, vec![0.0, 0.0, 1.0]);
}
#[test]
fn unknown_category_rejected() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["a", "b"]).unwrap();
assert!(matches!(
enc.transform_strs(&["z"]),
Err(RillError::UnknownCategory(_))
));
}
#[test]
fn new_category_added_on_update() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["a"]).unwrap();
assert_eq!(enc.categories(), &["a"]);
enc.update_strs(&["b"]).unwrap();
assert_eq!(enc.categories(), &["a", "b"]);
}
#[test]
fn multiple_features_produce_concatenated_vectors() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["a", "b"]).unwrap();
let out = enc.transform_strs(&["a", "b"]).unwrap();
assert_eq!(out, vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn reset_clears_state() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["a", "b"]).unwrap();
enc.reset();
assert!(enc.categories().is_empty());
assert_eq!(enc.samples_seen(), 0);
}
#[test]
fn samples_seen_tracks_updates() {
let mut enc = OneHotEncoder::new();
assert_eq!(enc.samples_seen(), 0);
enc.update_strs(&["a"]).unwrap();
assert_eq!(enc.samples_seen(), 1);
enc.update_strs(&["b", "c"]).unwrap();
assert_eq!(enc.samples_seen(), 2);
}
#[test]
fn empty_input_rejected() {
let mut enc = OneHotEncoder::new();
assert!(matches!(
enc.update_strs(&[]),
Err(RillError::EmptyFeatures)
));
assert!(matches!(
enc.transform_strs(&[]),
Err(RillError::EmptyFeatures)
));
}
#[test]
fn empty_categories_returns_empty_vec() {
let enc = OneHotEncoder::new();
let out = enc.transform_strs(&["a"]).unwrap();
assert!(out.is_empty());
}
#[test]
#[cfg(feature = "serde")]
fn serde_roundtrip() {
let mut enc = OneHotEncoder::new();
enc.update_strs(&["b", "a", "c"]).unwrap();
let json = serde_json::to_string(&enc).unwrap();
let restored: OneHotEncoder = serde_json::from_str(&json).unwrap();
assert_eq!(restored.categories(), enc.categories());
assert_eq!(restored.samples_seen(), enc.samples_seen());
}
}