Files
lean-bench/leanbench/evalstats.py
claude-bot ca98703538
ci / python (pull_request) Successful in 17s
ci / lean (pull_request) Successful in 1m10s
ci / image (pull_request) Has been skipped
Pilot prep: simplex schema, projection, alignment, lint, stats, pilot scripts, protocol v1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYqwNMyPUHeTbFWg83GQq6
2026-10-22 05:52:28 +00:00

94 lines
3.2 KiB
Python

"""Small, dependency-free statistics for evaluating a detector against human labels."""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
def wilson(successes: int, total: int, z: float = 1.96) -> tuple[float, float]:
"""Wilson score interval for a proportion; (0, 1) when there is no data."""
if total <= 0:
return 0.0, 1.0
p = successes / total
denom = 1 + z * z / total
centre = (p + z * z / (2 * total)) / denom
half = z * math.sqrt(p * (1 - p) / total + z * z / (4 * total * total)) / denom
return max(0.0, centre - half), min(1.0, centre + half)
@dataclass
class Confusion:
"""``positive`` means 'the item is unfaithful' and a detector 'flags' it."""
tp: int = 0
fp: int = 0
fn: int = 0
tn: int = 0
@property
def n(self) -> int:
return self.tp + self.fp + self.fn + self.tn
@property
def recall(self) -> float:
return self.tp / (self.tp + self.fn) if (self.tp + self.fn) else float("nan")
@property
def precision(self) -> float:
return self.tp / (self.tp + self.fp) if (self.tp + self.fp) else float("nan")
@property
def false_flag_rate(self) -> float:
return self.fp / (self.fp + self.tn) if (self.fp + self.tn) else float("nan")
@property
def f1(self) -> float:
p, r = self.precision, self.recall
return 2 * p * r / (p + r) if (p == p and r == r and (p + r) > 0) else float("nan")
def as_dict(self) -> dict[str, float | int]:
return {
"tp": self.tp, "fp": self.fp, "fn": self.fn, "tn": self.tn, "n": self.n,
"recall": self.recall, "precision": self.precision,
"false_flag_rate": self.false_flag_rate, "f1": self.f1,
"recall_ci": wilson(self.tp, self.tp + self.fn),
"false_flag_ci": wilson(self.fp, self.fp + self.tn),
}
def confusion(flagged: Sequence[bool], unfaithful: Sequence[bool]) -> Confusion:
if len(flagged) != len(unfaithful):
raise ValueError("flagged and unfaithful must have the same length")
c = Confusion()
for f, u in zip(flagged, unfaithful, strict=True):
if f and u:
c.tp += 1
elif f and not u:
c.fp += 1
elif u:
c.fn += 1
else:
c.tn += 1
return c
def auc(scores: Sequence[float], unfaithful: Sequence[bool]) -> float:
"""ROC-AUC of 'higher score means unfaithful' (rank-sum form, ties count half)."""
pos = [s for s, u in zip(scores, unfaithful, strict=True) if u]
neg = [s for s, u in zip(scores, unfaithful, strict=True) if not u]
if not pos or not neg:
return float("nan")
wins = sum((p > n) + 0.5 * (p == n) for p in pos for n in neg)
return wins / (len(pos) * len(neg))
def cohen_kappa(a: Sequence[str], b: Sequence[str]) -> float:
if len(a) != len(b) or not a:
raise ValueError("need two label lists of equal, non-zero length")
n = len(a)
observed = sum(x == y for x, y in zip(a, b, strict=True)) / n
labels = set(a) | set(b)
expected = sum((a.count(k) / n) * (b.count(k) / n) for k in labels)
return 1.0 if expected == 1 else (observed - expected) / (1 - expected)