""" 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())