| |
| """Latent chain-of-thought interpretability probe (logit-lens). |
| |
| For a trained coconut model on the 2-arm star task, decode what each intermediate |
| latent token encodes. Because node id == token id, applying the LM head to a |
| latent position's hidden state and taking argmax yields a predicted graph node |
| directly. |
| |
| In a single forward pass with a full k-latent chain the model predicts the hop-m |
| node at the position `root + (m-1)` (stage-m training supervises hop-m after m-1 |
| latents). We therefore read, for each latent slot, the model's decoded node and |
| compare it to the TRUE bfs frontier at that hop, over the whole validation set. |
| |
| Outputs: |
| * per-hop accuracy: does latent slot m decode to a node on the reachable |
| frontier at depth m? (frontier = either arm's depth-m node) |
| * per-hop "on-target-arm" accuracy: exact match to the shortest-path node. |
| * a confusion matrix: latent slot (predicted depth) vs the decoded node's TRUE |
| role (reachable depth 0..L, negative-component, or off-graph) -- i.e. which |
| depth each latent actually captures and how cleanly. |
| |
| Usage: |
| python scripts/probe_latents.py \ |
| --ckpt ckpts/star-coconut-L10-bfs-backtrack/checkpoint_650 \ |
| --val data/star_2arm_L10_valid_fo_bfs.json \ |
| --model_id configs/symbol-2layer-8head-768dim-L20.json --L 10 |
| """ |
| import argparse |
| import json |
| import torch |
| from transformers import AutoModelForCausalLM, AutoConfig |
|
|
| from stokenizer import STokenizer |
| from coconut import Coconut |
|
|
|
|
| def build_prefix_tokens(sample, tok): |
| """Match dataset.get_prefix (without the random shuffles; order-invariant for a |
| trained model). Ends at the root token; the caller appends latent tokens.""" |
| edges = sample["edges"] |
| q = "<eos> " + "|".join([f" {e[0]} {e[1]} " for e in edges]).strip() + " [Q] " |
| q += f'{sample["target"]} {sample["neg_target"]}' |
| q += " [R] " + str(sample["root"]) |
| return tok.encode(q, add_special_tokens=False) |
|
|
|
|
| def node_depth_maps(sample, L): |
| """Map each node id -> ('pos', depth) reachable-arm depth 0..L, |
| or ('neg', depth) for the unreachable component, else absent.""" |
| role = {} |
| role[sample["root"]] = ("pos", 0) |
| role[sample["neg_root"]] = ("neg", 0) |
| for k in range(1, L + 1): |
| for n in sample["neighbor_k"].get(str(k), []): |
| role[n] = ("pos", k) |
| for n in sample["neg_neighbor_k"].get(str(k), []): |
| role[n] = ("neg", k) |
| return role |
|
|
|
|
| @torch.no_grad() |
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--ckpt", required=True) |
| ap.add_argument("--val", required=True) |
| ap.add_argument("--model_id", default="configs/symbol-2layer-8head-768dim-L20.json") |
| ap.add_argument("--L", type=int, required=True) |
| ap.add_argument("--batch_size", type=int, default=64) |
| ap.add_argument("--device", default="cuda:0") |
| args = ap.parse_args() |
|
|
| tok = STokenizer() |
| latent_id = tok.convert_tokens_to_ids("<|latent|>") |
|
|
| base = AutoModelForCausalLM.from_config(AutoConfig.from_pretrained(args.model_id)) |
| model = Coconut(base, latent_id, |
| tok.convert_tokens_to_ids("<|start-latent|>"), |
| tok.convert_tokens_to_ids("<|end-latent|>"), |
| tok.eos_token_id) |
| sd = torch.load(args.ckpt, map_location="cpu") |
| missing, unexpected = model.load_state_dict(sd, strict=False) |
| print(f"loaded ckpt: {len(sd)} tensors | missing={len(missing)} unexpected={len(unexpected)}") |
| model.to(args.device).eval() |
|
|
| data = json.load(open(args.val)) |
| L = args.L |
|
|
| |
| frontier_correct = [0] * (L + 1) |
| target_correct = [0] * (L + 1) |
| total = 0 |
| |
| |
| NEG, OFF = L + 1, L + 2 |
| conf = [[0] * (L + 3) for _ in range(L + 1)] |
|
|
| for i in range(0, len(data), args.batch_size): |
| batch = data[i:i + args.batch_size] |
| seqs = [] |
| for s in batch: |
| ids = build_prefix_tokens(s, tok) + [latent_id] * L |
| seqs.append(ids) |
| maxlen = max(len(x) for x in seqs) |
| assert all(len(x) == maxlen for x in seqs), "fixed L => equal lengths expected" |
| input_ids = torch.tensor(seqs, device=args.device) |
| attn = torch.ones_like(input_ids) |
| pos = torch.arange(maxlen, device=args.device).unsqueeze(0).expand(len(batch), -1) |
| out = model.forward(input_ids, attn, input_ids.clone(), pos) |
| logits = out.logits |
|
|
| for bi, s in enumerate(batch): |
| total += 1 |
| role = node_depth_maps(s, L) |
| root_pos = len(build_prefix_tokens(s, tok)) - 1 |
| for m in range(1, L + 1): |
| |
| pred = int(torch.argmax(logits[bi, root_pos + (m - 1)]).item()) |
| |
| if pred in s["neighbor_k"].get(str(m), []): |
| frontier_correct[m] += 1 |
| |
| if m - 1 < len(s["steps"]) and pred == int(s["steps"][m - 1]): |
| target_correct[m] += 1 |
| |
| r = role.get(pred) |
| if r is None: |
| conf[m][OFF] += 1 |
| elif r[0] == "neg": |
| conf[m][NEG] += 1 |
| else: |
| conf[m][r[1]] += 1 |
|
|
| print(f"\n=== samples: {total} | checkpoint: {args.ckpt} ===") |
| print("\nPer-hop latent decode accuracy (single forward, full latent chain):") |
| print(f"{'hop':>4} {'frontier_acc':>13} {'target_arm_acc':>15}") |
| for m in range(1, L + 1): |
| print(f"{m:>4} {frontier_correct[m] / total:>13.3f} {target_correct[m] / total:>15.3f}") |
|
|
| print("\nConfusion: latent slot m (row) vs TRUE reachable depth of decoded node (col).") |
| header = "slot\\depth " + " ".join(f"{d:>5}" for d in range(0, L + 1)) + f" {'neg':>5} {'off':>5}" |
| print(header) |
| for m in range(1, L + 1): |
| row = conf[m] |
| cells = " ".join(f"{row[d]:>5}" for d in range(0, L + 1)) |
| print(f"{m:>9} {cells} {row[NEG]:>5} {row[OFF]:>5}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|