Skip to main content

anno_eval/eval/
analysis.rs

1//! Error analysis and diagnostics for NER evaluation.
2//!
3//! Provides tools to understand *why* a model makes mistakes:
4//! - Confusion matrix (which types get confused)
5//! - Error categorization (boundary, type, spurious, missed)
6//! - Statistical significance testing between systems
7
8use super::datasets::GoldEntity;
9use anno::Entity;
10use std::collections::HashMap;
11
12// =============================================================================
13// Confusion Matrix
14// =============================================================================
15
16/// Confusion matrix for entity type predictions.
17///
18/// Shows which entity types get confused with which others.
19#[derive(Debug, Clone, Default)]
20pub struct ConfusionMatrix {
21    /// Matrix[predicted][actual] = count
22    matrix: HashMap<String, HashMap<String, usize>>,
23    /// Total predictions per type
24    pub predicted_totals: HashMap<String, usize>,
25    /// Total ground truth per type
26    pub actual_totals: HashMap<String, usize>,
27}
28
29impl ConfusionMatrix {
30    /// Create a new empty confusion matrix.
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Add a prediction-actual pair.
37    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    /// Get count for a prediction-actual pair.
52    #[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    /// Get all entity types in the matrix.
62    #[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    /// Get per-type precision.
76    #[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    /// Get per-type recall.
88    #[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    /// Get most confused pairs (predicted, actual, count).
100    #[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        // Header
123        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        // Rows
130        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// =============================================================================
150// Error Categorization
151// =============================================================================
152
153/// Categories of NER errors.
154///
155/// Note: This type overlaps with `ErrorCategory` in the `error_analysis` module
156/// (available with the `eval` feature). The mapping is:
157/// - `TypeMismatch` ↔ `ErrorCategory::TypeError`
158/// - `BoundaryError` ↔ `ErrorCategory::BoundaryError`
159/// - `BoundaryAndType` ↔ `ErrorCategory::PartialMatch`
160/// - `Spurious` ↔ `ErrorCategory::FalsePositive`
161/// - `Missed` ↔ `ErrorCategory::FalseNegative`
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163pub enum ErrorType {
164    /// Correct span, wrong type
165    TypeMismatch,
166    /// Overlapping span, correct type (boundary error)
167    BoundaryError,
168    /// Overlapping span, wrong type
169    BoundaryAndType,
170    /// Predicted entity with no ground truth match
171    Spurious,
172    /// Ground truth entity with no prediction
173    Missed,
174}
175
176#[cfg(feature = "eval")]
177impl ErrorType {
178    /// Convert to the equivalent [`super::error_analysis::ErrorCategory`].
179    #[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/// A single NER error instance.
193#[derive(Debug, Clone)]
194pub struct NERError {
195    /// Error category
196    pub error_type: ErrorType,
197    /// Predicted entity (if any)
198    pub predicted: Option<Entity>,
199    /// Ground truth entity (if any)
200    pub gold: Option<GoldEntity>,
201    /// Text context
202    pub context: String,
203}
204
205/// Error analysis results.
206#[derive(Debug, Clone, Default)]
207pub struct ErrorAnalysis {
208    /// Errors by category
209    pub errors: Vec<NERError>,
210    /// Count by error type
211    pub counts: HashMap<ErrorType, usize>,
212    /// Total predictions
213    pub total_predictions: usize,
214    /// Total ground truth
215    pub total_gold: usize,
216}
217
218impl ErrorAnalysis {
219    /// Create a new error analysis.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Analyze predictions against ground truth.
226    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        // Check each prediction
234        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                // Check for exact match
244                if pred.start() == g.start && pred.end() == g.end {
245                    if pred.entity_type == g.entity_type {
246                        // Correct - not an error
247                        gold_matched[i] = true;
248                        is_perfect_match = true;
249                        break;
250                    } else {
251                        best_match = Some((i, ErrorType::TypeMismatch));
252                    }
253                }
254                // Check for overlap
255                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            // Skip perfect matches - they're not errors
268            if is_perfect_match {
269                continue;
270            }
271
272            if let Some((gold_idx, error_type)) = best_match {
273                // Found a match but with an error (type/boundary)
274                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                // Extract context using character offsets (not byte offsets)
280                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                // This prediction didn't match any unmatched gold entity.
295                // Check if it would have matched a gold that was already matched
296                // by a previous prediction - if so, this is a duplicate/spurious prediction.
297                //
298                // Note: We check gold_matched[i] to see if the gold was already matched.
299                // If a gold was already matched AND this prediction matches it exactly,
300                // then this prediction is a duplicate (spurious), not a correct match.
301                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                // This prediction is spurious (either duplicate or no match)
309                // Both duplicates and non-matching predictions are spurious
310                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                // Extract context using character offsets (not byte offsets)
315                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        // Check for missed entities
332        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                // Extract context using character offsets (not byte offsets)
339                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    /// Get error rate by type.
359    #[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    /// Summary of error distribution.
371    #[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// =============================================================================
401// Statistical Significance for NER
402// =============================================================================
403
404/// Result of paired significance test for NER systems.
405#[derive(Debug, Clone)]
406pub struct NERSignificanceTest {
407    /// System A name
408    pub system_a: String,
409    /// System B name
410    pub system_b: String,
411    /// System A mean F1
412    pub mean_a: f64,
413    /// System B mean F1
414    pub mean_b: f64,
415    /// Difference (A - B)
416    pub difference: f64,
417    /// Standard error of difference
418    pub std_error: f64,
419    /// t-statistic
420    pub t_statistic: f64,
421    /// p-value (two-tailed)
422    pub p_value: f64,
423    /// Number of test cases
424    pub n: usize,
425    /// Significant at p < 0.05?
426    pub significant_05: bool,
427    /// Significant at p < 0.01?
428    pub significant_01: bool,
429}
430
431impl NERSignificanceTest {
432    /// Perform paired t-test on F1 scores.
433    #[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        // Approximate p-value
488        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/// Compare two NER systems with significance testing.
541#[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/// Build confusion matrix from predictions and ground truth.
552#[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            // Find best matching gold entity
563            for (i, gold) in golds.iter().enumerate() {
564                if gold_matched[i] {
565                    continue;
566                }
567
568                // Check for overlap
569                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        // Count missed entities
579        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// =============================================================================
591// Tests
592// =============================================================================
593
594#[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"); // Confusion
605        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        // Precision for PER: 2/3
612        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        // Note: "Microsoft" (20-29) overlaps with "Google" (20-26)
643        // Both are Organization, so this is a BoundaryError
644        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        // "Microsoft" overlaps with "Google" (same type) -> BoundaryError (not Spurious)
657        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        // John Smith matches exactly, so no errors from that
667        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        // Systems with same performance
681        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), // Wrong type
707        ];
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            // Predicted "John Smith" but gold is "Dr. John Smith"
728            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        // All error counts should be 0
755        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        // 10 correct PER predictions
794        for _ in 0..10 {
795            cm.add("PER", "PER");
796        }
797        // 2 PER predicted as ORG
798        cm.add("ORG", "PER");
799        cm.add("ORG", "PER");
800
801        // Recall for PER: 10 / 12 = 0.833
802        assert!((cm.recall("PER") - 10.0 / 12.0).abs() < 0.01);
803
804        // Precision for PER: 10 / 10 = 1.0
805        assert!((cm.precision("PER") - 1.0).abs() < 0.01);
806    }
807}