jevassert 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.
- jevassert/__init__.py +3 -0
- jevassert/__main__.py +3 -0
- jevassert/cli.py +529 -0
- jevassert/client.py +148 -0
- jevassert/compare.py +108 -0
- jevassert/metrics.py +526 -0
- jevassert/packs.py +454 -0
- jevassert/report.py +132 -0
- jevassert/runner.py +159 -0
- jevassert-0.1.0.dist-info/METADATA +194 -0
- jevassert-0.1.0.dist-info/RECORD +14 -0
- jevassert-0.1.0.dist-info/WHEEL +4 -0
- jevassert-0.1.0.dist-info/entry_points.txt +2 -0
- jevassert-0.1.0.dist-info/licenses/LICENSE +201 -0
jevassert/compare.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Paired comparison of two recordings (same pack): accuracy deltas + McNemar.
|
|
2
|
+
|
|
3
|
+
Typical use: compare a rubric/wording change or a model-version bump.
|
|
4
|
+
Both recordings must cover the same (case, question) items; only items present
|
|
5
|
+
and answerable on both sides are compared.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .metrics import INPUT_USD_PER_MTOK, Item, build_items
|
|
15
|
+
from .packs import Pack
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class QuestionDelta:
|
|
20
|
+
qid: str
|
|
21
|
+
n: int
|
|
22
|
+
accuracy_a: float
|
|
23
|
+
accuracy_b: float
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def delta(self) -> float:
|
|
27
|
+
return self.accuracy_b - self.accuracy_a
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CompareResult:
|
|
32
|
+
n_items: int
|
|
33
|
+
accuracy_a: float
|
|
34
|
+
accuracy_b: float
|
|
35
|
+
wins: int # A correct, B wrong
|
|
36
|
+
losses: int # A wrong, B correct
|
|
37
|
+
p_value: float
|
|
38
|
+
cost_per_case_a_usd: float | None
|
|
39
|
+
cost_per_case_b_usd: float | None
|
|
40
|
+
per_question: dict[str, QuestionDelta] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def delta(self) -> float:
|
|
44
|
+
return self.accuracy_b - self.accuracy_a
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compare(
|
|
48
|
+
pack: Pack,
|
|
49
|
+
predictions_a: dict[str, dict[str, Any]],
|
|
50
|
+
predictions_b: dict[str, dict[str, Any]],
|
|
51
|
+
) -> CompareResult:
|
|
52
|
+
items_a = _keyed(build_items(pack, predictions_a)[0])
|
|
53
|
+
items_b = _keyed(build_items(pack, predictions_b)[0])
|
|
54
|
+
shared = sorted(set(items_a) & set(items_b))
|
|
55
|
+
if not shared:
|
|
56
|
+
raise ValueError("no shared (case, question) items between the two recordings")
|
|
57
|
+
|
|
58
|
+
paired = [(items_a[key], items_b[key]) for key in shared]
|
|
59
|
+
wins = sum(1 for a, b in paired if a.correct and not b.correct)
|
|
60
|
+
losses = sum(1 for a, b in paired if not a.correct and b.correct)
|
|
61
|
+
|
|
62
|
+
per_question: dict[str, QuestionDelta] = {}
|
|
63
|
+
for qid in pack.questions:
|
|
64
|
+
q_pairs = [(a, b) for a, b in paired if a.qid == qid]
|
|
65
|
+
if not q_pairs:
|
|
66
|
+
continue
|
|
67
|
+
per_question[qid] = QuestionDelta(
|
|
68
|
+
qid=qid,
|
|
69
|
+
n=len(q_pairs),
|
|
70
|
+
accuracy_a=sum(1.0 for a, _ in q_pairs if a.correct) / len(q_pairs),
|
|
71
|
+
accuracy_b=sum(1.0 for _, b in q_pairs if b.correct) / len(q_pairs),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return CompareResult(
|
|
75
|
+
n_items=len(paired),
|
|
76
|
+
accuracy_a=sum(1.0 for a, _ in paired if a.correct) / len(paired),
|
|
77
|
+
accuracy_b=sum(1.0 for _, b in paired if b.correct) / len(paired),
|
|
78
|
+
wins=wins,
|
|
79
|
+
losses=losses,
|
|
80
|
+
p_value=mcnemar_exact(wins, losses),
|
|
81
|
+
cost_per_case_a_usd=_cost_per_case(pack, predictions_a),
|
|
82
|
+
cost_per_case_b_usd=_cost_per_case(pack, predictions_b),
|
|
83
|
+
per_question=per_question,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _keyed(items: list[Item]) -> dict[tuple[str, str], Item]:
|
|
88
|
+
return {(item.case_id, item.qid): item for item in items}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mcnemar_exact(wins: int, losses: int) -> float:
|
|
92
|
+
"""Two-sided exact McNemar test (binomial, p=0.5) — no scipy needed."""
|
|
93
|
+
discordant = wins + losses
|
|
94
|
+
if discordant == 0:
|
|
95
|
+
return 1.0
|
|
96
|
+
tail = sum(math.comb(discordant, k) for k in range(min(wins, losses) + 1))
|
|
97
|
+
return min(1.0, 2 * tail / 2**discordant)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cost_per_case(pack: Pack, predictions: dict[str, dict[str, Any]]) -> float | None:
|
|
101
|
+
tokens = 0
|
|
102
|
+
saw_usage = False
|
|
103
|
+
for case in pack.cases:
|
|
104
|
+
usage = (predictions.get(case.id) or {}).get("usage")
|
|
105
|
+
if isinstance(usage, dict) and isinstance(usage.get("input_tokens"), int | float):
|
|
106
|
+
tokens += int(usage["input_tokens"])
|
|
107
|
+
saw_usage = True
|
|
108
|
+
return (tokens * INPUT_USD_PER_MTOK / 1_000_000 / len(pack.cases)) if saw_usage else None
|
jevassert/metrics.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Metrics for recorded predictions: accuracy, calibration, coverage, gates.
|
|
2
|
+
|
|
3
|
+
Definitions used throughout:
|
|
4
|
+
|
|
5
|
+
- ``decision`` — what code would act on: ``choice`` field, ``noul >= 0.5``,
|
|
6
|
+
or the rounded ``score``.
|
|
7
|
+
- ``decision_prob`` — the probability the model assigned to the decision it
|
|
8
|
+
made (for Noul, ``max(p, 1 - p)``). This is what coverage/ECE are computed on.
|
|
9
|
+
- ECE — expected calibration error over equal-mass bins of ``decision_prob``
|
|
10
|
+
against decision correctness. Brier is reported for Noul questions only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import random
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .packs import Pack
|
|
22
|
+
|
|
23
|
+
INPUT_USD_PER_MTOK = 0.042 # TypeSafe early-access price; output tokens are free.
|
|
24
|
+
COVERAGE_THRESHOLDS = (0.5, 0.6, 0.7, 0.8, 0.9, 0.95)
|
|
25
|
+
SMALL_N_ITEMS = 50 # below this, report that CI/ECE are coarse
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Item:
|
|
30
|
+
case_id: str
|
|
31
|
+
qid: str
|
|
32
|
+
qtype: str
|
|
33
|
+
expected: Any
|
|
34
|
+
got: Any
|
|
35
|
+
correct: bool
|
|
36
|
+
decision_prob: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class QuestionMetrics:
|
|
41
|
+
qid: str
|
|
42
|
+
qtype: str
|
|
43
|
+
n: int
|
|
44
|
+
missing: int
|
|
45
|
+
accuracy: float
|
|
46
|
+
mean_decision_prob: float
|
|
47
|
+
ece: float
|
|
48
|
+
brier: float | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CoverageRow:
|
|
53
|
+
threshold: float
|
|
54
|
+
coverage: float
|
|
55
|
+
precision: float | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ThresholdCoverage:
|
|
60
|
+
"""Coverage when auto-accepting with the pack's own per-label thresholds."""
|
|
61
|
+
|
|
62
|
+
n_total: int
|
|
63
|
+
n_auto: int
|
|
64
|
+
coverage: float
|
|
65
|
+
precision: float | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ThresholdSuggestion:
|
|
70
|
+
"""Highest-coverage threshold that still reaches a target precision."""
|
|
71
|
+
|
|
72
|
+
threshold: float
|
|
73
|
+
coverage: float
|
|
74
|
+
precision: float
|
|
75
|
+
n_accepted: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class OverallMetrics:
|
|
80
|
+
n_cases: int
|
|
81
|
+
n_items: int
|
|
82
|
+
case_errors: int
|
|
83
|
+
missing_items: int
|
|
84
|
+
accuracy: float
|
|
85
|
+
ece: float
|
|
86
|
+
mean_decision_prob: float
|
|
87
|
+
total_cost_usd: float | None
|
|
88
|
+
cost_per_case_usd: float | None
|
|
89
|
+
p50_latency_ms: float | None
|
|
90
|
+
p95_latency_ms: float | None
|
|
91
|
+
per_question: dict[str, QuestionMetrics] = field(default_factory=dict)
|
|
92
|
+
coverage: tuple[CoverageRow, ...] = ()
|
|
93
|
+
threshold_coverage: ThresholdCoverage | None = None
|
|
94
|
+
accuracy_ci: tuple[float, float] | None = None
|
|
95
|
+
ece_ci: tuple[float, float] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class GateResult:
|
|
100
|
+
gate: str
|
|
101
|
+
ok: bool | None # None = not computable from this recording
|
|
102
|
+
detail: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_items(pack: Pack, predictions: dict[str, dict[str, Any]]) -> tuple[list[Item], int, int]:
|
|
106
|
+
"""Turn predictions into comparable items.
|
|
107
|
+
|
|
108
|
+
Returns (items, case_errors, missing_items). Cases with an error record or
|
|
109
|
+
without answers count once as a case error; their expected questions count
|
|
110
|
+
as missing items.
|
|
111
|
+
"""
|
|
112
|
+
items: list[Item] = []
|
|
113
|
+
case_errors = 0
|
|
114
|
+
missing = 0
|
|
115
|
+
|
|
116
|
+
for case in pack.cases:
|
|
117
|
+
record = predictions.get(case.id)
|
|
118
|
+
if record is None or record.get("error") or record.get("answers") is None:
|
|
119
|
+
case_errors += 1
|
|
120
|
+
missing += len(case.expect)
|
|
121
|
+
continue
|
|
122
|
+
answers = record["answers"]
|
|
123
|
+
for qid, expected in case.expect.items():
|
|
124
|
+
answer = answers.get(qid)
|
|
125
|
+
if not isinstance(answer, dict):
|
|
126
|
+
missing += 1
|
|
127
|
+
continue
|
|
128
|
+
item = _item_for(case.id, qid, pack.questions[qid], expected, answer)
|
|
129
|
+
if item is not None:
|
|
130
|
+
items.append(item)
|
|
131
|
+
else:
|
|
132
|
+
missing += 1
|
|
133
|
+
return items, case_errors, missing
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _item_for(
|
|
137
|
+
case_id: str, qid: str, question: dict[str, Any], expected: Any, answer: dict[str, Any]
|
|
138
|
+
) -> Item | None:
|
|
139
|
+
qtype = question["type"]
|
|
140
|
+
if qtype == "noul":
|
|
141
|
+
if "noul" not in answer:
|
|
142
|
+
return None
|
|
143
|
+
probability = float(answer["noul"])
|
|
144
|
+
decision = probability >= 0.5
|
|
145
|
+
return Item(
|
|
146
|
+
case_id=case_id,
|
|
147
|
+
qid=qid,
|
|
148
|
+
qtype=qtype,
|
|
149
|
+
expected=expected,
|
|
150
|
+
got=decision,
|
|
151
|
+
correct=decision == expected,
|
|
152
|
+
decision_prob=max(probability, 1.0 - probability),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if qtype == "choice":
|
|
156
|
+
probabilities = _float_map(answer.get("probabilities"))
|
|
157
|
+
if not probabilities:
|
|
158
|
+
return None
|
|
159
|
+
decision = answer.get("choice") or max(probabilities, key=probabilities.get)
|
|
160
|
+
return Item(
|
|
161
|
+
case_id=case_id,
|
|
162
|
+
qid=qid,
|
|
163
|
+
qtype=qtype,
|
|
164
|
+
expected=expected,
|
|
165
|
+
got=decision,
|
|
166
|
+
correct=decision == expected,
|
|
167
|
+
decision_prob=probabilities.get(decision, max(probabilities.values())),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if qtype == "score":
|
|
171
|
+
probabilities = _float_map(answer.get("probabilities"))
|
|
172
|
+
if not probabilities:
|
|
173
|
+
return None
|
|
174
|
+
levels = list(question["levels"])
|
|
175
|
+
best_key = max(probabilities, key=probabilities.get)
|
|
176
|
+
try:
|
|
177
|
+
decision = levels[int(best_key)]
|
|
178
|
+
except (ValueError, IndexError):
|
|
179
|
+
return None
|
|
180
|
+
return Item(
|
|
181
|
+
case_id=case_id,
|
|
182
|
+
qid=qid,
|
|
183
|
+
qtype=qtype,
|
|
184
|
+
expected=expected,
|
|
185
|
+
got=decision,
|
|
186
|
+
correct=decision == expected,
|
|
187
|
+
decision_prob=probabilities[best_key],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return None # pragma: no cover - loader rejects unknown types
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _float_map(raw: Any) -> dict[str, float]:
|
|
194
|
+
if not isinstance(raw, dict):
|
|
195
|
+
return {}
|
|
196
|
+
parsed: dict[str, float] = {}
|
|
197
|
+
for key, value in raw.items():
|
|
198
|
+
try:
|
|
199
|
+
parsed[str(key)] = float(value)
|
|
200
|
+
except (TypeError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
return parsed
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def compute(
|
|
206
|
+
pack: Pack,
|
|
207
|
+
predictions: dict[str, dict[str, Any]],
|
|
208
|
+
bootstrap: int = 1000,
|
|
209
|
+
seed: int = 0,
|
|
210
|
+
) -> OverallMetrics:
|
|
211
|
+
items, case_errors, missing_items = build_items(pack, predictions)
|
|
212
|
+
|
|
213
|
+
per_question: dict[str, QuestionMetrics] = {}
|
|
214
|
+
for qid in pack.questions:
|
|
215
|
+
subset = [item for item in items if item.qid == qid]
|
|
216
|
+
expected_count = sum(1 for case in pack.cases if qid in case.expect)
|
|
217
|
+
q_missing = expected_count - len(subset)
|
|
218
|
+
if not subset:
|
|
219
|
+
per_question[qid] = QuestionMetrics(
|
|
220
|
+
qid=qid,
|
|
221
|
+
qtype=pack.questions[qid]["type"],
|
|
222
|
+
n=0,
|
|
223
|
+
missing=q_missing,
|
|
224
|
+
accuracy=0.0,
|
|
225
|
+
mean_decision_prob=0.0,
|
|
226
|
+
ece=0.0,
|
|
227
|
+
brier=None,
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
corrects = [item.correct for item in subset]
|
|
231
|
+
probs = [item.decision_prob for item in subset]
|
|
232
|
+
brier = None
|
|
233
|
+
if pack.questions[qid]["type"] == "noul":
|
|
234
|
+
brier = sum(
|
|
235
|
+
(_noul_probability(item) - float(item.expected)) ** 2 for item in subset
|
|
236
|
+
) / len(subset)
|
|
237
|
+
per_question[qid] = QuestionMetrics(
|
|
238
|
+
qid=qid,
|
|
239
|
+
qtype=pack.questions[qid]["type"],
|
|
240
|
+
n=len(subset),
|
|
241
|
+
missing=q_missing,
|
|
242
|
+
accuracy=_mean(corrects),
|
|
243
|
+
mean_decision_prob=_mean(probs),
|
|
244
|
+
ece=expected_calibration_error(probs, corrects),
|
|
245
|
+
brier=brier,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
all_probs = [item.decision_prob for item in items]
|
|
249
|
+
all_correct = [item.correct for item in items]
|
|
250
|
+
|
|
251
|
+
total_input_tokens = 0
|
|
252
|
+
saw_usage = False
|
|
253
|
+
latencies: list[float] = []
|
|
254
|
+
for case in pack.cases:
|
|
255
|
+
record = predictions.get(case.id) or {}
|
|
256
|
+
usage = record.get("usage")
|
|
257
|
+
if isinstance(usage, dict) and isinstance(usage.get("input_tokens"), int | float):
|
|
258
|
+
total_input_tokens += int(usage["input_tokens"])
|
|
259
|
+
saw_usage = True
|
|
260
|
+
if isinstance(record.get("latency_ms"), int | float):
|
|
261
|
+
latencies.append(float(record["latency_ms"]))
|
|
262
|
+
|
|
263
|
+
total_cost = (total_input_tokens * INPUT_USD_PER_MTOK / 1_000_000) if saw_usage else None
|
|
264
|
+
cost_per_case = (total_cost / len(pack.cases)) if total_cost is not None else None
|
|
265
|
+
|
|
266
|
+
return OverallMetrics(
|
|
267
|
+
n_cases=len(pack.cases),
|
|
268
|
+
n_items=len(items),
|
|
269
|
+
case_errors=case_errors,
|
|
270
|
+
missing_items=missing_items,
|
|
271
|
+
accuracy=_mean(all_correct),
|
|
272
|
+
ece=expected_calibration_error(all_probs, all_correct),
|
|
273
|
+
mean_decision_prob=_mean(all_probs),
|
|
274
|
+
total_cost_usd=total_cost,
|
|
275
|
+
cost_per_case_usd=cost_per_case,
|
|
276
|
+
p50_latency_ms=percentile(latencies, 50),
|
|
277
|
+
p95_latency_ms=percentile(latencies, 95),
|
|
278
|
+
per_question=per_question,
|
|
279
|
+
coverage=coverage_rows(items),
|
|
280
|
+
threshold_coverage=_threshold_coverage(pack, items) if pack.thresholds else None,
|
|
281
|
+
accuracy_ci=bootstrap_interval(all_correct, _mean, bootstrap, seed),
|
|
282
|
+
ece_ci=bootstrap_interval(
|
|
283
|
+
list(zip(all_probs, all_correct, strict=True)),
|
|
284
|
+
lambda sample: expected_calibration_error(
|
|
285
|
+
[pair[0] for pair in sample], [pair[1] for pair in sample]
|
|
286
|
+
),
|
|
287
|
+
bootstrap,
|
|
288
|
+
seed,
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _threshold_coverage(pack: Pack, items: list[Item]) -> ThresholdCoverage:
|
|
294
|
+
"""Auto-accept coverage under the pack's `thresholds` (SPEC semantics).
|
|
295
|
+
|
|
296
|
+
An item is auto-accepted when the label the model answered has a floor in
|
|
297
|
+
the pack thresholds and the decision probability clears it. Labels without
|
|
298
|
+
a floor (typically `unknown`) always route to review.
|
|
299
|
+
"""
|
|
300
|
+
accepted: list[Item] = []
|
|
301
|
+
for item in items:
|
|
302
|
+
if item.qtype == "noul":
|
|
303
|
+
label = "true" if item.got else "false"
|
|
304
|
+
else:
|
|
305
|
+
label = str(item.got)
|
|
306
|
+
floor = pack.thresholds.get(item.qid, {}).get(label)
|
|
307
|
+
if floor is not None and item.decision_prob >= floor:
|
|
308
|
+
accepted.append(item)
|
|
309
|
+
return ThresholdCoverage(
|
|
310
|
+
n_total=len(items),
|
|
311
|
+
n_auto=len(accepted),
|
|
312
|
+
coverage=len(accepted) / len(items) if items else 0.0,
|
|
313
|
+
precision=_mean([item.correct for item in accepted]) if accepted else None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _noul_probability(item: Item) -> float:
|
|
318
|
+
return item.decision_prob if item.got else 1.0 - item.decision_prob
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _mean(values: list[Any]) -> float:
|
|
322
|
+
return sum(float(v) for v in values) / len(values) if values else 0.0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def expected_calibration_error(
|
|
326
|
+
probabilities: list[float], corrects: list[bool], bins: int = 10
|
|
327
|
+
) -> float:
|
|
328
|
+
"""ECE over equal-mass bins of decision probability vs correctness."""
|
|
329
|
+
if not probabilities:
|
|
330
|
+
return 0.0
|
|
331
|
+
pairs = sorted(zip(probabilities, corrects, strict=True))
|
|
332
|
+
n = len(pairs)
|
|
333
|
+
k = min(bins, n)
|
|
334
|
+
total = 0.0
|
|
335
|
+
for index in range(k):
|
|
336
|
+
chunk = pairs[index * n // k : (index + 1) * n // k]
|
|
337
|
+
if not chunk:
|
|
338
|
+
continue
|
|
339
|
+
mean_p = sum(p for p, _ in chunk) / len(chunk)
|
|
340
|
+
mean_c = sum(1.0 for _, correct in chunk if correct) / len(chunk)
|
|
341
|
+
total += len(chunk) / n * abs(mean_p - mean_c)
|
|
342
|
+
return total
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def percentile(values: list[float], pct: float) -> float | None:
|
|
346
|
+
"""Nearest-rank percentile; None when there are no values."""
|
|
347
|
+
if not values:
|
|
348
|
+
return None
|
|
349
|
+
ordered = sorted(values)
|
|
350
|
+
rank = max(1, math.ceil(pct / 100 * len(ordered)))
|
|
351
|
+
return ordered[rank - 1]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def bootstrap_interval(
|
|
355
|
+
values: list[Any],
|
|
356
|
+
statistic: Callable[[list[Any]], float],
|
|
357
|
+
n_resamples: int,
|
|
358
|
+
seed: int,
|
|
359
|
+
confidence: float = 0.95,
|
|
360
|
+
) -> tuple[float, float] | None:
|
|
361
|
+
"""Percentile bootstrap CI for a statistic; None when disabled or no data."""
|
|
362
|
+
if not values or n_resamples <= 0:
|
|
363
|
+
return None
|
|
364
|
+
rng = random.Random(seed)
|
|
365
|
+
n = len(values)
|
|
366
|
+
stats = sorted(
|
|
367
|
+
statistic([values[rng.randrange(n)] for _ in range(n)]) for _ in range(n_resamples)
|
|
368
|
+
)
|
|
369
|
+
low_index = max(0, int((1 - confidence) / 2 * n_resamples))
|
|
370
|
+
high_index = min(n_resamples - 1, int((1 + confidence) / 2 * n_resamples))
|
|
371
|
+
return (stats[low_index], stats[high_index])
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def suggest_threshold(
|
|
375
|
+
items: list[Item], target_precision: float, min_accepted: int = 10
|
|
376
|
+
) -> ThresholdSuggestion | None:
|
|
377
|
+
"""Highest-coverage probability cut that still reaches the target precision.
|
|
378
|
+
|
|
379
|
+
``min_accepted`` keeps a handful of lucky items from suggesting a cut that
|
|
380
|
+
no real workload could use.
|
|
381
|
+
"""
|
|
382
|
+
if not items:
|
|
383
|
+
return None
|
|
384
|
+
best: ThresholdSuggestion | None = None
|
|
385
|
+
for step in range(50, 100):
|
|
386
|
+
threshold = step / 100
|
|
387
|
+
accepted = [item for item in items if item.decision_prob >= threshold]
|
|
388
|
+
if len(accepted) < min_accepted:
|
|
389
|
+
continue
|
|
390
|
+
precision = _mean([item.correct for item in accepted])
|
|
391
|
+
if precision < target_precision:
|
|
392
|
+
continue
|
|
393
|
+
coverage = len(accepted) / len(items)
|
|
394
|
+
if best is None or coverage > best.coverage:
|
|
395
|
+
best = ThresholdSuggestion(
|
|
396
|
+
threshold=threshold,
|
|
397
|
+
coverage=coverage,
|
|
398
|
+
precision=precision,
|
|
399
|
+
n_accepted=len(accepted),
|
|
400
|
+
)
|
|
401
|
+
return best
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def coverage_rows(items: list[Item]) -> tuple[CoverageRow, ...]:
|
|
405
|
+
rows = []
|
|
406
|
+
for threshold in COVERAGE_THRESHOLDS:
|
|
407
|
+
accepted = [item for item in items if item.decision_prob >= threshold]
|
|
408
|
+
precision = _mean([item.correct for item in accepted]) if accepted else None
|
|
409
|
+
rows.append(
|
|
410
|
+
CoverageRow(
|
|
411
|
+
threshold=threshold,
|
|
412
|
+
coverage=len(accepted) / len(items) if items else 0.0,
|
|
413
|
+
precision=precision,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
return tuple(rows)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def evaluate_gates(pack: Pack, overall: OverallMetrics) -> list[GateResult]:
|
|
420
|
+
results: list[GateResult] = []
|
|
421
|
+
gates = pack.gates
|
|
422
|
+
|
|
423
|
+
if "min_accuracy" in gates:
|
|
424
|
+
value = float(gates["min_accuracy"])
|
|
425
|
+
results.append(
|
|
426
|
+
GateResult(
|
|
427
|
+
"min_accuracy",
|
|
428
|
+
overall.accuracy >= value,
|
|
429
|
+
f"accuracy {overall.accuracy:.3f} >= {value:.3f}",
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
if "max_ece" in gates:
|
|
433
|
+
value = float(gates["max_ece"])
|
|
434
|
+
results.append(
|
|
435
|
+
GateResult("max_ece", overall.ece <= value, f"ece {overall.ece:.3f} <= {value:.3f}")
|
|
436
|
+
)
|
|
437
|
+
if "max_cost_per_case_usd" in gates:
|
|
438
|
+
value = float(gates["max_cost_per_case_usd"])
|
|
439
|
+
if overall.cost_per_case_usd is None:
|
|
440
|
+
results.append(GateResult("max_cost_per_case_usd", None, "no usage data in recording"))
|
|
441
|
+
else:
|
|
442
|
+
results.append(
|
|
443
|
+
GateResult(
|
|
444
|
+
"max_cost_per_case_usd",
|
|
445
|
+
overall.cost_per_case_usd <= value,
|
|
446
|
+
f"cost/case ${overall.cost_per_case_usd:.6f} <= ${value:.6f}",
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
if "max_p95_latency_ms" in gates:
|
|
450
|
+
value = float(gates["max_p95_latency_ms"])
|
|
451
|
+
if overall.p95_latency_ms is None:
|
|
452
|
+
results.append(GateResult("max_p95_latency_ms", None, "no latency data in recording"))
|
|
453
|
+
else:
|
|
454
|
+
results.append(
|
|
455
|
+
GateResult(
|
|
456
|
+
"max_p95_latency_ms",
|
|
457
|
+
overall.p95_latency_ms <= value,
|
|
458
|
+
f"p95 {overall.p95_latency_ms:.0f}ms <= {value:.0f}ms",
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
if "min_coverage_at_precision" in gates:
|
|
462
|
+
spec = gates["min_coverage_at_precision"]
|
|
463
|
+
required_precision = float(spec["precision"])
|
|
464
|
+
min_coverage = float(spec["min_coverage"])
|
|
465
|
+
best = None
|
|
466
|
+
for row in overall.coverage:
|
|
467
|
+
if row.precision is not None and row.precision >= required_precision:
|
|
468
|
+
best = row
|
|
469
|
+
if best is None:
|
|
470
|
+
results.append(
|
|
471
|
+
GateResult(
|
|
472
|
+
"min_coverage_at_precision",
|
|
473
|
+
False,
|
|
474
|
+
f"no threshold on the grid reaches precision >= {required_precision:.2f}",
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
results.append(
|
|
479
|
+
GateResult(
|
|
480
|
+
"min_coverage_at_precision",
|
|
481
|
+
best.coverage >= min_coverage,
|
|
482
|
+
f"coverage {best.coverage:.2f} at precision >= {required_precision:.2f} "
|
|
483
|
+
f"(threshold {best.threshold:.2f}) >= {min_coverage:.2f}",
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
if "min_accuracy_ci_lower" in gates:
|
|
487
|
+
value = float(gates["min_accuracy_ci_lower"])
|
|
488
|
+
if overall.accuracy_ci is None:
|
|
489
|
+
results.append(
|
|
490
|
+
GateResult("min_accuracy_ci_lower", None, "bootstrap disabled (--bootstrap 0)")
|
|
491
|
+
)
|
|
492
|
+
else:
|
|
493
|
+
low = overall.accuracy_ci[0]
|
|
494
|
+
results.append(
|
|
495
|
+
GateResult(
|
|
496
|
+
"min_accuracy_ci_lower",
|
|
497
|
+
low >= value,
|
|
498
|
+
f"accuracy CI lower {low:.3f} >= {value:.3f}",
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
for qid, per_question_gates in (gates.get("per_question") or {}).items():
|
|
502
|
+
metrics = overall.per_question.get(qid)
|
|
503
|
+
where = f"per_question.{qid}"
|
|
504
|
+
if metrics is None or metrics.n == 0:
|
|
505
|
+
for gate_name in per_question_gates:
|
|
506
|
+
results.append(GateResult(f"{where}.{gate_name}", None, "no items in recording"))
|
|
507
|
+
continue
|
|
508
|
+
if "min_accuracy" in per_question_gates:
|
|
509
|
+
value = float(per_question_gates["min_accuracy"])
|
|
510
|
+
results.append(
|
|
511
|
+
GateResult(
|
|
512
|
+
f"{where}.min_accuracy",
|
|
513
|
+
metrics.accuracy >= value,
|
|
514
|
+
f"accuracy {metrics.accuracy:.3f} >= {value:.3f}",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
if "max_ece" in per_question_gates:
|
|
518
|
+
value = float(per_question_gates["max_ece"])
|
|
519
|
+
results.append(
|
|
520
|
+
GateResult(
|
|
521
|
+
f"{where}.max_ece",
|
|
522
|
+
metrics.ece <= value,
|
|
523
|
+
f"ece {metrics.ece:.3f} <= {value:.3f}",
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
return results
|