Skip to content

Commit 353835e

Browse files
authored
Merge pull request #1 from open-gitagent/feat/evaluate-harness
2 parents 6d43bb4 + e42421e commit 353835e

7 files changed

Lines changed: 476 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
**A fine-tuning SDK. Any open model — with any method, on any hardware, for any harness.**
1515

16-
Open source · built by [Lyzr Research Labs](https://lyzr.ai) · maintained by [Khush Patel](mailto:khush@lyzr.ai) · `slm♥`
16+
Open source · built by [Lyzr Research Labs](https://lyzr.ai) · maintained by [Khush Patel](mailto:khush@lyzr.ai) & [Shreyas Kapale](mailto:shreyas@lyzr.ai) · `slm♥`
1717

1818
```bash
1919
pip install shadowlm # batteries included — the full training stack

examples/evaluate.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Score a model on a task — quality, not training loss.
2+
3+
`finetune` tells you the loss went down; `evaluate` tells you whether the model
4+
actually does the job. Point a loaded model at a dataset, pick a metric, get one
5+
number plus a per-row breakdown.
6+
7+
python examples/evaluate.py # runs from any working directory
8+
"""
9+
10+
from pathlib import Path
11+
12+
import shadowlm as slm
13+
14+
MODEL = "mlx-community/Qwen2.5-0.5B-Instruct-4bit"
15+
# Resolve the dataset next to this script, so the demo runs from any CWD.
16+
DATA = Path(__file__).resolve().parent / "sample_dataset.jsonl"
17+
18+
# A dataset with a prompt column (instruction/question/...) and an answer column.
19+
ds = slm.Dataset.from_jsonl(DATA)
20+
model = slm.load(MODEL)
21+
22+
# contains-match: 1.0 when the expected answer appears in the output ----------
23+
res = slm.evaluate(model, ds, metric="contains")
24+
print(res) # EvalResult(metric='contains', score=..., n=...)
25+
print("per-row:", res.sparkline())
26+
27+
# the rows it did worst on ----------------------------------------------------
28+
for ex in res.worst(3):
29+
print(f" {ex['score']:.1f} {ex['input'][:50]!r}{ex['output'][:50]!r}")
30+
31+
# LLM-as-judge scoring (here the model judges itself; use a stronger judge for real)
32+
judged = slm.evaluate(model, ds, judge=model)
33+
print("judge score:", round(judged.score, 3))

shadowlm/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .capture import CaptureProxy, capture
2121
from .checkpoints import Checkpoint
2222
from .data import Dataset
23+
from .eval import EvalResult, evaluate
2324
from .models import Model, Reply, load
2425
from .rl import Trajectory, TrajectoryGroup, judge_group
2526
from .training import Metric, TrainConfig, TrainingRun
@@ -29,6 +30,8 @@
2930
__all__ = [
3031
"APORun",
3132
"optimize_prompt",
33+
"evaluate",
34+
"EvalResult",
3235
"CaptureProxy",
3336
"capture",
3437
"Checkpoint",

shadowlm/apo.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ def _cols(row: dict) -> tuple[str, str | None]:
6767
return p, a
6868

6969

70+
def _norm(text: str) -> str:
71+
"""Whitespace-collapsed, lowercased text for tolerant string matching."""
72+
return " ".join(str(text).lower().split())
73+
74+
7075
def _contains_score(output: str, expected: str) -> float:
7176
"""Default scorer: 1.0 if the expected answer appears in the output."""
72-
o = " ".join(str(output).lower().split())
73-
e = " ".join(str(expected).lower().split())
77+
o, e = _norm(output), _norm(expected)
7478
return 1.0 if e and e in o else 0.0
7579

7680

@@ -218,15 +222,47 @@ def _propose(optimizer, current, failures, k, temperature, max_new_tokens) -> li
218222
return out
219223

220224

225+
# The shared single-answer judge: a short rubric + a tolerant number parse, used
226+
# by both APO and `evaluate` so they agree on what a good answer is. (The RL judge
227+
# in rl.py is a *group-relative* ranker — it can't score a lone eval row — so eval
228+
# reuses this scorer, not judge_group.)
229+
_JUDGE_RUBRIC = (
230+
"Reward correctness first, then helpfulness, then concision. "
231+
"Penalize factual errors and ignored instructions."
232+
)
233+
234+
235+
def _parse_judge_score(raw: str) -> float:
236+
"""Tolerantly pull a 0–1 score out of a judge's reply.
237+
238+
Small judges phrase scores many ways — a bare decimal ("0.7"), a ratio
239+
("7/10"), or an integer rating ("8" → 0.8). Handle all three, then clamp.
240+
"""
241+
import re # noqa: PLC0415
242+
243+
s = str(raw)
244+
m = re.search(r"(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)", s) # "7/10"
245+
if m:
246+
num, den = float(m.group(1)), float(m.group(2))
247+
return max(0.0, min(1.0, num / den)) if den else 0.0
248+
m = re.search(r"\d+\.\d+", s) # a decimal like "0.7"
249+
if m:
250+
return max(0.0, min(1.0, float(m.group())))
251+
m = re.search(r"\d+", s) # a bare integer — assume an x/10 rating above 1
252+
if m:
253+
v = float(m.group())
254+
return max(0.0, min(1.0, v if v <= 1 else v / 10.0))
255+
return 0.0
256+
257+
221258
def _judge_one(judge, question: str, output: str, expected: str) -> float:
222259
prompt = (
223260
"Score how well the ANSWER responds to the INPUT from 0.0 to 1.0.\n"
261+
f"{_JUDGE_RUBRIC}\n"
224262
f"INPUT: {question}\nANSWER: {output}\n"
225263
+ (f"REFERENCE: {expected}\n" if expected else "")
226264
+ 'Reply with ONLY a number like 0.7.'
227265
)
228266
raw = str(judge.chat([{"role": "user", "content": prompt}],
229267
temperature=0.0, max_new_tokens=8))
230-
import re # noqa: PLC0415
231-
m = re.search(r"[01](?:\.\d+)?", raw)
232-
return max(0.0, min(1.0, float(m.group()))) if m else 0.0
268+
return _parse_judge_score(raw)

shadowlm/cli.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,63 @@ def export(
378378
console.print(f"exported [slm]{format}[/slm] → [slm]{out}[/slm]")
379379

380380

381+
@app.command(name="eval", rich_help_panel="Models")
382+
def evaluate_cmd(
383+
target: Annotated[str, typer.Argument(help="model name, or an adapter directory")],
384+
dataset: Annotated[str, typer.Argument(
385+
help="dataset (.jsonl/.json/.csv/.parquet) with a prompt + answer column")],
386+
metric: Annotated[str, typer.Option(help="contains | exact | judge")] = "contains",
387+
judge: Annotated[Optional[str], typer.Option(
388+
help="judge model (HF id) — implies --metric judge")] = None,
389+
system: Annotated[Optional[str], typer.Option(help="system prompt for every query")] = None,
390+
sample: Annotated[Optional[int], typer.Option(help="evaluate only the first N rows")] = None,
391+
show: Annotated[int, typer.Option(help="how many worst examples to show")] = 5,
392+
model: Annotated[Optional[str], typer.Option("--model", "-m",
393+
help="base model override for adapter dirs")] = None,
394+
backend: Annotated[str, typer.Option(help="auto | mlx | torch")] = "auto",
395+
load_in_4bit: Annotated[bool, typer.Option("--load-in-4bit")] = False,
396+
max_new_tokens: Annotated[int, typer.Option("--max-new-tokens")] = 256,
397+
hf_token: Annotated[Optional[str], typer.Option("--hf-token", envvar="HF_TOKEN")] = None,
398+
):
399+
"""Score a model on a dataset — task quality, not training loss."""
400+
from .data import Dataset # noqa: PLC0415
401+
from .eval import evaluate as _evaluate # noqa: PLC0415
402+
from .models import load # noqa: PLC0415
403+
404+
if metric not in ("contains", "exact", "judge"):
405+
raise typer.BadParameter("--metric must be 'contains', 'exact', or 'judge'")
406+
# Validate before loading the model — otherwise the user waits for a full
407+
# model download only to hit a scorer error.
408+
if metric == "judge" and not judge:
409+
raise typer.BadParameter("--metric judge needs a judge model: --judge <hf-id>")
410+
_maybe_set_token(hf_token)
411+
m = _resolve_target(target, model, backend, load_in_4bit)
412+
judge_model = load(judge, backend=backend) if judge else None
413+
data = Dataset.load(dataset)
414+
415+
result = _evaluate(m, data, metric=metric, judge=judge_model, system=system,
416+
sample=sample, max_new_tokens=max_new_tokens, verbose=False)
417+
418+
console.print(
419+
f"[slm]{result.metric}[/slm] score [ok]{result.score:.3f}[/ok] "
420+
f"over {result.n} rows {result.sparkline()}")
421+
worst = result.worst(show)
422+
if worst and result.score < 1.0:
423+
table = Table(title="lowest-scoring examples", title_style="slm",
424+
header_style="slm", border_style="muted")
425+
for col in ("score", "input", "expected", "output"):
426+
table.add_column(col, no_wrap=(col == "score"))
427+
for ex in worst:
428+
table.add_row(f"{ex['score']:.2f}", _trunc(ex["input"]),
429+
_trunc(ex["expected"]), _trunc(ex["output"]))
430+
console.print(table)
431+
432+
433+
def _trunc(text: str, n: int = 60) -> str:
434+
text = " ".join(str(text).split())
435+
return text if len(text) <= n else text[: n - 1] + "…"
436+
437+
381438
# ---- runs / history ---------------------------------------------------------
382439
@app.command(rich_help_panel="Runs")
383440
def runs(

shadowlm/eval.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Evaluation — score a model on a task, not just its training loss.
2+
3+
`finetune` reports next-token *loss*; this reports task *quality*. Point a loaded
4+
model at a dataset, pick a metric, and get one number plus a per-row breakdown:
5+
6+
res = slm.evaluate(model, "qa.jsonl") # contains-match
7+
res = slm.evaluate(model, ds, metric="exact") # exact-match
8+
res = slm.evaluate(model, ds, judge=judge) # LLM-as-judge
9+
res = slm.evaluate(model, ds, metric=my_score_fn) # custom scorer
10+
print(res.score, res.sparkline())
11+
12+
This is the front half of an "eval gate" — the same capture/judge primitives,
13+
turned toward *measuring* a model instead of training one. Pure ShadowLM: it
14+
only needs a loaded model's `.chat()`. The built-in scorers are reused from APO
15+
(`apo._contains_score`, `apo._judge_one`) so eval and prompt-optimization agree
16+
on what a good answer is.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from dataclasses import dataclass, field
22+
23+
from .data import Dataset
24+
25+
26+
def _exact_score(output: str, expected: str) -> float:
27+
"""1.0 when the output equals the expected answer (case/space-insensitive)."""
28+
from .apo import _norm # noqa: PLC0415
29+
30+
o, e = _norm(output), _norm(expected)
31+
return 1.0 if e and o == e else 0.0
32+
33+
34+
@dataclass
35+
class EvalResult:
36+
"""The outcome of an `evaluate` run: an aggregate score plus per-row detail."""
37+
38+
metric: str
39+
score: float # mean of `scores`
40+
scores: list[float] = field(default_factory=list)
41+
examples: list[dict] = field(default_factory=list) # [{input, output, expected, score}]
42+
n: int = 0
43+
44+
def sparkline(self) -> str:
45+
"""A tiny unicode bar of per-row scores — handy in a REPL or log line."""
46+
if not self.scores:
47+
return ""
48+
bars = "▁▂▃▄▅▆▇█"
49+
lo, hi = min(self.scores), max(self.scores)
50+
rng = (hi - lo) or 1.0
51+
return "".join(bars[min(7, int((s - lo) / rng * 7))] for s in self.scores)
52+
53+
def worst(self, k: int = 5) -> list[dict]:
54+
"""The k lowest-scoring examples (for eyeballing where the model fails)."""
55+
return sorted(self.examples, key=lambda e: e["score"])[:k]
56+
57+
def to_dict(self) -> dict:
58+
return {"metric": self.metric, "score": self.score, "n": self.n,
59+
"scores": self.scores, "examples": self.examples}
60+
61+
def __repr__(self) -> str:
62+
return f"EvalResult(metric={self.metric!r}, score={self.score:.4f}, n={self.n})"
63+
64+
65+
def _row_io(row: dict, fmt: str) -> tuple[list[dict], str]:
66+
"""Pull (history, expected_answer) out of a row, by dataset format.
67+
68+
`history` is the conversation to feed the model — a full multi-turn prefix
69+
for chat rows, or a single user turn for QA/preference rows. `expected` may
70+
be "" when the dataset carries no reference answer (e.g. judge scoring on
71+
prompts alone).
72+
"""
73+
from .data import CHAT, PREFERENCE # noqa: PLC0415
74+
75+
if fmt == CHAT or "messages" in row:
76+
msgs = [{"role": m.get("role", "user"), "content": m.get("content") or ""}
77+
for m in row.get("messages", [])]
78+
# Everything up to the final assistant turn is context; that turn is the
79+
# reference — so a multi-turn row is answered in its full conversation,
80+
# not scored as "answer the opening question".
81+
last_asst = next((i for i in range(len(msgs) - 1, -1, -1)
82+
if msgs[i]["role"] == "assistant"), None)
83+
if last_asst is None:
84+
return (msgs or [{"role": "user", "content": ""}]), ""
85+
history = msgs[:last_asst] or [{"role": "user", "content": ""}]
86+
return history, msgs[last_asst]["content"]
87+
if fmt == PREFERENCE or ("chosen" in row and "prompt" in row):
88+
return [{"role": "user", "content": str(row.get("prompt", ""))}], \
89+
str(row.get("chosen", ""))
90+
# instruction / QA / raw dict — auto-detect the prompt & answer columns
91+
from .apo import _cols # noqa: PLC0415
92+
93+
pcol, acol = _cols(row)
94+
if not pcol:
95+
from .apo import _PROMPT_KEYS # noqa: PLC0415
96+
97+
raise ValueError(
98+
f"no prompt column found in row (looked for {_PROMPT_KEYS}); "
99+
"pass chat-format rows or a dataset with a prompt/question column")
100+
prompt = str(row[pcol])
101+
# alpaca-style extra context column, when distinct from the prompt
102+
if pcol != "input" and row.get("input"):
103+
prompt = f"{prompt}\n\n{row['input']}"
104+
return [{"role": "user", "content": prompt}], \
105+
(str(row.get(acol, "")) if acol else "")
106+
107+
108+
def _resolve_scorer(metric, judge):
109+
"""Map the metric arg to a scorer `(output, expected, prompt) -> float`."""
110+
if callable(metric):
111+
return metric, getattr(metric, "__name__", "custom")
112+
from .apo import _contains_score, _judge_one # noqa: PLC0415
113+
114+
if metric == "contains":
115+
return (lambda out, exp, q: _contains_score(out, exp)), "contains"
116+
if metric == "exact":
117+
return (lambda out, exp, q: _exact_score(out, exp)), "exact"
118+
if metric == "judge":
119+
if judge is None:
120+
raise ValueError("metric='judge' needs a judge model: evaluate(..., judge=model)")
121+
return (lambda out, exp, q: _judge_one(judge, q, out, exp)), "judge"
122+
raise ValueError(
123+
f"unknown metric {metric!r} (expected 'contains', 'exact', 'judge', or a callable)")
124+
125+
126+
def evaluate(
127+
model,
128+
data: Dataset | list[dict] | str,
129+
*,
130+
metric="contains",
131+
judge=None,
132+
system: str | None = None,
133+
sample: int | None = None,
134+
max_new_tokens: int = 256,
135+
temperature: float = 0.0,
136+
verbose: bool = True,
137+
) -> EvalResult:
138+
"""Score `model` on `data`, returning an `EvalResult`.
139+
140+
model: a loaded shadowlm Model (answers each row via `.chat`).
141+
data: a Dataset, rows, or a path to a dataset file (jsonl/json/csv/parquet).
142+
metric: "contains" (default — expected answer appears in the output), "exact"
143+
(normalized equality), "judge" (LLM-as-judge, needs `judge=`), or a custom
144+
callable `(output, expected, prompt) -> float in [0, 1]`.
145+
judge: a Model that scores answers 0–1. Passing it defaults `metric` to "judge".
146+
system: optional system prompt prepended to every query.
147+
sample: evaluate only the first N rows.
148+
temperature: generation temperature — 0.0 (default) for deterministic scoring.
149+
"""
150+
if isinstance(data, str):
151+
data = Dataset.load(data)
152+
fmt = data.format if isinstance(data, Dataset) else None
153+
rows = list(data.rows if isinstance(data, Dataset) else data)
154+
if sample is not None:
155+
rows = rows[:sample]
156+
if not rows:
157+
raise ValueError("evaluate needs at least one row")
158+
if judge is not None and metric == "contains":
159+
metric = "judge" # passing a judge implies judge scoring
160+
if fmt is None:
161+
from .data import _detect_format # noqa: PLC0415
162+
163+
fmt = _detect_format(rows)
164+
scorer, metric_name = _resolve_scorer(metric, judge)
165+
166+
scores: list[float] = []
167+
examples: list[dict] = []
168+
for r in rows:
169+
history, expected = _row_io(r, fmt)
170+
msgs = ([{"role": "system", "content": system}] if system else []) + history
171+
out = str(model.chat(msgs, temperature=temperature, max_new_tokens=max_new_tokens))
172+
# the last user turn is the "question" passed to a judge / shown in output
173+
question = next((m["content"] for m in reversed(history)
174+
if m["role"] == "user"), "")
175+
s = max(0.0, min(1.0, float(scorer(out, expected, question))))
176+
scores.append(s)
177+
examples.append({"input": question, "output": out, "expected": expected, "score": s})
178+
179+
score = sum(scores) / len(scores)
180+
if verbose:
181+
print(f"[eval] {metric_name} · {score:.3f} over {len(scores)} rows", flush=True)
182+
return EvalResult(metric=metric_name, score=score, scores=scores,
183+
examples=examples, n=len(scores))

0 commit comments

Comments
 (0)