File size: 10,729 Bytes
994182c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | #!/usr/bin/env python3
"""Launch and/or watch a Stage-1 training run: loss curve + benchmark-vs-base.
This is the single "just run it" entrypoint for a training run you want to babysit.
It reads the files run_sft.py writes (`<output_dir>/metrics.jsonl` and, when the
in-training benchmark is enabled, `<output_dir>/eval_progress.jsonl`) and renders a
compact snapshot: an ASCII loss curve, latest lr/grad-norm/eval-loss, and the held-out
benchmark accuracy with its delta vs the base model.
Modes:
--once render one snapshot and exit (what an external watcher polls each tick)
(default loop) re-render every --interval seconds (default 900 = 15 min)
--launch CONFIG start `run_sft.py --config CONFIG` detached first, then watch
The renderers are pure functions (stdlib only) so they are unit-tested offline; the
training process itself is what needs the GPU.
Examples:
# launch Stage-1 and watch every 15 min, comparing to the base baseline:
python training/scripts/train_watch.py \
--launch training/configs/stage1_lora_sft.yaml \
--output-dir /workspace/checkpoints/qwen36_27b_cybergym_stage1_lora_sft \
--base-eval reports/eval/base_eval.json --interval 900
# one snapshot of an already-running run:
python training/scripts/train_watch.py \
--output-dir /workspace/checkpoints/qwen36_27b_cybergym_stage1_lora_sft --once
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
BLOCKS = "βββββ
βββ"
def read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
pass
return rows
def sparkline(values: list[float], width: int = 60) -> str:
vals = [v for v in values if isinstance(v, (int, float))]
if not vals:
return "(no data)"
if len(vals) > width:
# bucket-average down to width points
step = len(vals) / width
bucketed = []
for i in range(width):
chunk = vals[int(i * step):int((i + 1) * step)] or [vals[min(int(i * step), len(vals) - 1)]]
bucketed.append(sum(chunk) / len(chunk))
vals = bucketed
lo, hi = min(vals), max(vals)
if hi - lo < 1e-12:
return BLOCKS[0] * len(vals)
return "".join(BLOCKS[min(len(BLOCKS) - 1, int((v - lo) / (hi - lo) * (len(BLOCKS) - 1)))] for v in vals)
def trend(values: list[float]) -> str:
vals = [v for v in values if isinstance(v, (int, float))]
if len(vals) < 2:
return "n/a"
delta = vals[-1] - vals[0]
arrow = "β" if delta < 0 else ("β" if delta > 0 else "β")
return f"{arrow} {delta:+.4f} over {len(vals)} pts"
def fmt_age(ts: float | None) -> str:
if not ts:
return "n/a"
secs = max(0, time.time() - ts)
if secs < 90:
return f"{int(secs)}s ago"
if secs < 5400:
return f"{secs/60:.1f}m ago"
return f"{secs/3600:.1f}h ago"
def summarize_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
train = [(r.get("step"), r.get("loss"), r.get("ts")) for r in rows if "loss" in r]
evals = [(r.get("step"), r.get("eval_loss")) for r in rows if "eval_loss" in r]
losses = [l for _, l, _ in train]
last = rows[-1] if rows else {}
return {
"n_log": len(rows),
"train_losses": losses,
"first_loss": losses[0] if losses else None,
"last_loss": losses[-1] if losses else None,
"min_loss": min(losses) if losses else None,
"eval_losses": [e for _, e in evals],
"last_lr": last.get("learning_rate"),
"last_grad_norm": last.get("grad_norm"),
"last_epoch": last.get("epoch"),
"last_step": last.get("step"),
"last_ts": last.get("ts"),
}
def render_snapshot(output_dir: Path, metrics_rows, progress_rows, base_acc: dict[str, float]) -> str:
m = summarize_metrics(metrics_rows)
out: list[str] = []
epoch_str = f"{m['last_epoch']:.2f}" if isinstance(m["last_epoch"], (int, float)) else str(m["last_epoch"])
out.append(f"# Training watch β {output_dir.name}")
out.append("")
out.append(f"- log lines: {m['n_log']} | last step: {m['last_step']} epoch: {epoch_str}")
out.append(f"- last update: {fmt_age(m['last_ts'])}")
out.append("")
out.append("## Train loss")
out.append(f"`{sparkline(m['train_losses'])}`")
out.append(f"- first {m['first_loss']} β last {m['last_loss']} (min {m['min_loss']})")
out.append(f"- trend: {trend(m['train_losses'])}")
if m["eval_losses"]:
out.append("")
out.append("## Eval loss")
out.append(f"`{sparkline(m['eval_losses'])}` last {m['eval_losses'][-1]}")
out.append("")
out.append(f"- lr: {m['last_lr']} | grad_norm: {m['last_grad_norm']}")
# checkpoints
ckpts = sorted(p.name for p in output_dir.glob("checkpoint-*")) if output_dir.is_dir() else []
if ckpts:
out.append(f"- checkpoints: {', '.join(ckpts)}")
# per-set accuracy trajectory across checkpoints (early-trend detection)
seqs = {} # set_name -> [(step, acc), ...]
valid = [r for r in progress_rows if isinstance(r, dict) and "error" not in r]
for r in valid:
for name, met in (r.get("sets") or {}).items():
if isinstance(met, dict) and "accuracy" in met:
seqs.setdefault(name, []).append((r.get("step"), met["accuracy"]))
# overall early-trend verdict (loss + benchmark direction)
loss_dir = trend(m["train_losses"]) # has β/β arrow
bench_bits = []
for name, pts in seqs.items():
if len(pts) >= 2:
d = pts[-1][1] - pts[0][1]
arrow = "β" if d > 0.01 else ("β" if d < -0.01 else "β")
bench_bits.append(f"{name.replace('_test','').replace('knowledge_','')}:{pts[0][1]:.0%}{arrow}{pts[-1][1]:.0%}")
verdict = f"loss {loss_dir}"
if bench_bits:
verdict += " | " + " ".join(bench_bits)
out.insert(3, f"- β‘ EARLY TREND: {verdict}")
out.append("")
out.append("## Held-out benchmark vs base")
if not valid:
out.append("_(no in-training benchmark yet β runs every eval_every_steps + epoch end)_")
else:
latest = valid[-1]
out.append(f"- as of step {latest.get('step')} (epoch {latest.get('epoch')}):")
out.append("")
out.append("| set | now | base | Ξ vs base | trajectory |")
out.append("|---|---|---|---|---|")
for name, met in (latest.get("sets") or {}).items():
if not isinstance(met, dict) or "accuracy" not in met:
continue
kind = met.get("kind", "")
b = base_acc.get(kind)
acc = met["accuracy"]
d = f"{acc - b:+.1%}" if isinstance(b, (int, float)) else "n/a"
bs = f"{b:.0%}" if isinstance(b, (int, float)) else "n/a"
traj = " ".join(f"{a:.0%}" for _, a in seqs.get(name, []))
f1 = f" F1 {met.get('f1_vuln',0):.2f}" if kind == "vuln_detection" else ""
out.append(f"| {name} | {acc:.0%}{f1} | {bs} | {d} | {traj} |")
out.append("")
out.append(f"_rendered {fmt_age(time.time())[:-4] or 'now'} (utc epoch {int(time.time())})_")
return "\n".join(out)
def load_base_acc(base_eval: Path | None) -> dict[str, float]:
if not base_eval or not base_eval.is_file():
return {}
try:
payload = json.loads(base_eval.read_text(encoding="utf-8"))
return {r["kind"]: r["accuracy"] for r in payload.get("results", []) if "kind" in r}
except Exception:
return {}
def one_snapshot(args) -> str:
output_dir = Path(args.output_dir)
metrics_path = Path(args.metrics) if args.metrics else output_dir / "metrics.jsonl"
progress_path = Path(args.progress) if args.progress else output_dir / "eval_progress.jsonl"
base_acc = load_base_acc(Path(args.base_eval) if args.base_eval else None)
snap = render_snapshot(output_dir, read_jsonl(metrics_path), read_jsonl(progress_path), base_acc)
if args.snapshot_out:
out = Path(args.snapshot_out)
else:
out = Path("reports/training") / output_dir.name / "watch.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(snap + "\n", encoding="utf-8")
return snap
def launch_training(config: str, log_path: Path) -> subprocess.Popen:
log_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [sys.executable, str(Path(__file__).resolve().parent / "run_sft.py"), "--config", config]
print(f"launching: {' '.join(cmd)} (log: {log_path})")
return subprocess.Popen(cmd, stdout=log_path.open("w"), stderr=subprocess.STDOUT)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--output-dir", required=True, help="Training output_dir (holds metrics.jsonl).")
p.add_argument("--metrics", help="Override path to metrics.jsonl.")
p.add_argument("--progress", help="Override path to eval_progress.jsonl.")
p.add_argument("--base-eval", help="reports/eval/base_eval.json for vs-base comparison.")
p.add_argument("--snapshot-out", help="Where to write the snapshot markdown.")
p.add_argument("--interval", type=int, default=900, help="Seconds between snapshots (default 900).")
p.add_argument("--once", action="store_true", help="Render one snapshot and exit.")
p.add_argument("--max-ticks", type=int, default=0, help="Stop after N snapshots (0 = until training ends).")
p.add_argument("--launch", help="Launch run_sft.py with this config before watching.")
p.add_argument("--launch-log", default="/workspace/tmp/stage1_train.log")
return p.parse_args()
def main() -> int:
args = parse_args()
proc = None
if args.launch:
proc = launch_training(args.launch, Path(args.launch_log))
if args.once and not args.launch:
print(one_snapshot(args))
return 0
tick = 0
while True:
tick += 1
print("\n" + "=" * 72)
print(one_snapshot(args))
if proc is not None and proc.poll() is not None:
print(f"\n[training process exited rc={proc.returncode}] final snapshot above.")
return proc.returncode or 0
if args.max_ticks and tick >= args.max_ticks:
return 0
if args.once:
return 0
time.sleep(args.interval)
if __name__ == "__main__":
raise SystemExit(main())
|