1use super::datasets::GoldEntity;
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39
40#[derive(Debug, Clone)]
49pub struct PredictedEntity {
50 pub text: String,
52 pub entity_type: String,
54 pub start: usize,
56 pub end: usize,
58 pub confidence: f64,
60}
61
62impl PredictedEntity {
63 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 pub fn with_confidence(mut self, confidence: f64) -> Self {
81 self.confidence = confidence;
82 self
83 }
84
85 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#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ErrorInstance {
100 pub category: ErrorCategory,
102 pub predicted: Option<EntityInfo>,
104 pub gold: Option<EntityInfo>,
106 pub description: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct EntityInfo {
113 pub text: String,
115 pub entity_type: String,
117 pub span: (usize, usize),
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131pub enum ErrorCategory {
132 BoundaryError,
134 TypeError,
136 FalsePositive,
138 FalseNegative,
140 PartialMatch,
142}
143
144impl ErrorCategory {
145 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ErrorReport {
162 pub boundary_errors: Vec<ErrorInstance>,
164 pub type_errors: Vec<ErrorInstance>,
166 pub false_positives: Vec<ErrorInstance>,
168 pub false_negatives: Vec<ErrorInstance>,
170 pub partial_matches: Vec<ErrorInstance>,
172 pub counts: HashMap<String, usize>,
174 pub rates: HashMap<String, f64>,
176 pub common_patterns: Vec<ErrorPattern>,
178 pub by_type: HashMap<String, TypeErrorStats>,
180 pub recommendations: Vec<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ErrorPattern {
187 pub description: String,
189 pub count: usize,
191 pub examples: Vec<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct TypeErrorStats {
198 pub gold_count: usize,
200 pub correct: usize,
202 pub boundary_errors: usize,
204 pub confused_with: HashMap<String, usize>,
206 pub missed: usize,
208}
209
210#[derive(Debug, Clone)]
220pub struct ErrorAnalyzer {
221 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 pub fn new(overlap_threshold: f64) -> Self {
236 Self { overlap_threshold }
237 }
238
239 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 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 (gi, g) in gold.iter().enumerate() {
263 let g_type = g.entity_type.as_label();
264
265 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; 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 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 } else if exact_boundary && !type_match {
316 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_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_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 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 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 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 let by_type = self.analyze_by_type(&type_errors, &false_negatives, gold);
439
440 let common_patterns = self.find_common_patterns(&type_errors, &boundary_errors);
442
443 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 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 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 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 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 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 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 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 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 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 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#[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); 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}