Skip to main content

rill_ml/
feature_hasher.rs

1//! Feature hashing for dimensionality reduction.
2//!
3//! Maps high-dimensional sparse features into a fixed-dimensional dense
4//! vector using a deterministic hash function. Supports signed hashing
5//! to reduce collision bias.
6//!
7//! # Examples
8//!
9//! ```
10//! use rill_ml::feature_hasher::FeatureHasher;
11//! use rill_ml::sparse::SparseFeatures;
12//!
13//! let hasher = FeatureHasher::new(8, 42).unwrap();
14//! let sf = SparseFeatures::from_sorted(vec![(1, 3.0), (5, -2.0)]).unwrap();
15//! let dense = hasher.transform(&sf).unwrap();
16//! assert_eq!(dense.len(), 8);
17//! ```
18
19use crate::error::{RillError, checked_finite_add, ensure_finite};
20use crate::sparse::{FeatureId, SparseFeatures};
21use std::collections::hash_map::DefaultHasher;
22use std::hash::{Hash, Hasher};
23
24/// Configuration for [`FeatureHasher`].
25#[derive(Debug, Clone)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct FeatureHasherConfig {
28    /// Output dimension. Must be > 0.
29    pub dimension: usize,
30    /// Random seed for reproducible hashing.
31    pub seed: u64,
32    /// Whether to use signed hashing (alternate sign based on hash bit).
33    pub signed: bool,
34}
35
36impl Default for FeatureHasherConfig {
37    fn default() -> Self {
38        Self {
39            dimension: 1024,
40            seed: 0,
41            signed: true,
42        }
43    }
44}
45
46/// Fixed-dimension feature hasher.
47///
48/// Uses two independent hash functions:
49/// - The first determines the target bucket (`hash1 % dimension`).
50/// - The second determines the sign (`hash2 & 1`) when `signed = true`.
51///
52/// The hash is deterministic given the same `seed`, ensuring reproducible
53/// output across runs.
54#[derive(Debug, Clone)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize))]
56pub struct FeatureHasher {
57    config: FeatureHasherConfig,
58}
59
60impl FeatureHasher {
61    /// Create a new hasher with the given dimension and seed.
62    ///
63    /// Uses signed hashing by default.
64    pub fn new(dimension: usize, seed: u64) -> Result<Self, RillError> {
65        Self::with_config(FeatureHasherConfig {
66            dimension,
67            seed,
68            signed: true,
69        })
70    }
71
72    /// Create a new hasher with a custom configuration.
73    pub fn with_config(config: FeatureHasherConfig) -> Result<Self, RillError> {
74        if config.dimension == 0 {
75            return Err(RillError::InvalidHashDimension(config.dimension));
76        }
77        Ok(Self { config })
78    }
79
80    /// The output dimension.
81    pub const fn dimension(&self) -> usize {
82        self.config.dimension
83    }
84
85    /// The random seed.
86    pub const fn seed(&self) -> u64 {
87        self.config.seed
88    }
89
90    /// Whether signed hashing is enabled.
91    pub const fn signed(&self) -> bool {
92        self.config.signed
93    }
94
95    /// Hash a `FeatureId` to a `(bucket, sign)` pair.
96    fn hash_id(&self, id: FeatureId) -> (usize, f64) {
97        let bucket = self.hash_bucket(id);
98        let sign = if self.config.signed {
99            self.hash_sign(id)
100        } else {
101            1.0
102        };
103        (bucket, sign)
104    }
105
106    /// Compute the bucket index for a feature id.
107    fn hash_bucket(&self, id: FeatureId) -> usize {
108        let mut hasher = DefaultHasher::new();
109        self.config.seed.hash(&mut hasher);
110        id.hash(&mut hasher);
111        (hasher.finish() as usize) % self.config.dimension
112    }
113
114    /// Compute the sign for a feature id (signed hashing).
115    fn hash_sign(&self, id: FeatureId) -> f64 {
116        let mut hasher = DefaultHasher::new();
117        (self.config.seed.wrapping_mul(0x517cc1b727220a95)).hash(&mut hasher);
118        id.hash(&mut hasher);
119        if hasher.finish() & 1 == 1 { -1.0 } else { 1.0 }
120    }
121
122    /// Hash a string feature name to a `FeatureId`.
123    pub fn hash_string(&self, name: &str) -> FeatureId {
124        let mut hasher = DefaultHasher::new();
125        self.config.seed.hash(&mut hasher);
126        name.hash(&mut hasher);
127        hasher.finish()
128    }
129
130    /// Create `SparseFeatures` from string name/value pairs.
131    ///
132    /// Each string is hashed to a `FeatureId`, then the pairs are sorted
133    /// and duplicates are merged by summing values.
134    pub fn hash_strings(&self, pairs: &[(&str, f64)]) -> Result<SparseFeatures, RillError> {
135        let mut ids: Vec<(FeatureId, f64)> = Vec::with_capacity(pairs.len());
136        for (name, value) in pairs {
137            ensure_finite("hash_value", *value)?;
138            ids.push((self.hash_string(name), *value));
139        }
140        SparseFeatures::from_unsorted(ids)
141    }
142
143    /// Transform `SparseFeatures` into a dense `Vec<f64>`.
144    ///
145    /// Each feature's value is added to its target bucket (multiplied by
146    /// the sign if signed hashing is enabled). Collisions cause values to
147    /// accumulate.
148    pub fn transform(&self, features: &SparseFeatures) -> Result<Vec<f64>, RillError> {
149        if self.config.dimension == 0 {
150            return Err(RillError::InvalidHashDimension(0));
151        }
152        features.validate()?;
153        let mut output = vec![0.0; self.config.dimension];
154        for &(id, value) in features.values() {
155            ensure_finite("sparse_value", value)?;
156            let (bucket, sign) = self.hash_id(id);
157            output[bucket] = checked_finite_add(output[bucket], sign * value, "hashed feature")?;
158        }
159        Ok(output)
160    }
161}
162
163#[cfg(feature = "serde")]
164impl<'de> serde::Deserialize<'de> for FeatureHasher {
165    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166    where
167        D: serde::Deserializer<'de>,
168    {
169        #[derive(serde::Deserialize)]
170        struct FeatureHasherState {
171            config: FeatureHasherConfig,
172        }
173
174        let state = FeatureHasherState::deserialize(deserializer)?;
175        Self::with_config(state.config).map_err(serde::de::Error::custom)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn reproducible_output() {
185        let h = FeatureHasher::new(16, 42).unwrap();
186        let sf = SparseFeatures::from_sorted(vec![(1, 3.0), (5, -2.0), (10, 1.0)]).unwrap();
187        let out1 = h.transform(&sf).unwrap();
188        let out2 = h.transform(&sf).unwrap();
189        assert_eq!(out1, out2);
190    }
191
192    #[test]
193    fn collision_overflow_is_rejected() {
194        let hasher = FeatureHasher::with_config(FeatureHasherConfig {
195            dimension: 1,
196            seed: 42,
197            signed: false,
198        })
199        .unwrap();
200        let features = SparseFeatures::from_sorted(vec![(1, f64::MAX), (2, f64::MAX)]).unwrap();
201        assert!(hasher.transform(&features).is_err());
202    }
203
204    #[cfg(feature = "serde")]
205    #[test]
206    fn serde_rejects_zero_dimension() {
207        let malformed = r#"{"config":{"dimension":0,"seed":0,"signed":true}}"#;
208        assert!(serde_json::from_str::<FeatureHasher>(malformed).is_err());
209    }
210
211    #[test]
212    fn different_seeds_produce_different_output() {
213        let h1 = FeatureHasher::new(16, 1).unwrap();
214        let h2 = FeatureHasher::new(16, 2).unwrap();
215        let sf = SparseFeatures::from_sorted(vec![(1, 1.0), (2, 2.0)]).unwrap();
216        let out1 = h1.transform(&sf).unwrap();
217        let out2 = h2.transform(&sf).unwrap();
218        assert_ne!(out1, out2);
219    }
220
221    #[test]
222    fn signed_hashing_produces_negatives() {
223        let h = FeatureHasher::with_config(FeatureHasherConfig {
224            dimension: 256,
225            seed: 42,
226            signed: true,
227        })
228        .unwrap();
229        let sf =
230            SparseFeatures::from_sorted((0..100).map(|i| (i, 1.0)).collect::<Vec<_>>()).unwrap();
231        let out = h.transform(&sf).unwrap();
232        // With 100 features and signed hashing, at least some should be negative
233        assert!(out.iter().any(|&v| v < 0.0));
234    }
235
236    #[test]
237    fn unsigned_hashing_all_positive() {
238        let h = FeatureHasher::with_config(FeatureHasherConfig {
239            dimension: 256,
240            seed: 42,
241            signed: false,
242        })
243        .unwrap();
244        let sf =
245            SparseFeatures::from_sorted((0..100).map(|i| (i, 1.0)).collect::<Vec<_>>()).unwrap();
246        let out = h.transform(&sf).unwrap();
247        assert!(out.iter().all(|&v| v >= 0.0));
248    }
249
250    #[test]
251    fn dimension_one_all_same_bucket() {
252        let h = FeatureHasher::new(1, 42).unwrap();
253        let sf = SparseFeatures::from_sorted(vec![(1, 3.0), (2, 5.0)]).unwrap();
254        let out = h.transform(&sf).unwrap();
255        assert_eq!(out.len(), 1);
256        // Both values land in bucket 0, with signs
257        assert!(out[0].abs() > 0.0);
258    }
259
260    #[test]
261    fn empty_features_returns_zeros() {
262        let h = FeatureHasher::new(8, 42).unwrap();
263        let sf = SparseFeatures::new();
264        let out = h.transform(&sf).unwrap();
265        assert_eq!(out, vec![0.0; 8]);
266    }
267
268    #[test]
269    fn string_hash_reproducible() {
270        let h = FeatureHasher::new(8, 42).unwrap();
271        let id1 = h.hash_string("user_id");
272        let id2 = h.hash_string("user_id");
273        assert_eq!(id1, id2);
274    }
275
276    #[test]
277    fn different_strings_different_ids() {
278        let h = FeatureHasher::new(8, 42).unwrap();
279        let id1 = h.hash_string("user_id");
280        let id2 = h.hash_string("device_id");
281        assert_ne!(id1, id2);
282    }
283
284    #[test]
285    fn hash_strings_creates_sorted_features() {
286        let h = FeatureHasher::new(8, 42).unwrap();
287        let sf = h
288            .hash_strings(&[("alpha", 1.0), ("beta", 2.0), ("gamma", 3.0)])
289            .unwrap();
290        // Should be valid sorted sparse features
291        assert!(sf.validate().is_ok());
292        assert_eq!(sf.len(), 3);
293    }
294
295    #[test]
296    fn invalid_dimension_rejected() {
297        assert!(matches!(
298            FeatureHasher::new(0, 42),
299            Err(RillError::InvalidHashDimension(0))
300        ));
301    }
302
303    #[test]
304    #[cfg(feature = "serde")]
305    fn serde_roundtrip() {
306        let h = FeatureHasher::new(16, 42).unwrap();
307        let json = serde_json::to_string(&h).unwrap();
308        let restored: FeatureHasher = serde_json::from_str(&json).unwrap();
309        assert_eq!(restored.dimension(), 16);
310        assert_eq!(restored.seed(), 42);
311        assert!(restored.signed());
312    }
313}