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
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()
|