jevkit-calibrate 0.1.0__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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .ruff_cache/
12
+ uv.lock
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-calibrate
3
+ Version: 0.1.0
4
+ Summary: Verify TypeSafe Jev's calibration on your own data. Reliability diagrams, ECE, Brier, and confidence thresholds derived from labeled outcomes.
5
+ Project-URL: Homepage, https://github.com/pjdurden/jevkit-py
6
+ Project-URL: Issues, https://github.com/pjdurden/jevkit-py/issues
7
+ Author: Prajjwal Chittori
8
+ License-Expression: MIT
9
+ Keywords: calibration,ece,jev,reliability,system-one,thresholds,typesafe
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: jevkit-core>=0.2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # jevkit-calibrate
22
+
23
+ Calibration is Jev's central claim: the probabilities are meant to track real
24
+ frequencies, so that among answers given 0.8, about 80% are right. TypeSafe
25
+ measures this across groups of predictions and says plainly that it does not
26
+ guarantee any individual answer.
27
+
28
+ This package checks the claim on **your** data, which is the part nobody else
29
+ can do for you, and turns the result into a threshold you can defend.
30
+
31
+ > Unofficial and unaffiliated with TypeSafe.
32
+
33
+ ```bash
34
+ pip install jevkit-calibrate
35
+ ```
36
+
37
+ Pure standard library. No numpy, no pandas, no plotting stack. A calibration
38
+ check that needs a build toolchain is a calibration check that does not get run.
39
+
40
+ ## Measure
41
+
42
+ ```python
43
+ from jevkit_core import read_records
44
+ from jevkit_calibrate import calibrate, observations_from_records
45
+
46
+ observations = observations_from_records(read_records("labeled.jevl"))
47
+ report = calibrate(observations)
48
+
49
+ print(report.summary())
50
+ print(report.diagram())
51
+ ```
52
+
53
+ ```
54
+ observations: 4000
55
+ accuracy: 0.7485
56
+ mean claimed: 0.7469 (underconfident by 0.0016)
57
+ ECE: 0.0094 (over 10 bins)
58
+ MCE: 0.0167
59
+ Brier: 0.1681
60
+ log loss: 0.5043
61
+ ```
62
+
63
+ `ECE` is the average gap between claimed and observed, weighted by how many
64
+ observations fall in each bin. `MCE` is the worst gap in any one bin, which is
65
+ what catches a healthy-looking ECE hiding one badly wrong region. `Brier` and
66
+ `log loss` are proper scoring rules: they reward being calibrated **and**
67
+ decisive, so a model that always says 0.5 scores badly even though it is
68
+ perfectly calibrated.
69
+
70
+ ## Pick a threshold
71
+
72
+ TypeSafe's confidence page recommends three bands and says where you draw them
73
+ depends on your data. This draws them from the data.
74
+
75
+ ```python
76
+ from jevkit_calibrate import recommend_for_accuracy
77
+
78
+ point = recommend_for_accuracy(observations, target_accuracy=0.95)
79
+ if point is None:
80
+ print("no threshold reaches 95% on this data")
81
+ else:
82
+ print(f"threshold {point.threshold:.2f}: "
83
+ f"covers {point.coverage:.1%} at {point.accuracy:.1%}, "
84
+ f"{point.errors} wrong answers acted on")
85
+ ```
86
+
87
+ `None` is a real answer, not a failure. It means this question cannot be
88
+ automated at that bar, and the honest move is to change the question rather
89
+ than lower the threshold.
90
+
91
+ ## CLI
92
+
93
+ ```bash
94
+ jevkit-calibrate labeled.jevl
95
+ jevkit-calibrate labeled.jevl --target-accuracy 0.95
96
+ jevkit-calibrate labeled.jevl --min-coverage 0.80
97
+ jevkit-calibrate labeled.jevl --max-ece 0.05 # CI gate
98
+ jevkit-calibrate labeled.jevl --format json
99
+ ```
100
+
101
+ ## Which quantity gets calibrated
102
+
103
+ By default, the probability mass on the chosen outcome, which is what "when it
104
+ says 0.8, is it right 80% of the time" means.
105
+
106
+ `--use-confidence` calibrates the API's `confidence` statistic instead. That is
107
+ a different question, and the one to ask when you want to know whether your
108
+ *routing* threshold sits in the right place. Nouls carry no confidence, so they
109
+ fall back to probability either way.
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,93 @@
1
+ # jevkit-calibrate
2
+
3
+ Calibration is Jev's central claim: the probabilities are meant to track real
4
+ frequencies, so that among answers given 0.8, about 80% are right. TypeSafe
5
+ measures this across groups of predictions and says plainly that it does not
6
+ guarantee any individual answer.
7
+
8
+ This package checks the claim on **your** data, which is the part nobody else
9
+ can do for you, and turns the result into a threshold you can defend.
10
+
11
+ > Unofficial and unaffiliated with TypeSafe.
12
+
13
+ ```bash
14
+ pip install jevkit-calibrate
15
+ ```
16
+
17
+ Pure standard library. No numpy, no pandas, no plotting stack. A calibration
18
+ check that needs a build toolchain is a calibration check that does not get run.
19
+
20
+ ## Measure
21
+
22
+ ```python
23
+ from jevkit_core import read_records
24
+ from jevkit_calibrate import calibrate, observations_from_records
25
+
26
+ observations = observations_from_records(read_records("labeled.jevl"))
27
+ report = calibrate(observations)
28
+
29
+ print(report.summary())
30
+ print(report.diagram())
31
+ ```
32
+
33
+ ```
34
+ observations: 4000
35
+ accuracy: 0.7485
36
+ mean claimed: 0.7469 (underconfident by 0.0016)
37
+ ECE: 0.0094 (over 10 bins)
38
+ MCE: 0.0167
39
+ Brier: 0.1681
40
+ log loss: 0.5043
41
+ ```
42
+
43
+ `ECE` is the average gap between claimed and observed, weighted by how many
44
+ observations fall in each bin. `MCE` is the worst gap in any one bin, which is
45
+ what catches a healthy-looking ECE hiding one badly wrong region. `Brier` and
46
+ `log loss` are proper scoring rules: they reward being calibrated **and**
47
+ decisive, so a model that always says 0.5 scores badly even though it is
48
+ perfectly calibrated.
49
+
50
+ ## Pick a threshold
51
+
52
+ TypeSafe's confidence page recommends three bands and says where you draw them
53
+ depends on your data. This draws them from the data.
54
+
55
+ ```python
56
+ from jevkit_calibrate import recommend_for_accuracy
57
+
58
+ point = recommend_for_accuracy(observations, target_accuracy=0.95)
59
+ if point is None:
60
+ print("no threshold reaches 95% on this data")
61
+ else:
62
+ print(f"threshold {point.threshold:.2f}: "
63
+ f"covers {point.coverage:.1%} at {point.accuracy:.1%}, "
64
+ f"{point.errors} wrong answers acted on")
65
+ ```
66
+
67
+ `None` is a real answer, not a failure. It means this question cannot be
68
+ automated at that bar, and the honest move is to change the question rather
69
+ than lower the threshold.
70
+
71
+ ## CLI
72
+
73
+ ```bash
74
+ jevkit-calibrate labeled.jevl
75
+ jevkit-calibrate labeled.jevl --target-accuracy 0.95
76
+ jevkit-calibrate labeled.jevl --min-coverage 0.80
77
+ jevkit-calibrate labeled.jevl --max-ece 0.05 # CI gate
78
+ jevkit-calibrate labeled.jevl --format json
79
+ ```
80
+
81
+ ## Which quantity gets calibrated
82
+
83
+ By default, the probability mass on the chosen outcome, which is what "when it
84
+ says 0.8, is it right 80% of the time" means.
85
+
86
+ `--use-confidence` calibrates the API's `confidence` statistic instead. That is
87
+ a different question, and the one to ask when you want to know whether your
88
+ *routing* threshold sits in the right place. Nouls carry no confidence, so they
89
+ fall back to probability either way.
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "jevkit-calibrate"
3
+ version = "0.1.0"
4
+ description = "Verify TypeSafe Jev's calibration on your own data. Reliability diagrams, ECE, Brier, and confidence thresholds derived from labeled outcomes."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "calibration", "ece", "reliability", "thresholds"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = ["jevkit-core>=0.2.0"]
20
+
21
+ [project.scripts]
22
+ jevkit-calibrate = "jevkit_calibrate.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/pjdurden/jevkit-py"
26
+ Issues = "https://github.com/pjdurden/jevkit-py/issues"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/jevkit_calibrate"]
@@ -0,0 +1,25 @@
1
+ """Verify Jev's calibration on your own data, and pick thresholds from it.
2
+
3
+ Calibration is the central claim behind a System One model, and it is the one
4
+ thing a user cannot check without tooling. This package computes reliability
5
+ diagrams, ECE, MCE, Brier and log loss over labeled answers, and turns a
6
+ labeled set into a defensible confidence threshold.
7
+
8
+ Pure standard library. A calibration check that needs a numpy build is a
9
+ calibration check that does not get run.
10
+ """
11
+
12
+ from .metrics import (Bin, CalibrationReport, Observation, brier_score, calibrate,
13
+ expected_calibration_error, log_loss,
14
+ maximum_calibration_error, reliability_bins)
15
+ from .records import observations_from_records
16
+ from .thresholds import (ThresholdPoint, recommend_for_accuracy,
17
+ recommend_for_coverage, sweep)
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = ["calibrate", "CalibrationReport", "Observation", "Bin",
22
+ "reliability_bins", "expected_calibration_error",
23
+ "maximum_calibration_error", "brier_score", "log_loss",
24
+ "observations_from_records", "sweep", "ThresholdPoint",
25
+ "recommend_for_accuracy", "recommend_for_coverage", "__version__"]
@@ -0,0 +1,108 @@
1
+ """``jevkit-calibrate`` command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from jevkit_core import RecordFormatError, read_records
10
+
11
+ from .metrics import calibrate
12
+ from .records import observations_from_records
13
+ from .thresholds import recommend_for_accuracy, recommend_for_coverage
14
+
15
+ EXIT_OK = 0
16
+ EXIT_FAILED_GATE = 1
17
+ EXIT_USAGE = 2
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(
22
+ prog="jevkit-calibrate",
23
+ description="Measure how well Jev's probabilities match outcomes on your labeled "
24
+ "data, and pick a confidence threshold from it. Never calls the API.",
25
+ )
26
+ parser.add_argument("files", nargs="+", help=".jevl files containing labeled records")
27
+ parser.add_argument("--bins", type=int, default=10, help="reliability bins (default 10)")
28
+ parser.add_argument("--question", action="append", dest="questions",
29
+ help="restrict to this question id (repeatable)")
30
+ parser.add_argument("--use-confidence", action="store_true",
31
+ help="calibrate the API's confidence rather than the probability "
32
+ "on the chosen outcome")
33
+ parser.add_argument("--target-accuracy", type=float,
34
+ help="recommend the lowest threshold reaching this accuracy")
35
+ parser.add_argument("--min-coverage", type=float,
36
+ help="recommend the highest threshold still covering this fraction")
37
+ parser.add_argument("--max-ece", type=float,
38
+ help="exit non-zero if ECE exceeds this")
39
+ parser.add_argument("--format", choices=("text", "json"), default="text")
40
+ parser.add_argument("--no-diagram", action="store_true")
41
+ args = parser.parse_args(argv)
42
+
43
+ records = []
44
+ try:
45
+ for path in args.files:
46
+ records.extend(read_records(path))
47
+ except (OSError, RecordFormatError) as exc:
48
+ print(f"jevkit-calibrate: {exc}", file=sys.stderr)
49
+ return EXIT_USAGE
50
+
51
+ observations = observations_from_records(
52
+ records, question_ids=args.questions, use_confidence=args.use_confidence
53
+ )
54
+ if not observations:
55
+ print("jevkit-calibrate: no labeled observations found. Records need a 'label' "
56
+ "object keyed by question id.", file=sys.stderr)
57
+ return EXIT_USAGE
58
+
59
+ report = calibrate(observations, n_bins=args.bins)
60
+
61
+ recommendation = None
62
+ if args.target_accuracy is not None:
63
+ recommendation = ("accuracy", args.target_accuracy,
64
+ recommend_for_accuracy(observations, args.target_accuracy))
65
+ elif args.min_coverage is not None:
66
+ recommendation = ("coverage", args.min_coverage,
67
+ recommend_for_coverage(observations, args.min_coverage))
68
+
69
+ if args.format == "json":
70
+ payload = report.to_dict()
71
+ if recommendation:
72
+ kind, target, point = recommendation
73
+ payload["recommendation"] = {
74
+ "for": kind, "target": target,
75
+ "threshold": point.threshold if point else None,
76
+ "coverage": point.coverage if point else None,
77
+ "accuracy": point.accuracy if point else None,
78
+ "errors": point.errors if point else None,
79
+ }
80
+ print(json.dumps(payload, indent=2))
81
+ else:
82
+ print(report.summary())
83
+ if not args.no_diagram:
84
+ print()
85
+ print(report.diagram())
86
+ if recommendation:
87
+ kind, target, point = recommendation
88
+ print()
89
+ if point is None:
90
+ if kind == "accuracy":
91
+ print(f"No threshold reaches {target:.1%} accuracy on this data. The "
92
+ f"question cannot be automated at that bar; change the question "
93
+ f"rather than the threshold.")
94
+ else:
95
+ print(f"No threshold covers {target:.1%} of cases.")
96
+ else:
97
+ print(f"threshold {point.threshold:.2f}: covers {point.coverage:.1%} "
98
+ f"({point.covered}/{point.total}) at {point.accuracy:.1%} accuracy, "
99
+ f"{point.errors} wrong answer(s) acted on, {point.escalated} escalated")
100
+
101
+ if args.max_ece is not None and report.ece > args.max_ece:
102
+ print(f"jevkit-calibrate: ECE {report.ece:.4f} exceeds {args.max_ece}", file=sys.stderr)
103
+ return EXIT_FAILED_GATE
104
+ return EXIT_OK
105
+
106
+
107
+ if __name__ == "__main__": # pragma: no cover
108
+ raise SystemExit(main())
@@ -0,0 +1,264 @@
1
+ """Calibration metrics over labeled jev answers.
2
+
3
+ Calibration is Jev's central claim: the probabilities are meant to reflect
4
+ real-world frequencies, so that among answers given 0.8, about 80% are right.
5
+ TypeSafe measures this across groups of predictions and says plainly that it
6
+ does not guarantee any individual answer. This module is how you check the claim
7
+ on *your* data, which is the part nobody else can do for you.
8
+
9
+ Everything here is pure arithmetic over recorded answers. Nothing calls the API,
10
+ and there are no dependencies beyond the standard library: a calibration check
11
+ that requires a numpy build is a calibration check that does not get run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass, field
18
+ from typing import Iterable, Sequence
19
+
20
+ __all__ = ["Observation", "Bin", "CalibrationReport", "reliability_bins",
21
+ "expected_calibration_error", "maximum_calibration_error",
22
+ "brier_score", "log_loss", "calibrate"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Observation:
27
+ """One labeled prediction.
28
+
29
+ ``probability`` is the model's stated probability for the outcome it chose;
30
+ ``correct`` is whether that outcome matched the label.
31
+ """
32
+
33
+ probability: float
34
+ correct: bool
35
+ question_id: str = ""
36
+ request_id: str = ""
37
+
38
+ def __post_init__(self) -> None:
39
+ if not 0.0 <= self.probability <= 1.0:
40
+ raise ValueError(f"probability must be in [0, 1], got {self.probability}")
41
+
42
+
43
+ @dataclass
44
+ class Bin:
45
+ """One bucket of a reliability diagram."""
46
+
47
+ lower: float
48
+ upper: float
49
+ observations: list[Observation] = field(default_factory=list)
50
+
51
+ @property
52
+ def count(self) -> int:
53
+ return len(self.observations)
54
+
55
+ @property
56
+ def mean_probability(self) -> float:
57
+ """What the model claimed, on average."""
58
+ if not self.observations:
59
+ return 0.0
60
+ return sum(o.probability for o in self.observations) / self.count
61
+
62
+ @property
63
+ def accuracy(self) -> float:
64
+ """What actually happened."""
65
+ if not self.observations:
66
+ return 0.0
67
+ return sum(1 for o in self.observations if o.correct) / self.count
68
+
69
+ @property
70
+ def gap(self) -> float:
71
+ """Claimed minus observed. Positive means overconfident."""
72
+ return self.mean_probability - self.accuracy
73
+
74
+ def label(self) -> str:
75
+ return f"[{self.lower:.2f}, {self.upper:.2f}{']' if self.upper == 1.0 else ')'}"
76
+
77
+
78
+ def reliability_bins(observations: Sequence[Observation], n_bins: int = 10) -> list[Bin]:
79
+ """Bucket observations into equal-width probability bins.
80
+
81
+ Equal-width rather than equal-count, because the question being asked is
82
+ "when the model says 0.9, is it right 90% of the time", and that question is
83
+ about fixed probability ranges. Empty bins are kept so the diagram does not
84
+ silently mislead about coverage.
85
+ """
86
+ if n_bins < 1:
87
+ raise ValueError("n_bins must be at least 1")
88
+ edges = [i / n_bins for i in range(n_bins + 1)]
89
+ bins = [Bin(lower=edges[i], upper=edges[i + 1]) for i in range(n_bins)]
90
+ for obs in observations:
91
+ # The top bin is closed so that a probability of exactly 1.0 lands in it.
92
+ index = min(int(obs.probability * n_bins), n_bins - 1)
93
+ bins[index].observations.append(obs)
94
+ return bins
95
+
96
+
97
+ def expected_calibration_error(observations: Sequence[Observation], n_bins: int = 10) -> float:
98
+ """ECE: average gap between claimed and observed, weighted by bin population.
99
+
100
+ 0 is perfect. Note that ECE depends on the bin count, so compare ECE values
101
+ only when they were computed with the same ``n_bins``.
102
+ """
103
+ if not observations:
104
+ return 0.0
105
+ total = len(observations)
106
+ return sum(
107
+ (b.count / total) * abs(b.gap) for b in reliability_bins(observations, n_bins) if b.count
108
+ )
109
+
110
+
111
+ def maximum_calibration_error(observations: Sequence[Observation], n_bins: int = 10) -> float:
112
+ """MCE: the worst gap in any populated bin.
113
+
114
+ ECE can look healthy while one region is badly wrong. MCE is what catches
115
+ that, and it is the number that matters when a single region is where your
116
+ high-stakes decisions live.
117
+ """
118
+ if not observations:
119
+ return 0.0
120
+ return max((abs(b.gap) for b in reliability_bins(observations, n_bins) if b.count), default=0.0)
121
+
122
+
123
+ def brier_score(observations: Sequence[Observation]) -> float:
124
+ """Mean squared error between probability and outcome. Lower is better.
125
+
126
+ Unlike ECE this is a proper scoring rule: it rewards being both calibrated
127
+ and decisive, so a model that always says 0.5 scores poorly even though it
128
+ is perfectly calibrated.
129
+ """
130
+ if not observations:
131
+ return 0.0
132
+ return sum((o.probability - (1.0 if o.correct else 0.0)) ** 2 for o in observations) / len(
133
+ observations
134
+ )
135
+
136
+
137
+ def log_loss(observations: Sequence[Observation], eps: float = 1e-15) -> float:
138
+ """Mean negative log likelihood. Lower is better.
139
+
140
+ Punishes confident mistakes far harder than Brier does. ``eps`` clamps the
141
+ probability away from 0 and 1, since an unclamped confident miss is infinite
142
+ and would swamp every other observation.
143
+ """
144
+ if not observations:
145
+ return 0.0
146
+ total = 0.0
147
+ for o in observations:
148
+ p = min(max(o.probability, eps), 1.0 - eps)
149
+ total -= math.log(p) if o.correct else math.log(1.0 - p)
150
+ return total / len(observations)
151
+
152
+
153
+ @dataclass
154
+ class CalibrationReport:
155
+ observations: list[Observation]
156
+ n_bins: int = 10
157
+
158
+ @property
159
+ def count(self) -> int:
160
+ return len(self.observations)
161
+
162
+ @property
163
+ def accuracy(self) -> float:
164
+ if not self.observations:
165
+ return 0.0
166
+ return sum(1 for o in self.observations if o.correct) / self.count
167
+
168
+ @property
169
+ def mean_probability(self) -> float:
170
+ if not self.observations:
171
+ return 0.0
172
+ return sum(o.probability for o in self.observations) / self.count
173
+
174
+ @property
175
+ def bins(self) -> list[Bin]:
176
+ return reliability_bins(self.observations, self.n_bins)
177
+
178
+ @property
179
+ def ece(self) -> float:
180
+ return expected_calibration_error(self.observations, self.n_bins)
181
+
182
+ @property
183
+ def mce(self) -> float:
184
+ return maximum_calibration_error(self.observations, self.n_bins)
185
+
186
+ @property
187
+ def brier(self) -> float:
188
+ return brier_score(self.observations)
189
+
190
+ @property
191
+ def log_loss(self) -> float:
192
+ return log_loss(self.observations)
193
+
194
+ @property
195
+ def overconfident(self) -> bool:
196
+ """Claimed probability exceeds observed accuracy overall."""
197
+ return self.mean_probability > self.accuracy
198
+
199
+ def diagram(self, width: int = 28) -> str:
200
+ """A text reliability diagram.
201
+
202
+ Each row shows a bin's claimed probability against what actually
203
+ happened, so a miscalibrated region is visible without plotting.
204
+ """
205
+ lines = [
206
+ f"{'bin':<14} {'n':>5} {'claimed':>8} {'actual':>8} {'gap':>7}",
207
+ "-" * 46,
208
+ ]
209
+ for b in self.bins:
210
+ if not b.count:
211
+ lines.append(f"{b.label():<14} {0:>5} {'-':>8} {'-':>8} {'-':>7}")
212
+ continue
213
+ bar_len = int(b.accuracy * width)
214
+ marker = int(b.mean_probability * width)
215
+ cells = ["#" if i < bar_len else " " for i in range(width)]
216
+ if 0 <= marker < width:
217
+ cells[marker] = "|" if cells[marker] == " " else "+"
218
+ lines.append(
219
+ f"{b.label():<14} {b.count:>5} {b.mean_probability:>8.3f} "
220
+ f"{b.accuracy:>8.3f} {b.gap:>+7.3f} {''.join(cells)}"
221
+ )
222
+ lines.append("")
223
+ lines.append(" # observed accuracy, | claimed probability, + both")
224
+ return "\n".join(lines)
225
+
226
+ def summary(self) -> str:
227
+ if not self.count:
228
+ return "no labeled observations"
229
+ direction = "overconfident" if self.overconfident else "underconfident"
230
+ return "\n".join([
231
+ f"observations: {self.count}",
232
+ f"accuracy: {self.accuracy:.4f}",
233
+ f"mean claimed: {self.mean_probability:.4f} ({direction} "
234
+ f"by {abs(self.mean_probability - self.accuracy):.4f})",
235
+ f"ECE: {self.ece:.4f} (over {self.n_bins} bins)",
236
+ f"MCE: {self.mce:.4f}",
237
+ f"Brier: {self.brier:.4f}",
238
+ f"log loss: {self.log_loss:.4f}",
239
+ ])
240
+
241
+ def to_dict(self) -> dict[str, object]:
242
+ return {
243
+ "count": self.count,
244
+ "accuracy": self.accuracy,
245
+ "mean_probability": self.mean_probability,
246
+ "overconfident": self.overconfident,
247
+ "ece": self.ece,
248
+ "mce": self.mce,
249
+ "brier": self.brier,
250
+ "log_loss": self.log_loss,
251
+ "n_bins": self.n_bins,
252
+ "bins": [
253
+ {
254
+ "lower": b.lower, "upper": b.upper, "count": b.count,
255
+ "mean_probability": b.mean_probability, "accuracy": b.accuracy,
256
+ "gap": b.gap,
257
+ }
258
+ for b in self.bins
259
+ ],
260
+ }
261
+
262
+
263
+ def calibrate(observations: Iterable[Observation], n_bins: int = 10) -> CalibrationReport:
264
+ return CalibrationReport(observations=list(observations), n_bins=n_bins)
@@ -0,0 +1,59 @@
1
+ """Turning labeled `.jevl` records into calibration observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from jevkit_core import Record, parse_answers
8
+
9
+ from .metrics import Observation
10
+
11
+ __all__ = ["observations_from_records", "SKIPPED_UNLABELED"]
12
+
13
+ SKIPPED_UNLABELED = "unlabeled"
14
+
15
+
16
+ def observations_from_records(
17
+ records: Iterable[Record],
18
+ *,
19
+ question_ids: Iterable[str] | None = None,
20
+ use_confidence: bool = False,
21
+ ) -> list[Observation]:
22
+ """Extract one observation per labeled answer.
23
+
24
+ Records with no ``label`` are skipped: calibration needs ground truth, and
25
+ silently treating an unlabeled record as correct or incorrect would poison
26
+ every number downstream.
27
+
28
+ ``use_confidence`` picks which quantity is being calibrated. The default,
29
+ ``False``, uses the probability mass on the chosen outcome, which is what
30
+ "when it says 0.8, is it right 80% of the time" means. Setting it to ``True``
31
+ calibrates the API's ``confidence`` statistic instead, which is a different
32
+ question and answers whether your *routing* threshold is well placed. Nouls
33
+ have no confidence, so they fall back to the probability either way.
34
+ """
35
+ wanted = set(question_ids) if question_ids is not None else None
36
+ out: list[Observation] = []
37
+
38
+ for record in records:
39
+ if not record.label:
40
+ continue
41
+ answers = parse_answers(record.answers)
42
+ for qid, label in record.label.items():
43
+ if wanted is not None and qid not in wanted:
44
+ continue
45
+ answer = answers.get(qid)
46
+ if answer is None:
47
+ continue
48
+ correct = answer.is_correct(label)
49
+ if use_confidence and answer.confidence is not None:
50
+ probability = answer.confidence
51
+ else:
52
+ probability = answer.top_probability
53
+ out.append(Observation(
54
+ probability=probability,
55
+ correct=correct,
56
+ question_id=qid,
57
+ request_id=record.request_id,
58
+ ))
59
+ return out
@@ -0,0 +1,112 @@
1
+ """Choosing a confidence threshold from labeled data instead of guessing.
2
+
3
+ TypeSafe's confidence page recommends three bands: act automatically, proceed
4
+ with caution, escalate. It also says plainly that where you draw those lines
5
+ depends on your domain and your data. This module draws them from the data.
6
+
7
+ The trade is always the same. Raising the threshold means acting on fewer cases
8
+ but being right more often on the ones you do act on. That is a curve, not a
9
+ number, so ``sweep`` returns the whole curve and the ``recommend_*`` functions
10
+ pick a point on it against a constraint you state.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Sequence
17
+
18
+ from .metrics import Observation
19
+
20
+ __all__ = ["ThresholdPoint", "sweep", "recommend_for_accuracy", "recommend_for_coverage"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ThresholdPoint:
25
+ """What happens if you auto-handle everything at or above ``threshold``."""
26
+
27
+ threshold: float
28
+ covered: int
29
+ total: int
30
+ correct: int
31
+
32
+ @property
33
+ def coverage(self) -> float:
34
+ """Fraction of cases handled automatically."""
35
+ return self.covered / self.total if self.total else 0.0
36
+
37
+ @property
38
+ def accuracy(self) -> float:
39
+ """Accuracy on the automatically handled cases.
40
+
41
+ Defined as 1.0 when nothing is covered: a threshold that acts on nothing
42
+ is never wrong. Read it together with ``coverage``, never alone.
43
+ """
44
+ return self.correct / self.covered if self.covered else 1.0
45
+
46
+ @property
47
+ def escalated(self) -> int:
48
+ return self.total - self.covered
49
+
50
+ @property
51
+ def errors(self) -> int:
52
+ """Wrong answers you acted on. Usually the number that actually costs."""
53
+ return self.covered - self.correct
54
+
55
+
56
+ def sweep(observations: Sequence[Observation], steps: int = 101) -> list[ThresholdPoint]:
57
+ """Coverage and accuracy at every threshold from 0 to 1.
58
+
59
+ Uses the probability the model put on the outcome it chose, so this works
60
+ for Choice and Score confidence and for a Noul's distance from 0.5 alike, as
61
+ long as the caller is consistent about which it fed in.
62
+ """
63
+ if steps < 2:
64
+ raise ValueError("steps must be at least 2")
65
+ total = len(observations)
66
+ points: list[ThresholdPoint] = []
67
+ for i in range(steps):
68
+ threshold = i / (steps - 1)
69
+ covered = [o for o in observations if o.probability >= threshold]
70
+ points.append(ThresholdPoint(
71
+ threshold=threshold,
72
+ covered=len(covered),
73
+ total=total,
74
+ correct=sum(1 for o in covered if o.correct),
75
+ ))
76
+ return points
77
+
78
+
79
+ def recommend_for_accuracy(
80
+ observations: Sequence[Observation],
81
+ target_accuracy: float,
82
+ *,
83
+ steps: int = 101,
84
+ ) -> ThresholdPoint | None:
85
+ """Lowest threshold reaching ``target_accuracy``, so coverage stays highest.
86
+
87
+ Returns ``None`` when no threshold reaches the target, which is a real
88
+ answer: it means this question cannot be automated at that accuracy and the
89
+ honest move is to change the question rather than the threshold.
90
+ """
91
+ if not 0.0 <= target_accuracy <= 1.0:
92
+ raise ValueError("target_accuracy must be in [0, 1]")
93
+ candidates = [p for p in sweep(observations, steps) if p.covered and p.accuracy >= target_accuracy]
94
+ return min(candidates, key=lambda p: p.threshold) if candidates else None
95
+
96
+
97
+ def recommend_for_coverage(
98
+ observations: Sequence[Observation],
99
+ min_coverage: float,
100
+ *,
101
+ steps: int = 101,
102
+ ) -> ThresholdPoint | None:
103
+ """Highest threshold still covering ``min_coverage``, so accuracy is best.
104
+
105
+ The mirror of ``recommend_for_accuracy``: use it when throughput is the
106
+ binding constraint and you want the most accurate threshold that still keeps
107
+ enough volume out of the review queue.
108
+ """
109
+ if not 0.0 <= min_coverage <= 1.0:
110
+ raise ValueError("min_coverage must be in [0, 1]")
111
+ candidates = [p for p in sweep(observations, steps) if p.coverage >= min_coverage]
112
+ return max(candidates, key=lambda p: p.threshold) if candidates else None
@@ -0,0 +1,131 @@
1
+ import random
2
+ import pytest
3
+ from jevkit_core import Record
4
+ from jevkit_calibrate import (Observation, brier_score, calibrate,
5
+ expected_calibration_error, log_loss,
6
+ maximum_calibration_error, observations_from_records,
7
+ recommend_for_accuracy, recommend_for_coverage, sweep)
8
+
9
+
10
+ def perfect(n=2000, seed=1):
11
+ rng = random.Random(seed)
12
+ out = []
13
+ for _ in range(n):
14
+ p = rng.uniform(0.5, 1.0)
15
+ out.append(Observation(probability=p, correct=rng.random() < p))
16
+ return out
17
+
18
+
19
+ def test_probability_outside_the_unit_interval_is_rejected():
20
+ for bad in (-0.1, 1.1):
21
+ with pytest.raises(ValueError, match="probability must be"):
22
+ Observation(probability=bad, correct=True)
23
+
24
+
25
+ def test_a_perfectly_calibrated_generator_has_near_zero_ece():
26
+ assert expected_calibration_error(perfect()) < 0.05
27
+
28
+
29
+ def test_a_maximally_overconfident_model_has_ece_near_one():
30
+ obs = [Observation(probability=1.0, correct=False) for _ in range(100)]
31
+ assert expected_calibration_error(obs) == pytest.approx(1.0)
32
+
33
+
34
+ def test_mce_catches_one_bad_region_that_ece_dilutes():
35
+ good = [Observation(probability=0.5, correct=i % 2 == 0) for i in range(998)]
36
+ bad = [Observation(probability=1.0, correct=False) for _ in range(2)]
37
+ obs = good + bad
38
+ assert expected_calibration_error(obs) < 0.01 # diluted
39
+ assert maximum_calibration_error(obs) == pytest.approx(1.0) # caught
40
+
41
+
42
+ def test_brier_rewards_being_decisive_as_well_as_calibrated():
43
+ always_half = [Observation(probability=0.5, correct=i % 2 == 0) for i in range(100)]
44
+ decisive = [Observation(probability=1.0, correct=True) for _ in range(100)]
45
+ assert brier_score(decisive) < brier_score(always_half)
46
+
47
+
48
+ def test_log_loss_is_finite_on_a_confident_miss():
49
+ assert log_loss([Observation(probability=1.0, correct=False)]) < float("inf")
50
+
51
+
52
+ def test_empty_input_produces_zeros_not_errors():
53
+ assert expected_calibration_error([]) == 0.0
54
+ assert brier_score([]) == 0.0
55
+ assert calibrate([]).summary() == "no labeled observations"
56
+
57
+
58
+ def test_bins_cover_the_unit_interval_and_include_one():
59
+ report = calibrate([Observation(probability=1.0, correct=True)], n_bins=10)
60
+ populated = [b for b in report.bins if b.count]
61
+ assert len(populated) == 1 and populated[0].upper == 1.0
62
+
63
+
64
+ def test_empty_bins_are_kept_so_coverage_is_visible():
65
+ report = calibrate([Observation(probability=0.95, correct=True)], n_bins=10)
66
+ assert len(report.bins) == 10
67
+ assert sum(1 for b in report.bins if b.count == 0) == 9
68
+
69
+
70
+ def test_overconfidence_is_detected():
71
+ obs = [Observation(probability=0.9, correct=i < 50) for i in range(100)]
72
+ assert calibrate(obs).overconfident
73
+
74
+
75
+ def test_diagram_renders_without_a_plotting_library():
76
+ out = calibrate(perfect(200)).diagram()
77
+ assert "claimed" in out and "actual" in out
78
+
79
+
80
+ def test_sweep_is_monotone_in_coverage():
81
+ points = sweep(perfect(500))
82
+ coverages = [p.coverage for p in points]
83
+ assert coverages == sorted(coverages, reverse=True)
84
+
85
+
86
+ def test_threshold_for_accuracy_picks_the_lowest_qualifying_one():
87
+ obs = perfect(3000)
88
+ point = recommend_for_accuracy(obs, 0.85)
89
+ assert point is not None and point.accuracy >= 0.85
90
+ lower = [p for p in sweep(obs) if p.threshold < point.threshold and p.covered]
91
+ assert all(p.accuracy < 0.85 for p in lower)
92
+
93
+
94
+ def test_unreachable_accuracy_returns_none_rather_than_a_bad_threshold():
95
+ obs = [Observation(probability=0.6, correct=False) for _ in range(100)]
96
+ assert recommend_for_accuracy(obs, 0.99) is None
97
+
98
+
99
+ def test_threshold_for_coverage_picks_the_highest_qualifying_one():
100
+ point = recommend_for_coverage(perfect(1000), 0.5)
101
+ assert point is not None and point.coverage >= 0.5
102
+
103
+
104
+ def test_accuracy_is_one_when_nothing_is_covered():
105
+ obs = [Observation(probability=0.1, correct=False)]
106
+ top = sweep(obs)[-1]
107
+ assert top.covered == 0 and top.accuracy == 1.0
108
+
109
+
110
+ def test_records_without_labels_are_skipped():
111
+ unlabeled = Record(model="m", state="s", questions={"q": {"type": "noul"}},
112
+ answers={"q": {"type": "noul", "noul": 0.9}})
113
+ assert observations_from_records([unlabeled]) == []
114
+
115
+
116
+ def test_observations_are_extracted_from_labeled_records():
117
+ labeled = Record(model="m", state="s", questions={"q": {"type": "choice"}},
118
+ answers={"q": {"type": "choice", "choice": "a",
119
+ "probabilities": {"a": 0.8, "b": 0.2}, "confidence": 0.6}},
120
+ label={"q": "a"})
121
+ (obs,) = observations_from_records([labeled])
122
+ assert obs.correct and obs.probability == pytest.approx(0.8)
123
+
124
+
125
+ def test_use_confidence_selects_the_other_quantity():
126
+ labeled = Record(model="m", state="s", questions={"q": {"type": "choice"}},
127
+ answers={"q": {"type": "choice", "choice": "a",
128
+ "probabilities": {"a": 0.8, "b": 0.2}, "confidence": 0.6}},
129
+ label={"q": "a"})
130
+ (obs,) = observations_from_records([labeled], use_confidence=True)
131
+ assert obs.probability == pytest.approx(0.6)