attempt_vanilla / scripts /eval_models_on_eval_json.py
arthrod's picture
attempt_vanilla: cleaned experiment log (GLiNER PII fine-tuning, Feb 2026)
24197e0
Raw History Blame Contribute Delete
14.4 kB
#!/usr/bin/env python3
"""Evaluate GLiNER checkpoints and external models on an eval.json file.
This script expects GLiNER-style records with:
- tokenized_text: list[str]
- ner: list[[start_token_idx, end_token_idx, label], ...]
It rebuilds each sample text from tokenized tokens (" ".join(tokens)),
runs batched inference, and computes exact-match entity metrics
(label + start_char + end_char):
- overall precision / recall / F1
- per-entity precision / recall / F1
- counts (tp/fp/fn, predicted/true totals)
Examples:
python scripts/eval_models_on_eval_json.py \
--eval-json /mnt/doscratch/attempt_vanilla/data/eval.json \
--model /mnt/doscratch/attempt_vanilla/gliner_logs/gliner-pii-large-full-v1/checkpoint-5500
python scripts/eval_models_on_eval_json.py \
--eval-json /mnt/doscratch/attempt_vanilla/data/eval.json \
--checkpoints-root /mnt/doscratch/attempt_vanilla/gliner_logs/gliner-pii-large-full-v1 \
--checkpoint-glob 'checkpoint-*'
"""
from __future__ import annotations
import argparse
import json
import re
import time
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import torch
from tqdm import tqdm
from gliner import GLiNER
Span = tuple[str, int, int]
@dataclass(frozen=True)
class Example:
text: str
gold_spans: frozenset[Span]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Evaluate GLiNER models on eval.json with overall and per-entity PRF."
)
parser.add_argument(
"--eval-json",
required=True,
type=Path,
help="Path to eval.json in GLiNER format.",
)
parser.add_argument(
"--model",
action="append",
default=[],
help=(
"Model reference(s): local checkpoint/model directory or HF id. "
"Repeatable."
),
)
parser.add_argument(
"--checkpoints-root",
type=Path,
default=None,
help="Directory that contains checkpoint-* folders to evaluate.",
)
parser.add_argument(
"--checkpoint-glob",
default="checkpoint-*",
help="Glob for checkpoint discovery under --checkpoints-root.",
)
parser.add_argument(
"--base-model",
default="nvidia/gliner-PII",
help="External baseline model id to include unless --skip-base-model is set.",
)
parser.add_argument(
"--skip-base-model",
action="store_true",
help="Do not include --base-model in evaluation.",
)
parser.add_argument(
"--batch-size",
type=int,
default=12,
help="Inference batch size.",
)
parser.add_argument(
"--threshold",
type=float,
default=0.5,
help="Prediction threshold for GLiNER inference.",
)
parser.add_argument(
"--flat-ner",
action=argparse.BooleanOptionalAction,
default=True,
help="Use flat NER decoding (default: true).",
)
parser.add_argument(
"--multi-label",
action=argparse.BooleanOptionalAction,
default=False,
help="Allow multi-label decoding per span/token.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Evaluate only first N examples (0 = all).",
)
parser.add_argument(
"--device",
default="auto",
help="Device for model inference: auto|cpu|cuda|cuda:0 etc.",
)
parser.add_argument(
"--case-sensitive-labels",
action="store_true",
help="Keep label casing as-is. Default normalizes labels to lowercase.",
)
parser.add_argument(
"--output-json",
type=Path,
default=None,
help="Optional path to write full metrics JSON.",
)
return parser.parse_args()
def normalize_label(label: str, case_sensitive: bool) -> str:
value = str(label).strip()
return value if case_sensitive else value.lower()
def token_offsets(tokens: list[str]) -> list[tuple[int, int]]:
offsets: list[tuple[int, int]] = []
cursor = 0
for token in tokens:
start = cursor
end = cursor + len(token)
offsets.append((start, end))
cursor = end + 1
return offsets
def parse_examples(
data: list[dict],
case_sensitive_labels: bool,
) -> tuple[list[Example], list[str], dict[str, int]]:
examples: list[Example] = []
all_labels: set[str] = set()
skipped_bad_rows = 0
skipped_bad_spans = 0
for idx, row in enumerate(data):
tokens = row.get("tokenized_text")
if not isinstance(tokens, list) or not all(isinstance(t, str) for t in tokens):
skipped_bad_rows += 1
continue
text = " ".join(tokens)
offsets = token_offsets(tokens)
spans: set[Span] = set()
for ner_item in row.get("ner", []):
if not isinstance(ner_item, (list, tuple)) or len(ner_item) < 3:
skipped_bad_spans += 1
continue
start_tok, end_tok, label = ner_item[0], ner_item[1], ner_item[2]
if not isinstance(start_tok, int) or not isinstance(end_tok, int):
skipped_bad_spans += 1
continue
if start_tok < 0 or end_tok < start_tok or end_tok >= len(tokens):
skipped_bad_spans += 1
continue
norm_label = normalize_label(label, case_sensitive_labels)
start_char = offsets[start_tok][0]
end_char = offsets[end_tok][1]
spans.add((norm_label, start_char, end_char))
all_labels.add(norm_label)
examples.append(Example(text=text, gold_spans=frozenset(spans)))
labels = sorted(all_labels)
stats = {
"input_rows": len(data),
"used_rows": len(examples),
"skipped_bad_rows": skipped_bad_rows,
"skipped_bad_spans": skipped_bad_spans,
"num_labels": len(labels),
}
return examples, labels, stats
def checkpoint_sort_key(path: Path) -> tuple[int, str]:
m = re.search(r"(\d+)(?!.*\d)", path.name)
if m is None:
return (10**18, path.name)
return (int(m.group(1)), path.name)
def collect_models(
model_args: list[str],
checkpoints_root: Path | None,
checkpoint_glob: str,
base_model: str,
skip_base_model: bool,
) -> list[str]:
model_refs: list[str] = []
for ref in model_args:
if ref:
model_refs.append(ref)
if checkpoints_root is not None:
ckpts = [p for p in checkpoints_root.glob(checkpoint_glob) if p.is_dir()]
for ckpt in sorted(ckpts, key=checkpoint_sort_key):
model_refs.append(str(ckpt))
if not skip_base_model and base_model:
model_refs.append(base_model)
deduped: list[str] = []
seen: set[str] = set()
for ref in model_refs:
if ref not in seen:
deduped.append(ref)
seen.add(ref)
return deduped
def resolve_device(device_arg: str) -> str:
if device_arg != "auto":
return device_arg
return "cuda" if torch.cuda.is_available() else "cpu"
def safe_prf(tp: int, fp: int, fn: int) -> dict[str, float]:
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
return {"precision": precision, "recall": recall, "f1": f1}
def compute_metrics(
gold_by_sample: list[set[Span]],
pred_by_sample: list[set[Span]],
) -> tuple[dict, dict[str, dict]]:
overall_tp = 0
overall_fp = 0
overall_fn = 0
true_by_label: dict[str, set[tuple[int, int, int]]] = defaultdict(set)
pred_by_label: dict[str, set[tuple[int, int, int]]] = defaultdict(set)
for sample_idx, (gold, pred) in enumerate(zip(gold_by_sample, pred_by_sample)):
tp_items = gold & pred
overall_tp += len(tp_items)
overall_fp += len(pred - gold)
overall_fn += len(gold - pred)
for label, start, end in gold:
true_by_label[label].add((sample_idx, start, end))
for label, start, end in pred:
pred_by_label[label].add((sample_idx, start, end))
overall = {
**safe_prf(overall_tp, overall_fp, overall_fn),
"tp": overall_tp,
"fp": overall_fp,
"fn": overall_fn,
"pred_total": overall_tp + overall_fp,
"true_total": overall_tp + overall_fn,
}
per_entity: dict[str, dict] = {}
all_labels = sorted(set(true_by_label) | set(pred_by_label))
for label in all_labels:
true_items = true_by_label.get(label, set())
pred_items = pred_by_label.get(label, set())
tp = len(true_items & pred_items)
fp = len(pred_items - true_items)
fn = len(true_items - pred_items)
per_entity[label] = {
**safe_prf(tp, fp, fn),
"tp": tp,
"fp": fp,
"fn": fn,
"pred_total": tp + fp,
"true_total": tp + fn,
}
return overall, per_entity
def batched(iterable: list[Example], n: int) -> Iterable[list[Example]]:
for i in range(0, len(iterable), n):
yield iterable[i : i + n]
@torch.no_grad()
def evaluate_model(
model_ref: str,
examples: list[Example],
labels: list[str],
batch_size: int,
threshold: float,
flat_ner: bool,
multi_label: bool,
case_sensitive_labels: bool,
device: str,
) -> dict:
model = GLiNER.from_pretrained(model_ref)
model.to(device)
model.eval()
gold_by_sample: list[set[Span]] = [set(ex.gold_spans) for ex in examples]
pred_by_sample: list[set[Span]] = []
for batch in tqdm(
list(batched(examples, batch_size)),
desc=f"eval:{model_ref}",
leave=False,
):
texts = [ex.text for ex in batch]
batch_preds = model.inference(
texts,
labels=labels,
threshold=threshold,
flat_ner=flat_ner,
multi_label=multi_label,
batch_size=len(texts),
)
for sample_preds in batch_preds:
pred_spans: set[Span] = set()
for item in sample_preds:
label = normalize_label(item.get("label", ""), case_sensitive_labels)
start = item.get("start")
end = item.get("end")
if not isinstance(start, int) or not isinstance(end, int):
continue
pred_spans.add((label, start, end))
pred_by_sample.append(pred_spans)
overall, per_entity = compute_metrics(gold_by_sample, pred_by_sample)
return {
"model": model_ref,
"overall": overall,
"per_entity": per_entity,
}
def print_result(result: dict) -> None:
model_ref = result["model"]
overall = result["overall"]
per_entity = result["per_entity"]
print(f"\nModel: {model_ref}")
print(
"Overall: "
f"P={overall['precision']:.4f} "
f"R={overall['recall']:.4f} "
f"F1={overall['f1']:.4f} "
f"(tp={overall['tp']} fp={overall['fp']} fn={overall['fn']} "
f"pred={overall['pred_total']} true={overall['true_total']})"
)
print("Per-entity:")
print(" label\tprecision\trecall\tf1\ttp\tfp\tfn\tpred\ttrue")
for label in sorted(per_entity):
m = per_entity[label]
print(
f" {label}\t"
f"{m['precision']:.4f}\t"
f"{m['recall']:.4f}\t"
f"{m['f1']:.4f}\t"
f"{m['tp']}\t{m['fp']}\t{m['fn']}\t"
f"{m['pred_total']}\t{m['true_total']}"
)
def main() -> None:
args = parse_args()
with args.eval_json.open("r", encoding="utf-8") as f:
raw = json.load(f)
if not isinstance(raw, list):
raise ValueError(f"{args.eval_json} must contain a JSON list of samples.")
if args.limit and args.limit > 0:
raw = raw[: args.limit]
examples, labels, parse_stats = parse_examples(
raw,
case_sensitive_labels=args.case_sensitive_labels,
)
if not examples:
raise ValueError("No valid examples found in eval JSON.")
if not labels:
raise ValueError("No labels found in eval JSON.")
model_refs = collect_models(
model_args=args.model,
checkpoints_root=args.checkpoints_root,
checkpoint_glob=args.checkpoint_glob,
base_model=args.base_model,
skip_base_model=args.skip_base_model,
)
if not model_refs:
raise ValueError("No models to evaluate. Provide --model or --checkpoints-root.")
device = resolve_device(args.device)
print(f"Eval file: {args.eval_json}")
print(f"Examples: {parse_stats['used_rows']} (from {parse_stats['input_rows']})")
print(f"Labels: {parse_stats['num_labels']}")
print(f"Device: {device}")
print(f"Models: {len(model_refs)}")
all_results: list[dict] = []
for model_ref in model_refs:
started = time.time()
result = evaluate_model(
model_ref=model_ref,
examples=examples,
labels=labels,
batch_size=args.batch_size,
threshold=args.threshold,
flat_ner=args.flat_ner,
multi_label=args.multi_label,
case_sensitive_labels=args.case_sensitive_labels,
device=device,
)
result["elapsed_sec"] = round(time.time() - started, 3)
print_result(result)
all_results.append(result)
payload = {
"eval_json": str(args.eval_json),
"parse_stats": parse_stats,
"labels": labels,
"settings": {
"batch_size": args.batch_size,
"threshold": args.threshold,
"flat_ner": args.flat_ner,
"multi_label": args.multi_label,
"case_sensitive_labels": args.case_sensitive_labels,
"device": device,
},
"results": all_results,
}
if args.output_json is not None:
args.output_json.parent.mkdir(parents=True, exist_ok=True)
with args.output_json.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\nSaved metrics JSON: {args.output_json}")
if __name__ == "__main__":
main()