File size: 5,480 Bytes
9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 95c986f 9eb2e79 d32fcdc 9eb2e79 d32fcdc 9eb2e79 d32fcdc 9eb2e79 d32fcdc 9eb2e79 | 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 | """
Search over cube states guided by a learned distance estimate.
The policy beam search in serve.py explores sequences the *policy* ranks highly.
This explores states the *value function* thinks are closest to solved, which is
a different and stronger thing: it can prefer a move the policy never considered,
because the ranking comes from an independent estimate rather than from the
policy's own confidence.
This is DeepCubeA's shape in miniature -- batch-weighted search with the engine
as the transition function and a network supplying the heuristic. The weighting
`h + lambda * g` trades solution length against search effort: lambda=0 is pure
greedy on the heuristic (fast, longer solutions), higher lambda behaves more like
uniform-cost search.
Two kinds of value model can drive this, and `load_value_model` tells them apart
by what the checkpoint carries:
- **supervised** (`train_value.py`): a distribution over distances, trained on
Kociemba lengths. Those are upper bounds rather than true optimal distances, so
the heuristic is inadmissible and no shortest-solution guarantee holds.
- **value iteration** (`train_value_iteration.py`): a scalar, bootstrapped from
the solved state with no solver involved.
Either way the engine verifies that whatever comes back actually solves, and
finding *a* solution at depth 20 is the open problem, not finding the shortest.
"""
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).parent))
import cube_tokenizer as T
from gen_data import SOLVED, MOVES, apply_move
def load_value_model(path, device):
"""Loads either value-model flavour, detected from the checkpoint's own keys.
The two trainers save under different keys ("state_dict" vs "model"), which is
the only reliable discriminator: both carry identical hidden/layers/heads, so
shape alone cannot distinguish a 27-way distribution head from a scalar one
without guessing. The flavour is recorded on the model so `heuristic` reads
each correctly -- feeding a scalar head through softmax would silently return
a constant and turn the search into an untargeted walk.
"""
ckpt = torch.load(path, map_location=device)
if "state_dict" in ckpt:
from train_value import ValueNet
model = ValueNet(ckpt["hidden"], ckpt["layers"], ckpt["heads"]).to(device)
model.load_state_dict(ckpt["state_dict"])
kind = "distribution"
elif "model" in ckpt:
from train_value_iteration import ValueNet as ValueNetScalar
model = ValueNetScalar(ckpt["hidden"], ckpt["layers"], ckpt["heads"]).to(device)
model.load_state_dict(ckpt["model"])
kind = "scalar"
else:
raise ValueError(f"{path}: no 'state_dict' or 'model' key; not a value "
f"checkpoint (keys: {sorted(ckpt)[:8]})")
model.eval()
model.value_kind = kind
return model
@torch.no_grad()
def heuristic(model, states, device, batch_size=1024):
"""Estimated distance-to-solved for each state.
For a distribution head, the expectation over the predicted distribution
rather than the argmax: the extra resolution matters when ranking siblings
that all round to the same integer, which is most of the frontier. A scalar
head already has that resolution and is read directly.
"""
kind = getattr(model, "value_kind", "distribution")
out = []
for i in range(0, len(states), batch_size):
chunk = states[i:i + batch_size]
ids = torch.tensor([T.encode_state(s) for s in chunk], device=device)
raw = model(ids).float()
if kind == "scalar":
out.extend(raw.clamp(min=0.0).tolist())
else:
probs = torch.softmax(raw, dim=-1)
values = torch.arange(probs.shape[-1], device=device, dtype=torch.float32)
out.extend((probs * values).sum(-1).tolist())
return out
def solve_value_beam(model, state, device, beam=64, max_depth=26, lam=0.0,
batch_size=1024, stats=None):
"""Beam search over states, ranked by `h + lam * g`.
Returns the move list that solves, or [] if the beam is exhausted. Visited
states are tracked globally: revisiting one cannot help, and on a group this
symmetric the frontier collapses onto duplicates quickly without it.
`stats`, when given a dict, receives `scored` (states the network evaluated)
and `steps` (levels expanded) -- the cost of the solve, which a replay page
can show beside the time.
"""
if stats is not None:
stats["scored"], stats["steps"] = 0, 0
if state == SOLVED:
return []
frontier = [(state, [])]
seen = {state}
for _ in range(max_depth):
children, paths = [], []
for s, path in frontier:
for mv in MOVES:
nxt = apply_move(s, mv)
if nxt in seen:
continue
if nxt == SOLVED:
return path + [mv]
seen.add(nxt)
children.append(nxt)
paths.append(path + [mv])
if not children:
return []
h = heuristic(model, children, device, batch_size)
if stats is not None:
stats["scored"] += len(children)
stats["steps"] += 1
scored = sorted(zip(h, children, paths), key=lambda x: x[0] + lam * len(x[2]))
frontier = [(c, p) for _, c, p in scored[:beam]]
return []
|