1use serde::{Deserialize, Serialize};
37use std::collections::{HashMap, HashSet};
38
39#[derive(Debug, Clone)]
45pub struct ModelPrediction {
46 pub model_name: String,
48 pub entities: Vec<(String, String)>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SingleExampleAnalysis {
55 pub agreement_rate: f64,
57 pub agreed_entities: Vec<(String, String)>,
59 pub disagreed_entities: Vec<DisagreementDetail>,
61 pub num_models: usize,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct DisagreementDetail {
68 pub text: String,
70 pub predictions: HashMap<String, Option<String>>,
72 pub majority_vote: Option<String>,
74 pub majority_confidence: f64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct EnsembleAnalysisResults {
81 pub overall_agreement_rate: f64,
83 pub fleiss_kappa: f64,
85 pub agreement_by_type: HashMap<String, f64>,
87 pub most_disagreed_types: Vec<(String, f64)>,
89 pub sample_disagreements: Vec<DisagreementDetail>,
91 pub total_examples: usize,
93 pub total_entities: usize,
95 pub pairwise_agreement: HashMap<String, HashMap<String, f64>>,
97}
98
99#[derive(Debug, Clone, Default)]
105pub struct EnsembleAnalyzer {
106 pub max_samples: usize,
108}
109
110impl EnsembleAnalyzer {
111 pub fn new(max_samples: usize) -> Self {
113 Self { max_samples }
114 }
115
116 pub fn analyze_single(&self, predictions: &[ModelPrediction]) -> SingleExampleAnalysis {
118 if predictions.is_empty() {
119 return SingleExampleAnalysis {
120 agreement_rate: 1.0,
121 agreed_entities: Vec::new(),
122 disagreed_entities: Vec::new(),
123 num_models: 0,
124 };
125 }
126
127 let all_texts: HashSet<String> = predictions
129 .iter()
130 .flat_map(|p| p.entities.iter().map(|(t, _)| t.to_lowercase()))
131 .collect();
132
133 let mut agreed = Vec::new();
134 let mut disagreed = Vec::new();
135
136 for text in all_texts {
137 let mut entity_predictions: HashMap<String, Option<String>> = HashMap::new();
139
140 for pred in predictions {
141 let model_pred = pred
142 .entities
143 .iter()
144 .find(|(t, _)| t.to_lowercase() == text)
145 .map(|(_, typ)| typ.clone());
146
147 entity_predictions.insert(pred.model_name.clone(), model_pred);
148 }
149
150 let non_none_types: Vec<&String> = entity_predictions
152 .values()
153 .filter_map(|v| v.as_ref())
154 .collect();
155
156 if non_none_types.is_empty() {
157 continue;
158 }
159
160 let first_type = non_none_types[0];
161 let all_agree = non_none_types.iter().all(|t| *t == first_type)
162 && entity_predictions.values().all(|v| v.is_some());
163
164 if all_agree {
165 agreed.push((text.clone(), first_type.clone()));
166 } else {
167 let mut type_counts: HashMap<String, usize> = HashMap::new();
169 for typ in &non_none_types {
170 *type_counts.entry((*typ).clone()).or_insert(0) += 1;
171 }
172
173 let (majority_type, majority_count) = type_counts
174 .iter()
175 .max_by_key(|(_, count)| *count)
176 .map(|(t, c)| (Some(t.clone()), *c))
177 .unwrap_or((None, 0));
178
179 let majority_confidence = majority_count as f64 / predictions.len() as f64;
180
181 disagreed.push(DisagreementDetail {
182 text: text.clone(),
183 predictions: entity_predictions,
184 majority_vote: majority_type,
185 majority_confidence,
186 });
187 }
188 }
189
190 let total = agreed.len() + disagreed.len();
191 let agreement_rate = if total == 0 {
192 1.0
193 } else {
194 agreed.len() as f64 / total as f64
195 };
196
197 SingleExampleAnalysis {
198 agreement_rate,
199 agreed_entities: agreed,
200 disagreed_entities: disagreed,
201 num_models: predictions.len(),
202 }
203 }
204
205 pub fn analyze_batch(&self, batch: &[Vec<ModelPrediction>]) -> EnsembleAnalysisResults {
207 if batch.is_empty() {
208 return EnsembleAnalysisResults {
209 overall_agreement_rate: 1.0,
210 fleiss_kappa: 1.0,
211 agreement_by_type: HashMap::new(),
212 most_disagreed_types: Vec::new(),
213 sample_disagreements: Vec::new(),
214 total_examples: 0,
215 total_entities: 0,
216 pairwise_agreement: HashMap::new(),
217 };
218 }
219
220 let mut total_agreed = 0;
221 let mut total_entities = 0;
222 let mut all_disagreements = Vec::new();
223 let mut type_agreed: HashMap<String, usize> = HashMap::new();
224 let mut type_total: HashMap<String, usize> = HashMap::new();
225
226 let model_names: Vec<String> = batch
228 .first()
229 .map(|preds| preds.iter().map(|p| p.model_name.clone()).collect())
230 .unwrap_or_default();
231
232 let mut pairwise_agreed: HashMap<(String, String), usize> = HashMap::new();
233 let mut pairwise_total: HashMap<(String, String), usize> = HashMap::new();
234
235 for example_preds in batch {
236 let analysis = self.analyze_single(example_preds);
237
238 total_agreed += analysis.agreed_entities.len();
239 total_entities += analysis.agreed_entities.len() + analysis.disagreed_entities.len();
240
241 for (_, typ) in &analysis.agreed_entities {
243 *type_agreed.entry(typ.clone()).or_insert(0) += 1;
244 *type_total.entry(typ.clone()).or_insert(0) += 1;
245 }
246
247 for disagreement in &analysis.disagreed_entities {
248 if let Some(ref majority) = disagreement.majority_vote {
249 *type_total.entry(majority.clone()).or_insert(0) += 1;
250 }
251 if all_disagreements.len() < self.max_samples.max(20) {
252 all_disagreements.push(disagreement.clone());
253 }
254 }
255
256 for i in 0..model_names.len() {
258 for j in (i + 1)..model_names.len() {
259 let key = (model_names[i].clone(), model_names[j].clone());
260
261 let pred_i = example_preds
262 .iter()
263 .find(|p| p.model_name == model_names[i]);
264 let pred_j = example_preds
265 .iter()
266 .find(|p| p.model_name == model_names[j]);
267
268 if let (Some(pi), Some(pj)) = (pred_i, pred_j) {
269 let entities_i: HashSet<_> = pi.entities.iter().collect();
271 let entities_j: HashSet<_> = pj.entities.iter().collect();
272
273 let intersection = entities_i.intersection(&entities_j).count();
274 let union = entities_i.union(&entities_j).count();
275
276 *pairwise_agreed.entry(key.clone()).or_insert(0) += intersection;
277 *pairwise_total.entry(key).or_insert(0) += union;
278 }
279 }
280 }
281 }
282
283 let overall_agreement_rate = if total_entities == 0 {
285 1.0
286 } else {
287 total_agreed as f64 / total_entities as f64
288 };
289
290 let agreement_by_type: HashMap<String, f64> = type_total
292 .iter()
293 .map(|(typ, total)| {
294 let agreed = type_agreed.get(typ).copied().unwrap_or(0);
295 (typ.clone(), agreed as f64 / *total as f64)
296 })
297 .collect();
298
299 let mut most_disagreed: Vec<(String, f64)> = agreement_by_type
301 .iter()
302 .map(|(t, rate)| (t.clone(), 1.0 - rate))
303 .collect();
304 most_disagreed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
305
306 let mut pairwise_agreement: HashMap<String, HashMap<String, f64>> = HashMap::new();
308 for ((m1, m2), total) in &pairwise_total {
309 let agreed = pairwise_agreed
310 .get(&(m1.clone(), m2.clone()))
311 .copied()
312 .unwrap_or(0);
313 let rate = if *total == 0 {
314 1.0
315 } else {
316 agreed as f64 / *total as f64
317 };
318
319 pairwise_agreement
320 .entry(m1.clone())
321 .or_default()
322 .insert(m2.clone(), rate);
323 pairwise_agreement
324 .entry(m2.clone())
325 .or_default()
326 .insert(m1.clone(), rate);
327 }
328
329 let fleiss_kappa = self.compute_fleiss_kappa(batch);
331
332 EnsembleAnalysisResults {
333 overall_agreement_rate,
334 fleiss_kappa,
335 agreement_by_type,
336 most_disagreed_types: most_disagreed.into_iter().take(10).collect(),
337 sample_disagreements: all_disagreements,
338 total_examples: batch.len(),
339 total_entities,
340 pairwise_agreement,
341 }
342 }
343
344 fn compute_fleiss_kappa(&self, batch: &[Vec<ModelPrediction>]) -> f64 {
346 if batch.is_empty() {
347 return 1.0;
348 }
349
350 let mut n_subjects = 0; let mut p_bar = 0.0; let mut category_proportions: HashMap<String, f64> = HashMap::new();
356 let mut total_ratings = 0;
357
358 for example_preds in batch {
359 if example_preds.is_empty() {
360 continue;
361 }
362
363 let n_raters = example_preds.len();
364
365 let all_texts: HashSet<String> = example_preds
367 .iter()
368 .flat_map(|p| p.entities.iter().map(|(t, _)| t.to_lowercase()))
369 .collect();
370
371 for text in all_texts {
372 n_subjects += 1;
373
374 let mut category_counts: HashMap<String, usize> = HashMap::new();
376
377 for pred in example_preds {
378 if let Some((_, typ)) =
379 pred.entities.iter().find(|(t, _)| t.to_lowercase() == text)
380 {
381 *category_counts.entry(typ.clone()).or_insert(0) += 1;
382 total_ratings += 1;
383 *category_proportions.entry(typ.clone()).or_insert(0.0) += 1.0;
384 }
385 }
386
387 let sum_squared: f64 = category_counts.values().map(|&n| (n * n) as f64).sum();
389 let n = n_raters as f64;
390 let p_i = (sum_squared - n) / (n * (n - 1.0));
391 p_bar += p_i;
392 }
393 }
394
395 if n_subjects == 0 || total_ratings == 0 {
396 return 1.0;
397 }
398
399 p_bar /= n_subjects as f64;
400
401 let p_e: f64 = category_proportions
403 .values()
404 .map(|&p| {
405 let prop = p / total_ratings as f64;
406 prop * prop
407 })
408 .sum();
409
410 if (1.0 - p_e).abs() < 1e-10 {
412 1.0
413 } else {
414 (p_bar - p_e) / (1.0 - p_e)
415 }
416 }
417}
418
419pub fn agreement_grade(rate: f64) -> &'static str {
425 if rate >= 0.95 {
426 "Excellent agreement"
427 } else if rate >= 0.85 {
428 "Good agreement"
429 } else if rate >= 0.70 {
430 "Moderate agreement"
431 } else if rate >= 0.50 {
432 "Fair agreement"
433 } else {
434 "Poor agreement"
435 }
436}
437
438pub fn kappa_interpretation(kappa: f64) -> &'static str {
440 if kappa < 0.0 {
441 "Less than chance agreement"
442 } else if kappa < 0.20 {
443 "Slight agreement"
444 } else if kappa < 0.40 {
445 "Fair agreement"
446 } else if kappa < 0.60 {
447 "Moderate agreement"
448 } else if kappa < 0.80 {
449 "Substantial agreement"
450 } else {
451 "Almost perfect agreement"
452 }
453}
454
455#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn test_perfect_agreement() {
465 let predictions = vec![
466 ModelPrediction {
467 model_name: "model_a".into(),
468 entities: vec![
469 ("John".into(), "PER".into()),
470 ("Google".into(), "ORG".into()),
471 ],
472 },
473 ModelPrediction {
474 model_name: "model_b".into(),
475 entities: vec![
476 ("John".into(), "PER".into()),
477 ("Google".into(), "ORG".into()),
478 ],
479 },
480 ];
481
482 let analyzer = EnsembleAnalyzer::default();
483 let results = analyzer.analyze_single(&predictions);
484
485 assert!((results.agreement_rate - 1.0).abs() < 0.01);
486 assert_eq!(results.agreed_entities.len(), 2);
487 assert!(results.disagreed_entities.is_empty());
488 }
489
490 #[test]
491 fn test_partial_disagreement() {
492 let predictions = vec![
493 ModelPrediction {
494 model_name: "model_a".into(),
495 entities: vec![
496 ("John".into(), "PER".into()),
497 ("Google".into(), "ORG".into()),
498 ],
499 },
500 ModelPrediction {
501 model_name: "model_b".into(),
502 entities: vec![
503 ("John".into(), "PER".into()),
504 ("Google".into(), "LOC".into()),
505 ],
506 },
507 ];
508
509 let analyzer = EnsembleAnalyzer::default();
510 let results = analyzer.analyze_single(&predictions);
511
512 assert!((results.agreement_rate - 0.5).abs() < 0.01);
513 assert_eq!(results.agreed_entities.len(), 1);
514 assert_eq!(results.disagreed_entities.len(), 1);
515 }
516
517 #[test]
518 fn test_missing_entity() {
519 let predictions = vec![
520 ModelPrediction {
521 model_name: "model_a".into(),
522 entities: vec![
523 ("John".into(), "PER".into()),
524 ("Google".into(), "ORG".into()),
525 ],
526 },
527 ModelPrediction {
528 model_name: "model_b".into(),
529 entities: vec![("John".into(), "PER".into())], },
531 ];
532
533 let analyzer = EnsembleAnalyzer::default();
534 let results = analyzer.analyze_single(&predictions);
535
536 assert_eq!(results.disagreed_entities.len(), 1);
538 }
539
540 #[test]
541 fn test_batch_analysis() {
542 let batch = vec![
543 vec![
544 ModelPrediction {
545 model_name: "a".into(),
546 entities: vec![("x".into(), "T1".into())],
547 },
548 ModelPrediction {
549 model_name: "b".into(),
550 entities: vec![("x".into(), "T1".into())],
551 },
552 ],
553 vec![
554 ModelPrediction {
555 model_name: "a".into(),
556 entities: vec![("y".into(), "T2".into())],
557 },
558 ModelPrediction {
559 model_name: "b".into(),
560 entities: vec![("y".into(), "T3".into())],
561 },
562 ],
563 ];
564
565 let analyzer = EnsembleAnalyzer::new(10);
566 let results = analyzer.analyze_batch(&batch);
567
568 assert_eq!(results.total_examples, 2);
569 assert!(results.overall_agreement_rate > 0.0);
570 assert!(results.overall_agreement_rate < 1.0);
571 }
572
573 #[test]
574 fn test_agreement_grades() {
575 assert_eq!(agreement_grade(0.98), "Excellent agreement");
576 assert_eq!(agreement_grade(0.90), "Good agreement");
577 assert_eq!(agreement_grade(0.75), "Moderate agreement");
578 assert_eq!(agreement_grade(0.55), "Fair agreement");
579 assert_eq!(agreement_grade(0.30), "Poor agreement");
580 }
581
582 #[test]
583 fn test_kappa_interpretation() {
584 assert_eq!(kappa_interpretation(-0.1), "Less than chance agreement");
585 assert_eq!(kappa_interpretation(0.10), "Slight agreement");
586 assert_eq!(kappa_interpretation(0.35), "Fair agreement");
587 assert_eq!(kappa_interpretation(0.55), "Moderate agreement");
588 assert_eq!(kappa_interpretation(0.75), "Substantial agreement");
589 assert_eq!(kappa_interpretation(0.90), "Almost perfect agreement");
590 }
591}