Skip to main content

anno_eval/eval/
error_analysis.rs

1//! Error analysis for NER systems.
2//!
3//! Categorizes and analyzes prediction errors to guide improvement efforts.
4//!
5//! # Error Categories
6//!
7//! - **Boundary Errors**: Correct type but wrong span
8//! - **Type Errors**: Correct span but wrong type
9//! - **False Positives**: Predicted entity that doesn't exist
10//! - **False Negatives**: Missed a real entity
11//!
12//! # Example
13//!
14//! ```rust
15//! use anno_eval::eval::error_analysis::{ErrorAnalyzer, PredictedEntity};
16//! use anno_eval::eval::datasets::GoldEntity;
17//! use anno::EntityType;
18//!
19//! let analyzer = ErrorAnalyzer::default();
20//!
21//! let predictions = vec![
22//!     PredictedEntity::new("John", "PER", 0, 4),
23//!     PredictedEntity::new("Google", "LOC", 14, 20),  // Wrong type!
24//! ];
25//!
26//! let gold = vec![
27//!     GoldEntity::with_span("John Smith", EntityType::Person, 0, 10),    // Boundary error
28//!     GoldEntity::with_span("Google", EntityType::Organization, 14, 20), // Type error
29//! ];
30//!
31//! let report = analyzer.analyze(&predictions, &gold);
32//! println!("Boundary errors: {}", report.boundary_errors.len());
33//! println!("Type errors: {}", report.type_errors.len());
34//! ```
35
36use super::datasets::GoldEntity;
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39
40// =============================================================================
41// Data Structures
42// =============================================================================
43
44/// A predicted entity for error analysis.
45///
46/// Uses string-based entity types to allow comparison across different
47/// labeling schemes without requiring type normalization.
48#[derive(Debug, Clone)]
49pub struct PredictedEntity {
50    /// Entity text
51    pub text: String,
52    /// Predicted type (as string label, e.g., "PER", "PERSON", "B-PER")
53    pub entity_type: String,
54    /// Start offset
55    pub start: usize,
56    /// End offset
57    pub end: usize,
58    /// Prediction confidence
59    pub confidence: f64,
60}
61
62impl PredictedEntity {
63    /// Create a new predicted entity.
64    pub fn new(
65        text: impl Into<String>,
66        entity_type: impl Into<String>,
67        start: usize,
68        end: usize,
69    ) -> Self {
70        Self {
71            text: text.into(),
72            entity_type: entity_type.into(),
73            start,
74            end,
75            confidence: 1.0,
76        }
77    }
78
79    /// Set confidence.
80    pub fn with_confidence(mut self, confidence: f64) -> Self {
81        self.confidence = confidence;
82        self
83    }
84
85    /// Create from an anno Entity.
86    pub fn from_entity(entity: &anno::Entity) -> Self {
87        Self {
88            text: entity.text.clone(),
89            entity_type: entity.entity_type.as_label().to_string(),
90            start: entity.start(),
91            end: entity.end(),
92            confidence: entity.confidence.into(),
93        }
94    }
95}
96
97/// A specific error instance.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ErrorInstance {
100    /// Error category
101    pub category: ErrorCategory,
102    /// Predicted entity (if any)
103    pub predicted: Option<EntityInfo>,
104    /// Gold entity (if any)
105    pub gold: Option<EntityInfo>,
106    /// Error description
107    pub description: String,
108}
109
110/// Entity information for error reports.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EntityInfo {
113    /// Entity text
114    pub text: String,
115    /// Entity type (as string label for cross-schema comparison)
116    pub entity_type: String,
117    /// Span
118    pub span: (usize, usize),
119}
120
121/// Error category for NER analysis.
122///
123/// Note: This type overlaps with `ErrorType` in the `analysis` module.
124/// The mapping is:
125/// - `TypeError` ↔ `ErrorType::TypeMismatch`
126/// - `BoundaryError` ↔ `ErrorType::BoundaryError`
127/// - `PartialMatch` ↔ `ErrorType::BoundaryAndType`
128/// - `FalsePositive` ↔ `ErrorType::Spurious`
129/// - `FalseNegative` ↔ `ErrorType::Missed`
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131pub enum ErrorCategory {
132    /// Correct type but wrong boundaries
133    BoundaryError,
134    /// Correct boundaries but wrong type
135    TypeError,
136    /// Predicted entity that doesn't exist
137    FalsePositive,
138    /// Missed a real entity
139    FalseNegative,
140    /// Both boundary and type are wrong but overlapping
141    PartialMatch,
142}
143
144impl ErrorCategory {
145    /// Convert to the equivalent [`super::analysis::ErrorType`].
146    #[must_use]
147    pub fn to_error_type(self) -> super::analysis::ErrorType {
148        use super::analysis::ErrorType;
149        match self {
150            ErrorCategory::TypeError => ErrorType::TypeMismatch,
151            ErrorCategory::BoundaryError => ErrorType::BoundaryError,
152            ErrorCategory::PartialMatch => ErrorType::BoundaryAndType,
153            ErrorCategory::FalsePositive => ErrorType::Spurious,
154            ErrorCategory::FalseNegative => ErrorType::Missed,
155        }
156    }
157}
158
159/// Comprehensive error analysis report.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ErrorReport {
162    /// Boundary errors
163    pub boundary_errors: Vec<ErrorInstance>,
164    /// Type errors
165    pub type_errors: Vec<ErrorInstance>,
166    /// False positives
167    pub false_positives: Vec<ErrorInstance>,
168    /// False negatives
169    pub false_negatives: Vec<ErrorInstance>,
170    /// Partial matches
171    pub partial_matches: Vec<ErrorInstance>,
172    /// Error counts by category
173    pub counts: HashMap<String, usize>,
174    /// Error rates by category
175    pub rates: HashMap<String, f64>,
176    /// Most common error patterns
177    pub common_patterns: Vec<ErrorPattern>,
178    /// Per-type error breakdown
179    pub by_type: HashMap<String, TypeErrorStats>,
180    /// Recommendations
181    pub recommendations: Vec<String>,
182}
183
184/// Common error pattern.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ErrorPattern {
187    /// Pattern description
188    pub description: String,
189    /// Number of occurrences
190    pub count: usize,
191    /// Example errors
192    pub examples: Vec<String>,
193}
194
195/// Error statistics for a specific entity type.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct TypeErrorStats {
198    /// Total gold entities of this type
199    pub gold_count: usize,
200    /// Correct predictions
201    pub correct: usize,
202    /// Boundary errors
203    pub boundary_errors: usize,
204    /// Confused with other types (type -> count)
205    pub confused_with: HashMap<String, usize>,
206    /// False negatives
207    pub missed: usize,
208}
209
210// =============================================================================
211// Error Analyzer
212// =============================================================================
213
214/// Analyzer for NER prediction errors.
215///
216/// Uses an optimized O(n + m) algorithm with spatial indexing instead of
217/// naive O(n*m) nested loops. For datasets with >1000 entities, this provides
218/// significant speedup.
219#[derive(Debug, Clone)]
220pub struct ErrorAnalyzer {
221    /// Overlap threshold for partial match (IoU)
222    pub overlap_threshold: f64,
223}
224
225impl Default for ErrorAnalyzer {
226    fn default() -> Self {
227        Self {
228            overlap_threshold: 0.5,
229        }
230    }
231}
232
233impl ErrorAnalyzer {
234    /// Create analyzer with custom overlap threshold.
235    pub fn new(overlap_threshold: f64) -> Self {
236        Self { overlap_threshold }
237    }
238
239    /// Analyze errors between predictions and gold entities.
240    ///
241    /// Uses the canonical `GoldEntity` type from `eval::datasets`.
242    /// Entity types are compared using their string labels.
243    pub fn analyze(&self, predictions: &[PredictedEntity], gold: &[GoldEntity]) -> ErrorReport {
244        let mut boundary_errors = Vec::new();
245        let mut type_errors = Vec::new();
246        let mut false_positives = Vec::new();
247        let mut false_negatives = Vec::new();
248        let mut partial_matches = Vec::new();
249
250        let mut matched_preds = vec![false; predictions.len()];
251        let mut matched_gold = vec![false; gold.len()];
252
253        // Build spatial index for predictions (sorted by start position)
254        let mut pred_by_start: Vec<(usize, usize, usize)> = predictions
255            .iter()
256            .enumerate()
257            .map(|(i, p)| (p.start, p.end, i))
258            .collect();
259        pred_by_start.sort_by_key(|x| x.0);
260
261        // For each gold entity, find candidate predictions using binary search
262        for (gi, g) in gold.iter().enumerate() {
263            let g_type = g.entity_type.as_label();
264
265            // Find predictions that could overlap with this gold entity
266            // A prediction overlaps if pred.start < g.end && pred.end > g.start
267            let candidates: Vec<usize> = pred_by_start
268                .iter()
269                .filter(|(p_start, p_end, _)| *p_start < g.end && *p_end > g.start)
270                .map(|(_, _, idx)| *idx)
271                .collect();
272
273            let mut best_match: Option<(usize, f64, bool, bool)> = None; // (idx, overlap, exact_boundary, type_match)
274
275            for pi in candidates {
276                if matched_preds[pi] {
277                    continue;
278                }
279
280                let p = &predictions[pi];
281                let exact_boundary = p.start == g.start && p.end == g.end;
282                let type_match = p.entity_type == g_type;
283                let overlap = self.compute_overlap(p.start, p.end, g.start, g.end);
284
285                // Prefer exact matches, then type matches, then highest overlap
286                let dominated =
287                    best_match.is_some_and(|(_, best_overlap, best_exact, best_type)| {
288                        if exact_boundary && !best_exact {
289                            return false;
290                        }
291                        if !exact_boundary && best_exact {
292                            return true;
293                        }
294                        if type_match && !best_type {
295                            return false;
296                        }
297                        if !type_match && best_type {
298                            return true;
299                        }
300                        overlap <= best_overlap
301                    });
302
303                if !dominated && overlap > self.overlap_threshold {
304                    best_match = Some((pi, overlap, exact_boundary, type_match));
305                }
306            }
307
308            if let Some((pi, _overlap, exact_boundary, type_match)) = best_match {
309                let p = &predictions[pi];
310                matched_preds[pi] = true;
311                matched_gold[gi] = true;
312
313                if exact_boundary && type_match {
314                    // Correct prediction - not an error
315                } else if exact_boundary && !type_match {
316                    // Type error
317                    type_errors.push(ErrorInstance {
318                        category: ErrorCategory::TypeError,
319                        predicted: Some(EntityInfo {
320                            text: p.text.clone(),
321                            entity_type: p.entity_type.clone(),
322                            span: (p.start, p.end),
323                        }),
324                        gold: Some(EntityInfo {
325                            text: g.text.clone(),
326                            entity_type: g_type.to_string(),
327                            span: (g.start, g.end),
328                        }),
329                        description: format!(
330                            "Predicted {} as {} (should be {})",
331                            p.text, p.entity_type, g_type
332                        ),
333                    });
334                } else if type_match {
335                    // Boundary error
336                    boundary_errors.push(ErrorInstance {
337                        category: ErrorCategory::BoundaryError,
338                        predicted: Some(EntityInfo {
339                            text: p.text.clone(),
340                            entity_type: p.entity_type.clone(),
341                            span: (p.start, p.end),
342                        }),
343                        gold: Some(EntityInfo {
344                            text: g.text.clone(),
345                            entity_type: g_type.to_string(),
346                            span: (g.start, g.end),
347                        }),
348                        description: format!(
349                            "Predicted '{}' [{},{}] vs gold '{}' [{},{}]",
350                            p.text, p.start, p.end, g.text, g.start, g.end
351                        ),
352                    });
353                } else {
354                    // Partial match with wrong type
355                    partial_matches.push(ErrorInstance {
356                        category: ErrorCategory::PartialMatch,
357                        predicted: Some(EntityInfo {
358                            text: p.text.clone(),
359                            entity_type: p.entity_type.clone(),
360                            span: (p.start, p.end),
361                        }),
362                        gold: Some(EntityInfo {
363                            text: g.text.clone(),
364                            entity_type: g_type.to_string(),
365                            span: (g.start, g.end),
366                        }),
367                        description: format!(
368                            "Partial: '{}' ({}) vs '{}' ({})",
369                            p.text, p.entity_type, g.text, g_type
370                        ),
371                    });
372                }
373            }
374        }
375
376        // Unmatched predictions are false positives
377        for (pi, p) in predictions.iter().enumerate() {
378            if !matched_preds[pi] {
379                false_positives.push(ErrorInstance {
380                    category: ErrorCategory::FalsePositive,
381                    predicted: Some(EntityInfo {
382                        text: p.text.clone(),
383                        entity_type: p.entity_type.clone(),
384                        span: (p.start, p.end),
385                    }),
386                    gold: None,
387                    description: format!(
388                        "Spurious {} '{}' at [{},{}]",
389                        p.entity_type, p.text, p.start, p.end
390                    ),
391                });
392            }
393        }
394
395        // Unmatched gold are false negatives
396        for (gi, g) in gold.iter().enumerate() {
397            if !matched_gold[gi] {
398                let g_type = g.entity_type.as_label();
399                false_negatives.push(ErrorInstance {
400                    category: ErrorCategory::FalseNegative,
401                    predicted: None,
402                    gold: Some(EntityInfo {
403                        text: g.text.clone(),
404                        entity_type: g_type.to_string(),
405                        span: (g.start, g.end),
406                    }),
407                    description: format!(
408                        "Missed {} '{}' at [{},{}]",
409                        g_type, g.text, g.start, g.end
410                    ),
411                });
412            }
413        }
414
415        // Compute statistics
416        let total_errors = boundary_errors.len()
417            + type_errors.len()
418            + false_positives.len()
419            + false_negatives.len()
420            + partial_matches.len();
421
422        let mut counts: HashMap<String, usize> = HashMap::new();
423        counts.insert("boundary_errors".into(), boundary_errors.len());
424        counts.insert("type_errors".into(), type_errors.len());
425        counts.insert("false_positives".into(), false_positives.len());
426        counts.insert("false_negatives".into(), false_negatives.len());
427        counts.insert("partial_matches".into(), partial_matches.len());
428        counts.insert("total".into(), total_errors);
429
430        let mut rates = HashMap::new();
431        if total_errors > 0 {
432            for (k, v) in &counts {
433                rates.insert(k.clone(), *v as f64 / total_errors as f64);
434            }
435        }
436
437        // Per-type analysis
438        let by_type = self.analyze_by_type(&type_errors, &false_negatives, gold);
439
440        // Find common patterns
441        let common_patterns = self.find_common_patterns(&type_errors, &boundary_errors);
442
443        // Generate recommendations
444        let recommendations = self.generate_recommendations(&counts, &by_type);
445
446        ErrorReport {
447            boundary_errors,
448            type_errors,
449            false_positives,
450            false_negatives,
451            partial_matches,
452            counts,
453            rates,
454            common_patterns,
455            by_type,
456            recommendations,
457        }
458    }
459
460    fn compute_overlap(&self, p_start: usize, p_end: usize, g_start: usize, g_end: usize) -> f64 {
461        let intersection_start = p_start.max(g_start);
462        let intersection_end = p_end.min(g_end);
463
464        if intersection_start >= intersection_end {
465            return 0.0;
466        }
467
468        let intersection = intersection_end - intersection_start;
469        let union = (p_end - p_start) + (g_end - g_start) - intersection;
470
471        if union == 0 {
472            0.0
473        } else {
474            intersection as f64 / union as f64
475        }
476    }
477
478    fn analyze_by_type(
479        &self,
480        type_errors: &[ErrorInstance],
481        false_negatives: &[ErrorInstance],
482        gold: &[GoldEntity],
483    ) -> HashMap<String, TypeErrorStats> {
484        let mut stats: HashMap<String, TypeErrorStats> = HashMap::new();
485
486        // Initialize from gold
487        for g in gold {
488            let g_type = g.entity_type.as_label().to_string();
489            let entry = stats.entry(g_type).or_insert(TypeErrorStats {
490                gold_count: 0,
491                correct: 0,
492                boundary_errors: 0,
493                confused_with: HashMap::new(),
494                missed: 0,
495            });
496            entry.gold_count += 1;
497        }
498
499        // Count type confusions
500        for err in type_errors {
501            if let (Some(pred), Some(gold_info)) = (&err.predicted, &err.gold) {
502                if let Some(entry) = stats.get_mut(&gold_info.entity_type) {
503                    *entry
504                        .confused_with
505                        .entry(pred.entity_type.clone())
506                        .or_insert(0) += 1;
507                }
508            }
509        }
510
511        // Count misses
512        for err in false_negatives {
513            if let Some(gold_info) = &err.gold {
514                if let Some(entry) = stats.get_mut(&gold_info.entity_type) {
515                    entry.missed += 1;
516                }
517            }
518        }
519
520        stats
521    }
522
523    fn find_common_patterns(
524        &self,
525        type_errors: &[ErrorInstance],
526        boundary_errors: &[ErrorInstance],
527    ) -> Vec<ErrorPattern> {
528        let mut patterns: HashMap<String, (usize, Vec<String>)> = HashMap::new();
529
530        // Count type confusion patterns
531        for err in type_errors {
532            if let (Some(pred), Some(gold_info)) = (&err.predicted, &err.gold) {
533                let key = format!("{} -> {}", gold_info.entity_type, pred.entity_type);
534                let entry = patterns.entry(key).or_insert((0, Vec::new()));
535                entry.0 += 1;
536                if entry.1.len() < 3 {
537                    entry.1.push(err.description.clone());
538                }
539            }
540        }
541
542        // Count boundary patterns (e.g., "too short", "too long")
543        let mut too_short = 0;
544        let mut too_long = 0;
545
546        for err in boundary_errors {
547            if let (Some(pred), Some(gold_info)) = (&err.predicted, &err.gold) {
548                let pred_len = pred.span.1 - pred.span.0;
549                let gold_len = gold_info.span.1 - gold_info.span.0;
550
551                if pred_len < gold_len {
552                    too_short += 1;
553                } else {
554                    too_long += 1;
555                }
556            }
557        }
558
559        if too_short > 0 {
560            patterns.insert(
561                "Boundary: Predicted span too short".into(),
562                (too_short, vec!["Model truncates entities".into()]),
563            );
564        }
565        if too_long > 0 {
566            patterns.insert(
567                "Boundary: Predicted span too long".into(),
568                (too_long, vec!["Model over-extends entities".into()]),
569            );
570        }
571
572        let mut result: Vec<ErrorPattern> = patterns
573            .into_iter()
574            .map(|(desc, (count, examples))| ErrorPattern {
575                description: desc,
576                count,
577                examples,
578            })
579            .collect();
580
581        result.sort_by_key(|b| std::cmp::Reverse(b.count));
582        result.truncate(10);
583        result
584    }
585
586    fn generate_recommendations(
587        &self,
588        counts: &HashMap<String, usize>,
589        by_type: &HashMap<String, TypeErrorStats>,
590    ) -> Vec<String> {
591        let mut recs = Vec::new();
592
593        let boundary = counts.get("boundary_errors").copied().unwrap_or(0);
594        let type_err = counts.get("type_errors").copied().unwrap_or(0);
595        let fp = counts.get("false_positives").copied().unwrap_or(0);
596        let fn_count = counts.get("false_negatives").copied().unwrap_or(0);
597        let total = counts.get("total").copied().unwrap_or(1).max(1);
598
599        // Boundary recommendations
600        if boundary as f64 / total as f64 > 0.3 {
601            recs.push(
602                "High boundary error rate: Consider boundary-aware training or CRF layer".into(),
603            );
604        }
605
606        // Type confusion recommendations
607        if type_err as f64 / total as f64 > 0.2 {
608            recs.push(
609                "Frequent type confusions: Add more training examples for confused types".into(),
610            );
611
612            // Find most confused pairs
613            for (typ, stats) in by_type {
614                if let Some((confused_type, count)) =
615                    stats.confused_with.iter().max_by_key(|(_, c)| *c)
616                {
617                    if *count > 2 {
618                        recs.push(format!(
619                            "Type {}: Often confused with {} ({} times) - add disambiguation features",
620                            typ, confused_type, count
621                        ));
622                    }
623                }
624            }
625        }
626
627        // False positive recommendations
628        if fp as f64 / total as f64 > 0.25 {
629            recs.push(
630                "High false positive rate: Model is over-predicting - consider higher threshold"
631                    .into(),
632            );
633        }
634
635        // False negative recommendations
636        if fn_count as f64 / total as f64 > 0.25 {
637            recs.push(
638                "High miss rate: Model is under-predicting - consider lower threshold or more data"
639                    .into(),
640            );
641        }
642
643        if recs.is_empty() {
644            recs.push("Error distribution is balanced - continue monitoring".into());
645        }
646
647        recs
648    }
649}
650
651// =============================================================================
652// Tests
653// =============================================================================
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use anno::EntityType;
659
660    #[test]
661    fn test_type_error_detection() {
662        let predictions = vec![PredictedEntity::new("Google", "LOC", 0, 6)];
663        let gold = vec![GoldEntity::with_span(
664            "Google",
665            EntityType::Organization,
666            0,
667            6,
668        )];
669
670        let analyzer = ErrorAnalyzer::default();
671        let report = analyzer.analyze(&predictions, &gold);
672
673        assert_eq!(report.type_errors.len(), 1);
674        assert_eq!(report.boundary_errors.len(), 0);
675    }
676
677    #[test]
678    fn test_boundary_error_detection() {
679        let predictions = vec![PredictedEntity::new("John", "PER", 0, 4)];
680        let gold = vec![GoldEntity::with_span(
681            "John Smith",
682            EntityType::Person,
683            0,
684            10,
685        )];
686
687        let analyzer = ErrorAnalyzer::new(0.3); // Low threshold for partial match
688        let report = analyzer.analyze(&predictions, &gold);
689
690        assert_eq!(report.boundary_errors.len(), 1);
691    }
692
693    #[test]
694    fn test_false_positive_detection() {
695        let predictions = vec![PredictedEntity::new("Random", "PER", 0, 6)];
696        let gold: Vec<GoldEntity> = vec![];
697
698        let analyzer = ErrorAnalyzer::default();
699        let report = analyzer.analyze(&predictions, &gold);
700
701        assert_eq!(report.false_positives.len(), 1);
702    }
703
704    #[test]
705    fn test_false_negative_detection() {
706        let predictions: Vec<PredictedEntity> = vec![];
707        let gold = vec![GoldEntity::new("John", EntityType::Person, 0)];
708
709        let analyzer = ErrorAnalyzer::default();
710        let report = analyzer.analyze(&predictions, &gold);
711
712        assert_eq!(report.false_negatives.len(), 1);
713    }
714
715    #[test]
716    fn test_correct_prediction() {
717        let predictions = vec![PredictedEntity::new("John", "PER", 0, 4)];
718        let gold = vec![GoldEntity::with_span("John", EntityType::Person, 0, 4)];
719
720        let analyzer = ErrorAnalyzer::default();
721        let report = analyzer.analyze(&predictions, &gold);
722
723        assert_eq!(*report.counts.get("total").unwrap_or(&0), 0);
724    }
725
726    #[test]
727    fn test_from_entity() {
728        let entity = anno::Entity::new("Test", EntityType::Person, 0, 4, 0.95);
729        let pred = PredictedEntity::from_entity(&entity);
730        assert_eq!(pred.text, "Test");
731        assert_eq!(pred.entity_type, "PER");
732        assert_eq!(pred.confidence, 0.95);
733    }
734}