code: policy distillation trainer, tests and bootstrap mode
Browse files- code/test_policy_distill.py +159 -0
- code/train_policy_distill.py +644 -0
- code/vast_bootstrap.sh +88 -1
code/test_policy_distill.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smoke tests for train_policy_distill.py, runnable on CPU with no checkpoint.
|
| 3 |
+
|
| 4 |
+
The point is not coverage. It is that a rented GPU box must not be the place
|
| 5 |
+
where a shape error or a misaligned target index shows up: that costs metered
|
| 6 |
+
time, and a *silently* misaligned target costs a whole run that looks healthy.
|
| 7 |
+
So the alignment between the language-model sequence and the rollout is checked
|
| 8 |
+
against the string engine in gen_data, which is a completely separate
|
| 9 |
+
implementation from the numpy one in cube_ops.
|
| 10 |
+
|
| 11 |
+
python test_policy_distill.py
|
| 12 |
+
"""
|
| 13 |
+
import sys, tempfile
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 20 |
+
import cube_ops as C
|
| 21 |
+
import cube_tokenizer as T
|
| 22 |
+
import train_policy_distill as D
|
| 23 |
+
from gen_data import SOLVED, apply_sequence
|
| 24 |
+
from train_value_iteration import ValueNet
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def make_teacher(tmp):
|
| 28 |
+
"""A randomly initialised value net, saved in the value-iteration format."""
|
| 29 |
+
net = ValueNet(32, 2, 2)
|
| 30 |
+
path = Path(tmp) / "teacher.pt"
|
| 31 |
+
torch.save({"model": net.state_dict(), "hidden": 32, "layers": 2, "heads": 2,
|
| 32 |
+
"step": 0}, path)
|
| 33 |
+
return D.load_teacher(str(path), "cpu")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_token_tables():
|
| 37 |
+
assert len(D.ANSWER_IDS) == 19
|
| 38 |
+
assert D.MOVE_IDS == [T.STOI[m] for m in T.MOVES]
|
| 39 |
+
assert D.ANSWER_IDS[D.EOS_SLOT] == T.EOS
|
| 40 |
+
# The move a target index names must be the move cube_ops applies for it.
|
| 41 |
+
s = C.encode(SOLVED)
|
| 42 |
+
for i, mv in enumerate(T.MOVES):
|
| 43 |
+
arr = C.decode(C.apply_moves(s, [i]))[0]
|
| 44 |
+
assert arr == apply_sequence(SOLVED, [mv]), mv
|
| 45 |
+
print("ok token tables agree with both engines")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_pack_alignment(net):
|
| 49 |
+
"""Every masked position must carry the state its prefix actually reaches."""
|
| 50 |
+
rng = np.random.default_rng(0)
|
| 51 |
+
states0, _ = C.random_walk(6, 12, rng)
|
| 52 |
+
batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.0, rng, amp=False)
|
| 53 |
+
|
| 54 |
+
ids = batch["ids"].numpy()
|
| 55 |
+
faces = batch["faces"].numpy()
|
| 56 |
+
mask = batch["posmask"].numpy()
|
| 57 |
+
order = batch["order"].numpy()
|
| 58 |
+
|
| 59 |
+
for b in range(len(states0)):
|
| 60 |
+
start = C.decode(states0[b:b + 1])[0]
|
| 61 |
+
assert list(ids[b, :T.STATE_LEN + 2]) == T.encode_state(start)
|
| 62 |
+
idx = np.where(mask[b])[0]
|
| 63 |
+
assert list(order[b, idx]) == list(range(len(idx)))
|
| 64 |
+
for t, pos in enumerate(idx):
|
| 65 |
+
# The prefix that reaches this position is the prompt plus t moves.
|
| 66 |
+
prefix = ids[b, D.PROMPT_LEN:D.PROMPT_LEN + t]
|
| 67 |
+
moves = [T.ITOS[int(i)] for i in prefix]
|
| 68 |
+
assert apply_sequence(start, moves) == C.decode(faces[b, pos:pos + 1])[0], (b, t)
|
| 69 |
+
print(f"ok pack alignment on {len(states0)} rollouts, string engine agrees")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_targets(net):
|
| 73 |
+
rng = np.random.default_rng(1)
|
| 74 |
+
states0, _ = C.random_walk(32, 6, rng)
|
| 75 |
+
batch = D.rollout_batch(net, states0, "cpu", 10, 0.35, 0.0, rng, amp=False)
|
| 76 |
+
soft = batch["soft"].numpy()
|
| 77 |
+
mask = batch["posmask"].numpy()
|
| 78 |
+
faces = batch["faces"].numpy()
|
| 79 |
+
|
| 80 |
+
# float32 before summing: the packed target is float16, so a float16
|
| 81 |
+
# accumulation over 19 entries has more error than the assertion allows.
|
| 82 |
+
rows = soft[mask].astype(np.float32)
|
| 83 |
+
assert np.allclose(rows.sum(axis=1), 1.0, atol=1e-3), rows.sum(axis=1)[:5]
|
| 84 |
+
# EOS carries all the mass exactly where the state is solved, and none anywhere else.
|
| 85 |
+
solved = C.is_solved(faces[mask])
|
| 86 |
+
assert np.allclose(rows[solved][:, D.EOS_SLOT], 1.0)
|
| 87 |
+
assert np.allclose(rows[~solved][:, D.EOS_SLOT], 0.0)
|
| 88 |
+
assert (soft[~mask] == 0).all()
|
| 89 |
+
print(f"ok targets normalised; EOS mass matches solved on {mask.sum()} positions")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_hard_targets(net):
|
| 93 |
+
rng = np.random.default_rng(2)
|
| 94 |
+
states0, _ = C.random_walk(8, 6, rng)
|
| 95 |
+
batch = D.rollout_batch(net, states0, "cpu", 6, 0.35, 0.0, rng, hard=True, amp=False)
|
| 96 |
+
rows = batch["soft"].numpy()[batch["posmask"].numpy()]
|
| 97 |
+
assert np.allclose(rows.max(axis=1), 1.0)
|
| 98 |
+
print("ok --hard-targets produces one-hot rows")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_train_step(net):
|
| 102 |
+
class Args:
|
| 103 |
+
hidden, layers, heads = 32, 2, 2
|
| 104 |
+
|
| 105 |
+
model = D.build_policy(Args())
|
| 106 |
+
head = D.StateHead(32)
|
| 107 |
+
rng = np.random.default_rng(3)
|
| 108 |
+
states0, _ = C.random_walk(8, 10, rng)
|
| 109 |
+
batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.2, rng, amp=False)
|
| 110 |
+
loss, mloss, floss, facc = D.losses(model, head, batch, "cpu", 1.0,
|
| 111 |
+
torch.bfloat16, False)
|
| 112 |
+
assert torch.isfinite(loss), loss
|
| 113 |
+
loss.backward()
|
| 114 |
+
grads = [q.grad for q in head.parameters() if q.grad is not None]
|
| 115 |
+
assert grads and any(g.abs().sum() > 0 for g in grads)
|
| 116 |
+
# The facelet probe must reach the trunk, not just its own linear layer.
|
| 117 |
+
trunk = model.model.layers[0].self_attn.q_proj.weight.grad
|
| 118 |
+
assert trunk is not None and trunk.abs().sum() > 0
|
| 119 |
+
print(f"ok one training step: move {mloss.item():.3f} face {floss.item():.3f} "
|
| 120 |
+
f"acc {facc.item():.1%}, gradients reach the trunk")
|
| 121 |
+
return model, head
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_eval(net, model, head):
|
| 125 |
+
rates = D.student_greedy(model, "cpu", [3], 8, np.random.default_rng(4))
|
| 126 |
+
assert set(rates) == {3} and 0.0 <= rates[3] <= 1.0
|
| 127 |
+
ceil = D.teacher_greedy(net, "cpu", [3], 8, np.random.default_rng(5), max_steps=4,
|
| 128 |
+
amp=False)
|
| 129 |
+
assert 0.0 <= ceil[3] <= 1.0
|
| 130 |
+
per_pos, exact = D.state_accuracy(model, head, net, "cpu", [3], 8,
|
| 131 |
+
np.random.default_rng(6), 6, 0.35, False)
|
| 132 |
+
assert "0:" in per_pos and 0.0 <= exact <= 1.0
|
| 133 |
+
print(f"ok eval paths run: student {rates[3]:.0%}, teacher {ceil[3]:.0%}, "
|
| 134 |
+
f"state {per_pos}")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_solved_start(net):
|
| 138 |
+
"""A start state that is already solved must produce EOS and nothing else."""
|
| 139 |
+
states0 = C.SOLVED_ARR[None, :].copy()
|
| 140 |
+
batch = D.rollout_batch(net, states0, "cpu", 8, 0.35, 0.0,
|
| 141 |
+
np.random.default_rng(7), amp=False)
|
| 142 |
+
mask = batch["posmask"].numpy()[0]
|
| 143 |
+
assert mask.sum() == 1
|
| 144 |
+
assert batch["soft"].numpy()[0, mask][0, D.EOS_SLOT] == 1.0
|
| 145 |
+
print("ok a solved start state yields exactly one EOS position")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
torch.manual_seed(0)
|
| 150 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 151 |
+
teacher = make_teacher(tmp)
|
| 152 |
+
test_token_tables()
|
| 153 |
+
test_pack_alignment(teacher)
|
| 154 |
+
test_targets(teacher)
|
| 155 |
+
test_hard_targets(teacher)
|
| 156 |
+
test_solved_start(teacher)
|
| 157 |
+
model, head = test_train_step(teacher)
|
| 158 |
+
test_eval(teacher, model, head)
|
| 159 |
+
print("\nall smoke tests passed")
|
code/train_policy_distill.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trains the one-shot policy against the value model, with explicit state tracking.
|
| 3 |
+
|
| 4 |
+
The policy model (`train.py`) hits a hard reach wall at ~8 moves: 69% at depth 6,
|
| 5 |
+
1.5% at depth 10, zero beyond. Four interventions moved it none of the way (see
|
| 6 |
+
`CLAUDE.md`). Two suspects survive, and this trainer attacks both at once.
|
| 7 |
+
|
| 8 |
+
## Suspect 1 -- the labels punish correct moves
|
| 9 |
+
|
| 10 |
+
`train.py` trains on one Kociemba solution per state with hard cross-entropy. A
|
| 11 |
+
cube state usually has several optimal first moves, and Kociemba emits one path.
|
| 12 |
+
Every other optimal move is scored as an error, so the loss actively pushes down
|
| 13 |
+
moves that are perfectly good.
|
| 14 |
+
|
| 15 |
+
The fix here replaces the single hard label with a soft distribution supplied by
|
| 16 |
+
the value model. For a state s, score all 18 children with the teacher, take
|
| 17 |
+
`q(a) = 1 + J(s')`, and train against `softmax(-q / tau)`. Two optimal moves get
|
| 18 |
+
equal mass instead of one getting all of it. No search is needed to build this
|
| 19 |
+
target: it is 18 forward passes of a 33.6M net, the same expansion DAVI already
|
| 20 |
+
does every step in `train_value_iteration.py`.
|
| 21 |
+
|
| 22 |
+
## Suspect 2 -- the decoder loses the cube
|
| 23 |
+
|
| 24 |
+
A one-shot policy must carry the cube in its activations. It emits move 1, then
|
| 25 |
+
must predict move 2 from a state it was never shown. Nothing in `train.py` ever
|
| 26 |
+
checks that the internal state is right, so a model can learn plausible-looking
|
| 27 |
+
move preferences while its internal cube drifts.
|
| 28 |
+
|
| 29 |
+
The fix is an auxiliary head. At every output position the decoder must also
|
| 30 |
+
predict the 54 facelets of the state it is currently in. The engine knows that
|
| 31 |
+
state exactly, so the supervision costs nothing. `state accuracy by position` in
|
| 32 |
+
the eval output is the direct read: if it falls away with position, the decoder
|
| 33 |
+
is losing the cube, and that is the wall.
|
| 34 |
+
|
| 35 |
+
## What the numbers mean
|
| 36 |
+
|
| 37 |
+
The eval prints the student's greedy solve rate and the teacher's side by side.
|
| 38 |
+
This is the point of the experiment, not decoration:
|
| 39 |
+
|
| 40 |
+
- **teacher greedy** is the ceiling. The student is trained to imitate the
|
| 41 |
+
teacher's per-state preference, and the teacher descends with the real state
|
| 42 |
+
supplied by the engine at every step. A perfect student matches it and cannot
|
| 43 |
+
beat it. Measured on the published value-iteration weights that is ~34% at
|
| 44 |
+
depth 10 -- far below the 100% that beam 1024 reaches, because a beam is a
|
| 45 |
+
search and this is not.
|
| 46 |
+
- **student greedy** is the capability, since a one-shot policy is what the LLMs
|
| 47 |
+
on the board are scored on.
|
| 48 |
+
- **the gap between them** is the cost of internal state tracking, because the
|
| 49 |
+
two differ in nothing else.
|
| 50 |
+
|
| 51 |
+
So a student that lands near the teacher says the labels were the problem and
|
| 52 |
+
state tracking is fine. A student that stays far below it says the opposite. The
|
| 53 |
+
old run gives neither reading, which is why it stalled.
|
| 54 |
+
|
| 55 |
+
Three arms come out of one script:
|
| 56 |
+
|
| 57 |
+
--state-weight 0 soft targets only
|
| 58 |
+
--state-weight 1 soft targets + state tracking (the full proposal)
|
| 59 |
+
--hard-targets teacher argmin as a one-hot label, for the ablation
|
| 60 |
+
that separates "soft vs hard" from "value teacher vs
|
| 61 |
+
Kociemba"
|
| 62 |
+
|
| 63 |
+
The saved checkpoint is a plain `LlamaForCausalLM`, so `serve.py`,
|
| 64 |
+
`eval_canonical.py` and `run_benchmark.sh` all read it unchanged. The auxiliary
|
| 65 |
+
head is a diagnostic and is saved beside it, never needed at inference.
|
| 66 |
+
"""
|
| 67 |
+
import argparse, json, math, os, sys, time
|
| 68 |
+
from pathlib import Path
|
| 69 |
+
|
| 70 |
+
import numpy as np
|
| 71 |
+
import torch
|
| 72 |
+
import torch.nn.functional as F
|
| 73 |
+
|
| 74 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 75 |
+
import cube_ops as C
|
| 76 |
+
import cube_tokenizer as T
|
| 77 |
+
from train_value_iteration import encode_batch
|
| 78 |
+
|
| 79 |
+
# The 19 tokens the policy may emit on the answer span: 18 moves, then EOS.
|
| 80 |
+
# Derived from the tokenizer rather than by arithmetic on ids, because bare-face
|
| 81 |
+
# move tokens share their ids with colour tokens (see cube_tokenizer). Sharing is
|
| 82 |
+
# harmless here -- colours never appear as a target on the answer span -- but the
|
| 83 |
+
# ids are not contiguous, so they have to be looked up.
|
| 84 |
+
MOVE_IDS = [T.STOI[m] for m in T.MOVES]
|
| 85 |
+
ANSWER_IDS = MOVE_IDS + [T.EOS]
|
| 86 |
+
EOS_SLOT = len(MOVE_IDS) # index of EOS inside a 19-wide target vector
|
| 87 |
+
PROMPT_LEN = 1 + T.STATE_LEN + 1 # [BOS] + 54 facelets + [SEP]
|
| 88 |
+
|
| 89 |
+
assert len(set(ANSWER_IDS)) == len(ANSWER_IDS), "answer token ids must be distinct"
|
| 90 |
+
assert T.MOVES == list(C.MOVES), "tokenizer and cube_ops must agree on move order"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# --------------------------------------------------------------------------
|
| 94 |
+
# teacher
|
| 95 |
+
# --------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def load_teacher(path, device):
|
| 98 |
+
"""Loads either value-model flavour through search.load_value_model.
|
| 99 |
+
|
| 100 |
+
That function is the one place that tells a scalar head from a 27-way
|
| 101 |
+
distribution head, and reading a scalar head as a distribution silently
|
| 102 |
+
returns a constant. Do not bypass it (recorded trap in CLAUDE.md).
|
| 103 |
+
"""
|
| 104 |
+
from search import load_value_model
|
| 105 |
+
|
| 106 |
+
net = load_value_model(path, device)
|
| 107 |
+
net.eval()
|
| 108 |
+
for q in net.parameters():
|
| 109 |
+
q.requires_grad_(False)
|
| 110 |
+
return net
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@torch.no_grad()
|
| 114 |
+
def teacher_values(net, states: np.ndarray, device, batch_size=8192, amp=True):
|
| 115 |
+
"""Estimated distance-to-solved for each state in an (N, 54) array."""
|
| 116 |
+
kind = getattr(net, "value_kind", "scalar")
|
| 117 |
+
out = np.empty(len(states), dtype=np.float32)
|
| 118 |
+
for i in range(0, len(states), batch_size):
|
| 119 |
+
chunk = states[i:i + batch_size]
|
| 120 |
+
ids = encode_batch(chunk, device)
|
| 121 |
+
with torch.autocast("cuda", dtype=torch.bfloat16,
|
| 122 |
+
enabled=amp and device == "cuda"):
|
| 123 |
+
raw = net(ids).float()
|
| 124 |
+
if kind == "scalar":
|
| 125 |
+
v = raw.clamp(min=0.0)
|
| 126 |
+
else:
|
| 127 |
+
probs = torch.softmax(raw, dim=-1)
|
| 128 |
+
grid = torch.arange(probs.shape[-1], device=probs.device, dtype=torch.float32)
|
| 129 |
+
v = (probs * grid).sum(-1)
|
| 130 |
+
out[i:i + len(chunk)] = v.cpu().numpy()
|
| 131 |
+
# A solved state is worth 0 by definition, never by prediction.
|
| 132 |
+
out[C.is_solved(states)] = 0.0
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def child_costs(net, states: np.ndarray, device, amp=True):
|
| 137 |
+
"""(N, 54) -> (N, 18) cost `1 + J(child)` for every move, and the children."""
|
| 138 |
+
kids = C.all_children(states)
|
| 139 |
+
flat = kids.reshape(-1, 54)
|
| 140 |
+
v = teacher_values(net, flat, device, amp=amp).reshape(len(states), C.NUM_MOVES)
|
| 141 |
+
# A move onto solved costs exactly 1, whatever the net thinks of that state.
|
| 142 |
+
solved_kid = C.is_solved(flat).reshape(len(states), C.NUM_MOVES)
|
| 143 |
+
return np.where(solved_kid, 1.0, 1.0 + v).astype(np.float32), kids
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# --------------------------------------------------------------------------
|
| 147 |
+
# rollouts -> training batch
|
| 148 |
+
# --------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
def rollout_batch(net, states0: np.ndarray, device, rollout_len, tau,
|
| 151 |
+
explore_frac, rng, hard=False, amp=True):
|
| 152 |
+
"""Rolls the teacher out and returns one padded training batch.
|
| 153 |
+
|
| 154 |
+
Position t of the answer span sits at input index `PROMPT_LEN - 1 + t` and
|
| 155 |
+
predicts the move to make from state s_t. Its target is the teacher's
|
| 156 |
+
preference over the 18 children of s_t -- or all mass on EOS when s_t is
|
| 157 |
+
already solved.
|
| 158 |
+
|
| 159 |
+
Trajectories that never reach solved are kept, not discarded. Dropping them
|
| 160 |
+
would bias the whole dataset toward states the teacher can already descend
|
| 161 |
+
greedily, which are exactly the shallow ones, and would rebuild the reach
|
| 162 |
+
wall out of the training distribution. Every position is correct
|
| 163 |
+
supervision on its own -- "here is the best move from here" -- whether or not
|
| 164 |
+
the trajectory that produced it ever terminated. A truncated trajectory
|
| 165 |
+
simply carries no EOS target, which teaches nothing false.
|
| 166 |
+
"""
|
| 167 |
+
b = len(states0)
|
| 168 |
+
cap = rollout_len + 1 # +1 for the position that emits EOS
|
| 169 |
+
tgt = np.zeros((b, cap, len(ANSWER_IDS)), dtype=np.float32)
|
| 170 |
+
seen = np.zeros((b, cap, T.STATE_LEN), dtype=np.uint8)
|
| 171 |
+
moves = np.full((b, cap), -1, dtype=np.int64)
|
| 172 |
+
npos = np.zeros(b, dtype=np.int64)
|
| 173 |
+
|
| 174 |
+
cur = states0.copy()
|
| 175 |
+
alive = np.ones(b, dtype=bool)
|
| 176 |
+
|
| 177 |
+
for t in range(cap):
|
| 178 |
+
idx = np.where(alive)[0]
|
| 179 |
+
if not len(idx):
|
| 180 |
+
break
|
| 181 |
+
seen[idx, t] = cur[idx]
|
| 182 |
+
npos[idx] = t + 1
|
| 183 |
+
|
| 184 |
+
solved_now = C.is_solved(cur[idx])
|
| 185 |
+
stop = idx[solved_now]
|
| 186 |
+
tgt[stop, t, EOS_SLOT] = 1.0
|
| 187 |
+
alive[stop] = False
|
| 188 |
+
|
| 189 |
+
go = idx[~solved_now]
|
| 190 |
+
if not len(go):
|
| 191 |
+
continue
|
| 192 |
+
if t == cap - 1:
|
| 193 |
+
# No room left to emit a move from here, so this position carries no
|
| 194 |
+
# target and must not be trained on.
|
| 195 |
+
npos[go] = t
|
| 196 |
+
alive[go] = False
|
| 197 |
+
continue
|
| 198 |
+
|
| 199 |
+
cost, kids = child_costs(net, cur[go], device, amp=amp)
|
| 200 |
+
best = cost.argmin(axis=1)
|
| 201 |
+
if hard:
|
| 202 |
+
tgt[go, t, best] = 1.0
|
| 203 |
+
else:
|
| 204 |
+
p = torch.softmax(torch.from_numpy(-cost / tau), dim=1).numpy()
|
| 205 |
+
tgt[go, t, :C.NUM_MOVES] = p
|
| 206 |
+
|
| 207 |
+
chosen = best
|
| 208 |
+
if explore_frac > 0:
|
| 209 |
+
take = rng.random(len(go)) < explore_frac
|
| 210 |
+
if take.any():
|
| 211 |
+
w = torch.softmax(torch.from_numpy(-cost[take] / tau), dim=1).numpy()
|
| 212 |
+
u = rng.random((int(take.sum()), 1))
|
| 213 |
+
chosen = chosen.copy()
|
| 214 |
+
chosen[take] = (w.cumsum(axis=1) < u).sum(axis=1).clip(0, C.NUM_MOVES - 1)
|
| 215 |
+
|
| 216 |
+
moves[go, t] = chosen
|
| 217 |
+
cur[go] = kids[np.arange(len(go)), chosen]
|
| 218 |
+
|
| 219 |
+
return pack(states0, tgt, seen, moves, npos)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def pack(states0, tgt, seen, moves, npos):
|
| 223 |
+
"""Pads the rollouts into tensors aligned to the language-model sequence."""
|
| 224 |
+
b = len(states0)
|
| 225 |
+
keep = npos > 0
|
| 226 |
+
length = int(npos.max())
|
| 227 |
+
seq = PROMPT_LEN + max(length - 1, 0)
|
| 228 |
+
|
| 229 |
+
ids = np.full((b, seq), T.PAD, dtype=np.int64)
|
| 230 |
+
ids[:, 0] = T.BOS
|
| 231 |
+
ids[:, 1:1 + T.STATE_LEN] = np.asarray(T.COLOR_IDS, dtype=np.int64)[states0.astype(np.int64)]
|
| 232 |
+
ids[:, T.STATE_LEN + 1] = T.SEP
|
| 233 |
+
|
| 234 |
+
attn = np.zeros((b, seq), dtype=np.int64)
|
| 235 |
+
posmask = np.zeros((b, seq), dtype=bool)
|
| 236 |
+
# float16 / uint8 rather than float32 / int64: a packed batch is kept in a
|
| 237 |
+
# replay buffer, and the int64 facelets alone would be 9MB a batch.
|
| 238 |
+
soft = np.zeros((b, seq, tgt.shape[2]), dtype=np.float16)
|
| 239 |
+
faces = np.zeros((b, seq, T.STATE_LEN), dtype=np.uint8)
|
| 240 |
+
order = np.zeros((b, seq), dtype=np.int16)
|
| 241 |
+
|
| 242 |
+
for i in range(b):
|
| 243 |
+
n = int(npos[i])
|
| 244 |
+
if n <= 0:
|
| 245 |
+
continue
|
| 246 |
+
# The prefix that reaches position t is the prompt plus moves 1..t, so a
|
| 247 |
+
# trajectory with n scored positions contributes n-1 move tokens.
|
| 248 |
+
mv = moves[i, : n - 1]
|
| 249 |
+
ids[i, PROMPT_LEN:PROMPT_LEN + len(mv)] = np.asarray(MOVE_IDS, dtype=np.int64)[mv]
|
| 250 |
+
attn[i, : PROMPT_LEN + len(mv)] = 1
|
| 251 |
+
lo = PROMPT_LEN - 1
|
| 252 |
+
posmask[i, lo:lo + n] = True
|
| 253 |
+
soft[i, lo:lo + n] = tgt[i, :n]
|
| 254 |
+
faces[i, lo:lo + n] = seen[i, :n]
|
| 255 |
+
order[i, lo:lo + n] = np.arange(n)
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
"ids": torch.from_numpy(ids),
|
| 259 |
+
"attn": torch.from_numpy(attn),
|
| 260 |
+
"posmask": torch.from_numpy(posmask),
|
| 261 |
+
"soft": torch.from_numpy(soft),
|
| 262 |
+
"faces": torch.from_numpy(faces),
|
| 263 |
+
"order": torch.from_numpy(order),
|
| 264 |
+
"solved": torch.from_numpy(keep & (tgt[:, :, EOS_SLOT].sum(axis=1) > 0)),
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# --------------------------------------------------------------------------
|
| 269 |
+
# model
|
| 270 |
+
# --------------------------------------------------------------------------
|
| 271 |
+
|
| 272 |
+
def build_policy(args):
|
| 273 |
+
from transformers import LlamaConfig, LlamaForCausalLM
|
| 274 |
+
|
| 275 |
+
cfg = LlamaConfig(
|
| 276 |
+
vocab_size=T.VOCAB_SIZE,
|
| 277 |
+
hidden_size=args.hidden,
|
| 278 |
+
intermediate_size=args.hidden * 4,
|
| 279 |
+
num_hidden_layers=args.layers,
|
| 280 |
+
num_attention_heads=args.heads,
|
| 281 |
+
num_key_value_heads=args.heads,
|
| 282 |
+
max_position_embeddings=T.MAX_SEQ,
|
| 283 |
+
rms_norm_eps=1e-5,
|
| 284 |
+
pad_token_id=T.PAD,
|
| 285 |
+
bos_token_id=T.BOS,
|
| 286 |
+
eos_token_id=T.EOS,
|
| 287 |
+
tie_word_embeddings=True,
|
| 288 |
+
)
|
| 289 |
+
return LlamaForCausalLM(cfg)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class StateHead(torch.nn.Module):
|
| 293 |
+
"""Predicts the 54 facelets of the state the decoder is currently in.
|
| 294 |
+
|
| 295 |
+
One linear layer on the hidden state, deliberately: a deep head could learn
|
| 296 |
+
to reconstruct the cube itself and let the trunk off the hook, which would
|
| 297 |
+
make the diagnostic meaningless. The question is whether the trunk carries
|
| 298 |
+
the cube, so the probe must be weak.
|
| 299 |
+
"""
|
| 300 |
+
|
| 301 |
+
def __init__(self, hidden):
|
| 302 |
+
super().__init__()
|
| 303 |
+
self.proj = torch.nn.Linear(hidden, T.STATE_LEN * len(C.COLORS))
|
| 304 |
+
|
| 305 |
+
def forward(self, h):
|
| 306 |
+
return self.proj(h).view(*h.shape[:-1], T.STATE_LEN, len(C.COLORS))
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def losses(model, head, batch, device, state_weight, amp_dtype, use_amp):
|
| 310 |
+
ids = batch["ids"].to(device)
|
| 311 |
+
attn = batch["attn"].to(device)
|
| 312 |
+
posmask = batch["posmask"].to(device)
|
| 313 |
+
soft = batch["soft"].to(device).float()
|
| 314 |
+
faces = batch["faces"].to(device).long()
|
| 315 |
+
|
| 316 |
+
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
|
| 317 |
+
hidden = model.model(input_ids=ids, attention_mask=attn).last_hidden_state
|
| 318 |
+
logits = model.lm_head(hidden)
|
| 319 |
+
face_logits = head(hidden)
|
| 320 |
+
|
| 321 |
+
# Cross-entropy over the full vocabulary against a soft target that is zero
|
| 322 |
+
# off the answer tokens. Normalising over the whole vocabulary rather than
|
| 323 |
+
# over the 19 answer tokens keeps the remaining logits pushed down, so greedy
|
| 324 |
+
# decoding cannot wander into a colour token.
|
| 325 |
+
logp = torch.log_softmax(logits.float(), dim=-1)[..., ANSWER_IDS]
|
| 326 |
+
move_loss = -(soft * logp).sum(-1)[posmask].mean()
|
| 327 |
+
|
| 328 |
+
sel = face_logits.float()[posmask]
|
| 329 |
+
face_loss = F.cross_entropy(sel.reshape(-1, len(C.COLORS)), faces[posmask].reshape(-1))
|
| 330 |
+
with torch.no_grad():
|
| 331 |
+
face_acc = (sel.argmax(-1) == faces[posmask]).float().mean()
|
| 332 |
+
return move_loss + state_weight * face_loss, move_loss, face_loss, face_acc
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
# --------------------------------------------------------------------------
|
| 336 |
+
# evaluation
|
| 337 |
+
# --------------------------------------------------------------------------
|
| 338 |
+
|
| 339 |
+
def _walk_exact(n, depth, rng):
|
| 340 |
+
"""n states, each from a walk of exactly `depth` moves off solved."""
|
| 341 |
+
states = np.repeat(C.SOLVED_ARR[None, :], n, axis=0)
|
| 342 |
+
prev = np.full(n, -1)
|
| 343 |
+
for _ in range(depth):
|
| 344 |
+
mv = rng.integers(0, C.NUM_MOVES, size=n)
|
| 345 |
+
clash = (mv // 3) == prev
|
| 346 |
+
while clash.any():
|
| 347 |
+
mv[clash] = rng.integers(0, C.NUM_MOVES, size=int(clash.sum()))
|
| 348 |
+
clash = (mv // 3) == prev
|
| 349 |
+
states = states[np.arange(n)[:, None], C.PERMS[mv]]
|
| 350 |
+
prev = mv // 3
|
| 351 |
+
return states
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@torch.no_grad()
|
| 355 |
+
def teacher_greedy(net, device, depths, n, rng, max_steps=30, amp=True):
|
| 356 |
+
"""The ceiling: descend to the lowest-cost child with the engine supplying
|
| 357 |
+
the state at every step."""
|
| 358 |
+
out = {}
|
| 359 |
+
for d in depths:
|
| 360 |
+
states = _walk_exact(n, d, rng)
|
| 361 |
+
done = C.is_solved(states)
|
| 362 |
+
for _ in range(max_steps):
|
| 363 |
+
live = np.where(~done)[0]
|
| 364 |
+
if not len(live):
|
| 365 |
+
break
|
| 366 |
+
cost, kids = child_costs(net, states[live], device, amp=amp)
|
| 367 |
+
best = cost.argmin(axis=1)
|
| 368 |
+
states[live] = kids[np.arange(len(live)), best]
|
| 369 |
+
done[live] = C.is_solved(states[live])
|
| 370 |
+
out[d] = float(done.mean())
|
| 371 |
+
return out
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@torch.no_grad()
|
| 375 |
+
def student_greedy(model, device, depths, n, rng, max_new=T.MAX_SOLUTION + 1,
|
| 376 |
+
batch_size=256):
|
| 377 |
+
"""The capability: one prompt, one greedy sequence, replayed by the engine.
|
| 378 |
+
|
| 379 |
+
Greedy is the honest number for a policy, unlike for the value model, where
|
| 380 |
+
greedy is a training signal and search is the system. Here the sequence is
|
| 381 |
+
the whole answer, and it is what every LLM on the board is scored on.
|
| 382 |
+
"""
|
| 383 |
+
model.eval()
|
| 384 |
+
out = {}
|
| 385 |
+
for d in depths:
|
| 386 |
+
states = _walk_exact(n, d, rng)
|
| 387 |
+
solved = 0
|
| 388 |
+
for start in range(0, n, batch_size):
|
| 389 |
+
chunk = states[start:start + batch_size]
|
| 390 |
+
cur = chunk.copy()
|
| 391 |
+
ids = encode_batch(chunk, device)
|
| 392 |
+
# Decoding stops at EOS and only at EOS, then the whole emitted
|
| 393 |
+
# sequence is replayed. Stopping early because the cube happens to be
|
| 394 |
+
# solved mid-sequence would score a model the harness would mark
|
| 395 |
+
# wrong: the harness applies every move the model wrote, and moves
|
| 396 |
+
# after a solve unsolve it.
|
| 397 |
+
live = np.ones(len(chunk), dtype=bool)
|
| 398 |
+
for _ in range(max_new):
|
| 399 |
+
logits = model(input_ids=ids).logits[:, -1, :]
|
| 400 |
+
nxt = logits[:, ANSWER_IDS].argmax(-1).cpu().numpy()
|
| 401 |
+
live = live & (nxt != EOS_SLOT)
|
| 402 |
+
step = np.where(live)[0]
|
| 403 |
+
if not len(step):
|
| 404 |
+
break
|
| 405 |
+
cur[step] = C.apply_moves(cur[step], nxt[step])
|
| 406 |
+
tok = torch.full((len(chunk), 1), T.PAD, dtype=torch.long, device=device)
|
| 407 |
+
tok[step, 0] = torch.tensor(
|
| 408 |
+
[MOVE_IDS[m] for m in nxt[step]], dtype=torch.long, device=device)
|
| 409 |
+
ids = torch.cat([ids, tok], dim=1)
|
| 410 |
+
if ids.shape[1] >= T.MAX_SEQ:
|
| 411 |
+
break
|
| 412 |
+
solved += int(C.is_solved(cur).sum())
|
| 413 |
+
out[d] = solved / max(n, 1)
|
| 414 |
+
model.train()
|
| 415 |
+
return out
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@torch.no_grad()
|
| 419 |
+
def state_accuracy(model, head, net, device, depths, n, rng, rollout_len, tau, amp):
|
| 420 |
+
"""Facelet accuracy of the auxiliary probe, bucketed by output position.
|
| 421 |
+
|
| 422 |
+
The direct test of suspect 2. Teacher-forced, so the prefix is a real
|
| 423 |
+
trajectory and the true state at every position is known exactly.
|
| 424 |
+
"""
|
| 425 |
+
model.eval()
|
| 426 |
+
head.eval()
|
| 427 |
+
bins = {"0": [0, 0], "1-4": [0, 0], "5-8": [0, 0], "9-12": [0, 0], "13+": [0, 0]}
|
| 428 |
+
exact = [0, 0]
|
| 429 |
+
for d in depths:
|
| 430 |
+
batch = rollout_batch(net, _walk_exact(n, d, rng), device, rollout_len, tau,
|
| 431 |
+
0.0, rng, amp=amp)
|
| 432 |
+
hidden = model.model(input_ids=batch["ids"].to(device),
|
| 433 |
+
attention_mask=batch["attn"].to(device)).last_hidden_state
|
| 434 |
+
pred = head(hidden.float()).argmax(-1).cpu().to(torch.uint8)
|
| 435 |
+
ok = (pred == batch["faces"])
|
| 436 |
+
mask = batch["posmask"]
|
| 437 |
+
order = batch["order"]
|
| 438 |
+
for key, lo, hi in (("0", 0, 0), ("1-4", 1, 4), ("5-8", 5, 8),
|
| 439 |
+
("9-12", 9, 12), ("13+", 13, 10**6)):
|
| 440 |
+
sel = mask & (order >= lo) & (order <= hi)
|
| 441 |
+
if sel.any():
|
| 442 |
+
bins[key][0] += int(ok[sel].sum())
|
| 443 |
+
bins[key][1] += int(sel.sum()) * T.STATE_LEN
|
| 444 |
+
exact[0] += int(ok.all(-1)[mask].sum())
|
| 445 |
+
exact[1] += int(mask.sum())
|
| 446 |
+
model.train()
|
| 447 |
+
head.train()
|
| 448 |
+
per_pos = " ".join(f"{k}:{v[0]/v[1]:.1%}" for k, v in bins.items() if v[1])
|
| 449 |
+
return per_pos, exact[0] / max(exact[1], 1)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# --------------------------------------------------------------------------
|
| 453 |
+
|
| 454 |
+
def main():
|
| 455 |
+
p = argparse.ArgumentParser(description=__doc__,
|
| 456 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 457 |
+
p.add_argument("--teacher", required=True,
|
| 458 |
+
help="Value-model checkpoint (.pt) from train_value_iteration.py.")
|
| 459 |
+
p.add_argument("--hidden", type=int, default=512)
|
| 460 |
+
p.add_argument("--layers", type=int, default=10)
|
| 461 |
+
p.add_argument("--heads", type=int, default=8)
|
| 462 |
+
p.add_argument("--batch-size", type=int, default=128,
|
| 463 |
+
help="Start states per step. Each contributes up to "
|
| 464 |
+
"--rollout-len scored positions, and costs 18 teacher "
|
| 465 |
+
"forward passes per position.")
|
| 466 |
+
p.add_argument("--rollout-len", type=int, default=16,
|
| 467 |
+
help="Cap on trajectory length. Must stay at or under 25, which "
|
| 468 |
+
"is where the sequence reaches cube_tokenizer.MAX_SEQ.")
|
| 469 |
+
p.add_argument("--scramble-depth", type=int, default=12,
|
| 470 |
+
help="States come from walks of 1..this many moves. Deliberately "
|
| 471 |
+
"NOT the value trainer's 30: the teacher's predicted distance "
|
| 472 |
+
"saturates near 12 moves, so past that every child scores the "
|
| 473 |
+
"same and the soft target is noise with a shape. Sampling "
|
| 474 |
+
"there would train the policy on the teacher's ignorance. "
|
| 475 |
+
"Raise it only with a teacher whose frontier reaches further.")
|
| 476 |
+
p.add_argument("--buffer", type=int, default=32,
|
| 477 |
+
help="Packed rollout batches kept for reuse. The rollouts come "
|
| 478 |
+
"from the teacher, not from the student, so the data "
|
| 479 |
+
"distribution does not move as the student learns and reuse "
|
| 480 |
+
"is plain caching rather than off-policy training.")
|
| 481 |
+
p.add_argument("--updates-per-rollout", type=int, default=4,
|
| 482 |
+
help="Gradient steps taken per batch of rollouts generated, "
|
| 483 |
+
"sampled from the buffer. Building a target costs 18 teacher "
|
| 484 |
+
"forward passes per position and a gradient step costs one, "
|
| 485 |
+
"so without reuse the teacher is ~95%% of the run and the "
|
| 486 |
+
"student sees too few steps to converge.")
|
| 487 |
+
p.add_argument("--tau", type=float, default=0.35, metavar="MOVES",
|
| 488 |
+
help="Softmax temperature on child costs, in moves. Costs "
|
| 489 |
+
"differ by whole moves, so tau=1 spreads ~30%% of the mass "
|
| 490 |
+
"onto the best child and the rest onto plainly worse ones; "
|
| 491 |
+
"0.35 keeps ties sharing equally while a one-move-worse "
|
| 492 |
+
"child takes ~5%%. Ties are what this is for, not noise.")
|
| 493 |
+
p.add_argument("--explore-frac", type=float, default=0.2,
|
| 494 |
+
help="Fraction of steps that sample from the target instead of "
|
| 495 |
+
"taking the argmin, to widen the visited states.")
|
| 496 |
+
p.add_argument("--state-weight", type=float, default=1.0,
|
| 497 |
+
help="Weight on the auxiliary facelet loss. 0 disables it, "
|
| 498 |
+
"which is the soft-targets-only arm.")
|
| 499 |
+
p.add_argument("--hard-targets", action="store_true",
|
| 500 |
+
help="One-hot on the teacher's argmin instead of a soft "
|
| 501 |
+
"distribution. The ablation for suspect 1.")
|
| 502 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 503 |
+
p.add_argument("--warmup", type=int, default=200)
|
| 504 |
+
p.add_argument("--max-steps", type=int, default=20000)
|
| 505 |
+
p.add_argument("--eval-every", type=int, default=500)
|
| 506 |
+
p.add_argument("--eval-n", type=int, default=100)
|
| 507 |
+
p.add_argument("--eval-depths", default="3,6,10")
|
| 508 |
+
p.add_argument("--ceiling-only", action="store_true",
|
| 509 |
+
help="Measure the teacher's greedy solve rate per depth and "
|
| 510 |
+
"exit, training nothing. This is the upper limit on what "
|
| 511 |
+
"the student can score, so it is worth knowing before a "
|
| 512 |
+
"training run is paid for. Inference only.")
|
| 513 |
+
p.add_argument("--out", default="checkpoints/policy_distill")
|
| 514 |
+
p.add_argument("--hub-repo", default="")
|
| 515 |
+
p.add_argument("--seed", type=int, default=0)
|
| 516 |
+
args = p.parse_args()
|
| 517 |
+
|
| 518 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 519 |
+
amp = device == "cuda"
|
| 520 |
+
torch.manual_seed(args.seed)
|
| 521 |
+
rng = np.random.default_rng(args.seed)
|
| 522 |
+
|
| 523 |
+
net = load_teacher(args.teacher, device)
|
| 524 |
+
|
| 525 |
+
if args.ceiling_only:
|
| 526 |
+
depths = [int(d) for d in args.eval_depths.split(",")]
|
| 527 |
+
t = time.time()
|
| 528 |
+
rates = teacher_greedy(net, device, depths, args.eval_n,
|
| 529 |
+
np.random.default_rng(12345), amp=amp)
|
| 530 |
+
print(f"teacher greedy, n={args.eval_n} per depth, {time.time()-t:.0f}s")
|
| 531 |
+
for d in depths:
|
| 532 |
+
print(f" depth {d:>3}: {rates[d]:.0%}")
|
| 533 |
+
print(json.dumps({"teacher_greedy": rates, "n": args.eval_n}))
|
| 534 |
+
return
|
| 535 |
+
|
| 536 |
+
model = build_policy(args).to(device)
|
| 537 |
+
head = StateHead(args.hidden).to(device)
|
| 538 |
+
n_params = sum(q.numel() for q in model.parameters())
|
| 539 |
+
print(f"policy {n_params/1e6:.1f}M params | teacher {args.teacher} "
|
| 540 |
+
f"({getattr(net, 'value_kind', '?')}) | {device}", flush=True)
|
| 541 |
+
print(f"targets {'hard argmin' if args.hard_targets else f'soft tau={args.tau}'} | "
|
| 542 |
+
f"state weight {args.state_weight} | batch {args.batch_size} x "
|
| 543 |
+
f"rollout {args.rollout_len}", flush=True)
|
| 544 |
+
|
| 545 |
+
amp_dtype = torch.bfloat16
|
| 546 |
+
if amp and not torch.cuda.is_bf16_supported():
|
| 547 |
+
amp_dtype = torch.float16
|
| 548 |
+
print("bf16 unsupported on this GPU; using fp16", flush=True)
|
| 549 |
+
|
| 550 |
+
opt = torch.optim.AdamW(list(model.parameters()) + list(head.parameters()),
|
| 551 |
+
lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
|
| 552 |
+
sched = torch.optim.lr_scheduler.LambdaLR(
|
| 553 |
+
opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
|
| 554 |
+
* 0.5 * (1 + math.cos(math.pi * min(s / max(args.max_steps, 1), 1.0))))
|
| 555 |
+
|
| 556 |
+
depths = [int(d) for d in args.eval_depths.split(",")]
|
| 557 |
+
out_dir = Path(args.out)
|
| 558 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 559 |
+
|
| 560 |
+
# The ceiling, measured once before training. Everything the student does is
|
| 561 |
+
# read against this, so it is printed first and stored with the history.
|
| 562 |
+
ceiling = teacher_greedy(net, device, depths, args.eval_n,
|
| 563 |
+
np.random.default_rng(12345), amp=amp)
|
| 564 |
+
print("teacher greedy (the ceiling): " +
|
| 565 |
+
" ".join(f"d{d}:{r:.0%}" for d, r in ceiling.items()), flush=True)
|
| 566 |
+
|
| 567 |
+
api = None
|
| 568 |
+
if args.hub_repo:
|
| 569 |
+
from huggingface_hub import HfApi
|
| 570 |
+
api = HfApi(token=os.environ.get("HF_TOKEN"))
|
| 571 |
+
|
| 572 |
+
run, best, t0 = [], -1.0, time.time()
|
| 573 |
+
buffer, solved_frac = [], 0.0
|
| 574 |
+
for step in range(1, args.max_steps + 1):
|
| 575 |
+
if step % args.updates_per_rollout == 1 or args.updates_per_rollout == 1:
|
| 576 |
+
states0, _ = C.random_walk(args.batch_size, args.scramble_depth, rng)
|
| 577 |
+
fresh = rollout_batch(net, states0, device, args.rollout_len, args.tau,
|
| 578 |
+
args.explore_frac, rng, hard=args.hard_targets,
|
| 579 |
+
amp=amp)
|
| 580 |
+
solved_frac = float(fresh["solved"].float().mean())
|
| 581 |
+
buffer.append(fresh)
|
| 582 |
+
if len(buffer) > args.buffer:
|
| 583 |
+
buffer.pop(0)
|
| 584 |
+
batch = buffer[rng.integers(len(buffer))]
|
| 585 |
+
loss, mloss, floss, facc = losses(model, head, batch, device,
|
| 586 |
+
args.state_weight, amp_dtype, amp)
|
| 587 |
+
opt.zero_grad(set_to_none=True)
|
| 588 |
+
loss.backward()
|
| 589 |
+
torch.nn.utils.clip_grad_norm_(
|
| 590 |
+
list(model.parameters()) + list(head.parameters()), 1.0)
|
| 591 |
+
opt.step()
|
| 592 |
+
sched.step()
|
| 593 |
+
|
| 594 |
+
if step % 50 == 0:
|
| 595 |
+
print(f"step {step}/{args.max_steps} move {mloss.item():.4f} "
|
| 596 |
+
f"face {floss.item():.4f} face_acc {facc.item():.1%} "
|
| 597 |
+
f"solved_rollouts {solved_frac:.0%} "
|
| 598 |
+
f"{step/(time.time()-t0):.2f} it/s", flush=True)
|
| 599 |
+
|
| 600 |
+
if step % args.eval_every == 0 or step == args.max_steps:
|
| 601 |
+
ev = np.random.default_rng(12345)
|
| 602 |
+
rates = student_greedy(model, device, depths, args.eval_n, ev)
|
| 603 |
+
ev = np.random.default_rng(999)
|
| 604 |
+
per_pos, exact = state_accuracy(model, head, net, device, depths,
|
| 605 |
+
max(args.eval_n // 4, 8), ev,
|
| 606 |
+
args.rollout_len, args.tau, amp)
|
| 607 |
+
mean = float(np.mean(list(rates.values())))
|
| 608 |
+
gap = {d: ceiling[d] - rates[d] for d in depths}
|
| 609 |
+
print(f" step {step} student greedy " +
|
| 610 |
+
" ".join(f"d{d}:{r:.0%}" for d, r in rates.items()) +
|
| 611 |
+
f" | mean {mean:.1%}", flush=True)
|
| 612 |
+
print(f" gap to teacher " +
|
| 613 |
+
" ".join(f"d{d}:{g:+.0%}" for d, g in gap.items()), flush=True)
|
| 614 |
+
print(f" state accuracy by position {per_pos} "
|
| 615 |
+
f"| whole cube exact {exact:.1%}", flush=True)
|
| 616 |
+
run.append({"step": step, "student": rates, "teacher": ceiling,
|
| 617 |
+
"gap": gap, "mean": mean, "state_by_pos": per_pos,
|
| 618 |
+
"state_exact": exact})
|
| 619 |
+
(out_dir / "history.json").write_text(json.dumps(run, indent=2))
|
| 620 |
+
# Latest always, best additionally -- a saturating metric must never
|
| 621 |
+
# be the only thing that chooses a checkpoint (CLAUDE.md).
|
| 622 |
+
model.save_pretrained(str(out_dir))
|
| 623 |
+
torch.save({"head": head.state_dict(), "hidden": args.hidden},
|
| 624 |
+
out_dir / "state_head.pt")
|
| 625 |
+
if mean >= best:
|
| 626 |
+
best = mean
|
| 627 |
+
model.save_pretrained(f"{args.out}-best")
|
| 628 |
+
torch.save({"head": head.state_dict(), "hidden": args.hidden},
|
| 629 |
+
Path(f"{args.out}-best") / "state_head.pt")
|
| 630 |
+
if api is not None:
|
| 631 |
+
try:
|
| 632 |
+
# Keyed on the output directory, so arms run back to
|
| 633 |
+
# back on one box do not overwrite each other's weights.
|
| 634 |
+
api.upload_folder(repo_id=args.hub_repo,
|
| 635 |
+
folder_path=f"{args.out}-best",
|
| 636 |
+
path_in_repo=f"checkpoints/{out_dir.name}")
|
| 637 |
+
except Exception as e:
|
| 638 |
+
print(f" upload failed: {type(e).__name__}: {e}", flush=True)
|
| 639 |
+
|
| 640 |
+
print(f"done. best mean student greedy {best:.1%}. weights in {out_dir}", flush=True)
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
if __name__ == "__main__":
|
| 644 |
+
main()
|
code/vast_bootstrap.sh
CHANGED
|
@@ -49,7 +49,7 @@ echo "building venv from $PYBIN ($($PYBIN -V 2>&1))"
|
|
| 49 |
# because a failed C-extension build falls back to an upstream-git rebuild --
|
| 50 |
# observed on a run that then never imported the module at all.
|
| 51 |
NEEDS_SOLVER=1
|
| 52 |
-
if [ -n "${EVAL_VALUE_ONLY:-}${TRAIN_VALUE_ITER:-}" ]; then NEEDS_SOLVER=""; fi
|
| 53 |
|
| 54 |
# kociemba's setup.py predates modern setuptools' removal of install_layout.
|
| 55 |
/opt/venv/bin/pip install -q "setuptools<60" wheel
|
|
@@ -97,6 +97,93 @@ if [ -n "${EVAL_VALUE_ONLY:-}" ]; then
|
|
| 97 |
exit 0
|
| 98 |
fi
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
# Value iteration needs no dataset and no solver: it derives its own targets by
|
| 101 |
# bootstrapping from the solved state. So this mode skips both the kociemba gate
|
| 102 |
# and the multi-million-pair generation that dominates every other run's startup
|
|
|
|
| 49 |
# because a failed C-extension build falls back to an upstream-git rebuild --
|
| 50 |
# observed on a run that then never imported the module at all.
|
| 51 |
NEEDS_SOLVER=1
|
| 52 |
+
if [ -n "${EVAL_VALUE_ONLY:-}${TRAIN_VALUE_ITER:-}${TRAIN_POLICY_DISTILL:-}" ]; then NEEDS_SOLVER=""; fi
|
| 53 |
|
| 54 |
# kociemba's setup.py predates modern setuptools' removal of install_layout.
|
| 55 |
/opt/venv/bin/pip install -q "setuptools<60" wheel
|
|
|
|
| 97 |
exit 0
|
| 98 |
fi
|
| 99 |
|
| 100 |
+
# Policy distillation from a trained value model. Needs no dataset and no solver:
|
| 101 |
+
# the targets are the teacher's scores for the 18 children of each visited state,
|
| 102 |
+
# and the auxiliary facelet targets come from applying moves. So, like value
|
| 103 |
+
# iteration, this goes straight from a GPU check to training.
|
| 104 |
+
#
|
| 105 |
+
# DISTILL_ARMS runs several arms back to back on one box, which is the point of
|
| 106 |
+
# the experiment rather than a convenience -- the arms differ only in the loss, so
|
| 107 |
+
# comparing them across two rentals would add a hardware variable to a difference
|
| 108 |
+
# of a few points:
|
| 109 |
+
# full soft targets + the state-tracking head (the whole proposal)
|
| 110 |
+
# soft soft targets only -- isolates what the head contributes
|
| 111 |
+
# hard teacher argmin, no soft mass -- isolates soft-vs-hard from the teacher
|
| 112 |
+
if [ -n "${TRAIN_POLICY_DISTILL:-}" ]; then
|
| 113 |
+
cd "${CODE_DIR:-/workspace/code}"
|
| 114 |
+
mkdir -p checkpoints
|
| 115 |
+
echo "=== GPU check ==="
|
| 116 |
+
POLICY_HIDDEN="${HIDDEN:-512}" POLICY_LAYERS="${LAYERS:-10}" POLICY_BATCH="${BATCH:-256}" \
|
| 117 |
+
/opt/venv/bin/python - <<'PYPD'
|
| 118 |
+
import os, sys, torch
|
| 119 |
+
if not torch.cuda.is_available():
|
| 120 |
+
sys.exit("ABORT: no CUDA device")
|
| 121 |
+
h, l, b = (int(os.environ[k]) for k in ("POLICY_HIDDEN", "POLICY_LAYERS", "POLICY_BATCH"))
|
| 122 |
+
vram = torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 123 |
+
print(f"gpu: {torch.cuda.get_device_name(0)} "
|
| 124 |
+
f"sm_{''.join(map(str, torch.cuda.get_device_capability(0)))} {vram:.0f}GB "
|
| 125 |
+
f"| torch {torch.__version__}")
|
| 126 |
+
# The real config at the real batch, for the reason recorded in the main block: a
|
| 127 |
+
# scaled-down probe passes on a GPU that cannot hold the model, so the failure
|
| 128 |
+
# surfaces only once the run has been paid for.
|
| 129 |
+
try:
|
| 130 |
+
from transformers import LlamaConfig, LlamaForCausalLM
|
| 131 |
+
m = LlamaForCausalLM(LlamaConfig(
|
| 132 |
+
vocab_size=28, hidden_size=h, intermediate_size=h * 4, num_hidden_layers=l,
|
| 133 |
+
num_attention_heads=8, num_key_value_heads=8, max_position_embeddings=83,
|
| 134 |
+
tie_word_embeddings=True)).cuda()
|
| 135 |
+
opt = torch.optim.AdamW(m.parameters(), lr=1e-4)
|
| 136 |
+
ids = torch.randint(0, 28, (b, 83), device="cuda")
|
| 137 |
+
with torch.autocast("cuda", dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16):
|
| 138 |
+
m(input_ids=ids, labels=ids).loss.backward()
|
| 139 |
+
opt.step()
|
| 140 |
+
torch.cuda.synchronize()
|
| 141 |
+
print(f"gpu smoke test: full step at batch {b} OK, "
|
| 142 |
+
f"peak {torch.cuda.max_memory_allocated()/1e9:.1f}GB of {vram:.0f}GB")
|
| 143 |
+
except torch.OutOfMemoryError:
|
| 144 |
+
sys.exit(f"ABORT: batch {b} of a {l}-layer/{h}-wide model does not fit in {vram:.0f}GB")
|
| 145 |
+
except Exception as e:
|
| 146 |
+
sys.exit(f"ABORT: GPU cannot run the model ({type(e).__name__}: {e})")
|
| 147 |
+
PYPD
|
| 148 |
+
|
| 149 |
+
echo "=== fetching teacher ${TEACHER_REPO:-briscoooe/tiny-cube-value-iter} ==="
|
| 150 |
+
TEACHER="$(TR="${TEACHER_REPO:-briscoooe/tiny-cube-value-iter}" \
|
| 151 |
+
TF="${TEACHER_FILE:-checkpoints/value_iter/value_iter_best.pt}" \
|
| 152 |
+
/opt/venv/bin/python -c 'import os; from huggingface_hub import hf_hub_download; print(hf_hub_download(os.environ["TR"], os.environ["TF"], token=os.environ.get("HF_TOKEN")))')"
|
| 153 |
+
echo "teacher: $TEACHER"
|
| 154 |
+
|
| 155 |
+
# The highest score any arm can reach, printed before a single gradient step.
|
| 156 |
+
# The student copies the teacher's one-step choice while the teacher descends
|
| 157 |
+
# with the true state supplied by the engine, so the teacher's greedy rate caps
|
| 158 |
+
# the student. Cheap, and it makes an undertrained result impossible to confuse
|
| 159 |
+
# with a saturated one.
|
| 160 |
+
/opt/venv/bin/python train_policy_distill.py --teacher "$TEACHER" --ceiling-only \
|
| 161 |
+
--eval-depths "${EVAL_DEPTHS:-3,6,10}" --eval-n "${EVAL_N:-100}"
|
| 162 |
+
|
| 163 |
+
for ARM in $(echo "${DISTILL_ARMS:-full}" | tr ',' ' '); do
|
| 164 |
+
case "$ARM" in
|
| 165 |
+
full) ARM_FLAGS="--state-weight ${STATE_WEIGHT:-1.0}" ;;
|
| 166 |
+
soft) ARM_FLAGS="--state-weight 0" ;;
|
| 167 |
+
hard) ARM_FLAGS="--state-weight ${STATE_WEIGHT:-1.0} --hard-targets" ;;
|
| 168 |
+
*) echo "unknown arm: $ARM" >&2; exit 1 ;;
|
| 169 |
+
esac
|
| 170 |
+
echo "=== arm $ARM ($ARM_FLAGS) ==="
|
| 171 |
+
/opt/venv/bin/python train_policy_distill.py \
|
| 172 |
+
--teacher "$TEACHER" $ARM_FLAGS \
|
| 173 |
+
--hidden "${HIDDEN:-512}" --layers "${LAYERS:-10}" --heads 8 \
|
| 174 |
+
--batch-size "${BATCH:-256}" --lr "${LR:-3e-4}" --max-steps "${STEPS:-12000}" \
|
| 175 |
+
--rollout-len "${ROLLOUT_LEN:-16}" --scramble-depth "${WALK_DEPTH:-12}" \
|
| 176 |
+
--tau "${TAU:-0.35}" --explore-frac "${EXPLORE_FRAC:-0.2}" \
|
| 177 |
+
--buffer "${BUFFER:-32}" --updates-per-rollout "${UPDATES_PER_ROLLOUT:-4}" \
|
| 178 |
+
--eval-every "${EVAL_EVERY:-1000}" --eval-n "${EVAL_N:-100}" \
|
| 179 |
+
--eval-depths "${EVAL_DEPTHS:-3,6,10}" \
|
| 180 |
+
--out "checkpoints/policy_distill_$ARM" \
|
| 181 |
+
--hub-repo "${HUB_REPO:-}"
|
| 182 |
+
done
|
| 183 |
+
echo "=== done ==="
|
| 184 |
+
exit 0
|
| 185 |
+
fi
|
| 186 |
+
|
| 187 |
# Value iteration needs no dataset and no solver: it derives its own targets by
|
| 188 |
# bootstrapping from the solved state. So this mode skips both the kociemba gate
|
| 189 |
# and the multi-million-pair generation that dominates every other run's startup
|