ocr-bench 0.0.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.
- ocr_bench-0.0.1/PKG-INFO +131 -0
- ocr_bench-0.0.1/README.md +103 -0
- ocr_bench-0.0.1/pyproject.toml +50 -0
- ocr_bench-0.0.1/src/ocr_bench/__init__.py +3 -0
- ocr_bench-0.0.1/src/ocr_bench/backends.py +238 -0
- ocr_bench-0.0.1/src/ocr_bench/cli.py +632 -0
- ocr_bench-0.0.1/src/ocr_bench/dataset.py +301 -0
- ocr_bench-0.0.1/src/ocr_bench/elo.py +320 -0
- ocr_bench-0.0.1/src/ocr_bench/judge.py +289 -0
- ocr_bench-0.0.1/src/ocr_bench/publish.py +271 -0
- ocr_bench-0.0.1/src/ocr_bench/py.typed +0 -0
- ocr_bench-0.0.1/src/ocr_bench/run.py +193 -0
- ocr_bench-0.0.1/src/ocr_bench/space.py +18 -0
- ocr_bench-0.0.1/src/ocr_bench/static/style.css +379 -0
- ocr_bench-0.0.1/src/ocr_bench/templates/base.html +48 -0
- ocr_bench-0.0.1/src/ocr_bench/templates/comparison_card.html +88 -0
- ocr_bench-0.0.1/src/ocr_bench/templates/comparisons.html +40 -0
- ocr_bench-0.0.1/src/ocr_bench/templates/leaderboard.html +182 -0
- ocr_bench-0.0.1/src/ocr_bench/templates/stats_panel.html +10 -0
- ocr_bench-0.0.1/src/ocr_bench/validate.py +362 -0
- ocr_bench-0.0.1/src/ocr_bench/viewer.py +202 -0
- ocr_bench-0.0.1/src/ocr_bench/web.py +518 -0
ocr_bench-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocr-bench
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards
|
|
5
|
+
Author: Daniel van Strien
|
|
6
|
+
Author-email: Daniel van Strien <davanstrien@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Dist: datasets>=4.0.0
|
|
9
|
+
Requires-Dist: huggingface-hub
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: openai
|
|
12
|
+
Requires-Dist: pillow
|
|
13
|
+
Requires-Dist: rich
|
|
14
|
+
Requires-Dist: scipy
|
|
15
|
+
Requires-Dist: stamina
|
|
16
|
+
Requires-Dist: structlog
|
|
17
|
+
Requires-Dist: tqdm
|
|
18
|
+
Requires-Dist: fastapi>=0.115 ; extra == 'viewer'
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.32 ; extra == 'viewer'
|
|
20
|
+
Requires-Dist: jinja2>=3.1 ; extra == 'viewer'
|
|
21
|
+
Requires-Dist: python-multipart>=0.0.9 ; extra == 'viewer'
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://github.com/davanstrien/ocr-bench
|
|
24
|
+
Project-URL: Repository, https://github.com/davanstrien/ocr-bench
|
|
25
|
+
Project-URL: Demo, https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer
|
|
26
|
+
Provides-Extra: viewer
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# ocr-bench
|
|
30
|
+
|
|
31
|
+
**There is no single best OCR model.** Rankings change depending on your documents — manuscript cards, printed books, and historical texts all produce different winners.
|
|
32
|
+
|
|
33
|
+
ocr-bench allows you to create **per-collection leaderboards** using a VLM-as-judge approach, so you can find what works best for _your_ documents rather than relying on generic benchmarks. You can validate the VLM's judgement with human votes, and share results via the Hugging Face Hub.
|
|
34
|
+
|
|
35
|
+
## Why?
|
|
36
|
+
|
|
37
|
+
Generic OCR benchmarks tell you which model wins _on average_. But if you're digitising 18th-century encyclopaedias, that average doesn't help — the best model for your documents might be the worst on someone else's.
|
|
38
|
+
|
|
39
|
+
ocr-bench lets you run the same set of OCR models on a sample of _your_ collection, then uses a vision-language model to judge which produces the best transcription for each document. The result is a leaderboard specific to your data.
|
|
40
|
+
|
|
41
|
+
| Model | BPL card catalog | Britannica 1771 |
|
|
42
|
+
| ------------------ | :--------------: | :-------------: |
|
|
43
|
+
| GLM-OCR (0.9B) | #2 (1535) | **#1** (1787) |
|
|
44
|
+
| LightOnOCR-2 (1B) | **#1** (1559) | #2 (1780) |
|
|
45
|
+
| FireRed-OCR (2.1B) | — | #3 (1551) |
|
|
46
|
+
| DeepSeek-OCR (4B) | #4 (1452) | #4 (1437) |
|
|
47
|
+
| dots.ocr (1.7B) | #3 (1453) | #5 (945) |
|
|
48
|
+
|
|
49
|
+
Rankings flip completely between collections. The model that's #1 on BPL cards drops to #2 on Britannica, and dots.ocr goes from #3 to dead last.
|
|
50
|
+
|
|
51
|
+
## Hub-native by design
|
|
52
|
+
|
|
53
|
+
The entire evaluation loop lives on the Hugging Face Hub:
|
|
54
|
+
|
|
55
|
+
1. **Your dataset** on the Hub (images + optional ground truth)
|
|
56
|
+
2. **OCR models** run via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview) → outputs written as PRs on a Hub dataset
|
|
57
|
+
3. **VLM judge** via [HF Inference Providers](https://huggingface.co/docs/inference-providers/index) — only needs an HF token
|
|
58
|
+
4. **Results** published to a Hub dataset (leaderboard + pairwise comparisons)
|
|
59
|
+
5. **Viewer** as a [HF Space](https://huggingface.co/spaces) for browsing and human validation
|
|
60
|
+
|
|
61
|
+
No local GPU required. Everything is shareable via Hub URLs.
|
|
62
|
+
|
|
63
|
+
## Quickstart
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install ocr-bench[viewer]
|
|
67
|
+
|
|
68
|
+
# 1. Run OCR models on your dataset
|
|
69
|
+
ocr-bench run <input-dataset> <output-repo> --max-samples 50
|
|
70
|
+
|
|
71
|
+
# 2. Judge outputs pairwise with a VLM
|
|
72
|
+
ocr-bench judge <output-repo>
|
|
73
|
+
|
|
74
|
+
# 3. Browse results + validate
|
|
75
|
+
ocr-bench view <output-repo>-results
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
**`ocr-bench run`** launches OCR models on your dataset via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview). Each model writes its output as a PR on the same Hub dataset, keeping everything together without merge conflicts.
|
|
81
|
+
|
|
82
|
+
**`ocr-bench judge`** runs pairwise comparisons using a VLM judge (default: [Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) via HF Inference Providers). For each document, the judge sees the original image and two OCR outputs (anonymised as A/B) and picks the better transcription. Results are fit to a [Bradley-Terry model](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model) to produce ELO ratings with bootstrap 95% confidence intervals. Adaptive stopping halts early when rankings are statistically resolved.
|
|
83
|
+
|
|
84
|
+
**`ocr-bench view`** serves a local web viewer with a leaderboard, comparison browser, and human validation. Vote on comparisons to cross-check the automated judge with human judgement.
|
|
85
|
+
|
|
86
|
+
## Available models
|
|
87
|
+
|
|
88
|
+
ocr-bench ships with 5 OCR models ready to run:
|
|
89
|
+
|
|
90
|
+
| Model | Size | Best for | Notes |
|
|
91
|
+
|-------|------|----------|-------|
|
|
92
|
+
| `glm-ocr` | 0.9B | Historical printed text | Top performer on Britannica |
|
|
93
|
+
| `lighton-ocr-2` | 1B | Card catalogs, manuscripts | Top performer on BPL |
|
|
94
|
+
| `firered-ocr` | 2.1B | Clean printed text | Mid-pack on degraded docs |
|
|
95
|
+
| `deepseek-ocr` | 4B | Diverse documents | Most consistent across types |
|
|
96
|
+
| `dots-ocr` | 1.7B | General | Struggles on historical text |
|
|
97
|
+
|
|
98
|
+
All model scripts are available at [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) on the Hub.
|
|
99
|
+
|
|
100
|
+
By default all 5 run. To pick specific models:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
ocr-bench run <dataset> <output> --models glm-ocr lighton-ocr-2
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Example results
|
|
107
|
+
|
|
108
|
+
Browse these on the Hub:
|
|
109
|
+
|
|
110
|
+
- [davanstrien/ocr-bench-britannica-results-qwen35](https://huggingface.co/datasets/davanstrien/ocr-bench-britannica-results-qwen35) — Encyclopaedia Britannica 1771, 5 models, 50 samples
|
|
111
|
+
- [davanstrien/bpl-ocr-bench-results](https://huggingface.co/datasets/davanstrien/bpl-ocr-bench-results) — Boston Public Library card catalog, 4 models, 150 samples
|
|
112
|
+
- [Live viewer](https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer) — Britannica leaderboard with ELO chart and comparison browser
|
|
113
|
+
|
|
114
|
+
## Install
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install ocr-bench # Core (run + judge)
|
|
118
|
+
pip install ocr-bench[viewer] # With web UI
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv pip install ocr-bench[viewer]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Requires Python >= 3.11 and an [HF token](https://huggingface.co/settings/tokens).
|
|
128
|
+
|
|
129
|
+
## Status
|
|
130
|
+
|
|
131
|
+
Working proof of concept. The core pipeline (run → judge → view) is functional. Not polished production software — expect rough edges.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# ocr-bench
|
|
2
|
+
|
|
3
|
+
**There is no single best OCR model.** Rankings change depending on your documents — manuscript cards, printed books, and historical texts all produce different winners.
|
|
4
|
+
|
|
5
|
+
ocr-bench allows you to create **per-collection leaderboards** using a VLM-as-judge approach, so you can find what works best for _your_ documents rather than relying on generic benchmarks. You can validate the VLM's judgement with human votes, and share results via the Hugging Face Hub.
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
Generic OCR benchmarks tell you which model wins _on average_. But if you're digitising 18th-century encyclopaedias, that average doesn't help — the best model for your documents might be the worst on someone else's.
|
|
10
|
+
|
|
11
|
+
ocr-bench lets you run the same set of OCR models on a sample of _your_ collection, then uses a vision-language model to judge which produces the best transcription for each document. The result is a leaderboard specific to your data.
|
|
12
|
+
|
|
13
|
+
| Model | BPL card catalog | Britannica 1771 |
|
|
14
|
+
| ------------------ | :--------------: | :-------------: |
|
|
15
|
+
| GLM-OCR (0.9B) | #2 (1535) | **#1** (1787) |
|
|
16
|
+
| LightOnOCR-2 (1B) | **#1** (1559) | #2 (1780) |
|
|
17
|
+
| FireRed-OCR (2.1B) | — | #3 (1551) |
|
|
18
|
+
| DeepSeek-OCR (4B) | #4 (1452) | #4 (1437) |
|
|
19
|
+
| dots.ocr (1.7B) | #3 (1453) | #5 (945) |
|
|
20
|
+
|
|
21
|
+
Rankings flip completely between collections. The model that's #1 on BPL cards drops to #2 on Britannica, and dots.ocr goes from #3 to dead last.
|
|
22
|
+
|
|
23
|
+
## Hub-native by design
|
|
24
|
+
|
|
25
|
+
The entire evaluation loop lives on the Hugging Face Hub:
|
|
26
|
+
|
|
27
|
+
1. **Your dataset** on the Hub (images + optional ground truth)
|
|
28
|
+
2. **OCR models** run via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview) → outputs written as PRs on a Hub dataset
|
|
29
|
+
3. **VLM judge** via [HF Inference Providers](https://huggingface.co/docs/inference-providers/index) — only needs an HF token
|
|
30
|
+
4. **Results** published to a Hub dataset (leaderboard + pairwise comparisons)
|
|
31
|
+
5. **Viewer** as a [HF Space](https://huggingface.co/spaces) for browsing and human validation
|
|
32
|
+
|
|
33
|
+
No local GPU required. Everything is shareable via Hub URLs.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install ocr-bench[viewer]
|
|
39
|
+
|
|
40
|
+
# 1. Run OCR models on your dataset
|
|
41
|
+
ocr-bench run <input-dataset> <output-repo> --max-samples 50
|
|
42
|
+
|
|
43
|
+
# 2. Judge outputs pairwise with a VLM
|
|
44
|
+
ocr-bench judge <output-repo>
|
|
45
|
+
|
|
46
|
+
# 3. Browse results + validate
|
|
47
|
+
ocr-bench view <output-repo>-results
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
**`ocr-bench run`** launches OCR models on your dataset via [HF Jobs](https://huggingface.co/docs/hub/jobs-overview). Each model writes its output as a PR on the same Hub dataset, keeping everything together without merge conflicts.
|
|
53
|
+
|
|
54
|
+
**`ocr-bench judge`** runs pairwise comparisons using a VLM judge (default: [Qwen3.5-35B-A3B](https://huggingface.co/Qwen/Qwen3.5-35B-A3B) via HF Inference Providers). For each document, the judge sees the original image and two OCR outputs (anonymised as A/B) and picks the better transcription. Results are fit to a [Bradley-Terry model](https://en.wikipedia.org/wiki/Bradley%E2%80%93Terry_model) to produce ELO ratings with bootstrap 95% confidence intervals. Adaptive stopping halts early when rankings are statistically resolved.
|
|
55
|
+
|
|
56
|
+
**`ocr-bench view`** serves a local web viewer with a leaderboard, comparison browser, and human validation. Vote on comparisons to cross-check the automated judge with human judgement.
|
|
57
|
+
|
|
58
|
+
## Available models
|
|
59
|
+
|
|
60
|
+
ocr-bench ships with 5 OCR models ready to run:
|
|
61
|
+
|
|
62
|
+
| Model | Size | Best for | Notes |
|
|
63
|
+
|-------|------|----------|-------|
|
|
64
|
+
| `glm-ocr` | 0.9B | Historical printed text | Top performer on Britannica |
|
|
65
|
+
| `lighton-ocr-2` | 1B | Card catalogs, manuscripts | Top performer on BPL |
|
|
66
|
+
| `firered-ocr` | 2.1B | Clean printed text | Mid-pack on degraded docs |
|
|
67
|
+
| `deepseek-ocr` | 4B | Diverse documents | Most consistent across types |
|
|
68
|
+
| `dots-ocr` | 1.7B | General | Struggles on historical text |
|
|
69
|
+
|
|
70
|
+
All model scripts are available at [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) on the Hub.
|
|
71
|
+
|
|
72
|
+
By default all 5 run. To pick specific models:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ocr-bench run <dataset> <output> --models glm-ocr lighton-ocr-2
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Example results
|
|
79
|
+
|
|
80
|
+
Browse these on the Hub:
|
|
81
|
+
|
|
82
|
+
- [davanstrien/ocr-bench-britannica-results-qwen35](https://huggingface.co/datasets/davanstrien/ocr-bench-britannica-results-qwen35) — Encyclopaedia Britannica 1771, 5 models, 50 samples
|
|
83
|
+
- [davanstrien/bpl-ocr-bench-results](https://huggingface.co/datasets/davanstrien/bpl-ocr-bench-results) — Boston Public Library card catalog, 4 models, 150 samples
|
|
84
|
+
- [Live viewer](https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer) — Britannica leaderboard with ELO chart and comparison browser
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install ocr-bench # Core (run + judge)
|
|
90
|
+
pip install ocr-bench[viewer] # With web UI
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv pip install ocr-bench[viewer]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Requires Python >= 3.11 and an [HF token](https://huggingface.co/settings/tokens).
|
|
100
|
+
|
|
101
|
+
## Status
|
|
102
|
+
|
|
103
|
+
Working proof of concept. The core pipeline (run → judge → view) is functional. Not polished production software — expect rough edges.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ocr-bench"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "OCR model evaluation toolkit — VLM-as-judge with per-dataset leaderboards"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Daniel van Strien", email = "davanstrien@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"datasets>=4.0.0",
|
|
13
|
+
"huggingface-hub",
|
|
14
|
+
"numpy",
|
|
15
|
+
"openai",
|
|
16
|
+
"pillow",
|
|
17
|
+
"rich",
|
|
18
|
+
"scipy",
|
|
19
|
+
"stamina",
|
|
20
|
+
"structlog",
|
|
21
|
+
"tqdm",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/davanstrien/ocr-bench"
|
|
26
|
+
Repository = "https://github.com/davanstrien/ocr-bench"
|
|
27
|
+
Demo = "https://huggingface.co/spaces/davanstrien/ocr-bench-britannica-results-qwen35-viewer"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
ocr-bench = "ocr_bench.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
viewer = ["fastapi>=0.115", "uvicorn[standard]>=0.32", "jinja2>=3.1", "python-multipart>=0.0.9"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.9.27,<0.10.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "UP"]
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=9.0.2",
|
|
48
|
+
"ruff>=0.11",
|
|
49
|
+
"ty>=0.0.17",
|
|
50
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Judge backends — API-based (HF Inference Providers, OpenAI-compatible)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import stamina
|
|
11
|
+
import structlog
|
|
12
|
+
from huggingface_hub import InferenceClient
|
|
13
|
+
from openai import OpenAI
|
|
14
|
+
|
|
15
|
+
from ocr_bench.judge import JUDGE_SCHEMA, Comparison, parse_judge_output
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
# Retry on these exception types with exponential backoff + jitter.
|
|
20
|
+
_RETRYABLE = (Exception,)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JudgeBackend(abc.ABC):
|
|
24
|
+
"""Base class for judge backends."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
concurrency: int = 1
|
|
28
|
+
|
|
29
|
+
@abc.abstractmethod
|
|
30
|
+
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
|
31
|
+
"""Run the judge on a single comparison."""
|
|
32
|
+
|
|
33
|
+
def judge(self, comparisons: list[Comparison]) -> list[dict[str, str]]:
|
|
34
|
+
"""Run the judge on a list of comparisons (concurrently if supported).
|
|
35
|
+
|
|
36
|
+
Returns a list of parsed results (one per comparison).
|
|
37
|
+
Each result is a dict with ``winner`` and ``reason`` keys,
|
|
38
|
+
or an empty dict on failure.
|
|
39
|
+
"""
|
|
40
|
+
if self.concurrency <= 1 or len(comparisons) <= 1:
|
|
41
|
+
return [self._call_single(comp) for comp in comparisons]
|
|
42
|
+
|
|
43
|
+
# Concurrent execution preserving order
|
|
44
|
+
results: list[dict[str, str]] = [{}] * len(comparisons)
|
|
45
|
+
with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
|
|
46
|
+
future_to_idx = {
|
|
47
|
+
pool.submit(self._call_single, comp): i
|
|
48
|
+
for i, comp in enumerate(comparisons)
|
|
49
|
+
}
|
|
50
|
+
for future in as_completed(future_to_idx):
|
|
51
|
+
idx = future_to_idx[future]
|
|
52
|
+
try:
|
|
53
|
+
results[idx] = future.result()
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.warning("judge_call_failed", idx=idx, error=str(exc))
|
|
56
|
+
results[idx] = {}
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InferenceProviderJudge(JudgeBackend):
|
|
64
|
+
"""HF Inference Providers backend (Novita, Together, etc.)."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self, model: str, provider: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
68
|
+
):
|
|
69
|
+
self.name = f"{provider + ':' if provider else ''}{model}"
|
|
70
|
+
self.model = model
|
|
71
|
+
self.max_tokens = max_tokens
|
|
72
|
+
self.client = InferenceClient(model=model, provider=provider) # type: ignore[invalid-argument-type]
|
|
73
|
+
|
|
74
|
+
@stamina.retry(on=_RETRYABLE, attempts=6)
|
|
75
|
+
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
|
76
|
+
response = self.client.chat_completion( # type: ignore[no-matching-overload]
|
|
77
|
+
messages=comp.messages,
|
|
78
|
+
max_tokens=self.max_tokens,
|
|
79
|
+
temperature=0.0,
|
|
80
|
+
response_format={"type": "json_object"},
|
|
81
|
+
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
|
82
|
+
)
|
|
83
|
+
raw = response.choices[0].message.content.strip()
|
|
84
|
+
result = parse_judge_output(raw)
|
|
85
|
+
if not result:
|
|
86
|
+
logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OpenAICompatibleJudge(JudgeBackend):
|
|
91
|
+
"""OpenAI-compatible endpoint (local vLLM server, Ollama, HF IE, etc.)."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
base_url: str,
|
|
96
|
+
model: str = "default",
|
|
97
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
98
|
+
api_key: str = "not-needed",
|
|
99
|
+
extra_body: dict | None = None,
|
|
100
|
+
temperature: float = 0.0,
|
|
101
|
+
concurrency: int = 1,
|
|
102
|
+
):
|
|
103
|
+
self.name = model if model != "default" else f"openai@{base_url}"
|
|
104
|
+
self.model = model
|
|
105
|
+
self.max_tokens = max_tokens
|
|
106
|
+
self.temperature = temperature
|
|
107
|
+
self.extra_body = extra_body if extra_body is not None else {"guided_json": JUDGE_SCHEMA}
|
|
108
|
+
self.concurrency = concurrency
|
|
109
|
+
self.client = OpenAI(base_url=base_url, api_key=api_key)
|
|
110
|
+
|
|
111
|
+
@stamina.retry(on=_RETRYABLE, attempts=3)
|
|
112
|
+
def _call_single(self, comp: Comparison) -> dict[str, str]:
|
|
113
|
+
response = self.client.chat.completions.create(
|
|
114
|
+
model=self.model,
|
|
115
|
+
messages=comp.messages, # type: ignore[invalid-argument-type]
|
|
116
|
+
max_tokens=self.max_tokens,
|
|
117
|
+
temperature=self.temperature,
|
|
118
|
+
extra_body=self.extra_body,
|
|
119
|
+
)
|
|
120
|
+
raw = response.choices[0].message.content.strip()
|
|
121
|
+
result = parse_judge_output(raw)
|
|
122
|
+
if not result:
|
|
123
|
+
logger.warning("empty_parse", backend=self.name, sample=comp.sample_idx)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Spec parsing
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
DEFAULT_JUDGE = "novita:Qwen/Qwen3.5-35B-A3B"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_judge_spec(
|
|
135
|
+
spec: str, max_tokens: int = DEFAULT_MAX_TOKENS, concurrency: int = 1,
|
|
136
|
+
) -> JudgeBackend:
|
|
137
|
+
"""Parse a judge specification string into a backend.
|
|
138
|
+
|
|
139
|
+
Formats:
|
|
140
|
+
- ``"https://xxx.endpoints.huggingface.cloud"`` → :class:`OpenAICompatibleJudge`
|
|
141
|
+
(HF Inference Endpoints, OpenAI-compatible with HF token auth)
|
|
142
|
+
- ``"http://..."`` or ``"https://..."`` (other) → :class:`OpenAICompatibleJudge`
|
|
143
|
+
- ``"provider:org/model"`` (colon before first ``/``) → :class:`InferenceProviderJudge`
|
|
144
|
+
- anything else → :class:`InferenceProviderJudge` (no provider)
|
|
145
|
+
"""
|
|
146
|
+
if spec.startswith("http://") or spec.startswith("https://"):
|
|
147
|
+
# Check for url:model format (e.g. https://...cloud/v1/:org/model)
|
|
148
|
+
url_part = spec
|
|
149
|
+
model_name = "default"
|
|
150
|
+
# Split on /v1/: to separate URL from model name
|
|
151
|
+
if "/v1/:" in spec:
|
|
152
|
+
url_part, model_name = spec.split("/v1/:", 1)
|
|
153
|
+
url_part += "/v1"
|
|
154
|
+
|
|
155
|
+
# HF Inference Endpoints — OpenAI-compatible, auth via HF token
|
|
156
|
+
if ".endpoints.huggingface." in url_part:
|
|
157
|
+
from huggingface_hub import get_token
|
|
158
|
+
|
|
159
|
+
base_url = url_part.rstrip("/")
|
|
160
|
+
if not base_url.endswith("/v1"):
|
|
161
|
+
base_url += "/v1"
|
|
162
|
+
token = get_token() or "not-needed"
|
|
163
|
+
return OpenAICompatibleJudge(
|
|
164
|
+
base_url=base_url,
|
|
165
|
+
model=model_name,
|
|
166
|
+
api_key=token,
|
|
167
|
+
max_tokens=max_tokens,
|
|
168
|
+
temperature=0.7,
|
|
169
|
+
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
|
|
170
|
+
concurrency=concurrency,
|
|
171
|
+
)
|
|
172
|
+
return OpenAICompatibleJudge(
|
|
173
|
+
base_url=url_part, model=model_name, max_tokens=max_tokens,
|
|
174
|
+
concurrency=concurrency,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if ":" in spec:
|
|
178
|
+
# provider:model format — colon must come before first slash
|
|
179
|
+
colon_idx = spec.index(":")
|
|
180
|
+
slash_idx = spec.find("/")
|
|
181
|
+
if slash_idx == -1 or colon_idx < slash_idx:
|
|
182
|
+
provider, model = spec.split(":", 1)
|
|
183
|
+
return InferenceProviderJudge(model=model, provider=provider, max_tokens=max_tokens)
|
|
184
|
+
|
|
185
|
+
return InferenceProviderJudge(model=spec, max_tokens=max_tokens)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Jury aggregation
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def aggregate_jury_votes(
|
|
194
|
+
all_results: list[list[dict[str, str]]],
|
|
195
|
+
judge_names: list[str],
|
|
196
|
+
) -> list[dict[str, Any]]:
|
|
197
|
+
"""Aggregate votes from multiple judges using majority voting.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
all_results: List of result lists, one per judge. Each inner list
|
|
201
|
+
has one dict per comparison.
|
|
202
|
+
judge_names: Names of the judges (same order as *all_results*).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Aggregated results with ``winner``, ``reason``, and ``agreement`` fields.
|
|
206
|
+
"""
|
|
207
|
+
if not all_results:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
n_comparisons = len(all_results[0])
|
|
211
|
+
n_judges = len(all_results)
|
|
212
|
+
aggregated: list[dict[str, Any]] = []
|
|
213
|
+
|
|
214
|
+
for i in range(n_comparisons):
|
|
215
|
+
votes: list[str] = []
|
|
216
|
+
reasons: list[str] = []
|
|
217
|
+
for j in range(n_judges):
|
|
218
|
+
result = all_results[j][i] if i < len(all_results[j]) else {}
|
|
219
|
+
winner = result.get("winner", "")
|
|
220
|
+
if winner:
|
|
221
|
+
votes.append(winner)
|
|
222
|
+
reasons.append(f"{judge_names[j]}: {result.get('reason', '')}")
|
|
223
|
+
|
|
224
|
+
if not votes:
|
|
225
|
+
aggregated.append({"winner": "tie", "reason": "no valid votes", "agreement": "0/0"})
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
counter = Counter(votes)
|
|
229
|
+
majority_winner, majority_count = counter.most_common(1)[0]
|
|
230
|
+
agreement = f"{majority_count}/{len(votes)}"
|
|
231
|
+
|
|
232
|
+
aggregated.append({
|
|
233
|
+
"winner": majority_winner,
|
|
234
|
+
"reason": "; ".join(reasons),
|
|
235
|
+
"agreement": agreement,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
return aggregated
|