briscoooe commited on
Commit
e61febb
·
verified ·
1 Parent(s): 0bcae1d

code: detached probe, so every version can be measured

Browse files
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)), 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
  # --------------------------------------------------------------------------
@@ -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()), 1.0)
 
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, 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())))
@@ -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(), "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
 
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