proofstep-core 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.
@@ -0,0 +1,434 @@
1
+ """Corpus-level statistical evaluators.
2
+
3
+ These implement `CorpusEvaluator`, not `Evaluator`, because they are **not means of
4
+ per-example scores**. F1 needs the full confusion matrix; NDCG needs the whole
5
+ ranking; ECE needs the full distribution of confidences. Computing "the mean of
6
+ per-example F1" is a real and common statistical error that produces a number which
7
+ looks plausible and is wrong (docs/EVALUATION_ENGINE.md §1).
8
+
9
+ Everything here is ground-truth arithmetic. No judge is involved, and none should be:
10
+ a classifier's accuracy against human labels is measurable, and asking a model to
11
+ opine on it would be slower, costlier, and less correct.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from collections import defaultdict
18
+ from collections.abc import Sequence
19
+ from typing import Any, Literal
20
+
21
+ from proofstep_core.paths import try_resolve
22
+ from proofstep_core.stats import mean
23
+ from proofstep_types import ExampleResult, Metric
24
+
25
+ Averaging = Literal["macro", "micro", "weighted"]
26
+
27
+
28
+ class ClassificationEvaluator:
29
+ """Accuracy, precision, recall, F1 and per-class breakdowns.
30
+
31
+ Always emits per-class recall as sliced metrics, whether or not anyone gated on
32
+ it. That is deliberate: a rare-class collapse should at minimum be *visible* in
33
+ the report even when the suite author did not think to protect it.
34
+ """
35
+
36
+ version = 1
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ prediction_field: str = "intent",
42
+ label_field: str | None = None,
43
+ averaging: Averaging = "macro",
44
+ name: str = "classification",
45
+ labels: Sequence[str] | None = None,
46
+ ) -> None:
47
+ self.name = name
48
+ self.prediction_field = prediction_field
49
+ self.label_field = label_field or prediction_field
50
+ self.averaging = averaging
51
+ self.labels = list(labels) if labels else None
52
+
53
+ def evaluate_corpus(self, results: Sequence[ExampleResult]) -> list[Metric]:
54
+ pairs = self._pairs(results)
55
+ if not pairs:
56
+ return [Metric(key=f"{self.name}_accuracy", value=0.0, count=0)]
57
+
58
+ classes = self.labels or sorted({label for _, label in pairs} | {p for p, _ in pairs})
59
+ matrix = confusion_matrix(pairs, classes)
60
+ support = {c: sum(1 for _, label in pairs if label == c) for c in classes}
61
+
62
+ correct = sum(1 for pred, label in pairs if pred == label)
63
+ metrics: list[Metric] = [
64
+ Metric(
65
+ key=f"{self.name}_accuracy",
66
+ value=correct / len(pairs),
67
+ count=len(pairs),
68
+ aggregation="accuracy",
69
+ )
70
+ ]
71
+
72
+ per_class: dict[str, tuple[float, float, float]] = {}
73
+ for cls in classes:
74
+ tp = matrix[cls][cls]
75
+ fp = sum(matrix[other][cls] for other in classes if other != cls)
76
+ fn = sum(matrix[cls][other] for other in classes if other != cls)
77
+ precision = tp / (tp + fp) if tp + fp else 0.0
78
+ recall = tp / (tp + fn) if tp + fn else 0.0
79
+ f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
80
+ per_class[cls] = (precision, recall, f1)
81
+
82
+ metrics += [
83
+ Metric(
84
+ key=f"{self.name}_precision",
85
+ value=precision,
86
+ count=support[cls],
87
+ slice={"class": cls},
88
+ ),
89
+ Metric(
90
+ key=f"{self.name}_recall",
91
+ value=recall,
92
+ count=support[cls],
93
+ slice={"class": cls},
94
+ ),
95
+ Metric(key=f"{self.name}_f1", value=f1, count=support[cls], slice={"class": cls}),
96
+ ]
97
+
98
+ metrics += self._averaged(per_class, support, pairs, matrix, classes)
99
+ metrics.append(
100
+ Metric(
101
+ key=f"{self.name}_confusion_matrix",
102
+ value=0.0,
103
+ count=len(pairs),
104
+ aggregation="matrix",
105
+ )
106
+ )
107
+ return metrics
108
+
109
+ def _averaged(
110
+ self,
111
+ per_class: dict[str, tuple[float, float, float]],
112
+ support: dict[str, int],
113
+ pairs: list[tuple[str, str]],
114
+ matrix: dict[str, dict[str, int]],
115
+ classes: Sequence[str],
116
+ ) -> list[Metric]:
117
+ n = len(pairs)
118
+ if self.averaging == "macro":
119
+ # Unweighted mean over classes. This is the average that *notices* a rare
120
+ # class, which is why it is the default for classification suites.
121
+ return [
122
+ Metric(
123
+ key=f"{self.name}_macro_f1",
124
+ value=mean([f for _, _, f in per_class.values()]),
125
+ count=n,
126
+ ),
127
+ Metric(
128
+ key=f"{self.name}_macro_recall",
129
+ value=mean([r for _, r, _ in per_class.values()]),
130
+ count=n,
131
+ ),
132
+ ]
133
+ if self.averaging == "weighted":
134
+ total = sum(support.values()) or 1
135
+ return [
136
+ Metric(
137
+ key=f"{self.name}_weighted_f1",
138
+ value=sum(per_class[c][2] * support[c] for c in classes) / total,
139
+ count=n,
140
+ )
141
+ ]
142
+ # Micro-averaged F1 over a single-label problem equals accuracy; report it
143
+ # under its own name so nobody mistakes it for a second, independent signal.
144
+ tp = sum(matrix[c][c] for c in classes)
145
+ return [Metric(key=f"{self.name}_micro_f1", value=tp / n, count=n)]
146
+
147
+ def _pairs(self, results: Sequence[ExampleResult]) -> list[tuple[str, str]]:
148
+ pairs: list[tuple[str, str]] = []
149
+ for result in results:
150
+ if not result.ok or result.expected is None:
151
+ continue
152
+ predicted = try_resolve(result.output, self.prediction_field)
153
+ actual = result.expected.get(self.label_field)
154
+ if predicted is None or actual is None:
155
+ continue
156
+ pairs.append((str(predicted), str(actual)))
157
+ return pairs
158
+
159
+
160
+ def confusion_matrix(
161
+ pairs: Sequence[tuple[str, str]], classes: Sequence[str]
162
+ ) -> dict[str, dict[str, int]]:
163
+ """``matrix[actual][predicted]`` counts."""
164
+ matrix: dict[str, dict[str, int]] = {a: dict.fromkeys(classes, 0) for a in classes}
165
+ for predicted, actual in pairs:
166
+ if actual in matrix and predicted in matrix[actual]:
167
+ matrix[actual][predicted] += 1
168
+ return matrix
169
+
170
+
171
+ class RankingEvaluator:
172
+ """Precision@K, Recall@K, NDCG@K, MRR and MAP against a relevant-item set."""
173
+
174
+ version = 1
175
+
176
+ def __init__(
177
+ self,
178
+ *,
179
+ k: int = 10,
180
+ ranking_field: str = "results",
181
+ relevant_field: str = "relevant",
182
+ id_key: str | None = None,
183
+ name: str = "ranking",
184
+ ) -> None:
185
+ self.name = name
186
+ self.k = k
187
+ self.ranking_field = ranking_field
188
+ self.relevant_field = relevant_field
189
+ self.id_key = id_key
190
+
191
+ def evaluate_corpus(self, results: Sequence[ExampleResult]) -> list[Metric]:
192
+ precisions: list[float] = []
193
+ recalls: list[float] = []
194
+ ndcgs: list[float] = []
195
+ rrs: list[float] = []
196
+ aps: list[float] = []
197
+
198
+ for result in results:
199
+ if not result.ok or result.expected is None:
200
+ continue
201
+ ranked = self._ids(try_resolve(result.output, self.ranking_field) or [])
202
+ relevant = set(self._ids(result.expected.get(self.relevant_field) or []))
203
+ if not ranked:
204
+ continue
205
+
206
+ top = ranked[: self.k]
207
+ hits = sum(1 for item in top if item in relevant)
208
+ precisions.append(hits / len(top))
209
+ recalls.append(hits / len(relevant) if relevant else 0.0)
210
+ ndcgs.append(ndcg_at_k(ranked, relevant, self.k))
211
+ rrs.append(reciprocal_rank(ranked, relevant))
212
+ aps.append(average_precision(ranked, relevant, self.k))
213
+
214
+ if not precisions:
215
+ return [Metric(key=f"{self.name}_precision_at_{self.k}", value=0.0, count=0)]
216
+
217
+ n = len(precisions)
218
+ return [
219
+ Metric(key=f"{self.name}_precision_at_{self.k}", value=mean(precisions), count=n),
220
+ Metric(key=f"{self.name}_recall_at_{self.k}", value=mean(recalls), count=n),
221
+ Metric(key=f"{self.name}_ndcg_at_{self.k}", value=mean(ndcgs), count=n),
222
+ Metric(key=f"{self.name}_mrr", value=mean(rrs), count=n),
223
+ Metric(key=f"{self.name}_map", value=mean(aps), count=n),
224
+ ]
225
+
226
+ def _ids(self, items: Any) -> list[str]:
227
+ out: list[str] = []
228
+ for item in items:
229
+ if self.id_key and isinstance(item, dict):
230
+ out.append(str(item.get(self.id_key)))
231
+ elif isinstance(item, dict):
232
+ out.append(str(item.get("id", item)))
233
+ else:
234
+ out.append(str(item))
235
+ return out
236
+
237
+
238
+ def dcg_at_k(ranked: Sequence[str], relevant: set[str], k: int) -> float:
239
+ """Binary-gain DCG with log2(rank + 1) discount."""
240
+ return math.fsum(
241
+ 1.0 / math.log2(rank + 2) for rank, item in enumerate(ranked[:k]) if item in relevant
242
+ )
243
+
244
+
245
+ def ndcg_at_k(ranked: Sequence[str], relevant: set[str], k: int) -> float:
246
+ if not relevant:
247
+ return 0.0
248
+ ideal = math.fsum(1.0 / math.log2(rank + 2) for rank in range(min(len(relevant), k)))
249
+ return dcg_at_k(ranked, relevant, k) / ideal if ideal else 0.0
250
+
251
+
252
+ def reciprocal_rank(ranked: Sequence[str], relevant: set[str]) -> float:
253
+ for rank, item in enumerate(ranked, start=1):
254
+ if item in relevant:
255
+ return 1.0 / rank
256
+ return 0.0
257
+
258
+
259
+ def average_precision(ranked: Sequence[str], relevant: set[str], k: int) -> float:
260
+ if not relevant:
261
+ return 0.0
262
+ hits = 0
263
+ total = 0.0
264
+ for rank, item in enumerate(ranked[:k], start=1):
265
+ if item in relevant:
266
+ hits += 1
267
+ total += hits / rank
268
+ return total / min(len(relevant), k)
269
+
270
+
271
+ class CalibrationEvaluator:
272
+ """Expected calibration error and Brier score over predicted confidences.
273
+
274
+ Calibration is why a confidence field is worth having at all: a model that says
275
+ 0.9 should be right about 90% of the time, and one that is confidently wrong is
276
+ more dangerous than one that is uncertain.
277
+ """
278
+
279
+ version = 1
280
+
281
+ def __init__(
282
+ self,
283
+ *,
284
+ confidence_field: str = "confidence",
285
+ correct_field: str | None = None,
286
+ prediction_field: str = "intent",
287
+ label_field: str = "intent",
288
+ bins: int = 10,
289
+ name: str = "calibration",
290
+ ) -> None:
291
+ self.name = name
292
+ self.confidence_field = confidence_field
293
+ self.correct_field = correct_field
294
+ self.prediction_field = prediction_field
295
+ self.label_field = label_field
296
+ self.bins = bins
297
+
298
+ def evaluate_corpus(self, results: Sequence[ExampleResult]) -> list[Metric]:
299
+ points: list[tuple[float, bool]] = []
300
+ for result in results:
301
+ if not result.ok or result.expected is None:
302
+ continue
303
+ confidence = try_resolve(result.output, self.confidence_field)
304
+ if not isinstance(confidence, int | float) or isinstance(confidence, bool):
305
+ continue
306
+ if self.correct_field:
307
+ correct = bool(try_resolve(result.output, self.correct_field))
308
+ else:
309
+ predicted = try_resolve(result.output, self.prediction_field)
310
+ correct = str(predicted) == str(result.expected.get(self.label_field))
311
+ points.append((float(confidence), correct))
312
+
313
+ if not points:
314
+ return [Metric(key=f"{self.name}_ece", value=0.0, count=0)]
315
+
316
+ return [
317
+ Metric(
318
+ key=f"{self.name}_ece",
319
+ value=expected_calibration_error(points, self.bins),
320
+ count=len(points),
321
+ ),
322
+ Metric(key=f"{self.name}_brier", value=brier_score(points), count=len(points)),
323
+ ]
324
+
325
+
326
+ def expected_calibration_error(points: Sequence[tuple[float, bool]], bins: int = 10) -> float:
327
+ """Weighted mean gap between confidence and accuracy across equal-width bins."""
328
+ if not points:
329
+ return 0.0
330
+ buckets: dict[int, list[tuple[float, bool]]] = defaultdict(list)
331
+ for confidence, correct in points:
332
+ index = min(bins - 1, max(0, int(confidence * bins)))
333
+ buckets[index].append((confidence, correct))
334
+
335
+ total = len(points)
336
+ return math.fsum(
337
+ (len(bucket) / total)
338
+ * abs(
339
+ mean([1.0 if correct else 0.0 for _, correct in bucket]) - mean([c for c, _ in bucket])
340
+ )
341
+ for bucket in buckets.values()
342
+ )
343
+
344
+
345
+ def brier_score(points: Sequence[tuple[float, bool]]) -> float:
346
+ if not points:
347
+ return 0.0
348
+ return mean([(confidence - (1.0 if correct else 0.0)) ** 2 for confidence, correct in points])
349
+
350
+
351
+ class DiscriminationEvaluator:
352
+ """ROC-AUC over a predicted score against a binary outcome.
353
+
354
+ Exists because "does this model rank the right cases higher?" is an ordinary supervised
355
+ learning question with an ordinary answer, and it is the place teams most often reach for
356
+ an LLM judge when they should be doing ordinary ML evaluation. Asking a model whether a
357
+ mastery prediction was good is strictly worse than measuring it against the next answer
358
+ the learner actually gave.
359
+
360
+ AUC rather than accuracy, because the useful property is *ranking*: a mastery predictor
361
+ that outputs 0.4 for everyone who fails and 0.6 for everyone who passes is perfectly
362
+ discriminating and 0% accurate at a 0.5 threshold.
363
+ """
364
+
365
+ version = 1
366
+
367
+ def __init__(
368
+ self,
369
+ *,
370
+ score_field: str = "predicted",
371
+ outcome_field: str = "correct",
372
+ name: str = "discrimination",
373
+ ) -> None:
374
+ self.name = name
375
+ self.score_field = score_field
376
+ self.outcome_field = outcome_field
377
+
378
+ def evaluate_corpus(self, results: Sequence[ExampleResult]) -> list[Metric]:
379
+ points: list[tuple[float, bool]] = []
380
+ for result in results:
381
+ if not result.ok or result.expected is None:
382
+ continue
383
+ score = try_resolve(result.output, self.score_field)
384
+ if not isinstance(score, int | float) or isinstance(score, bool):
385
+ continue
386
+ outcome = result.expected.get(self.outcome_field)
387
+ if outcome is None:
388
+ continue
389
+ points.append((float(score), bool(outcome)))
390
+
391
+ value, reason = roc_auc(points)
392
+ if value is None:
393
+ # Zero measurements, not 0.5. An AUC of 0.5 means "no better than chance", which
394
+ # is a measured result; a set with only one class has no AUC at all, and reporting
395
+ # chance-level there would look like a real finding.
396
+ return [Metric(key=f"{self.name}_auc", value=0.0, count=0, unit=reason)]
397
+ return [Metric(key=f"{self.name}_auc", value=value, count=len(points))]
398
+
399
+
400
+ def roc_auc(points: Sequence[tuple[float, bool]]) -> tuple[float | None, str]:
401
+ """ROC-AUC by the rank-sum identity, returning `(value, reason_if_undefined)`.
402
+
403
+ Computed as the Mann-Whitney U statistic over midranks rather than by integrating a
404
+ trapezoid over thresholds. The two agree exactly, and the rank form handles ties
405
+ correctly without any special-casing — which matters here because a mastery predictor
406
+ emitting a handful of discrete probabilities produces ties constantly.
407
+
408
+ Undefined when either class is empty: AUC asks "how often does a positive outrank a
409
+ negative", and with no negatives there is nothing to outrank.
410
+ """
411
+ positives = [score for score, outcome in points if outcome]
412
+ negatives = [score for score, outcome in points if not outcome]
413
+ if not positives or not negatives:
414
+ return None, "auc undefined: only one outcome class present"
415
+
416
+ ordered = sorted(points, key=lambda item: item[0])
417
+ ranks: list[float] = [0.0] * len(ordered)
418
+ index = 0
419
+ while index < len(ordered):
420
+ stop = index
421
+ while stop + 1 < len(ordered) and ordered[stop + 1][0] == ordered[index][0]:
422
+ stop += 1
423
+ # Midrank for a tied group, which is what makes a tie count as half a win.
424
+ shared = (index + stop) / 2 + 1
425
+ for position in range(index, stop + 1):
426
+ ranks[position] = shared
427
+ index = stop + 1
428
+
429
+ positive_rank_sum = math.fsum(
430
+ rank for rank, (_, outcome) in zip(ranks, ordered, strict=True) if outcome
431
+ )
432
+ n_pos, n_neg = len(positives), len(negatives)
433
+ statistic = positive_rank_sum - n_pos * (n_pos + 1) / 2
434
+ return statistic / (n_pos * n_neg), ""