Skip to content

Commit 179adc5

Browse files
Merge pull request #125 from sensein/eval-per-entity-accuracy
Add per-entity-type NER accuracy and benchmark labeled recall/precision
2 parents eabe47c + c23cb5b commit 179adc5

5 files changed

Lines changed: 6676 additions & 1241 deletions

File tree

evaluation/benchmark/analysis/benchmark_eval.py

Lines changed: 176 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,55 @@
2828
from 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

Comments
 (0)