mirror of https://github.com/razor-ai/soup.git
224 lines
6.6 KiB
Python
224 lines
6.6 KiB
Python
"""Human evaluation — terminal-based A/B comparison with Elo ratings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
ELO_K = 32
|
|
ELO_DEFAULT = 1500
|
|
|
|
|
|
@dataclass
|
|
class HumanJudgment:
|
|
"""A single human judgment in an A/B comparison."""
|
|
|
|
prompt: str
|
|
response_a: str
|
|
response_b: str
|
|
model_a: str
|
|
model_b: str
|
|
winner: str # "a", "b", or "tie"
|
|
|
|
|
|
@dataclass
|
|
class EloRating:
|
|
"""Elo rating for a model."""
|
|
|
|
model: str
|
|
rating: float = ELO_DEFAULT
|
|
wins: int = 0
|
|
losses: int = 0
|
|
ties: int = 0
|
|
total: int = 0
|
|
|
|
|
|
@dataclass
|
|
class HumanEvalResults:
|
|
"""Aggregated results from human evaluation."""
|
|
|
|
judgments: list[HumanJudgment] = field(default_factory=list)
|
|
ratings: dict[str, EloRating] = field(default_factory=dict)
|
|
|
|
def compute_ratings(self) -> None:
|
|
"""Compute Elo ratings from all judgments."""
|
|
self.ratings = {}
|
|
for judgment in self.judgments:
|
|
self._ensure_model(judgment.model_a)
|
|
self._ensure_model(judgment.model_b)
|
|
|
|
rating_a = self.ratings[judgment.model_a]
|
|
rating_b = self.ratings[judgment.model_b]
|
|
|
|
# Expected scores
|
|
exp_a = _expected_score(rating_a.rating, rating_b.rating)
|
|
exp_b = _expected_score(rating_b.rating, rating_a.rating)
|
|
|
|
# Actual scores
|
|
if judgment.winner == "a":
|
|
actual_a, actual_b = 1.0, 0.0
|
|
rating_a.wins += 1
|
|
rating_b.losses += 1
|
|
elif judgment.winner == "b":
|
|
actual_a, actual_b = 0.0, 1.0
|
|
rating_a.losses += 1
|
|
rating_b.wins += 1
|
|
else:
|
|
actual_a, actual_b = 0.5, 0.5
|
|
rating_a.ties += 1
|
|
rating_b.ties += 1
|
|
|
|
rating_a.rating += ELO_K * (actual_a - exp_a)
|
|
rating_b.rating += ELO_K * (actual_b - exp_b)
|
|
rating_a.total += 1
|
|
rating_b.total += 1
|
|
|
|
def _ensure_model(self, model: str) -> None:
|
|
if model not in self.ratings:
|
|
self.ratings[model] = EloRating(model=model)
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Serialize results for storage."""
|
|
return {
|
|
"judgments": [
|
|
{
|
|
"prompt": j.prompt,
|
|
"response_a": j.response_a,
|
|
"response_b": j.response_b,
|
|
"model_a": j.model_a,
|
|
"model_b": j.model_b,
|
|
"winner": j.winner,
|
|
}
|
|
for j in self.judgments
|
|
],
|
|
"ratings": {
|
|
name: {
|
|
"rating": r.rating,
|
|
"wins": r.wins,
|
|
"losses": r.losses,
|
|
"ties": r.ties,
|
|
"total": r.total,
|
|
}
|
|
for name, r in sorted(
|
|
self.ratings.items(),
|
|
key=lambda item: item[1].rating,
|
|
reverse=True,
|
|
)
|
|
},
|
|
}
|
|
|
|
|
|
def _expected_score(rating_self: float, rating_opponent: float) -> float:
|
|
"""Elo expected score calculation."""
|
|
return 1.0 / (1.0 + math.pow(10, (rating_opponent - rating_self) / 400.0))
|
|
|
|
|
|
MAX_PROMPTS = 10_000
|
|
|
|
|
|
def load_prompts(path: Path) -> list[dict]:
|
|
"""Load evaluation prompts from JSONL.
|
|
|
|
Each line: {"prompt": "...", "category": "..."}
|
|
Capped at MAX_PROMPTS to prevent unbounded memory usage.
|
|
"""
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Prompts file not found: {path}")
|
|
|
|
prompts: list[dict] = []
|
|
with open(path, encoding="utf-8") as fh:
|
|
for line_num, line in enumerate(fh, 1):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
if len(prompts) >= MAX_PROMPTS:
|
|
raise ValueError(
|
|
f"Prompts file exceeds maximum of {MAX_PROMPTS} entries "
|
|
f"(stopped at line {line_num})"
|
|
)
|
|
|
|
try:
|
|
row = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(
|
|
f"Invalid JSON on line {line_num}: {exc}"
|
|
) from exc
|
|
if "prompt" not in row:
|
|
raise ValueError(
|
|
f"Line {line_num}: missing required field 'prompt'"
|
|
)
|
|
prompts.append(row)
|
|
return prompts
|
|
|
|
|
|
def save_results(results: HumanEvalResults, path: Path) -> None:
|
|
"""Save human eval results to a JSON file."""
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(results.to_dict(), fh, indent=2)
|
|
|
|
|
|
def load_results(path: Path) -> HumanEvalResults:
|
|
"""Load human eval results from a JSON file."""
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Results file not found: {path}")
|
|
|
|
with open(path, encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
|
|
eval_results = HumanEvalResults()
|
|
for jdata in data.get("judgments", []):
|
|
eval_results.judgments.append(HumanJudgment(
|
|
prompt=jdata["prompt"],
|
|
response_a=jdata["response_a"],
|
|
response_b=jdata["response_b"],
|
|
model_a=jdata["model_a"],
|
|
model_b=jdata["model_b"],
|
|
winner=jdata["winner"],
|
|
))
|
|
|
|
eval_results.compute_ratings()
|
|
return eval_results
|
|
|
|
|
|
def run_human_eval_session(
|
|
prompts: list[dict],
|
|
model_a_name: str,
|
|
model_b_name: str,
|
|
generate_a: Optional[object] = None,
|
|
generate_b: Optional[object] = None,
|
|
responses_a: Optional[list[str]] = None,
|
|
responses_b: Optional[list[str]] = None,
|
|
) -> HumanEvalResults:
|
|
"""Run an interactive human evaluation session.
|
|
|
|
Either provide generate functions or pre-computed responses.
|
|
When running non-interactively (testing), pass responses directly.
|
|
"""
|
|
results = HumanEvalResults()
|
|
|
|
for idx, prompt_data in enumerate(prompts):
|
|
prompt = prompt_data["prompt"]
|
|
|
|
if responses_a and responses_b:
|
|
resp_a = responses_a[idx] if idx < len(responses_a) else ""
|
|
resp_b = responses_b[idx] if idx < len(responses_b) else ""
|
|
else:
|
|
resp_a = generate_a(prompt) if generate_a else ""
|
|
resp_b = generate_b(prompt) if generate_b else ""
|
|
|
|
results.judgments.append(HumanJudgment(
|
|
prompt=prompt,
|
|
response_a=resp_a,
|
|
response_b=resp_b,
|
|
model_a=model_a_name,
|
|
model_b=model_b_name,
|
|
winner="tie", # Default; interactive UI sets this
|
|
))
|
|
|
|
results.compute_ratings()
|
|
return results
|