Skip to main content

anno_eval/eval/
annotator.rs

1//! Multi-annotator disagreement modeling.
2//!
3//! # The Ground Truth Problem
4//!
5//! NER annotation is inherently subjective. Different annotators disagree about:
6//! - **Entity boundaries**: "New York City" vs "New York"
7//! - **Entity types**: Is "Apple" a company or a product?
8//! - **What counts as an entity**: Generic nouns? Metonymic references?
9//!
10//! Traditional evaluation assumes a single gold standard, hiding this uncertainty.
11//! This module models annotator disagreement explicitly.
12//!
13//! # Disagreement Metrics
14//!
15//! | Metric | Description |
16//! |--------|-------------|
17//! | **Agreement Rate** | % of tokens/spans all annotators agree on |
18//! | **Fleiss' Kappa** | Chance-corrected agreement for multiple annotators |
19//! | **Krippendorff's Alpha** | Agreement metric that handles missing data |
20//! | **Soft F1** | F1 weighted by annotator agreement |
21//!
22//! # Example
23//!
24//! ```rust
25//! use anno_eval::eval::annotator::{MultiAnnotatorCorpus, AnnotatorAnalyzer};
26//!
27//! let mut corpus = MultiAnnotatorCorpus::new();
28//!
29//! // Add annotations from multiple annotators
30//! corpus.add_annotation("doc1", "annotator_A", vec![
31//!     ("Barack Obama", 0, 12, "PER"),
32//!     ("Hawaii", 25, 31, "LOC"),
33//! ]);
34//! corpus.add_annotation("doc1", "annotator_B", vec![
35//!     ("Barack Obama", 0, 12, "PER"),
36//!     ("Hawaii", 25, 31, "GPE"),  // Different type!
37//! ]);
38//!
39//! let analyzer = AnnotatorAnalyzer::new(&corpus);
40//! let stats = analyzer.compute_agreement();
41//! println!("Span agreement: {:.2}%", stats.span_agreement * 100.0);
42//! println!("Type agreement: {:.2}%", stats.type_agreement * 100.0);
43//! ```
44//!
45//! # Research Background
46//!
47//! - **Hirschman et al. (1998)**: "Automating Coreference" [cmp-lg/9803001]
48//!   - Found only 16% of interannotator disagreements were genuine coreference disagreement
49//!   - 84% were systematic errors (missed pronouns, overlooked chains, zone issues)
50//!   - Two-stage annotation (markables first, linking second) can improve agreement
51//! - Plank et al. (2014): "Learning part-of-speech taggers with inter-annotator agreement loss"
52//! - Pavlick & Kwiatkowski (2019): "Inherent Disagreements in Human Textual Inferences"
53//! - Uma et al. (2021): "Learning from Disagreement: A Survey"
54
55use serde::{Deserialize, Serialize};
56use std::collections::{HashMap, HashSet};
57
58// =============================================================================
59// Core Types
60// =============================================================================
61
62/// A single annotation from one annotator.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub struct Annotation {
65    /// Entity text
66    pub text: String,
67    /// Start character offset
68    pub start: usize,
69    /// End character offset
70    pub end: usize,
71    /// Entity type
72    pub entity_type: String,
73    /// Annotator ID
74    pub annotator: String,
75}
76
77impl Annotation {
78    /// Create a new annotation.
79    pub fn new(text: &str, start: usize, end: usize, entity_type: &str, annotator: &str) -> Self {
80        Self {
81            text: text.to_string(),
82            start,
83            end,
84            entity_type: entity_type.to_string(),
85            annotator: annotator.to_string(),
86        }
87    }
88
89    /// Check if this annotation overlaps with another.
90    pub fn overlaps(&self, other: &Self) -> bool {
91        self.start < other.end && other.start < self.end
92    }
93
94    /// Check if spans are identical (ignoring type).
95    pub fn same_span(&self, other: &Self) -> bool {
96        self.start == other.start && self.end == other.end
97    }
98}
99
100/// Document with annotations from multiple annotators.
101#[derive(Debug, Clone, Default)]
102pub struct AnnotatedDocument {
103    /// Document ID
104    pub doc_id: String,
105    /// Document text
106    pub text: String,
107    /// Annotations grouped by annotator
108    pub annotations: HashMap<String, Vec<Annotation>>,
109}
110
111impl AnnotatedDocument {
112    /// Create a new annotated document.
113    pub fn new(doc_id: &str, text: &str) -> Self {
114        Self {
115            doc_id: doc_id.to_string(),
116            text: text.to_string(),
117            annotations: HashMap::new(),
118        }
119    }
120
121    /// Add annotations from an annotator.
122    pub fn add_annotator(&mut self, annotator: &str, annotations: Vec<Annotation>) {
123        self.annotations.insert(annotator.to_string(), annotations);
124    }
125
126    /// Get all unique annotators.
127    pub fn annotators(&self) -> Vec<&str> {
128        self.annotations.keys().map(|s| s.as_str()).collect()
129    }
130
131    /// Get all unique spans across all annotators.
132    pub fn unique_spans(&self) -> HashSet<(usize, usize)> {
133        self.annotations
134            .values()
135            .flat_map(|anns| anns.iter().map(|a| (a.start, a.end)))
136            .collect()
137    }
138}
139
140/// Corpus with multi-annotator annotations.
141#[derive(Debug, Clone, Default)]
142pub struct MultiAnnotatorCorpus {
143    /// Documents with annotations
144    pub documents: HashMap<String, AnnotatedDocument>,
145    /// All annotator IDs
146    pub annotators: HashSet<String>,
147}
148
149impl MultiAnnotatorCorpus {
150    /// Create a new corpus.
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Add an annotation.
156    pub fn add_annotation(
157        &mut self,
158        doc_id: &str,
159        annotator: &str,
160        annotations: Vec<(&str, usize, usize, &str)>,
161    ) {
162        self.annotators.insert(annotator.to_string());
163
164        let doc = self
165            .documents
166            .entry(doc_id.to_string())
167            .or_insert_with(|| AnnotatedDocument::new(doc_id, ""));
168
169        let anns: Vec<Annotation> = annotations
170            .into_iter()
171            .map(|(text, start, end, etype)| Annotation::new(text, start, end, etype, annotator))
172            .collect();
173
174        doc.add_annotator(annotator, anns);
175    }
176
177    /// Get number of documents.
178    pub fn num_documents(&self) -> usize {
179        self.documents.len()
180    }
181
182    /// Get number of annotators.
183    pub fn num_annotators(&self) -> usize {
184        self.annotators.len()
185    }
186}
187
188// =============================================================================
189// Agreement Metrics
190// =============================================================================
191
192/// Agreement statistics across annotators.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct AgreementStats {
195    /// Proportion of spans where all annotators agree on existence
196    pub span_agreement: f64,
197    /// Proportion of spans where all annotators agree on type
198    pub type_agreement: f64,
199    /// Fleiss' kappa for span identification
200    pub fleiss_kappa: f64,
201    /// Per-type agreement rates
202    pub type_specific_agreement: HashMap<String, f64>,
203    /// Most disagreed spans (for error analysis)
204    pub contentious_spans: Vec<ContentiousSpan>,
205    /// Number of annotators
206    pub num_annotators: usize,
207    /// Number of documents
208    pub num_documents: usize,
209}
210
211/// A span with significant annotator disagreement.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ContentiousSpan {
214    /// Document ID
215    pub doc_id: String,
216    /// Start offset
217    pub start: usize,
218    /// End offset
219    pub end: usize,
220    /// Text
221    pub text: String,
222    /// Types assigned by different annotators
223    pub types_assigned: HashMap<String, Vec<String>>, // type -> [annotators]
224    /// Disagreement score (0 = full agreement, 1 = no agreement)
225    pub disagreement: f64,
226}
227
228/// Analyzer for multi-annotator agreement.
229pub struct AnnotatorAnalyzer<'a> {
230    corpus: &'a MultiAnnotatorCorpus,
231}
232
233impl<'a> AnnotatorAnalyzer<'a> {
234    /// Create a new analyzer.
235    pub fn new(corpus: &'a MultiAnnotatorCorpus) -> Self {
236        Self { corpus }
237    }
238
239    /// Compute agreement statistics.
240    pub fn compute_agreement(&self) -> AgreementStats {
241        let mut span_agree_count = 0;
242        let mut span_total = 0;
243        let mut type_agree_count = 0;
244        let mut type_total = 0;
245        let mut type_counts: HashMap<String, (usize, usize)> = HashMap::new(); // (agree, total)
246        let mut contentious: Vec<ContentiousSpan> = Vec::new();
247
248        for (doc_id, doc) in &self.corpus.documents {
249            let spans = doc.unique_spans();
250            let annotators: Vec<_> = doc.annotators();
251
252            for (start, end) in spans {
253                span_total += 1;
254
255                // Collect annotations for this span
256                let mut types_for_span: HashMap<String, Vec<String>> = HashMap::new();
257                let mut annotators_with_span = 0;
258
259                for annotator in &annotators {
260                    if let Some(anns) = doc.annotations.get(*annotator) {
261                        for ann in anns {
262                            if ann.start == start && ann.end == end {
263                                types_for_span
264                                    .entry(ann.entity_type.clone())
265                                    .or_default()
266                                    .push((*annotator).to_string());
267                                annotators_with_span += 1;
268                            }
269                        }
270                    }
271                }
272
273                // Check span agreement (all annotators marked this span)
274                if annotators_with_span == annotators.len() {
275                    span_agree_count += 1;
276                }
277
278                // Check type agreement
279                type_total += 1;
280                let all_same_type = types_for_span.len() == 1;
281                if all_same_type && annotators_with_span == annotators.len() {
282                    type_agree_count += 1;
283                }
284
285                // Track per-type agreement
286                for (etype, ann_list) in &types_for_span {
287                    let entry = type_counts.entry(etype.clone()).or_insert((0, 0));
288                    entry.1 += 1;
289                    if ann_list.len() == annotators.len() {
290                        entry.0 += 1;
291                    }
292                }
293
294                // Track contentious spans
295                if types_for_span.len() > 1 || annotators_with_span < annotators.len() {
296                    let disagreement = 1.0
297                        - (types_for_span.values().map(|v| v.len()).max().unwrap_or(0) as f64
298                            / annotators.len() as f64);
299                    let text = doc
300                        .annotations
301                        .values()
302                        .flat_map(|anns| anns.iter())
303                        .find(|a| a.start == start && a.end == end)
304                        .map(|a| a.text.clone())
305                        .unwrap_or_default();
306
307                    contentious.push(ContentiousSpan {
308                        doc_id: doc_id.clone(),
309                        start,
310                        end,
311                        text,
312                        types_assigned: types_for_span.clone(),
313                        disagreement,
314                    });
315                }
316            }
317        }
318
319        // Sort contentious by disagreement
320        contentious.sort_by(|a, b| {
321            b.disagreement
322                .partial_cmp(&a.disagreement)
323                .unwrap_or(std::cmp::Ordering::Equal)
324        });
325
326        // Compute Fleiss' kappa
327        let fleiss_kappa = self.compute_fleiss_kappa();
328
329        // Per-type agreement
330        let type_specific_agreement: HashMap<String, f64> = type_counts
331            .into_iter()
332            .map(|(t, (agree, total))| {
333                (
334                    t,
335                    if total > 0 {
336                        agree as f64 / total as f64
337                    } else {
338                        0.0
339                    },
340                )
341            })
342            .collect();
343
344        AgreementStats {
345            span_agreement: if span_total > 0 {
346                span_agree_count as f64 / span_total as f64
347            } else {
348                0.0
349            },
350            type_agreement: if type_total > 0 {
351                type_agree_count as f64 / type_total as f64
352            } else {
353                0.0
354            },
355            fleiss_kappa,
356            type_specific_agreement,
357            contentious_spans: contentious.into_iter().take(20).collect(), // Top 20
358            num_annotators: self.corpus.num_annotators(),
359            num_documents: self.corpus.num_documents(),
360        }
361    }
362
363    /// Compute Fleiss' kappa for inter-annotator agreement.
364    fn compute_fleiss_kappa(&self) -> f64 {
365        // Simplified Fleiss' kappa computation
366        // Full implementation would need token-level annotation matrix
367
368        let mut total_agreement = 0.0;
369        let mut count = 0;
370
371        for doc in self.corpus.documents.values() {
372            let spans = doc.unique_spans();
373            let n_annotators = doc.annotators().len();
374            if n_annotators < 2 {
375                continue;
376            }
377
378            for (start, end) in spans {
379                let mut votes: HashMap<String, usize> = HashMap::new();
380                let mut n_votes = 0;
381
382                for anns in doc.annotations.values() {
383                    for ann in anns {
384                        if ann.start == start && ann.end == end {
385                            *votes.entry(ann.entity_type.clone()).or_insert(0) += 1;
386                            n_votes += 1;
387                        }
388                    }
389                }
390
391                if n_votes >= 2 {
392                    // Agreement for this item
393                    let sum_sq: usize = votes.values().map(|v| v * v).sum();
394                    let p_i = (sum_sq - n_votes) as f64 / (n_votes * (n_votes - 1)) as f64;
395                    total_agreement += p_i;
396                    count += 1;
397                }
398            }
399        }
400
401        if count == 0 {
402            return 0.0;
403        }
404
405        let p_bar = total_agreement / count as f64;
406        // Simplified: assume uniform category distribution for P_e
407        let p_e = 0.5; // This should be computed from category frequencies
408
409        if (1.0_f64 - p_e).abs() < 1e-7 {
410            return 1.0;
411        }
412
413        (p_bar - p_e) / (1.0 - p_e)
414    }
415
416    /// Create a "soft" gold standard by aggregating annotations.
417    ///
418    /// Returns annotations with confidence based on annotator agreement.
419    pub fn aggregate_gold(&self, doc_id: &str) -> Vec<(Annotation, f64)> {
420        let doc = match self.corpus.documents.get(doc_id) {
421            Some(d) => d,
422            None => return Vec::new(),
423        };
424
425        let spans = doc.unique_spans();
426        let n_annotators = doc.annotators().len();
427        let mut result = Vec::new();
428
429        for (start, end) in spans {
430            let mut type_votes: HashMap<String, usize> = HashMap::new();
431            let mut text = String::new();
432            let mut total_votes = 0;
433
434            for anns in doc.annotations.values() {
435                for ann in anns {
436                    if ann.start == start && ann.end == end {
437                        *type_votes.entry(ann.entity_type.clone()).or_insert(0) += 1;
438                        text = ann.text.clone();
439                        total_votes += 1;
440                    }
441                }
442            }
443
444            if total_votes > 0 {
445                // Majority vote for type
446                let (best_type, best_count) = type_votes
447                    .iter()
448                    .max_by_key(|(_, c)| *c)
449                    .map(|(t, c)| (t.clone(), *c))
450                    .expect("type_votes should not be empty");
451
452                // Confidence based on agreement
453                let span_confidence = total_votes as f64 / n_annotators as f64;
454                let type_confidence = best_count as f64 / total_votes as f64;
455                let confidence = span_confidence * type_confidence;
456
457                result.push((
458                    Annotation::new(&text, start, end, &best_type, "aggregated"),
459                    confidence,
460                ));
461            }
462        }
463
464        result
465    }
466}
467
468// =============================================================================
469// Soft Evaluation
470// =============================================================================
471
472/// Compute soft F1 that accounts for annotator disagreement.
473///
474/// Traditional F1 treats all gold annotations as equally certain.
475/// Soft F1 weights matches by annotator agreement.
476#[derive(Debug, Clone, Default)]
477pub struct SoftEvaluator {
478    /// Minimum agreement threshold to count as gold
479    pub min_agreement: f64,
480}
481
482impl SoftEvaluator {
483    /// Create a new soft evaluator.
484    pub fn new(min_agreement: f64) -> Self {
485        Self { min_agreement }
486    }
487
488    /// Compute soft precision/recall/F1.
489    pub fn evaluate(
490        &self,
491        predictions: &[Annotation],
492        gold_with_confidence: &[(Annotation, f64)],
493    ) -> SoftMetrics {
494        let mut weighted_tp = 0.0;
495        let mut weighted_fp = 0.0;
496        let mut weighted_fn = 0.0;
497
498        // Filter gold by minimum agreement
499        let gold: Vec<_> = gold_with_confidence
500            .iter()
501            .filter(|(_, conf)| *conf >= self.min_agreement)
502            .collect();
503
504        // For each prediction, find best matching gold
505        let mut matched_gold: HashSet<usize> = HashSet::new();
506
507        for pred in predictions {
508            let mut best_match: Option<(usize, f64)> = None;
509
510            for (i, (g, conf)) in gold.iter().enumerate() {
511                let should_update = match best_match {
512                    None => true,
513                    Some((_, best_conf)) => *conf > best_conf,
514                };
515                if pred.same_span(g)
516                    && pred.entity_type == g.entity_type
517                    && !matched_gold.contains(&i)
518                    && should_update
519                {
520                    best_match = Some((i, *conf));
521                }
522            }
523
524            if let Some((idx, conf)) = best_match {
525                weighted_tp += conf;
526                matched_gold.insert(idx);
527            } else {
528                weighted_fp += 1.0;
529            }
530        }
531
532        // Unmatched gold are false negatives
533        for (i, (_, conf)) in gold.iter().enumerate() {
534            if !matched_gold.contains(&i) {
535                weighted_fn += *conf;
536            }
537        }
538
539        let precision = if weighted_tp + weighted_fp > 0.0 {
540            weighted_tp / (weighted_tp + weighted_fp)
541        } else {
542            0.0
543        };
544
545        let recall = if weighted_tp + weighted_fn > 0.0 {
546            weighted_tp / (weighted_tp + weighted_fn)
547        } else {
548            0.0
549        };
550
551        let f1 = if precision + recall > 0.0 {
552            2.0 * precision * recall / (precision + recall)
553        } else {
554            0.0
555        };
556
557        SoftMetrics {
558            precision,
559            recall,
560            f1,
561            weighted_tp,
562            weighted_fp,
563            weighted_fn,
564        }
565    }
566}
567
568/// Soft evaluation metrics.
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct SoftMetrics {
571    /// Weighted precision
572    pub precision: f64,
573    /// Weighted recall
574    pub recall: f64,
575    /// Weighted F1
576    pub f1: f64,
577    /// Weighted true positives
578    pub weighted_tp: f64,
579    /// Weighted false positives
580    pub weighted_fp: f64,
581    /// Weighted false negatives
582    pub weighted_fn: f64,
583}
584
585// =============================================================================
586// Tests
587// =============================================================================
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn test_annotation_overlap() {
595        let a1 = Annotation::new("Barack Obama", 0, 12, "PER", "A");
596        let a2 = Annotation::new("Obama", 7, 12, "PER", "B");
597        let a3 = Annotation::new("Hawaii", 20, 26, "LOC", "A");
598
599        assert!(a1.overlaps(&a2));
600        assert!(!a1.overlaps(&a3));
601    }
602
603    #[test]
604    fn test_multi_annotator_corpus() {
605        let mut corpus = MultiAnnotatorCorpus::new();
606
607        corpus.add_annotation(
608            "doc1",
609            "annotator_A",
610            vec![("Barack Obama", 0, 12, "PER"), ("Hawaii", 25, 31, "LOC")],
611        );
612        corpus.add_annotation(
613            "doc1",
614            "annotator_B",
615            vec![
616                ("Barack Obama", 0, 12, "PER"),
617                ("Hawaii", 25, 31, "GPE"), // Different type
618            ],
619        );
620
621        assert_eq!(corpus.num_annotators(), 2);
622        assert_eq!(corpus.num_documents(), 1);
623    }
624
625    #[test]
626    fn test_agreement_computation() {
627        let mut corpus = MultiAnnotatorCorpus::new();
628
629        corpus.add_annotation(
630            "doc1",
631            "A",
632            vec![("Obama", 0, 5, "PER"), ("Hawaii", 10, 16, "LOC")],
633        );
634        corpus.add_annotation(
635            "doc1",
636            "B",
637            vec![("Obama", 0, 5, "PER"), ("Hawaii", 10, 16, "LOC")],
638        );
639
640        let analyzer = AnnotatorAnalyzer::new(&corpus);
641        let stats = analyzer.compute_agreement();
642
643        // Full agreement
644        assert_eq!(stats.span_agreement, 1.0);
645        assert_eq!(stats.type_agreement, 1.0);
646    }
647
648    #[test]
649    fn test_contentious_spans() {
650        let mut corpus = MultiAnnotatorCorpus::new();
651
652        corpus.add_annotation("doc1", "A", vec![("Apple", 0, 5, "ORG")]);
653        corpus.add_annotation("doc1", "B", vec![("Apple", 0, 5, "PRODUCT")]);
654        corpus.add_annotation("doc1", "C", vec![("Apple", 0, 5, "ORG")]);
655
656        let analyzer = AnnotatorAnalyzer::new(&corpus);
657        let stats = analyzer.compute_agreement();
658
659        // Should have contentious span for Apple
660        assert!(!stats.contentious_spans.is_empty());
661        assert_eq!(stats.contentious_spans[0].text, "Apple");
662    }
663
664    #[test]
665    fn test_aggregate_gold() {
666        let mut corpus = MultiAnnotatorCorpus::new();
667
668        corpus.add_annotation("doc1", "A", vec![("Apple", 0, 5, "ORG")]);
669        corpus.add_annotation("doc1", "B", vec![("Apple", 0, 5, "ORG")]);
670        corpus.add_annotation("doc1", "C", vec![("Apple", 0, 5, "PRODUCT")]);
671
672        let analyzer = AnnotatorAnalyzer::new(&corpus);
673        let gold = analyzer.aggregate_gold("doc1");
674
675        assert_eq!(gold.len(), 1);
676        assert_eq!(gold[0].0.entity_type, "ORG"); // Majority vote
677        assert!(gold[0].1 > 0.5); // > 50% agreement
678    }
679
680    #[test]
681    fn test_soft_evaluation() {
682        let predictions = vec![Annotation::new("Obama", 0, 5, "PER", "model")];
683        let gold_with_conf = vec![(Annotation::new("Obama", 0, 5, "PER", "gold"), 0.9)];
684
685        let evaluator = SoftEvaluator::new(0.5);
686        let metrics = evaluator.evaluate(&predictions, &gold_with_conf);
687
688        assert!(metrics.f1 > 0.8); // Should be close to 1.0 weighted by confidence
689    }
690}