1use anno::DiscontinuousEntity;
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DiscontinuousGold {
35 pub spans: Vec<(usize, usize)>,
37 pub entity_type: String,
39 pub text: String,
41}
42
43impl DiscontinuousGold {
44 pub fn new(
46 spans: Vec<(usize, usize)>,
47 entity_type: impl Into<String>,
48 text: impl Into<String>,
49 ) -> Self {
50 Self {
51 spans,
52 entity_type: entity_type.into(),
53 text: text.into(),
54 }
55 }
56
57 pub fn contiguous(
59 start: usize,
60 end: usize,
61 entity_type: impl Into<String>,
62 text: impl Into<String>,
63 ) -> Self {
64 Self {
65 spans: vec![(start, end)],
66 entity_type: entity_type.into(),
67 text: text.into(),
68 }
69 }
70
71 pub fn is_contiguous(&self) -> bool {
73 self.spans.len() == 1
74 }
75
76 pub fn bounding_range(&self) -> Option<(usize, usize)> {
78 let min_start = self.spans.iter().map(|(s, _)| *s).min()?;
79 let max_end = self.spans.iter().map(|(_, e)| *e).max()?;
80 Some((min_start, max_end))
81 }
82
83 pub fn total_length(&self) -> usize {
85 self.spans.iter().map(|(s, e)| e - s).sum()
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct DiscontinuousNERMetrics {
92 pub exact_f1: f64,
94 pub exact_precision: f64,
96 pub exact_recall: f64,
98 pub entity_boundary_f1: f64,
100 pub entity_boundary_precision: f64,
102 pub entity_boundary_recall: f64,
104 pub partial_span_f1: f64,
106 pub partial_span_precision: f64,
108 pub partial_span_recall: f64,
110 pub num_predicted: usize,
112 pub num_gold: usize,
114 pub exact_matches: usize,
116 pub boundary_matches: usize,
118 pub per_type: HashMap<String, TypeMetrics>,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct TypeMetrics {
125 pub exact_f1: f64,
127 pub boundary_f1: f64,
129 pub gold_count: usize,
131 pub pred_count: usize,
133 pub exact_matches: usize,
135}
136
137#[derive(Debug, Clone)]
139pub struct DiscontinuousEvalConfig {
140 pub overlap_threshold: f64,
142 pub require_type_match: bool,
144}
145
146impl Default for DiscontinuousEvalConfig {
147 fn default() -> Self {
148 Self {
149 overlap_threshold: 0.5,
150 require_type_match: true,
151 }
152 }
153}
154
155pub fn evaluate_discontinuous_ner(
192 gold: &[DiscontinuousGold],
193 pred: &[DiscontinuousEntity],
194 config: &DiscontinuousEvalConfig,
195) -> DiscontinuousNERMetrics {
196 if gold.is_empty() && pred.is_empty() {
197 return DiscontinuousNERMetrics {
198 exact_f1: 1.0,
199 exact_precision: 1.0,
200 exact_recall: 1.0,
201 entity_boundary_f1: 1.0,
202 entity_boundary_precision: 1.0,
203 entity_boundary_recall: 1.0,
204 partial_span_f1: 1.0,
205 partial_span_precision: 1.0,
206 partial_span_recall: 1.0,
207 num_predicted: 0,
208 num_gold: 0,
209 exact_matches: 0,
210 boundary_matches: 0,
211 per_type: HashMap::new(),
212 };
213 }
214
215 let mut gold_matched_exact = vec![false; gold.len()];
217 let mut gold_matched_boundary = vec![false; gold.len()];
218 let mut pred_matched_exact = vec![false; pred.len()];
219 let mut pred_matched_boundary = vec![false; pred.len()];
220
221 let mut type_stats: HashMap<String, (usize, usize, usize, usize)> = HashMap::new(); for g in gold {
226 let entry = type_stats.entry(g.entity_type.clone()).or_default();
227 entry.0 += 1;
228 }
229
230 for p in pred {
232 let entry = type_stats.entry(p.entity_type.clone()).or_default();
233 entry.1 += 1;
234 }
235
236 for (pi, p) in pred.iter().enumerate() {
238 for (gi, g) in gold.iter().enumerate() {
239 if gold_matched_exact[gi] {
240 continue;
241 }
242
243 if config.require_type_match && p.entity_type != g.entity_type {
245 continue;
246 }
247
248 if spans_match_exactly(&p.spans, &g.spans) {
250 gold_matched_exact[gi] = true;
251 pred_matched_exact[pi] = true;
252
253 let entry = type_stats.entry(g.entity_type.clone()).or_default();
254 entry.2 += 1;
255 break;
256 }
257 }
258 }
259
260 for (pi, p) in pred.iter().enumerate() {
262 for (gi, g) in gold.iter().enumerate() {
263 if gold_matched_boundary[gi] {
264 continue;
265 }
266
267 if config.require_type_match && p.entity_type != g.entity_type {
268 continue;
269 }
270
271 if boundaries_match(&p.spans, &g.spans) {
273 gold_matched_boundary[gi] = true;
274 pred_matched_boundary[pi] = true;
275
276 let entry = type_stats.entry(g.entity_type.clone()).or_default();
277 entry.3 += 1;
278 break;
279 }
280 }
281 }
282
283 let mut partial_precision_sum = 0.0;
285 let mut partial_recall_sum = 0.0;
286
287 for p in pred {
288 let best_overlap = gold
289 .iter()
290 .filter(|g| !config.require_type_match || p.entity_type == g.entity_type)
291 .map(|g| calculate_multi_span_overlap(&p.spans, &g.spans))
292 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
293 .unwrap_or(0.0);
294 partial_precision_sum += best_overlap;
295 }
296
297 for g in gold {
298 let best_overlap = pred
299 .iter()
300 .filter(|p| !config.require_type_match || p.entity_type == g.entity_type)
301 .map(|p| calculate_multi_span_overlap(&p.spans, &g.spans))
302 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
303 .unwrap_or(0.0);
304 partial_recall_sum += best_overlap;
305 }
306
307 let exact_matches = pred_matched_exact.iter().filter(|&&m| m).count();
309 let boundary_matches = pred_matched_boundary.iter().filter(|&&m| m).count();
310
311 let exact_precision = if !pred.is_empty() {
312 exact_matches as f64 / pred.len() as f64
313 } else {
314 0.0
315 };
316 let exact_recall = if !gold.is_empty() {
317 exact_matches as f64 / gold.len() as f64
318 } else {
319 0.0
320 };
321 let exact_f1 = f1_score(exact_precision, exact_recall);
322
323 let boundary_precision = if !pred.is_empty() {
324 boundary_matches as f64 / pred.len() as f64
325 } else {
326 0.0
327 };
328 let boundary_recall = if !gold.is_empty() {
329 boundary_matches as f64 / gold.len() as f64
330 } else {
331 0.0
332 };
333 let entity_boundary_f1 = f1_score(boundary_precision, boundary_recall);
334
335 let partial_span_precision = if !pred.is_empty() {
336 partial_precision_sum / pred.len() as f64
337 } else {
338 0.0
339 };
340 let partial_span_recall = if !gold.is_empty() {
341 partial_recall_sum / gold.len() as f64
342 } else {
343 0.0
344 };
345 let partial_span_f1 = f1_score(partial_span_precision, partial_span_recall);
346
347 let per_type: HashMap<String, TypeMetrics> = type_stats
349 .into_iter()
350 .map(|(t, (gold_count, pred_count, exact, boundary))| {
351 let exact_p = if pred_count > 0 {
352 exact as f64 / pred_count as f64
353 } else {
354 0.0
355 };
356 let exact_r = if gold_count > 0 {
357 exact as f64 / gold_count as f64
358 } else {
359 0.0
360 };
361 let boundary_p = if pred_count > 0 {
362 boundary as f64 / pred_count as f64
363 } else {
364 0.0
365 };
366 let boundary_r = if gold_count > 0 {
367 boundary as f64 / gold_count as f64
368 } else {
369 0.0
370 };
371
372 (
373 t,
374 TypeMetrics {
375 exact_f1: f1_score(exact_p, exact_r),
376 boundary_f1: f1_score(boundary_p, boundary_r),
377 gold_count,
378 pred_count,
379 exact_matches: exact,
380 },
381 )
382 })
383 .collect();
384
385 DiscontinuousNERMetrics {
386 exact_f1,
387 exact_precision,
388 exact_recall,
389 entity_boundary_f1,
390 entity_boundary_precision: boundary_precision,
391 entity_boundary_recall: boundary_recall,
392 partial_span_f1,
393 partial_span_precision,
394 partial_span_recall,
395 num_predicted: pred.len(),
396 num_gold: gold.len(),
397 exact_matches,
398 boundary_matches,
399 per_type,
400 }
401}
402
403fn spans_match_exactly(a: &[(usize, usize)], b: &[(usize, usize)]) -> bool {
405 if a.len() != b.len() {
406 return false;
407 }
408
409 let mut a_sorted: Vec<_> = a.to_vec();
410 let mut b_sorted: Vec<_> = b.to_vec();
411 a_sorted.sort();
412 b_sorted.sort();
413
414 a_sorted == b_sorted
415}
416
417fn boundaries_match(a: &[(usize, usize)], b: &[(usize, usize)]) -> bool {
419 match (a.is_empty(), b.is_empty()) {
420 (true, true) => true,
421 (true, false) | (false, true) => false,
422 (false, false) => {
423 let (Some(a_min), Some(a_max)) = (
425 a.iter().map(|(s, _)| *s).min(),
426 a.iter().map(|(_, e)| *e).max(),
427 ) else {
428 return false;
429 };
430 let (Some(b_min), Some(b_max)) = (
431 b.iter().map(|(s, _)| *s).min(),
432 b.iter().map(|(_, e)| *e).max(),
433 ) else {
434 return false;
435 };
436 a_min == b_min && a_max == b_max
437 }
438 }
439}
440
441fn calculate_multi_span_overlap(a: &[(usize, usize)], b: &[(usize, usize)]) -> f64 {
445 let a_chars: std::collections::HashSet<usize> = a.iter().flat_map(|(s, e)| *s..*e).collect();
446 let b_chars: std::collections::HashSet<usize> = b.iter().flat_map(|(s, e)| *s..*e).collect();
447
448 let intersection = a_chars.intersection(&b_chars).count();
449 let union = a_chars.union(&b_chars).count();
450
451 if union == 0 {
452 return 1.0; }
454
455 intersection as f64 / union as f64
456}
457
458fn f1_score(precision: f64, recall: f64) -> f64 {
460 if precision + recall > 0.0 {
461 2.0 * precision * recall / (precision + recall)
462 } else {
463 0.0
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn test_exact_match() {
473 let gold = vec![DiscontinuousGold::new(
474 vec![(0, 5), (10, 15)],
475 "LOC",
476 "test",
477 )];
478 let pred = vec![DiscontinuousEntity {
479 spans: vec![(0, 5), (10, 15)],
480 text: "test".to_string(),
481 entity_type: "LOC".to_string(),
482 confidence: anno::Confidence::new(0.9),
483 }];
484
485 let metrics = evaluate_discontinuous_ner(&gold, &pred, &DiscontinuousEvalConfig::default());
486 assert!((metrics.exact_f1 - 1.0).abs() < 0.001);
487 }
488
489 #[test]
490 fn test_boundary_match() {
491 let gold = vec![DiscontinuousGold::new(
492 vec![(0, 5), (10, 15)],
493 "LOC",
494 "test",
495 )];
496 let pred = vec![DiscontinuousEntity {
498 spans: vec![(0, 3), (3, 5), (10, 15)],
499 text: "test".to_string(),
500 entity_type: "LOC".to_string(),
501 confidence: anno::Confidence::new(0.9),
502 }];
503
504 let metrics = evaluate_discontinuous_ner(&gold, &pred, &DiscontinuousEvalConfig::default());
505 assert!(metrics.exact_f1 < 1.0);
507 assert!((metrics.entity_boundary_f1 - 1.0).abs() < 0.001);
509 }
510
511 #[test]
512 fn test_contiguous_entity() {
513 let gold = DiscontinuousGold::contiguous(0, 10, "PER", "John Smith");
514 assert!(gold.is_contiguous());
515 assert_eq!(gold.total_length(), 10);
516 }
517
518 #[test]
519 fn test_bounding_range() {
520 let gold = DiscontinuousGold::new(vec![(0, 5), (20, 30)], "LOC", "test");
521 assert_eq!(gold.bounding_range(), Some((0, 30)));
522 }
523
524 #[test]
525 fn test_empty_inputs() {
526 let metrics = evaluate_discontinuous_ner(&[], &[], &DiscontinuousEvalConfig::default());
527 assert!((metrics.exact_f1 - 1.0).abs() < 0.001);
528 }
529
530 #[test]
531 fn test_type_mismatch() {
532 let gold = vec![DiscontinuousGold::new(vec![(0, 5)], "PER", "John")];
533 let pred = vec![DiscontinuousEntity {
534 spans: vec![(0, 5)],
535 text: "John".to_string(),
536 entity_type: "ORG".to_string(), confidence: anno::Confidence::new(0.9),
538 }];
539
540 let config = DiscontinuousEvalConfig {
541 require_type_match: true,
542 ..Default::default()
543 };
544 let metrics = evaluate_discontinuous_ner(&gold, &pred, &config);
545 assert!(metrics.exact_f1 < 0.001);
546 }
547
548 #[test]
549 fn test_partial_overlap() {
550 let gold = vec![DiscontinuousGold::new(vec![(0, 10)], "LOC", "test")];
551 let pred = vec![DiscontinuousEntity {
552 spans: vec![(5, 15)], text: "test".to_string(),
554 entity_type: "LOC".to_string(),
555 confidence: anno::Confidence::new(0.9),
556 }];
557
558 let metrics = evaluate_discontinuous_ner(&gold, &pred, &DiscontinuousEvalConfig::default());
559 assert!(metrics.partial_span_f1 > 0.0);
561 assert!(metrics.partial_span_f1 < 1.0);
562 }
563
564 #[test]
565 fn test_multi_span_overlap() {
566 let a = vec![(0, 10), (20, 30)];
567 let b = vec![(5, 25)];
568
569 let overlap = calculate_multi_span_overlap(&a, &b);
570 assert!(overlap > 0.0);
575 assert!(overlap < 1.0);
576 }
577}