Add held-out eval (base vs adapter) + standalone eval job + final eval stage in training job
Browse files- eval_adapter.py +116 -0
- prepare_tasks.py +13 -11
- run_eval.sh +78 -0
- run_job.sh +45 -0
eval_adapter.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Held-out evaluation: base model vs LoRA adapter on SmolDataEnvs test tasks.
|
| 2 |
+
|
| 3 |
+
Runs N test tasks through the env server under OpenCode, once per model, and
|
| 4 |
+
reports pass@1 (first graded attempt correct) for each, plus the per-task
|
| 5 |
+
agreement breakdown the article's comparisons used.
|
| 6 |
+
|
| 7 |
+
The vLLM server must serve the base model AND the adapter as a named LoRA:
|
| 8 |
+
vllm serve LiquidAI/LFM2.5-2.6B --enable-lora --max-lora-rank 32 \
|
| 9 |
+
--lora-modules trained=<adapter-repo> ...
|
| 10 |
+
|
| 11 |
+
Config via env:
|
| 12 |
+
ENV_URL env server (default http://127.0.0.1:8000)
|
| 13 |
+
VLLM_URL policy server (default http://127.0.0.1:8001)
|
| 14 |
+
TEST_SPLIT served test split (default /app/prepared/datasets/test)
|
| 15 |
+
BASE_MODEL served base model id (default LiquidAI/LFM2.5-2.6B)
|
| 16 |
+
ADAPTER_NAME LoRA name on the server (default "trained"); "" evaluates base only
|
| 17 |
+
N_TASKS how many test tasks (default 50)
|
| 18 |
+
CONCURRENCY parallel rollouts (default 8; keep <= MAX_CONCURRENT_ENVS)
|
| 19 |
+
OUT results JSON path (default /app/eval_results.json)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import threading
|
| 25 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 26 |
+
|
| 27 |
+
from harbor_env import HarborEnv # re-export of openenv.harbor.client.HarborEnv
|
| 28 |
+
|
| 29 |
+
ENV_URL = os.environ.get("ENV_URL", "http://127.0.0.1:8000")
|
| 30 |
+
VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8001")
|
| 31 |
+
TEST_SPLIT = os.environ.get("TEST_SPLIT", "/app/prepared/datasets/test")
|
| 32 |
+
BASE_MODEL = os.environ.get("BASE_MODEL", "LiquidAI/LFM2.5-2.6B")
|
| 33 |
+
ADAPTER_NAME = os.environ.get("ADAPTER_NAME", "trained")
|
| 34 |
+
N_TASKS = int(os.environ.get("N_TASKS", "50"))
|
| 35 |
+
CONCURRENCY = int(os.environ.get("CONCURRENCY", "8"))
|
| 36 |
+
OUT = os.environ.get("OUT", "/app/eval_results.json")
|
| 37 |
+
|
| 38 |
+
_local = threading.local()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def run_one(task_index, model):
|
| 42 |
+
"""One graded rollout. Returns (correct, failed, error_or_none)."""
|
| 43 |
+
if not hasattr(_local, "env"):
|
| 44 |
+
_local.env = HarborEnv(base_url=ENV_URL)
|
| 45 |
+
kwargs = dict(
|
| 46 |
+
split=TEST_SPLIT,
|
| 47 |
+
task_index=task_index,
|
| 48 |
+
harness="opencode",
|
| 49 |
+
sandbox=os.environ.get("SANDBOX", "hf-sandbox"),
|
| 50 |
+
reward_key="correctness,reward",
|
| 51 |
+
llm_url=VLLM_URL,
|
| 52 |
+
model=model,
|
| 53 |
+
eval_sampling={"temperature": 1.0, "top_p": 1.0},
|
| 54 |
+
agent_step_limit=17, # production eval cap from the FineEnvs Space
|
| 55 |
+
agent_timeout_sec=600,
|
| 56 |
+
)
|
| 57 |
+
try:
|
| 58 |
+
result = _local.env.run_rollout(**kwargs)
|
| 59 |
+
except TypeError:
|
| 60 |
+
# older server without eval_sampling: retry without it
|
| 61 |
+
kwargs.pop("eval_sampling")
|
| 62 |
+
result = _local.env.run_rollout(**kwargs)
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
return task_index, model, None, True, f"{type(exc).__name__}: {exc}"
|
| 65 |
+
if not result.ok or result.reward is None:
|
| 66 |
+
return task_index, model, None, True, getattr(result, "error", None) or "not ok"
|
| 67 |
+
return task_index, model, float(result.reward) == 1.0, False, None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def main():
|
| 71 |
+
models = [BASE_MODEL] + ([ADAPTER_NAME] if ADAPTER_NAME else [])
|
| 72 |
+
work = [(i, m) for i in range(N_TASKS) for m in models]
|
| 73 |
+
cells = {} # (task_index, model) -> {"correct": bool|None, "failed": bool, "error": str|None}
|
| 74 |
+
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
|
| 75 |
+
futures = [pool.submit(run_one, i, m) for i, m in work]
|
| 76 |
+
done = 0
|
| 77 |
+
for fut in as_completed(futures):
|
| 78 |
+
i, m, correct, failed, err = fut.result()
|
| 79 |
+
cells[(i, m)] = {"correct": correct, "failed": failed, "error": err}
|
| 80 |
+
done += 1
|
| 81 |
+
if done % 20 == 0 or done == len(work):
|
| 82 |
+
print(f"[eval] {done}/{len(work)} cells done", flush=True)
|
| 83 |
+
|
| 84 |
+
summary = {"n_tasks": N_TASKS, "models": {}}
|
| 85 |
+
for m in models:
|
| 86 |
+
scorable = [c["correct"] for (i, mm), c in cells.items() if mm == m and not c["failed"]]
|
| 87 |
+
failed = sum(1 for (i, mm), c in cells.items() if mm == m and c["failed"])
|
| 88 |
+
summary["models"][m] = {
|
| 89 |
+
"pass_at_1": sum(scorable) / len(scorable) if scorable else None,
|
| 90 |
+
"solved": sum(scorable),
|
| 91 |
+
"scorable": len(scorable),
|
| 92 |
+
"failed_rollouts": failed,
|
| 93 |
+
}
|
| 94 |
+
if len(models) == 2:
|
| 95 |
+
b, a = models
|
| 96 |
+
both = sum(1 for i in range(N_TASKS)
|
| 97 |
+
if cells.get((i, b), {}).get("correct") and cells.get((i, a), {}).get("correct"))
|
| 98 |
+
only_a = sum(1 for i in range(N_TASKS)
|
| 99 |
+
if not cells.get((i, b), {}).get("correct") and cells.get((i, a), {}).get("correct"))
|
| 100 |
+
only_b = sum(1 for i in range(N_TASKS)
|
| 101 |
+
if cells.get((i, b), {}).get("correct") and not cells.get((i, a), {}).get("correct"))
|
| 102 |
+
summary["agreement"] = {"both_solved": both, "only_adapter": only_a, "only_base": only_b}
|
| 103 |
+
pa = summary["models"][a]["pass_at_1"]
|
| 104 |
+
pb = summary["models"][b]["pass_at_1"]
|
| 105 |
+
if pa is not None and pb is not None:
|
| 106 |
+
summary["delta"] = pa - pb
|
| 107 |
+
|
| 108 |
+
print(json.dumps(summary, indent=2))
|
| 109 |
+
with open(OUT, "w") as f:
|
| 110 |
+
json.dump({"summary": summary,
|
| 111 |
+
"cells": {f"{i}|{m}": c for (i, m), c in cells.items()}}, f, indent=2)
|
| 112 |
+
print(f"[eval] wrote {OUT}")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
main()
|
prepare_tasks.py
CHANGED
|
@@ -46,12 +46,12 @@ def task_rows(split):
|
|
| 46 |
return json.loads((ROOT / "data" / f"{split}.json").read_text())
|
| 47 |
|
| 48 |
|
| 49 |
-
def prepare(destination):
|
| 50 |
import tomli_w
|
| 51 |
from huggingface_hub import snapshot_download
|
| 52 |
|
| 53 |
destination = Path(destination).resolve()
|
| 54 |
-
for split in
|
| 55 |
rows = task_rows(split)
|
| 56 |
repo, revision = DATASETS[split]
|
| 57 |
source = Path(
|
|
@@ -94,18 +94,20 @@ def prepare(destination):
|
|
| 94 |
(target / "task.toml").write_text(tomli_w.dumps(spec))
|
| 95 |
print(f"{split}: {len(rows)} tasks -> {destination / 'datasets' / split}")
|
| 96 |
# Overlap guard, as in the original.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
if __name__ == "__main__":
|
| 108 |
p = argparse.ArgumentParser(description=__doc__)
|
| 109 |
p.add_argument("--output", type=Path, default=ROOT / "prepared")
|
|
|
|
| 110 |
a = p.parse_args()
|
| 111 |
-
prepare(a.output)
|
|
|
|
| 46 |
return json.loads((ROOT / "data" / f"{split}.json").read_text())
|
| 47 |
|
| 48 |
|
| 49 |
+
def prepare(destination, splits=("train", "test")):
|
| 50 |
import tomli_w
|
| 51 |
from huggingface_hub import snapshot_download
|
| 52 |
|
| 53 |
destination = Path(destination).resolve()
|
| 54 |
+
for split in splits:
|
| 55 |
rows = task_rows(split)
|
| 56 |
repo, revision = DATASETS[split]
|
| 57 |
source = Path(
|
|
|
|
| 94 |
(target / "task.toml").write_text(tomli_w.dumps(spec))
|
| 95 |
print(f"{split}: {len(rows)} tasks -> {destination / 'datasets' / split}")
|
| 96 |
# Overlap guard, as in the original.
|
| 97 |
+
if set(splits) == {"train", "test"}:
|
| 98 |
+
def names(split):
|
| 99 |
+
return {r["name"] for r in task_rows(split)}
|
| 100 |
+
notebooks = [
|
| 101 |
+
{r.get("notebook") for r in task_rows(s)} - {None} for s in ("train", "test")
|
| 102 |
+
]
|
| 103 |
+
if names("train") & names("test") or notebooks[0] & notebooks[1]:
|
| 104 |
+
raise ValueError("Train/test overlap")
|
| 105 |
+
print("no train/test overlap")
|
| 106 |
|
| 107 |
|
| 108 |
if __name__ == "__main__":
|
| 109 |
p = argparse.ArgumentParser(description=__doc__)
|
| 110 |
p.add_argument("--output", type=Path, default=ROOT / "prepared")
|
| 111 |
+
p.add_argument("--splits", nargs="+", default=["train", "test"], choices=["train", "test"])
|
| 112 |
a = p.parse_args()
|
| 113 |
+
prepare(a.output, tuple(a.splits))
|
run_eval.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Standalone eval job (1 GPU): base model vs a LoRA adapter on held-out test tasks.
|
| 3 |
+
#
|
| 4 |
+
# Expected env:
|
| 5 |
+
# HF_TOKEN (secret) hub downloads + sandbox Jobs
|
| 6 |
+
# ADAPTER_REPO (env) Hub repo with adapter_config.json at root (or empty for base-only)
|
| 7 |
+
# N_TASKS (env) test tasks per model (default 50)
|
| 8 |
+
# OUT_REPO (env) where to upload eval_results.json (default: ADAPTER_REPO)
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
cd "$(dirname "$0")"
|
| 11 |
+
|
| 12 |
+
echo "=== [setup] ==="
|
| 13 |
+
apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
|
| 14 |
+
python3 -m pip install --quiet \
|
| 15 |
+
'openenv[harbor]' \
|
| 16 |
+
'harbor[hf-sandbox]>=0.24.0' \
|
| 17 |
+
math-verify tomli-w \
|
| 18 |
+
aiohttp
|
| 19 |
+
git clone --depth 1 https://github.com/huggingface/OpenEnv.git /opt/OpenEnv
|
| 20 |
+
SITE=$(python3 -c "import site; print(site.getsitepackages()[0])")
|
| 21 |
+
echo "/opt/OpenEnv/envs" > "$SITE/openenv_examples.pth"
|
| 22 |
+
|
| 23 |
+
echo "=== [setup] pinned test tasks ==="
|
| 24 |
+
python3 prepare_tasks.py --output /app/prepared --splits test
|
| 25 |
+
|
| 26 |
+
echo "=== [start] vLLM (base + adapter as LoRA 'trained') ==="
|
| 27 |
+
LORA_ARGS=()
|
| 28 |
+
if [ -n "${ADAPTER_REPO:-}" ]; then
|
| 29 |
+
LORA_ARGS=(--enable-lora --max-lora-rank 32 --lora-modules "trained=${ADAPTER_REPO}")
|
| 30 |
+
fi
|
| 31 |
+
CUDA_VISIBLE_DEVICES=0 \
|
| 32 |
+
vllm serve "${BASE_MODEL:-LiquidAI/LFM2.5-2.6B}" \
|
| 33 |
+
--port 8001 \
|
| 34 |
+
--max-model-len 32768 \
|
| 35 |
+
--return-tokens-as-token-ids \
|
| 36 |
+
--logprobs-mode processed_logprobs \
|
| 37 |
+
--enable-auto-tool-choice \
|
| 38 |
+
--tool-call-parser lfm2 \
|
| 39 |
+
"${LORA_ARGS[@]}" &
|
| 40 |
+
VLLM_PID=$!
|
| 41 |
+
|
| 42 |
+
echo "=== [start] env server (test split only) ==="
|
| 43 |
+
PREPARED=/app/prepared bash serve_env.sh &
|
| 44 |
+
ENV_PID=$!
|
| 45 |
+
|
| 46 |
+
python3 - <<'EOF'
|
| 47 |
+
import time, urllib.request
|
| 48 |
+
for name, url in [("vllm", "http://127.0.0.1:8001/v1/models"),
|
| 49 |
+
("env", "http://127.0.0.1:8000/health")]:
|
| 50 |
+
for i in range(360):
|
| 51 |
+
try:
|
| 52 |
+
urllib.request.urlopen(url, timeout=5)
|
| 53 |
+
print(name, "ready")
|
| 54 |
+
break
|
| 55 |
+
except Exception:
|
| 56 |
+
time.sleep(5)
|
| 57 |
+
else:
|
| 58 |
+
raise SystemExit(f"{name} server never became ready")
|
| 59 |
+
EOF
|
| 60 |
+
|
| 61 |
+
echo "=== [eval] N_TASKS=${N_TASKS:-50} ==="
|
| 62 |
+
ADAPTER_NAME="${ADAPTER_REPO:+trained}" python3 eval_adapter.py
|
| 63 |
+
|
| 64 |
+
echo "=== [upload] results ==="
|
| 65 |
+
python3 - <<'EOF'
|
| 66 |
+
import os
|
| 67 |
+
from huggingface_hub import HfApi
|
| 68 |
+
repo = os.environ.get("OUT_REPO") or os.environ.get("ADAPTER_REPO")
|
| 69 |
+
if repo:
|
| 70 |
+
api = HfApi()
|
| 71 |
+
api.upload_file(path_or_fileobj="/app/eval_results.json",
|
| 72 |
+
path_in_repo=f"eval/{os.environ.get('EVAL_TAG', 'eval')}_results.json",
|
| 73 |
+
repo_id=repo)
|
| 74 |
+
print("uploaded to", repo)
|
| 75 |
+
EOF
|
| 76 |
+
|
| 77 |
+
kill $VLLM_PID $ENV_PID 2>/dev/null || true
|
| 78 |
+
echo "=== done ==="
|
run_job.sh
CHANGED
|
@@ -90,5 +90,50 @@ ENV_URL=http://127.0.0.1:8000 \
|
|
| 90 |
TRAIN_SPLIT=/app/prepared/datasets/train \
|
| 91 |
python3 train_lora.py
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
kill $VLLM_PID $ENV_PID 2>/dev/null || true
|
| 94 |
echo "=== done ==="
|
|
|
|
| 90 |
TRAIN_SPLIT=/app/prepared/datasets/train \
|
| 91 |
python3 train_lora.py
|
| 92 |
|
| 93 |
+
if [ "${SKIP_EVAL:-0}" != "1" ] && [ -n "${ADAPTER_REPO:-}" ]; then
|
| 94 |
+
echo "=== [eval] restarting vLLM with the trained adapter (GPU 1) ==="
|
| 95 |
+
kill $VLLM_PID 2>/dev/null || true; sleep 10
|
| 96 |
+
# Late-bind the eval code: a fix uploaded during training still lands here.
|
| 97 |
+
mkdir -p /app-eval
|
| 98 |
+
hf download "${CODE_REPO:-SaylorTwift/lfm-opencode-lora-rl-code}" \
|
| 99 |
+
--local-dir /app-eval --include "eval_adapter.py" || cp /app/eval_adapter.py /app-eval/
|
| 100 |
+
CUDA_VISIBLE_DEVICES=1 \
|
| 101 |
+
vllm serve "${MODEL:-LiquidAI/LFM2.5-2.6B}" \
|
| 102 |
+
--port 8001 \
|
| 103 |
+
--max-model-len 32768 \
|
| 104 |
+
--return-tokens-as-token-ids \
|
| 105 |
+
--logprobs-mode processed_logprobs \
|
| 106 |
+
--enable-auto-tool-choice \
|
| 107 |
+
--tool-call-parser lfm2 \
|
| 108 |
+
--enable-lora --max-lora-rank 32 \
|
| 109 |
+
--lora-modules "trained=${ADAPTER_REPO}" &
|
| 110 |
+
VLLM_PID=$!
|
| 111 |
+
python3 - <<'EOF'
|
| 112 |
+
import time, urllib.request
|
| 113 |
+
for i in range(360):
|
| 114 |
+
try:
|
| 115 |
+
urllib.request.urlopen("http://127.0.0.1:8001/v1/models", timeout=5)
|
| 116 |
+
print("vllm ready")
|
| 117 |
+
break
|
| 118 |
+
except Exception:
|
| 119 |
+
time.sleep(5)
|
| 120 |
+
else:
|
| 121 |
+
raise SystemExit("vllm never became ready for eval")
|
| 122 |
+
EOF
|
| 123 |
+
echo "=== [eval] 250 held-out tasks x {base, trained} ==="
|
| 124 |
+
N_TASKS=250 OUT=/app/eval_results_final.json ADAPTER_NAME=trained \
|
| 125 |
+
python3 /app-eval/eval_adapter.py
|
| 126 |
+
python3 - <<'EOF'
|
| 127 |
+
import os
|
| 128 |
+
from huggingface_hub import HfApi
|
| 129 |
+
repo = os.environ.get("ADAPTER_REPO")
|
| 130 |
+
if repo:
|
| 131 |
+
HfApi().upload_file(path_or_fileobj="/app/eval_results_final.json",
|
| 132 |
+
path_in_repo="eval/final_results.json",
|
| 133 |
+
repo_id=repo)
|
| 134 |
+
print("uploaded to", repo)
|
| 135 |
+
EOF
|
| 136 |
+
fi
|
| 137 |
+
|
| 138 |
kill $VLLM_PID $ENV_PID 2>/dev/null || true
|
| 139 |
echo "=== done ==="
|