379 lines
14 KiB
Python
379 lines
14 KiB
Python
"""Scorer primitives: compare an extracted prediction against the target.
|
|
|
|
Contract: fn(pred: str, target, sample: Sample, ctx: ScoreContext) -> (scores, details)
|
|
scores: {'acc': 1.0} details: {'acc': {...audit info...}}
|
|
Register: @register_scorer('exact')
|
|
Look up: get_scorer('exact') / make_scorers({'acc': 'exact', ...})
|
|
|
|
Four scoring paradigms (mirrors the benchmark survey):
|
|
text compare -- exact / math_equal / em_f1 / alias_match [implemented]
|
|
LLM-as-judge -- llm_judge [needs ModelAdapter; wired via ctx.judge]
|
|
code execution -- execution [needs Sandbox layer; raises NotReady]
|
|
environment reward-- env_reward [needs Agent loop; raises NotReady]
|
|
"""
|
|
|
|
import re
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from ..data.sample import Sample
|
|
from .registry import EvalRegistry
|
|
|
|
ScorerFn = Callable[[str, Any, Sample, 'ScoreContext'], Tuple[Dict[str, float], Dict[str, Any]]]
|
|
|
|
SCORER_REGISTRY = EvalRegistry('scorer')
|
|
|
|
|
|
def register_scorer(name: str):
|
|
def decorator(fn: ScorerFn) -> ScorerFn:
|
|
SCORER_REGISTRY.register(name, fn)
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
|
|
def get_scorer(name: str) -> ScorerFn:
|
|
return SCORER_REGISTRY.get(name)
|
|
|
|
|
|
class ScoreContext(BaseModel):
|
|
"""Everything a scorer may need beyond (pred, target, sample).
|
|
|
|
judge: optional callable(prompt_messages) -> str. Provided by the runner
|
|
when a ModelAdapter is configured; llm_judge scorers raise NotReady if
|
|
it is None (explicit, never silently wrong).
|
|
"""
|
|
|
|
class Config:
|
|
arbitrary_types_allowed = True
|
|
|
|
judge: Optional[Callable] = None
|
|
judge_model: str = ''
|
|
params: Dict[str, Any] = {}
|
|
|
|
|
|
class LayerNotReady(RuntimeError):
|
|
"""A scoring paradigm needs a layer that is not built yet (sandbox/agent)."""
|
|
|
|
|
|
# ------------------------- normalization helpers -------------------------
|
|
|
|
|
|
def _strip_string(s: str) -> str:
|
|
"""Light math normalization (subset of Hendrycks/Qwen strip_string)."""
|
|
s = s.strip()
|
|
s = re.sub(r'\\text\{(.+?)\}', r'\1', s)
|
|
s = re.sub(r'\\!|\\,|\\;|\\ ', '', s)
|
|
s = s.replace('\\%', '%').replace('\\$', '$').replace('$', '').replace('%', '')
|
|
s = s.replace('^{\\circ}', '').replace('^\\circ', '')
|
|
s = re.sub(r'(\d),(\d{3})', r'\1\2', s)
|
|
s = re.sub(r'\.0+(?=$|[^0-9])', '', s)
|
|
if len(s) > 1 and s[0] == '{' and s[-1] == '}':
|
|
s = s[1:-1]
|
|
s = s.replace(' ', '').lower()
|
|
return s
|
|
|
|
|
|
def normalize_text(value: str, mode: str = 'math') -> str:
|
|
if mode == 'raw':
|
|
return (value or '').strip()
|
|
if mode == 'numeric':
|
|
v = _strip_string(value)
|
|
try:
|
|
f = float(v.replace(',', ''))
|
|
return str(int(f)) if f == int(f) else str(f)
|
|
except ValueError:
|
|
return v
|
|
return _strip_string(value)
|
|
|
|
|
|
def _targets_list(target) -> List[str]:
|
|
if target is None:
|
|
return []
|
|
if isinstance(target, list):
|
|
return [str(t) for t in target]
|
|
return [str(target)]
|
|
|
|
|
|
# ------------------------- text-compare scorers -------------------------
|
|
|
|
|
|
@register_scorer('exact')
|
|
def exact(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""Exact match after shared normalization. mode: raw|math|numeric."""
|
|
mode = ctx.params.get('mode', 'math')
|
|
norm = normalize_text(pred or '', mode)
|
|
hit = int(any(norm == normalize_text(t, mode) for t in _targets_list(target)))
|
|
return {'acc': float(hit)}, {'acc': {'mode': mode, 'normalized_pred': norm}}
|
|
|
|
|
|
@register_scorer('math_equal')
|
|
def math_equal(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""Normalized equality, then optional sympy symbolic equivalence.
|
|
|
|
sympy path is tried only when available and only for non-identical
|
|
strings; identical normalized strings short-circuit (fast + exact).
|
|
"""
|
|
targets = _targets_list(target)
|
|
norm = _strip_string(pred or '')
|
|
if norm and any(norm == _strip_string(t) for t in targets):
|
|
return {'acc': 1.0}, {'acc': {'path': 'normalized_string'}}
|
|
|
|
details: Dict[str, Any] = {'acc': {'path': 'none', 'normalized_pred': norm}}
|
|
if ctx.params.get('sympy', True):
|
|
try:
|
|
from .math_grader import grade_answer # lazily imported, optional dep
|
|
|
|
if any(grade_answer(pred or '', t) for t in targets):
|
|
details['acc'] = {'path': 'sympy'}
|
|
return {'acc': 1.0}, details
|
|
except ImportError:
|
|
details['acc']['sympy'] = 'not installed (pip install sympy pylatexenc)'
|
|
return {'acc': 0.0}, details
|
|
|
|
|
|
def _token_bag(text: str) -> List[str]:
|
|
from string import punctuation
|
|
|
|
text = (text or '').lower()
|
|
text = re.sub(r'\b(a|an|the)\b', ' ', text)
|
|
text = re.sub(f'[{re.escape(punctuation)}]', ' ', text)
|
|
return [t for t in text.split() if t]
|
|
|
|
|
|
def _drop_normalize(text: str) -> List[str]:
|
|
"""Official DROP normalization: tokenize on space/hyphen, per-token number
|
|
normalization (float str), punctuation strip, article strip, lowercase."""
|
|
from string import punctuation
|
|
|
|
out = []
|
|
for token in re.split(r'[ |-]', text or ''):
|
|
token = token.lower()
|
|
if _is_number_official(token):
|
|
token = str(float(token))
|
|
else:
|
|
token = ''.join(c for c in token if c not in punctuation)
|
|
token = re.sub(r'\b(a|an|the)\b', ' ', token)
|
|
token = ' '.join(token.split())
|
|
if token:
|
|
out.append(token)
|
|
return out
|
|
|
|
|
|
def _is_number_official(text: str) -> bool:
|
|
try:
|
|
float(text)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _drop_f1(pred_set: set, gold_set: set) -> float:
|
|
intersection = len(gold_set & pred_set)
|
|
precision = intersection / len(pred_set) if pred_set else 1.0
|
|
recall = intersection / len(gold_set) if gold_set else 1.0
|
|
if precision == 0.0 and recall == 0.0:
|
|
return 0.0
|
|
return 2 * precision * recall / (precision + recall)
|
|
|
|
|
|
def _drop_metrics(predicted: List[str], gold: List[str]):
|
|
"""Official get_drop_metrics: EM on normalized span sets; F1 via optimal
|
|
1-1 bag alignment (Hungarian), numbers must intersect."""
|
|
pred_norm = [_drop_normalize(p) for p in predicted if (p or '').strip()]
|
|
gold_norm = [_drop_normalize(g) for g in gold if (g or '').strip()]
|
|
if not gold_norm:
|
|
return 0.0, 0.0
|
|
# EM compares the SET of normalized spans (order-insensitive)
|
|
em = 1.0 if pred_norm and set(tuple(p) for p in pred_norm) == set(tuple(g) for g in gold_norm) else 0.0
|
|
|
|
pred_bags = [set(' '.join(p).split()) for p in pred_norm]
|
|
gold_bags = [set(' '.join(g).split()) for g in gold_norm]
|
|
try:
|
|
import numpy as np
|
|
from scipy.optimize import linear_sum_assignment
|
|
except ImportError as e:
|
|
raise LayerNotReady("official DROP scoring needs numpy+scipy: pip install 'evalharness[exec]'") from e
|
|
|
|
n, m = len(gold_bags), len(pred_bags)
|
|
if m == 0:
|
|
return 0.0, 0.0
|
|
score = np.zeros((n, m))
|
|
for gi, gb in enumerate(gold_bags):
|
|
for pi, pb in enumerate(pred_bags):
|
|
gold_nums = {w for w in gb if _is_number_official(w)}
|
|
pred_nums = {w for w in pb if _is_number_official(w)}
|
|
if not gold_nums or (gold_nums & pred_nums):
|
|
score[gi, pi] = _drop_f1(pb, gb)
|
|
rows, cols = linear_sum_assignment(-score)
|
|
per_bag = [0.0] * max(n, m)
|
|
for r, c in zip(rows, cols):
|
|
per_bag[r] = max(per_bag[r], score[r, c])
|
|
f1 = round(float(np.mean(per_bag)) * 100, 2)
|
|
return em, f1
|
|
|
|
|
|
@register_scorer('em_f1')
|
|
def em_f1(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""DROP official EM/F1 over gold spans.
|
|
|
|
Target shape: List[str] (multiple spans) or str. The prediction is split
|
|
on the official '\\n' separator of multiple predicted spans.
|
|
"""
|
|
gold: List[str] = [str(t) for t in target] if isinstance(target, list) else [str(target)]
|
|
predicted = [p for p in re.split(r'\n', pred or '') if p.strip()]
|
|
em, f1 = _drop_metrics(predicted, gold)
|
|
return {'em': em / 100.0 if em > 1.0 else em, 'f1': f1 / 100.0}, {'em': {}, 'f1': {'official': True}}
|
|
|
|
|
|
@register_scorer('alias_match')
|
|
def alias_match(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""TriviaQA style: normalized pred equals any alias, or alias contained."""
|
|
targets = _targets_list(target)
|
|
norm = normalize_text(pred or '', 'math')
|
|
hit = 0
|
|
for t in targets:
|
|
tn = normalize_text(t, 'math')
|
|
if norm == tn or (len(norm) >= 3 and norm in tn):
|
|
hit = 1
|
|
break
|
|
return {'em': float(hit)}, {'em': {'aliases': len(targets)}}
|
|
|
|
|
|
# ------------------------- judge / execution / env (paradigm slots) -------------------------
|
|
|
|
|
|
@register_scorer('llm_judge')
|
|
def llm_judge(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""LLM-as-judge with an explicit label->scores contract.
|
|
|
|
params: prompt_template (with {prediction} {target} {question}),
|
|
labels: {'C': {'acc': 1.0}, 'I': {'acc': 0.0}},
|
|
primary: 'acc'
|
|
"""
|
|
if ctx.judge is None:
|
|
raise LayerNotReady(
|
|
"llm_judge needs a judge model; configure a ModelAdapter (runner judge=...) "
|
|
'before running recipes that use it'
|
|
)
|
|
template = ctx.params.get('prompt_template', '{prediction}')
|
|
prompt = template.format(prediction=pred or '', target=target or '', question=sample.input_text)
|
|
raw = ctx.judge([{'role': 'user', 'content': prompt}])
|
|
import json as _json
|
|
|
|
try: # judge callable may return a parsed object
|
|
raw_text = raw if isinstance(raw, str) else _json.dumps(raw)
|
|
except Exception:
|
|
raw_text = str(raw)
|
|
labels: Dict[str, Dict[str, float]] = ctx.params.get('labels') or {}
|
|
upper = (raw_text or '').upper()
|
|
chosen = None
|
|
for label in labels:
|
|
if label.upper() and label.upper() in upper:
|
|
chosen = label
|
|
break
|
|
primary = ctx.params.get('primary', 'acc')
|
|
default_label = ctx.params.get('default_label') # e.g. SimpleQA's C on parse failure
|
|
if chosen is None and default_label and default_label in labels:
|
|
chosen = default_label
|
|
if chosen is None:
|
|
scores = {k: 0.0 for k in (labels.get(next(iter(labels))) or {})}
|
|
return scores, {primary: {'judge_raw': raw_text[:500], 'parse': 'failed'}}
|
|
return dict(labels[chosen]), {primary: {'judge_label': chosen, 'judge_raw': raw_text[:500]}}
|
|
|
|
|
|
@register_scorer('execution')
|
|
def execution(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""Run code in a sandbox: sandbox.exec(files built by params['harness']).
|
|
|
|
Layering: the sandbox is generic (run files, report exit/stdout/stderr);
|
|
the bench-specific program assembly (completion + tests + checker) is a
|
|
``harness(sample, pred) -> {filename: content}`` closure provided by the
|
|
recipe. params: harness (required), sandbox ('docker'|'local'),
|
|
entry, timeout_s.
|
|
"""
|
|
harness = ctx.params.get('harness')
|
|
if harness is None:
|
|
raise LayerNotReady(
|
|
"execution scorer needs params['harness']: a recipe-provided "
|
|
'(sample, pred) -> {filename: content} builder'
|
|
)
|
|
from ..sandbox import get_sandbox
|
|
|
|
sbx = get_sandbox(ctx.params.get('sandbox', 'local'))
|
|
files = harness(sample, pred or '')
|
|
result = sbx.exec(files, entry=ctx.params.get('entry', 'main.py'),
|
|
timeout_s=ctx.params.get('timeout_s', 30))
|
|
ok = result.ok
|
|
return ({'pass': 1.0} if ok else {'pass': 0.0}), {'pass': {
|
|
'exit_code': result.exit_code,
|
|
'timed_out': result.timed_out,
|
|
'duration_s': result.duration_s,
|
|
'stderr_tail': result.stderr[-400:],
|
|
'sandbox': sbx.name,
|
|
}}
|
|
|
|
|
|
@register_scorer('env_reward')
|
|
def env_reward(pred: str, target, sample: Sample, ctx: ScoreContext):
|
|
"""Score an agent trajectory by environment final state.
|
|
|
|
Consumes ctx.params['env_state'] (set by the runner from the trajectory);
|
|
today: bfcl-style call-sequence comparison against official ground truth.
|
|
tau2/swe get dedicated envs later; this scorer stays the entry point.
|
|
"""
|
|
env_state = ctx.params.get('env_state') or {}
|
|
if not env_state:
|
|
raise LayerNotReady(
|
|
'env_reward needs env_state from an agent trajectory '
|
|
'(run with run_eval(loop=True) or a bench env)'
|
|
)
|
|
calls = env_state.get('calls', [])
|
|
gt_calls = (env_state.get('ground_truth') or {}).get('tool_calls')
|
|
if gt_calls is None:
|
|
# irrelevance categories: correct behavior is calling NOTHING
|
|
hit = int(len(calls) == 0)
|
|
return {'acc': float(hit)}, {'acc': {'mode': 'no_calls', 'n_calls': len(calls)}}
|
|
|
|
def norm(call: Dict[str, Any]) -> str:
|
|
return json.dumps({'name': call.get('name'),
|
|
'arguments': call.get('arguments') or call.get('parameters', {})},
|
|
sort_keys=True, ensure_ascii=False)
|
|
|
|
want = [norm(c) for c in gt_calls]
|
|
got = [norm(c) for c in calls]
|
|
hit = int(want == got)
|
|
return {'acc': float(hit)}, {'acc': {
|
|
'mode': 'call_sequence', 'expected': want[:5], 'got': got[:5],
|
|
'n_expected': len(want), 'n_got': len(got),
|
|
}}
|
|
|
|
|
|
# ------------------------- resolution -------------------------
|
|
|
|
ScorerSpec = Union[str, ScorerFn, Dict[str, Any]]
|
|
|
|
|
|
def make_scorer(metric: str, spec: ScorerSpec) -> ScorerFn:
|
|
"""Resolve one metric's scorer spec (name / fn / {'name', **params})."""
|
|
params: Dict[str, Any] = {}
|
|
if isinstance(spec, dict):
|
|
spec = dict(spec)
|
|
params = {k: v for k, v in spec.items() if k != 'name'}
|
|
spec = spec.get('name')
|
|
if callable(spec):
|
|
return spec
|
|
if isinstance(spec, str):
|
|
base = get_scorer(spec)
|
|
if not params:
|
|
return base
|
|
|
|
def with_params(pred, target, sample, ctx):
|
|
merged = ScoreContext(judge=ctx.judge, judge_model=ctx.judge_model,
|
|
params={**ctx.params, **params})
|
|
return base(pred, target, sample, merged)
|
|
|
|
return with_params
|
|
raise TypeError(f'bad scorer spec for {metric!r}: {spec!r}')
|