markuz89's picture
MiniJEV v2 model export
026f63e verified
Raw History Blame Contribute Delete
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"]