evalpair 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.
- evalpair/__init__.py +3 -0
- evalpair/__main__.py +4 -0
- evalpair/analysis.py +141 -0
- evalpair/backends.py +168 -0
- evalpair/checkers.py +252 -0
- evalpair/cli.py +164 -0
- evalpair/runner.py +67 -0
- evalpair/server.py +116 -0
- evalpair/stats.py +52 -0
- evalpair/store.py +86 -0
- evalpair/suites/e2e-extract-150/items.jsonl +150 -0
- evalpair/suites/e2e-extract-150/suite.toml +6 -0
- evalpair/suites/gsm8k-160/items.jsonl +160 -0
- evalpair/suites/gsm8k-160/suite.toml +6 -0
- evalpair/suites/mmlu-hs-160/items.jsonl +160 -0
- evalpair/suites/mmlu-hs-160/suite.toml +7 -0
- evalpair/suites.py +147 -0
- evalpair-0.1.0.dist-info/METADATA +189 -0
- evalpair-0.1.0.dist-info/RECORD +22 -0
- evalpair-0.1.0.dist-info/WHEEL +4 -0
- evalpair-0.1.0.dist-info/entry_points.txt +3 -0
- evalpair-0.1.0.dist-info/licenses/LICENSE +21 -0
evalpair/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Command line: evalpair suites | run | runs | show | compare | mcp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__, analysis, store, suites
|
|
11
|
+
from .backends import Backend, BackendError, Cache
|
|
12
|
+
|
|
13
|
+
API_KEY_ENV = ("EVALPAIR_API_KEY", "OPENAI_API_KEY")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def api_key() -> str | None:
|
|
17
|
+
return next((os.environ[k] for k in API_KEY_ENV if os.environ.get(k)), None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_backend(h: str, kind: str, model: str, base_url: str | None, temperature: float, seed: int | None,
|
|
21
|
+
num_ctx: int | None, think: bool | None, cache_only: bool = False) -> Backend:
|
|
22
|
+
opts = {"temperature": temperature}
|
|
23
|
+
if seed is not None:
|
|
24
|
+
opts["seed"] = seed
|
|
25
|
+
if num_ctx and kind == "ollama":
|
|
26
|
+
opts["num_ctx"] = num_ctx
|
|
27
|
+
return Backend(kind, model, Cache(store.cache_path(h)), base_url, api_key(), opts, think, cache_only)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print(obj, as_json: bool, text_fn) -> None:
|
|
31
|
+
if as_json:
|
|
32
|
+
print(json.dumps(obj, indent=1, ensure_ascii=False))
|
|
33
|
+
else:
|
|
34
|
+
print(text_fn(obj))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def text_summary(s: dict) -> str:
|
|
38
|
+
lines = [f"{s['run']} ({s['model']} on {s['suite']}, {s['items']} items)",
|
|
39
|
+
f" score {s['score']['text']}",
|
|
40
|
+
f" no answer {s['no_answer']} truncated {s['truncated']} "
|
|
41
|
+
f"mean output {s['mean_output_tokens']} tokens"]
|
|
42
|
+
nf = s.get("noise_floor")
|
|
43
|
+
if nf:
|
|
44
|
+
lines.append(f" noise floor {nf['answers_changed']} of {nf['items']} answers changed on a repeat "
|
|
45
|
+
f"({nf['change_rate']['text']})")
|
|
46
|
+
for g, v in (s.get("by_group") or {}).items():
|
|
47
|
+
lines.append(f" {g:<28} {100 * v['score']:5.1f}% ({v['items']})")
|
|
48
|
+
if s["failures"]:
|
|
49
|
+
lines.append(" failures (unparsed and truncated first):")
|
|
50
|
+
for f in s["failures"]:
|
|
51
|
+
end = f["reply_end"].replace("\n", " ")[-160:]
|
|
52
|
+
lines.append(f" {f['id']}: answer {f['answer']!r} via {f['how']}"
|
|
53
|
+
f"{', truncated' if f['truncated'] else ''} …{end}")
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def text_compare(c: dict) -> str:
|
|
58
|
+
if not c.get("items"):
|
|
59
|
+
return "\n".join(c["notes"])
|
|
60
|
+
lines = [f"A {c['a']['run']}: {c['a']['score']['text']}",
|
|
61
|
+
f"B {c['b']['run']}: {c['b']['score']['text']}",
|
|
62
|
+
f"B − A {c['difference']['text']}",
|
|
63
|
+
f"changed {c['answers_changed']['text']} of {c['items']} items "
|
|
64
|
+
f"({c['worse_in_b']} worse in B, {c['better_in_b']} better)"]
|
|
65
|
+
for k, f in (c.get("noise_floor") or {}).items():
|
|
66
|
+
lines.append(f"floor ({k.upper()}) {f['change_rate']['text']}; excess {f['excess_over_floor']['text']}")
|
|
67
|
+
lines += ["", c["verdict"]] + [f"Note: {n}" for n in c["notes"]]
|
|
68
|
+
for kind in ("worse_in_b", "better_in_b"):
|
|
69
|
+
for e in c["examples"][kind]:
|
|
70
|
+
lines.append(f" {kind.replace('_', ' ')}: {e['id']} {e['a_answer']!r} → {e['b_answer']!r}")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: list[str] | None = None) -> None:
|
|
75
|
+
p = argparse.ArgumentParser(prog="evalpair", description=__doc__)
|
|
76
|
+
p.add_argument("--version", action="version", version=f"evalpair {__version__}")
|
|
77
|
+
p.add_argument("--home", help="data folder (default $EVALPAIR_HOME or ~/.evalpair)")
|
|
78
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
79
|
+
|
|
80
|
+
sub.add_parser("suites", help="list the suites you can run").add_argument("--json", action="store_true")
|
|
81
|
+
|
|
82
|
+
r = sub.add_parser("run", help="run a suite against a model")
|
|
83
|
+
r.add_argument("suite", help="a suite name, or a path to a suite folder")
|
|
84
|
+
r.add_argument("--model", "-m", required=True)
|
|
85
|
+
r.add_argument("--backend", choices=["ollama", "openai"], default="ollama")
|
|
86
|
+
r.add_argument("--base-url", help="server URL (default: Ollama on localhost, or http://localhost:8000/v1)")
|
|
87
|
+
r.add_argument("--limit", "-n", type=int, help="only the first N items")
|
|
88
|
+
r.add_argument("--repeat", action="store_true", help="send every item twice to measure the noise floor")
|
|
89
|
+
r.add_argument("--workers", type=int, default=1)
|
|
90
|
+
r.add_argument("--label", help="run id (default: suite-model-timestamp)")
|
|
91
|
+
r.add_argument("--temperature", type=float, default=0.0)
|
|
92
|
+
r.add_argument("--seed", type=int, default=7)
|
|
93
|
+
r.add_argument("--num-ctx", type=int, help="Ollama context window")
|
|
94
|
+
r.add_argument("--think", choices=["on", "off"], help="Ollama thinking for models that support it")
|
|
95
|
+
r.add_argument("--json", action="store_true")
|
|
96
|
+
|
|
97
|
+
ls = sub.add_parser("runs", help="list saved runs, newest first")
|
|
98
|
+
ls.add_argument("--suite")
|
|
99
|
+
ls.add_argument("--json", action="store_true")
|
|
100
|
+
|
|
101
|
+
sh = sub.add_parser("show", help="summarise a run, with its failures")
|
|
102
|
+
sh.add_argument("run")
|
|
103
|
+
sh.add_argument("--failures", type=int, default=5)
|
|
104
|
+
sh.add_argument("--json", action="store_true")
|
|
105
|
+
|
|
106
|
+
c = sub.add_parser("compare", help="compare two runs item by item")
|
|
107
|
+
c.add_argument("a")
|
|
108
|
+
c.add_argument("b")
|
|
109
|
+
c.add_argument("--examples", type=int, default=5)
|
|
110
|
+
c.add_argument("--json", action="store_true")
|
|
111
|
+
|
|
112
|
+
sub.add_parser("mcp", help="run the MCP server on stdio")
|
|
113
|
+
|
|
114
|
+
args = p.parse_args(argv)
|
|
115
|
+
h = store.home(args.home)
|
|
116
|
+
try:
|
|
117
|
+
if args.cmd == "suites":
|
|
118
|
+
rows = [suites.find(n, h).summary() for n in suites.available(h)]
|
|
119
|
+
_print(rows, args.json, lambda rs: "\n".join(
|
|
120
|
+
f"{s['name']:<22} {s['items']:>5} items {s['checker']:<12} {s['description']}" for s in rs))
|
|
121
|
+
elif args.cmd == "run":
|
|
122
|
+
suite = suites.find(args.suite, h)
|
|
123
|
+
think = None if args.think is None else args.think == "on"
|
|
124
|
+
be = make_backend(h, args.backend, args.model, args.base_url, args.temperature, args.seed,
|
|
125
|
+
args.num_ctx, think)
|
|
126
|
+
|
|
127
|
+
tty = sys.stderr.isatty()
|
|
128
|
+
|
|
129
|
+
def progress(done, total):
|
|
130
|
+
if tty and (done == total or done % 10 == 0):
|
|
131
|
+
print(f"\r{done}/{total}", end="", file=sys.stderr, flush=True)
|
|
132
|
+
elif not tty and (done == total or done % 50 == 0): # a log file: whole lines, fewer of them
|
|
133
|
+
print(f"{done}/{total}", file=sys.stderr, flush=True)
|
|
134
|
+
from .runner import run
|
|
135
|
+
meta = run(suite, be, h, args.limit, args.repeat, args.workers, args.label, progress)
|
|
136
|
+
if tty:
|
|
137
|
+
print(file=sys.stderr)
|
|
138
|
+
m, rows = store.load(h, meta["run"])
|
|
139
|
+
_print(analysis.summarize(m, rows), args.json, text_summary)
|
|
140
|
+
elif args.cmd == "runs":
|
|
141
|
+
rows = store.list_runs(h, args.suite)
|
|
142
|
+
_print(rows, args.json, lambda rs: "\n".join(
|
|
143
|
+
f"{m['run']:<50} {m['model']:<24} {m['items']:>5} items{' +repeat' if m['repeat'] else ''}"
|
|
144
|
+
for m in rs) or "no runs yet")
|
|
145
|
+
elif args.cmd == "show":
|
|
146
|
+
m, rows = store.load(h, args.run)
|
|
147
|
+
_print(analysis.summarize(m, rows, args.failures), args.json, text_summary)
|
|
148
|
+
elif args.cmd == "compare":
|
|
149
|
+
ma, ra = store.load(h, args.a)
|
|
150
|
+
mb, rb = store.load(h, args.b)
|
|
151
|
+
_print(analysis.compare(ma, ra, mb, rb, args.examples), args.json, text_compare)
|
|
152
|
+
elif args.cmd == "mcp":
|
|
153
|
+
from .server import mcp
|
|
154
|
+
mcp.run()
|
|
155
|
+
except (suites.SuiteError, store.RunError, BackendError) as e:
|
|
156
|
+
print(f"evalpair: {e}", file=sys.stderr)
|
|
157
|
+
sys.exit(2)
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
print("\ninterrupted; replies so far are cached, so the same command resumes", file=sys.stderr)
|
|
160
|
+
sys.exit(130)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
evalpair/runner.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Run a suite against one model and save the run.
|
|
2
|
+
|
|
3
|
+
Items are taken in file order (bundled suites are already a seeded random draw), so `limit=50` is the
|
|
4
|
+
same 50 items every time and two limited runs still pair up. With `repeat`, every item is sent a second
|
|
5
|
+
time as a separate call; how often the answer changes between the two passes is the run's noise floor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from . import __version__, store
|
|
17
|
+
from .backends import Backend
|
|
18
|
+
from .suites import Suite
|
|
19
|
+
|
|
20
|
+
REPEAT_TAG = "repeat"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _row(suite: Suite, item: dict, pass_no: int, reply: dict) -> dict:
|
|
24
|
+
c = suite.check(reply["text"], item)
|
|
25
|
+
return {"id": item["id"], "pass": pass_no, "group": item.get(suite.group) if suite.group else None,
|
|
26
|
+
"answer": c.answer, "score": c.score, "how": c.how, "text": reply["text"],
|
|
27
|
+
"truncated": reply.get("done_reason") == "length", "prompt_tokens": reply.get("prompt_tokens", 0),
|
|
28
|
+
"output_tokens": reply.get("output_tokens", 0), "seconds": round(reply.get("seconds", 0.0), 3),
|
|
29
|
+
"cached": reply.get("cached", False)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(suite: Suite, backend: Backend, home: str, limit: int | None = None, repeat: bool = False,
|
|
33
|
+
workers: int = 1, label: str | None = None,
|
|
34
|
+
progress: Callable[[int, int], None] | None = None) -> dict:
|
|
35
|
+
items = suite.items[:limit] if limit else list(suite.items)
|
|
36
|
+
jobs = [(it, 1) for it in items] + ([(it, 2) for it in items] if repeat else [])
|
|
37
|
+
started = dt.datetime.now(dt.timezone.utc)
|
|
38
|
+
run_id = store.check_id(label) if label else store.slug(
|
|
39
|
+
f"{suite.name}-{backend.model}-{started.strftime('%Y%m%d-%H%M%S')}")
|
|
40
|
+
t0 = time.time()
|
|
41
|
+
done, lock = [0], threading.Lock()
|
|
42
|
+
|
|
43
|
+
def one(job):
|
|
44
|
+
it, p = job
|
|
45
|
+
reply = backend.chat(suite.render(it), suite.max_tokens, REPEAT_TAG if p == 2 else "")
|
|
46
|
+
with lock:
|
|
47
|
+
done[0] += 1
|
|
48
|
+
n = done[0]
|
|
49
|
+
if progress:
|
|
50
|
+
progress(n, len(jobs))
|
|
51
|
+
return _row(suite, it, p, reply)
|
|
52
|
+
|
|
53
|
+
if workers > 1:
|
|
54
|
+
with ThreadPoolExecutor(workers) as ex:
|
|
55
|
+
rows = list(ex.map(one, jobs))
|
|
56
|
+
else:
|
|
57
|
+
rows = [one(j) for j in jobs]
|
|
58
|
+
meta = {
|
|
59
|
+
"run": run_id, "suite": suite.name, "suite_hash": suite.hash, "checker": suite.checker,
|
|
60
|
+
**backend.describe(), "options": backend.options, "think": backend.think, "max_tokens": suite.max_tokens,
|
|
61
|
+
"items": len(items), "repeat": repeat, "calls": backend.stats["calls"], "cache_hits": backend.stats["hits"],
|
|
62
|
+
"started": started.isoformat(timespec="seconds"),
|
|
63
|
+
"finished": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
|
64
|
+
"wall_seconds": round(time.time() - t0, 1), "evalpair": __version__,
|
|
65
|
+
}
|
|
66
|
+
store.save(home, meta, rows)
|
|
67
|
+
return meta
|
evalpair/server.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""MCP server front end (stdio). Four read-only tools over saved runs, and one that runs a suite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import anyio.from_thread
|
|
9
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
10
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
11
|
+
from mcp.types import ToolAnnotations
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from . import __version__, analysis, store, suites
|
|
15
|
+
from .backends import BackendError
|
|
16
|
+
from .cli import make_backend
|
|
17
|
+
|
|
18
|
+
INSTRUCTIONS = (
|
|
19
|
+
"evalpair runs an eval suite (questions with known answers, a prompt template and a deterministic checker) "
|
|
20
|
+
"against a model on Ollama or any OpenAI-compatible server, caches every call, and compares two runs item by "
|
|
21
|
+
"item. Scores come with 95% bootstrap intervals over items. A comparison says whether a score difference is "
|
|
22
|
+
"distinguishable from zero and whether answers changed more than the noise floor (the same model asked twice, "
|
|
23
|
+
"measured when a run is made with repeat=true). Prefer a comparison's verdict over reading two scores side by "
|
|
24
|
+
"side. Read a run's failures before trusting its score: unparsed or truncated replies are listed first."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
mcp = MCPServer("evalpair", instructions=INSTRUCTIONS, version=__version__)
|
|
28
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
|
|
29
|
+
RUNS = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True)
|
|
30
|
+
|
|
31
|
+
RunId = Annotated[str, Field(description="A run id from list_runs or run_eval.", min_length=1, max_length=120)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def home() -> str:
|
|
35
|
+
return store.home()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _guard(fn, *args, **kw):
|
|
39
|
+
try:
|
|
40
|
+
return fn(*args, **kw)
|
|
41
|
+
except (suites.SuiteError, store.RunError, BackendError) as e:
|
|
42
|
+
raise ToolError(str(e)) from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool(title="List eval suites", annotations=READ_ONLY)
|
|
46
|
+
def list_suites() -> dict[str, Any]:
|
|
47
|
+
"""Use to see which eval suites can be run: name, item count, checker and what each measures."""
|
|
48
|
+
h = home()
|
|
49
|
+
return {"suites": _guard(lambda: [suites.find(n, h).summary() for n in suites.available(h)]),
|
|
50
|
+
"your_suites_folder": f"{h}/suites"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool(title="List saved runs", annotations=READ_ONLY)
|
|
54
|
+
def list_runs(
|
|
55
|
+
suite: Annotated[str | None, Field(description="Only runs of this suite.")] = None,
|
|
56
|
+
limit: Annotated[int, Field(description="How many, newest first.", ge=1, le=200)] = 20,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Use to find earlier runs to show or compare: run id, suite, model, item count, whether it has a repeat pass."""
|
|
59
|
+
keep = ("run", "suite", "model", "backend", "items", "repeat", "finished", "digest")
|
|
60
|
+
return {"runs": [{k: m.get(k) for k in keep} for m in store.list_runs(home(), suite)[:limit]]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool(title="Run an eval suite", annotations=RUNS)
|
|
64
|
+
async def run_eval(
|
|
65
|
+
suite: Annotated[str, Field(description="A suite name from list_suites, or a path to a suite folder.")],
|
|
66
|
+
model: Annotated[str, Field(description="Model name as the server knows it, e.g. 'llama3.1:8b' on Ollama.")],
|
|
67
|
+
ctx: Context,
|
|
68
|
+
backend: Annotated[Literal["ollama", "openai"], Field(
|
|
69
|
+
description="'ollama' for Ollama's API; 'openai' for any OpenAI-compatible server.")] = "ollama",
|
|
70
|
+
base_url: Annotated[str | None, Field(
|
|
71
|
+
description="Server URL. Defaults: http://localhost:11434 (ollama), http://localhost:8000/v1 (openai).")] = None,
|
|
72
|
+
limit: Annotated[int | None, Field(description="Only the first N items; start small, runs take a while.",
|
|
73
|
+
ge=1)] = None,
|
|
74
|
+
repeat: Annotated[bool, Field(description="Send every item twice to measure the noise floor.")] = False,
|
|
75
|
+
temperature: Annotated[float, Field(ge=0, le=2)] = 0.0,
|
|
76
|
+
seed: Annotated[int | None, Field(description="Sampling seed, where the server supports one.")] = 7,
|
|
77
|
+
think: Annotated[bool | None, Field(description="Ollama thinking on or off, for models that support it.")] = None,
|
|
78
|
+
label: Annotated[str | None, Field(description="Run id to save under (letters, digits, . _ -).")] = None,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Use to run a suite against one model and save the run. Returns its score with an interval and its first failures. Calls the model; cached replies are reused."""
|
|
81
|
+
from .runner import run
|
|
82
|
+
h = home()
|
|
83
|
+
s = _guard(suites.find, suite, h)
|
|
84
|
+
be = _guard(make_backend, h, backend, model, base_url, temperature, seed, None, think)
|
|
85
|
+
n = min(limit or len(s.items), len(s.items)) * (2 if repeat else 1)
|
|
86
|
+
|
|
87
|
+
def progress(done, total):
|
|
88
|
+
if done == total or done % 5 == 0:
|
|
89
|
+
anyio.from_thread.run(ctx.report_progress, done, total, f"{done}/{total} calls")
|
|
90
|
+
|
|
91
|
+
meta = await anyio.to_thread.run_sync(lambda: _guard(run, s, be, h, limit, repeat, 1, label, progress))
|
|
92
|
+
m, rows = store.load(h, meta["run"])
|
|
93
|
+
return {**analysis.summarize(m, rows), "calls_made": meta["calls"], "calls_planned": n}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool(title="Show a run", annotations=READ_ONLY)
|
|
97
|
+
def show_run(
|
|
98
|
+
run: RunId,
|
|
99
|
+
failures: Annotated[int, Field(description="How many failed items to include.", ge=0, le=50)] = 5,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Use to see a saved run's score with its interval, the noise floor if it has a repeat, and the end of failed replies."""
|
|
102
|
+
m, rows = _guard(store.load, home(), run)
|
|
103
|
+
return analysis.summarize(m, rows, failures)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(title="Compare two runs", annotations=READ_ONLY)
|
|
107
|
+
def compare_runs(
|
|
108
|
+
a: Annotated[str, Field(description="The baseline run id.", min_length=1, max_length=120)],
|
|
109
|
+
b: Annotated[str, Field(description="The run to compare against it.", min_length=1, max_length=120)],
|
|
110
|
+
examples: Annotated[int, Field(description="Items to show that got worse and better.", ge=0, le=20)] = 5,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Use to compare two runs of the same suite item by item: score difference with a paired interval, flips each way, and whether answers moved more than the noise floor."""
|
|
113
|
+
h = home()
|
|
114
|
+
ma, ra = _guard(store.load, h, a)
|
|
115
|
+
mb, rb = _guard(store.load, h, b)
|
|
116
|
+
return analysis.compare(ma, ra, mb, rb, examples)
|
evalpair/stats.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Percentile bootstrap over items, never over single calls.
|
|
2
|
+
|
|
3
|
+
Every run of a suite answers the same items, so a comparison is paired: resample items, and an item's
|
|
4
|
+
score under both runs moves together. With a `group` (e.g. MMLU subject), items are resampled within each
|
|
5
|
+
group so every resample keeps the suite's mix. Pure Python, seeded, so the same inputs give the same interval.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
|
|
14
|
+
BOOT = 2000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mean(xs) -> float:
|
|
18
|
+
xs = list(xs)
|
|
19
|
+
return sum(xs) / len(xs) if xs else math.nan
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _percentile(sorted_xs: list[float], q: float) -> float:
|
|
23
|
+
# linear interpolation, as numpy's default
|
|
24
|
+
pos = (len(sorted_xs) - 1) * q
|
|
25
|
+
lo, hi = math.floor(pos), math.ceil(pos)
|
|
26
|
+
return sorted_xs[lo] + (sorted_xs[hi] - sorted_xs[lo]) * (pos - lo)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def mean_ci(values, groups=None, n_boot: int = BOOT, seed: int = 0, level: float = 0.95) -> dict:
|
|
30
|
+
values = [float(v) for v in values]
|
|
31
|
+
n = len(values)
|
|
32
|
+
if n == 0:
|
|
33
|
+
return {"mean": math.nan, "lo": math.nan, "hi": math.nan, "n": 0}
|
|
34
|
+
groups = list(groups) if groups is not None else [0] * n
|
|
35
|
+
by = defaultdict(list)
|
|
36
|
+
for v, g in zip(values, groups):
|
|
37
|
+
by[g].append(v)
|
|
38
|
+
strata = list(by.values())
|
|
39
|
+
rng = random.Random(seed)
|
|
40
|
+
boots = []
|
|
41
|
+
for _ in range(n_boot):
|
|
42
|
+
s = 0.0
|
|
43
|
+
for vs in strata:
|
|
44
|
+
s += sum(rng.choices(vs, k=len(vs)))
|
|
45
|
+
boots.append(s / n)
|
|
46
|
+
boots.sort()
|
|
47
|
+
a = (1 - level) / 2
|
|
48
|
+
return {"mean": sum(values) / n, "lo": _percentile(boots, a), "hi": _percentile(boots, 1 - a), "n": n}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def excludes_zero(ci: dict) -> bool:
|
|
52
|
+
return ci["n"] > 0 and (ci["lo"] > 0 or ci["hi"] < 0)
|
evalpair/store.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Where evalpair keeps things: `$EVALPAIR_HOME`, or ~/.evalpair.
|
|
2
|
+
|
|
3
|
+
<home>/cache.sqlite every model reply, keyed by request
|
|
4
|
+
<home>/runs/<run>/meta.json what was run: suite and its hash, model, backend, options, counts
|
|
5
|
+
<home>/runs/<run>/rows.jsonl one line per item and pass: the reply, the parsed answer, the score
|
|
6
|
+
<home>/suites/<name>/ your own suites
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RunError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def home(path: str | None = None) -> str:
|
|
23
|
+
return os.path.abspath(os.path.expanduser(path or os.environ.get("EVALPAIR_HOME") or "~/.evalpair"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_path(h: str) -> str:
|
|
27
|
+
return os.path.join(h, "cache.sqlite")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def runs_dir(h: str) -> str:
|
|
31
|
+
return os.path.join(h, "runs")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def slug(s: str) -> str:
|
|
35
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", s).strip("-")[:60] or "run"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_id(run_id: str) -> str:
|
|
39
|
+
if not RUN_ID.match(run_id or "") or ".." in run_id:
|
|
40
|
+
raise RunError(f"bad run id {run_id!r}: use letters, digits, '.', '_' and '-'")
|
|
41
|
+
return run_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_path(h: str, run_id: str) -> str:
|
|
45
|
+
return os.path.join(runs_dir(h), check_id(run_id))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save(h: str, meta: dict, rows: list[dict]) -> str:
|
|
49
|
+
d = run_path(h, meta["run"])
|
|
50
|
+
os.makedirs(d, exist_ok=True)
|
|
51
|
+
tmp = os.path.join(d, "rows.jsonl.tmp")
|
|
52
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
53
|
+
for r in rows:
|
|
54
|
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
55
|
+
os.replace(tmp, os.path.join(d, "rows.jsonl"))
|
|
56
|
+
with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(meta, f, indent=1, ensure_ascii=False)
|
|
58
|
+
return d
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load(h: str, run_id: str) -> tuple[dict, list[dict]]:
|
|
62
|
+
d = run_path(h, run_id)
|
|
63
|
+
if not os.path.isfile(os.path.join(d, "meta.json")):
|
|
64
|
+
known = [m["run"] for m in list_runs(h)][:10]
|
|
65
|
+
raise RunError(f"no run {run_id!r}" + (f"; recent: {', '.join(known)}" if known else ""))
|
|
66
|
+
with open(os.path.join(d, "meta.json"), encoding="utf-8") as f:
|
|
67
|
+
meta = json.load(f)
|
|
68
|
+
with open(os.path.join(d, "rows.jsonl"), encoding="utf-8") as f:
|
|
69
|
+
rows = [json.loads(line) for line in f if line.strip()]
|
|
70
|
+
return meta, rows
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_runs(h: str, suite: str | None = None) -> list[dict]:
|
|
74
|
+
"""Newest first."""
|
|
75
|
+
out = []
|
|
76
|
+
d = runs_dir(h)
|
|
77
|
+
if not os.path.isdir(d):
|
|
78
|
+
return out
|
|
79
|
+
for name in os.listdir(d):
|
|
80
|
+
p = os.path.join(d, name, "meta.json")
|
|
81
|
+
if os.path.isfile(p):
|
|
82
|
+
with open(p, encoding="utf-8") as f:
|
|
83
|
+
m = json.load(f)
|
|
84
|
+
if suite is None or m.get("suite") == suite:
|
|
85
|
+
out.append(m)
|
|
86
|
+
return sorted(out, key=lambda m: m.get("finished", ""), reverse=True)
|