SaylorTwift HF Staff commited on
Commit
c511323
·
verified ·
1 Parent(s): f4740f8

Add held-out eval (base vs adapter) + standalone eval job + final eval stage in training job

Browse files
Files changed (4) hide show
  1. eval_adapter.py +116 -0
  2. prepare_tasks.py +13 -11
  3. run_eval.sh +78 -0
  4. 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 ("train", "test"):
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
- def names(split):
98
- return {r["name"] for r in task_rows(split)}
99
- notebooks = [
100
- {r.get("notebook") for r in task_rows(s)} - {None} for s in ("train", "test")
101
- ]
102
- if names("train") & names("test") or notebooks[0] & notebooks[1]:
103
- raise ValueError("Train/test overlap")
104
- print("no train/test overlap")
 
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 ==="