evalproof 0.2.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.
- evalproof/__init__.py +69 -0
- evalproof/_version.py +3 -0
- evalproof/cache.py +89 -0
- evalproof/calibration.py +266 -0
- evalproof/cli.py +265 -0
- evalproof/compare.py +169 -0
- evalproof/config.py +158 -0
- evalproof/dataset.py +66 -0
- evalproof/gate.py +107 -0
- evalproof/models.py +115 -0
- evalproof/providers/__init__.py +54 -0
- evalproof/providers/anthropic.py +84 -0
- evalproof/providers/base.py +75 -0
- evalproof/providers/ollama.py +70 -0
- evalproof/providers/openai.py +76 -0
- evalproof/py.typed +0 -0
- evalproof/report.py +149 -0
- evalproof/runner.py +135 -0
- evalproof/scorers/__init__.py +80 -0
- evalproof/scorers/deterministic.py +227 -0
- evalproof/scorers/judge.py +187 -0
- evalproof/scorers/retrieval.py +64 -0
- evalproof/stats.py +145 -0
- evalproof/systems.py +154 -0
- evalproof-0.2.0.dist-info/METADATA +279 -0
- evalproof-0.2.0.dist-info/RECORD +30 -0
- evalproof-0.2.0.dist-info/WHEEL +4 -0
- evalproof-0.2.0.dist-info/entry_points.txt +2 -0
- evalproof-0.2.0.dist-info/licenses/LICENSE +202 -0
- evalproof-0.2.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Scorers that need no model: string, pattern, schema and numeric checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from evalproof.models import Case, Score, SystemOutput
|
|
11
|
+
|
|
12
|
+
NUMBER = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize(text: str) -> str:
|
|
16
|
+
text = text.strip().lower()
|
|
17
|
+
text = re.sub(r"\s+", " ", text)
|
|
18
|
+
return text.strip(" .!?;:\"'`")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _case_list(case: Case, field: str | None) -> list[str]:
|
|
22
|
+
if not field:
|
|
23
|
+
return []
|
|
24
|
+
value = case.field(field)
|
|
25
|
+
if value is None:
|
|
26
|
+
return []
|
|
27
|
+
if isinstance(value, str):
|
|
28
|
+
return [value]
|
|
29
|
+
return [str(v) for v in value]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _binary(ok: bool, reason: str | None = None) -> Score:
|
|
33
|
+
return Score(value=1.0 if ok else 0.0, passed=ok, reason=reason)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExactMatch:
|
|
37
|
+
def __init__(self, name: str, field: str = "reference", normalize: bool = True) -> None:
|
|
38
|
+
self.name, self.field, self.norm = name, field, normalize
|
|
39
|
+
|
|
40
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
41
|
+
expected = case.field(self.field)
|
|
42
|
+
if expected is None:
|
|
43
|
+
return Score(value=None, reason=f"case has no {self.field!r}")
|
|
44
|
+
got, want = out.output, str(expected)
|
|
45
|
+
if self.norm:
|
|
46
|
+
got, want = normalize(got), normalize(want)
|
|
47
|
+
return _binary(got == want)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Contains:
|
|
51
|
+
"""Substring checks. ``all_of`` must all appear; ``any_of`` needs at least one."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
name: str,
|
|
56
|
+
any_of: list[str] | None = None,
|
|
57
|
+
all_of: list[str] | None = None,
|
|
58
|
+
any_of_field: str | None = None,
|
|
59
|
+
all_of_field: str | None = None,
|
|
60
|
+
case_sensitive: bool = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
self.name = name
|
|
63
|
+
self.any_of, self.all_of = any_of or [], all_of or []
|
|
64
|
+
self.any_of_field, self.all_of_field = any_of_field, all_of_field
|
|
65
|
+
self.case_sensitive = case_sensitive
|
|
66
|
+
|
|
67
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
68
|
+
text = out.output if self.case_sensitive else out.output.lower()
|
|
69
|
+
|
|
70
|
+
def fold(s: str) -> str:
|
|
71
|
+
return s if self.case_sensitive else s.lower()
|
|
72
|
+
|
|
73
|
+
any_of = self.any_of + _case_list(case, self.any_of_field)
|
|
74
|
+
all_of = self.all_of + _case_list(case, self.all_of_field)
|
|
75
|
+
if not any_of and not all_of:
|
|
76
|
+
return Score(value=None, reason="nothing to look for")
|
|
77
|
+
missing = [s for s in all_of if fold(s) not in text]
|
|
78
|
+
any_ok = not any_of or any(fold(s) in text for s in any_of)
|
|
79
|
+
ok = not missing and any_ok
|
|
80
|
+
reason = None if ok else f"missing {missing}" if missing else "none of any_of found"
|
|
81
|
+
return _binary(ok, reason)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Regex:
|
|
85
|
+
"""``pattern``/``any_of_field`` must match; ``none_of``/``none_of_field`` must not."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
name: str,
|
|
90
|
+
pattern: str | None = None,
|
|
91
|
+
any_of_field: str | None = None,
|
|
92
|
+
none_of: list[str] | None = None,
|
|
93
|
+
none_of_field: str | None = None,
|
|
94
|
+
flags: str = "i",
|
|
95
|
+
) -> None:
|
|
96
|
+
self.name = name
|
|
97
|
+
self.patterns = [pattern] if pattern else []
|
|
98
|
+
self.any_of_field = any_of_field
|
|
99
|
+
self.none_of = none_of or []
|
|
100
|
+
self.none_of_field = none_of_field
|
|
101
|
+
self.flags = (re.IGNORECASE if "i" in flags else 0) | (re.MULTILINE if "m" in flags else 0)
|
|
102
|
+
self.flags |= re.DOTALL if "s" in flags else 0
|
|
103
|
+
|
|
104
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
105
|
+
accept = self.patterns + _case_list(case, self.any_of_field)
|
|
106
|
+
reject = self.none_of + _case_list(case, self.none_of_field)
|
|
107
|
+
if not accept:
|
|
108
|
+
return Score(value=None, reason="no accept pattern")
|
|
109
|
+
hit = next((p for p in accept if re.search(p, out.output, self.flags)), None)
|
|
110
|
+
bad = next((p for p in reject if re.search(p, out.output, self.flags)), None)
|
|
111
|
+
ok = hit is not None and bad is None
|
|
112
|
+
reason = None if ok else f"rejected by {bad!r}" if bad else "no accept pattern matched"
|
|
113
|
+
return _binary(ok, reason)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check(instance: Any, schema: dict[str, Any], path: str = "$") -> list[str]:
|
|
117
|
+
"""A deliberately small JSON Schema subset: type, enum, required, properties, items."""
|
|
118
|
+
errors: list[str] = []
|
|
119
|
+
types = {
|
|
120
|
+
"object": dict,
|
|
121
|
+
"array": list,
|
|
122
|
+
"string": str,
|
|
123
|
+
"boolean": bool,
|
|
124
|
+
"null": type(None),
|
|
125
|
+
}
|
|
126
|
+
expected = schema.get("type")
|
|
127
|
+
if expected == "number":
|
|
128
|
+
if not isinstance(instance, int | float) or isinstance(instance, bool):
|
|
129
|
+
return [f"{path}: expected number"]
|
|
130
|
+
elif expected == "integer":
|
|
131
|
+
if not isinstance(instance, int) or isinstance(instance, bool):
|
|
132
|
+
return [f"{path}: expected integer"]
|
|
133
|
+
elif expected in types and not isinstance(instance, types[expected]):
|
|
134
|
+
return [f"{path}: expected {expected}"]
|
|
135
|
+
if "enum" in schema and instance not in schema["enum"]:
|
|
136
|
+
errors.append(f"{path}: {instance!r} not in enum")
|
|
137
|
+
if isinstance(instance, dict):
|
|
138
|
+
for key in schema.get("required", []):
|
|
139
|
+
if key not in instance:
|
|
140
|
+
errors.append(f"{path}: missing required {key!r}")
|
|
141
|
+
for key, sub in schema.get("properties", {}).items():
|
|
142
|
+
if key in instance:
|
|
143
|
+
errors.extend(_check(instance[key], sub, f"{path}.{key}"))
|
|
144
|
+
if isinstance(instance, list) and "items" in schema:
|
|
145
|
+
for i, item in enumerate(instance):
|
|
146
|
+
errors.extend(_check(item, schema["items"], f"{path}[{i}]"))
|
|
147
|
+
return errors
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_json(text: str) -> Any:
|
|
151
|
+
"""Parse JSON from a model reply that may wrap it in prose or a code fence."""
|
|
152
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
153
|
+
fence = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
|
|
154
|
+
if fence:
|
|
155
|
+
text = fence.group(1).strip()
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(text)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
pass
|
|
160
|
+
start = text.find("{")
|
|
161
|
+
while start != -1:
|
|
162
|
+
depth = 0
|
|
163
|
+
for i in range(start, len(text)):
|
|
164
|
+
if text[i] == "{":
|
|
165
|
+
depth += 1
|
|
166
|
+
elif text[i] == "}":
|
|
167
|
+
depth -= 1
|
|
168
|
+
if depth == 0:
|
|
169
|
+
try:
|
|
170
|
+
return json.loads(text[start : i + 1])
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
break
|
|
173
|
+
start = text.find("{", start + 1)
|
|
174
|
+
raise ValueError("no JSON object found")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class JsonSchema:
|
|
178
|
+
def __init__(
|
|
179
|
+
self, name: str, schema: dict[str, Any] | None = None, schema_field: str | None = None
|
|
180
|
+
) -> None:
|
|
181
|
+
self.name, self.schema, self.schema_field = name, schema, schema_field
|
|
182
|
+
|
|
183
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
184
|
+
schema = self.schema or (case.field(self.schema_field) if self.schema_field else None)
|
|
185
|
+
if not schema:
|
|
186
|
+
return Score(value=None, reason="no schema")
|
|
187
|
+
try:
|
|
188
|
+
data = extract_json(out.output)
|
|
189
|
+
except ValueError:
|
|
190
|
+
return _binary(False, "output is not JSON")
|
|
191
|
+
errors = _check(data, schema)
|
|
192
|
+
return _binary(not errors, "; ".join(errors[:3]) or None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class NumericTolerance:
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
field: str = "reference",
|
|
200
|
+
abs_tol: float = 0.0,
|
|
201
|
+
rel_tol: float = 0.0,
|
|
202
|
+
pick: str = "first",
|
|
203
|
+
) -> None:
|
|
204
|
+
if pick not in {"first", "last", "any"}:
|
|
205
|
+
raise ValueError("pick must be first, last or any")
|
|
206
|
+
self.name, self.field, self.abs_tol, self.rel_tol, self.pick = (
|
|
207
|
+
name,
|
|
208
|
+
field,
|
|
209
|
+
abs_tol,
|
|
210
|
+
rel_tol,
|
|
211
|
+
pick,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
215
|
+
raw = case.field(self.field)
|
|
216
|
+
if raw is None:
|
|
217
|
+
return Score(value=None, reason=f"case has no {self.field!r}")
|
|
218
|
+
expected = float(str(raw).replace(",", ""))
|
|
219
|
+
found = [float(n.replace(",", "")) for n in NUMBER.findall(out.output)]
|
|
220
|
+
if not found:
|
|
221
|
+
return _binary(False, "no number in output")
|
|
222
|
+
candidates = {"first": found[:1], "last": found[-1:], "any": found}[self.pick]
|
|
223
|
+
ok = any(
|
|
224
|
+
math.isclose(x, expected, rel_tol=self.rel_tol, abs_tol=self.abs_tol)
|
|
225
|
+
for x in candidates
|
|
226
|
+
)
|
|
227
|
+
return _binary(ok, None if ok else f"expected {expected}, found {candidates}")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""LLM-as-judge scorers.
|
|
2
|
+
|
|
3
|
+
A judge is a classifier, so it is treated like one: a strict JSON verdict, a parse
|
|
4
|
+
retry instead of a guess, ``undecided`` instead of a silent pass, and — in
|
|
5
|
+
``evalproof.calibration`` — measured error rates instead of trust.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from evalproof.config import GenerationParams
|
|
14
|
+
from evalproof.models import Case, Score, SystemOutput
|
|
15
|
+
from evalproof.providers import Message, Provider, ProviderError
|
|
16
|
+
from evalproof.scorers.deterministic import extract_json
|
|
17
|
+
|
|
18
|
+
DEFAULT_RUBRIC = (
|
|
19
|
+
"Pass if the response answers the question correctly and is consistent with the "
|
|
20
|
+
"reference answer. Fail if it is wrong, contradicts the reference, invents facts the "
|
|
21
|
+
"reference does not support, or does not answer. Wording and length do not matter."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
JUDGE_SYSTEM = (
|
|
25
|
+
"You are a strict, consistent evaluator. You grade one response at a time against a "
|
|
26
|
+
"rubric and a reference answer. Reply with a single JSON object and nothing else."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
JUDGE_TEMPLATE = """Rubric:
|
|
30
|
+
{rubric}
|
|
31
|
+
|
|
32
|
+
Question:
|
|
33
|
+
{question}
|
|
34
|
+
|
|
35
|
+
Reference answer:
|
|
36
|
+
{reference}
|
|
37
|
+
|
|
38
|
+
Response to grade:
|
|
39
|
+
{response}
|
|
40
|
+
|
|
41
|
+
Reply with exactly this JSON shape:
|
|
42
|
+
{{"reason": "<one sentence>", "pass": true or false, "score": <number from 0 to 1>}}"""
|
|
43
|
+
|
|
44
|
+
RETRY_NUDGE = (
|
|
45
|
+
"That was not a valid JSON object of the requested shape. Reply with only the JSON "
|
|
46
|
+
'object: {"reason": "...", "pass": true or false, "score": 0 to 1}'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _question(case: Case) -> str:
|
|
51
|
+
if isinstance(case.input, dict):
|
|
52
|
+
return str(case.input.get("question") or case.input)
|
|
53
|
+
return case.input
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_verdict(text: str) -> tuple[bool, float, str]:
|
|
57
|
+
data = extract_json(text)
|
|
58
|
+
if not isinstance(data, dict) or "pass" not in data:
|
|
59
|
+
raise ValueError("verdict needs a 'pass' field")
|
|
60
|
+
passed = data["pass"]
|
|
61
|
+
if isinstance(passed, str):
|
|
62
|
+
passed = passed.strip().lower() in {"true", "yes", "pass"}
|
|
63
|
+
if not isinstance(passed, bool):
|
|
64
|
+
raise ValueError("'pass' must be a boolean")
|
|
65
|
+
raw_score = data.get("score", 1.0 if passed else 0.0)
|
|
66
|
+
try:
|
|
67
|
+
score = min(1.0, max(0.0, float(raw_score)))
|
|
68
|
+
except (TypeError, ValueError) as exc:
|
|
69
|
+
raise ValueError("'score' must be a number") from exc
|
|
70
|
+
return passed, score, str(data.get("reason", ""))[:500]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LLMJudge:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
name: str,
|
|
77
|
+
provider: Provider,
|
|
78
|
+
params: GenerationParams,
|
|
79
|
+
rubric: str = DEFAULT_RUBRIC,
|
|
80
|
+
max_retries: int = 1,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.name = name
|
|
83
|
+
self.provider = provider
|
|
84
|
+
self.params = params
|
|
85
|
+
self.rubric = rubric
|
|
86
|
+
self.max_retries = max_retries
|
|
87
|
+
|
|
88
|
+
def messages(self, case: Case, out: SystemOutput) -> list[Message]:
|
|
89
|
+
prompt = JUDGE_TEMPLATE.format(
|
|
90
|
+
rubric=self.rubric.strip(),
|
|
91
|
+
question=_question(case),
|
|
92
|
+
reference=case.reference or "(none provided)",
|
|
93
|
+
response=out.output or "(empty)",
|
|
94
|
+
)
|
|
95
|
+
return [Message(role="system", content=JUDGE_SYSTEM), Message(role="user", content=prompt)]
|
|
96
|
+
|
|
97
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
98
|
+
if out.error:
|
|
99
|
+
return Score(value=0.0, passed=False, reason=f"system error: {out.error[:200]}")
|
|
100
|
+
messages = self.messages(case, out)
|
|
101
|
+
tokens = 0
|
|
102
|
+
last_text = ""
|
|
103
|
+
for _attempt in range(self.max_retries + 1):
|
|
104
|
+
try:
|
|
105
|
+
resp = self.provider.chat(messages, self.params)
|
|
106
|
+
except ProviderError as exc:
|
|
107
|
+
return Score(value=None, reason=f"judge error: {exc}", judge_tokens=tokens)
|
|
108
|
+
tokens += resp.tokens_in + resp.tokens_out
|
|
109
|
+
last_text = resp.text
|
|
110
|
+
try:
|
|
111
|
+
passed, value, reason = parse_verdict(resp.text)
|
|
112
|
+
except ValueError:
|
|
113
|
+
messages = [
|
|
114
|
+
*messages,
|
|
115
|
+
Message(role="assistant", content=resp.text),
|
|
116
|
+
Message(role="user", content=RETRY_NUDGE),
|
|
117
|
+
]
|
|
118
|
+
continue
|
|
119
|
+
return Score(value=value, passed=passed, reason=reason, judge_tokens=tokens)
|
|
120
|
+
return Score(
|
|
121
|
+
value=None,
|
|
122
|
+
reason=f"unparseable judge reply: {last_text[:200]!r}",
|
|
123
|
+
judge_tokens=tokens,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
PAIRWISE_TEMPLATE = """Question:
|
|
128
|
+
{question}
|
|
129
|
+
|
|
130
|
+
Reference answer:
|
|
131
|
+
{reference}
|
|
132
|
+
|
|
133
|
+
Response A:
|
|
134
|
+
{a}
|
|
135
|
+
|
|
136
|
+
Response B:
|
|
137
|
+
{b}
|
|
138
|
+
|
|
139
|
+
Which response answers the question better, judged against the reference? Reply with
|
|
140
|
+
exactly this JSON shape: {{"reason": "<one sentence>", "winner": "A" or "B" or "tie"}}"""
|
|
141
|
+
|
|
142
|
+
Winner = Literal["a", "b", "tie"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class PairwiseResult:
|
|
147
|
+
winner: Winner
|
|
148
|
+
consistent: bool
|
|
149
|
+
first: str # verdict with (a, b) order, in a/b terms
|
|
150
|
+
second: str # verdict with (b, a) order, mapped back to a/b terms
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pairwise_once(
|
|
154
|
+
provider: Provider, params: GenerationParams, case: Case, first: str, second: str
|
|
155
|
+
) -> str:
|
|
156
|
+
prompt = PAIRWISE_TEMPLATE.format(
|
|
157
|
+
question=_question(case), reference=case.reference or "(none provided)", a=first, b=second
|
|
158
|
+
)
|
|
159
|
+
resp = provider.chat(
|
|
160
|
+
[Message(role="system", content=JUDGE_SYSTEM), Message(role="user", content=prompt)],
|
|
161
|
+
params,
|
|
162
|
+
)
|
|
163
|
+
data = extract_json(resp.text)
|
|
164
|
+
winner = str(data.get("winner", "")).strip().lower() if isinstance(data, dict) else ""
|
|
165
|
+
if winner not in {"a", "b", "tie"}:
|
|
166
|
+
raise ValueError(f"bad pairwise verdict: {resp.text[:120]!r}")
|
|
167
|
+
return winner
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def pairwise(
|
|
171
|
+
provider: Provider,
|
|
172
|
+
params: GenerationParams,
|
|
173
|
+
case: Case,
|
|
174
|
+
a: str,
|
|
175
|
+
b: str,
|
|
176
|
+
swap: bool = True,
|
|
177
|
+
) -> PairwiseResult:
|
|
178
|
+
"""Compare two outputs. With ``swap`` the judge sees both orders; a verdict that flips
|
|
179
|
+
with position is reported as a tie and flagged inconsistent (position bias)."""
|
|
180
|
+
first = _pairwise_once(provider, params, case, a, b)
|
|
181
|
+
if not swap:
|
|
182
|
+
return PairwiseResult(winner=first, consistent=True, first=first, second=first) # type: ignore[arg-type]
|
|
183
|
+
raw = _pairwise_once(provider, params, case, b, a)
|
|
184
|
+
second = {"a": "b", "b": "a", "tie": "tie"}[raw]
|
|
185
|
+
consistent = first == second
|
|
186
|
+
winner: Winner = first if consistent else "tie" # type: ignore[assignment]
|
|
187
|
+
return PairwiseResult(winner=winner, consistent=consistent, first=first, second=second)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Retrieval metrics over ``SystemOutput.retrieved`` against a case's ``relevant`` ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from evalproof.models import Case, Score, SystemOutput
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _relevance(case: Case, field: str) -> dict[str, float]:
|
|
11
|
+
value = case.field(field)
|
|
12
|
+
if value is None:
|
|
13
|
+
return {}
|
|
14
|
+
if isinstance(value, dict):
|
|
15
|
+
return {str(k): float(v) for k, v in value.items()}
|
|
16
|
+
return {str(v): 1.0 for v in value}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _RetrievalScorer:
|
|
20
|
+
def __init__(
|
|
21
|
+
self, name: str, k: int = 10, field: str = "relevant", pass_threshold: float | None = None
|
|
22
|
+
) -> None:
|
|
23
|
+
if k < 1:
|
|
24
|
+
raise ValueError("k must be >= 1")
|
|
25
|
+
self.name, self.k, self.field, self.pass_threshold = name, k, field, pass_threshold
|
|
26
|
+
|
|
27
|
+
def _finish(self, value: float) -> Score:
|
|
28
|
+
passed = None if self.pass_threshold is None else value >= self.pass_threshold
|
|
29
|
+
return Score(value=value, passed=passed)
|
|
30
|
+
|
|
31
|
+
def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
def score(self, case: Case, out: SystemOutput) -> Score:
|
|
35
|
+
rel = _relevance(case, self.field)
|
|
36
|
+
if not rel:
|
|
37
|
+
return Score(value=None, reason=f"case has no {self.field!r}")
|
|
38
|
+
return self._finish(self.metric(rel, out.retrieved[: self.k]))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RecallAtK(_RetrievalScorer):
|
|
42
|
+
def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
|
|
43
|
+
relevant = {d for d, g in rel.items() if g > 0}
|
|
44
|
+
return len(relevant & set(ranked)) / len(relevant) if relevant else 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MRR(_RetrievalScorer):
|
|
48
|
+
def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
|
|
49
|
+
for i, doc in enumerate(ranked, start=1):
|
|
50
|
+
if rel.get(doc, 0) > 0:
|
|
51
|
+
return 1.0 / i
|
|
52
|
+
return 0.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NDCG(_RetrievalScorer):
|
|
56
|
+
"""nDCG@k with exponential gain ``2^rel - 1``."""
|
|
57
|
+
|
|
58
|
+
def metric(self, rel: dict[str, float], ranked: list[str]) -> float:
|
|
59
|
+
def dcg(gains: list[float]) -> float:
|
|
60
|
+
return sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(gains))
|
|
61
|
+
|
|
62
|
+
actual = dcg([rel.get(doc, 0.0) for doc in ranked])
|
|
63
|
+
ideal = dcg(sorted(rel.values(), reverse=True)[: self.k])
|
|
64
|
+
return actual / ideal if ideal > 0 else 0.0
|
evalproof/stats.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Small, readable statistics. numpy and the standard library only — no SciPy.
|
|
2
|
+
|
|
3
|
+
Every function here has a known-value test in ``tests/test_stats.py``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from fractions import Fraction
|
|
12
|
+
from statistics import NormalDist
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
FloatArray = NDArray[np.float64]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def z_for(alpha: float) -> float:
|
|
21
|
+
"""Two-sided critical value: ``z_for(0.05)`` ≈ 1.959964."""
|
|
22
|
+
if not 0 < alpha < 1:
|
|
23
|
+
raise ValueError("alpha must be in (0, 1)")
|
|
24
|
+
return NormalDist().inv_cdf(1 - alpha / 2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def wilson(k: int, n: int, alpha: float = 0.05) -> tuple[float, float]:
|
|
28
|
+
"""Wilson score interval for a binomial proportion. Behaves at 0/n and n/n."""
|
|
29
|
+
if n <= 0:
|
|
30
|
+
return (0.0, 1.0)
|
|
31
|
+
if not 0 <= k <= n:
|
|
32
|
+
raise ValueError("need 0 <= k <= n")
|
|
33
|
+
z = z_for(alpha)
|
|
34
|
+
p = k / n
|
|
35
|
+
denom = 1 + z * z / n
|
|
36
|
+
centre = (p + z * z / (2 * n)) / denom
|
|
37
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
|
|
38
|
+
# At k=0 the lower bound is exactly 0 (centre == half), and at k=n the upper
|
|
39
|
+
# bound is exactly 1. Computing them by subtraction leaves platform-dependent
|
|
40
|
+
# rounding (2.8e-17 on x86_64 Linux, 0.0 on arm64 macOS), so pin them.
|
|
41
|
+
lo = 0.0 if k == 0 else max(0.0, centre - half)
|
|
42
|
+
hi = 1.0 if k == n else min(1.0, centre + half)
|
|
43
|
+
return (lo, hi)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bootstrap_ci(
|
|
47
|
+
values: Sequence[float] | FloatArray,
|
|
48
|
+
stat: Callable[[FloatArray], float] = lambda x: float(np.mean(x)),
|
|
49
|
+
*,
|
|
50
|
+
n_boot: int = 5000,
|
|
51
|
+
alpha: float = 0.05,
|
|
52
|
+
seed: int = 0,
|
|
53
|
+
) -> tuple[float, float]:
|
|
54
|
+
"""Percentile bootstrap interval, seeded so a report is reproducible."""
|
|
55
|
+
arr = np.asarray(values, dtype=np.float64)
|
|
56
|
+
if arr.size == 0:
|
|
57
|
+
raise ValueError("no values")
|
|
58
|
+
rng = np.random.default_rng(seed)
|
|
59
|
+
idx = rng.integers(0, arr.size, size=(n_boot, arr.size))
|
|
60
|
+
stats = np.array([stat(arr[row]) for row in idx])
|
|
61
|
+
lo, hi = np.quantile(stats, [alpha / 2, 1 - alpha / 2])
|
|
62
|
+
return (float(lo), float(hi))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class PairedDelta:
|
|
67
|
+
"""``delta = mean(candidate) - mean(baseline)`` over paired cases."""
|
|
68
|
+
|
|
69
|
+
delta: float
|
|
70
|
+
ci_low: float
|
|
71
|
+
ci_high: float
|
|
72
|
+
p_value: float
|
|
73
|
+
n: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def paired_bootstrap(
|
|
77
|
+
baseline: Sequence[float] | FloatArray,
|
|
78
|
+
candidate: Sequence[float] | FloatArray,
|
|
79
|
+
*,
|
|
80
|
+
n_boot: int = 5000,
|
|
81
|
+
alpha: float = 0.05,
|
|
82
|
+
seed: int = 0,
|
|
83
|
+
) -> PairedDelta:
|
|
84
|
+
"""Resample *case indices*, so each replicate keeps baseline and candidate paired.
|
|
85
|
+
|
|
86
|
+
The two-sided p-value is the percentile-bootstrap approximation
|
|
87
|
+
``2 * min(P(d* <= 0), P(d* >= 0))`` — adequate for gating, and stated as such.
|
|
88
|
+
"""
|
|
89
|
+
a = np.asarray(baseline, dtype=np.float64)
|
|
90
|
+
b = np.asarray(candidate, dtype=np.float64)
|
|
91
|
+
if a.shape != b.shape or a.size == 0:
|
|
92
|
+
raise ValueError("need two non-empty arrays of equal length")
|
|
93
|
+
diff = b - a
|
|
94
|
+
rng = np.random.default_rng(seed)
|
|
95
|
+
idx = rng.integers(0, diff.size, size=(n_boot, diff.size))
|
|
96
|
+
reps = diff[idx].mean(axis=1)
|
|
97
|
+
lo, hi = np.quantile(reps, [alpha / 2, 1 - alpha / 2])
|
|
98
|
+
if np.all(diff == 0):
|
|
99
|
+
p = 1.0
|
|
100
|
+
else:
|
|
101
|
+
p = float(min(1.0, 2 * min(np.mean(reps <= 0), np.mean(reps >= 0))))
|
|
102
|
+
return PairedDelta(float(diff.mean()), float(lo), float(hi), p, int(diff.size))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def binom_cdf(k: int, n: int, p: float = 0.5) -> float:
|
|
106
|
+
"""``P(X <= k)`` for ``X ~ Binomial(n, p)``, summed exactly and rounded once.
|
|
107
|
+
|
|
108
|
+
In floating point, ``comb(n, i)`` alone passes the largest float from about
|
|
109
|
+
n = 1,030, so a comparison with that many discordant pairs raised OverflowError
|
|
110
|
+
instead of returning a p-value. Integers and fractions do not overflow.
|
|
111
|
+
"""
|
|
112
|
+
if k < 0:
|
|
113
|
+
return 0.0
|
|
114
|
+
if k >= n:
|
|
115
|
+
return 1.0
|
|
116
|
+
q = Fraction(p)
|
|
117
|
+
if q == Fraction(1, 2):
|
|
118
|
+
# The McNemar case: every outcome has probability 2**-n, so sum the binomial
|
|
119
|
+
# coefficients as integers and divide once (int / int is correctly rounded).
|
|
120
|
+
term = total = 1
|
|
121
|
+
for i in range(1, k + 1):
|
|
122
|
+
term = term * (n - i + 1) // i
|
|
123
|
+
total += term
|
|
124
|
+
return total / (1 << n)
|
|
125
|
+
return float(sum(math.comb(n, i) * q**i * (1 - q) ** (n - i) for i in range(k + 1)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def mcnemar_exact(b: int, c: int) -> float:
|
|
129
|
+
"""Exact two-sided McNemar test on the discordant pairs.
|
|
130
|
+
|
|
131
|
+
``b`` = baseline passed, candidate failed; ``c`` = baseline failed, candidate passed.
|
|
132
|
+
Only discordant pairs carry information about a difference between the two systems.
|
|
133
|
+
"""
|
|
134
|
+
if b < 0 or c < 0:
|
|
135
|
+
raise ValueError("counts must be non-negative")
|
|
136
|
+
n = b + c
|
|
137
|
+
if n == 0:
|
|
138
|
+
return 1.0
|
|
139
|
+
return min(1.0, 2 * binom_cdf(min(b, c), n))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def percentile(values: Sequence[float], q: float) -> float:
|
|
143
|
+
if not values:
|
|
144
|
+
return 0.0
|
|
145
|
+
return float(np.percentile(np.asarray(values, dtype=np.float64), q))
|