""" 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) # Colour-index -> token-id lookup, taken from the tokenizer so the array path and # T.encode_state produce byte-identical sequences (verified in tests). _COLOR_IDS = np.array(T.COLOR_IDS, dtype=np.int64) # The tokenizer's encode_state wraps a state in BOS/SEP for the seq2seq policy. # Reused unchanged so a state has one encoding across every model here. 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() # A solved state is worth 0 by definition, never by prediction: the search # and the targets both lean on that being exact. 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) # (N, 18, 54) flat = kids.reshape(-1, 54) v = value_of(target_net, flat, device, amp=amp).reshape(len(states), C.NUM_MOVES) # A move onto the solved state costs exactly 1 and nothing more, regardless # of what the net thinks of the solved state. 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: # A transient Hub failure must not kill a run that is otherwise fine; # the next improvement retries, and the local copy is still on disk. 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()