Download code/environments/snake/rl.py from markuz89/minijev-snake: direct link, hf CLI and curl.
- Browser
- Download file 8.59 kB
-
https://huggingface.co/markuz89/minijev-snake/resolve/main/code/environments/snake/rl.py
- Command line
-
hf download hf://markuz89/minijev-snake/code/environments/snake/rl.py
-
curl -L -o rl.py https://huggingface.co/markuz89/minijev-snake/resolve/main/code/environments/snake/rl.py
8.59 kB
| """PPO fine-tuning of a supervised MiniJEV Snake policy (Phase 2). | |
| Rollouts: `n_envs` games stepped together, batched inference, sampled actions. | |
| Reward comes from `env.reward` in the config (food +, death -, tiny/zero per | |
| step). The value head is warmed up first (it was never trained by behaviour | |
| cloning), and a KL penalty to the frozen supervised policy keeps early updates | |
| from destroying what imitation learned. Resumable from `rl_last.pt`. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import json | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| from minijev.calibration.metrics import calibration_report, fit_temperature | |
| from minijev.datasets.batching import BatchEncoder | |
| from minijev.datasets.decision_data import DecisionData | |
| from minijev.inference.runtime import TorchDecider | |
| from minijev.runs import RunLogger, environment_info | |
| from minijev.tokenizer import encode_batch | |
| from minijev.training.ppo import gae, ppo_update | |
| from minijev.training.supervised import make_model, predict_logits | |
| from .env import SnakeConfig, VectorSnakeEnv | |
| from .evaluate import model_policy, play_games | |
| from .task import SnakeTask | |
| QUESTION = SnakeTask.default_question | |
| def train_ppo(cfg: dict, checkpoint: str, run_dir: str | None = None, resume: bool = False) -> dict: | |
| rl = cfg.get("rl", {}) | |
| seed = int(cfg.get("seed", 0)) | |
| torch.manual_seed(seed) | |
| torch.set_num_threads(int(rl.get("threads", 8))) | |
| ck = torch.load(checkpoint, map_location="cpu", weights_only=False) | |
| base_cfg = ck["config"] | |
| task = SnakeTask(base_cfg.get("task", {})) # the checkpoint decides the choice labels | |
| model = make_model(base_cfg, task) | |
| model.load_state_dict(ck["model"]) | |
| model.log_temperature.zero_() | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model.to(device) | |
| ref = copy.deepcopy(model).eval() | |
| for p in ref.parameters(): | |
| p.requires_grad_(False) | |
| run_dir = Path(run_dir or f"runs/{cfg.get('name', 'snake')}-ppo") | |
| log = RunLogger(run_dir, {**cfg, "init_checkpoint": checkpoint}, resume=resume) | |
| opt = torch.optim.AdamW(model.parameters(), lr=rl.get("lr", 1e-4), weight_decay=0.0) | |
| env_cfg = SnakeConfig.from_dict(cfg.get("env", {})) | |
| n_envs, T = int(rl.get("n_envs", 64)), int(rl.get("rollout", 128)) | |
| total_updates = int(rl.get("total_env_steps", 1_000_000)) // (n_envs * T) | |
| start, best = 0, -1.0 | |
| last_ck = run_dir / "checkpoints" / "rl_last.pt" | |
| if resume and last_ck.exists(): | |
| st = torch.load(last_ck, weights_only=False) | |
| model.load_state_dict(st["model"]) | |
| opt.load_state_dict(st["optimizer"]) | |
| start, best = st["update"], st["best"] | |
| print(f"[rl] resumed at update {start}") | |
| envs = VectorSnakeEnv(n_envs, env_cfg, seed=seed * 7919 + start * n_envs) | |
| mcfg = base_cfg["model"] | |
| q = torch.as_tensor(encode_batch([QUESTION], mcfg.get("max_question_len", 32)), device=device).repeat(n_envs, 1) | |
| c = torch.as_tensor(encode_batch(list(task.default_choices), mcfg.get("max_choice_len", 16)), | |
| device=device)[None].repeat(n_envs, 1, 1) | |
| gamma, lam = rl.get("gamma", 0.99), rl.get("gae_lambda", 0.95) | |
| warm = int(rl.get("value_warmup_updates", 5)) | |
| eval_every = int(rl.get("eval_every", 10)) | |
| log.write_json("env.json", {"env": environment_info(), "updates": total_updates, "n_envs": n_envs, "rollout": T}) | |
| ep_scores: list[int] = [] | |
| t0 = time.time() | |
| print(f"[rl] PPO: {total_updates} updates x {n_envs * T} steps, reward={cfg.get('env', {}).get('reward')}") | |
| for upd in range(start, total_updates): | |
| obs, acts, logps, vals, rews, dns = [], [], [], [], [], [] | |
| model.eval() | |
| for _ in range(T): | |
| x = torch.as_tensor(task.encode_states(envs.states())["state_grid"], device=device) | |
| with torch.no_grad(): | |
| out = model({"state_grid": x}, q, c) | |
| dist = torch.distributions.Categorical(logits=out["logits"]) | |
| a = dist.sample() | |
| r, d, infos = envs.step(a.cpu().tolist()) | |
| ep_scores += [i["score"] for i in infos if i] | |
| obs.append(x) | |
| acts.append(a) | |
| logps.append(dist.log_prob(a)) | |
| vals.append(out["value"].cpu()) | |
| rews.append(r) | |
| dns.append(d.astype(np.float32)) | |
| with torch.no_grad(): | |
| x = torch.as_tensor(task.encode_states(envs.states())["state_grid"], device=device) | |
| last_v = model({"state_grid": x}, q, c)["value"].cpu().numpy() | |
| V = torch.stack(vals).numpy() | |
| adv, ret = gae(np.stack(rews), V, np.stack(dns), last_v, gamma, lam) | |
| adv_t = torch.as_tensor(adv.reshape(-1), device=device) | |
| data = {"state": {"state_grid": torch.cat(obs)}, "question_ids": q.repeat(T, 1), | |
| "choice_ids": c.repeat(T, 1, 1), "actions": torch.cat(acts), "old_logp": torch.cat(logps), | |
| "adv": (adv_t - adv_t.mean()) / (adv_t.std() + 1e-8), "ret": torch.as_tensor(ret.reshape(-1), device=device)} | |
| stats = ppo_update(model, opt, data, rl, ref_model=ref, policy_coef=0.0 if upd < warm else 1.0) | |
| recent = ep_scores[-200:] | |
| log.log(upd + 1, env_steps=(upd + 1) * n_envs * T, train_avg_score=float(np.mean(recent)) if recent else 0.0, | |
| episodes=len(ep_scores), **{k: round(v, 5) for k, v in stats.items()}) | |
| print(f" update {upd + 1}/{total_updates} | sampled-policy avg score {np.mean(recent) if recent else 0:.1f} " | |
| f"| entropy {stats['entropy']:.3f} kl_ref {stats['kl_ref']:.4f} vf {stats['vf']:.4f} " | |
| f"| {time.time() - t0:.0f}s", flush=True) | |
| if (upd + 1) % eval_every == 0 or upd + 1 == total_updates: | |
| dec = TorchDecider(model=model, cfg=base_cfg, device=str(device)) | |
| g = play_games(model_policy(dec), int(rl.get("eval_games", 100)), cfg.get("env", {}), seed=777) | |
| log.log(upd + 1, eval_avg_score=g["avg_score"], eval_fatal=g["fatal_move_rate"]) | |
| print(f" [eval] greedy avg score {g['avg_score']:.2f} median {g['median_score']} " | |
| f"fatal-move {g['fatal_move_rate']:.4%}", flush=True) | |
| if g["avg_score"] > best: | |
| best = g["avg_score"] | |
| model.to(device) # TorchDecider may have moved it | |
| torch.save({"model": model.state_dict(), "config": base_cfg, "update": upd + 1, "eval": g}, | |
| run_dir / "checkpoints" / "rl_best.pt") | |
| torch.save({"model": model.state_dict(), "optimizer": opt.state_dict(), "update": upd + 1, | |
| "best": best}, last_ck) | |
| return finalize_rl(cfg, base_cfg, run_dir, log) | |
| def finalize_rl(cfg, base_cfg, run_dir: Path, log) -> dict: | |
| """Re-calibrate the best RL policy on the validation split and evaluate it.""" | |
| task = SnakeTask(base_cfg.get("task", {})) | |
| best = torch.load(run_dir / "checkpoints" / "rl_best.pt", weights_only=False) | |
| model = make_model(base_cfg, task) | |
| model.load_state_dict(best["model"]) | |
| data_dir = Path(base_cfg["data"]["dir"]) | |
| mc = base_cfg["model"] | |
| kw = dict(max_question_len=mc.get("max_question_len", 32), max_choice_len=mc.get("max_choice_len", 16)) | |
| val = BatchEncoder(DecisionData.load(data_dir / "val.npz"), task, **kw) | |
| test = BatchEncoder(DecisionData.load(data_dir / "test.npz"), task, **kw) | |
| vl, vt = predict_logits(model, val, "cpu") | |
| tl, tt = predict_logits(model, test, "cpu") | |
| T = fit_temperature(vl, vt) | |
| model.log_temperature.fill_(float(np.log(T))) | |
| acc_ok = test.data.soft > 0 if test.data.soft is not None else None | |
| rep = calibration_report(tl, tt, T, acceptable=acc_ok) | |
| torch.save({"model": model.state_dict(), "config": base_cfg, "temperature": T}, run_dir / "checkpoints" / "final.pt") | |
| games = play_games(model_policy(TorchDecider(run_dir / "checkpoints" / "final.pt", threads=4)), | |
| cfg.get("eval", {}).get("games", 200), cfg.get("env", {})) | |
| (run_dir / "games.json").write_text(json.dumps(games, indent=2)) | |
| summary = {"params": model.num_parameters(), "temperature": T, "test_calibrated": rep, | |
| "test_raw": calibration_report(tl, tt, 1.0, acceptable=acc_ok), "games": games, | |
| "final_checkpoint": str(run_dir / "checkpoints" / "final.pt")} | |
| log.write_json("summary.json", summary) | |
| log.close() | |
| print(f"[rl] final: avg score {games['avg_score']:.2f} | oracle-agreement {rep['accuracy']:.4f} " | |
| f"| ECE vs oracle {rep['ece']:.4f} (T={T:.3f})") | |
| return summary | |
| __all__ = ["train_ppo"] | |