1use super::datasets::GoldEntity;
7use super::types::{GoalCheckResult, MetricValue};
8use super::TypeMetrics;
9use anno::{Error, Model, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NERQueryMetrics {
18 pub text: String,
20 pub test_case_id: Option<String>,
22 pub precision: MetricValue,
24 pub recall: MetricValue,
26 pub f1: MetricValue,
28 pub per_type: HashMap<String, TypeMetrics>,
30 pub found: usize,
32 pub expected: usize,
34 pub correct: usize,
36 pub tokens_per_second: f64,
38}
39
40#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
47pub enum AveragingMode {
48 #[default]
50 Micro,
51 Macro,
53 Weighted,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct NERAggregateMetrics {
72 pub precision: MetricValue,
74 pub recall: MetricValue,
76 pub f1: MetricValue,
78 pub macro_precision: MetricValue,
80 pub macro_recall: MetricValue,
82 pub macro_f1: MetricValue,
84 pub precision_std: f64,
86 pub recall_std: f64,
88 pub f1_std: f64,
90 pub precision_ci_95: Option<(f64, f64)>,
92 pub recall_ci_95: Option<(f64, f64)>,
94 pub f1_ci_95: Option<(f64, f64)>,
96 pub per_type: HashMap<String, TypeMetrics>,
98 pub tokens_per_second: f64,
100 pub num_test_cases: usize,
102 pub total_found: usize,
104 pub total_expected: usize,
106 pub total_correct: usize,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct NERMetricGoals {
115 pub min_precision: Option<MetricValue>,
117 pub min_recall: Option<MetricValue>,
119 pub min_f1: Option<MetricValue>,
121 pub per_type_goals: HashMap<String, TypeMetricGoals>,
123}
124
125impl NERMetricGoals {
126 #[must_use]
128 pub fn new() -> Self {
129 Self {
130 min_precision: None,
131 min_recall: None,
132 min_f1: None,
133 per_type_goals: HashMap::new(),
134 }
135 }
136
137 pub fn with_min_precision(mut self, value: f64) -> Result<Self> {
139 self.min_precision = Some(MetricValue::try_new(value)?);
140 Ok(self)
141 }
142
143 pub fn with_min_recall(mut self, value: f64) -> Result<Self> {
145 self.min_recall = Some(MetricValue::try_new(value)?);
146 Ok(self)
147 }
148
149 pub fn with_min_f1(mut self, value: f64) -> Result<Self> {
151 self.min_f1 = Some(MetricValue::try_new(value)?);
152 Ok(self)
153 }
154
155 #[must_use]
157 pub fn with_type_goal(mut self, entity_type: String, goal: TypeMetricGoals) -> Self {
158 self.per_type_goals.insert(entity_type, goal);
159 self
160 }
161}
162
163impl Default for NERMetricGoals {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct TypeMetricGoals {
172 pub min_precision: Option<MetricValue>,
174 pub min_recall: Option<MetricValue>,
176 pub min_f1: Option<MetricValue>,
178}
179
180impl TypeMetricGoals {
181 #[must_use]
183 pub fn new() -> Self {
184 Self {
185 min_precision: None,
186 min_recall: None,
187 min_f1: None,
188 }
189 }
190
191 pub fn with_min_precision(mut self, value: f64) -> Result<Self> {
193 self.min_precision = Some(MetricValue::try_new(value)?);
194 Ok(self)
195 }
196
197 pub fn with_min_recall(mut self, value: f64) -> Result<Self> {
199 self.min_recall = Some(MetricValue::try_new(value)?);
200 Ok(self)
201 }
202
203 pub fn with_min_f1(mut self, value: f64) -> Result<Self> {
205 self.min_f1 = Some(MetricValue::try_new(value)?);
206 Ok(self)
207 }
208}
209
210impl Default for TypeMetricGoals {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216pub trait NEREvaluator: Send + Sync {
245 fn evaluate_test_case(
262 &self,
263 model: &dyn Model,
264 text: &str,
265 ground_truth: &[GoldEntity],
266 test_case_id: Option<&str>,
267 ) -> Result<NERQueryMetrics>;
268
269 fn aggregate(&self, query_metrics: &[NERQueryMetrics]) -> Result<NERAggregateMetrics>;
277
278 fn check_goals(
287 &self,
288 metrics: &NERAggregateMetrics,
289 goals: &NERMetricGoals,
290 ) -> Result<GoalCheckResult>;
291}
292
293pub struct StandardNEREvaluator;
297
298impl StandardNEREvaluator {
299 #[must_use]
301 pub fn new() -> Self {
302 Self
303 }
304}
305
306impl Default for StandardNEREvaluator {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312impl NEREvaluator for StandardNEREvaluator {
313 fn evaluate_test_case(
314 &self,
315 model: &dyn Model,
316 text: &str,
317 ground_truth: &[GoldEntity],
318 test_case_id: Option<&str>,
319 ) -> Result<NERQueryMetrics> {
320 if text.is_empty() {
322 return Err(Error::InvalidInput(
323 "Text cannot be empty for NER evaluation".to_string(),
324 ));
325 }
326
327 let validation = crate::eval::validation::validate_ground_truth_entities(
329 text,
330 ground_truth,
331 false, );
333 if !validation.is_valid {
334 return Err(Error::InvalidInput(format!(
335 "Invalid ground truth entities: {}",
336 validation.errors.join("; ")
337 )));
338 }
339 if !validation.warnings.is_empty() {
341 eprintln!(
342 "WARNING: Ground truth validation warnings: {}",
343 validation.warnings.join("; ")
344 );
345 }
346
347 let start_time = std::time::Instant::now();
348
349 let predicted = model.extract_entities(text, None)?;
351
352 let elapsed = start_time.elapsed().as_secs_f64();
353 let tokens = text.split_whitespace().count();
354 let tokens_per_second = if elapsed > 0.0 {
355 tokens as f64 / elapsed
356 } else {
357 0.0
358 };
359
360 let mut gold_matched = vec![false; ground_truth.len()];
365 let mut correct = 0;
366 for pred in &predicted {
367 for (gt_idx, gt) in ground_truth.iter().enumerate() {
368 if gold_matched[gt_idx] {
369 continue; }
371 if pred.start() == gt.start
372 && pred.end() == gt.end
373 && super::entity_type_matches(&pred.entity_type, >.entity_type)
374 {
375 gold_matched[gt_idx] = true;
376 correct += 1;
377 break;
378 }
379 }
380 }
381
382 let mut per_type_stats: HashMap<String, (usize, usize, usize)> = HashMap::new(); let mut gold_matched_per_type = vec![false; ground_truth.len()];
388 for (gt_idx, gt) in ground_truth.iter().enumerate() {
389 let type_key = super::entity_type_to_string(>.entity_type);
390 let stats = per_type_stats.entry(type_key.clone()).or_insert((0, 0, 0));
391 stats.1 += 1; if !gold_matched_per_type[gt_idx] {
395 for pred in &predicted {
396 if pred.start() == gt.start
397 && pred.end() == gt.end
398 && super::entity_type_matches(&pred.entity_type, >.entity_type)
399 {
400 gold_matched_per_type[gt_idx] = true;
401 stats.2 += 1; break;
403 }
404 }
405 }
406 }
407
408 for pred in &predicted {
410 let type_key = super::entity_type_to_string(&pred.entity_type);
411 let stats = per_type_stats.entry(type_key).or_insert((0, 0, 0));
412 stats.0 += 1; }
414
415 let found = predicted.len();
417 let expected = ground_truth.len();
418
419 let precision = if found > 0 {
420 correct as f64 / found as f64
421 } else {
422 0.0
423 };
424 let recall = if expected > 0 {
425 correct as f64 / expected as f64
426 } else {
427 0.0
428 };
429 let f1 = if precision + recall > 0.0 {
430 2.0 * precision * recall / (precision + recall)
431 } else {
432 0.0
433 };
434
435 if !precision.is_finite() || !recall.is_finite() || !f1.is_finite() {
437 return Err(Error::InvalidInput(format!(
438 "Invalid metric values: precision={}, recall={}, f1={}",
439 precision, recall, f1
440 )));
441 }
442
443 let mut per_type = HashMap::new();
445 for (type_name, (found_count, expected_count, correct_count)) in per_type_stats {
446 let type_precision = if found_count > 0 {
447 correct_count as f64 / found_count as f64
448 } else {
449 0.0
450 };
451 let type_recall = if expected_count > 0 {
452 correct_count as f64 / expected_count as f64
453 } else {
454 0.0
455 };
456 let type_f1 = if type_precision + type_recall > 0.0 {
457 2.0 * type_precision * type_recall / (type_precision + type_recall)
458 } else {
459 0.0
460 };
461
462 per_type.insert(
463 type_name,
464 TypeMetrics {
465 precision: type_precision,
466 recall: type_recall,
467 f1: type_f1,
468 found: found_count,
469 expected: expected_count,
470 correct: correct_count,
471 },
472 );
473 }
474
475 Ok(NERQueryMetrics {
476 text: text.to_string(),
477 test_case_id: test_case_id.map(|s| s.to_string()),
478 precision: MetricValue::new(precision),
479 recall: MetricValue::new(recall),
480 f1: MetricValue::new(f1),
481 per_type,
482 found,
483 expected,
484 correct,
485 tokens_per_second,
486 })
487 }
488
489 fn aggregate(&self, query_metrics: &[NERQueryMetrics]) -> Result<NERAggregateMetrics> {
490 if query_metrics.is_empty() {
491 return Err(Error::InvalidInput(
492 "Cannot aggregate empty metrics".to_string(),
493 ));
494 }
495
496 let total_found: usize = query_metrics.iter().map(|m| m.found).sum();
498 let total_expected: usize = query_metrics.iter().map(|m| m.expected).sum();
499 let total_correct: usize = query_metrics.iter().map(|m| m.correct).sum();
500
501 let micro_precision = if total_found > 0 {
505 total_correct as f64 / total_found as f64
506 } else {
507 0.0 };
509 let micro_recall = if total_expected > 0 {
510 total_correct as f64 / total_expected as f64
511 } else {
512 0.0
513 };
514 let micro_f1 = if micro_precision + micro_recall > 0.0 {
515 2.0 * micro_precision * micro_recall / (micro_precision + micro_recall)
516 } else {
517 0.0
518 };
519
520 let precisions: Vec<f64> = query_metrics.iter().map(|m| m.precision.get()).collect();
522 let recalls: Vec<f64> = query_metrics.iter().map(|m| m.recall.get()).collect();
523 let f1s: Vec<f64> = query_metrics.iter().map(|m| m.f1.get()).collect();
524 let tokens_per_second: Vec<f64> =
525 query_metrics.iter().map(|m| m.tokens_per_second).collect();
526
527 let macro_precision = if precisions.is_empty() {
529 0.0
530 } else {
531 precisions.iter().sum::<f64>() / precisions.len() as f64
532 };
533 let macro_recall = if recalls.is_empty() {
534 0.0
535 } else {
536 recalls.iter().sum::<f64>() / recalls.len() as f64
537 };
538 let macro_f1 = if f1s.is_empty() {
539 0.0
540 } else {
541 f1s.iter().sum::<f64>() / f1s.len() as f64
542 };
543 let mean_tokens_per_second = if tokens_per_second.is_empty() {
544 0.0
545 } else {
546 tokens_per_second.iter().sum::<f64>() / tokens_per_second.len() as f64
547 };
548
549 if !micro_precision.is_finite()
551 || !micro_recall.is_finite()
552 || !micro_f1.is_finite()
553 || !mean_tokens_per_second.is_finite()
554 {
555 return Err(Error::InvalidInput(format!(
556 "Invalid aggregate metric values: precision={}, recall={}, f1={}, tps={}",
557 micro_precision, micro_recall, micro_f1, mean_tokens_per_second
558 )));
559 }
560
561 let precision_std = calculate_std_dev(&precisions, macro_precision);
563 let recall_std = calculate_std_dev(&recalls, macro_recall);
564 let f1_std = calculate_std_dev(&f1s, macro_f1);
565
566 let precision_ci_95 = calculate_ci_95(&precisions, macro_precision, precision_std);
568 let recall_ci_95 = calculate_ci_95(&recalls, macro_recall, recall_std);
569 let f1_ci_95 = calculate_ci_95(&f1s, macro_f1, f1_std);
570
571 let mut per_type_totals: HashMap<String, (usize, usize, usize)> = HashMap::new();
573 for metric in query_metrics {
574 for (type_name, type_metric) in &metric.per_type {
575 let entry = per_type_totals
576 .entry(type_name.clone())
577 .or_insert((0, 0, 0));
578 entry.0 += type_metric.found;
579 entry.1 += type_metric.expected;
580 entry.2 += type_metric.correct;
581 }
582 }
583
584 let mut per_type = HashMap::new();
585 for (type_name, (type_found, type_expected, type_correct)) in per_type_totals {
586 let type_precision = if type_found > 0 {
588 type_correct as f64 / type_found as f64
589 } else {
590 0.0
591 };
592 let type_recall = if type_expected > 0 {
593 type_correct as f64 / type_expected as f64
594 } else {
595 0.0
596 };
597 let type_f1 = if type_precision + type_recall > 0.0 {
598 2.0 * type_precision * type_recall / (type_precision + type_recall)
599 } else {
600 0.0
601 };
602
603 per_type.insert(
604 type_name,
605 TypeMetrics {
606 precision: type_precision,
607 recall: type_recall,
608 f1: type_f1,
609 found: type_found,
610 expected: type_expected,
611 correct: type_correct,
612 },
613 );
614 }
615
616 Ok(NERAggregateMetrics {
617 precision: MetricValue::new(micro_precision),
619 recall: MetricValue::new(micro_recall),
620 f1: MetricValue::new(micro_f1),
621 macro_precision: MetricValue::new(macro_precision),
623 macro_recall: MetricValue::new(macro_recall),
624 macro_f1: MetricValue::new(macro_f1),
625 precision_std,
626 recall_std,
627 f1_std,
628 precision_ci_95,
629 recall_ci_95,
630 f1_ci_95,
631 per_type,
632 tokens_per_second: mean_tokens_per_second,
633 num_test_cases: query_metrics.len(),
634 total_found,
635 total_expected,
636 total_correct,
637 })
638 }
639
640 fn check_goals(
641 &self,
642 metrics: &NERAggregateMetrics,
643 goals: &NERMetricGoals,
644 ) -> Result<GoalCheckResult> {
645 let mut result = GoalCheckResult::new();
646
647 if let Some(min_precision) = goals.min_precision {
649 let actual = metrics.precision.get();
650 let goal = min_precision.get();
651 if actual < goal {
652 result.add_failure("precision".to_string(), actual, goal);
653 }
654 }
655
656 if let Some(min_recall) = goals.min_recall {
657 let actual = metrics.recall.get();
658 let goal = min_recall.get();
659 if actual < goal {
660 result.add_failure("recall".to_string(), actual, goal);
661 }
662 }
663
664 if let Some(min_f1) = goals.min_f1 {
665 let actual = metrics.f1.get();
666 let goal = min_f1.get();
667 if actual < goal {
668 result.add_failure("f1".to_string(), actual, goal);
669 }
670 }
671
672 for (type_name, type_goals) in &goals.per_type_goals {
674 if let Some(type_metrics) = metrics.per_type.get(type_name) {
675 if let Some(min_precision) = type_goals.min_precision {
676 let actual = type_metrics.precision;
677 let goal = min_precision.get();
678 if actual < goal {
679 result.add_failure(format!("{}.precision", type_name), actual, goal);
680 }
681 }
682
683 if let Some(min_recall) = type_goals.min_recall {
684 let actual = type_metrics.recall;
685 let goal = min_recall.get();
686 if actual < goal {
687 result.add_failure(format!("{}.recall", type_name), actual, goal);
688 }
689 }
690
691 if let Some(min_f1) = type_goals.min_f1 {
692 let actual = type_metrics.f1;
693 let goal = min_f1.get();
694 if actual < goal {
695 result.add_failure(format!("{}.f1", type_name), actual, goal);
696 }
697 }
698 }
699 }
700
701 Ok(result)
702 }
703}
704
705fn calculate_std_dev(values: &[f64], mean: f64) -> f64 {
707 if values.len() < 2 {
708 return 0.0;
709 }
710
711 let variance =
712 values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (values.len() - 1) as f64;
713
714 variance.sqrt()
715}
716
717fn calculate_ci_95(values: &[f64], mean: f64, std_dev: f64) -> Option<(f64, f64)> {
727 if values.len() < 2 {
728 return None;
729 }
730
731 let z_score = 1.96;
734 let margin = z_score * std_dev / (values.len() as f64).sqrt();
735
736 let lower = (mean - margin).clamp(0.0, 1.0);
740 let upper = (mean + margin).clamp(0.0, 1.0);
741
742 Some((lower, upper))
743}
744
745