Download source/src/deg/extractbench.py from gcoderw/DEG-5B-E2B-1019: direct link, hf CLI and curl.
- Browser
- Download file 13.5 kB
-
https://huggingface.co/gcoderw/DEG-5B-E2B-1019/resolve/main/source/src/deg/extractbench.py
- Command line
-
hf download hf://gcoderw/DEG-5B-E2B-1019/source/src/deg/extractbench.py
-
curl -L -o extractbench.py https://huggingface.co/gcoderw/DEG-5B-E2B-1019/resolve/main/source/src/deg/extractbench.py
13.5 kB
| """ExtractBench (Contextual AI, arXiv 2602.12247, MIT) as a deg EVAL set. Human gold: | |
| evaluation only, never training data. | |
| ``prepare`` maps each of the 5 schemas through :func:`deg.normalize.normalize_schema` | |
| (no leaf cap; ``evaluation_config`` and other non-deg keys are dropped, fields deg | |
| cannot express are dropped), reports how many schemas/leaves compile, and writes one record | |
| per document ``{"uid", "split": "eval", "domain", "context", "schema", "value", | |
| "eval_config"}``: ``context`` is the PDF text (``<name>.txt`` next to the PDF, made with | |
| PyMuPDF), ``value`` the gold projected onto the normalized schema. Image-only PDFs (no text | |
| layer) are skipped and counted. | |
| uv run --package deg python -m deg.extractbench \\ | |
| --root /data/deg/external/extractbench/dataset \\ | |
| --out /data/deg/extractbench/records.jsonl | |
| PDF -> text first (one-time; PyMuPDF is not a deg dependency, AGPL, used as a tool): | |
| uv run --package deg --with pymupdf python -m deg.extractbench --pdf-text | |
| Scoring (:func:`score_record`) compares extract fields only (decide/generate fields and | |
| array counts are reported separately): ``exact`` is value equality, ``normalized`` is | |
| equality after case/whitespace/punctuation folding (numbers compare as floats). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import unicodedata | |
| from pathlib import Path | |
| from typing import Any | |
| from deg.normalize import count_leaves, normalize_schema | |
| from deg.schema import SchemaError, compile_schema | |
| ROOT = Path("/data/deg/external/extractbench/dataset") | |
| RECORDS = Path("/data/deg/extractbench/records.jsonl") | |
| def load_schema(path: Path) -> dict[str, Any]: | |
| doc = json.loads(path.read_text()) | |
| return doc["schema_definition"] if "schema_definition" in doc else doc | |
| def raw_leaves(schema: Any, root: Any = None, ptr: str = "", | |
| seen: tuple[str, ...] = ()) -> dict[str, str | None]: | |
| """Abstract pointer -> ExtractBench ``evaluation_config`` (or None) of every leaf of the | |
| raw schema (local ``$ref`` followed, unions walked at the same pointer).""" | |
| root = schema if root is None else root | |
| if not isinstance(schema, dict): | |
| return {} | |
| ref = schema.get("$ref") | |
| if isinstance(ref, str) and ref.startswith("#/") and ref not in seen: | |
| target: Any = root | |
| for part in ref[2:].split("/"): | |
| target = target.get(part, {}) if isinstance(target, dict) else {} | |
| return raw_leaves({**target, **{k: v for k, v in schema.items() if k != "$ref"}}, | |
| root, ptr, (*seen, ref)) | |
| out: dict[str, str | None] = {} | |
| branches = [b for k in ("anyOf", "oneOf", "allOf") for b in schema.get(k) or []] | |
| for name, sub in (schema.get("properties") or {}).items(): | |
| out |= raw_leaves(sub, root, f"{ptr}/{name}", seen) | |
| if isinstance(schema.get("items"), dict): | |
| out |= raw_leaves(schema["items"], root, f"{ptr}/*", seen) | |
| for branch in branches: | |
| out |= raw_leaves(branch, root, ptr, seen) | |
| if not out and ptr: | |
| config = schema.get("evaluation_config") | |
| out[ptr] = config if isinstance(config, str) else None | |
| return out | |
| def project(value: Any, schema: dict[str, Any]) -> Any: | |
| """Gold restricted to the fields the normalized schema kept.""" | |
| if schema.get("type") == "object": | |
| if not isinstance(value, dict): | |
| return value | |
| return {k: project(v, schema["properties"][k]) for k, v in value.items() | |
| if k in schema["properties"]} | |
| if schema.get("type") == "array" and isinstance(value, list): | |
| return [project(v, schema["items"]) for v in value] | |
| return value | |
| def pdf_text(root: Path) -> int: | |
| """Write ``<name>.txt`` (PyMuPDF text layer, pages joined by newlines) per PDF.""" | |
| import pymupdf as fitz | |
| n = 0 | |
| for pdf in sorted(root.glob("*/*/pdf+gold/*.pdf")): | |
| with fitz.open(pdf) as doc: | |
| pdf.with_suffix(".txt").write_text("\n".join(page.get_text() for page in doc)) | |
| n += 1 | |
| return n | |
| def prepare(root: Path, out: Path) -> dict[str, Any]: | |
| report: dict[str, Any] = {"schemas": {}, "documents": 0, "skipped": {}} | |
| rows = [] | |
| for schema_path in sorted(root.glob("*/*/*-schema.json")): | |
| raw = load_schema(schema_path) | |
| name = schema_path.name.removesuffix("-schema.json") | |
| try: | |
| compile_schema(raw) | |
| as_is = True | |
| except SchemaError: | |
| as_is = False | |
| schema, why = normalize_schema(raw, max_leaves=10_000, max_depth=8) | |
| info: dict[str, Any] = {"compiles_as_is": as_is, "normalized": why} | |
| report["schemas"][name] = info | |
| if schema is None: | |
| continue | |
| plan = compile_schema(schema) | |
| info["leaves_kept"] = count_leaves(schema) | |
| kept = {f.pointer for f in plan.fields} | |
| leaves = raw_leaves(raw) | |
| info["leaves_raw"] = len(leaves) | |
| info["dropped"] = sorted(p for p in leaves if p not in kept) | |
| info["modes"] = {m: sum(f.mode == m for f in plan.fields) | |
| for m in sorted({f.mode for f in plan.fields})} | |
| configs = {p: c for p, c in leaves.items() if c is not None} | |
| domain = "/".join(schema_path.parent.relative_to(root).parts) | |
| for pdf in sorted(schema_path.parent.glob("pdf+gold/*.pdf")): | |
| gold_path = pdf.parent / (pdf.stem + ".gold.json") | |
| txt = pdf.with_suffix(".txt") | |
| if not gold_path.exists(): | |
| report["skipped"]["no_gold"] = report["skipped"].get("no_gold", 0) + 1 | |
| continue | |
| if not txt.exists(): | |
| report["skipped"]["no_text"] = report["skipped"].get("no_text", 0) + 1 | |
| continue | |
| context = txt.read_text() | |
| if len(context.split()) < 20: | |
| report["skipped"]["image_only"] = report["skipped"].get("image_only", 0) + 1 | |
| continue | |
| rows.append({"uid": f"extractbench:{domain}/{pdf.stem}", "split": "eval", | |
| "domain": f"extractbench:{domain}", "context": context, | |
| "schema": schema, | |
| "value": project(json.loads(gold_path.read_text()), schema), | |
| "eval_config": configs}) | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| with out.open("w", encoding="utf-8") as f: | |
| for row in rows: | |
| f.write(json.dumps(row, ensure_ascii=False) + "\n") | |
| report["documents"] = len(rows) | |
| report["words"] = sorted(len(r["context"].split()) for r in rows) | |
| return report | |
| _PUNCT = re.compile(r"[\W_]+", re.UNICODE) | |
| def normalize_value(v: Any) -> Any: | |
| if isinstance(v, bool) or v is None: | |
| return v | |
| if isinstance(v, (int, float)): | |
| return float(v) | |
| s = unicodedata.normalize("NFKC", str(v)).casefold() | |
| s = _PUNCT.sub(" ", s).strip() | |
| try: | |
| return float(s.replace(" ", "")) if re.fullmatch(r"[\d ]+(\.\d+)?", s) else s | |
| except ValueError: | |
| return s | |
| def _flatten(value: Any, prefix: str = "") -> dict[str, Any]: | |
| if isinstance(value, dict): | |
| out: dict[str, Any] = {} | |
| for k, v in value.items(): | |
| out |= _flatten(v, f"{prefix}/{k.replace('~', '~0').replace('/', '~1')}") | |
| return out | |
| if isinstance(value, list): | |
| out = {f"{prefix}#len": len(value)} | |
| for i, v in enumerate(value): | |
| out |= _flatten(v, f"{prefix}/{i}") | |
| return out | |
| return {prefix: value} | |
| def _abstract(ptr: str) -> str: | |
| return "/".join("*" if p.isdigit() else p for p in ptr.split("/")) | |
| def score_record(schema: dict[str, Any], gold: Any, pred: Any, align: bool = False | |
| ) -> dict[str, int]: | |
| """Counts for one document: extract fields (gold non-null or predicted), exact and | |
| normalized matches; array count matches. ``align``: array items are first matched to | |
| their best gold item (order-insensitive, normalized equality) instead of by index.""" | |
| if align: | |
| from deg.evaluate import align_arrays | |
| pred = align_arrays(gold, pred, lambda g, p: normalize_value(g) == normalize_value(p)) | |
| modes = {f.pointer: f.mode for f in compile_schema(schema).fields} | |
| g, p = _flatten(gold), _flatten(pred) | |
| c = {"extract_n": 0, "exact": 0, "normalized": 0, "count_n": 0, "count_ok": 0} | |
| for ptr in sorted(set(g) | set(p)): | |
| if ptr.endswith("#len"): | |
| if ptr in g: | |
| c["count_n"] += 1 | |
| c["count_ok"] += p.get(ptr) == g[ptr] | |
| continue | |
| if not modes.get(_abstract(ptr), "").startswith("extract."): | |
| continue | |
| gv, pv = g.get(ptr), p.get(ptr) | |
| if gv is None and pv is None: | |
| continue | |
| c["extract_n"] += 1 | |
| c["exact"] += gv == pv | |
| c["normalized"] += normalize_value(gv) == normalize_value(pv) | |
| return c | |
| def evaluate_records(model: Any, records: Path, n: int | None = None, | |
| predictions: Path | None = None) -> dict[str, Any]: | |
| """Run :func:`deg.infer.fill` on prepared ExtractBench records; extract-field exact | |
| and normalized match per domain and overall (human gold; eval only). ``predictions``: | |
| also write one ``{uid, gold, pred, error}`` JSON line per document.""" | |
| import gc | |
| import time | |
| import torch | |
| from deg.infer import fill | |
| from deg.request import FillOptions | |
| from deg.response import FillError | |
| rows = [json.loads(line) for line in records.read_text().splitlines() if line.strip()] | |
| rows = rows[:n] if n else rows | |
| per: dict[str, dict[str, Any]] = {} | |
| docs: list[dict[str, Any]] = [] | |
| pf = predictions.open("w", encoding="utf-8") if predictions else None | |
| cuda = torch.cuda.is_available() and model.device.type == "cuda" | |
| for row in rows: | |
| m = per.setdefault(row["domain"], {"docs": 0, "valid": 0, "errors": {}, | |
| "extract_n": 0, "exact": 0, "normalized": 0, | |
| "count_n": 0, "count_ok": 0}) | |
| m["docs"] += 1 | |
| doc: dict[str, Any] = {"uid": row["uid"], "words": len(row["context"].split()), | |
| "tokens": len(model.renderer.context(row["context"]).ids)} | |
| docs.append(doc) | |
| if cuda: | |
| torch.cuda.synchronize() | |
| torch.cuda.reset_peak_memory_stats() | |
| base = torch.cuda.memory_allocated() | |
| t0 = time.perf_counter() | |
| try: | |
| resp = fill(model, row["context"], row["schema"], FillOptions()) | |
| err = None | |
| except (FillError, torch.OutOfMemoryError) as exc: | |
| # An OOM is reported, never worked around by truncating the context. | |
| resp, err = None, (f"{type(exc).__name__}: {exc}" if isinstance( | |
| exc, torch.OutOfMemoryError) else str(exc))[:80] | |
| if cuda: | |
| torch.cuda.synchronize() | |
| doc["peak_gpu_mb"] = round(torch.cuda.max_memory_allocated() / 2**20) | |
| doc["peak_over_weights_mb"] = round( | |
| (torch.cuda.max_memory_allocated() - base) / 2**20) | |
| doc["seconds"] = round(time.perf_counter() - t0, 3) | |
| if pf: | |
| pf.write(json.dumps({"uid": row["uid"], "gold": row["value"], | |
| "pred": resp["value"] if resp else None, "error": err}, | |
| ensure_ascii=False) + "\n") | |
| pf.flush() | |
| if resp is None: | |
| doc["error"] = err | |
| m["errors"][err] = m["errors"].get(err, 0) + 1 | |
| if cuda: # the failed request's tensors sit in exception/frame cycles | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| print(json.dumps(doc), flush=True) | |
| continue | |
| m["valid"] += 1 | |
| for k, v in score_record(row["schema"], row["value"], resp["value"]).items(): | |
| m[k] += v | |
| doc[k] = v | |
| for k, v in score_record(row["schema"], row["value"], resp["value"], align=True).items(): | |
| if k in ("exact", "normalized"): | |
| m[f"{k}_aligned"] = m.get(f"{k}_aligned", 0) + v | |
| doc[f"{k}_aligned"] = v | |
| print(json.dumps(doc), flush=True) | |
| if pf: | |
| pf.close() | |
| total = {k: sum(m[k] for m in per.values()) | |
| for k in ("docs", "valid", "extract_n", "exact", "normalized", "count_n", | |
| "count_ok")} | |
| for k in ("exact_aligned", "normalized_aligned"): | |
| total[k] = sum(m.get(k, 0) for m in per.values()) | |
| for m in [*per.values(), total]: | |
| m["exact_rate"] = round(m["exact"] / max(1, m["extract_n"]), 4) | |
| m["normalized_rate"] = round(m["normalized"] / max(1, m["extract_n"]), 4) | |
| m["normalized_aligned_rate"] = round(m.get("normalized_aligned", 0) | |
| / max(1, m["extract_n"]), 4) | |
| return {"by_domain": per, "all": total, "docs": docs} | |
| def main(argv: list[str] | None = None) -> None: | |
| ap = argparse.ArgumentParser(description="ExtractBench -> deg eval records") | |
| ap.add_argument("--root", type=Path, default=ROOT) | |
| ap.add_argument("--out", type=Path, default=RECORDS) | |
| ap.add_argument("--pdf-text", action="store_true", help="first convert PDFs to .txt") | |
| args = ap.parse_args(argv) | |
| if args.pdf_text: | |
| print(json.dumps({"pdfs_converted": pdf_text(args.root)})) | |
| print(json.dumps(prepare(args.root, args.out), indent=1)) | |
| if __name__ == "__main__": | |
| main() | |