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.
Files changed (45) hide show
  1. verifydoc/__init__.py +16 -0
  2. verifydoc/adapters/__init__.py +33 -0
  3. verifydoc/adapters/_ocr_common.py +80 -0
  4. verifydoc/adapters/api_vlm.py +97 -0
  5. verifydoc/adapters/base.py +31 -0
  6. verifydoc/adapters/docling.py +45 -0
  7. verifydoc/adapters/dots_ocr.py +66 -0
  8. verifydoc/adapters/mock.py +95 -0
  9. verifydoc/adapters/paddleocr_vl.py +50 -0
  10. verifydoc/adapters/text_search.py +53 -0
  11. verifydoc/calibration/__init__.py +20 -0
  12. verifydoc/calibration/base.py +56 -0
  13. verifydoc/calibration/conformal.py +75 -0
  14. verifydoc/calibration/histogram.py +34 -0
  15. verifydoc/calibration/isotonic.py +23 -0
  16. verifydoc/calibration/platt.py +30 -0
  17. verifydoc/calibration/splits.py +44 -0
  18. verifydoc/calibration/temperature.py +30 -0
  19. verifydoc/cli.py +48 -0
  20. verifydoc/confidence/__init__.py +18 -0
  21. verifydoc/confidence/combined.py +37 -0
  22. verifydoc/confidence/consensus.py +71 -0
  23. verifydoc/confidence/grounding_based.py +29 -0
  24. verifydoc/confidence/token_prob.py +43 -0
  25. verifydoc/confidence/verbalized.py +30 -0
  26. verifydoc/eval/__init__.py +1 -0
  27. verifydoc/eval/calibration.py +154 -0
  28. verifydoc/eval/extraction.py +534 -0
  29. verifydoc/eval/grounding.py +124 -0
  30. verifydoc/eval/harness.py +354 -0
  31. verifydoc/eval/selective.py +170 -0
  32. verifydoc/eval/stats.py +97 -0
  33. verifydoc/grounding/__init__.py +5 -0
  34. verifydoc/grounding/attach.py +87 -0
  35. verifydoc/ingest/__init__.py +5 -0
  36. verifydoc/ingest/loader.py +100 -0
  37. verifydoc/pipeline.py +134 -0
  38. verifydoc/policy/__init__.py +5 -0
  39. verifydoc/policy/abstention.py +73 -0
  40. verifydoc/types.py +230 -0
  41. verifydoc-0.1.0.dist-info/METADATA +374 -0
  42. verifydoc-0.1.0.dist-info/RECORD +45 -0
  43. verifydoc-0.1.0.dist-info/WHEEL +4 -0
  44. verifydoc-0.1.0.dist-info/entry_points.txt +2 -0
  45. 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])