Skip to main content

zer_compare/
em.rs

1use zer_core::{
2    comparison::{ComparisonBatch, ComparisonLevel, ComparisonVector},
3    error::ZerError,
4    scoring::ModelParams,
5};
6
7const N_LEVELS: usize = 4; // None=0, Partial=1, Close=2, Exact=3
8
9// ── E-step ────────────────────────────────────────────────────────────────────
10
11/// Compute P(match | comparison_vector) for a single pair given current params.
12pub 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                } // ComparisonLevel::Null, skip
41                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
50// ── M-step ────────────────────────────────────────────────────────────────────
51
52fn 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    // Field-outer, pair-inner: sequential reads of levels[f*n_pairs+p].
68    // This layout lets the compiler auto-vectorize the inner accumulation.
69    // Null (255) fields are skipped, they carry no m/u evidence.
70    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            } // ComparisonLevel::Null
77            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
114// ── Delta ─────────────────────────────────────────────────────────────────────
115
116fn 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
131// ── Initialization ────────────────────────────────────────────────────────────
132
133fn 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
145// ── Public API ────────────────────────────────────────────────────────────────
146
147/// Estimate the prior match rate λ = P(true match in candidate set).
148pub 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
161/// Auto-calibrate upper/lower thresholds after EM converges.
162pub 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
189/// Run the EM algorithm to learn m/u parameters without labels.
190pub 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, &params))
218            .collect();
219
220        let new_params = m_step(batch, &posteriors, &params);
221        let delta = params_delta(&params, &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}