File size: 15,920 Bytes
97f78c9 | 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 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | #!/usr/bin/env python3
"""Train a Jevon decision model on procedurally generated maze and Snake states."""
from __future__ import annotations
import argparse
import json
import math
import random
import sys
import time
from pathlib import Path
import torch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from jevon.data import SamplerConfig, TaskSampler, build_corpus, collate, load_frozen
from jevon.losses import LossWeights, decision_loss, field_loss
from jevon.modeling import JevonConfig, JevonModel
from jevon.planner import default_iterations
from jevon.tokenizer import JevonTokenizer
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--out", default="runs/jevon-s")
p.add_argument("--steps", type=int, default=6000)
p.add_argument("--states-per-batch", type=int, default=12)
p.add_argument("--lr", type=float, default=3e-4)
p.add_argument("--warmup", type=int, default=300)
p.add_argument("--weight-decay", type=float, default=0.01)
p.add_argument("--grad-clip", type=float, default=1.0)
p.add_argument("--seed", type=int, default=17)
p.add_argument("--device", default="mps")
p.add_argument("--d-model", type=int, default=384)
p.add_argument("--planner-dim", type=int, default=96)
p.add_argument("--planner-grad-steps", type=int, default=8)
p.add_argument("--planner-lr-mult", type=float, default=10.0,
help="learning-rate multiplier for the planner and field head")
p.add_argument("--field-target", choices=("log", "linear"), default="log",
help="shape of the distance target; linear keeps neighbour "
"contrast uniform, which is what greedy descent needs")
p.add_argument("--descent-weight", type=float, default=0.0,
help="hinge the field's 4-neighbour ordering toward the true "
"downhill step; this is what greedy descent reads")
p.add_argument("--descent-margin", type=float, default=1.0,
help="required neighbour separation, in units of one true step")
p.add_argument("--score-smoothing", type=float, default=0.0,
help="spread score targets over neighbouring levels (0 = one-hot)")
p.add_argument("--min-plus-field", action="store_true",
help="read the distance off a min-plus recurrence, making the "
"field traversable by construction")
p.add_argument("--expose-field", action="store_true",
help="let the text side read the planner's own distance read-out")
p.add_argument("--field-weight", type=float, default=1.0,
help="weight on the dense planner distance-field loss")
p.add_argument("--iteration-jitter", type=float, default=0.5,
help="planner budget is scaled by U(1-j, 1+j) during training")
p.add_argument("--snake-fraction", type=float, default=0.45)
p.add_argument("--eval-every", type=int, default=250)
p.add_argument("--eval-file", default="data/frozen/val.jsonl")
p.add_argument("--log-every", type=int, default=25)
p.add_argument("--resume", default=None)
p.add_argument("--no-planner", action="store_true")
return p.parse_args()
def lr_at(step: int, args) -> float:
if step < args.warmup:
return args.lr * (step + 1) / args.warmup
progress = (step - args.warmup) / max(1, args.steps - args.warmup)
return args.lr * (0.05 + 0.95 * 0.5 * (1 + math.cos(math.pi * min(progress, 1.0))))
def bucket_batch(sampler: TaskSampler, states: int, rng: random.Random) -> list:
"""One batch holds a single game and board size: exact planner budget, no padding."""
cfg = sampler.cfg
snake = rng.random() < cfg.snake_fraction
size = rng.choice(list(cfg.snake_sizes if snake else cfg.maze_sizes))
topology = None if snake else rng.choice(list(cfg.maze_topologies))
out = []
guard = 0
while len(out) < states and guard < states * 40:
guard += 1
seed = sampler._draw_seed()
if snake:
from jevon.data import sample_snake_state
sample = sample_snake_state(seed, size, sampler.rng, cfg.augment)
else:
from jevon.data import sample_maze_state
sample = sample_maze_state(seed, size, topology, sampler.rng, cfg.augment)
if sample is not None:
out.append(sample)
return out
@torch.no_grad()
def evaluate(model, states, tokenizer, device, args) -> dict:
model.eval()
totals: dict[str, list[float]] = {}
by_game: dict[str, list[float]] = {}
for start in range(0, len(states), args.states_per_batch):
group = states[start:start + args.states_per_batch]
batch = collate(group, tokenizer, device=device)
steps = default_iterations(int(batch["board_size"].max()))
cells = None
if model.planner is not None and "boards" in batch:
cells = model.encode_boards(batch["boards"], batch["passable"], steps, 0)
logits = model(batch, iterations=steps, grad_steps=0, cells=cells)
_, stats = decision_loss(logits, batch)
if cells is not None and "dist_field" in batch:
# Scored against the model's own target shape, so field_r2 means
# "how well did it fit what it was asked to fit". It is therefore
# NOT comparable across --field-target settings; descent accuracy
# from probe.py is the shape-independent metric for that.
_, f_stats = field_loss(model.supervised_field(cells),
batch["dist_field"],
batch["passable"], batch["board_size"],
args.field_target,
args.descent_weight, args.descent_margin)
stats.update(f_stats)
for key, value in stats.items():
v = float(value)
if v == v:
totals.setdefault(key, []).append(v)
target, valid = batch["target_probs"], batch["candidate_valid"]
picked = logits.argmax(-1)
hit = target.gather(1, picked.unsqueeze(1)).squeeze(1)
correct = (hit >= target.max(-1).values - 1e-6).float()
for meta, ok, qtype in zip(batch["meta"], correct.tolist(),
batch["question_type"].tolist()):
if qtype == 0: # choice == the gameplay decision
by_game.setdefault(f"{meta['game']}_action", []).append(ok)
del valid
model.train()
out = {k: sum(v) / len(v) for k, v in totals.items() if not k.startswith("_")}
if "_f_n" in totals: # pool R^2 instead of averaging it
n = sum(totals["_f_n"])
sse, tsum, tsq = (sum(totals[k]) for k in ("_f_sse", "_f_sum", "_f_sumsq"))
centred = max(tsq - tsum * tsum / max(n, 1.0), 1e-9)
out["field_r2"] = 1.0 - sse / centred
out["field_mae"] = sum(totals["_f_abs"]) / max(n, 1.0)
out.update({k: sum(v) / len(v) for k, v in by_game.items() if v})
return out
def balanced_score(metrics: dict) -> float:
"""How well the checkpoint plays *both* games, for model selection.
Eval loss is dominated by the ~970 boolean questions in the frozen split,
so the ~170 action questions every headline number comes from barely move
it -- a checkpoint that stopped playing snake can still own the lowest
loss, and one did. ``min`` rather than the mean because the failure being
guarded against is exactly the trade of one game for the other, and a mean
would average it away.
A missing metric scores zero rather than being skipped: a run that reports
no snake_action has not earned a balanced checkpoint.
"""
return min(metrics.get("maze_action", 0.0), metrics.get("snake_action", 0.0))
def main() -> None:
args = parse_args()
torch.manual_seed(args.seed)
rng = random.Random(args.seed)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
device = torch.device(args.device if torch.backends.mps.is_available()
or args.device != "mps" else "cpu")
sampler_cfg = SamplerConfig(snake_fraction=args.snake_fraction)
train_sampler = TaskSampler(sampler_cfg, "train", args.seed)
tok_path = out_dir / "tokenizer.json"
if tok_path.exists():
tokenizer = JevonTokenizer.load(tok_path)
else:
print("building tokenizer vocabulary ...", flush=True)
tokenizer = JevonTokenizer.build(build_corpus(train_sampler, 300),
max_vocab=4096, min_count=1)
tokenizer.save(tok_path)
print(f"vocab={len(tokenizer)} device={device}")
cfg = JevonConfig(vocab_size=len(tokenizer), d_model=args.d_model,
planner_dim=args.planner_dim,
planner_grad_steps=args.planner_grad_steps,
expose_field=args.expose_field,
min_plus_field=args.min_plus_field,
use_planner=not args.no_planner)
model = JevonModel(cfg).to(device)
if args.resume:
model.load_state_dict(torch.load(args.resume, map_location=device))
print(f"parameters: {model.num_parameters()/1e6:.2f}M")
(out_dir / "config.json").write_text(json.dumps(cfg.to_dict(), indent=2))
# config.json is the *architecture*; this is the *recipe*. Without it a run
# directory records what was built but not how it was trained, and the
# flags that distinguish these runs from each other -- --min-plus-field,
# --planner-lr-mult, --field-weight, --seed -- live only in the shell
# history of whoever launched them. argv is recorded verbatim alongside the
# parsed values because the two disagree in the way that matters: argv says
# what was asked for, the parsed dict says what the defaults turned it into.
(out_dir / "args.json").write_text(json.dumps(
{"argv": sys.argv, "args": vars(args)}, indent=2, default=str))
# The planner is one small block applied several hundred times, and only the
# last `grad_steps` applications carry gradient, so its updates arrive at a
# completely different scale from the transformer's. Measured on a fixed set
# of 11x11 mazes, 200 steps of field-only training: lr 3e-4 reaches R^2 0.24,
# lr 3e-3 reaches 0.64. It needs its own learning rate.
planner_names = {n for n, _ in model.named_parameters()
if n.startswith("planner.") or n.startswith("field_head.")}
groups = []
for is_planner in (False, True):
for wants_decay in (True, False):
params = [p for n, p in model.named_parameters()
if (n in planner_names) == is_planner
and (p.dim() >= 2) == wants_decay]
if params:
groups.append({
"params": params,
"weight_decay": args.weight_decay if wants_decay else 0.0,
"lr_mult": args.planner_lr_mult if is_planner else 1.0})
opt = torch.optim.AdamW(groups, lr=args.lr, betas=(0.9, 0.95))
eval_states = load_frozen(args.eval_file) if Path(args.eval_file).exists() else []
print(f"frozen eval states: {len(eval_states)}")
weights = LossWeights(score_smoothing=args.score_smoothing)
history, best = [], float("inf")
# Eval loss is dominated by the ~970 boolean questions in the split; the
# 170 action questions that every headline number is computed from barely
# move it. Selecting on loss alone therefore picked a checkpoint that had
# traded one game away -- a step reached maze_action 0.979 while snake_action
# fell to 0.297, below its own uniform control, and still took best.pt
# because the aggregate loss was the lowest so far. `min` rather than the
# mean because the failure being guarded against is exactly that trade.
best_balanced = -float("inf")
running: dict[str, float] = {}
start_time = time.time()
for step in range(args.steps):
for group in opt.param_groups:
group["lr"] = lr_at(step, args) * group["lr_mult"]
states = bucket_batch(train_sampler, args.states_per_batch, rng)
batch = collate(states, tokenizer, device=device)
size = int(batch["board_size"].max())
# Jitter skews upward: too few iterations teaches the model to decide
# before the distance field has arrived, which is the failure we are fixing.
jitter = 1.0 + rng.uniform(-0.25 * args.iteration_jitter, args.iteration_jitter)
steps_t = max(8, int(default_iterations(size) * jitter))
cells = None
if model.planner is not None and "boards" in batch:
cells = model.encode_boards(batch["boards"], batch["passable"],
steps_t, args.planner_grad_steps)
logits = model(batch, iterations=steps_t, cells=cells)
loss, stats = decision_loss(logits, batch, weights)
# Dense planner supervision. Without it the recurrence gets gradient
# from one distance question per state, which measurably fails to
# teach it distances (linear probe R^2 ~= 0.21).
if cells is not None and args.field_weight > 0 and "dist_field" in batch:
f_loss, f_stats = field_loss(model.supervised_field(cells),
batch["dist_field"], batch["passable"],
batch["board_size"], args.field_target,
args.descent_weight, args.descent_margin)
loss = loss + args.field_weight * f_loss
stats.update(f_stats)
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
opt.step()
for key, value in stats.items():
v = float(value)
if v == v:
running[key] = 0.9 * running.get(key, v) + 0.1 * v
if (step + 1) % args.log_every == 0:
rate = (step + 1) / (time.time() - start_time)
print(f"step {step+1:5d}/{args.steps} loss {running['loss']:.4f} "
f"acc {running['acc']:.3f} choice {running.get('acc_choice', float('nan')):.3f} "
f"bool {running.get('acc_boolean', float('nan')):.3f} "
f"tvd {running['tvd']:.3f} T={steps_t} {rate:.2f} it/s", flush=True)
if eval_states and ((step + 1) % args.eval_every == 0 or step + 1 == args.steps):
metrics = evaluate(model, eval_states, tokenizer, device, args)
metrics["step"] = step + 1
history.append(metrics)
print(" eval " + " ".join(
f"{k}={v:.4f}" for k, v in metrics.items() if k != "step"), flush=True)
(out_dir / "history.json").write_text(json.dumps(history, indent=2))
if metrics["loss"] < best:
best = metrics["loss"]
torch.save(model.state_dict(), out_dir / "best.pt")
(out_dir / "best.json").write_text(json.dumps(metrics, indent=2))
balanced = balanced_score(metrics)
if balanced > best_balanced:
best_balanced = balanced
torch.save(model.state_dict(), out_dir / "balanced.pt")
(out_dir / "balanced.json").write_text(
json.dumps({**metrics, "balanced": balanced}, indent=2))
if (step + 1) % 500 == 0:
torch.save(model.state_dict(), out_dir / "last.pt")
torch.save(model.state_dict(), out_dir / "last.pt")
print(f"done in {(time.time()-start_time)/60:.1f} min; best eval loss {best:.4f}; "
f"best balanced action acc {best_balanced:.4f}")
if __name__ == "__main__":
main()
|