khmer_tokenizer_core/
hmm.rs1use std::collections::HashMap;
10
11const NUM_STATES: usize = 4;
12const BEGIN: usize = 0;
13const MIDDLE: usize = 1;
14const END: usize = 2;
15const SINGLE: usize = 3;
16
17#[derive(Clone)]
28pub struct HmmModel {
29 start: [f64; NUM_STATES],
30 trans: [[f64; NUM_STATES]; NUM_STATES],
31 emit: HashMap<String, [f64; NUM_STATES]>,
32}
33
34fn smoothed_log_probs(counts: &[u64; NUM_STATES]) -> [f64; NUM_STATES] {
36 let total: f64 = counts.iter().map(|&c| c as f64 + 1.0).sum();
37 let mut out = [0.0; NUM_STATES];
38 for (i, &c) in counts.iter().enumerate() {
39 out[i] = ((c as f64 + 1.0) / total).ln();
40 }
41 out
42}
43
44impl HmmModel {
45 pub fn from_counts(
52 start_counts: [u64; NUM_STATES],
53 trans_counts: [[u64; NUM_STATES]; NUM_STATES],
54 emit_counts: HashMap<String, [u64; NUM_STATES]>,
55 ) -> Self {
56 let start = smoothed_log_probs(&start_counts);
57 let mut trans = [[0.0; NUM_STATES]; NUM_STATES];
58 for (i, row) in trans_counts.iter().enumerate() {
59 trans[i] = smoothed_log_probs(row);
60 }
61 let emit = emit_counts
62 .into_iter()
63 .map(|(cluster, counts)| (cluster, smoothed_log_probs(&counts)))
64 .collect();
65 Self { start, trans, emit }
66 }
67
68 fn emit_log_prob(&self, cluster: &str, state: usize) -> f64 {
73 match self.emit.get(cluster) {
74 Some(probs) => probs[state],
75 None => (1.0 / NUM_STATES as f64).ln(),
76 }
77 }
78
79 #[allow(clippy::needless_range_loop)]
86 fn viterbi_tags(&self, clusters: &[String]) -> Vec<usize> {
87 let n = clusters.len();
88 let mut score = vec![[f64::NEG_INFINITY; NUM_STATES]; n];
89 let mut back = vec![[0usize; NUM_STATES]; n];
90
91 for s in 0..NUM_STATES {
92 score[0][s] = self.start[s] + self.emit_log_prob(&clusters[0], s);
93 }
94 for t in 1..n {
95 for s in 0..NUM_STATES {
96 let mut best_score = f64::NEG_INFINITY;
97 let mut best_prev = 0;
98 for ps in 0..NUM_STATES {
99 let candidate = score[t - 1][ps] + self.trans[ps][s];
100 if candidate > best_score {
101 best_score = candidate;
102 best_prev = ps;
103 }
104 }
105 back[t][s] = best_prev;
106 score[t][s] = best_score + self.emit_log_prob(&clusters[t], s);
107 }
108 }
109
110 let mut best_final = 0;
111 for s in 1..NUM_STATES {
112 if score[n - 1][s] > score[n - 1][best_final] {
113 best_final = s;
114 }
115 }
116
117 let mut tags = vec![0usize; n];
118 tags[n - 1] = best_final;
119 for t in (1..n).rev() {
120 tags[t - 1] = back[t][tags[t]];
121 }
122 tags
123 }
124
125 pub(crate) fn segment_oov(&self, clusters: &[String]) -> Vec<Vec<String>> {
128 if clusters.is_empty() {
129 return Vec::new();
130 }
131 let tags = self.viterbi_tags(clusters);
132
133 let mut tokens = Vec::new();
134 let mut current: Vec<String> = Vec::new();
135 for (cluster, &tag) in clusters.iter().zip(&tags) {
136 match tag {
137 BEGIN => {
138 if !current.is_empty() {
139 tokens.push(std::mem::take(&mut current));
140 }
141 current.push(cluster.clone());
142 }
143 MIDDLE => current.push(cluster.clone()),
144 END => {
145 current.push(cluster.clone());
146 tokens.push(std::mem::take(&mut current));
147 }
148 SINGLE => {
149 if !current.is_empty() {
150 tokens.push(std::mem::take(&mut current));
151 }
152 tokens.push(vec![cluster.clone()]);
153 }
154 _ => unreachable!("tags are always in 0..NUM_STATES"),
155 }
156 }
157 if !current.is_empty() {
158 tokens.push(current);
159 }
160 tokens
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn two_word_model() -> HmmModel {
173 let start = [50, 0, 0, 0]; let mut trans = [[0u64; NUM_STATES]; NUM_STATES];
175 trans[BEGIN][END] = 50;
176 trans[END][SINGLE] = 50;
177
178 let mut emit = HashMap::new();
179 emit.insert("a".to_string(), [50, 0, 0, 0]);
180 emit.insert("b".to_string(), [0, 0, 50, 0]);
181 emit.insert("c".to_string(), [0, 0, 0, 50]);
182
183 HmmModel::from_counts(start, trans, emit)
184 }
185
186 #[test]
187 fn decodes_a_two_word_run_via_bmes_tags() {
188 let model = two_word_model();
189 let clusters = vec!["a".to_string(), "b".to_string(), "c".to_string()];
190 assert_eq!(
191 model.segment_oov(&clusters),
192 vec![
193 vec!["a".to_string(), "b".to_string()],
194 vec!["c".to_string()]
195 ]
196 );
197 }
198
199 #[test]
200 fn single_cluster_run_is_its_own_token() {
201 let model = two_word_model();
202 let clusters = vec!["c".to_string()];
203 assert_eq!(model.segment_oov(&clusters), vec![vec!["c".to_string()]]);
204 }
205
206 #[test]
207 fn empty_run_yields_no_tokens() {
208 let model = two_word_model();
209 assert!(model.segment_oov(&[]).is_empty());
210 }
211
212 #[test]
213 fn unseen_cluster_falls_back_to_uniform_emission_without_panicking() {
214 let model = two_word_model();
215 let clusters = vec!["z".to_string()];
216 assert_eq!(model.segment_oov(&clusters).concat(), vec!["z".to_string()]);
219 }
220}