1use serde::{Deserialize, Serialize};
56use std::collections::{HashMap, HashSet};
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub struct Annotation {
65 pub text: String,
67 pub start: usize,
69 pub end: usize,
71 pub entity_type: String,
73 pub annotator: String,
75}
76
77impl Annotation {
78 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 pub fn overlaps(&self, other: &Self) -> bool {
91 self.start < other.end && other.start < self.end
92 }
93
94 pub fn same_span(&self, other: &Self) -> bool {
96 self.start == other.start && self.end == other.end
97 }
98}
99
100#[derive(Debug, Clone, Default)]
102pub struct AnnotatedDocument {
103 pub doc_id: String,
105 pub text: String,
107 pub annotations: HashMap<String, Vec<Annotation>>,
109}
110
111impl AnnotatedDocument {
112 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 pub fn add_annotator(&mut self, annotator: &str, annotations: Vec<Annotation>) {
123 self.annotations.insert(annotator.to_string(), annotations);
124 }
125
126 pub fn annotators(&self) -> Vec<&str> {
128 self.annotations.keys().map(|s| s.as_str()).collect()
129 }
130
131 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#[derive(Debug, Clone, Default)]
142pub struct MultiAnnotatorCorpus {
143 pub documents: HashMap<String, AnnotatedDocument>,
145 pub annotators: HashSet<String>,
147}
148
149impl MultiAnnotatorCorpus {
150 pub fn new() -> Self {
152 Self::default()
153 }
154
155 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 pub fn num_documents(&self) -> usize {
179 self.documents.len()
180 }
181
182 pub fn num_annotators(&self) -> usize {
184 self.annotators.len()
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct AgreementStats {
195 pub span_agreement: f64,
197 pub type_agreement: f64,
199 pub fleiss_kappa: f64,
201 pub type_specific_agreement: HashMap<String, f64>,
203 pub contentious_spans: Vec<ContentiousSpan>,
205 pub num_annotators: usize,
207 pub num_documents: usize,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ContentiousSpan {
214 pub doc_id: String,
216 pub start: usize,
218 pub end: usize,
220 pub text: String,
222 pub types_assigned: HashMap<String, Vec<String>>, pub disagreement: f64,
226}
227
228pub struct AnnotatorAnalyzer<'a> {
230 corpus: &'a MultiAnnotatorCorpus,
231}
232
233impl<'a> AnnotatorAnalyzer<'a> {
234 pub fn new(corpus: &'a MultiAnnotatorCorpus) -> Self {
236 Self { corpus }
237 }
238
239 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(); 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 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 if annotators_with_span == annotators.len() {
275 span_agree_count += 1;
276 }
277
278 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 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 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 contentious.sort_by(|a, b| {
321 b.disagreement
322 .partial_cmp(&a.disagreement)
323 .unwrap_or(std::cmp::Ordering::Equal)
324 });
325
326 let fleiss_kappa = self.compute_fleiss_kappa();
328
329 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(), num_annotators: self.corpus.num_annotators(),
359 num_documents: self.corpus.num_documents(),
360 }
361 }
362
363 fn compute_fleiss_kappa(&self) -> f64 {
365 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 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 let p_e = 0.5; 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 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 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 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#[derive(Debug, Clone, Default)]
477pub struct SoftEvaluator {
478 pub min_agreement: f64,
480}
481
482impl SoftEvaluator {
483 pub fn new(min_agreement: f64) -> Self {
485 Self { min_agreement }
486 }
487
488 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 let gold: Vec<_> = gold_with_confidence
500 .iter()
501 .filter(|(_, conf)| *conf >= self.min_agreement)
502 .collect();
503
504 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct SoftMetrics {
571 pub precision: f64,
573 pub recall: f64,
575 pub f1: f64,
577 pub weighted_tp: f64,
579 pub weighted_fp: f64,
581 pub weighted_fn: f64,
583}
584
585#[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"), ],
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 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 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"); assert!(gold[0].1 > 0.5); }
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); }
690}