1use super::datasets::GoldEntity;
10use anno::Entity;
11use std::collections::HashSet;
12
13#[derive(Debug, Clone)]
18pub struct PartialMatchMetrics {
19 pub overlap_threshold: f64,
21 pub precision: f64,
23 pub recall: f64,
25 pub f1: f64,
27 pub partial_matches: usize,
29}
30
31#[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; }
55
56 intersection / union
57}
58
59pub 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 let mut gt_matched = vec![false; ground_truth.len()];
78
79 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; }
87
88 if !crate::eval::entity_type_matches(&pred.entity_type, >.entity_type) {
90 continue;
91 }
92
93 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 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#[derive(Debug, Clone, Default)]
153pub struct ExtractionQualityMetrics {
154 pub total: usize,
156 pub duplicates: usize,
158 pub duplication_rate: f64,
160 pub noisy: usize,
162 pub noise_rate: f64,
164}
165
166fn normalize_for_duplication(text: &str) -> String {
167 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 if t.chars()
184 .all(|c| c.is_whitespace() || c.is_ascii_punctuation())
185 {
186 return true;
187 }
188
189 if t.chars().all(|c| c.is_ascii_digit() || c.is_whitespace()) {
191 return true;
192 }
193
194 normalize_for_duplication(t).is_empty()
196}
197
198#[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#[derive(Debug, Clone)]
239pub struct ConfidenceThresholdAnalysis {
240 pub thresholds: Vec<f64>,
242 pub metrics_at_threshold: Vec<(f64, PartialMatchMetrics)>,
244 pub optimal_threshold: Option<f64>,
246}
247
248pub 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 let filtered: Vec<&Entity> = predicted
263 .iter()
264 .filter(|e| e.confidence >= *threshold)
265 .collect();
266
267 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#[derive(Debug, Clone, Default)]
296pub struct ClassificationMetrics {
297 pub total: usize,
299 pub correct: usize,
301 pub class_tp: std::collections::HashMap<String, usize>,
303 pub class_fp: std::collections::HashMap<String, usize>,
305 pub class_fn: std::collections::HashMap<String, usize>,
307 pub class_support: std::collections::HashMap<String, usize>,
309}
310
311impl ClassificationMetrics {
312 #[must_use]
314 pub fn new() -> Self {
315 Self::default()
316 }
317
318 pub fn add(&mut self, predicted: &str, actual: &str) {
320 self.total += 1;
321
322 *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 *self.class_fp.entry(predicted.to_string()).or_insert(0) += 1;
331 *self.class_fn.entry(actual.to_string()).or_insert(0) += 1;
333 }
334 }
335
336 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 metrics.add("sports", "sports");
498 metrics.add("sports", "sports");
499 metrics.add("business", "business");
500 metrics.add("sports", "business"); 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 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 assert!((calculate_overlap(0, 10, 0, 10) - 1.0).abs() < 0.001);
522
523 let overlap = calculate_overlap(0, 10, 5, 15);
525 assert!(overlap > 0.0 && overlap < 1.0);
526
527 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}