| """ |
| 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) |
|
|
| |
| |
| |
| |
| |
| 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)))) |
|
|
| |
| |
| |
| 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) |
| |
| |
| |
| |
| |
| |
| 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() |
|
|