myke69's picture
Add files using upload-large-folder tool
296a9b2 verified
Raw
History Blame Contribute Delete
6.14 kB
"""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()