rill_ml/preprocessing/
frequency.rs1use std::collections::BTreeMap;
7
8use crate::error::{RillError, checked_increment};
9#[cfg(feature = "serde")]
10use crate::persistence::ValidateState;
11
12#[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 pub fn new() -> Self {
26 Self {
27 category_counts: BTreeMap::new(),
28 total: 0,
29 samples_seen: 0,
30 }
31 }
32
33 pub fn category_counts(&self) -> &BTreeMap<String, u64> {
35 &self.category_counts
36 }
37
38 pub fn total(&self) -> u64 {
40 self.total
41 }
42
43 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 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 pub fn samples_seen(&self) -> u64 {
92 self.samples_seen
93 }
94
95 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 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 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 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 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 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 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}