Spaces:
Runtime error
Runtime error
| """CUAD evaluation harness — the accuracy slide. | |
| Scores the clause classifier against CUAD's expert labels: | |
| for each contract and each category we evaluate, CUAD gives gold answer | |
| spans (character offsets into the contract text). We predict spans = the | |
| clauses our pipeline tags with that category. A prediction matches a gold | |
| span if their character-range Jaccard overlap >= --threshold (default 0.3, | |
| since our clause spans are deliberately wider than CUAD's tight answers | |
| recall is what matters most here). | |
| Reports per-category precision / recall / F1. | |
| .venv/bin/python -m eval.run_eval --limit 50 (from backend/) | |
| .venv/bin/python -m eval.run_eval --limit 50 --classifier zeroshot | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import pathlib | |
| import sys | |
| sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| from app.classification import classify_all # noqa: E402 | |
| from app.schema import Clause, SourceSpan # noqa: E402 | |
| from app.segmentation import CLAUSE_START # noqa: E402 | |
| CUAD_JSON = pathlib.Path(__file__).resolve().parents[2] / "data" / "cuad" / "CUAD_v1.json" | |
| # our category key -> substring of the CUAD question that identifies it | |
| CATEGORY_TO_CUAD_QUESTION = { | |
| "auto_renewal": "Renewal Term", | |
| "liability_cap": "Cap On Liability", | |
| "governing_law": "Governing Law", | |
| "exclusivity": "Exclusivity", | |
| "termination": "Termination For Convenience", | |
| "insurance": "Insurance", | |
| "audit_rights": "Audit Rights", | |
| "assignment": "Anti-Assignment", | |
| "notice": "Notice Period To Terminate Renewal", | |
| "ip_ownership": "Ip Ownership Assignment", | |
| "warranty": "Warranty Duration", | |
| "term": "Expiration Date", | |
| } | |
| def segment_plain_text(text: str) -> list[Clause]: | |
| """Lightweight clause split for raw CUAD text (no PDF offsets needed).""" | |
| lines = text.split("\n") | |
| offsets, pos = [], 0 | |
| for ln in lines: | |
| offsets.append(pos) | |
| pos += len(ln) + 1 | |
| starts = [i for i, ln in enumerate(lines) if CLAUSE_START.match(ln)] | |
| clauses = [] | |
| if not starts: | |
| starts = [0] | |
| for n, li in enumerate(starts): | |
| s = offsets[li] | |
| e = offsets[starts[n + 1]] if n + 1 < len(starts) else len(text) | |
| clauses.append(Clause( | |
| id=f"c{n}", text=text[s:e], | |
| span=SourceSpan(start_char=s, end_char=e, page=0))) | |
| return clauses | |
| def jaccard(a: tuple[int, int], b: tuple[int, int]) -> float: | |
| inter = max(0, min(a[1], b[1]) - max(a[0], b[0])) | |
| union = (a[1] - a[0]) + (b[1] - b[0]) - inter | |
| return inter / union if union else 0.0 | |
| def overlap_recall(gold: tuple[int, int], pred: tuple[int, int]) -> float: | |
| """Fraction of the gold span covered by the prediction.""" | |
| inter = max(0, min(gold[1], pred[1]) - max(gold[0], pred[0])) | |
| return inter / (gold[1] - gold[0]) if gold[1] > gold[0] else 0.0 | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--limit", type=int, default=50, help="contracts to evaluate") | |
| ap.add_argument("--threshold", type=float, default=0.3, | |
| help="match if gold-span coverage >= threshold") | |
| ap.add_argument("--classifier", | |
| choices=["rules", "zeroshot", "legalbert", "fusion", "auto"], default="rules", | |
| help="classification backend to score (default: rules). 'fusion' is " | |
| "the shipped default (fine-tuned LegalBERT + zero-shot fill); on the " | |
| "scored CUAD categories it is identical to 'legalbert'.") | |
| args = ap.parse_args() | |
| os.environ["CLASSIFIER"] = args.classifier | |
| if not CUAD_JSON.exists(): | |
| sys.exit(f"CUAD not found at {CUAD_JSON}. Run scripts/download_cuad.py first.") | |
| data = json.loads(CUAD_JSON.read_text())["data"] | |
| data = data[: args.limit] | |
| stats = {k: {"tp": 0, "fp": 0, "fn": 0} for k in CATEGORY_TO_CUAD_QUESTION} | |
| for doc in data: | |
| for para in doc["paragraphs"]: | |
| text = para["context"] | |
| clauses = segment_plain_text(text) | |
| classify_all(clauses) | |
| # gold spans per category | |
| gold: dict[str, list[tuple[int, int]]] = {k: [] for k in stats} | |
| for qa in para["qas"]: | |
| for key, q_sub in CATEGORY_TO_CUAD_QUESTION.items(): | |
| if q_sub in qa["question"]: | |
| for ans in qa["answers"]: | |
| s = ans["answer_start"] | |
| gold[key].append((s, s + len(ans["text"]))) | |
| for key in stats: | |
| preds = [(c.span.start_char, c.span.end_char) | |
| for c in clauses if key in c.categories] | |
| golds = gold[key] | |
| matched_gold, matched_pred = set(), set() | |
| for gi, g in enumerate(golds): | |
| for pi, p in enumerate(preds): | |
| if overlap_recall(g, p) >= args.threshold: | |
| matched_gold.add(gi) | |
| matched_pred.add(pi) | |
| stats[key]["tp"] += len(matched_gold) | |
| stats[key]["fn"] += len(golds) - len(matched_gold) | |
| stats[key]["fp"] += len(preds) - len(matched_pred) | |
| print(f"\nCUAD eval — {len(data)} contracts, classifier={args.classifier}, " | |
| f"gold-coverage threshold {args.threshold}\n") | |
| print(f"{'category':<18} {'P':>6} {'R':>6} {'F1':>6} tp/fp/fn") | |
| print("-" * 56) | |
| macro = [] | |
| for key, s in stats.items(): | |
| p = s["tp"] / (s["tp"] + s["fp"]) if s["tp"] + s["fp"] else 0.0 | |
| r = s["tp"] / (s["tp"] + s["fn"]) if s["tp"] + s["fn"] else 0.0 | |
| f1 = 2 * p * r / (p + r) if p + r else 0.0 | |
| macro.append(f1) | |
| print(f"{key:<18} {p:6.2f} {r:6.2f} {f1:6.2f} " | |
| f"{s['tp']}/{s['fp']}/{s['fn']}") | |
| print("-" * 56) | |
| print(f"{'macro-F1':<18} {'':>6} {'':>6} {sum(macro) / len(macro):6.2f}") | |
| if __name__ == "__main__": | |
| main() | |