You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

199 lines
6.8 KiB
Python

"""QA Tool: evaluate parser output against a gold set of manually labeled documents."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from libs.common.config import get_settings
from libs.common.logging import configure_logging, get_logger
from libs.parser.rule_parser import RuleBasedParser
from libs.parser.text_normalizer import normalize_text
logger = get_logger(__name__)
_GOLD_SET_DIR = Path("data/gold_set")
_DIRECTION_FIELDS = ("event_type", "event_direction")
_ONEOFF_FIELD = "oneoff_item"
def _load_gold_set(gold_dir: Path) -> list[dict[str, Any]]:
"""Load all gold set JSON files from directory."""
entries = []
for path in sorted(gold_dir.glob("*.json")):
data = json.loads(path.read_text())
if isinstance(data, list):
entries.extend(data)
else:
entries.append(data)
return entries
def _precision_recall_f1(tp: int, fp: int, fn: int) -> dict[str, float]:
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = (
2 * precision * recall / (precision + recall)
if (precision + recall) > 0
else 0.0
)
return {"precision": precision, "recall": recall, "f1": f1}
def evaluate(gold_entries: list[dict[str, Any]], parser: RuleBasedParser) -> dict[str, Any]:
"""Run parser on gold set and compute accuracy metrics.
Each gold entry must have:
- document_id: str
- form_type: str
- text: str (raw exhibit text)
- gold_event_type: str
- gold_event_direction: str
- gold_guidance_status: str (optional)
- gold_oneoff: bool (optional)
- gold_quality_assessment: str (optional)
"""
total = len(gold_entries)
if total == 0:
return {"error": "Empty gold set"}
# Accuracy trackers
event_type_correct = 0
direction_correct = 0
guidance_correct = 0
guidance_total = 0
# Oneoff precision/recall
oneoff_tp = oneoff_fp = oneoff_fn = 0
# Evidence presence
evidence_present = 0
per_doc: list[dict[str, Any]] = []
for entry in gold_entries:
text = normalize_text(entry.get("text", ""))
meta = {
"filing_date": entry.get("filing_date", "1900-01-01"),
"form_type": entry.get("form_type", "8-K"),
}
output = parser.parse(
document_id=entry.get("document_id", "unknown"),
form_type=entry.get("form_type", "8-K"),
text=text,
metadata=meta,
)
out = output.model_dump()
gold_et = entry.get("gold_event_type", "")
gold_dir = entry.get("gold_event_direction", "")
gold_guidance = entry.get("gold_guidance_status", None)
gold_oneoff = entry.get("gold_oneoff", None)
et_match = out["event_type"] == gold_et
dir_match = out["event_direction"] == gold_dir
event_type_correct += int(et_match)
direction_correct += int(dir_match)
if gold_guidance is not None:
guidance_correct += int(out["guidance"]["status"] == gold_guidance)
guidance_total += 1
if gold_oneoff is not None:
pred_oneoff = out["risk_flags"]["oneoff_item"]
if pred_oneoff and gold_oneoff:
oneoff_tp += 1
elif pred_oneoff and not gold_oneoff:
oneoff_fp += 1
elif not pred_oneoff and gold_oneoff:
oneoff_fn += 1
has_evidence = len(out.get("evidence", [])) > 0
evidence_present += int(has_evidence)
per_doc.append({
"document_id": entry.get("document_id"),
"et_match": et_match,
"dir_match": dir_match,
"pred_event_type": out["event_type"],
"gold_event_type": gold_et,
"pred_direction": out["event_direction"],
"gold_direction": gold_dir,
"confidence": out["confidence"]["overall"],
})
oneoff_metrics = _precision_recall_f1(oneoff_tp, oneoff_fp, oneoff_fn)
summary: dict[str, Any] = {
"total": total,
"event_type_accuracy": event_type_correct / total,
"direction_accuracy": direction_correct / total,
"guidance_accuracy": guidance_correct / guidance_total if guidance_total > 0 else None,
"oneoff_precision": oneoff_metrics["precision"],
"oneoff_recall": oneoff_metrics["recall"],
"oneoff_f1": oneoff_metrics["f1"],
"evidence_presence_ratio": evidence_present / total,
"per_document": per_doc,
}
return summary
def main() -> None:
arg_parser = argparse.ArgumentParser(description="Evaluate parser against gold set")
arg_parser.add_argument(
"--gold-dir",
default=str(_GOLD_SET_DIR),
help="Directory containing gold set JSON files",
)
arg_parser.add_argument("--output", default=None, help="Write JSON report to this file")
arg_parser.add_argument("--verbose", action="store_true", help="Print per-doc results")
args = arg_parser.parse_args()
settings = get_settings()
configure_logging(settings.log_level)
gold_dir = Path(args.gold_dir)
if not gold_dir.exists():
print(f"Gold set directory not found: {gold_dir}", file=sys.stderr)
sys.exit(1)
gold_entries = _load_gold_set(gold_dir)
if not gold_entries:
print(f"No gold set entries found in {gold_dir}", file=sys.stderr)
sys.exit(1)
parser = RuleBasedParser()
report = evaluate(gold_entries, parser)
print(f"\n=== Gold Set Evaluation Report ({report['total']} documents) ===")
print(f" Event type accuracy : {report['event_type_accuracy']:.1%}")
print(f" Direction accuracy : {report['direction_accuracy']:.1%}")
if report["guidance_accuracy"] is not None:
print(f" Guidance accuracy : {report['guidance_accuracy']:.1%}")
print(f" One-off F1 : {report['oneoff_f1']:.3f}")
print(f" Precision : {report['oneoff_precision']:.3f}")
print(f" Recall : {report['oneoff_recall']:.3f}")
print(f" Evidence presence : {report['evidence_presence_ratio']:.1%}")
if args.verbose and report.get("per_document"):
print("\n--- Per-document results ---")
for doc in report["per_document"]:
status = "OK" if doc["et_match"] and doc["dir_match"] else "MISMATCH"
print(
f" [{status}] {doc['document_id']}: "
f"type={doc['pred_event_type']}({doc['gold_event_type']}) "
f"dir={doc['pred_direction']}({doc['gold_direction']}) "
f"conf={doc['confidence']:.2f}"
)
if args.output:
out_path = Path(args.output)
out_path.write_text(json.dumps(report, indent=2))
print(f"\nReport written to {out_path}")
if __name__ == "__main__":
main()