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.
- proofstep_core/__init__.py +59 -0
- proofstep_core/aggregate.py +140 -0
- proofstep_core/calibration.py +849 -0
- proofstep_core/calibration_runner.py +471 -0
- proofstep_core/compare.py +205 -0
- proofstep_core/dataset.py +174 -0
- proofstep_core/evaluators/__init__.py +47 -0
- proofstep_core/evaluators/deterministic.py +404 -0
- proofstep_core/evaluators/judge.py +335 -0
- proofstep_core/evaluators/operational.py +127 -0
- proofstep_core/evaluators/statistical.py +434 -0
- proofstep_core/gates.py +545 -0
- proofstep_core/paths.py +98 -0
- proofstep_core/py.typed +0 -0
- proofstep_core/redaction.py +263 -0
- proofstep_core/runner.py +482 -0
- proofstep_core/sampling.py +200 -0
- proofstep_core/significance.py +477 -0
- proofstep_core/stats.py +142 -0
- proofstep_core/suite.py +277 -0
- proofstep_core/testing.py +118 -0
- proofstep_core/types.py +156 -0
- proofstep_core/versioning.py +63 -0
- proofstep_core-0.1.0.dist-info/METADATA +39 -0
- proofstep_core-0.1.0.dist-info/RECORD +26 -0
- proofstep_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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), ""
|