contract-extractor / backend /scripts /prepare_cuad.py
myke69's picture
Add files using upload-large-folder tool
296a9b2 verified
Raw
History Blame Contribute Delete
5.42 kB
"""Convert CUAD (extractive-QA format) into multi-label clause-classification
training data.
CUAD ships as SQuAD-style question/answer-span data: per contract, ~41
questions, each answered with text spans (or none). To *train a classifier*
we need (clause_text -> set of category labels). This script:
1. segments each CUAD contract into clauses (same heading logic as the app),
2. labels a clause with category X if it overlaps a CUAD answer span for X's
question (coverage >= --overlap),
3. emits negatives (clauses with no label) so the model learns "none too",
4. splits BY CONTRACT (never by clause) so the eval can't leak.
python -m scripts.prepare_cuad (from backend/, needs data/cuad)
-> data/cuad/train.jsonl, val.jsonl, labels.json ({"text":..., "labels":[...]})
Only categories with real CUAD ground truth are trained (see CUAD_QUESTION_MAP);
the rest keep the rules/zero-shot path.
"""
from __future__ import annotations
import json
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from app.schema import Clause, SourceSpan # noqa: E402
from app.segmentation import CLAUSE_START # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parents[2]
CUAD_JSON = ROOT / "data" / "cuad" / "CUAD_v1.json"
OUT_DIR = ROOT / "data" / "cuad"
# our category key -> substring identifying the CUAD question for it.
# (Only categories CUAD actually labels; indemnification/sla/payment etc. have
# no CUAD ground truth, so they stay on the rules/zero-shot path.)
CUAD_QUESTION_MAP = {
"termination": "Termination For Convenience",
"auto_renewal": "Renewal Term",
"notice": "Notice Period To Terminate Renewal",
"liability_cap": "Cap On Liability",
"governing_law": "Governing Law",
"exclusivity": "Exclusivity",
"insurance": "Insurance",
"audit_rights": "Audit Rights",
"assignment": "Anti-Assignment",
"ip_ownership": "Ip Ownership Assignment",
"warranty": "Warranty Duration",
"term": "Expiration Date",
}
LABELS = sorted(CUAD_QUESTION_MAP)
def segment_plain_text(text: str) -> list[Clause]:
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)] or [0]
out = []
for n, li in enumerate(starts):
s = offsets[li]
e = offsets[starts[n + 1]] if n + 1 < len(starts) else len(text)
out.append(Clause(id=f"c{n}", text=text[s:e],
span=SourceSpan(start_char=s, end_char=e, page=0)))
return out
def coverage(gold: tuple[int, int], clause: tuple[int, int]) -> float:
inter = max(0, min(gold[1], clause[1]) - max(gold[0], clause[0]))
return inter / (gold[1] - gold[0]) if gold[1] > gold[0] else 0.0
def build(limit: int, overlap: float, val_frac: float):
data = json.loads(CUAD_JSON.read_text())["data"][:limit]
rows: list[tuple[int, dict]] = [] # (doc_index, example)
for di, doc in enumerate(data):
for para in doc["paragraphs"]:
text = para["context"]
clauses = segment_plain_text(text)
gold: dict[str, list[tuple[int, int]]] = {k: [] for k in LABELS}
for qa in para["qas"]:
for key, q_sub in CUAD_QUESTION_MAP.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 c in clauses:
if len(c.text.strip()) < 25:
continue
cspan = (c.span.start_char, c.span.end_char)
labels = [k for k in LABELS
if any(coverage(g, cspan) >= overlap for g in gold[k])]
rows.append((di, {"text": c.text.strip()[:4000], "labels": labels}))
# contract-level split (deterministic — by doc index, no RNG)
n_docs = len(data)
val_docs = {i for i in range(n_docs) if i % max(1, round(1 / val_frac)) == 0}
train = [ex for di, ex in rows if di not in val_docs]
val = [ex for di, ex in rows if di in val_docs]
return train, val
def main() -> None:
if not CUAD_JSON.exists():
sys.exit(f"CUAD not found at {CUAD_JSON}. Run scripts/download_cuad.py first.")
limit, overlap, val_frac = 999, 0.3, 0.15
for a in sys.argv:
if a.startswith("--limit="):
limit = int(a.split("=", 1)[1])
if a.startswith("--overlap="):
overlap = float(a.split("=", 1)[1])
train, val = build(limit, overlap, val_frac)
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / "train.jsonl").write_text("".join(json.dumps(r) + "\n" for r in train))
(OUT_DIR / "val.jsonl").write_text("".join(json.dumps(r) + "\n" for r in val))
(OUT_DIR / "labels.json").write_text(json.dumps(LABELS, indent=2))
pos = sum(1 for r in train if r["labels"])
print(f"wrote {len(train)} train / {len(val)} val clauses "
f"({pos} train clauses carry >=1 label) over {len(LABELS)} categories")
print(f" -> {OUT_DIR}/train.jsonl, val.jsonl, labels.json")
if __name__ == "__main__":
main()