2828from pathlib import Path
2929
3030
31+ # =============================================================================
32+ # LABEL FILTERS — dataset-specific accepted labels
33+ # =============================================================================
34+
35+ _DISEASE_LABELS = {
36+ "disease" , "disease_disorder" , "disease_or_syndrome" , "disorder" ,
37+ "disease_or_phenotype" , "disease_or_condition" , "disease_or_symptom" ,
38+ "disease_group" , "disease_symptom" , "disease_phenotype" ,
39+ "medical_condition" , "condition" , "genetic_condition" ,
40+ "clinical_feature" , "clinical_finding" , "clinical_sign" ,
41+ "symptom" , "sign_symptom" , "phenotype" ,
42+ "pathological_process" ,
43+ }
44+
45+ _SPECIES_LABELS = {
46+ "species" , "organism" , "taxon" , "taxonomy" ,
47+ "animal" , "plant" , "virus" , "bacteria" , "fungus" ,
48+ "microorganism" , "pathogen" ,
49+ }
50+
51+
52+ def _normalize_label_for_filter (label : str ) -> str :
53+ """Normalize label for filter matching."""
54+ return label .strip ().lower ().replace (" " , "_" ).replace ("-" , "_" )
55+
56+
57+ def _matches_label_filter (label : str , accepted_labels : set [str ]) -> bool :
58+ """Check if a label matches the accepted set (case-insensitive, flexible)."""
59+ norm = _normalize_label_for_filter (label )
60+ # Exact match
61+ if norm in accepted_labels :
62+ return True
63+ # Check if any accepted label is a substring (e.g., "disease" in "disease_or_syndrome")
64+ for accepted in accepted_labels :
65+ if accepted in norm :
66+ return True
67+ return False
68+
69+
70+ def get_label_filter (dataset_name : str ) -> set [str ] | None :
71+ """Return the label filter set for a dataset, or None for no filtering."""
72+ name = dataset_name .lower ()
73+ if "ncbi" in name or "disease" in name :
74+ return _DISEASE_LABELS
75+ if "s800" in name or "species" in name :
76+ return _SPECIES_LABELS
77+ return None
78+
79+
3180# =============================================================================
3281# TEXT NORMALIZATION
3382# =============================================================================
@@ -109,11 +158,16 @@ def load_ground_truth(jsonl_path: str) -> list[dict]:
109158 return mentions
110159
111160
112- def load_results (json_path : str ) -> tuple [list [dict ], dict [str , list [dict ]], list [tuple ]]:
161+ def load_results (
162+ json_path : str , label_filter : set [str ] | None = None
163+ ) -> tuple [list [dict ], dict [str , list [dict ]], list [tuple ], int , int , int ]:
113164 """Load StructSense results. Returns:
114- - entities: raw entity list (after filtering)
165+ - entities: entity list (after filtering)
115166 - text_index: normalized_text -> [entity_data, ...]
116167 - intervals: sorted list of (global_start, global_end, entity_text, entity_data)
168+ - total_count: raw entity count
169+ - filtered_count: en_core_web_sm filtered count
170+ - label_filtered_count: label-filtered count (0 if no label_filter)
117171 """
118172 with open (json_path ) as f :
119173 data = json .load (f )
@@ -125,6 +179,16 @@ def load_results(json_path: str) -> tuple[list[dict], dict[str, list[dict]], lis
125179 entities = [e for e in raw_entities if not is_only_en_core_web_sm (e )]
126180 filtered_count = total_count - len (entities )
127181
182+ # Optional: filter by label
183+ label_filtered_count = 0
184+ if label_filter is not None :
185+ before = len (entities )
186+ entities = [
187+ e for e in entities
188+ if _matches_label_filter (e .get ("label" , "" ), label_filter )
189+ ]
190+ label_filtered_count = before - len (entities )
191+
128192 # Build text index
129193 text_index : dict [str , list [dict ]] = defaultdict (list )
130194 for ent in entities :
@@ -148,7 +212,7 @@ def load_results(json_path: str) -> tuple[list[dict], dict[str, list[dict]], lis
148212
149213 intervals .sort (key = lambda x : x [0 ])
150214
151- return entities , dict (text_index ), intervals , total_count , filtered_count
215+ return entities , dict (text_index ), intervals , total_count , filtered_count , label_filtered_count
152216
153217
154218# =============================================================================
@@ -272,21 +336,32 @@ def compute_extra_entities(
272336# EVALUATION
273337# =============================================================================
274338
339+ def evaluate (
340+ gt_path : str ,
341+ result_path : str ,
342+ overlap_threshold : float = 0.5 ,
343+ dataset_name : str = "" ,
344+ ) -> dict :
345+ """Evaluate one result file against one ground truth file.
275346
276- def evaluate (gt_path : str , result_path : str , overlap_threshold : float = 0.5 ) -> dict :
277- """Evaluate one result file against one ground truth file."""
347+ Runs two evaluations:
348+ 1. All entities (label-agnostic extraction recall)
349+ 2. Label-filtered entities (labeled recall — only entities with correct type)
350+ """
278351 gt_mentions = load_ground_truth (gt_path )
279- entities , text_index , intervals , total_count , filtered_count = load_results (result_path )
352+ gt_unique = len ({m ["normalized" ] for m in gt_mentions })
353+ gt_total = len (gt_mentions )
280354
355+ # --- Evaluation 1: All entities (label-agnostic) ---
356+ entities , text_index , intervals , total_count , filtered_count , _ = load_results (
357+ result_path
358+ )
281359 match_report = match_ground_truth (gt_mentions , text_index , intervals , overlap_threshold )
282360
283- gt_unique = len ({m ["normalized" ] for m in gt_mentions })
284-
285361 n_exact = len (match_report ["exact_matches" ])
286362 n_span = len (match_report ["span_matches" ])
287363 n_partial = len (match_report ["partial_matches" ])
288364 n_missed = len (match_report ["missed" ])
289- gt_total = len (gt_mentions )
290365
291366 extra_count , extra_labels = compute_extra_entities (entities , gt_mentions , text_index )
292367
@@ -308,6 +383,61 @@ def evaluate(gt_path: str, result_path: str, overlap_threshold: float = 0.5) ->
308383 "extra_entity_labels" : dict (extra_labels .most_common ()),
309384 "missed_entities" : _summarize_missed (match_report ["missed" ]),
310385 }
386+
387+ # --- Evaluation 2: Label-filtered (labeled recall) ---
388+ label_filter = get_label_filter (dataset_name )
389+ if label_filter is not None :
390+ (
391+ lf_entities , lf_text_index , lf_intervals ,
392+ _ , _ , label_filtered_count ,
393+ ) = load_results (result_path , label_filter = label_filter )
394+
395+ lf_match = match_ground_truth (
396+ gt_mentions , lf_text_index , lf_intervals , overlap_threshold
397+ )
398+
399+ lf_exact = len (lf_match ["exact_matches" ])
400+ lf_span = len (lf_match ["span_matches" ])
401+ lf_partial = len (lf_match ["partial_matches" ])
402+ lf_missed = len (lf_match ["missed" ])
403+
404+ # Precision: of all label-filtered entities, how many match GT?
405+ gt_norms = {m ["normalized" ] for m in gt_mentions }
406+ lf_total = len (lf_entities )
407+ lf_matched_count = 0
408+ for ent in lf_entities :
409+ norm = normalize_entity_text (ent ["entity" ])
410+ matched = norm in gt_norms
411+ if not matched :
412+ for gt_norm in gt_norms :
413+ if gt_norm in norm or norm in gt_norm :
414+ shorter = min (len (gt_norm ), len (norm ))
415+ longer = max (len (gt_norm ), len (norm ))
416+ if shorter >= 3 and shorter / longer >= 0.3 :
417+ matched = True
418+ break
419+ if matched :
420+ lf_matched_count += 1
421+
422+ lf_precision = lf_matched_count / lf_total if lf_total > 0 else 0.0
423+
424+ report ["labeled" ] = {
425+ "label_filter" : sorted (label_filter ),
426+ "entities_before_filter" : len (lf_entities ) + label_filtered_count ,
427+ "entities_after_filter" : lf_total ,
428+ "label_filtered_count" : label_filtered_count ,
429+ "exact_matches" : lf_exact ,
430+ "span_matches" : lf_span ,
431+ "partial_matches" : lf_partial ,
432+ "missed" : lf_missed ,
433+ "recall_strict" : lf_exact / gt_total if gt_total > 0 else 0.0 ,
434+ "recall_relaxed" : (lf_exact + lf_span + lf_partial ) / gt_total if gt_total > 0 else 0.0 ,
435+ "precision" : lf_precision ,
436+ "precision_matched" : lf_matched_count ,
437+ "precision_total" : lf_total ,
438+ "missed_entities" : _summarize_missed (lf_match ["missed" ]),
439+ }
440+
311441 return report
312442
313443
@@ -437,6 +567,17 @@ def print_report(reports_by_dataset: dict[str, list[dict]], verbose: bool = Fals
437567 label_str = ", " .join (f"{ l } ({ c } )" for l , c in top_labels )
438568 print (f" Top labels: { label_str } " )
439569
570+ # Labeled recall + precision
571+ if "labeled" in r :
572+ lf = r ["labeled" ]
573+ print (f"\n Labeled evaluation (entities with matching type only):" )
574+ print (f" Entities after label filter: { lf ['entities_after_filter' ]} "
575+ f"(removed { lf ['label_filtered_count' ]} non-matching)" )
576+ lf_matched = lf ["exact_matches" ] + lf ["span_matches" ] + lf ["partial_matches" ]
577+ print (f" Labeled strict: { lf ['exact_matches' ]:4d} /{ gt_total } ({ lf ['recall_strict' ]* 100 :5.1f} %)" )
578+ print (f" Labeled relaxed: { lf_matched :4d} /{ gt_total } ({ lf ['recall_relaxed' ]* 100 :5.1f} %)" )
579+ print (f" Precision: { lf ['precision_matched' ]:4d} /{ lf ['precision_total' ]} ({ lf ['precision' ]* 100 :5.1f} %)" )
580+
440581 if r ["missed_entities" ]:
441582 n_show = 30 if verbose else 10
442583 print (f"\n Missed GT entities (top { min (n_show , len (r ['missed_entities' ]))} ):" )
@@ -450,8 +591,14 @@ def print_report(reports_by_dataset: dict[str, list[dict]], verbose: bool = Fals
450591 print (f"\n { '=' * 70 } " )
451592 print (f" SUMMARY TABLE" )
452593 print (f"{ '=' * 70 } " )
453- print (f"\n { 'Dataset' :<12s} { 'Model' :<30s} { 'Var' :<5s} { 'GT' :>5s} { 'Strict' :>8s} { 'Relaxed' :>8s} { 'Extra' :>6s} " )
454- print (f" { '-' * 80 } " )
594+ has_labeled = any ("labeled" in r for reports in reports_by_dataset .values () for r in reports )
595+ if has_labeled :
596+ print (f"\n { 'Dataset' :<12s} { 'Model' :<30s} { 'Var' :<5s} { 'GT' :>5s} "
597+ f"{ 'Strict' :>8s} { 'Relaxed' :>8s} { 'LblStrict' :>10s} { 'LblRelax' :>10s} { 'Prec' :>8s} { 'Extra' :>6s} " )
598+ print (f" { '-' * 112 } " )
599+ else :
600+ print (f"\n { 'Dataset' :<12s} { 'Model' :<30s} { 'Var' :<5s} { 'GT' :>5s} { 'Strict' :>8s} { 'Relaxed' :>8s} { 'Extra' :>6s} " )
601+ print (f" { '-' * 80 } " )
455602 for dataset , reports in reports_by_dataset .items ():
456603 for r in reports :
457604 model = os .path .basename (os .path .dirname (r ["result_file" ]))
@@ -463,13 +610,24 @@ def print_report(reports_by_dataset: dict[str, list[dict]], verbose: bool = Fals
463610 or "without_hil" in os .path .basename (r ["result_file" ]).lower ()
464611 else "hil"
465612 )
466- print (
613+ line = (
467614 f" { dataset :<12s} { model :<30s} { variant :<5s} "
468615 f"{ r ['gt_total' ]:>5d} "
469616 f"{ r ['recall_strict' ]* 100 :>6.1f} % "
470617 f"{ r ['recall_relaxed' ]* 100 :>6.1f} % "
471- f"{ r ['extra_entities' ]:>6d} "
472618 )
619+ if has_labeled :
620+ if "labeled" in r :
621+ lf = r ["labeled" ]
622+ line += (
623+ f"{ lf ['recall_strict' ]* 100 :>8.1f} % "
624+ f"{ lf ['recall_relaxed' ]* 100 :>8.1f} % "
625+ f"{ lf ['precision' ]* 100 :>6.1f} % "
626+ )
627+ else :
628+ line += f"{ 'N/A' :>9s} { 'N/A' :>9s} { 'N/A' :>7s} "
629+ line += f"{ r ['extra_entities' ]:>6d} "
630+ print (line )
473631 print ()
474632
475633
@@ -527,8 +685,8 @@ def main():
527685
528686 # Explicit GT + result pair
529687 if args .gt and args .result :
530- report = evaluate (args .gt , args .result , args .overlap_threshold )
531688 dataset_name = Path (args .gt ).parent .name
689+ report = evaluate (args .gt , args .result , args .overlap_threshold , dataset_name )
532690 reports_by_dataset = {dataset_name : [report ]}
533691 elif args .gt or args .result :
534692 print ("Error: --gt and --result must be used together" )
@@ -546,7 +704,10 @@ def main():
546704
547705 reports_by_dataset = defaultdict (list )
548706 for pair in pairs :
549- report = evaluate (pair ["gt_path" ], pair ["result_path" ], args .overlap_threshold )
707+ report = evaluate (
708+ pair ["gt_path" ], pair ["result_path" ],
709+ args .overlap_threshold , pair ["dataset" ],
710+ )
550711 reports_by_dataset [pair ["dataset" ]].append (report )
551712
552713 # Print
0 commit comments