Skip to main content

anno_eval/eval/
metrics.rs

1//! Advanced evaluation metrics for NER.
2//!
3//! Provides additional metrics beyond basic Precision/Recall/F1:
4//! - Partial match metrics (overlap-based)
5//! - Confidence threshold analysis
6//! - Per-language metrics (for multilingual datasets)
7//! - Cross-dataset comparison utilities
8
9use super::datasets::GoldEntity;
10use anno::Entity;
11use std::collections::HashSet;
12
13/// Partial match metrics (overlap-based).
14///
15/// Measures how well predicted entities overlap with ground truth,
16/// even if boundaries don't match exactly.
17#[derive(Debug, Clone)]
18pub struct PartialMatchMetrics {
19    /// Overlap threshold for considering a match (0.0-1.0)
20    pub overlap_threshold: f64,
21    /// Precision at this overlap threshold
22    pub precision: f64,
23    /// Recall at this overlap threshold
24    pub recall: f64,
25    /// F1 at this overlap threshold
26    pub f1: f64,
27    /// Number of partial matches found
28    pub partial_matches: usize,
29}
30
31/// Calculate overlap between two entity spans.
32///
33/// Returns overlap ratio (0.0-1.0) based on intersection over union.
34#[must_use]
35pub fn calculate_overlap(
36    pred_start: usize,
37    pred_end: usize,
38    gt_start: usize,
39    gt_end: usize,
40) -> f64 {
41    let intersection_start = pred_start.max(gt_start);
42    let intersection_end = pred_end.min(gt_end);
43
44    if intersection_start >= intersection_end {
45        return 0.0;
46    }
47
48    let intersection = (intersection_end - intersection_start) as f64;
49    let union = ((pred_end - pred_start) + (gt_end - gt_start)
50        - (intersection_end - intersection_start)) as f64;
51
52    if union == 0.0 {
53        return 1.0; // Both spans are empty
54    }
55
56    intersection / union
57}
58
59/// Calculate partial match metrics.
60///
61/// # Arguments
62/// * `predicted` - Predicted entities
63/// * `ground_truth` - Ground truth entities
64/// * `overlap_threshold` - Minimum overlap ratio to consider a match (default: 0.5)
65///
66/// # Returns
67/// Partial match metrics
68pub fn calculate_partial_match_metrics(
69    predicted: &[Entity],
70    ground_truth: &[GoldEntity],
71    overlap_threshold: f64,
72) -> PartialMatchMetrics {
73    let mut true_positives = 0;
74    let mut _false_positives = 0;
75
76    // Track which ground truth entities have been matched
77    let mut gt_matched = vec![false; ground_truth.len()];
78
79    // For each predicted entity, find best matching ground truth
80    for pred in predicted {
81        let mut best_match: Option<(usize, f64)> = None;
82
83        for (gt_idx, gt) in ground_truth.iter().enumerate() {
84            if gt_matched[gt_idx] {
85                continue; // Already matched
86            }
87
88            // Check entity type matches
89            if !crate::eval::entity_type_matches(&pred.entity_type, &gt.entity_type) {
90                continue;
91            }
92
93            // Calculate overlap
94            let overlap = calculate_overlap(pred.start(), pred.end(), gt.start, gt.end);
95
96            let should_update = match best_match {
97                None => true,
98                Some((_, best_overlap)) => best_overlap < overlap,
99            };
100            if overlap >= overlap_threshold && should_update {
101                best_match = Some((gt_idx, overlap));
102            }
103        }
104
105        if let Some((gt_idx, _)) = best_match {
106            true_positives += 1;
107            gt_matched[gt_idx] = true;
108        } else {
109            _false_positives += 1;
110        }
111    }
112
113    // Count unmatched ground truth as false negatives (for potential future use)
114    let _false_negatives = gt_matched.iter().filter(|&&matched| !matched).count();
115
116    let precision = if !predicted.is_empty() {
117        true_positives as f64 / predicted.len() as f64
118    } else {
119        0.0
120    };
121
122    let recall = if !ground_truth.is_empty() {
123        true_positives as f64 / ground_truth.len() as f64
124    } else {
125        0.0
126    };
127
128    let f1 = if precision + recall > 0.0 {
129        2.0 * precision * recall / (precision + recall)
130    } else {
131        0.0
132    };
133
134    PartialMatchMetrics {
135        overlap_threshold,
136        precision,
137        recall,
138        f1,
139        partial_matches: true_positives,
140    }
141}
142
143// =============================================================================
144// CORE-KG-inspired Extraction Diagnostics (Duplication / Noise)
145// =============================================================================
146
147/// Lightweight extraction-quality diagnostics inspired by CORE-KG.
148///
149/// These are **heuristics**. They are useful for monitoring regressions and surfacing
150/// obvious quality problems (e.g., repeated identical nodes, garbage spans) but they
151/// are not “ground truth” correctness metrics.
152#[derive(Debug, Clone, Default)]
153pub struct ExtractionQualityMetrics {
154    /// Total number of extracted entities.
155    pub total: usize,
156    /// Count of entities considered duplicates under conservative normalization.
157    pub duplicates: usize,
158    /// Duplicate rate (duplicates / total).
159    pub duplication_rate: f64,
160    /// Count of entities considered “noisy” spans (punctuation-only, numeric-only, etc.).
161    pub noisy: usize,
162    /// Noise rate (noisy / total).
163    pub noise_rate: f64,
164}
165
166fn normalize_for_duplication(text: &str) -> String {
167    // Conservative normalization:
168    // - Unicode-aware lowercasing
169    // - Keep only alphanumeric codepoints
170    text.chars()
171        .filter(|c| c.is_alphanumeric())
172        .flat_map(|c| c.to_lowercase())
173        .collect()
174}
175
176fn is_noisy_span(text: &str) -> bool {
177    let t = text.trim();
178    if t.is_empty() {
179        return true;
180    }
181
182    // Punctuation/whitespace-only
183    if t.chars()
184        .all(|c| c.is_whitespace() || c.is_ascii_punctuation())
185    {
186        return true;
187    }
188
189    // Numeric-only (common “legal noise” / artifacts)
190    if t.chars().all(|c| c.is_ascii_digit() || c.is_whitespace()) {
191        return true;
192    }
193
194    // If normalization removes everything, it's effectively garbage.
195    normalize_for_duplication(t).is_empty()
196}
197
198/// Compute duplication/noise metrics for a set of extracted entities.
199#[must_use]
200pub fn compute_extraction_quality_metrics(entities: &[Entity]) -> ExtractionQualityMetrics {
201    let total = entities.len();
202    if total == 0 {
203        return ExtractionQualityMetrics::default();
204    }
205
206    let mut seen: HashSet<(String, String)> = HashSet::new();
207    let mut duplicates = 0usize;
208    let mut noisy = 0usize;
209
210    for e in entities {
211        if is_noisy_span(&e.text) {
212            noisy += 1;
213        }
214
215        let key = (
216            format!("{:?}", e.entity_type),
217            normalize_for_duplication(&e.text),
218        );
219        if seen.contains(&key) {
220            duplicates += 1;
221        } else {
222            seen.insert(key);
223        }
224    }
225
226    ExtractionQualityMetrics {
227        total,
228        duplicates,
229        duplication_rate: duplicates as f64 / total as f64,
230        noisy,
231        noise_rate: noisy as f64 / total as f64,
232    }
233}
234
235/// Confidence threshold analysis.
236///
237/// Analyzes model performance at different confidence thresholds.
238#[derive(Debug, Clone)]
239pub struct ConfidenceThresholdAnalysis {
240    /// Threshold values tested
241    pub thresholds: Vec<f64>,
242    /// Metrics at each threshold
243    pub metrics_at_threshold: Vec<(f64, PartialMatchMetrics)>,
244    /// Optimal threshold (highest F1)
245    pub optimal_threshold: Option<f64>,
246}
247
248/// Analyze performance at different confidence thresholds.
249pub fn analyze_confidence_thresholds(
250    predicted: &[Entity],
251    ground_truth: &[GoldEntity],
252    overlap_threshold: f64,
253) -> ConfidenceThresholdAnalysis {
254    let thresholds: Vec<f64> = (0..=10).map(|i| i as f64 / 10.0).collect();
255
256    let mut metrics_at_threshold = Vec::new();
257    let mut best_f1 = 0.0;
258    let mut optimal_threshold = None;
259
260    for threshold in &thresholds {
261        // Filter predictions by confidence
262        let filtered: Vec<&Entity> = predicted
263            .iter()
264            .filter(|e| e.confidence >= *threshold)
265            .collect();
266
267        // Convert to owned for metrics calculation
268        let filtered_owned: Vec<Entity> = filtered.iter().map(|e| (*e).clone()).collect();
269
270        let metrics =
271            calculate_partial_match_metrics(&filtered_owned, ground_truth, overlap_threshold);
272
273        if metrics.f1 > best_f1 {
274            best_f1 = metrics.f1;
275            optimal_threshold = Some(*threshold);
276        }
277
278        metrics_at_threshold.push((*threshold, metrics));
279    }
280
281    ConfidenceThresholdAnalysis {
282        thresholds,
283        metrics_at_threshold,
284        optimal_threshold,
285    }
286}
287
288// ============================================================================
289// Text Classification Metrics
290// ============================================================================
291
292/// Metrics for text classification tasks.
293///
294/// Supports multi-class classification with macro/micro/weighted averaging.
295#[derive(Debug, Clone, Default)]
296pub struct ClassificationMetrics {
297    /// Total number of examples
298    pub total: usize,
299    /// Number of correct predictions
300    pub correct: usize,
301    /// Per-class true positives
302    pub class_tp: std::collections::HashMap<String, usize>,
303    /// Per-class false positives
304    pub class_fp: std::collections::HashMap<String, usize>,
305    /// Per-class false negatives
306    pub class_fn: std::collections::HashMap<String, usize>,
307    /// Per-class support (total examples per class)
308    pub class_support: std::collections::HashMap<String, usize>,
309}
310
311impl ClassificationMetrics {
312    /// Create new empty metrics.
313    #[must_use]
314    pub fn new() -> Self {
315        Self::default()
316    }
317
318    /// Add a prediction to the metrics.
319    pub fn add(&mut self, predicted: &str, actual: &str) {
320        self.total += 1;
321
322        // Update support
323        *self.class_support.entry(actual.to_string()).or_insert(0) += 1;
324
325        if predicted == actual {
326            self.correct += 1;
327            *self.class_tp.entry(actual.to_string()).or_insert(0) += 1;
328        } else {
329            // False positive for predicted class
330            *self.class_fp.entry(predicted.to_string()).or_insert(0) += 1;
331            // False negative for actual class
332            *self.class_fn.entry(actual.to_string()).or_insert(0) += 1;
333        }
334    }
335
336    /// Overall accuracy.
337    #[must_use]
338    pub fn accuracy(&self) -> f64 {
339        if self.total == 0 {
340            return 0.0;
341        }
342        self.correct as f64 / self.total as f64
343    }
344
345    /// Macro-averaged precision (unweighted average across classes).
346    #[must_use]
347    pub fn macro_precision(&self) -> f64 {
348        let classes: std::collections::HashSet<_> = self
349            .class_support
350            .keys()
351            .chain(self.class_fp.keys())
352            .collect();
353
354        if classes.is_empty() {
355            return 0.0;
356        }
357
358        let sum: f64 = classes
359            .iter()
360            .map(|class| self.class_precision(class))
361            .sum();
362
363        sum / classes.len() as f64
364    }
365
366    /// Macro-averaged recall (unweighted average across classes).
367    #[must_use]
368    pub fn macro_recall(&self) -> f64 {
369        if self.class_support.is_empty() {
370            return 0.0;
371        }
372
373        let sum: f64 = self
374            .class_support
375            .keys()
376            .map(|class| self.class_recall(class))
377            .sum();
378
379        sum / self.class_support.len() as f64
380    }
381
382    /// Macro-averaged F1 score.
383    #[must_use]
384    pub fn macro_f1(&self) -> f64 {
385        let p = self.macro_precision();
386        let r = self.macro_recall();
387        if p + r == 0.0 {
388            return 0.0;
389        }
390        2.0 * p * r / (p + r)
391    }
392
393    /// Micro-averaged precision (aggregate TP/FP across classes).
394    #[must_use]
395    pub fn micro_precision(&self) -> f64 {
396        let tp: usize = self.class_tp.values().sum();
397        let fp: usize = self.class_fp.values().sum();
398        if tp + fp == 0 {
399            return 0.0;
400        }
401        tp as f64 / (tp + fp) as f64
402    }
403
404    /// Micro-averaged recall (aggregate TP/FN across classes).
405    #[must_use]
406    pub fn micro_recall(&self) -> f64 {
407        let tp: usize = self.class_tp.values().sum();
408        let fn_sum: usize = self.class_fn.values().sum();
409        if tp + fn_sum == 0 {
410            return 0.0;
411        }
412        tp as f64 / (tp + fn_sum) as f64
413    }
414
415    /// Micro-averaged F1 score.
416    #[must_use]
417    pub fn micro_f1(&self) -> f64 {
418        let p = self.micro_precision();
419        let r = self.micro_recall();
420        if p + r == 0.0 {
421            return 0.0;
422        }
423        2.0 * p * r / (p + r)
424    }
425
426    /// Weighted F1 score (weighted by class support).
427    #[must_use]
428    pub fn weighted_f1(&self) -> f64 {
429        if self.total == 0 {
430            return 0.0;
431        }
432
433        let sum: f64 = self
434            .class_support
435            .iter()
436            .map(|(class, &support)| {
437                let f1 = self.class_f1(class);
438                f1 * support as f64
439            })
440            .sum();
441
442        sum / self.total as f64
443    }
444
445    /// Precision for a specific class.
446    #[must_use]
447    pub fn class_precision(&self, class: &str) -> f64 {
448        let tp = *self.class_tp.get(class).unwrap_or(&0);
449        let fp = *self.class_fp.get(class).unwrap_or(&0);
450        if tp + fp == 0 {
451            return 0.0;
452        }
453        tp as f64 / (tp + fp) as f64
454    }
455
456    /// Recall for a specific class.
457    #[must_use]
458    pub fn class_recall(&self, class: &str) -> f64 {
459        let tp = *self.class_tp.get(class).unwrap_or(&0);
460        let fn_count = *self.class_fn.get(class).unwrap_or(&0);
461        if tp + fn_count == 0 {
462            return 0.0;
463        }
464        tp as f64 / (tp + fn_count) as f64
465    }
466
467    /// F1 for a specific class.
468    #[must_use]
469    pub fn class_f1(&self, class: &str) -> f64 {
470        let p = self.class_precision(class);
471        let r = self.class_recall(class);
472        if p + r == 0.0 {
473            return 0.0;
474        }
475        2.0 * p * r / (p + r)
476    }
477
478    /// Get all class labels.
479    #[must_use]
480    pub fn classes(&self) -> Vec<&String> {
481        let mut classes: Vec<_> = self.class_support.keys().collect();
482        classes.sort();
483        classes
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use anno::EntityType;
491
492    #[test]
493    fn test_classification_metrics() {
494        let mut metrics = ClassificationMetrics::new();
495
496        // Add some predictions
497        metrics.add("sports", "sports");
498        metrics.add("sports", "sports");
499        metrics.add("business", "business");
500        metrics.add("sports", "business"); // Misclassification
501
502        assert_eq!(metrics.total, 4);
503        assert_eq!(metrics.correct, 3);
504        assert!((metrics.accuracy() - 0.75).abs() < 0.001);
505    }
506
507    #[test]
508    fn test_classification_macro_f1() {
509        let mut metrics = ClassificationMetrics::new();
510
511        // Perfect classification
512        metrics.add("a", "a");
513        metrics.add("b", "b");
514
515        assert!((metrics.macro_f1() - 1.0).abs() < 0.001);
516    }
517
518    #[test]
519    fn test_calculate_overlap() {
520        // Exact match
521        assert!((calculate_overlap(0, 10, 0, 10) - 1.0).abs() < 0.001);
522
523        // Partial overlap
524        let overlap = calculate_overlap(0, 10, 5, 15);
525        assert!(overlap > 0.0 && overlap < 1.0);
526
527        // No overlap
528        assert!((calculate_overlap(0, 10, 20, 30) - 0.0).abs() < 0.001);
529    }
530
531    #[test]
532    fn test_calculate_partial_match_metrics() {
533        let predicted = vec![Entity::new("John Smith", EntityType::Person, 0, 10, 0.9)];
534
535        let ground_truth = vec![GoldEntity {
536            text: "John Smith".to_string(),
537            entity_type: EntityType::Person,
538            original_label: "PER".to_string(),
539            start: 0,
540            end: 10,
541        }];
542
543        let metrics = calculate_partial_match_metrics(&predicted, &ground_truth, 0.5);
544        assert!((metrics.precision - 1.0).abs() < 0.001);
545        assert!((metrics.recall - 1.0).abs() < 0.001);
546    }
547}