fieldbench 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fieldbench-0.2.1/LICENSE +21 -0
- fieldbench-0.2.1/PKG-INFO +107 -0
- fieldbench-0.2.1/README.md +75 -0
- fieldbench-0.2.1/pyproject.toml +42 -0
- fieldbench-0.2.1/setup.cfg +4 -0
- fieldbench-0.2.1/src/fieldbench/__init__.py +23 -0
- fieldbench-0.2.1/src/fieldbench/aggregate.py +120 -0
- fieldbench-0.2.1/src/fieldbench/cli.py +134 -0
- fieldbench-0.2.1/src/fieldbench/corpus.py +167 -0
- fieldbench-0.2.1/src/fieldbench/run.py +209 -0
- fieldbench-0.2.1/src/fieldbench/scoring.py +333 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/PKG-INFO +107 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/SOURCES.txt +18 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/dependency_links.txt +1 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/entry_points.txt +2 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/requires.txt +18 -0
- fieldbench-0.2.1/src/fieldbench.egg-info/top_level.txt +1 -0
- fieldbench-0.2.1/tests/test_corpus.py +91 -0
- fieldbench-0.2.1/tests/test_run.py +131 -0
- fieldbench-0.2.1/tests/test_scoring.py +121 -0
fieldbench-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frank Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fieldbench
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: A cross-domain, field-level benchmark for schema-driven document extraction
|
|
5
|
+
Author: Frank Thomas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fieldbench/fieldbench
|
|
8
|
+
Project-URL: Corpus, https://github.com/fieldbench/corpus
|
|
9
|
+
Keywords: benchmark,document-extraction,information-extraction,evaluation,nlp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
24
|
+
Provides-Extra: anthropic
|
|
25
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
26
|
+
Provides-Extra: llamaindex
|
|
27
|
+
Requires-Dist: llama-index-core>=0.11; extra == "llamaindex"
|
|
28
|
+
Requires-Dist: llama-index-llms-openai>=0.2; extra == "llamaindex"
|
|
29
|
+
Provides-Extra: langchain
|
|
30
|
+
Requires-Dist: langchain-openai>=0.2; extra == "langchain"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# FieldBench
|
|
34
|
+
|
|
35
|
+
A cross-domain, field-level benchmark for **schema-driven document extraction** — and the canonical scorer that goes with it.
|
|
36
|
+
|
|
37
|
+
Every document-extraction vendor claims 95%+ accuracy; almost none publish how they measure it. FieldBench makes extraction accuracy **falsifiable and comparable**: a shared corpus, a shared type-aware scorer, and a leaderboard anyone can submit to.
|
|
38
|
+
|
|
39
|
+
> **Status: alpha (v0.1).** The scorer is the first piece to land. The corpus lives at [`fieldbench/corpus`](https://github.com/fieldbench/corpus). Leaderboard and HuggingFace packaging are in progress.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install fieldbench # once published; for now: pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Run a baseline
|
|
48
|
+
|
|
49
|
+
`fieldbench run` drives any extractor over the corpus and writes the prediction files for you. It's extractor-agnostic — the whole integration surface is a `complete(prompt) -> str` callable (see `examples/openai_runner.py`):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "fieldbench[openai]"
|
|
53
|
+
OPENAI_API_KEY=... FIELDBENCH_MODEL=gpt-4o-mini \
|
|
54
|
+
fieldbench run --corpus ./corpus --out ./preds/gpt-4o-mini \
|
|
55
|
+
--runner examples.openai_runner:make_runner
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Runs are **resumable** (existing predictions are skipped) and **never invent results** — a runner error writes nothing, so that document is scored as a real miss, not dropped.
|
|
59
|
+
|
|
60
|
+
## Score your system
|
|
61
|
+
|
|
62
|
+
Score prediction files — a flat `{field: value}` JSON named `<doc_id>.json` per document (produced by `fieldbench run` or by your own pipeline):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
fieldbench score --corpus /path/to/corpus --results /path/to/predictions/
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You get overall accuracy, the **all-null floor** (what an empty extractor scores for free), a **real-vs-synthetic** split, a **four-way outcome breakdown**, and a per-category table. `--json` emits the full report; `--category <name>` scopes to one category.
|
|
69
|
+
|
|
70
|
+
## What makes the scoring type-aware
|
|
71
|
+
|
|
72
|
+
`compare_field` runs **four-way null semantics** first — the thing plain accuracy hides:
|
|
73
|
+
|
|
74
|
+
| Outcome | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `correct_absence` | field absent in GT, system correctly returned null |
|
|
77
|
+
| `hallucination` | field absent in GT, system invented a value |
|
|
78
|
+
| `miss` | field present in GT, system returned null |
|
|
79
|
+
| `match` / `wrong_value` | both present → type-aware comparison |
|
|
80
|
+
|
|
81
|
+
For present-vs-present it is tolerant where it should be and strict where it must be: numeric with cent-exact tolerance, date normalization, order-independent array F1 (partial credit), punctuation-insensitive strings, and schema enum-alias folding — but never masking a genuine content difference.
|
|
82
|
+
|
|
83
|
+
## How to cite
|
|
84
|
+
|
|
85
|
+
```bibtex
|
|
86
|
+
@misc{fieldbench,
|
|
87
|
+
title = {FieldBench: A Cross-Domain Benchmark for Schema-Driven Document Extraction},
|
|
88
|
+
author = {Thomas, Frank},
|
|
89
|
+
year = {2026},
|
|
90
|
+
note = {https://github.com/fieldbench}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
<!-- Replaced with the Zenodo DOI on release, and the paper citation once published. -->
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e ".[dev]"
|
|
99
|
+
pytest # golden scorer tests
|
|
100
|
+
ruff check .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The golden tests in `tests/` are the anti-drift contract: they pin the canonical scoring semantics that any conforming implementation (including Koji's) must match.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
Code: MIT. The corpus is licensed per-source — see [`fieldbench/corpus`](https://github.com/fieldbench/corpus).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# FieldBench
|
|
2
|
+
|
|
3
|
+
A cross-domain, field-level benchmark for **schema-driven document extraction** — and the canonical scorer that goes with it.
|
|
4
|
+
|
|
5
|
+
Every document-extraction vendor claims 95%+ accuracy; almost none publish how they measure it. FieldBench makes extraction accuracy **falsifiable and comparable**: a shared corpus, a shared type-aware scorer, and a leaderboard anyone can submit to.
|
|
6
|
+
|
|
7
|
+
> **Status: alpha (v0.1).** The scorer is the first piece to land. The corpus lives at [`fieldbench/corpus`](https://github.com/fieldbench/corpus). Leaderboard and HuggingFace packaging are in progress.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fieldbench # once published; for now: pip install -e .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Run a baseline
|
|
16
|
+
|
|
17
|
+
`fieldbench run` drives any extractor over the corpus and writes the prediction files for you. It's extractor-agnostic — the whole integration surface is a `complete(prompt) -> str` callable (see `examples/openai_runner.py`):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install "fieldbench[openai]"
|
|
21
|
+
OPENAI_API_KEY=... FIELDBENCH_MODEL=gpt-4o-mini \
|
|
22
|
+
fieldbench run --corpus ./corpus --out ./preds/gpt-4o-mini \
|
|
23
|
+
--runner examples.openai_runner:make_runner
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Runs are **resumable** (existing predictions are skipped) and **never invent results** — a runner error writes nothing, so that document is scored as a real miss, not dropped.
|
|
27
|
+
|
|
28
|
+
## Score your system
|
|
29
|
+
|
|
30
|
+
Score prediction files — a flat `{field: value}` JSON named `<doc_id>.json` per document (produced by `fieldbench run` or by your own pipeline):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
fieldbench score --corpus /path/to/corpus --results /path/to/predictions/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You get overall accuracy, the **all-null floor** (what an empty extractor scores for free), a **real-vs-synthetic** split, a **four-way outcome breakdown**, and a per-category table. `--json` emits the full report; `--category <name>` scopes to one category.
|
|
37
|
+
|
|
38
|
+
## What makes the scoring type-aware
|
|
39
|
+
|
|
40
|
+
`compare_field` runs **four-way null semantics** first — the thing plain accuracy hides:
|
|
41
|
+
|
|
42
|
+
| Outcome | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `correct_absence` | field absent in GT, system correctly returned null |
|
|
45
|
+
| `hallucination` | field absent in GT, system invented a value |
|
|
46
|
+
| `miss` | field present in GT, system returned null |
|
|
47
|
+
| `match` / `wrong_value` | both present → type-aware comparison |
|
|
48
|
+
|
|
49
|
+
For present-vs-present it is tolerant where it should be and strict where it must be: numeric with cent-exact tolerance, date normalization, order-independent array F1 (partial credit), punctuation-insensitive strings, and schema enum-alias folding — but never masking a genuine content difference.
|
|
50
|
+
|
|
51
|
+
## How to cite
|
|
52
|
+
|
|
53
|
+
```bibtex
|
|
54
|
+
@misc{fieldbench,
|
|
55
|
+
title = {FieldBench: A Cross-Domain Benchmark for Schema-Driven Document Extraction},
|
|
56
|
+
author = {Thomas, Frank},
|
|
57
|
+
year = {2026},
|
|
58
|
+
note = {https://github.com/fieldbench}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
<!-- Replaced with the Zenodo DOI on release, and the paper citation once published. -->
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install -e ".[dev]"
|
|
67
|
+
pytest # golden scorer tests
|
|
68
|
+
ruff check .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The golden tests in `tests/` are the anti-drift contract: they pin the canonical scoring semantics that any conforming implementation (including Koji's) must match.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
Code: MIT. The corpus is licensed per-source — see [`fieldbench/corpus`](https://github.com/fieldbench/corpus).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fieldbench"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "A cross-domain, field-level benchmark for schema-driven document extraction"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Frank Thomas" }]
|
|
13
|
+
keywords = ["benchmark", "document-extraction", "information-extraction", "evaluation", "nlp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["pyyaml>=6"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
25
|
+
openai = ["openai>=1.0"]
|
|
26
|
+
anthropic = ["anthropic>=0.30"]
|
|
27
|
+
llamaindex = ["llama-index-core>=0.11", "llama-index-llms-openai>=0.2"]
|
|
28
|
+
langchain = ["langchain-openai>=0.2"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/fieldbench/fieldbench"
|
|
32
|
+
Corpus = "https://github.com/fieldbench/corpus"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
fieldbench = "fieldbench.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 120
|
|
42
|
+
target-version = "py310"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""FieldBench — a cross-domain, field-level benchmark for schema-driven document extraction."""
|
|
2
|
+
|
|
3
|
+
from .aggregate import DocResult, Report, build_report
|
|
4
|
+
from .corpus import list_documents, score_corpus
|
|
5
|
+
from .run import LLMRunner, Runner, run_corpus
|
|
6
|
+
from .scoring import SCORER_VERSION, FieldResult, compare_field
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.1"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"compare_field",
|
|
12
|
+
"FieldResult",
|
|
13
|
+
"SCORER_VERSION",
|
|
14
|
+
"score_corpus",
|
|
15
|
+
"list_documents",
|
|
16
|
+
"build_report",
|
|
17
|
+
"DocResult",
|
|
18
|
+
"Report",
|
|
19
|
+
"run_corpus",
|
|
20
|
+
"Runner",
|
|
21
|
+
"LLMRunner",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Aggregate per-field results into a FieldBench report.
|
|
2
|
+
|
|
3
|
+
Reports micro/macro field accuracy, always stratified real vs synthetic, with a
|
|
4
|
+
four-way confusion breakdown (correct-absence / hallucination / miss /
|
|
5
|
+
wrong-value) and the all-null degenerate floor — so every score is read against
|
|
6
|
+
what an empty extractor scores for free.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .scoring import SCORER_VERSION, FieldResult, is_empty
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DocResult:
|
|
19
|
+
stem: str
|
|
20
|
+
category: str
|
|
21
|
+
source: str # "real" | "synthetic" | "unknown"
|
|
22
|
+
fields: list[FieldResult]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Stratum:
|
|
27
|
+
docs: int = 0
|
|
28
|
+
fields: int = 0
|
|
29
|
+
passed: int = 0
|
|
30
|
+
weighted: float = 0.0
|
|
31
|
+
four_way: Counter = field(default_factory=Counter)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def accuracy(self) -> float:
|
|
35
|
+
return self.passed / self.fields if self.fields else 0.0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def partial(self) -> float:
|
|
39
|
+
return self.weighted / self.fields if self.fields else 0.0
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
n = self.fields or 1
|
|
43
|
+
return {
|
|
44
|
+
"docs": self.docs,
|
|
45
|
+
"fields": self.fields,
|
|
46
|
+
"passed": self.passed,
|
|
47
|
+
"accuracy": round(self.accuracy, 4),
|
|
48
|
+
"partial_credit": round(self.partial, 4),
|
|
49
|
+
"four_way": {
|
|
50
|
+
bucket: {"count": c, "rate": round(c / n, 4)}
|
|
51
|
+
for bucket, c in sorted(self.four_way.items())
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Report:
|
|
58
|
+
scorer_version: str = SCORER_VERSION
|
|
59
|
+
mode: str = "unspecified" # which representation the predictions came from
|
|
60
|
+
overall: Stratum = field(default_factory=Stratum)
|
|
61
|
+
by_source: dict[str, Stratum] = field(default_factory=dict)
|
|
62
|
+
by_category: dict[str, Stratum] = field(default_factory=dict)
|
|
63
|
+
four_way: Counter = field(default_factory=Counter)
|
|
64
|
+
all_null_floor: float = 0.0
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
total = sum(self.four_way.values()) or 1
|
|
68
|
+
return {
|
|
69
|
+
"scorer_version": self.scorer_version,
|
|
70
|
+
"mode": self.mode,
|
|
71
|
+
"overall": self.overall.to_dict(),
|
|
72
|
+
"by_source": {k: v.to_dict() for k, v in sorted(self.by_source.items())},
|
|
73
|
+
"by_category": {k: v.to_dict() for k, v in sorted(self.by_category.items())},
|
|
74
|
+
"four_way": {
|
|
75
|
+
bucket: {"count": n, "rate": round(n / total, 4)}
|
|
76
|
+
for bucket, n in sorted(self.four_way.items())
|
|
77
|
+
},
|
|
78
|
+
"all_null_floor": round(self.all_null_floor, 4),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _add(strata: dict[str, Stratum], key: str, r: FieldResult) -> None:
|
|
83
|
+
s = strata.setdefault(key, Stratum())
|
|
84
|
+
s.fields += 1
|
|
85
|
+
s.passed += int(r.passed)
|
|
86
|
+
s.weighted += r.weighted_score
|
|
87
|
+
s.four_way[r.bucket] += 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_report(docs: list[DocResult], mode: str = "unspecified") -> Report:
|
|
91
|
+
rep = Report(mode=mode)
|
|
92
|
+
empty_expected = 0
|
|
93
|
+
total = 0
|
|
94
|
+
doc_counts_source: dict[str, int] = defaultdict(int)
|
|
95
|
+
doc_counts_cat: dict[str, int] = defaultdict(int)
|
|
96
|
+
|
|
97
|
+
for doc in docs:
|
|
98
|
+
doc_counts_source[doc.source] += 1
|
|
99
|
+
doc_counts_cat[doc.category] += 1
|
|
100
|
+
for r in doc.fields:
|
|
101
|
+
total += 1
|
|
102
|
+
rep.overall.fields += 1
|
|
103
|
+
rep.overall.passed += int(r.passed)
|
|
104
|
+
rep.overall.weighted += r.weighted_score
|
|
105
|
+
rep.four_way[r.bucket] += 1
|
|
106
|
+
_add(rep.by_source, doc.source, r)
|
|
107
|
+
_add(rep.by_category, doc.category, r)
|
|
108
|
+
if is_empty(r.expected):
|
|
109
|
+
empty_expected += 1
|
|
110
|
+
|
|
111
|
+
rep.overall.docs = len(docs)
|
|
112
|
+
for src, n in doc_counts_source.items():
|
|
113
|
+
rep.by_source.setdefault(src, Stratum()).docs = n
|
|
114
|
+
for cat, n in doc_counts_cat.items():
|
|
115
|
+
rep.by_category.setdefault(cat, Stratum()).docs = n
|
|
116
|
+
|
|
117
|
+
# All-null floor: an empty extractor scores exactly the empty-GT fields
|
|
118
|
+
# (they land in `correct_absence`); everything else becomes a miss.
|
|
119
|
+
rep.all_null_floor = empty_expected / total if total else 0.0
|
|
120
|
+
return rep
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""FieldBench CLI: score prediction files against the corpus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .aggregate import build_report
|
|
12
|
+
from .corpus import score_corpus
|
|
13
|
+
from .run import run_corpus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_runner(ref: str):
|
|
17
|
+
"""Load a runner from `module:attr`. attr is a Runner instance or a
|
|
18
|
+
zero-arg factory that returns one."""
|
|
19
|
+
if ":" not in ref:
|
|
20
|
+
raise ValueError(f"--runner must be 'module:attr', got {ref!r}")
|
|
21
|
+
mod_name, attr = ref.split(":", 1)
|
|
22
|
+
obj = getattr(importlib.import_module(mod_name), attr)
|
|
23
|
+
if hasattr(obj, "extract"):
|
|
24
|
+
return obj
|
|
25
|
+
return obj() # factory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_table(report_dict: dict) -> None:
|
|
29
|
+
o = report_dict["overall"]
|
|
30
|
+
print(f"\nFieldBench (scorer {report_dict['scorer_version']})")
|
|
31
|
+
print("=" * 52)
|
|
32
|
+
print(f"Overall: {o['accuracy']:.1%} ({o['passed']}/{o['fields']} fields, {o['docs']} docs)")
|
|
33
|
+
print(f"All-null floor: {report_dict['all_null_floor']:.1%} <- a `{{}}`-emitting baseline scores this free")
|
|
34
|
+
|
|
35
|
+
print("\nBy source:")
|
|
36
|
+
for src, s in report_dict["by_source"].items():
|
|
37
|
+
print(f" {src:<12} {s['accuracy']:.1%} ({s['fields']} fields, {s['docs']} docs)")
|
|
38
|
+
|
|
39
|
+
print("\nFour-way outcomes:")
|
|
40
|
+
for bucket, v in report_dict["four_way"].items():
|
|
41
|
+
print(f" {bucket:<16} {v['count']:>6} ({v['rate']:.1%})")
|
|
42
|
+
|
|
43
|
+
print("\nBy category:")
|
|
44
|
+
for cat, s in sorted(report_dict["by_category"].items(), key=lambda kv: kv[1]["accuracy"]):
|
|
45
|
+
print(f" {cat:<24} {s['accuracy']:.1%} ({s['fields']} fields)")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(prog="fieldbench")
|
|
51
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
|
|
53
|
+
score = sub.add_parser("score", help="Score prediction files against the corpus")
|
|
54
|
+
score.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
|
|
55
|
+
score.add_argument("--results", required=True, type=Path, help="Directory of <stem>.json predictions")
|
|
56
|
+
score.add_argument("--category", default=None, help="Score a single category only")
|
|
57
|
+
score.add_argument(
|
|
58
|
+
"--mode", default="unspecified", help="Label for which representation the predictions came from (markdown/source/...)"
|
|
59
|
+
)
|
|
60
|
+
score.add_argument("--fuzzy-threshold", type=float, default=0.0, help="Off (0.0) for the official metric")
|
|
61
|
+
score.add_argument("--json", action="store_true", help="Emit the full report as JSON")
|
|
62
|
+
score.add_argument(
|
|
63
|
+
"--allow-missing",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Do not fail when prediction files are missing (they are scored as all-null)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
run = sub.add_parser("run", help="Run an extractor over the corpus, writing per-doc predictions")
|
|
69
|
+
run.add_argument("--corpus", required=True, type=Path, help="Path to the corpus root")
|
|
70
|
+
run.add_argument("--out", required=True, type=Path, help="Output dir for <stem>.json predictions")
|
|
71
|
+
run.add_argument("--runner", required=True, help="Runner as 'module:attr' (instance or zero-arg factory)")
|
|
72
|
+
run.add_argument("--mode", default="markdown", help="Which representation to feed (markdown/text/source)")
|
|
73
|
+
run.add_argument("--category", default=None, help="Run a single category only")
|
|
74
|
+
run.add_argument("--limit", type=int, default=None, help="Stop after N docs (smoke testing)")
|
|
75
|
+
run.add_argument("--no-resume", action="store_true", help="Re-run docs even if a prediction file exists")
|
|
76
|
+
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
if args.command == "run":
|
|
80
|
+
if not args.corpus.is_dir():
|
|
81
|
+
print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
try:
|
|
84
|
+
runner = _load_runner(args.runner)
|
|
85
|
+
except (ValueError, ImportError, AttributeError) as exc:
|
|
86
|
+
print(f"error: could not load runner {args.runner!r}: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
stats = run_corpus(
|
|
89
|
+
args.corpus,
|
|
90
|
+
runner,
|
|
91
|
+
args.out,
|
|
92
|
+
mode=args.mode,
|
|
93
|
+
category=args.category,
|
|
94
|
+
resume=not args.no_resume,
|
|
95
|
+
limit=args.limit,
|
|
96
|
+
on_progress=lambda stem, status: print(f" {stem}: {status}", file=sys.stderr),
|
|
97
|
+
)
|
|
98
|
+
print(
|
|
99
|
+
f"\nwrote {stats.written}, skipped {stats.skipped} (resume), "
|
|
100
|
+
f"errors {stats.errors}, no-representation {stats.no_representation}"
|
|
101
|
+
)
|
|
102
|
+
if stats.error_stems:
|
|
103
|
+
print(f"error docs: {', '.join(stats.error_stems[:10])}"
|
|
104
|
+
f"{'…' if len(stats.error_stems) > 10 else ''}", file=sys.stderr)
|
|
105
|
+
return 1 if (stats.errors or stats.no_representation) else 0
|
|
106
|
+
|
|
107
|
+
if args.command == "score":
|
|
108
|
+
if not args.corpus.is_dir():
|
|
109
|
+
print(f"error: corpus not found: {args.corpus}", file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
docs, missing = score_corpus(
|
|
112
|
+
args.corpus, args.results, category=args.category, fuzzy_threshold=args.fuzzy_threshold
|
|
113
|
+
)
|
|
114
|
+
if not docs:
|
|
115
|
+
print("error: no scorable documents found", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
report = build_report(docs, mode=args.mode).to_dict()
|
|
118
|
+
if args.json:
|
|
119
|
+
print(json.dumps(report, indent=2))
|
|
120
|
+
else:
|
|
121
|
+
_print_table(report)
|
|
122
|
+
if missing and not args.allow_missing:
|
|
123
|
+
print(
|
|
124
|
+
f"error: {missing} prediction file(s) missing — scored as all-null. "
|
|
125
|
+
f"Re-run with complete results, or pass --allow-missing to override.",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 1
|
|
129
|
+
return 0
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|