qwen3-8b-parallel-cot / training_code /latent_threads /eval_single_cellpatch.py
japhba's picture
Single-token-per-step latent-CoT organism: load-bearing + length-generalising
ca791bd verified
Raw
History Blame Contribute Delete
9.02 kB
"""Single-cell causal patch for the single-token organism.
Overwrite ONE cell value c_i(t) inside the soft token z_t (force cell i = digit d), then CONTINUE
free-running so the change propagates through the learned CA rule, and check the final answer equals
what the *true* CA produces for the altered row. This is a surgical test (one cell, one step) that the
latent at (t,i) causally carries c_i(t) AND that the downstream computation uses it.
Patch (per the card): generate free-running to step t; read z_t = softmax(head(h_t)) in R^{K x 10};
set z_t[i] <- one-hot(d) (other cells untouched); re-embed via the codebook (fed = sum_k z_t[k].C[k])
as the input to z_{t+1}; continue free-running; read the answer from the head at z_T, cell q.
Expected = CA-evolve(c_t with cell i := d) for the remaining T-1-t steps, read at cell q.
python -m latent_threads.eval_single_cellpatch --ckpt .../sg1/diffuse/best --n 400
"""
from __future__ import annotations
import argparse
import json
import os
import random
import dotenv
dotenv.load_dotenv()
_USER = os.environ.get("USER", "jbauer")
os.environ.setdefault("HF_HOME", f"/workspace-vast/{_USER}/hf")
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from tqdm.auto import tqdm
from latent_threads import tasks as LT
from latent_threads.single import build_single_batch, build_single_mask
from latent_threads.eval_single_report import load, RES
@torch.no_grad()
def patched_answer(model, head, codebook, q_emb, tok, task, batch, im, device, t_p, i_p, d_p):
"""Free-run; at step t_p[j] overwrite cell i_p[j] of the soft token with one-hot(d_p[j]); continue.
Returns the head's predicted digit for each example's queried cell at z_T."""
K, m = task.K, task.m
input_ids, roles, zs_, ls_, lens_ = build_single_batch(tok, task, batch, im, device, with_answer=False)
B, Lmax = input_ids.shape
attn = build_single_mask(roles, zs_, m)
pos = torch.arange(Lmax, device=device)[None].expand(B, Lmax)
E = model.get_input_embeddings()(input_ids)
bidx = torch.arange(B, device=device); zs = torch.tensor(zs_, device=device)
E = E.clone(); E[bidx, zs] = q_emb
out = None
for t in range(m):
out = model(inputs_embeds=E, attention_mask=attn, position_ids=pos, output_hidden_states=True)
if t == m - 1:
break
z = torch.softmax(head(out.hidden_states[-1][bidx, zs + t]).view(B, K, 10).float(), -1) # [B,K,10]
for j in range(B): # patch examples whose t_p == t
if t_p[j] == t:
z[j, i_p[j]] = F.one_hot(torch.tensor(d_p[j], device=device), 10).float()
fed = torch.einsum("bkv,kvd->bd", z, codebook.float())
E = E.clone(); E[bidx, zs + t + 1] = fed.to(E.dtype)
final = head(out.hidden_states[-1][bidx, zs + m - 1]).view(B, K, 10) # head at z_T
return [final[j, batch[j].q].argmax().item() for j in range(B)]
def expected_answer(task, p, t, i, d):
row = list(p.rows[t]); row[i] = d
for _ in range(task.m - 1 - t):
row = task._step(row)
return row[p.q]
def run(ckpt, model_name, n, device):
model, head, codebook, q_emb, tok, did, K, cfg, tag = load(ckpt, model_name, device)
im = tok.convert_tokens_to_ids("<|im_end|>")
m = cfg["task_kwargs"]["m"]; task = LT.make_task(cfg["task"], k=K, m=m)
rng = random.Random(7)
probs = [task.sample(rng) for _ in range(n)]
# per-example patch: a random step t in 0..m-2, cell i, target digit d != current value
t_p, i_p, d_p = [], [], []
for p in probs:
t = rng.randrange(0, m - 1); i = rng.randrange(K)
cur = p.rows[t][i]; d = rng.choice([x for x in range(10) if x != cur])
t_p.append(t); i_p.append(i); d_p.append(d)
exp = np.array([expected_answer(task, probs[j], t_p[j], i_p[j], d_p[j]) for j in range(n)])
orig = np.array([task.answer(probs[j]) for j in range(n)])
preds = []
bs = 16
for s in tqdm(range(0, n, bs), desc=f"{tag} cell-patch"):
b = probs[s:s + bs]
preds += patched_answer(model, head, codebook, q_emb, tok, task, b, im, device,
t_p[s:s + bs], i_p[s:s + bs], d_p[s:s + bs])
preds = np.array(preds)
follows_ca = float((preds == exp).mean())
changed = exp != orig # patch actually moves the answer
follows_on_changed = float((preds[changed] == exp[changed]).mean())
follows_orig_on_changed = float((preds[changed] == orig[changed]).mean()) # ignores the patch?
dist = np.array([m - 1 - t for t in t_p]) # propagation distance
by_dist = {int(dd): float((preds[dist == dd] == exp[dist == dd]).mean()) for dd in sorted(set(dist.tolist()))}
print("=" * 70 + f"\nSINGLE-CELL PATCH {tag} K={K} m={m} n={n}\n" + "=" * 70, flush=True)
print(f" follows CA-propagated answer (all patches): {follows_ca:.3f}", flush=True)
print(f" on patches that CHANGE the answer (n={int(changed.sum())}): follows patched CA={follows_on_changed:.3f} "
f"follows ORIGINAL (ignores patch)={follows_orig_on_changed:.3f}", flush=True)
print(f" by propagation distance (steps from patch to read): { {k: round(v,3) for k,v in by_dist.items()} }", flush=True)
res = dict(tag=tag, K=K, m=m, n=n, follows_ca=follows_ca, n_changed=int(changed.sum()),
follows_on_changed=follows_on_changed, follows_orig_on_changed=follows_orig_on_changed,
by_distance=by_dist)
json.dump(res, open(f"{RES}/single_cellpatch_{tag}.json", "w"), indent=2)
fig, ax = plt.subplots(figsize=(6, 4))
ds = sorted(by_dist); ax.plot(ds, [by_dist[d] for d in ds], "o-", color="#2ca02c", label="follows patched CA")
ax.axhline(1 / 10, ls="--", color="grey", label="chance 0.10")
ax.set_xlabel("propagation distance (CA steps from patched cell to read-out)")
ax.set_ylabel("P(answer = CA-propagated value)"); ax.set_ylim(0, 1.02); ax.legend()
ax.set_title(f"{tag}: single-cell patch c_i(t):=d propagates correctly")
png = f"{RES}/single_cellpatch_{tag}.png"; fig.savefig(png, bbox_inches="tight", dpi=130)
print(png, flush=True)
return res
def run_sweep(ckpt, model_name, n_probs, device):
"""Systematic spot-check: patch EVERY (step t, cell i) site on a small set of problems, force a
changed value, and print the full per-patch detail (orig answer / CA-expected / model) for review."""
model, head, codebook, q_emb, tok, did, K, cfg, tag = load(ckpt, model_name, device)
im = tok.convert_tokens_to_ids("<|im_end|>")
m = cfg["task_kwargs"]["m"]; task = LT.make_task(cfg["task"], k=K, m=m)
rng = random.Random(11)
probs = [task.sample(rng) for _ in range(n_probs)]
sites = [(t, i) for t in range(m - 1) for i in range(K)] # all positions x all cells
batch, T, I, D, meta = [], [], [], [], []
for pi, p in enumerate(probs):
for (t, i) in sites:
cur = p.rows[t][i]; d = rng.choice([x for x in range(10) if x != cur])
batch.append(p); T.append(t); I.append(i); D.append(d)
meta.append((pi, t, i, cur, d, p.q, task.answer(p), expected_answer(task, p, t, i, d)))
preds = []
for s in range(0, len(batch), 16):
preds += patched_answer(model, head, codebook, q_emb, tok, task, batch[s:s + 16], im, device,
T[s:s + 16], I[s:s + 16], D[s:s + 16])
print("=" * 92 + f"\nSINGLE-CELL PATCH SWEEP {tag} K={K} m={m} {len(sites)} sites x {n_probs} problems = {len(meta)} patches\n" + "=" * 92, flush=True)
print(f"{'prob':>4} {'site':>8} {'c_i(t)->d':>10} {'q':>4} {'orig':>5} {'CA_exp':>7} {'model':>6} match", flush=True)
persite, ok_all = {}, []
for k, (pi, t, i, cur, d, q, orig, exp) in enumerate(meta):
mod = preds[k]; ok = (mod == exp); ok_all.append(ok); persite.setdefault((t, i), []).append(ok)
print(f"{pi:>4} {f's{t+1}.c{i+1}':>8} {f'{cur}->{d}':>10} {f'c{q+1}':>4} {orig:>5} {exp:>7} {mod:>6} {'OK' if ok else 'XX'}", flush=True)
print("-" * 92 + "\nper-site follows-CA (over the problems):", flush=True)
for (t, i) in sites:
v = persite[(t, i)]; print(f" s{t+1}.c{i+1}: {sum(v)}/{len(v)} = {sum(v)/len(v):.2f}", flush=True)
print(f"OVERALL follows CA-propagated: {sum(ok_all)}/{len(ok_all)} = {sum(ok_all)/len(ok_all):.3f}", flush=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", required=True); ap.add_argument("--model", default="Qwen/Qwen3-8B")
ap.add_argument("--n", type=int, default=400)
ap.add_argument("--sweep", action="store_true"); ap.add_argument("--probs", type=int, default=7)
args = ap.parse_args()
if args.sweep:
run_sweep(args.ckpt, args.model, args.probs, "cuda")
else:
run(args.ckpt, args.model, args.n, "cuda")
if __name__ == "__main__":
main()