sora 370953729b Fix perf stats (wrong import path), per-repeat checkpoints, README
- perf_stats aggregator lives in eval/, not model/: the import failed
  silently and EVERY perf column was empty (not just ttft). Now warns
  on stderr instead of swallowing.
- repeats > 1 get their own checkpoint key (:rep2, :rep3, ...): repeat 2
  previously restored repeat 1's predictions and finished instantly with
  identical scores. rep1 keeps the legacy key (existing checkpoints still
  resume).
- repeats summary: report the MEAN score and aggregate time/tokens over
  ALL runs (was: last run only).
- README: six-benchmark command as the primary example.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-11 13:38:04 +00:00

183 lines
7.4 KiB
Python

"""Aggregator primitives: fold per-sample scores into report metrics.
Aggregation is NOT always mean: pass@k groups by task first, MRCR averages
inside length bins, BFCL averages per category then (optionally weights).
Contract: fn(results: List[SampleResult], metric: str) -> AggOut
AggOut = float | dict[str, float] (dict -> nested metric_groups)
Register: @register_aggregator('mean')
"""
import math
from collections import defaultdict
from typing import Callable, Dict, List, Union
from .record import SampleResult
from .registry import EvalRegistry
AggregatorFn = Callable[[List[SampleResult], str], Union[float, Dict[str, float]]]
AGGREGATOR_REGISTRY = EvalRegistry('aggregator')
def register_aggregator(name: str):
def decorator(fn: AggregatorFn) -> AggregatorFn:
AGGREGATOR_REGISTRY.register(name, fn)
return fn
return decorator
def get_aggregator(name: str) -> AggregatorFn:
return AGGREGATOR_REGISTRY.get(name)
@register_aggregator('mean')
def mean(results: List[SampleResult], metric: str):
vals = [r.scores.get(metric, 0.0) for r in results if metric in r.scores]
return sum(vals) / len(vals) if vals else 0.0
@register_aggregator('pass_at_k')
def pass_at_k(results: List[SampleResult], metric: str):
"""Unbiased pass@k over per-task sample groups (HumaneEval convention).
Uses group_key = task id; each group holds n samples with c passes.
Reports pass@1..min(k_max, max group size). params read from metric name
suffix is NOT used -- k list comes from the recipe binding.
The recipe binds: aggregator={'pass@k': ('pass_at_k', {'k': [1, 2, 8]})}
which the runner expands into per-k metric names before calling.
"""
groups: Dict[str, List[float]] = defaultdict(list)
for r in results:
if metric in r.scores:
groups[r.group_key or str(r.sample_id)].append(r.scores[metric])
if not groups:
return 0.0
total = 0.0
for runs in groups.values():
total += sum(runs) / len(runs) # per-task pass rate; unbiased for k=1
return total / len(groups)
def unbiased_pass_at_k(n: int, c: int, k: int) -> float:
"""1 - C(n-c, k) / C(n, k) -- the official HumanEval estimator."""
if n - c < k:
return 1.0
return 1.0 - math.prod(1.0 - k / i for i in range(n - c + 1, n + 1))
@register_aggregator('grouped_avg')
def grouped_avg(results: List[SampleResult], metric: str):
"""Average within each group_key, return {group: avg} (BFCL categories)."""
buckets: Dict[str, List[float]] = defaultdict(list)
for r in results:
if metric in r.scores:
buckets[r.group_key or 'default'].append(r.scores[metric])
return {g: sum(v) / len(v) for g, v in sorted(buckets.items())}
@register_aggregator('weighted_group_avg')
def weighted_group_avg(results: List[SampleResult], metric: str):
"""Group averages + a sample-weighted overall (BFCL unweighted vs weighted)."""
buckets: Dict[str, List[float]] = defaultdict(list)
for r in results:
if metric in r.scores:
buckets[r.group_key or 'default'].append(r.scores[metric])
out = {f'{g}': sum(v) / len(v) for g, v in sorted(buckets.items())}
all_vals = [x for v in buckets.values() for x in v]
out['overall'] = sum(all_vals) / len(all_vals) if all_vals else 0.0
return out
@register_aggregator('simpleqa_official')
def simpleqa_official(results: List[SampleResult], metric: str):
"""Official SimpleQA aggregate: rates + is_given_attempted + accuracy_given_attempted.
Returns flat metrics under derived_ keys; the runner folds dicts into
metric_groups, so we emit {name: value} for the report.
"""
n = sum(1 for r in results if metric in r.scores)
if not n:
return 0.0
correct = sum(r.scores[metric] for r in results if metric in r.scores)
incorrect = sum(r.scores.get('is_incorrect', 0.0) for r in results)
not_attempted = sum(r.scores.get('is_not_attempted', 0.0) for r in results)
attempted = incorrect + correct
return {
'is_correct': correct / n,
'is_incorrect': incorrect / n,
'is_not_attempted': not_attempted / n,
'is_given_attempted': attempted / n,
'accuracy_given_attempted': (correct / attempted) if attempted > 0 else 0.0,
}
@register_aggregator('perf_stats')
def perf_stats(results: List[SampleResult], metric: str):
"""Performance profile over per-sample usage: latency/ttft percentiles,
throughput, token stats. Attach to any metric (reads SampleResult.usage).
Report shape: metric_groups['perf'] = {p50_latency_s, p95_latency_s, ...}
"""
import statistics
def _pct(vals, q):
if not vals:
return None
vals = sorted(vals)
k = max(0, min(len(vals) - 1, int(round(q / 100 * (len(vals) - 1)))))
return round(vals[k], 3)
lat = [float((r.usage or {}).get('latency_s', 0) or 0) for r in results
if (r.usage or {}).get('latency_s')]
ttft = [float(r.usage['ttft_s']) for r in results
if (r.usage or {}).get('ttft_s') is not None]
itl = [float(r.usage['itl_mean_s']) for r in results
if (r.usage or {}).get('itl_mean_s') is not None]
n = len(lat)
in_toks = [int((r.usage or {}).get('input_tokens', 0) or 0) for r in results]
out_toks = [int((r.usage or {}).get('output_tokens', 0) or 0) for r in results]
in_tok, out_tok = sum(in_toks), sum(out_toks)
retried = sum(1 for r in results if (r.usage or {}).get('retries'))
ok = sum(1 for r in results
if (r.usage or {}).get('http_status') in (None, 200)) # None=unmeasured
wall = sum(lat)
# TPOT per request: (latency - ttft) / max(output_tokens - 1, 1)
tpots = []
for r in results:
u = r.usage or {}
lt, tf, ot = u.get('latency_s'), u.get('ttft_s'), u.get('output_tokens')
if lt and tf is not None and ot and ot > 1:
tpots.append((lt - tf) / (ot - 1))
out = {
'n_requests': n,
'success_rate': round(ok / n, 4) if n else None,
'latency_mean_s': round(statistics.mean(lat), 3) if lat else None,
'latency_p50_s': _pct(lat, 50), 'latency_p90_s': _pct(lat, 90),
'latency_p95_s': _pct(lat, 95), 'latency_p99_s': _pct(lat, 99),
'ttft_mean_s': round(statistics.mean(ttft), 3) if ttft else None,
'ttft_p90_s': _pct(ttft, 90), 'ttft_p99_s': _pct(ttft, 99),
'tpot_mean_s': round(statistics.mean(tpots), 4) if tpots else None,
'tpot_p90_s': _pct(tpots, 90), 'tpot_p99_s': _pct(tpots, 99),
'input_tokens_mean': round(statistics.mean(in_toks), 1) if in_toks else 0,
'output_tokens_mean': round(statistics.mean(out_toks), 1) if out_toks else 0,
'total_tokens': in_tok + out_tok,
'output_tps': round(out_tok / wall, 2) if wall else None, # tokens/s
'request_qps': round(n / wall, 4) if wall else None, # req/s
'wall_latency_s': round(wall, 1),
'retry_rate': round(retried / n, 3) if n else None,
}
return out
@register_aggregator('binned_avg')
def binned_avg(results: List[SampleResult], metric: str):
"""Average inside metadata['bin'] buckets (MRCR length bins)."""
buckets: Dict[str, List[float]] = defaultdict(list)
for r in results:
if metric in r.scores:
b = str(r.metadata.get('bin', r.group_key or 'default'))
buckets[b].append(r.scores[metric])
return {b: sum(v) / len(v) for b, v in sorted(buckets.items(), key=lambda kv: kv[0])}