verifydoc 0.1.0__py3-none-any.whl
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.
- verifydoc/__init__.py +16 -0
- verifydoc/adapters/__init__.py +33 -0
- verifydoc/adapters/_ocr_common.py +80 -0
- verifydoc/adapters/api_vlm.py +97 -0
- verifydoc/adapters/base.py +31 -0
- verifydoc/adapters/docling.py +45 -0
- verifydoc/adapters/dots_ocr.py +66 -0
- verifydoc/adapters/mock.py +95 -0
- verifydoc/adapters/paddleocr_vl.py +50 -0
- verifydoc/adapters/text_search.py +53 -0
- verifydoc/calibration/__init__.py +20 -0
- verifydoc/calibration/base.py +56 -0
- verifydoc/calibration/conformal.py +75 -0
- verifydoc/calibration/histogram.py +34 -0
- verifydoc/calibration/isotonic.py +23 -0
- verifydoc/calibration/platt.py +30 -0
- verifydoc/calibration/splits.py +44 -0
- verifydoc/calibration/temperature.py +30 -0
- verifydoc/cli.py +48 -0
- verifydoc/confidence/__init__.py +18 -0
- verifydoc/confidence/combined.py +37 -0
- verifydoc/confidence/consensus.py +71 -0
- verifydoc/confidence/grounding_based.py +29 -0
- verifydoc/confidence/token_prob.py +43 -0
- verifydoc/confidence/verbalized.py +30 -0
- verifydoc/eval/__init__.py +1 -0
- verifydoc/eval/calibration.py +154 -0
- verifydoc/eval/extraction.py +534 -0
- verifydoc/eval/grounding.py +124 -0
- verifydoc/eval/harness.py +354 -0
- verifydoc/eval/selective.py +170 -0
- verifydoc/eval/stats.py +97 -0
- verifydoc/grounding/__init__.py +5 -0
- verifydoc/grounding/attach.py +87 -0
- verifydoc/ingest/__init__.py +5 -0
- verifydoc/ingest/loader.py +100 -0
- verifydoc/pipeline.py +134 -0
- verifydoc/policy/__init__.py +5 -0
- verifydoc/policy/abstention.py +73 -0
- verifydoc/types.py +230 -0
- verifydoc-0.1.0.dist-info/METADATA +374 -0
- verifydoc-0.1.0.dist-info/RECORD +45 -0
- verifydoc-0.1.0.dist-info/WHEEL +4 -0
- verifydoc-0.1.0.dist-info/entry_points.txt +2 -0
- verifydoc-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Split conformal abstention / conformal risk control (PROJECT.md §5.G).
|
|
2
|
+
|
|
3
|
+
Chooses the acceptance threshold on the calibration split so that the
|
|
4
|
+
*expected* selective risk of accepted fields is bounded by the target alpha
|
|
5
|
+
under exchangeability (conformal risk control with the bounded miscoverage
|
|
6
|
+
loss; Angelopoulos et al.; conformal factuality, Mohri & Hashimoto 2024).
|
|
7
|
+
|
|
8
|
+
Threshold rule: the smallest confidence t such that on the calibration split
|
|
9
|
+
|
|
10
|
+
(#errors accepted at t + 1) / (#accepted at t + 1) <= alpha
|
|
11
|
+
|
|
12
|
+
The +1 terms are the finite-sample correction (the future test point counted
|
|
13
|
+
as a worst-case error). Per arXiv:2606.29054, meaningful certification can
|
|
14
|
+
force heavy abstention — so this class also reports the abstention it forces
|
|
15
|
+
(``abstention_rate_`` on the calibration split), and the benchmark reports
|
|
16
|
+
both numbers side by side.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConformalAbstention:
|
|
27
|
+
"""Distribution-free accept/review policy at a target selective risk."""
|
|
28
|
+
|
|
29
|
+
threshold_: float
|
|
30
|
+
abstention_rate_: float
|
|
31
|
+
alpha: float
|
|
32
|
+
|
|
33
|
+
def __init__(self, alpha: float = 0.05) -> None:
|
|
34
|
+
if not 0.0 < alpha < 1.0:
|
|
35
|
+
raise ValueError("alpha must be in (0, 1)")
|
|
36
|
+
self.alpha = alpha
|
|
37
|
+
self._fitted = False
|
|
38
|
+
|
|
39
|
+
def fit(self, conf: Sequence[float], correct: Sequence[int]) -> ConformalAbstention:
|
|
40
|
+
"""Fit the threshold on the CALIBRATION split (never test)."""
|
|
41
|
+
c = np.asarray(conf, dtype=float)
|
|
42
|
+
y = np.asarray(correct, dtype=float)
|
|
43
|
+
if c.shape != y.shape or c.ndim != 1 or c.size == 0:
|
|
44
|
+
raise ValueError("conf and correct must be 1-D, equal-length, non-empty")
|
|
45
|
+
|
|
46
|
+
order = np.argsort(-c, kind="stable")
|
|
47
|
+
c_sorted, y_sorted = c[order], y[order]
|
|
48
|
+
n_accepted = np.arange(1, c.size + 1)
|
|
49
|
+
n_errors = np.cumsum(1.0 - y_sorted)
|
|
50
|
+
bound = (n_errors + 1.0) / (n_accepted + 1.0)
|
|
51
|
+
boundary: np.ndarray = np.append(c_sorted[:-1] > c_sorted[1:], True)
|
|
52
|
+
ok = (bound <= self.alpha) & boundary
|
|
53
|
+
if ok.any():
|
|
54
|
+
k = int(np.flatnonzero(ok)[-1])
|
|
55
|
+
self.threshold_ = float(c_sorted[k])
|
|
56
|
+
self.abstention_rate_ = float(1.0 - (k + 1) / c.size)
|
|
57
|
+
else:
|
|
58
|
+
self.threshold_ = float("inf") # certification impossible: review everything
|
|
59
|
+
self.abstention_rate_ = 1.0
|
|
60
|
+
self._fitted = True
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def accept(self, conf: Sequence[float]) -> np.ndarray:
|
|
64
|
+
"""Boolean accept mask for new confidences (True = auto-accept)."""
|
|
65
|
+
if not self._fitted:
|
|
66
|
+
raise RuntimeError("ConformalAbstention must be fit on the calibration split first")
|
|
67
|
+
return np.asarray(conf, dtype=float) >= self.threshold_
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def guarantee(self) -> str:
|
|
71
|
+
return (
|
|
72
|
+
f"E[selective risk] <= {self.alpha:.3f} under exchangeability "
|
|
73
|
+
f"(split conformal, finite-sample corrected); forced abstention on "
|
|
74
|
+
f"calibration split: {self.abstention_rate_:.1%}"
|
|
75
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Histogram binning (Zadrozny & Elkan 2001): equal-width bins over [0, 1].
|
|
2
|
+
|
|
3
|
+
Each bin's calibrated confidence is its empirical accuracy on the calibration
|
|
4
|
+
split; empty bins fall back to the global accuracy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from verifydoc.calibration.base import Calibrator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HistogramBinning(Calibrator):
|
|
15
|
+
def __init__(self, n_bins: int = 15) -> None:
|
|
16
|
+
self.n_bins = n_bins
|
|
17
|
+
|
|
18
|
+
bin_accuracy_: np.ndarray
|
|
19
|
+
|
|
20
|
+
def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None:
|
|
21
|
+
edges = np.linspace(0.0, 1.0, self.n_bins + 1)
|
|
22
|
+
idx = np.clip(np.digitize(conf, edges[1:-1], right=True), 0, self.n_bins - 1)
|
|
23
|
+
global_acc = float(correct.mean())
|
|
24
|
+
acc = np.full(self.n_bins, global_acc)
|
|
25
|
+
for m in range(self.n_bins):
|
|
26
|
+
mask = idx == m
|
|
27
|
+
if mask.any():
|
|
28
|
+
acc[m] = float(correct[mask].mean())
|
|
29
|
+
self.bin_accuracy_ = acc
|
|
30
|
+
|
|
31
|
+
def _transform(self, conf: np.ndarray) -> np.ndarray:
|
|
32
|
+
edges = np.linspace(0.0, 1.0, self.n_bins + 1)
|
|
33
|
+
idx = np.clip(np.digitize(conf, edges[1:-1], right=True), 0, self.n_bins - 1)
|
|
34
|
+
return self.bin_accuracy_[idx]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Isotonic regression calibration (Zadrozny & Elkan 2002).
|
|
2
|
+
|
|
3
|
+
Non-parametric monotone mapping from confidence to empirical accuracy;
|
|
4
|
+
the strongest of the classic calibrators given enough calibration data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from sklearn.isotonic import IsotonicRegression
|
|
11
|
+
|
|
12
|
+
from verifydoc.calibration.base import Calibrator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IsotonicCalibrator(Calibrator):
|
|
16
|
+
_model: IsotonicRegression
|
|
17
|
+
|
|
18
|
+
def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None:
|
|
19
|
+
self._model = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip")
|
|
20
|
+
self._model.fit(conf, correct)
|
|
21
|
+
|
|
22
|
+
def _transform(self, conf: np.ndarray) -> np.ndarray:
|
|
23
|
+
return np.asarray(self._model.predict(conf), dtype=float)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Platt scaling: logistic regression a*logit(p) + b (Platt 1999).
|
|
2
|
+
|
|
3
|
+
Unlike temperature scaling it can also shift systematically biased scores,
|
|
4
|
+
not just soften them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from sklearn.linear_model import LogisticRegression
|
|
11
|
+
|
|
12
|
+
from verifydoc.calibration.base import Calibrator, logit, sigmoid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlattScaling(Calibrator):
|
|
16
|
+
a_: float
|
|
17
|
+
b_: float
|
|
18
|
+
|
|
19
|
+
def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None:
|
|
20
|
+
if len(np.unique(correct)) < 2:
|
|
21
|
+
# degenerate split (all right or all wrong): identity mapping
|
|
22
|
+
self.a_, self.b_ = 1.0, 0.0
|
|
23
|
+
return
|
|
24
|
+
model = LogisticRegression(C=1e6, solver="lbfgs")
|
|
25
|
+
model.fit(logit(conf).reshape(-1, 1), correct)
|
|
26
|
+
self.a_ = float(model.coef_[0][0])
|
|
27
|
+
self.b_ = float(model.intercept_[0])
|
|
28
|
+
|
|
29
|
+
def _transform(self, conf: np.ndarray) -> np.ndarray:
|
|
30
|
+
return sigmoid(self.a_ * logit(conf) + self.b_)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Split management: the never-tune-on-test rule, enforced in code.
|
|
2
|
+
|
|
3
|
+
Golden rule #4: calibration and abstention thresholds are fit only on a
|
|
4
|
+
dedicated calibration split. These helpers create and verify disjoint splits
|
|
5
|
+
by item id; every harness that fits a calibrator must call
|
|
6
|
+
``assert_disjoint`` before scoring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import TypeVar
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def split_calibration(
|
|
20
|
+
ids: Sequence[T], calibration_fraction: float = 0.5, seed: int = 0
|
|
21
|
+
) -> tuple[list[T], list[T]]:
|
|
22
|
+
"""Deterministically split ids into (calibration, test); disjoint and complete."""
|
|
23
|
+
if not 0.0 < calibration_fraction < 1.0:
|
|
24
|
+
raise ValueError("calibration_fraction must be in (0, 1)")
|
|
25
|
+
unique = list(dict.fromkeys(ids))
|
|
26
|
+
if len(unique) < 2:
|
|
27
|
+
raise ValueError("need at least two distinct ids to split")
|
|
28
|
+
rng = np.random.default_rng(seed)
|
|
29
|
+
order = rng.permutation(len(unique))
|
|
30
|
+
n_cal = max(1, min(len(unique) - 1, round(calibration_fraction * len(unique))))
|
|
31
|
+
cal = [unique[i] for i in sorted(order[:n_cal])]
|
|
32
|
+
test = [unique[i] for i in sorted(order[n_cal:])]
|
|
33
|
+
return cal, test
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def assert_disjoint(calibration_ids: Sequence[T], test_ids: Sequence[T]) -> None:
|
|
37
|
+
"""Raise if any id appears in both splits (tuning-on-test guard)."""
|
|
38
|
+
overlap = set(calibration_ids) & set(test_ids)
|
|
39
|
+
if overlap:
|
|
40
|
+
sample = sorted(str(x) for x in overlap)[:5]
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"calibration and test splits overlap on {len(overlap)} ids "
|
|
43
|
+
f"(e.g. {sample}); calibration must never see test data"
|
|
44
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Temperature scaling (Guo et al. 2017) on the confidence's log-odds.
|
|
2
|
+
|
|
3
|
+
Calibrated p' = sigmoid(logit(p) / T); the single scalar T is chosen to
|
|
4
|
+
minimize NLL on the calibration split. T > 1 softens overconfident scores.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy.optimize import minimize_scalar
|
|
11
|
+
|
|
12
|
+
from verifydoc.calibration.base import Calibrator, logit, sigmoid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TemperatureScaling(Calibrator):
|
|
16
|
+
temperature_: float
|
|
17
|
+
|
|
18
|
+
def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None:
|
|
19
|
+
z = logit(conf)
|
|
20
|
+
|
|
21
|
+
def nll_at(log_t: float) -> float:
|
|
22
|
+
p = sigmoid(z / np.exp(log_t))
|
|
23
|
+
p = np.clip(p, 1e-12, 1.0 - 1e-12)
|
|
24
|
+
return float(-np.mean(correct * np.log(p) + (1.0 - correct) * np.log(1.0 - p)))
|
|
25
|
+
|
|
26
|
+
result = minimize_scalar(nll_at, bounds=(-4.0, 4.0), method="bounded")
|
|
27
|
+
self.temperature_ = float(np.exp(result.x))
|
|
28
|
+
|
|
29
|
+
def _transform(self, conf: np.ndarray) -> np.ndarray:
|
|
30
|
+
return sigmoid(logit(conf) / self.temperature_)
|
verifydoc/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CLI: ``verifydoc extract doc.pdf --schema schema.json --target... ``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
import verifydoc
|
|
11
|
+
from verifydoc.adapters import get_adapter
|
|
12
|
+
from verifydoc.pipeline import DEFAULT_THRESHOLD, verify
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="verifydoc", help="Trust layer for document -> JSON extraction.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def extract(
|
|
19
|
+
source: Path = typer.Argument(..., help="Document to extract from (pdf/image/txt)."),
|
|
20
|
+
schema: Path = typer.Option(..., "--schema", "-s", help="JSON Schema with x-scoring rules."),
|
|
21
|
+
adapter: str = typer.Option("text-search", "--adapter", "-a", help="Extractor adapter."),
|
|
22
|
+
k: int = typer.Option(1, "--k", help="Self-consistency samples (k > 1 enables consensus)."),
|
|
23
|
+
threshold: float = typer.Option(
|
|
24
|
+
DEFAULT_THRESHOLD, "--threshold", "-t", help="Accept-decision confidence cutoff."
|
|
25
|
+
),
|
|
26
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Write JSON here (else stdout)."),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Extract fields with confidence + grounding + accept/review decisions."""
|
|
29
|
+
result = verify(source, schema, adapter=get_adapter(adapter), k=k, threshold=threshold)
|
|
30
|
+
payload = json.dumps(result.to_dict(), indent=2, ensure_ascii=False)
|
|
31
|
+
if out is not None:
|
|
32
|
+
out.write_text(payload + "\n", encoding="utf-8")
|
|
33
|
+
typer.echo(
|
|
34
|
+
f"{result.doc_id}: {result.n_accepted} accepted, "
|
|
35
|
+
f"{result.n_review} to review -> {out}"
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
typer.echo(payload)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def version() -> None:
|
|
43
|
+
"""Print the installed VerifyDoc version."""
|
|
44
|
+
typer.echo(verifydoc.__version__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__": # pragma: no cover
|
|
48
|
+
app()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Confidence signals (PROJECT.md §5.G): raw per-field scores before calibration."""
|
|
2
|
+
|
|
3
|
+
from verifydoc.confidence.combined import combined_confidence
|
|
4
|
+
from verifydoc.confidence.consensus import consensus
|
|
5
|
+
from verifydoc.confidence.grounding_based import apply_grounding_confidence, grounding_confidence
|
|
6
|
+
from verifydoc.confidence.token_prob import apply_token_prob, token_prob_confidence
|
|
7
|
+
from verifydoc.confidence.verbalized import apply_verbalized, verbalized_confidence
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"apply_grounding_confidence",
|
|
11
|
+
"apply_token_prob",
|
|
12
|
+
"apply_verbalized",
|
|
13
|
+
"combined_confidence",
|
|
14
|
+
"consensus",
|
|
15
|
+
"grounding_confidence",
|
|
16
|
+
"token_prob_confidence",
|
|
17
|
+
"verbalized_confidence",
|
|
18
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Combined confidence: fuse the available signals for one field.
|
|
2
|
+
|
|
3
|
+
# DECISION: v0.1 uses a transparent weighted mean over whichever signals are
|
|
4
|
+
# present (weights renormalized to the available subset). A learned combiner
|
|
5
|
+
# (logistic regression on the calibration split) can replace this behind the
|
|
6
|
+
# same function without touching other stages; the paper compares both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
14
|
+
"consensus": 0.5,
|
|
15
|
+
"grounding": 0.3,
|
|
16
|
+
"token_prob": 0.1,
|
|
17
|
+
"verbalized": 0.1,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def combined_confidence(
|
|
22
|
+
signals: Mapping[str, float | None],
|
|
23
|
+
weights: Mapping[str, float] | None = None,
|
|
24
|
+
) -> float:
|
|
25
|
+
"""Weighted mean of available signals; unknown signal names are rejected."""
|
|
26
|
+
w = dict(weights) if weights is not None else DEFAULT_WEIGHTS
|
|
27
|
+
unknown = set(signals) - set(w)
|
|
28
|
+
if unknown:
|
|
29
|
+
raise ValueError(f"unknown signals: {sorted(unknown)}")
|
|
30
|
+
available = {name: v for name, v in signals.items() if v is not None}
|
|
31
|
+
if not available:
|
|
32
|
+
raise ValueError("no signals available")
|
|
33
|
+
total_weight = sum(w[name] for name in available)
|
|
34
|
+
if total_weight <= 0:
|
|
35
|
+
raise ValueError("available signals have zero total weight")
|
|
36
|
+
value = sum(w[name] * v for name, v in available.items()) / total_weight
|
|
37
|
+
return min(1.0, max(0.0, value))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Consensus (self-consistency) confidence — the black-box VerifyDoc default.
|
|
2
|
+
|
|
3
|
+
Run the extractor k times (or run m different extractors) and use agreement as
|
|
4
|
+
confidence: no logits needed, works for any adapter.
|
|
5
|
+
|
|
6
|
+
# DECISION (consensus semantics, pinned by tests):
|
|
7
|
+
# - Votes are normalized values (whitespace-collapsed, casefolded); a sample
|
|
8
|
+
# that omits the field (or predicts None) casts an explicit "omit" vote.
|
|
9
|
+
# - The consensus value is the modal vote's first-seen raw value; confidence =
|
|
10
|
+
# modal vote count / number of samples. Ties break by first appearance.
|
|
11
|
+
# - If the modal vote is "omit", the consensus prediction has value None with
|
|
12
|
+
# confidence = omit fraction (the abstention layer treats it as omitted).
|
|
13
|
+
# - Grounding is taken from the first sample that cast the modal vote.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from verifydoc.eval.extraction import normalize_text
|
|
22
|
+
from verifydoc.types import FieldPrediction
|
|
23
|
+
|
|
24
|
+
_OMIT = "<omit>"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _vote(value: Any) -> str:
|
|
28
|
+
return _OMIT if value is None else normalize_text(value).casefold()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def consensus(samples: list[list[FieldPrediction]]) -> list[FieldPrediction]:
|
|
32
|
+
"""Aggregate k sample runs into one prediction per field path."""
|
|
33
|
+
if not samples:
|
|
34
|
+
raise ValueError("need at least one sample run")
|
|
35
|
+
k = len(samples)
|
|
36
|
+
|
|
37
|
+
paths: list[str] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
for run in samples:
|
|
40
|
+
for sample_pred in run:
|
|
41
|
+
if sample_pred.path not in seen:
|
|
42
|
+
seen.add(sample_pred.path)
|
|
43
|
+
paths.append(sample_pred.path)
|
|
44
|
+
|
|
45
|
+
out: list[FieldPrediction] = []
|
|
46
|
+
for path in paths:
|
|
47
|
+
votes: list[str] = []
|
|
48
|
+
first_pred: dict[str, FieldPrediction] = {}
|
|
49
|
+
for run in samples:
|
|
50
|
+
by_path = {p.path: p for p in run}
|
|
51
|
+
pred = by_path.get(path)
|
|
52
|
+
vote = _vote(pred.value) if pred is not None else _OMIT
|
|
53
|
+
votes.append(vote)
|
|
54
|
+
if vote not in first_pred and pred is not None:
|
|
55
|
+
first_pred[vote] = pred
|
|
56
|
+
winner, count = Counter(votes).most_common(1)[0]
|
|
57
|
+
agreement = count / k
|
|
58
|
+
source = first_pred.get(winner)
|
|
59
|
+
if winner == _OMIT or source is None:
|
|
60
|
+
out.append(FieldPrediction(path=path, value=None, confidence=agreement))
|
|
61
|
+
else:
|
|
62
|
+
out.append(
|
|
63
|
+
FieldPrediction(
|
|
64
|
+
path=path,
|
|
65
|
+
value=source.value,
|
|
66
|
+
confidence=agreement,
|
|
67
|
+
grounding=source.grounding,
|
|
68
|
+
meta=dict(source.meta),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return out
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Grounding-based confidence: provenance quality as a trust signal.
|
|
2
|
+
|
|
3
|
+
The hypothesis (risk-controlled generative OCR, arXiv:2603.19790): values that
|
|
4
|
+
cannot be traced to a tight source region are more likely hallucinated. The
|
|
5
|
+
signal is the grounding's ``support`` score (e.g. string-match/IoU quality
|
|
6
|
+
computed by the grounder); a field with no grounding at all gets
|
|
7
|
+
``ungrounded_confidence`` (default 0).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from verifydoc.types import FieldPrediction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def grounding_confidence(pred: FieldPrediction, ungrounded_confidence: float = 0.0) -> float:
|
|
16
|
+
"""Confidence from provenance: the grounding's support, else the floor."""
|
|
17
|
+
if pred.grounding is None:
|
|
18
|
+
return ungrounded_confidence
|
|
19
|
+
return pred.grounding.support
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_grounding_confidence(
|
|
23
|
+
predictions: list[FieldPrediction], ungrounded_confidence: float = 0.0
|
|
24
|
+
) -> list[FieldPrediction]:
|
|
25
|
+
"""Return copies with ``confidence`` set from grounding support."""
|
|
26
|
+
return [
|
|
27
|
+
pred.model_copy(update={"confidence": grounding_confidence(pred, ungrounded_confidence)})
|
|
28
|
+
for pred in predictions
|
|
29
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Token/sequence-probability confidence (requires logit access).
|
|
2
|
+
|
|
3
|
+
Adapters that see logits stash per-field token log-probs in
|
|
4
|
+
``FieldPrediction.meta["token_logprobs"]``; this signal turns them into a
|
|
5
|
+
confidence: geometric-mean token probability (``mean``), worst token (``min``),
|
|
6
|
+
or full sequence probability (``prod``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from verifydoc.types import FieldPrediction
|
|
15
|
+
|
|
16
|
+
Aggregate = Literal["mean", "min", "prod"]
|
|
17
|
+
|
|
18
|
+
META_KEY = "token_logprobs"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def token_prob_confidence(pred: FieldPrediction, aggregate: Aggregate = "mean") -> float | None:
|
|
22
|
+
"""Confidence from token log-probs; ``None`` when the adapter gave none."""
|
|
23
|
+
logprobs = pred.meta.get(META_KEY)
|
|
24
|
+
if not logprobs:
|
|
25
|
+
return None
|
|
26
|
+
if aggregate == "mean":
|
|
27
|
+
value = math.exp(sum(logprobs) / len(logprobs))
|
|
28
|
+
elif aggregate == "min":
|
|
29
|
+
value = math.exp(min(logprobs))
|
|
30
|
+
else:
|
|
31
|
+
value = math.exp(sum(logprobs))
|
|
32
|
+
return min(1.0, max(0.0, value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def apply_token_prob(
|
|
36
|
+
predictions: list[FieldPrediction], aggregate: Aggregate = "mean"
|
|
37
|
+
) -> list[FieldPrediction]:
|
|
38
|
+
"""Return copies with ``confidence`` set from token log-probs where available."""
|
|
39
|
+
out = []
|
|
40
|
+
for pred in predictions:
|
|
41
|
+
conf = token_prob_confidence(pred, aggregate)
|
|
42
|
+
out.append(pred if conf is None else pred.model_copy(update={"confidence": conf}))
|
|
43
|
+
return out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Verbalized confidence: the extractor rates its own answer (0-1).
|
|
2
|
+
|
|
3
|
+
Adapters that prompt the model for a per-field self-assessment stash it in
|
|
4
|
+
``FieldPrediction.meta["verbalized_confidence"]``. Known failure mode
|
|
5
|
+
(paper §5.G): verbalized scores can be inflated by RLHF — always compare
|
|
6
|
+
against consensus and report calibration, never trust raw.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from verifydoc.types import FieldPrediction
|
|
12
|
+
|
|
13
|
+
META_KEY = "verbalized_confidence"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verbalized_confidence(pred: FieldPrediction) -> float | None:
|
|
17
|
+
"""Clamped self-reported confidence; ``None`` when the adapter gave none."""
|
|
18
|
+
value = pred.meta.get(META_KEY)
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
return min(1.0, max(0.0, float(value)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_verbalized(predictions: list[FieldPrediction]) -> list[FieldPrediction]:
|
|
25
|
+
"""Return copies with ``confidence`` set from verbalized scores where available."""
|
|
26
|
+
out = []
|
|
27
|
+
for pred in predictions:
|
|
28
|
+
conf = verbalized_confidence(pred)
|
|
29
|
+
out.append(pred if conf is None else pred.model_copy(update={"confidence": conf}))
|
|
30
|
+
return out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Evaluation harness: scores anything that emits FieldPrediction."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Calibration metrics (PROJECT.md §5.B).
|
|
2
|
+
|
|
3
|
+
Implements ECE (15 equal-width bins by default), Adaptive ECE (equal-mass
|
|
4
|
+
bins), MCE, Brier score, NLL, TCE (target calibration error at abstention
|
|
5
|
+
operating points), and reliability-diagram data (Guo et al. 2017 conventions).
|
|
6
|
+
|
|
7
|
+
All functions take a confidence vector ``conf`` in [0, 1] and a 0/1
|
|
8
|
+
correctness vector ``correct`` — the (c_i, correct_i) pairs produced by
|
|
9
|
+
``eval.extraction.score_fields``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate(conf: Sequence[float], correct: Sequence[int]) -> tuple[np.ndarray, np.ndarray]:
|
|
21
|
+
c = np.asarray(conf, dtype=float)
|
|
22
|
+
y = np.asarray(correct, dtype=float)
|
|
23
|
+
if c.shape != y.shape or c.ndim != 1:
|
|
24
|
+
raise ValueError("conf and correct must be 1-D and the same length")
|
|
25
|
+
if c.size == 0:
|
|
26
|
+
raise ValueError("empty inputs")
|
|
27
|
+
if ((c < 0) | (c > 1)).any():
|
|
28
|
+
raise ValueError("confidences must lie in [0, 1]")
|
|
29
|
+
if not np.isin(y, (0.0, 1.0)).all():
|
|
30
|
+
raise ValueError("correct must be 0/1")
|
|
31
|
+
return c, y
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ReliabilityBin:
|
|
36
|
+
"""One bin of a reliability diagram."""
|
|
37
|
+
|
|
38
|
+
lo: float
|
|
39
|
+
hi: float
|
|
40
|
+
mean_confidence: float
|
|
41
|
+
accuracy: float
|
|
42
|
+
count: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reliability_bins(
|
|
46
|
+
conf: Sequence[float], correct: Sequence[int], n_bins: int = 15
|
|
47
|
+
) -> list[ReliabilityBin]:
|
|
48
|
+
"""Equal-width reliability-diagram bins over [0, 1] (empty bins skipped).
|
|
49
|
+
|
|
50
|
+
Bin edges follow Guo et al.: bin m covers ((m-1)/M, m/M]; conf == 0 falls
|
|
51
|
+
into the first bin.
|
|
52
|
+
"""
|
|
53
|
+
c, y = _validate(conf, correct)
|
|
54
|
+
edges = np.linspace(0.0, 1.0, n_bins + 1)
|
|
55
|
+
idx = np.clip(np.digitize(c, edges[1:-1], right=True), 0, n_bins - 1)
|
|
56
|
+
bins = []
|
|
57
|
+
for m in range(n_bins):
|
|
58
|
+
mask = idx == m
|
|
59
|
+
if not mask.any():
|
|
60
|
+
continue
|
|
61
|
+
bins.append(
|
|
62
|
+
ReliabilityBin(
|
|
63
|
+
lo=float(edges[m]),
|
|
64
|
+
hi=float(edges[m + 1]),
|
|
65
|
+
mean_confidence=float(c[mask].mean()),
|
|
66
|
+
accuracy=float(y[mask].mean()),
|
|
67
|
+
count=int(mask.sum()),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return bins
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ece(conf: Sequence[float], correct: Sequence[int], n_bins: int = 15) -> float:
|
|
74
|
+
"""Expected Calibration Error: sum_m (|B_m|/N) * |acc(B_m) - conf(B_m)|."""
|
|
75
|
+
n = len(conf)
|
|
76
|
+
return sum(
|
|
77
|
+
(b.count / n) * abs(b.accuracy - b.mean_confidence)
|
|
78
|
+
for b in reliability_bins(conf, correct, n_bins)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def adaptive_ece(conf: Sequence[float], correct: Sequence[int], n_bins: int = 15) -> float:
|
|
83
|
+
"""Adaptive ECE: equal-mass bins (robust when confidences cluster)."""
|
|
84
|
+
c, y = _validate(conf, correct)
|
|
85
|
+
order = np.argsort(c, kind="stable")
|
|
86
|
+
c, y = c[order], y[order]
|
|
87
|
+
splits = np.array_split(np.arange(c.size), n_bins)
|
|
88
|
+
total = 0.0
|
|
89
|
+
for chunk in splits:
|
|
90
|
+
if chunk.size == 0:
|
|
91
|
+
continue
|
|
92
|
+
total += (chunk.size / c.size) * abs(float(y[chunk].mean()) - float(c[chunk].mean()))
|
|
93
|
+
return total
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def mce(conf: Sequence[float], correct: Sequence[int], n_bins: int = 15) -> float:
|
|
97
|
+
"""Maximum Calibration Error: worst-bin |acc - conf| over non-empty bins."""
|
|
98
|
+
return max(abs(b.accuracy - b.mean_confidence) for b in reliability_bins(conf, correct, n_bins))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def brier(conf: Sequence[float], correct: Sequence[int]) -> float:
|
|
102
|
+
"""Brier score: mean squared error between confidence and correctness."""
|
|
103
|
+
c, y = _validate(conf, correct)
|
|
104
|
+
return float(np.mean((c - y) ** 2))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def nll(conf: Sequence[float], correct: Sequence[int], eps: float = 1e-12) -> float:
|
|
108
|
+
"""Negative log-likelihood; confidences clipped to [eps, 1-eps]."""
|
|
109
|
+
c, y = _validate(conf, correct)
|
|
110
|
+
c = np.clip(c, eps, 1.0 - eps)
|
|
111
|
+
return float(-np.mean(y * np.log(c) + (1.0 - y) * np.log(1.0 - c)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tce(
|
|
115
|
+
conf: Sequence[float],
|
|
116
|
+
correct: Sequence[int],
|
|
117
|
+
alphas: Sequence[float] = (0.01, 0.02, 0.05, 0.10),
|
|
118
|
+
tune_conf: Sequence[float] | None = None,
|
|
119
|
+
) -> float:
|
|
120
|
+
"""Target Calibration Error: mean over alpha of |achieved risk - alpha|.
|
|
121
|
+
|
|
122
|
+
For each target risk ``alpha``, the acceptance threshold is chosen from the
|
|
123
|
+
confidences alone: accept the largest confidence-sorted prefix whose
|
|
124
|
+
*predicted* risk (mean of 1 - c over accepted) is <= alpha, cutting only at
|
|
125
|
+
confidence boundaries so the prefix corresponds to a real threshold.
|
|
126
|
+
The achieved selective risk on (conf, correct) is then compared to alpha.
|
|
127
|
+
|
|
128
|
+
# DECISION: the threshold is tuned from predicted risk (no labels), which
|
|
129
|
+
# is exactly how a deployed system must pick its operating point; pass
|
|
130
|
+
# ``tune_conf`` to select the threshold on a calibration split instead.
|
|
131
|
+
# Accepting nothing has selective risk 0 by convention.
|
|
132
|
+
"""
|
|
133
|
+
c, y = _validate(conf, correct)
|
|
134
|
+
t = np.asarray(tune_conf, dtype=float) if tune_conf is not None else c
|
|
135
|
+
total = 0.0
|
|
136
|
+
for alpha in alphas:
|
|
137
|
+
threshold = _risk_threshold(t, float(alpha))
|
|
138
|
+
accepted = c >= threshold
|
|
139
|
+
achieved = float(1.0 - y[accepted].mean()) if accepted.any() else 0.0
|
|
140
|
+
total += abs(achieved - alpha)
|
|
141
|
+
return total / len(alphas)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _risk_threshold(conf: np.ndarray, alpha: float) -> float:
|
|
145
|
+
"""Lowest confidence threshold whose predicted selective risk is <= alpha."""
|
|
146
|
+
c = np.sort(conf)[::-1]
|
|
147
|
+
predicted_risk = np.cumsum(1.0 - c) / np.arange(1, c.size + 1)
|
|
148
|
+
# valid cut points: where the next confidence is strictly lower (or the end)
|
|
149
|
+
boundary: np.ndarray = np.append(c[:-1] > c[1:], True)
|
|
150
|
+
ok = (predicted_risk <= alpha) & boundary
|
|
151
|
+
if not ok.any():
|
|
152
|
+
return float("inf") # accept nothing
|
|
153
|
+
k = int(np.flatnonzero(ok)[-1])
|
|
154
|
+
return float(c[k])
|