Skip to main content

anno_eval/eval/
ensemble.rs

1//! Ensemble disagreement metrics for multi-model NER systems.
2//!
3//! When running multiple NER models, disagreement patterns reveal:
4//! - Ambiguous or difficult examples
5//! - Model-specific biases
6//! - Opportunities for ensemble voting
7//!
8//! # Key Metrics
9//!
10//! - **Agreement Rate**: How often do models agree?
11//! - **Fleiss' Kappa**: Inter-rater reliability adjusted for chance
12//! - **Disagreement Entropy**: Information content of disagreements
13//! - **Per-Entity-Type Agreement**: Where do models disagree most?
14//!
15//! # Example
16//!
17//! ```rust
18//! use anno_eval::eval::ensemble::{EnsembleAnalyzer, ModelPrediction};
19//!
20//! let predictions = vec![
21//!     ModelPrediction {
22//!         model_name: "model_a".into(),
23//!         entities: vec![("John".into(), "PER".into()), ("Google".into(), "ORG".into())],
24//!     },
25//!     ModelPrediction {
26//!         model_name: "model_b".into(),
27//!         entities: vec![("John".into(), "PER".into()), ("Google".into(), "LOC".into())],  // Disagrees on Google
28//!     },
29//! ];
30//!
31//! let analyzer = EnsembleAnalyzer::default();
32//! let results = analyzer.analyze_single(&predictions);
33//! println!("Agreement rate: {:.1}%", results.agreement_rate * 100.0);
34//! ```
35
36use serde::{Deserialize, Serialize};
37use std::collections::{HashMap, HashSet};
38
39// =============================================================================
40// Data Structures
41// =============================================================================
42
43/// A single model's predictions for one text.
44#[derive(Debug, Clone)]
45pub struct ModelPrediction {
46    /// Model identifier
47    pub model_name: String,
48    /// Predicted entities as (text, type) pairs
49    pub entities: Vec<(String, String)>,
50}
51
52/// Results of ensemble analysis for a single example.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SingleExampleAnalysis {
55    /// Proportion of entity predictions where all models agree
56    pub agreement_rate: f64,
57    /// Entities where all models agree
58    pub agreed_entities: Vec<(String, String)>,
59    /// Entities with disagreement: (text, model -> type)
60    pub disagreed_entities: Vec<DisagreementDetail>,
61    /// Number of models that participated
62    pub num_models: usize,
63}
64
65/// Details about a disagreement.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct DisagreementDetail {
68    /// Entity text
69    pub text: String,
70    /// Model name -> predicted type (None if model didn't predict this entity)
71    pub predictions: HashMap<String, Option<String>>,
72    /// Most common prediction (majority vote)
73    pub majority_vote: Option<String>,
74    /// Confidence in majority vote (proportion of models agreeing)
75    pub majority_confidence: f64,
76}
77
78/// Results of ensemble analysis across multiple examples.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct EnsembleAnalysisResults {
81    /// Overall agreement rate across all examples
82    pub overall_agreement_rate: f64,
83    /// Fleiss' Kappa (inter-rater reliability)
84    pub fleiss_kappa: f64,
85    /// Per-entity-type agreement rates
86    pub agreement_by_type: HashMap<String, f64>,
87    /// Most disagreed entity types (sorted by disagreement rate)
88    pub most_disagreed_types: Vec<(String, f64)>,
89    /// Sample disagreements for inspection
90    pub sample_disagreements: Vec<DisagreementDetail>,
91    /// Total examples analyzed
92    pub total_examples: usize,
93    /// Total entities analyzed
94    pub total_entities: usize,
95    /// Model-pair agreement matrix
96    pub pairwise_agreement: HashMap<String, HashMap<String, f64>>,
97}
98
99// =============================================================================
100// Ensemble Analyzer
101// =============================================================================
102
103/// Analyzer for ensemble model disagreements.
104#[derive(Debug, Clone, Default)]
105pub struct EnsembleAnalyzer {
106    /// Maximum sample disagreements to keep
107    pub max_samples: usize,
108}
109
110impl EnsembleAnalyzer {
111    /// Create analyzer with custom sample limit.
112    pub fn new(max_samples: usize) -> Self {
113        Self { max_samples }
114    }
115
116    /// Analyze disagreements for a single example.
117    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        // Collect all unique entity texts
128        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            // Collect predictions for this entity
138            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            // Check for agreement
151            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                // Count types for majority vote
168                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    /// Analyze disagreements across multiple examples.
206    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        // Model names for pairwise analysis
227        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            // Track per-type agreement
242            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            // Pairwise agreement tracking
257            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                        // Count entities where both models agree
270                        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        // Compute overall agreement
284        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        // Compute per-type agreement
291        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        // Sort types by disagreement rate
300        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        // Compute pairwise agreement matrix
307        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        // Compute Fleiss' Kappa (simplified)
330        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    /// Compute Fleiss' Kappa for inter-rater reliability.
345    fn compute_fleiss_kappa(&self, batch: &[Vec<ModelPrediction>]) -> f64 {
346        if batch.is_empty() {
347            return 1.0;
348        }
349
350        // Simplified Fleiss' Kappa computation
351        // Treats each entity prediction as a rating
352
353        let mut n_subjects = 0; // Number of entities rated
354        let mut p_bar = 0.0; // Average agreement per subject
355        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            // Collect all entities mentioned
366            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                // Count ratings per category for this entity
375                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                // Compute agreement for this entity
388                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        // Compute expected agreement
402        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        // Fleiss' Kappa
411        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
419// =============================================================================
420// Utility Functions
421// =============================================================================
422
423/// Grade agreement rate.
424pub 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
438/// Interpret Fleiss' Kappa value.
439pub 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// =============================================================================
456// Tests
457// =============================================================================
458
459#[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())], // Missing Google
530            },
531        ];
532
533        let analyzer = EnsembleAnalyzer::default();
534        let results = analyzer.analyze_single(&predictions);
535
536        // Google is disagreed because model_b didn't predict it
537        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}