309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""Async generation runner: model + dataset -> predictions -> scored report.
|
|
|
|
The async boundary is exactly "waiting on the model". Data loading and
|
|
scoring stay synchronous (fast, CPU/disk bound); this coroutine fans out
|
|
model calls with a semaphore, streams progress, then hands the collected
|
|
raw strings to the sync evaluate().
|
|
|
|
from evalharness.model import run_eval
|
|
report = asyncio.run(run_eval(ds, 'mock', limit=50)) # offline smoke
|
|
report = asyncio.run(run_eval(ds, 'openai/http://gpu03:8000/v1?qwen3-8b'))
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from ..data.dataset import Dataset
|
|
from ..data.sample import ChatMessage, Sample
|
|
from ..eval.recipe import EvalRecipe
|
|
from ..eval.record import EvalReport
|
|
from ..eval.runner import evaluate
|
|
from .adapter import ModelAdapter, resolve_adapter
|
|
from .output import Usage
|
|
|
|
|
|
async def generate_predictions(
|
|
adapter: ModelAdapter,
|
|
samples: List[Sample],
|
|
concurrency: int = 32,
|
|
limit: Optional[int] = None,
|
|
gen_kwargs: Optional[Dict[str, Any]] = None,
|
|
progress: bool = True,
|
|
env_factory=None,
|
|
system: str = '',
|
|
max_turns: int = 8,
|
|
max_input_chars: int = 0,
|
|
attach_context_keys: tuple = ('passage', 'context'),
|
|
few_shot_num: int = 0,
|
|
few_shot_samples: Optional[List[Sample]] = None,
|
|
few_shot_text: Optional[str] = None,
|
|
prompt_style: str = 'strict_letter',
|
|
) -> tuple:
|
|
"""Fan out model calls; returns (pred-dicts, total_usage).
|
|
|
|
MCQ samples are generated with the strict-letter contract ('ANSWER: X',
|
|
evalscope parity). few_shot: official exemplar text (few_shot_text) or
|
|
dev/train-split samples (few_shot_samples) are prepended.
|
|
"""
|
|
gen_kwargs = gen_kwargs or {}
|
|
sem = asyncio.Semaphore(concurrency)
|
|
total_usage = Usage()
|
|
done_count = 0
|
|
t0 = time.time()
|
|
|
|
def assemble(sample: Sample) -> str:
|
|
parts = []
|
|
if few_shot_text:
|
|
parts.append(few_shot_text.strip()) # official exemplars, verbatim
|
|
elif few_shot_num and few_shot_samples:
|
|
letters_fs = 'ABCDEFGH'
|
|
for fs in few_shot_samples[:few_shot_num]:
|
|
line = f'Question: {fs.input_text}'
|
|
if fs.choices:
|
|
line += '\n' + '\n'.join(f'{letters_fs[j]}. {c}' for j, c in enumerate(fs.choices))
|
|
ans = fs.target if not isinstance(fs.target, list) else fs.target[0]
|
|
line += f'\nAnswer: {ans}'
|
|
parts.append(line)
|
|
for key in attach_context_keys:
|
|
ctx = (sample.metadata or {}).get(key)
|
|
if ctx:
|
|
parts.append(str(ctx))
|
|
question = sample.input_text
|
|
if sample.choices:
|
|
if prompt_style in ('strict_letter', 'auto'):
|
|
# evalscope/OpenAI-style contract: reply ONLY 'ANSWER: X'
|
|
letters = 'ABCDEFGH'
|
|
opts = '\n'.join(f'{letters[i]}. {c}' for i, c in enumerate(sample.choices)
|
|
if i < len(letters))
|
|
question = (f'Answer the following multiple choice question. The entire '
|
|
f'content of your response should be of the following format: '
|
|
f"'ANSWER: [LETTER]' (without quotes) where [LETTER] is one of "
|
|
f'{letters[:len(sample.choices)]}.\n\n{question}\n\n{opts}')
|
|
else:
|
|
letters = 'ABCDEFGH'
|
|
opts = '\n'.join(f'{letters[i]}. {c}' for i, c in enumerate(sample.choices)
|
|
if i < len(letters))
|
|
question = (f'{question}\n\n{opts}\n\n'
|
|
'Answer with the letter of the correct option.')
|
|
elif sample.task_type in ('qa',):
|
|
question = (f'{question}\n\n'
|
|
'End your reply with the final answer on its own last line '
|
|
'in the form "Answer: <answer>".')
|
|
parts.append(question)
|
|
text = '\n\n'.join(parts)
|
|
if max_input_chars and len(text) > max_input_chars:
|
|
keep = max_input_chars // 2
|
|
head = text[:keep]
|
|
tail = text[-keep:]
|
|
text = f'{head}\n\n...[truncated {len(text) - 2 * keep} chars]...\n\n{tail}'
|
|
return text
|
|
|
|
async def one(sample: Sample) -> Dict[str, Any]:
|
|
nonlocal done_count, total_usage
|
|
if env_factory is not None:
|
|
from ..agent import drive, trajectory_to_prediction
|
|
|
|
async with sem:
|
|
traj = await drive(adapter, sample, env=env_factory(),
|
|
max_turns=max_turns, system=system)
|
|
total_usage = total_usage + traj.usage
|
|
pred = trajectory_to_prediction(traj)
|
|
pred['group_key'] = str(sample.metadata.get('test_category')
|
|
or sample.metadata.get('category')
|
|
or sample.metadata.get('id') or sample.id or '')
|
|
done_count += 1
|
|
_progress(progress, done_count, len(samples), t0, total_usage)
|
|
return pred
|
|
|
|
messages = ([ChatMessage(role='user', content=assemble(sample))]
|
|
if isinstance(sample.input, str) else list(sample.input))
|
|
tools = None
|
|
if sample.tools:
|
|
tools = [{'name': t.name, 'description': t.description or '',
|
|
'parameters': t.parameters} for t in sample.tools]
|
|
if getattr(adapter, 'name', '') == 'mock' \
|
|
and adapter.extra.get('mode') in ('boxed', 'oracle', 'fc') \
|
|
and sample.target not in ('', None):
|
|
# oracle channel for mock verification so full pipelines run offline
|
|
messages = messages + [ChatMessage(role='user',
|
|
content=f'MOCKTARGET::{sample.target}')]
|
|
async with sem:
|
|
out = await adapter.generate(messages, tools=tools, **gen_kwargs)
|
|
total_usage = total_usage + out.usage
|
|
text = out.text
|
|
if out.tool_calls: # fc tasks: serialize calls as the prediction
|
|
import json
|
|
|
|
text = (text + '\n' if text else '') + json.dumps(
|
|
[c.to_openai()['function'] for c in out.tool_calls], ensure_ascii=False)
|
|
done_count += 1
|
|
_progress(progress, done_count, len(samples), t0, total_usage)
|
|
return {'raw': text, 'usage': out.usage.model_dump()}
|
|
|
|
work = samples[:limit] if limit else samples
|
|
preds = list(await asyncio.gather(*(one(s) for s in work)))
|
|
usages = [p.get('usage', {}) for p in preds]
|
|
return preds, usages, total_usage
|
|
|
|
|
|
def _progress(progress: bool, done: int, total: int, t0: float, usage: Usage) -> None:
|
|
if progress and (done % 20 == 0 or done == total):
|
|
rate = done / max(time.time() - t0, 1e-6)
|
|
print(f' [{done}/{total}] {rate:.1f} samples/s tokens={usage.total_tokens}', flush=True)
|
|
|
|
|
|
async def run_eval(
|
|
dataset: Union[Dataset, List[Sample]],
|
|
model_spec: str,
|
|
recipe: Optional[EvalRecipe] = None,
|
|
*,
|
|
concurrency: int = 32,
|
|
limit: Optional[int] = None,
|
|
gen_kwargs: Optional[Dict[str, Any]] = None,
|
|
judge_spec: Optional[str] = None,
|
|
judge: Optional[Any] = None,
|
|
progress: bool = True,
|
|
env: str = '',
|
|
system: str = '',
|
|
max_turns: int = 8,
|
|
max_input_chars: int = 0,
|
|
few_shot_num: int = -1,
|
|
prompt_style: str = 'strict_letter',
|
|
) -> EvalReport:
|
|
"""Generate + score in one call. Model spec examples:
|
|
'mock', 'mock:boxed', 'openai/http://gpu03:8000/v1?qwen3-8b', 'deploy:vllm/qwen3-8b'.
|
|
|
|
env: environment plugin name ('bfcl_mock') -> agent message pump per
|
|
sample; omit for single-turn generation.
|
|
'auto' = logprob when the adapter supports it.
|
|
few_shot_num: -1 = the dataset's declared paper default (mmlu 5, bbh 3,
|
|
gsm8k 4, ...); 0 = zero-shot; N = explicit override.
|
|
prompt_style: 'strict_letter' (default, evalscope-style 'ANSWER: X')
|
|
| 'cot' (reasoning-friendly).
|
|
"""
|
|
spec = getattr(dataset, 'spec', None)
|
|
if few_shot_num < 0:
|
|
few_shot_num = (spec.few_shot_num if spec is not None else 0)
|
|
adapter = _make_adapter(model_spec)
|
|
name = spec.name if spec is not None else 'adhoc'
|
|
if recipe is None:
|
|
from ..eval.recipe import EvalRecipe, get_eval
|
|
|
|
try:
|
|
recipe = get_eval(name)
|
|
except KeyError:
|
|
if name != 'adhoc':
|
|
raise
|
|
recipe = EvalRecipe(name='adhoc', extract='identity',
|
|
scorers={'acc': {'name': 'exact', 'mode': 'raw'}})
|
|
samples = list(dataset)[:limit] if limit else list(dataset)
|
|
if progress:
|
|
mode = f'agent env={env}' if env else 'single-turn'
|
|
print(f'generating: {adapter} on {len(samples)} samples '
|
|
f'({mode}, concurrency={concurrency})', flush=True)
|
|
|
|
env_factory = None
|
|
if env:
|
|
from ..agent.loop import ENV_REGISTRY, get_env
|
|
|
|
if env not in ENV_REGISTRY:
|
|
raise KeyError(f'unknown env {env!r}; available: {", ".join(ENV_REGISTRY.names())}')
|
|
env_factory = lambda: get_env(env) # noqa: E731
|
|
|
|
# paper-faithful few-shot exemplars: official hand-written hooks first
|
|
# (bbh CoT), else the dataset's own dev/train split
|
|
few_shot_samples = None
|
|
few_shot_text = None
|
|
if few_shot_num:
|
|
from ..data.registry import get_dataset_provider
|
|
|
|
prov = get_dataset_provider(name)
|
|
hook = getattr(prov, 'few_shot_hook', None)
|
|
if hook is not None:
|
|
few_shot_text = hook('', spec.subset if spec else 'default', few_shot_num)
|
|
if few_shot_text is None:
|
|
fs_split = (spec.few_shot_split if spec is not None else None) or 'dev'
|
|
try:
|
|
import dataclasses
|
|
|
|
fs_spec = dataclasses.replace(spec, split=fs_split) if spec is not None else None
|
|
if fs_spec is not None:
|
|
from ..data.loader import load_raw_records
|
|
|
|
fn = prov.resolve_record_fn()
|
|
fs_raw = load_raw_records(fs_spec)
|
|
few_shot_samples = [fn(r) for r in fs_raw[:few_shot_num]]
|
|
except Exception as e:
|
|
print(f'few-shot: could not load {fs_split} split ({type(e).__name__}: '
|
|
f'{str(e)[:80]}); continuing 0-shot', flush=True)
|
|
|
|
try:
|
|
preds, _usages, usage = await generate_predictions(
|
|
adapter, samples, concurrency, progress=progress,
|
|
gen_kwargs=gen_kwargs, env_factory=env_factory,
|
|
system=system, max_turns=max_turns, max_input_chars=max_input_chars,
|
|
few_shot_num=few_shot_num,
|
|
few_shot_samples=few_shot_samples, few_shot_text=few_shot_text,
|
|
prompt_style=prompt_style)
|
|
finally:
|
|
await adapter.close()
|
|
if judge is None and judge_spec:
|
|
judge_adapter = _make_adapter(judge_spec)
|
|
judge = _judge_callable(judge_adapter)
|
|
|
|
report = evaluate(
|
|
samples, preds, recipe,
|
|
model=model_spec,
|
|
judge=judge,
|
|
extra_metadata={'gen_input_tokens': usage.input_tokens,
|
|
'gen_output_tokens': usage.output_tokens,
|
|
'gen_total_tokens': usage.total_tokens},
|
|
)
|
|
report.model = model_spec
|
|
report.dataset = name
|
|
return report
|
|
|
|
|
|
def _make_adapter(spec: str) -> ModelAdapter:
|
|
"""'mock:boxed' -> MockAdapter(mode='boxed'); else resolve_adapter().
|
|
|
|
The colon-mode syntax exists ONLY for 'mock': adapter names contain no
|
|
scheme/colon, so 'mock:xxx' is safe while URLs ('openai/http://...')
|
|
must never be split on ':'.
|
|
"""
|
|
base, sep, mode = spec.partition(':')
|
|
if sep and '/' not in base and base == 'mock':
|
|
adapter = resolve_adapter('mock')
|
|
adapter.extra['mode'] = mode or 'echo'
|
|
return adapter
|
|
return resolve_adapter(spec)
|
|
|
|
|
|
def _judge_callable(judge_adapter: ModelAdapter):
|
|
"""Sync judge bridge. Works inside a running event loop (evaluate() may be
|
|
called from async run_eval): the coroutine runs on a private loop in a
|
|
worker thread."""
|
|
|
|
def ask(messages) -> str:
|
|
import asyncio
|
|
|
|
if isinstance(messages, list) and messages and isinstance(messages[0], dict):
|
|
messages = [ChatMessage(role=m.get('role', 'user'), content=m.get('content', ''))
|
|
for m in messages]
|
|
|
|
async def go():
|
|
out = await judge_adapter.generate(messages)
|
|
return out.text
|
|
|
|
try:
|
|
asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return asyncio.run(go())
|
|
import concurrent.futures
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(asyncio.run, go()).result()
|
|
|
|
return ask
|