MSG
Feat/monday sprint 2 (#19)
727cb75
Raw
History Blame Contribute Delete
14.3 kB
"""
slm-lm-eval — Academic benchmarks via lm-evaluation-harness
============================================================
Run GSM8K, ARC, HellaSwag, and related tasks against presets and finetuned
checkpoints.
Usage:
uv run --package slm-evals slm-lm-eval \\
--config research/evals/configs/lm_eval_minicpm5.yaml \\
--preset minicpm5-1b \\
--experiment-name minicpm5-1b__baseline
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import yaml
from slm_evals.lm_eval.preset_resolver import resolve_model_spec
from slm_evals.lm_eval.profiles import (
config_path_for_profile,
format_lm_eval_tasks,
format_profiles_table,
)
def _ensure_lm_eval_models_registered() -> None:
"""Import lm-eval model backends so registry includes hf."""
import lm_eval.models # noqa: F401 — registers bundled backends when available
try:
import lm_eval.models.huggingface # noqa: F401
except ImportError:
pass
_REPO_ROOT = Path(__file__).resolve().parents[4]
_DEFAULT_OUTPUT = _REPO_ROOT / "results" / "lm_eval"
_METRIC_PRIORITY = (
"acc,none",
"acc_norm,none",
"exact_match,strict-match",
"exact_match,flexible-extract",
"f1,none",
"bleu,none",
)
# lm-eval tasks that execute model-generated code (pass@k). lm-eval refuses to
# run them unless confirm_run_unsafe_code=True, and the HF `evaluate` code_eval
# metric additionally requires HF_ALLOW_CODE_EVAL=1.
_CODE_EXEC_TASK_PREFIXES = ("humaneval", "mbpp")
def _requires_code_execution(tasks: list[str], override: bool | None) -> bool:
if override is not None:
return bool(override)
return any(str(t).lower().startswith(_CODE_EXEC_TASK_PREFIXES) for t in tasks)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run lm-evaluation-harness benchmarks via slm-evals",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Profiles: slm-lm-eval --list-profiles\n"
" slm-lm-eval --profile reasoning --preset minicpm5-1b\n"
"All tasks: slm-lm-eval --list-tasks (requires uv sync --group lm-eval)"
),
)
parser.add_argument(
"--list-profiles",
action="store_true",
help="Show claim-matched lm-eval profiles and other eval suites",
)
parser.add_argument(
"--list-profiles-all",
action="store_true",
help="Like --list-profiles but include agentic suites and external notes",
)
parser.add_argument(
"--list-tasks",
action="store_true",
help="List lm-eval task names (from harness, or catalog fallback)",
)
parser.add_argument(
"--list-tasks-all",
action="store_true",
help="List all lm-eval task names (can be long)",
)
parser.add_argument(
"--profile",
type=str,
default=None,
metavar="NAME",
help="Shorthand for --config (e.g. reasoning, understanding, code, smoke)",
)
parser.add_argument("--config", type=str, default=None, help="YAML config path")
parser.add_argument("--preset", type=str, default=None, help="models.yaml preset key")
parser.add_argument(
"--model",
type=str,
default=None,
help="HF Hub id or merged checkpoint dir",
)
parser.add_argument("--adapter", type=str, default=None, help="LoRA adapter path")
parser.add_argument(
"--tasks",
nargs="+",
default=None,
help="Task names (overrides config)",
)
parser.add_argument("--num-fewshot", type=int, default=None)
parser.add_argument("--limit", type=int, default=None, help="Max samples per task")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--batch-size", default=None)
parser.add_argument("--device", type=str, default=None)
parser.add_argument("--dtype", type=str, default=None)
parser.add_argument(
"--output-dir",
type=str,
default=str(_DEFAULT_OUTPUT),
help="Root directory for lm-eval results",
)
parser.add_argument("--experiment-name", type=str, default=None)
parser.add_argument(
"--compare-to",
type=str,
default=None,
help="Path to baseline results.json for delta table",
)
return parser.parse_args()
def load_lm_eval_config(path: str) -> dict[str, Any]:
with open(path) as f:
cfg = yaml.safe_load(f) or {}
cfg.setdefault("tasks", ["arc_easy", "hellaswag"])
cfg.setdefault("num_fewshot", 0)
cfg.setdefault("limit", None)
cfg.setdefault("seed", 42)
cfg.setdefault("batch_size", "auto")
cfg.setdefault("device", "auto")
cfg.setdefault("dtype", "bfloat16")
cfg.setdefault("trust_remote_code", True)
cfg.setdefault("output_dir", str(_DEFAULT_OUTPUT))
return cfg
def merge_config(args: argparse.Namespace) -> dict[str, Any]:
cfg: dict[str, Any] = {}
config_path = args.config
if args.profile:
if config_path:
raise SystemExit("Pass only one of --profile or --config, not both.")
config_path = str(config_path_for_profile(args.profile))
if config_path:
cfg = load_lm_eval_config(config_path)
if args.tasks:
cfg["tasks"] = args.tasks
if args.num_fewshot is not None:
cfg["num_fewshot"] = args.num_fewshot
if args.limit is not None:
cfg["limit"] = args.limit
if args.seed is not None:
cfg["seed"] = args.seed
if args.batch_size is not None:
cfg["batch_size"] = args.batch_size
if args.device is not None:
cfg["device"] = args.device
if args.dtype is not None:
cfg["dtype"] = args.dtype
if args.output_dir:
cfg["output_dir"] = args.output_dir
cfg["preset"] = args.preset
cfg["model_path"] = args.model
cfg["adapter_path"] = args.adapter
cfg["compare_to"] = args.compare_to or cfg.get("compare_to")
if not cfg.get("experiment_name"):
if args.experiment_name:
cfg["experiment_name"] = args.experiment_name
else:
tag = args.preset or Path(args.model or "model").name
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
cfg["experiment_name"] = f"{tag}__lm-eval__{ts}"
elif args.experiment_name:
cfg["experiment_name"] = args.experiment_name
return cfg
def _git_hash() -> str | None:
try:
out = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=_REPO_ROOT,
stderr=subprocess.DEVNULL,
text=True,
)
return out.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _primary_metric(task_metrics: dict[str, Any]) -> tuple[str, float] | None:
for key in _METRIC_PRIORITY:
if key in task_metrics and isinstance(task_metrics[key], (int, float)):
return key, float(task_metrics[key])
for key, value in task_metrics.items():
if isinstance(value, (int, float)):
return key, float(value)
return None
def write_summary_md(
path: Path,
*,
spec,
cfg: dict[str, Any],
results_payload: dict[str, Any],
) -> None:
lines = [
"# lm-eval summary",
"",
f"- experiment: `{cfg['experiment_name']}`",
f"- model backend: `{spec.lm_eval_model}`",
f"- base model: `{spec.base_model}`",
]
if spec.adapter_path:
lines.append(f"- adapter: `{spec.adapter_path}`")
lines.extend(
[
f"- tasks: {', '.join(cfg['tasks'])}",
f"- num_fewshot: {cfg.get('num_fewshot')}",
f"- limit: {cfg.get('limit')}",
f"- seed: {cfg.get('seed')}",
"",
"| task | metric | score |",
"| --- | --- | ---: |",
]
)
task_results = results_payload.get("results", {})
for task, metrics in sorted(task_results.items()):
picked = _primary_metric(metrics)
if picked:
metric_name, score = picked
lines.append(f"| {task} | {metric_name} | {score:.4f} |")
else:
lines.append(f"| {task} | — | — |")
path.write_text("\n".join(lines) + "\n")
def compare_results(
baseline_path: Path,
candidate_path: Path,
*,
cfg: dict[str, Any],
) -> str:
baseline = json.loads(baseline_path.read_text())
candidate = json.loads(candidate_path.read_text())
warnings: list[str] = []
for key in ("seed", "limit", "num_fewshot"):
b_cfg = baseline.get("run_meta", {}).get(key, baseline.get("config", {}).get(key))
c_cfg = candidate.get("run_meta", {}).get(key, candidate.get("config", {}).get(key))
if b_cfg != c_cfg and b_cfg is not None and c_cfg is not None:
warnings.append(f"Mismatch on {key}: baseline={b_cfg!r} candidate={c_cfg!r}")
b_tasks = set(baseline.get("results", {}))
c_tasks = set(candidate.get("results", {}))
shared = sorted(b_tasks & c_tasks)
if not shared:
warnings.append("No shared tasks between baseline and candidate.")
lines = [
"# lm-eval comparison",
"",
f"- baseline: `{baseline_path}`",
f"- candidate: `{candidate_path}`",
f"- candidate experiment: `{cfg['experiment_name']}`",
"",
]
if warnings:
lines.append("## Warnings")
lines.extend(f"- {w}" for w in warnings)
lines.append("")
lines.extend(["| task | baseline | candidate | delta |", "| --- | ---: | ---: | ---: |"])
for task in shared:
b_metric = _primary_metric(baseline["results"][task])
c_metric = _primary_metric(candidate["results"][task])
if not b_metric or not c_metric:
continue
_, b_score = b_metric
_, c_score = c_metric
delta = c_score - b_score
sign = "+" if delta >= 0 else ""
lines.append(
f"| {task} | {b_score:.4f} | {c_score:.4f} | {sign}{delta:.4f} |"
)
return "\n".join(lines) + "\n"
def main() -> int:
args = parse_args()
if args.list_profiles or args.list_profiles_all:
print(
format_profiles_table(
include_suites=args.list_profiles_all,
include_external=args.list_profiles_all,
)
)
return 0
if args.list_tasks or args.list_tasks_all:
print(format_lm_eval_tasks(limit=0 if args.list_tasks_all else 80))
return 0
cfg = merge_config(args)
if not cfg.get("preset") and not cfg.get("model_path"):
print("Error: pass --preset or --model (or set in config).", file=sys.stderr)
return 1
spec = resolve_model_spec(
preset=cfg.get("preset"),
model_path=cfg.get("model_path"),
adapter_path=cfg.get("adapter_path"),
trust_remote_code=cfg.get("trust_remote_code"),
dtype=cfg.get("dtype"),
device=cfg.get("device"),
)
out_dir = Path(cfg["output_dir"]) / cfg["experiment_name"]
out_dir.mkdir(parents=True, exist_ok=True)
try:
import lm_eval
except ImportError as exc:
print(
"lm-eval is not installed. Run: uv sync --group lm-eval",
file=sys.stderr,
)
raise SystemExit(1) from exc
_ensure_lm_eval_models_registered()
confirm_unsafe_code = _requires_code_execution(
cfg["tasks"], cfg.get("confirm_run_unsafe_code")
)
if confirm_unsafe_code:
# Required by the HF `evaluate` code_eval metric to compute pass@k.
os.environ.setdefault("HF_ALLOW_CODE_EVAL", "1")
print(
"Enabling code execution for tasks "
f"{[t for t in cfg['tasks'] if str(t).lower().startswith(_CODE_EXEC_TASK_PREFIXES)]} "
"(confirm_run_unsafe_code=True, HF_ALLOW_CODE_EVAL=1)",
file=sys.stderr,
)
seed = int(cfg.get("seed", 42))
model_args = dict(spec.model_args)
eval_device = cfg.get("device")
if spec.lm_eval_model == "hf":
model_args.pop("device", None)
else:
eval_device = None
eval_results = lm_eval.simple_evaluate(
model=spec.lm_eval_model,
model_args=model_args,
tasks=cfg["tasks"],
num_fewshot=cfg.get("num_fewshot"),
batch_size=cfg.get("batch_size"),
device=eval_device,
limit=cfg.get("limit"),
random_seed=seed,
numpy_random_seed=seed,
torch_random_seed=seed,
fewshot_random_seed=seed,
confirm_run_unsafe_code=confirm_unsafe_code,
log_samples=False,
)
if eval_results is None:
print("lm-eval returned no results.", file=sys.stderr)
return 1
run_meta = {
"experiment_name": cfg["experiment_name"],
"preset": spec.preset_key,
"lm_eval_model": spec.lm_eval_model,
"base_model": spec.base_model,
"adapter_path": spec.adapter_path,
"tasks": cfg["tasks"],
"num_fewshot": cfg.get("num_fewshot"),
"limit": cfg.get("limit"),
"seed": seed,
"batch_size": cfg.get("batch_size"),
"device": cfg.get("device"),
"dtype": cfg.get("dtype"),
"git_hash": _git_hash(),
}
payload = dict(eval_results)
payload["run_meta"] = run_meta
results_path = out_dir / "results.json"
results_path.write_text(json.dumps(payload, indent=2, default=str))
summary_path = out_dir / "summary.md"
write_summary_md(summary_path, spec=spec, cfg=cfg, results_payload=payload)
meta_path = out_dir / "run_meta.json"
meta_path.write_text(json.dumps(run_meta, indent=2))
print(f"Wrote {results_path}")
print(f"Wrote {summary_path}")
compare_to = cfg.get("compare_to")
if compare_to:
compare_path = out_dir / "comparison.md"
compare_text = compare_results(
Path(compare_to),
results_path,
cfg=cfg,
)
compare_path.write_text(compare_text)
print(f"Wrote {compare_path}")
print()
print(compare_text)
return 0
if __name__ == "__main__":
raise SystemExit(main())