File size: 13,483 Bytes
16d760d
aff03ff
 
16d760d
 
aff03ff
 
 
 
 
 
16d760d
 
 
aff03ff
16d760d
aff03ff
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
 
aff03ff
16d760d
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
16d760d
 
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
"""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()