1use super::datasets::GoldEntity;
9use anno::Entity;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Default)]
20pub struct ConfusionMatrix {
21 matrix: HashMap<String, HashMap<String, usize>>,
23 pub predicted_totals: HashMap<String, usize>,
25 pub actual_totals: HashMap<String, usize>,
27}
28
29impl ConfusionMatrix {
30 #[must_use]
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn add(&mut self, predicted: &str, actual: &str) {
38 *self
39 .matrix
40 .entry(predicted.to_string())
41 .or_default()
42 .entry(actual.to_string())
43 .or_insert(0) += 1;
44 *self
45 .predicted_totals
46 .entry(predicted.to_string())
47 .or_insert(0) += 1;
48 *self.actual_totals.entry(actual.to_string()).or_insert(0) += 1;
49 }
50
51 #[must_use]
53 pub fn get(&self, predicted: &str, actual: &str) -> usize {
54 self.matrix
55 .get(predicted)
56 .and_then(|row| row.get(actual))
57 .copied()
58 .unwrap_or(0)
59 }
60
61 #[must_use]
63 pub fn types(&self) -> Vec<String> {
64 let mut types: Vec<String> = self
65 .predicted_totals
66 .keys()
67 .chain(self.actual_totals.keys())
68 .cloned()
69 .collect();
70 types.sort();
71 types.dedup();
72 types
73 }
74
75 #[must_use]
77 pub fn precision(&self, entity_type: &str) -> f64 {
78 let correct = self.get(entity_type, entity_type);
79 let predicted = self.predicted_totals.get(entity_type).copied().unwrap_or(0);
80 if predicted == 0 {
81 0.0
82 } else {
83 correct as f64 / predicted as f64
84 }
85 }
86
87 #[must_use]
89 pub fn recall(&self, entity_type: &str) -> f64 {
90 let correct = self.get(entity_type, entity_type);
91 let actual = self.actual_totals.get(entity_type).copied().unwrap_or(0);
92 if actual == 0 {
93 0.0
94 } else {
95 correct as f64 / actual as f64
96 }
97 }
98
99 #[must_use]
101 pub fn most_confused(&self, top_n: usize) -> Vec<(String, String, usize)> {
102 let mut confusions: Vec<(String, String, usize)> = Vec::new();
103
104 for (pred, actuals) in &self.matrix {
105 for (actual, &count) in actuals {
106 if pred != actual && count > 0 {
107 confusions.push((pred.clone(), actual.clone(), count));
108 }
109 }
110 }
111
112 confusions.sort_by_key(|b| std::cmp::Reverse(b.2));
113 confusions.truncate(top_n);
114 confusions
115 }
116}
117
118impl std::fmt::Display for ConfusionMatrix {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 let types = self.types();
121
122 write!(f, "{:12}", "Pred\\Actual")?;
124 for t in &types {
125 write!(f, " {:>8}", &t[..t.len().min(8)])?;
126 }
127 writeln!(f)?;
128
129 for pred in &types {
131 write!(f, "{:12}", &pred[..pred.len().min(12)])?;
132 for actual in &types {
133 let count = self.get(pred, actual);
134 if pred == actual {
135 write!(f, " {:>8}", format!("[{}]", count))?;
136 } else if count > 0 {
137 write!(f, " {:>8}", count)?;
138 } else {
139 write!(f, " {:>8}", ".")?;
140 }
141 }
142 writeln!(f)?;
143 }
144
145 Ok(())
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163pub enum ErrorType {
164 TypeMismatch,
166 BoundaryError,
168 BoundaryAndType,
170 Spurious,
172 Missed,
174}
175
176#[cfg(feature = "eval")]
177impl ErrorType {
178 #[must_use]
180 pub fn to_error_category(self) -> super::error_analysis::ErrorCategory {
181 use super::error_analysis::ErrorCategory;
182 match self {
183 ErrorType::TypeMismatch => ErrorCategory::TypeError,
184 ErrorType::BoundaryError => ErrorCategory::BoundaryError,
185 ErrorType::BoundaryAndType => ErrorCategory::PartialMatch,
186 ErrorType::Spurious => ErrorCategory::FalsePositive,
187 ErrorType::Missed => ErrorCategory::FalseNegative,
188 }
189 }
190}
191
192#[derive(Debug, Clone)]
194pub struct NERError {
195 pub error_type: ErrorType,
197 pub predicted: Option<Entity>,
199 pub gold: Option<GoldEntity>,
201 pub context: String,
203}
204
205#[derive(Debug, Clone, Default)]
207pub struct ErrorAnalysis {
208 pub errors: Vec<NERError>,
210 pub counts: HashMap<ErrorType, usize>,
212 pub total_predictions: usize,
214 pub total_gold: usize,
216}
217
218impl ErrorAnalysis {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn analyze(text: &str, predicted: &[Entity], gold: &[GoldEntity]) -> Self {
227 let mut analysis = Self::new();
228 analysis.total_predictions = predicted.len();
229 analysis.total_gold = gold.len();
230
231 let mut gold_matched = vec![false; gold.len()];
232
233 for pred in predicted {
235 let mut best_match: Option<(usize, ErrorType)> = None;
236 let mut is_perfect_match = false;
237
238 for (i, g) in gold.iter().enumerate() {
239 if gold_matched[i] {
240 continue;
241 }
242
243 if pred.start() == g.start && pred.end() == g.end {
245 if pred.entity_type == g.entity_type {
246 gold_matched[i] = true;
248 is_perfect_match = true;
249 break;
250 } else {
251 best_match = Some((i, ErrorType::TypeMismatch));
252 }
253 }
254 else if pred.start() < g.end && pred.end() > g.start {
256 let error = if pred.entity_type == g.entity_type {
257 ErrorType::BoundaryError
258 } else {
259 ErrorType::BoundaryAndType
260 };
261 if best_match.is_none() {
262 best_match = Some((i, error));
263 }
264 }
265 }
266
267 if is_perfect_match {
269 continue;
270 }
271
272 if let Some((gold_idx, error_type)) = best_match {
273 gold_matched[gold_idx] = true;
275 let char_count = text.chars().count();
276 let context_start = pred.start().saturating_sub(20);
277 let context_end = (pred.end() + 20).min(char_count);
278
279 let context: String = text
281 .chars()
282 .skip(context_start)
283 .take(context_end.saturating_sub(context_start))
284 .collect();
285
286 analysis.errors.push(NERError {
287 error_type,
288 predicted: Some(pred.clone()),
289 gold: Some(gold[gold_idx].clone()),
290 context,
291 });
292 *analysis.counts.entry(error_type).or_insert(0) += 1;
293 } else {
294 let _is_duplicate = gold.iter().enumerate().any(|(i, g)| {
302 gold_matched[i]
303 && pred.start() == g.start
304 && pred.end() == g.end
305 && pred.entity_type == g.entity_type
306 });
307
308 let char_count = text.chars().count();
311 let context_start = pred.start().saturating_sub(20);
312 let context_end = (pred.end() + 20).min(char_count);
313
314 let context: String = text
316 .chars()
317 .skip(context_start)
318 .take(context_end.saturating_sub(context_start))
319 .collect();
320
321 analysis.errors.push(NERError {
322 error_type: ErrorType::Spurious,
323 predicted: Some(pred.clone()),
324 gold: None,
325 context,
326 });
327 *analysis.counts.entry(ErrorType::Spurious).or_insert(0) += 1;
328 }
329 }
330
331 for (i, g) in gold.iter().enumerate() {
333 if !gold_matched[i] {
334 let char_count = text.chars().count();
335 let context_start = g.start.saturating_sub(20);
336 let context_end = (g.end + 20).min(char_count);
337
338 let context: String = text
340 .chars()
341 .skip(context_start)
342 .take(context_end.saturating_sub(context_start))
343 .collect();
344
345 analysis.errors.push(NERError {
346 error_type: ErrorType::Missed,
347 predicted: None,
348 gold: Some(g.clone()),
349 context,
350 });
351 *analysis.counts.entry(ErrorType::Missed).or_insert(0) += 1;
352 }
353 }
354
355 analysis
356 }
357
358 #[must_use]
360 pub fn error_rate(&self, error_type: ErrorType) -> f64 {
361 let count = self.counts.get(&error_type).copied().unwrap_or(0);
362 let total = self.total_predictions.max(self.total_gold);
363 if total == 0 {
364 0.0
365 } else {
366 count as f64 / total as f64
367 }
368 }
369
370 #[must_use]
372 pub fn summary(&self) -> String {
373 let mut s = String::new();
374 s.push_str(&format!(
375 "Error Analysis ({} predictions, {} gold):\n",
376 self.total_predictions, self.total_gold
377 ));
378
379 for error_type in [
380 ErrorType::TypeMismatch,
381 ErrorType::BoundaryError,
382 ErrorType::BoundaryAndType,
383 ErrorType::Spurious,
384 ErrorType::Missed,
385 ] {
386 let count = self.counts.get(&error_type).copied().unwrap_or(0);
387 let rate = self.error_rate(error_type) * 100.0;
388 s.push_str(&format!(
389 " {:15} {:4} ({:.1}%)\n",
390 format!("{:?}", error_type),
391 count,
392 rate
393 ));
394 }
395
396 s
397 }
398}
399
400#[derive(Debug, Clone)]
406pub struct NERSignificanceTest {
407 pub system_a: String,
409 pub system_b: String,
411 pub mean_a: f64,
413 pub mean_b: f64,
415 pub difference: f64,
417 pub std_error: f64,
419 pub t_statistic: f64,
421 pub p_value: f64,
423 pub n: usize,
425 pub significant_05: bool,
427 pub significant_01: bool,
429}
430
431impl NERSignificanceTest {
432 #[must_use]
434 pub fn paired_t_test(
435 system_a: &str,
436 scores_a: &[f64],
437 system_b: &str,
438 scores_b: &[f64],
439 ) -> Self {
440 assert_eq!(
441 scores_a.len(),
442 scores_b.len(),
443 "Scores must have same length"
444 );
445 let n = scores_a.len();
446
447 if n < 2 {
448 return Self {
449 system_a: system_a.to_string(),
450 system_b: system_b.to_string(),
451 mean_a: scores_a.first().copied().unwrap_or(0.0),
452 mean_b: scores_b.first().copied().unwrap_or(0.0),
453 difference: 0.0,
454 std_error: 0.0,
455 t_statistic: 0.0,
456 p_value: 1.0,
457 n,
458 significant_05: false,
459 significant_01: false,
460 };
461 }
462
463 let differences: Vec<f64> = scores_a
464 .iter()
465 .zip(scores_b.iter())
466 .map(|(a, b)| a - b)
467 .collect();
468
469 let mean_diff = differences.iter().sum::<f64>() / n as f64;
470 let mean_a = scores_a.iter().sum::<f64>() / n as f64;
471 let mean_b = scores_b.iter().sum::<f64>() / n as f64;
472
473 let variance: f64 = differences
474 .iter()
475 .map(|&d| (d - mean_diff).powi(2))
476 .sum::<f64>()
477 / (n - 1) as f64;
478 let std_diff = variance.sqrt();
479 let std_error = std_diff / (n as f64).sqrt();
480
481 let t_stat = if std_error > 0.0 {
482 mean_diff / std_error
483 } else {
484 0.0
485 };
486
487 let p_value = Self::approximate_p_value(t_stat.abs(), n - 1);
489
490 Self {
491 system_a: system_a.to_string(),
492 system_b: system_b.to_string(),
493 mean_a,
494 mean_b,
495 difference: mean_diff,
496 std_error,
497 t_statistic: t_stat,
498 p_value,
499 n,
500 significant_05: p_value < 0.05,
501 significant_01: p_value < 0.01,
502 }
503 }
504
505 fn approximate_p_value(t: f64, df: usize) -> f64 {
506 let critical_05 = if df >= 30 { 1.96 } else { 2.1 };
507 let critical_01 = if df >= 30 { 2.576 } else { 2.9 };
508
509 if t < critical_05 {
510 0.10
511 } else if t < critical_01 {
512 0.03
513 } else {
514 0.005
515 }
516 }
517}
518
519impl std::fmt::Display for NERSignificanceTest {
520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521 writeln!(f, "Paired t-test (n={}):", self.n)?;
522 writeln!(f, " {}: {:.1}%", self.system_a, self.mean_a * 100.0)?;
523 writeln!(f, " {}: {:.1}%", self.system_b, self.mean_b * 100.0)?;
524 writeln!(f, " Difference: {:+.1}%", self.difference * 100.0)?;
525 writeln!(f, " t={:.3}, p={:.4}", self.t_statistic, self.p_value)?;
526
527 let sig = if self.significant_01 {
528 "** (p < 0.01)"
529 } else if self.significant_05 {
530 "* (p < 0.05)"
531 } else {
532 "not significant"
533 };
534 writeln!(f, " {}", sig)?;
535
536 Ok(())
537 }
538}
539
540#[must_use]
542pub fn compare_ner_systems(
543 system_a: &str,
544 f1_scores_a: &[f64],
545 system_b: &str,
546 f1_scores_b: &[f64],
547) -> NERSignificanceTest {
548 NERSignificanceTest::paired_t_test(system_a, f1_scores_a, system_b, f1_scores_b)
549}
550
551#[must_use]
553pub fn build_confusion_matrix(predictions: &[(Vec<Entity>, Vec<GoldEntity>)]) -> ConfusionMatrix {
554 let mut matrix = ConfusionMatrix::new();
555
556 for (preds, golds) in predictions {
557 let mut gold_matched = vec![false; golds.len()];
558
559 for pred in preds {
560 let pred_type = pred.entity_type.as_label().to_string();
561
562 for (i, gold) in golds.iter().enumerate() {
564 if gold_matched[i] {
565 continue;
566 }
567
568 if pred.start() < gold.end && pred.end() > gold.start {
570 let gold_type = gold.entity_type.as_label().to_string();
571 matrix.add(&pred_type, &gold_type);
572 gold_matched[i] = true;
573 break;
574 }
575 }
576 }
577
578 for (i, gold) in golds.iter().enumerate() {
580 if !gold_matched[i] {
581 let gold_type = gold.entity_type.as_label().to_string();
582 matrix.add("MISSED", &gold_type);
583 }
584 }
585 }
586
587 matrix
588}
589
590#[cfg(test)]
595mod tests {
596 use super::*;
597 use anno::EntityType;
598
599 #[test]
600 fn test_confusion_matrix() {
601 let mut cm = ConfusionMatrix::new();
602 cm.add("PER", "PER");
603 cm.add("PER", "PER");
604 cm.add("PER", "ORG"); cm.add("ORG", "ORG");
606
607 assert_eq!(cm.get("PER", "PER"), 2);
608 assert_eq!(cm.get("PER", "ORG"), 1);
609 assert_eq!(cm.get("ORG", "ORG"), 1);
610
611 assert!((cm.precision("PER") - 2.0 / 3.0).abs() < 0.01);
613 }
614
615 #[test]
616 fn test_most_confused() {
617 let mut cm = ConfusionMatrix::new();
618 cm.add("PER", "ORG");
619 cm.add("PER", "ORG");
620 cm.add("LOC", "ORG");
621
622 let confused = cm.most_confused(2);
623 assert_eq!(confused.len(), 2);
624 assert_eq!(confused[0], ("PER".to_string(), "ORG".to_string(), 2));
625 }
626
627 #[test]
628 fn test_significance_test() {
629 let scores_a = vec![0.85, 0.82, 0.88, 0.79, 0.84];
630 let scores_b = vec![0.78, 0.76, 0.82, 0.74, 0.79];
631
632 let test = compare_ner_systems("A", &scores_a, "B", &scores_b);
633
634 assert!(test.mean_a > test.mean_b);
635 assert!(test.difference > 0.0);
636 }
637
638 #[test]
639 fn test_error_analysis() {
640 let text = "John Smith works at Google in New York.";
641
642 let predicted = vec![
645 Entity::new("John Smith", EntityType::Person, 0, 10, 0.9),
646 Entity::new("Microsoft", EntityType::Organization, 20, 29, 0.8),
647 ];
648
649 let gold = vec![
650 GoldEntity::new("John Smith", EntityType::Person, 0),
651 GoldEntity::new("Google", EntityType::Organization, 20),
652 ];
653
654 let analysis = ErrorAnalysis::analyze(text, &predicted, &gold);
655
656 assert_eq!(
658 analysis
659 .counts
660 .get(&ErrorType::BoundaryError)
661 .copied()
662 .unwrap_or(0),
663 1,
664 "Expected boundary error for Microsoft/Google overlap"
665 );
666 assert_eq!(
668 analysis
669 .counts
670 .get(&ErrorType::TypeMismatch)
671 .copied()
672 .unwrap_or(0),
673 0,
674 "Expected no type mismatches"
675 );
676 }
677
678 #[test]
679 fn test_significance_equal_systems() {
680 let scores = vec![0.80, 0.81, 0.79, 0.80, 0.80];
682 let test = compare_ner_systems("A", &scores, "B", &scores);
683
684 assert!((test.difference).abs() < 0.001);
685 assert!(!test.significant_05);
686 }
687
688 #[test]
689 fn test_confusion_matrix_display() {
690 let mut cm = ConfusionMatrix::new();
691 cm.add("PER", "PER");
692 cm.add("ORG", "ORG");
693 cm.add("LOC", "LOC");
694
695 let display = format!("{}", cm);
696 assert!(display.contains("PER"));
697 assert!(display.contains("ORG"));
698 assert!(display.contains("LOC"));
699 }
700
701 #[test]
702 fn test_error_type_mismatch() {
703 let text = "Test Person here.";
704
705 let predicted = vec![
706 Entity::new("Person", EntityType::Organization, 5, 11, 0.9), ];
708
709 let gold = vec![GoldEntity::new("Person", EntityType::Person, 5)];
710
711 let analysis = ErrorAnalysis::analyze(text, &predicted, &gold);
712 assert_eq!(
713 analysis
714 .counts
715 .get(&ErrorType::TypeMismatch)
716 .copied()
717 .unwrap_or(0),
718 1
719 );
720 }
721
722 #[test]
723 fn test_error_boundary() {
724 let text = "Dr. John Smith is here.";
725
726 let predicted = vec![
727 Entity::new("John Smith", EntityType::Person, 4, 14, 0.9),
729 ];
730
731 let gold = vec![GoldEntity::new("Dr. John Smith", EntityType::Person, 0)];
732
733 let analysis = ErrorAnalysis::analyze(text, &predicted, &gold);
734 assert_eq!(
735 analysis
736 .counts
737 .get(&ErrorType::BoundaryError)
738 .copied()
739 .unwrap_or(0),
740 1
741 );
742 }
743
744 #[test]
745 fn test_perfect_match_no_errors() {
746 let text = "John Smith works here.";
747
748 let predicted = vec![Entity::new("John Smith", EntityType::Person, 0, 10, 0.9)];
749
750 let gold = vec![GoldEntity::new("John Smith", EntityType::Person, 0)];
751
752 let analysis = ErrorAnalysis::analyze(text, &predicted, &gold);
753
754 assert_eq!(
756 analysis
757 .counts
758 .get(&ErrorType::TypeMismatch)
759 .copied()
760 .unwrap_or(0),
761 0
762 );
763 assert_eq!(
764 analysis
765 .counts
766 .get(&ErrorType::BoundaryError)
767 .copied()
768 .unwrap_or(0),
769 0
770 );
771 assert_eq!(
772 analysis
773 .counts
774 .get(&ErrorType::Spurious)
775 .copied()
776 .unwrap_or(0),
777 0
778 );
779 assert_eq!(
780 analysis
781 .counts
782 .get(&ErrorType::Missed)
783 .copied()
784 .unwrap_or(0),
785 0
786 );
787 }
788
789 #[test]
790 fn test_recall_precision_from_confusion() {
791 let mut cm = ConfusionMatrix::new();
792
793 for _ in 0..10 {
795 cm.add("PER", "PER");
796 }
797 cm.add("ORG", "PER");
799 cm.add("ORG", "PER");
800
801 assert!((cm.recall("PER") - 10.0 / 12.0).abs() < 0.01);
803
804 assert!((cm.precision("PER") - 1.0).abs() < 0.01);
806 }
807}