| """ |
| Trains a value function by approximate value iteration -- no solver in the loop. |
| |
| ## Why this replaces the supervised version |
| |
| `train_value.py` learns distance-to-solved from Kociemba's solution lengths. |
| That reached MAE 1.333 and, with beam search, solved canonical depth 10 at 100% |
| -- but depth 15/20 stayed at 28%/16%, and the reason turned out to be the |
| labels, not the model: |
| |
| Kociemba length on uniform random states: 47% are exactly 21, 79% in 20-22. |
| |
| They are two-phase *upper bounds*, and they cluster. Above distance ~15 there is |
| almost no label variance to learn from, so nothing can learn to tell a |
| distance-19 state from a distance-21 one. Measured directly: in the 15+ band |
| (85.5% of that holdout) the supervised model scored MAE 1.44 against 0.57 for a |
| constant that always answers 21 -- 2.5x worse than ignoring the cube. Search |
| worked where the heuristic was informative and wandered where it was not. |
| |
| More capacity, steps or data cannot fix a label that carries no information. |
| |
| ## The method (DeepCubeA's DAVI, Agostinelli et al., Nature MI 2019) |
| |
| Bootstrap the targets instead of labelling them. For a state s, its cost-to-go |
| is one move plus the best of its successors: |
| |
| J(s) = 0 if s is solved |
| J(s) = min over a of (1 + J_target(s')) otherwise |
| |
| Start from a randomly initialised net and iterate. Accuracy radiates outward |
| from the solved state: once distance-1 states are right, distance-2 states |
| become learnable, and so on. The targets are real distances the net discovers, |
| not a solver's upper bounds, so they stay informative at every distance -- which |
| is precisely what the supervised labels stopped being past 15. |
| |
| Two details that make it converge rather than chase itself: |
| |
| - **A target network.** Targets come from a frozen copy, refreshed only when the |
| training loss falls below `--update-threshold`. Bootstrapping against the |
| live net is the standard way this diverges. |
| - **Regression, not classification.** `train_value.py` argues for |
| classification because a squared-error model collapses toward the 18-21 mode |
| of the *Kociemba label* distribution. That argument does not transfer: these |
| targets are bootstrapped and spread across the whole range, and DAVI's `min` |
| needs a scalar it can compare and add 1 to. |
| |
| ## What to watch |
| |
| Not the loss -- it is measured against a moving target, so it says little about |
| quality. **Watch the greedy solve rate**, reported per canonical depth bucket. |
| That is the heuristic doing the actual job: descend to the lowest-valued child |
| each step and see whether it reaches solved. It needs no labels, which is the |
| point, since out here there is no ground-truth distance to compare against. |
| """ |
| import argparse, json, sys, time |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import torch.nn.functional as F |
|
|
| sys.path.insert(0, str(Path(__file__).parent)) |
| import cube_ops as C |
| import cube_tokenizer as T |
|
|
|
|
| class ValueNet(torch.nn.Module): |
| """Llama encoder over the 54-facelet state, mean-pooled, to one scalar.""" |
|
|
| def __init__(self, hidden, layers, heads): |
| super().__init__() |
| from transformers import LlamaConfig, LlamaModel |
| cfg = LlamaConfig( |
| vocab_size=T.VOCAB_SIZE, hidden_size=hidden, intermediate_size=hidden * 4, |
| num_hidden_layers=layers, num_attention_heads=heads, |
| num_key_value_heads=heads, max_position_embeddings=T.MAX_SEQ, |
| pad_token_id=T.PAD, |
| ) |
| self.encoder = LlamaModel(cfg) |
| self.head = torch.nn.Linear(hidden, 1) |
|
|
| def forward(self, ids): |
| h = self.encoder(input_ids=ids).last_hidden_state.mean(dim=1) |
| return self.head(h).squeeze(-1) |
|
|
|
|
| |
| |
| _COLOR_IDS = np.array(T.COLOR_IDS, dtype=np.int64) |
|
|
|
|
| |
| |
| def encode_batch(states: np.ndarray, device): |
| """(N, 54) uint8 facelet indices -> (N, MAX) token ids on `device`.""" |
| toks = _COLOR_IDS[states.astype(np.int64)] |
| bos = np.full((len(states), 1), T.BOS, dtype=np.int64) |
| sep = np.full((len(states), 1), T.SEP, dtype=np.int64) |
| return torch.from_numpy(np.concatenate([bos, toks, sep], axis=1)).to(device) |
|
|
|
|
| @torch.no_grad() |
| def value_of(net, states: np.ndarray, device, batch_size=4096, amp=True): |
| """Scalar value per state, in eval mode, batched.""" |
| net.eval() |
| out = np.empty(len(states), dtype=np.float32) |
| for i in range(0, len(states), batch_size): |
| chunk = states[i:i + batch_size] |
| ids = encode_batch(chunk, device) |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=amp and device == "cuda"): |
| v = net(ids) |
| out[i:i + len(chunk)] = v.float().cpu().numpy() |
| net.train() |
| |
| |
| out[C.is_solved(states)] = 0.0 |
| return np.maximum(out, 0.0) |
|
|
|
|
| def davi_targets(target_net, states: np.ndarray, device, amp=True): |
| """J(s) = min over the 18 moves of (1 + J_target(s')), and 0 where s is solved.""" |
| kids = C.all_children(states) |
| flat = kids.reshape(-1, 54) |
| v = value_of(target_net, flat, device, amp=amp).reshape(len(states), C.NUM_MOVES) |
| |
| |
| solved_kid = C.is_solved(flat).reshape(len(states), C.NUM_MOVES) |
| v = np.where(solved_kid, 0.0, v) |
| tgt = (1.0 + v).min(axis=1) |
| tgt[C.is_solved(states)] = 0.0 |
| return tgt.astype(np.float32) |
|
|
|
|
| @torch.no_grad() |
| def greedy_solve_rate(net, device, depths, n_per_depth, rng, max_steps=40, amp=True): |
| """Fraction of scrambles solved by descending to the lowest-valued child. |
| |
| The honest metric for this trainer: no labels involved, and it is the |
| heuristic doing the job search will ask of it. Every scramble at every depth |
| is stepped in one batch, so this costs a handful of forward passes rather |
| than one per puzzle. |
| """ |
| out = {} |
| for d in depths: |
| states = _walk_exact(n_per_depth, d, rng) |
| done = C.is_solved(states) |
| for _ in range(max_steps): |
| live = np.where(~done)[0] |
| if not len(live): |
| break |
| kids = C.all_children(states[live]) |
| flat = kids.reshape(-1, 54) |
| v = value_of(net, flat, device, amp=amp).reshape(len(live), C.NUM_MOVES) |
| best = v.argmin(axis=1) |
| states[live] = kids[np.arange(len(live)), best] |
| done[live] = C.is_solved(states[live]) |
| out[d] = float(done.mean()) |
| return out |
|
|
|
|
| def _walk_exact(n, depth, rng): |
| """n states each from a walk of exactly `depth` moves.""" |
| states = np.repeat(C.SOLVED_ARR[None, :], n, axis=0) |
| prev = np.full(n, -1) |
| for _ in range(depth): |
| mv = rng.integers(0, C.NUM_MOVES, size=n) |
| clash = (mv // 3) == prev |
| while clash.any(): |
| mv[clash] = rng.integers(0, C.NUM_MOVES, size=int(clash.sum())) |
| clash = (mv // 3) == prev |
| states = states[np.arange(n)[:, None], C.PERMS[mv]] |
| prev = mv // 3 |
| return states |
|
|
|
|
| def main(): |
| p = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| p.add_argument("--hidden", type=int, default=512) |
| p.add_argument("--layers", type=int, default=8) |
| p.add_argument("--heads", type=int, default=8) |
| p.add_argument("--batch-size", type=int, default=1024) |
| p.add_argument("--lr", type=float, default=3e-4) |
| p.add_argument("--max-steps", type=int, default=100000) |
| p.add_argument("--scramble-depth", type=int, default=30, |
| help="States are sampled from walks of 1..this many moves. Past " |
| "~20 every state is fully mixed (God's Number), so a larger " |
| "value buys nothing.") |
| p.add_argument("--update-threshold", type=float, default=0.15, metavar="MOVES", |
| help="Refresh the target network once the net fits its current " |
| "target to within this mean absolute error, IN MOVES. " |
| "Deliberately not an MSE threshold: target values grow from " |
| "~1 early to ~20 at full distance and MSE scales with their " |
| "square, so the achievable loss floor rises through the run " |
| "and no fixed MSE value works at both ends (measured: 0.0006 " |
| "at step 400, 0.02-0.03 by step 1500). MAE in moves is " |
| "interpretable and roughly scale-stable.") |
| p.add_argument("--min-update-steps", type=int, default=100, |
| help="Floor on the refresh interval, so one lucky batch cannot " |
| "ratchet the target forward.") |
| p.add_argument("--max-update-steps", type=int, default=400, |
| help="Backstop: refresh after this many steps even if the " |
| "threshold is unmet. Each refresh propagates accuracy about " |
| "one move further from solved, so a threshold that stops " |
| "being reachable stalls the frontier outright while training " |
| "still looks healthy -- observed at 1300 steps with no " |
| "refresh. This bounds the run at ~max*30 steps.") |
| p.add_argument("--eval-every", type=int, default=2000) |
| p.add_argument("--eval-n", type=int, default=200) |
| p.add_argument("--eval-depths", default="3,6,10,15,20") |
| p.add_argument("--out", default="checkpoints/value_iter") |
| p.add_argument("--hub-repo", default="") |
| p.add_argument("--upload-every", type=int, default=1, |
| help="Push to the Hub every N evals that improve on the best " |
| "mean greedy solve rate. The point is that a run can be " |
| "stopped the moment its frontier plateaus without losing " |
| "the model -- an end-of-run-only upload makes stopping " |
| "early cost the whole run.") |
| p.add_argument("--seed", type=int, default=0) |
| args = p.parse_args() |
|
|
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| amp = device == "cuda" |
| torch.manual_seed(args.seed) |
| rng = np.random.default_rng(args.seed) |
|
|
| net = ValueNet(args.hidden, args.layers, args.heads).to(device) |
| target_net = ValueNet(args.hidden, args.layers, args.heads).to(device) |
| target_net.load_state_dict(net.state_dict()) |
| for q in target_net.parameters(): |
| q.requires_grad_(False) |
|
|
| params = sum(q.numel() for q in net.parameters()) |
| print(f"value-iteration net: {params/1e6:.1f}M params | {device} | " |
| f"batch {args.batch_size} | walk depth 1..{args.scramble_depth}", flush=True) |
|
|
| opt = torch.optim.AdamW(net.parameters(), lr=args.lr) |
| eval_depths = [int(d) for d in args.eval_depths.split(",")] |
| out_dir = Path(args.out) |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| api = None |
| if args.hub_repo: |
| import os |
| from huggingface_hub import HfApi |
| api = HfApi(token=os.environ.get("HF_TOKEN")) |
|
|
| def publish(tag): |
| if api is None: |
| return |
| try: |
| api.upload_folder(repo_id=args.hub_repo, folder_path=str(out_dir), |
| path_in_repo="checkpoints/value_iter") |
| print(f" uploaded ({tag})", flush=True) |
| except Exception as e: |
| |
| |
| print(f" upload failed ({tag}): {type(e).__name__}: {e}", flush=True) |
|
|
| best = -1.0 |
| improved = 0 |
| updates = 0 |
| last_update = 0 |
| run = [] |
| t0 = time.time() |
| for step in range(1, args.max_steps + 1): |
| states, _ = C.random_walk(args.batch_size, args.scramble_depth, rng) |
| tgt = davi_targets(target_net, states, device, amp=amp) |
|
|
| ids = encode_batch(states, device) |
| y = torch.from_numpy(tgt).to(device) |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=amp): |
| pred = net(ids) |
| loss = F.mse_loss(pred.float(), y) |
| opt.zero_grad(set_to_none=True) |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0) |
| opt.step() |
|
|
| l = loss.item() |
| with torch.no_grad(): |
| mae = (pred.float() - y).abs().mean().item() |
| since = step - last_update |
| ready = mae < args.update_threshold and since >= args.min_update_steps |
| forced = since >= args.max_update_steps |
| if ready or forced: |
| target_net.load_state_dict(net.state_dict()) |
| updates += 1 |
| last_update = step |
| print(f" step {step}: target net updated (#{updates}, " |
| f"mae {mae:.3f} moves{', forced' if forced and not ready else ''})", |
| flush=True) |
|
|
| if step % 100 == 0: |
| print(f"step {step}/{args.max_steps} loss {l:.4f} mae {mae:.3f} " |
| f"tgt_updates {updates} {step/(time.time()-t0):.1f} it/s", flush=True) |
|
|
| if step % args.eval_every == 0 or step == args.max_steps: |
| rates = greedy_solve_rate(net, device, eval_depths, args.eval_n, |
| np.random.default_rng(12345), amp=amp) |
| parts = " ".join(f"d{d}:{r:.0%}" for d, r in rates.items()) |
| mean = float(np.mean(list(rates.values()))) |
| print(f" step {step} greedy solve {parts} | mean {mean:.1%}", flush=True) |
| run.append({"step": step, "loss": l, "target_updates": updates, |
| "greedy": rates, "mean": mean}) |
| torch.save({"model": net.state_dict(), "hidden": args.hidden, |
| "layers": args.layers, "heads": args.heads, "step": step}, |
| out_dir / "value_iter.pt") |
| (out_dir / "history.json").write_text(json.dumps(run, indent=2)) |
| if mean > best: |
| best = mean |
| improved += 1 |
| torch.save({"model": net.state_dict(), "hidden": args.hidden, |
| "layers": args.layers, "heads": args.heads, "step": step}, |
| out_dir / "value_iter_best.pt") |
| if improved % args.upload_every == 0: |
| publish(f"step {step}, mean {mean:.1%}") |
|
|
| print(f"done. best mean greedy solve rate {best:.1%}. weights in {out_dir}", |
| flush=True) |
|
|
| publish("final") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|