use crate::traits::{Error, Fit, Result, Transform};
use polars::prelude::*;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
pub struct FeatureHasher {
fitted: bool,
columns: Vec<String>,
n_features: usize,
}
impl FeatureHasher {
pub fn new(columns: &[&str], n_features: usize) -> Self {
Self {
fitted: false,
columns: columns.iter().map(|s| s.to_string()).collect(),
n_features,
}
}
fn hash_to_bucket(s: &str, n: usize) -> (usize, f64) {
let mut h_idx = DefaultHasher::new();
0u8.hash(&mut h_idx);
s.hash(&mut h_idx);
let idx = (h_idx.finish() as usize) % n;
let mut h_sign = DefaultHasher::new();
1u8.hash(&mut h_sign);
s.hash(&mut h_sign);
let sign = if h_sign.finish() & 1 == 1 { 1.0 } else { -1.0 };
(idx, sign)
}
}
impl Fit<DataFrame> for FeatureHasher {
type Output = ();
fn fit(&mut self, x: DataFrame) -> Result<()> {
if self.n_features == 0 {
return Err(Error::InvalidInput(
"FeatureHasher: n_features must be >= 1.".into(),
));
}
for col in &self.columns {
let s = x.column(col.as_str()).map_err(|_| {
Error::InvalidInput(format!("FeatureHasher.fit: column '{}' not found.", col))
})?;
let s = s.as_materialized_series();
s.str().map_err(|e| {
Error::InvalidInput(format!(
"FeatureHasher.fit: column '{}' has dtype {}; expected String. {}",
col,
s.dtype(),
e
))
})?;
}
self.fitted = true;
Ok(())
}
}
impl Transform<DataFrame> for FeatureHasher {
type Output = DataFrame;
fn transform(&self, x: DataFrame) -> Result<DataFrame> {
if !self.fitted {
return Err(Error::NotFitted("FeatureHasher".into()));
}
let n_rows = x.height();
let mut buckets = vec![vec![0.0f64; n_rows]; self.n_features];
for col in &self.columns {
let s = x
.column(col.as_str())
.map_err(|e| {
Error::InvalidInput(format!(
"FeatureHasher.transform: column '{}' not found. {}",
col, e
))
})?
.as_materialized_series();
let ca = s.str().map_err(|e| {
Error::InvalidInput(format!(
"FeatureHasher.transform: column '{}' has dtype {}; expected String. {}",
col,
s.dtype(),
e
))
})?;
for (i, opt) in ca.iter().enumerate() {
if let Some(val) = opt {
let (idx, sign) = Self::hash_to_bucket(val, self.n_features);
buckets[idx][i] += sign;
}
}
}
let mut out_cols: Vec<Column> = Vec::with_capacity(self.n_features);
for (idx, bucket) in buckets.iter().enumerate() {
let name = format!("hashed_{}", idx);
out_cols.push(Column::from(Series::new(name.as_str().into(), bucket)));
}
DataFrame::new(n_rows, out_cols).map_err(|e| Error::Computation(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_feature_hasher() {
let c = Column::from(Series::new("color".into(), &["red", "blue", "red"]));
let df = DataFrame::new(3, vec![c]).unwrap();
let mut fh = FeatureHasher::new(&["color"], 10);
fh.fit(df.clone()).unwrap();
let result = fh.transform(df).unwrap();
assert_eq!(result.width(), 10);
assert_eq!(result.height(), 3);
}
#[test]
fn test_hash_to_bucket_signed_and_deterministic() {
for s in &["red", "blue", "green", "x", "y", "a very long category"] {
let (idx, sign) = FeatureHasher::hash_to_bucket(s, 64);
assert!(idx < 64, "index out of range for '{s}'");
assert!(sign == 1.0 || sign == -1.0, "sign must be ±1 for '{s}'");
let (idx2, sign2) = FeatureHasher::hash_to_bucket(s, 64);
assert_eq!((idx, sign), (idx2, sign2));
}
}
#[test]
fn test_feature_hasher_signed_values() {
let c = Column::from(Series::new(
"color".into(),
&["red", "blue", "red", "green", "blue"],
));
let df = DataFrame::new(5, vec![c]).unwrap();
let mut fh = FeatureHasher::new(&["color"], 32);
fh.fit(df.clone()).unwrap();
let result = fh.transform(df).unwrap();
for col in result.columns() {
for v in col.as_materialized_series().f64().unwrap().iter().flatten() {
let frac = v.fract();
assert!(frac == 0.0, "bucket value {v} must be integral");
}
}
}
#[test]
fn test_fit_rejects_non_string_column() {
let c = Column::from(Series::new("count".into(), &[1_i32, 2, 3]));
let df = DataFrame::new(3, vec![c]).unwrap();
let mut fh = FeatureHasher::new(&["count"], 8);
let err = fh.fit(df.clone()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("expected String"),
"error should mention expected String, got: {msg}"
);
let transform_err = fh.transform(df).unwrap_err().to_string();
assert!(
transform_err.contains("not fitted"),
"transform after a failed fit should report NotFitted, got: {transform_err}"
);
}
}