Skip to main content

rill_ml/preprocessing/
frequency.rs

1//! Online frequency encoder for categorical string features.
2//!
3//! Each category is mapped to its observed frequency `count / total`,
4//! updated incrementally as new samples are seen.
5
6use std::collections::BTreeMap;
7
8use crate::error::{RillError, checked_increment};
9#[cfg(feature = "serde")]
10use crate::persistence::ValidateState;
11
12/// Online frequency encoder for string features.
13///
14/// Maps each category to its observed frequency `count / total`.
15#[derive(Debug, Clone, Default)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct FrequencyEncoder {
18    category_counts: BTreeMap<String, u64>,
19    total: u64,
20    samples_seen: u64,
21}
22
23impl FrequencyEncoder {
24    /// Create a new empty encoder.
25    pub fn new() -> Self {
26        Self {
27            category_counts: BTreeMap::new(),
28            total: 0,
29            samples_seen: 0,
30        }
31    }
32
33    /// The per-category counts.
34    pub fn category_counts(&self) -> &BTreeMap<String, u64> {
35        &self.category_counts
36    }
37
38    /// The total number of category observations seen.
39    pub fn total(&self) -> u64 {
40        self.total
41    }
42
43    /// Return the observed frequency for each input string.
44    ///
45    /// Unknown categories map to `0.0`. Before any sample has been seen,
46    /// all outputs are `0.0`.
47    ///
48    /// # Errors
49    /// Returns [`RillError::EmptyFeatures`] if `features` is empty.
50    pub fn transform_strs(&self, features: &[&str]) -> Result<Vec<f64>, RillError> {
51        if features.is_empty() {
52            return Err(RillError::EmptyFeatures);
53        }
54        if self.total == 0 {
55            return Ok(vec![0.0; features.len()]);
56        }
57        let total = self.total as f64;
58        Ok(features
59            .iter()
60            .map(|&feat| {
61                self.category_counts
62                    .get(feat)
63                    .map(|&c| c as f64 / total)
64                    .unwrap_or(0.0)
65            })
66            .collect())
67    }
68
69    /// Increment counts for each category in `features` and increment
70    /// `samples_seen`.
71    ///
72    /// # Errors
73    /// Returns [`RillError::EmptyFeatures`] if `features` is empty.
74    pub fn update_strs(&mut self, features: &[&str]) -> Result<(), RillError> {
75        if features.is_empty() {
76            return Err(RillError::EmptyFeatures);
77        }
78        for &feat in features {
79            let count = self.category_counts.entry(feat.to_string()).or_insert(0);
80            *count = checked_increment(*count, "category_count")?;
81        }
82        self.total = self
83            .total
84            .checked_add(features.len() as u64)
85            .ok_or_else(|| RillError::InvalidState("total counter overflow".to_string()))?;
86        self.samples_seen = checked_increment(self.samples_seen, "samples_seen")?;
87        Ok(())
88    }
89
90    /// How many samples have been seen.
91    pub fn samples_seen(&self) -> u64 {
92        self.samples_seen
93    }
94
95    /// Reset the encoder to its initial empty state.
96    pub fn reset(&mut self) {
97        self.category_counts.clear();
98        self.total = 0;
99        self.samples_seen = 0;
100    }
101}
102
103#[cfg(feature = "serde")]
104impl ValidateState for FrequencyEncoder {
105    fn validate_state(&self) -> Result<(), RillError> {
106        // Every category count must be strictly positive: counts are only
107        // inserted via `entry().or_insert(0)` followed by an immediate
108        // increment, so a zero value indicates a corrupted or maliciously
109        // crafted serde payload.
110        for (cat, &count) in &self.category_counts {
111            if count == 0 {
112                return Err(RillError::InvalidState(format!(
113                    "frequency encoder category `{cat}` has zero count"
114                )));
115            }
116        }
117        // `total` must equal the sum of all category counts: each `update_strs`
118        // call increments `total` by `features.len()` and increments a
119        // per-category count by 1 for each feature occurrence, so the two
120        // must stay in lockstep.
121        let sum: u64 = self.category_counts.values().sum();
122        if self.total != sum {
123            return Err(RillError::InvalidState(format!(
124                "frequency encoder total ({}) does not match sum of category counts ({})",
125                self.total, sum
126            )));
127        }
128        // `samples_seen` cannot exceed `total`: each update adds at least 1
129        // to `total` (features is non-empty) and exactly 1 to `samples_seen`.
130        if self.samples_seen > self.total {
131            return Err(RillError::InvalidState(format!(
132                "frequency encoder samples_seen ({}) cannot exceed total ({})",
133                self.samples_seen, self.total
134            )));
135        }
136        Ok(())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn frequency_calculation() {
146        let mut enc = FrequencyEncoder::new();
147        // "a" x2, "b" x1 -> total 3
148        enc.update_strs(&["a"]).unwrap();
149        enc.update_strs(&["a", "b"]).unwrap();
150        let out = enc.transform_strs(&["a"]).unwrap();
151        assert!((out[0] - 2.0 / 3.0).abs() < 1e-12);
152        let out = enc.transform_strs(&["b"]).unwrap();
153        assert!((out[0] - 1.0 / 3.0).abs() < 1e-12);
154    }
155
156    #[test]
157    fn unknown_category_returns_zero() {
158        let mut enc = FrequencyEncoder::new();
159        enc.update_strs(&["a"]).unwrap();
160        let out = enc.transform_strs(&["z"]).unwrap();
161        assert_eq!(out, vec![0.0]);
162    }
163
164    #[test]
165    fn multiple_updates_accumulate() {
166        let mut enc = FrequencyEncoder::new();
167        enc.update_strs(&["a", "a"]).unwrap();
168        enc.update_strs(&["a"]).unwrap();
169        // "a" count = 3, total = 3 -> freq = 1.0
170        let out = enc.transform_strs(&["a"]).unwrap();
171        assert!((out[0] - 1.0).abs() < 1e-12);
172    }
173
174    #[test]
175    fn reset_clears_state() {
176        let mut enc = FrequencyEncoder::new();
177        enc.update_strs(&["a", "b"]).unwrap();
178        enc.reset();
179        assert_eq!(enc.total(), 0);
180        assert_eq!(enc.samples_seen(), 0);
181        assert!(enc.category_counts().is_empty());
182    }
183
184    #[test]
185    fn multiple_features_return_one_frequency_each() {
186        let mut enc = FrequencyEncoder::new();
187        // "a" x1, "b" x3 -> total 4
188        enc.update_strs(&["b", "b", "b", "a"]).unwrap();
189        let out = enc.transform_strs(&["a", "b"]).unwrap();
190        assert!((out[0] - 0.25).abs() < 1e-12);
191        assert!((out[1] - 0.75).abs() < 1e-12);
192    }
193
194    #[test]
195    fn total_tracks_observations() {
196        let mut enc = FrequencyEncoder::new();
197        enc.update_strs(&["a", "b"]).unwrap();
198        assert_eq!(enc.total(), 2);
199        enc.update_strs(&["c"]).unwrap();
200        assert_eq!(enc.total(), 3);
201    }
202
203    #[test]
204    #[cfg(feature = "serde")]
205    fn serde_roundtrip() {
206        let mut enc = FrequencyEncoder::new();
207        enc.update_strs(&["a", "b", "a"]).unwrap();
208        let json = serde_json::to_string(&enc).unwrap();
209        let restored: FrequencyEncoder = serde_json::from_str(&json).unwrap();
210        assert_eq!(restored.total(), enc.total());
211        assert_eq!(restored.samples_seen(), enc.samples_seen());
212        assert_eq!(restored.category_counts(), enc.category_counts());
213    }
214}