1use zer_core::{
2 comparison::{ComparisonBatch, ComparisonLevel, ComparisonVector},
3 error::ZerError,
4 scoring::ModelParams,
5};
6
7const N_LEVELS: usize = 4; pub fn e_step(vector: &ComparisonVector, params: &ModelParams) -> f32 {
13 let log_odds: f32 = params.log_prior_odds
14 + vector
15 .levels
16 .iter()
17 .enumerate()
18 .map(|(i, &level)| {
19 if level == ComparisonLevel::Null {
20 return 0.0_f32;
21 }
22 let l = level as usize;
23 let m = params.m[i][l].max(1e-9_f32);
24 let u = params.u[i][l].max(1e-9_f32);
25 (m / u).ln()
26 })
27 .sum::<f32>();
28 1.0 / (1.0 + (-log_odds).exp())
29}
30
31#[inline]
32fn e_step_p(batch: &ComparisonBatch, p: usize, params: &ModelParams) -> f32 {
33 let n_pairs = batch.n_pairs;
34 let log_odds: f32 = params.log_prior_odds
35 + (0..batch.n_fields)
36 .map(|f| {
37 let l_u8 = batch.levels[f * n_pairs + p];
38 if l_u8 == 255 {
39 return 0.0_f32;
40 } let l = l_u8 as usize;
42 let m = params.m[f][l].max(1e-9_f32);
43 let u = params.u[f][l].max(1e-9_f32);
44 (m / u).ln()
45 })
46 .sum::<f32>();
47 1.0 / (1.0 + (-log_odds).exp())
48}
49
50fn m_step(batch: &ComparisonBatch, posteriors: &[f32], prev: &ModelParams) -> ModelParams {
53 let n_fields = batch.n_fields;
54 let n_pairs = batch.n_pairs;
55
56 let mut m_num = vec![vec![0.0f32; N_LEVELS]; n_fields];
57 let mut u_num = vec![vec![0.0f32; N_LEVELS]; n_fields];
58
59 let mut total_match = 0.0f32;
60 let mut total_nonmatch = 0.0f32;
61
62 for &post in posteriors.iter().take(n_pairs) {
63 total_match += post;
64 total_nonmatch += 1.0 - post;
65 }
66
67 for f in 0..n_fields {
71 let field_slice = &batch.levels[f * n_pairs..(f + 1) * n_pairs];
72 for p in 0..n_pairs {
73 let l_u8 = field_slice[p];
74 if l_u8 == 255 {
75 continue;
76 } let l = l_u8 as usize;
78 m_num[f][l] += posteriors[p];
79 u_num[f][l] += 1.0 - posteriors[p];
80 }
81 }
82
83 let total_match = total_match.max(1e-9);
84 let total_nonmatch = total_nonmatch.max(1e-9);
85
86 let mut m = vec![vec![1e-9f32; N_LEVELS]; n_fields];
87 let mut u = vec![vec![1e-9f32; N_LEVELS]; n_fields];
88
89 for f in 0..n_fields {
90 for l in 0..N_LEVELS {
91 m[f][l] = (m_num[f][l] / total_match).max(1e-9);
92 u[f][l] = (u_num[f][l] / total_nonmatch).max(1e-9);
93 }
94 let m_sum: f32 = m[f].iter().sum();
95 let u_sum: f32 = u[f].iter().sum();
96 for l in 0..N_LEVELS {
97 m[f][l] /= m_sum;
98 u[f][l] /= u_sum;
99 }
100 }
101
102 let lambda = (total_match / n_pairs as f32).clamp(0.001, 0.999);
103 let log_prior = (lambda / (1.0 - lambda)).ln();
104
105 ModelParams {
106 m,
107 u,
108 log_prior_odds: log_prior,
109 upper_threshold: prev.upper_threshold,
110 lower_threshold: prev.lower_threshold,
111 }
112}
113
114fn params_delta(a: &ModelParams, b: &ModelParams) -> f32 {
117 let mut max_delta = 0.0f32;
118 for (am, bm) in a.m.iter().zip(b.m.iter()) {
119 for (&av, &bv) in am.iter().zip(bm.iter()) {
120 max_delta = max_delta.max((av - bv).abs());
121 }
122 }
123 for (au, bu) in a.u.iter().zip(b.u.iter()) {
124 for (&av, &bv) in au.iter().zip(bu.iter()) {
125 max_delta = max_delta.max((av - bv).abs());
126 }
127 }
128 max_delta
129}
130
131fn init_from_priors(n_fields: usize) -> ModelParams {
134 let m = vec![vec![0.02, 0.06, 0.12, 0.80]; n_fields];
135 let u = vec![vec![0.70, 0.15, 0.10, 0.05]; n_fields];
136 ModelParams {
137 m,
138 u,
139 log_prior_odds: 0.0,
140 upper_threshold: 0.9,
141 lower_threshold: 0.1,
142 }
143}
144
145pub fn estimate_lambda(batch: &ComparisonBatch) -> f32 {
149 if batch.n_pairs == 0 {
150 return 0.01;
151 }
152 let exact = ComparisonLevel::Exact as u8;
153 let n_pairs = batch.n_pairs;
154 let high_sim_count = (0..n_pairs)
155 .filter(|&p| (0..batch.n_fields).any(|f| batch.levels[f * n_pairs + p] == exact))
156 .count();
157 let raw = high_sim_count as f32 / n_pairs as f32;
158 raw.clamp(0.001, 0.5)
159}
160
161pub fn auto_calibrate_thresholds(scores: &[f32]) -> (f32, f32) {
163 if scores.is_empty() {
164 return (0.9, 0.1);
165 }
166
167 let high: Vec<f32> = scores.iter().copied().filter(|&s| s >= 0.7).collect();
168 let low: Vec<f32> = scores.iter().copied().filter(|&s| s <= 0.3).collect();
169
170 let upper = if high.len() >= 10 {
171 let mut sorted = high.clone();
172 sorted.sort_by(f32::total_cmp);
173 sorted[(sorted.len() as f32 * 0.05) as usize].max(0.85)
174 } else {
175 0.9
176 };
177
178 let lower = if low.len() >= 10 {
179 let mut sorted = low.clone();
180 sorted.sort_by(f32::total_cmp);
181 sorted[(sorted.len() as f32 * 0.95) as usize].min(0.15)
182 } else {
183 0.1
184 };
185
186 (upper, lower)
187}
188
189pub fn run_em(
191 batch: &ComparisonBatch,
192 init: Option<ModelParams>,
193 max_iter: usize,
194) -> Result<ModelParams, ZerError> {
195 if batch.n_pairs == 0 {
196 return Err(ZerError::SchemaMismatch {
197 expected: 1,
198 got: 0,
199 });
200 }
201
202 let n_fields = batch.n_fields;
203 if n_fields == 0 {
204 return Err(ZerError::EmptySchema);
205 }
206
207 let mut params = init.unwrap_or_else(|| {
208 let mut p = init_from_priors(n_fields);
209 let lambda = estimate_lambda(batch);
210 p.log_prior_odds = (lambda / (1.0 - lambda)).ln();
211 tracing::debug!(lambda, "auto-estimated prior match rate");
212 p
213 });
214
215 for iter in 0..max_iter {
216 let posteriors: Vec<f32> = (0..batch.n_pairs)
217 .map(|p| e_step_p(batch, p, ¶ms))
218 .collect();
219
220 let new_params = m_step(batch, &posteriors, ¶ms);
221 let delta = params_delta(¶ms, &new_params);
222
223 params = new_params;
224 tracing::debug!(iter, delta, "EM iteration");
225
226 if delta < 1e-6 {
227 tracing::info!(iter, "EM converged");
228 break;
229 }
230 }
231
232 Ok(params)
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use zer_core::comparison::{ComparisonBatch, ComparisonLevel, ComparisonVector};
239
240 fn uniform_vector(
241 id_a: u64,
242 id_b: u64,
243 n_fields: usize,
244 level: ComparisonLevel,
245 ) -> ComparisonVector {
246 ComparisonVector::new(id_a, id_b, vec![level; n_fields])
247 }
248
249 fn synthetic_batch(n_match: usize, n_nonmatch: usize, n_fields: usize) -> ComparisonBatch {
250 let mut vecs = Vec::with_capacity(n_match + n_nonmatch);
251 for i in 0..n_match {
252 vecs.push(uniform_vector(
253 i as u64,
254 (i + 1_000_000) as u64,
255 n_fields,
256 ComparisonLevel::Exact,
257 ));
258 }
259 for i in 0..n_nonmatch {
260 vecs.push(uniform_vector(
261 (i + 2_000_000) as u64,
262 (i + 3_000_000) as u64,
263 n_fields,
264 ComparisonLevel::None,
265 ));
266 }
267 ComparisonBatch::from_vectors(&vecs)
268 }
269
270 #[test]
271 fn em_converges_on_synthetic_data() {
272 let batch = synthetic_batch(200, 800, 4);
273 let params = run_em(&batch, None, 100).expect("EM should succeed");
274 for f in 0..4 {
275 let exact_idx = ComparisonLevel::Exact as usize;
276 assert!(
277 params.m[f][exact_idx] > params.u[f][exact_idx],
278 "m[Exact] should exceed u[Exact] for field {f}: m={}, u={}",
279 params.m[f][exact_idx],
280 params.u[f][exact_idx]
281 );
282 }
283 }
284
285 #[test]
286 fn em_warm_start_converges_faster() {
287 let batch = synthetic_batch(200, 800, 3);
288
289 let warm = ModelParams {
290 m: vec![vec![0.02, 0.06, 0.12, 0.78]; 3],
291 u: vec![vec![0.75, 0.12, 0.08, 0.05]; 3],
292 log_prior_odds: (0.2_f32 / 0.8_f32).ln(),
293 upper_threshold: 0.9,
294 lower_threshold: 0.1,
295 };
296
297 let params = run_em(&batch, Some(warm), 5).expect("warm start EM should succeed");
298 for f in 0..3 {
299 let exact_idx = ComparisonLevel::Exact as usize;
300 assert!(
301 params.m[f][exact_idx] > params.u[f][exact_idx],
302 "warm-start: m[Exact] should exceed u[Exact] for field {f}"
303 );
304 }
305 }
306
307 #[test]
308 fn em_empty_batch_returns_error() {
309 let batch = ComparisonBatch::new(0, 0, vec![]);
310 let result = run_em(&batch, None, 50);
311 assert!(result.is_err(), "empty batch should return an error");
312 }
313
314 #[test]
315 fn estimate_lambda_all_exact() {
316 let batch = synthetic_batch(100, 0, 2);
317 let lambda = estimate_lambda(&batch);
318 assert_eq!(lambda, 0.5);
319 }
320
321 #[test]
322 fn estimate_lambda_all_none() {
323 let batch = synthetic_batch(0, 100, 2);
324 let lambda = estimate_lambda(&batch);
325 assert_eq!(lambda, 0.001);
326 }
327
328 #[test]
329 fn auto_calibrate_bimodal_distribution() {
330 let mut scores = vec![];
331 for _ in 0..50 {
332 scores.push(0.95_f32);
333 }
334 for _ in 0..200 {
335 scores.push(0.05_f32);
336 }
337 let (upper, lower) = auto_calibrate_thresholds(&scores);
338 assert!(
339 upper >= 0.85,
340 "upper threshold should be ≥ 0.85, got {upper}"
341 );
342 assert!(
343 lower <= 0.15,
344 "lower threshold should be ≤ 0.15, got {lower}"
345 );
346 }
347
348 #[test]
349 fn auto_calibrate_empty_returns_defaults() {
350 let (upper, lower) = auto_calibrate_thresholds(&[]);
351 assert_eq!(upper, 0.9);
352 assert_eq!(lower, 0.1);
353 }
354}