code: detached probe, so every version can be measured
Browse files- code/test_policy_distill.py +21 -0
- code/train_policy_distill.py +40 -11
code/test_policy_distill.py
CHANGED
|
@@ -121,6 +121,26 @@ def test_train_step(net):
|
|
| 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
|
|
@@ -154,6 +174,7 @@ if __name__ == "__main__":
|
|
| 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")
|
|
|
|
| 121 |
return model, head
|
| 122 |
|
| 123 |
|
| 124 |
+
def test_probe_is_detached(net):
|
| 125 |
+
"""The probe must train itself and leave the model it measures alone."""
|
| 126 |
+
class Args:
|
| 127 |
+
hidden, layers, heads = 32, 2, 2
|
| 128 |
+
|
| 129 |
+
model = D.build_policy(Args())
|
| 130 |
+
head, probe = D.StateHead(32), D.StateHead(32)
|
| 131 |
+
rng = np.random.default_rng(11)
|
| 132 |
+
states0, _ = C.random_walk(8, 8, rng)
|
| 133 |
+
batch = D.rollout_batch(net, states0, "cpu", 6, 0.35, 0.0, rng, amp=False)
|
| 134 |
+
# state_weight 0 removes the attached head, so anything reaching the trunk
|
| 135 |
+
# after that comes from the move loss alone -- never from the probe.
|
| 136 |
+
loss, _, _, _ = D.losses(model, head, batch, "cpu", 0.0, torch.bfloat16, False,
|
| 137 |
+
probe)
|
| 138 |
+
loss.backward()
|
| 139 |
+
assert all(q.grad is not None and q.grad.abs().sum() > 0 for q in probe.parameters())
|
| 140 |
+
assert all(q.grad is None or q.grad.abs().sum() == 0 for q in head.parameters())
|
| 141 |
+
print("ok the probe trains itself and does not reach the attached head")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def test_eval(net, model, head):
|
| 145 |
rates = D.student_greedy(model, "cpu", [3], 8, np.random.default_rng(4))
|
| 146 |
assert set(rates) == {3} and 0.0 <= rates[3] <= 1.0
|
|
|
|
| 174 |
test_targets(teacher)
|
| 175 |
test_hard_targets(teacher)
|
| 176 |
test_solved_start(teacher)
|
| 177 |
+
test_probe_is_detached(teacher)
|
| 178 |
model, head = test_train_step(teacher)
|
| 179 |
test_eval(teacher, model, head)
|
| 180 |
print("\nall smoke tests passed")
|
code/train_policy_distill.py
CHANGED
|
@@ -296,6 +296,20 @@ class StateHead(torch.nn.Module):
|
|
| 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):
|
|
@@ -306,7 +320,7 @@ class StateHead(torch.nn.Module):
|
|
| 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)
|
|
@@ -325,11 +339,23 @@ def losses(model, head, batch, device, state_weight, amp_dtype, use_amp):
|
|
| 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)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
with torch.no_grad():
|
| 331 |
face_acc = (sel.argmax(-1) == faces[posmask]).float().mean()
|
| 332 |
-
return
|
| 333 |
|
| 334 |
|
| 335 |
# --------------------------------------------------------------------------
|
|
@@ -535,6 +561,7 @@ def main():
|
|
| 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)
|
|
@@ -547,7 +574,8 @@ def main():
|
|
| 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)
|
|
@@ -583,11 +611,12 @@ def main():
|
|
| 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())
|
|
|
|
| 591 |
opt.step()
|
| 592 |
sched.step()
|
| 593 |
|
|
@@ -601,7 +630,7 @@ def main():
|
|
| 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,
|
| 605 |
max(args.eval_n // 4, 8), ev,
|
| 606 |
args.rollout_len, args.tau, amp)
|
| 607 |
mean = float(np.mean(list(rates.values())))
|
|
@@ -620,13 +649,13 @@ def main():
|
|
| 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(), "
|
| 624 |
-
|
| 625 |
if mean >= best:
|
| 626 |
best = mean
|
| 627 |
model.save_pretrained(f"{args.out}-best")
|
| 628 |
-
torch.save({"head": head.state_dict(), "
|
| 629 |
-
|
| 630 |
if api is not None:
|
| 631 |
try:
|
| 632 |
# Keyed on the output directory, so arms run back to
|
|
|
|
| 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 |
+
Two of these are built, and the difference is the whole reason there are two:
|
| 301 |
+
|
| 302 |
+
- the **probe** reads `hidden.detach()`, so it is a pure measurement. It is
|
| 303 |
+
trained in every arm, including the ones with no auxiliary loss, and it
|
| 304 |
+
never changes the model it measures.
|
| 305 |
+
- the **auxiliary head** reads the live hidden state, so its loss flows into
|
| 306 |
+
the trunk. That is the intervention, and `--state-weight 0` removes it.
|
| 307 |
+
|
| 308 |
+
The first version of this trainer used one head for both jobs. That reads
|
| 309 |
+
correctly in the arms that use the auxiliary loss and reads pure chance in the
|
| 310 |
+
arms that do not -- the head is simply never trained there -- so tracking
|
| 311 |
+
could not be compared across arms at all, which was the comparison worth
|
| 312 |
+
having. Runs before 2026-09-16 have that limitation.
|
| 313 |
"""
|
| 314 |
|
| 315 |
def __init__(self, hidden):
|
|
|
|
| 320 |
return self.proj(h).view(*h.shape[:-1], T.STATE_LEN, len(C.COLORS))
|
| 321 |
|
| 322 |
|
| 323 |
+
def losses(model, head, batch, device, state_weight, amp_dtype, use_amp, probe=None):
|
| 324 |
ids = batch["ids"].to(device)
|
| 325 |
attn = batch["attn"].to(device)
|
| 326 |
posmask = batch["posmask"].to(device)
|
|
|
|
| 339 |
logp = torch.log_softmax(logits.float(), dim=-1)[..., ANSWER_IDS]
|
| 340 |
move_loss = -(soft * logp).sum(-1)[posmask].mean()
|
| 341 |
|
| 342 |
+
target = faces[posmask].reshape(-1)
|
| 343 |
sel = face_logits.float()[posmask]
|
| 344 |
+
face_loss = F.cross_entropy(sel.reshape(-1, len(C.COLORS)), target)
|
| 345 |
+
|
| 346 |
+
total = move_loss + state_weight * face_loss
|
| 347 |
+
if probe is not None:
|
| 348 |
+
# Detached, so this term trains the probe and nothing else. Always on, at
|
| 349 |
+
# weight 1, in every arm -- it is the measurement, not the intervention.
|
| 350 |
+
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
|
| 351 |
+
probe_logits = probe(hidden.detach())
|
| 352 |
+
psel = probe_logits.float()[posmask]
|
| 353 |
+
probe_loss = F.cross_entropy(psel.reshape(-1, len(C.COLORS)), target)
|
| 354 |
+
total = total + probe_loss
|
| 355 |
+
sel = psel
|
| 356 |
with torch.no_grad():
|
| 357 |
face_acc = (sel.argmax(-1) == faces[posmask]).float().mean()
|
| 358 |
+
return total, move_loss, face_loss, face_acc
|
| 359 |
|
| 360 |
|
| 361 |
# --------------------------------------------------------------------------
|
|
|
|
| 561 |
|
| 562 |
model = build_policy(args).to(device)
|
| 563 |
head = StateHead(args.hidden).to(device)
|
| 564 |
+
probe = StateHead(args.hidden).to(device)
|
| 565 |
n_params = sum(q.numel() for q in model.parameters())
|
| 566 |
print(f"policy {n_params/1e6:.1f}M params | teacher {args.teacher} "
|
| 567 |
f"({getattr(net, 'value_kind', '?')}) | {device}", flush=True)
|
|
|
|
| 574 |
amp_dtype = torch.float16
|
| 575 |
print("bf16 unsupported on this GPU; using fp16", flush=True)
|
| 576 |
|
| 577 |
+
opt = torch.optim.AdamW(list(model.parameters()) + list(head.parameters())
|
| 578 |
+
+ list(probe.parameters()),
|
| 579 |
lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
|
| 580 |
sched = torch.optim.lr_scheduler.LambdaLR(
|
| 581 |
opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
|
|
|
|
| 611 |
buffer.pop(0)
|
| 612 |
batch = buffer[rng.integers(len(buffer))]
|
| 613 |
loss, mloss, floss, facc = losses(model, head, batch, device,
|
| 614 |
+
args.state_weight, amp_dtype, amp, probe)
|
| 615 |
opt.zero_grad(set_to_none=True)
|
| 616 |
loss.backward()
|
| 617 |
torch.nn.utils.clip_grad_norm_(
|
| 618 |
+
list(model.parameters()) + list(head.parameters())
|
| 619 |
+
+ list(probe.parameters()), 1.0)
|
| 620 |
opt.step()
|
| 621 |
sched.step()
|
| 622 |
|
|
|
|
| 630 |
ev = np.random.default_rng(12345)
|
| 631 |
rates = student_greedy(model, device, depths, args.eval_n, ev)
|
| 632 |
ev = np.random.default_rng(999)
|
| 633 |
+
per_pos, exact = state_accuracy(model, probe, net, device, depths,
|
| 634 |
max(args.eval_n // 4, 8), ev,
|
| 635 |
args.rollout_len, args.tau, amp)
|
| 636 |
mean = float(np.mean(list(rates.values())))
|
|
|
|
| 649 |
# Latest always, best additionally -- a saturating metric must never
|
| 650 |
# be the only thing that chooses a checkpoint (CLAUDE.md).
|
| 651 |
model.save_pretrained(str(out_dir))
|
| 652 |
+
torch.save({"head": head.state_dict(), "probe": probe.state_dict(),
|
| 653 |
+
"hidden": args.hidden}, out_dir / "state_head.pt")
|
| 654 |
if mean >= best:
|
| 655 |
best = mean
|
| 656 |
model.save_pretrained(f"{args.out}-best")
|
| 657 |
+
torch.save({"head": head.state_dict(), "probe": probe.state_dict(),
|
| 658 |
+
"hidden": args.hidden}, Path(f"{args.out}-best") / "state_head.pt")
|
| 659 |
if api is not None:
|
| 660 |
try:
|
| 661 |
# Keyed on the output directory, so arms run back to
|