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.
- jevkit_calibrate-0.1.0/.gitignore +12 -0
- jevkit_calibrate-0.1.0/PKG-INFO +113 -0
- jevkit_calibrate-0.1.0/README.md +93 -0
- jevkit_calibrate-0.1.0/pyproject.toml +33 -0
- jevkit_calibrate-0.1.0/src/jevkit_calibrate/__init__.py +25 -0
- jevkit_calibrate-0.1.0/src/jevkit_calibrate/cli.py +108 -0
- jevkit_calibrate-0.1.0/src/jevkit_calibrate/metrics.py +264 -0
- jevkit_calibrate-0.1.0/src/jevkit_calibrate/records.py +59 -0
- jevkit_calibrate-0.1.0/src/jevkit_calibrate/thresholds.py +112 -0
- jevkit_calibrate-0.1.0/tests/test_calibrate.py +131 -0
|
@@ -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)
|