tiny-cube-value / code /train.py
briscoooe's picture
Value function training and state search
223c8ed verified
Raw
History Blame Contribute Delete
10.8 kB
"""
Trains the tiny cube-solving model.
Architecture is a stock LlamaConfig rather than a bespoke nn.Module. The model is
identical either way, but a standard architecture loads with plain
`transformers` (no trust_remote_code), pushes to the Hub cleanly, and stays
compatible with the wider tooling if it is ever wanted. That costs a config
object instead of a class.
## The metric is solve rate, not loss
Token accuracy and validation loss are both misleading here. A cube has
astronomically many valid solutions and Kociemba emits one of them, so a model
that produces a *different* valid solve scores badly on token match and
perfectly on the only thing that matters. The harness scores by applying the
moves and asking the engine whether the cube ended solved, so evaluation here
does the same. Watch `solve_rate`; loss is only useful for spotting divergence.
"""
import argparse, json, math, os, random, sys, time
from collections import defaultdict
from pathlib import Path
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
sys.path.insert(0, str(Path(__file__).parent))
import cube_tokenizer as T
from gen_data import SOLVED, apply_sequence
class CubeDataset(Dataset):
def __init__(self, rows):
self.rows = rows
def __len__(self):
return len(self.rows)
def __getitem__(self, i):
state, solution = self.rows[i]
ids, labels = T.encode_pair(state, solution)
return torch.tensor(ids), torch.tensor(labels)
def collate(batch):
n = max(len(x[0]) for x in batch)
ids = torch.full((len(batch), n), T.PAD, dtype=torch.long)
labels = torch.full((len(batch), n), -100, dtype=torch.long)
mask = torch.zeros((len(batch), n), dtype=torch.long)
for i, (a, b) in enumerate(batch):
ids[i, : len(a)] = a
labels[i, : len(b)] = b
mask[i, : len(a)] = 1
return ids, labels, mask
def load_jsonl(path, limit=0):
rows = []
with open(path) as fh:
for line in fh:
if limit and len(rows) >= limit:
break
r = json.loads(line)
moves = r["solution"].split()
if len(moves) > T.MAX_SOLUTION:
continue
rows.append((r["state"], moves))
return rows
@torch.no_grad()
def solve_rate(model, rows, device, max_new=T.MAX_SOLUTION + 1, batch_size=256):
"""Greedy-decodes each state and reports the fraction that solve, plus a
breakdown by solution length.
The breakdown is not decoration. A single aggregate over this holdout is
saturated by construction: the set is ~15% near-solved states and ~85%
fully-mixed ones, so a model whose reach stops at 8 moves cannot score above
~15% however well it trains. Watching only the aggregate showed a flat 11-12%
for 13,000 steps while the model was in fact going from 52% to 72% on
seven-move solves -- the progress was real and entirely invisible.
Bucketed by label length rather than by the recorded scramble depth, since
depth stops tracking difficulty past ~15 moves (every deep scramble is ~20
moves from solved) and length is what actually governs whether the model can
do it.
"""
model.eval()
solved = 0
buckets = defaultdict(lambda: [0, 0])
for start in range(0, len(rows), batch_size):
chunk = rows[start : start + batch_size]
prompts = torch.tensor([T.encode_state(s) for s, _ in chunk], device=device)
out = prompts
finished = torch.zeros(len(chunk), dtype=torch.bool, device=device)
for _ in range(max_new):
logits = model(input_ids=out).logits[:, -1, :]
nxt = logits.argmax(-1)
nxt[finished] = T.PAD
finished |= nxt == T.EOS
out = torch.cat([out, nxt[:, None]], dim=1)
if finished.all():
break
for row, (state, label) in zip(out[:, prompts.shape[1] :].tolist(), chunk):
moves = T.decode_solution(row)
ok = bool(moves) and apply_sequence(state, moves) == SOLVED
solved += ok
key = "1-8" if len(label) <= 8 else ("9-14" if len(label) <= 14 else "15+")
buckets[key][0] += ok
buckets[key][1] += 1
model.train()
parts = " ".join(f"{k}:{buckets[k][0]}/{buckets[k][1]}"
for k in ("1-8", "9-14", "15+") if buckets[k][1])
return solved / max(len(rows), 1), parts
def build_model(args):
from transformers import LlamaConfig, LlamaForCausalLM
cfg = LlamaConfig(
vocab_size=T.VOCAB_SIZE,
hidden_size=args.hidden,
intermediate_size=args.hidden * 4,
num_hidden_layers=args.layers,
num_attention_heads=args.heads,
num_key_value_heads=args.heads,
max_position_embeddings=T.MAX_SEQ,
rms_norm_eps=1e-5,
pad_token_id=T.PAD,
bos_token_id=T.BOS,
eos_token_id=T.EOS,
tie_word_embeddings=True,
)
return LlamaForCausalLM(cfg)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--data", required=True)
p.add_argument("--val-data", default="", help="Held-out set; defaults to a slice of --data.")
p.add_argument("--limit", type=int, default=0)
p.add_argument("--hidden", type=int, default=256)
p.add_argument("--layers", type=int, default=6)
p.add_argument("--heads", type=int, default=8)
p.add_argument("--batch-size", type=int, default=256)
p.add_argument("--lr", type=float, default=3e-4)
p.add_argument("--epochs", type=int, default=1)
p.add_argument("--max-steps", type=int, default=0)
p.add_argument("--warmup", type=int, default=200)
p.add_argument("--eval-every", type=int, default=500)
p.add_argument("--eval-n", type=int, default=256)
p.add_argument("--out", default="checkpoints/cube")
p.add_argument("--hub-repo", default="", help="Push checkpoints here (e.g. user/tiny-cube).")
p.add_argument("--seed", type=int, default=0)
p.add_argument("--resume", default="",
help="Checkpoint to continue from (local dir or Hub repo id).")
args = p.parse_args()
torch.manual_seed(args.seed)
random.seed(args.seed)
device = "cuda" if torch.cuda.is_available() else "cpu"
rows = load_jsonl(args.data, args.limit)
if args.val_data:
val = load_jsonl(args.val_data, args.eval_n)
else:
split = max(len(rows) - args.eval_n, 1)
rows, val = rows[:split], rows[split:]
print(f"train {len(rows)} | val {len(val)} | device {device}", flush=True)
# Resuming matters on a preemptible box: a reclaimed instance otherwise
# restarts a multi-hour run from random init. Weights come back from the Hub,
# which is why checkpoints are pushed there rather than kept only on disk.
# Optimizer state is not restored -- only the weights -- so the LR schedule
# restarts; that costs a little progress but keeps the checkpoint portable.
if args.resume:
from transformers import LlamaForCausalLM
model = LlamaForCausalLM.from_pretrained(args.resume).to(device)
print(f"resumed from {args.resume}", flush=True)
else:
model = build_model(args).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"params {n_params/1e6:.1f}M", flush=True)
loader = DataLoader(CubeDataset(rows), batch_size=args.batch_size, shuffle=True,
collate_fn=collate, drop_last=True, num_workers=2)
steps = args.max_steps or len(loader) * args.epochs
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
sched = torch.optim.lr_scheduler.LambdaLR(
opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
* 0.5 * (1 + math.cos(math.pi * min(s / max(steps, 1), 1.0))))
# bf16 needs Ampere or newer. Falling back to fp16 rather than assuming, so a
# cheaper pre-Ampere box (T4, V100, P100) trains correctly instead of silently
# producing garbage or refusing to start.
use_amp = device == "cuda"
amp_dtype = torch.bfloat16
if use_amp and not torch.cuda.is_bf16_supported():
amp_dtype = torch.float16
print("bf16 unsupported on this GPU; using fp16", flush=True)
scaler = torch.amp.GradScaler("cuda", enabled=use_amp and amp_dtype is torch.float16)
Path(args.out).mkdir(parents=True, exist_ok=True)
step, t0, best = 0, time.time(), -1.0
done = False
while not done:
for ids, labels, mask in loader:
ids, labels, mask = ids.to(device), labels.to(device), mask.to(device)
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
loss = model(input_ids=ids, attention_mask=mask, labels=labels).loss
scaler.scale(loss).backward()
scaler.unscale_(opt)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()
sched.step()
opt.zero_grad(set_to_none=True)
step += 1
if step % 50 == 0:
print(f"step {step}/{steps} loss {loss.item():.4f} "
f"lr {sched.get_last_lr()[0]:.2e} {step/(time.time()-t0):.1f} it/s", flush=True)
if step % args.eval_every == 0 or step == steps:
rate, breakdown = solve_rate(model, val, device)
print(f" step {step} SOLVE RATE {rate:.1%} ({len(val)} held out) "
f"by solution length: {breakdown}", flush=True)
# Always keep the latest weights, and additionally keep the best.
# Saving only on improvement silently threw away 22,000 steps on the
# first real run: the holdout metric is saturated (see solve_rate),
# so it peaked mid-run and every later checkpoint -- including the
# final one -- was discarded. A metric that cannot distinguish two
# models must not be the thing that chooses between them.
model.save_pretrained(args.out)
if rate >= best:
best = rate
model.save_pretrained(f"{args.out}-best")
if args.hub_repo:
try:
model.push_to_hub(args.hub_repo, commit_message=f"step {step} solve {rate:.3f}")
except Exception as e:
print(f" hub push failed (continuing): {e}", flush=True)
if step >= steps:
done = True
break
print(f"done. best solve rate {best:.1%}. final weights in {args.out}, "
f"best-scoring in {args.out}-best", flush=True)
if __name__ == "__main__":
main()