File size: 19,674 Bytes
bb102c0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | #!/usr/bin/env python
"""Lab 8 (capstone): build your own edit from the model's own loops, then test it on problems it never saw.
. /nvme/wsp-lab/code/scripts/env.sh && cd /nvme/wsp-lab/code
CUDA_VISIBLE_DEVICES=7 python labs/lab8_capstone.py # about 5 minutes on one GPU
CUDA_VISIBLE_DEVICES="" python scripts/build_models.py --variants configs/capstone.json # CPU, about a minute
python scripts/run_jobs.py plans/extra.json --gpus 6,7 --only 'dev/cap*' # about 30 minutes, one run per GPU
python scripts/summarize.py /nvme/wsp-lab/runs # writes /nvme/wsp-lab/summary.md
Experiments (each writes to its own folder, results/lab8/try-*, and leaves the main results, the figures and
configs/capstone.json alone; the script prints the commands that build and test the try's two edits):
CUDA_VISIBLE_DEVICES=7 python labs/lab8_capstone.py --max-samples 16 # u from only 16 loops
CUDA_VISIBLE_DEVICES=7 python labs/lab8_capstone.py --min-before 2048 # only loops that start late
CUDA_VISIBLE_DEVICES=7 python labs/lab8_capstone.py --alpha 8 # same u and neurons, stronger edit
CUDA_VISIBLE_DEVICES=7 python labs/lab8_capstone.py --split-by problem # stability checks with no problem in both halves
The paper's agent built its edit from 8 short texts that it wrote and labeled itself. Here the data is the
unedited model's own answers to 128 practice problems (runs/build/base; none of them is used for testing).
For every answer that ends in a verbatim loop, take three 36-token windows:
in the loop starts two periods after the loop starts, when the model is surely repeating
just before ends 32 tokens before the loop starts (recipe A)
well before the middle of the text before the loop (recipe B)
u = mean over answers of (stream in the loop - stream before), after blocks 15 and 19, scaled to length 1.
Neuron score = (how much neuron j writes along u) x (how much more neuron j fires in the loop; last 24 tokens of
each window). The 64 highest-scoring neurons get the edit with alpha 6: the agent's recipe, your data.
Before you trust any direction you extract, measure:
agreement cos with the released u, and how many of the released 64 neurons you picked too
stability split the answers in half at random: do the two halves give the same u?
data needed how many answers you need before u stops changing (the agent used 4 pairs)
transfer does your u, built from the model's loops, separate the agent's 8 hand-written texts?
and one question about the loops themselves: lined up at the token where each loop starts, when does the stream
move along the released u, and when do the released 64 neurons start pushing (s(t), the edit's trigger)?
"""
import argparse
import json
import os
import sys
import numpy as np
import torch
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from wsp import hf, loops # noqa: E402
ap = argparse.ArgumentParser()
ap.add_argument("--run", default=os.path.join(hf.LAB, "runs", "build", "base"))
ap.add_argument("--data", default="build128")
ap.add_argument("--min-before", type=int, default=512, help="skip loops that start fewer answer tokens in")
ap.add_argument("--max-samples", type=int, default=0, help="use at most this many loops (0 = all)")
ap.add_argument("--alpha", type=float, default=6)
ap.add_argument("--split-by", choices=("answer", "problem"), default="answer",
help="stability checks: split the loops at random by answer, or keep both answers to a problem "
"in the same half (the edits themselves do not change)")
ap.add_argument("--try-name", help="write to results/lab8/try-<name>/ (a name is picked for you when a flag changes)")
a = ap.parse_args()
# Experiments write to results/lab8/try-*/ and name their edits capA-<try>/capB-<try>, so the main results, the
# figures, configs/capstone.json and the models/qwen35-cap*-a6 folders are never overwritten.
changed = [f"{k.replace('_', '-')}-{getattr(a, k):g}" for k in ("min_before", "max_samples", "alpha")
if getattr(a, k) != ap.get_default(k)]
if a.run != ap.get_default("run") or a.data != ap.get_default("data"):
changed.append(f"{os.path.basename(os.path.normpath(a.run))}-{a.data}")
# --split-by on its own changes only the stability checks, so those edits are the main run's. Every other experiment,
# including a --try-name run after you change the code (GAP, say), writes edits you can build.
new_edits = bool(changed) or a.split_by == "answer"
if a.split_by != "answer":
changed.append(f"split-by-{a.split_by}")
TRY = a.try_name or "-".join(changed) or None
OUT = os.path.join(ROOT, "results", "lab8", f"try-{TRY}") if TRY else os.path.join(ROOT, "results", "lab8")
FIG = OUT if TRY else os.path.join(ROOT, "results", "figures")
os.makedirs(OUT, exist_ok=True)
os.makedirs(FIG, exist_ok=True)
LAYERS = (15, 19)
WIN, AWIN, GAP = 36, 24, 32 # window sizes as in the agent's recipe; GAP = distance of window A from onset
PRE, POST = 2048, 256 # the onset profile covers this many tokens before and after each loop starts
RECIPES = {"A": "just before the loop", "B": "well before the loop"}
rng = np.random.default_rng(0)
def unit(v):
return v / v.norm()
rel = hf.released_edit()
lab3 = json.load(open(os.path.join(ROOT, "results", "lab3", "rebuild.json")))
# the released u, signed to point toward looping (Lab 3 found which way it points)
u_rel = {L: unit(rel[L]["u"].float()) * float(np.sign(lab3["layers"][str(L)]["cos_with_released_u"]))
for L in LAYERS}
# 1. find the loops
rows = loops.read_rows(hf.LAB, a.data)
m, tok = hf.load("base")
n_total = n_cut = 0
for rec, j, s, text in loops.iter_samples(a.run):
n_total += 1
n_cut += bool(s.get("cut"))
found = loops.looping_samples(tok, a.run, rows)
print(f"{n_total} answers in {a.run}: {n_cut} hit the token cap, {len(found)} of those end in a verbatim loop")
def windows(f):
n0, on, p, T = f["n_prompt"], f["onset"], f["period"], len(f["ids"])
r0 = min(on + 2 * p, T - 1 - WIN) # the last token is not part of the checked loop
if on - n0 < a.min_before or r0 < on:
return None
b0 = n0 + (on - n0) // 2 - WIN // 2
return {"loop": (r0, r0 + WIN), "A": (on - GAP - WIN, on - GAP), "B": (b0, b0 + WIN)}
used = [(f, w) for f in found if (w := windows(f))]
if a.max_samples:
used = used[:a.max_samples]
N = len(used)
print(f"using {N} loops (loop starts at least {a.min_before} answer tokens in)")
assert N >= 8, "too few loops to build a direction"
Wd = {L: hf.down_proj(m, L).weight.detach().float().cpu() for L in LAYERS}
r_rel = {L: Wd[L][:, rel[L]["cols"]].T @ u_rel[L] for L in LAYERS} # per released neuron: its push along u
# 2. one forward pass per answer, cut where nothing later is needed (the model is causal)
dh = {k: {L: [] for L in LAYERS} for k in RECIPES}
da = {k: {L: [] for L in LAYERS} for k in RECIPES}
prof = {q: {L: [] for L in LAYERS} for q in ("stream", "s")}
meta, example = [], None
order_by_onset = sorted(range(N), key=lambda i: used[i][0]["onset"] - used[i][0]["n_prompt"])
example_i = order_by_onset[N // 2]
for i, (f, w) in enumerate(used):
n0, on = f["n_prompt"], f["onset"]
end = max(w["loop"][1], min(on + POST, len(f["ids"])))
x = torch.tensor([f["ids"][:end]], device="cuda")
with torch.no_grad(), hf.record_outputs(m, LAYERS) as H, hf.record_mlp_acts(m, LAYERS) as A:
m.model(x, use_cache=False)
pos = np.arange(end) - on
keep = (pos >= -PRE) & (pos < POST) & (np.arange(end) >= n0)
ex = {} if i == example_i else None
for L in LAYERS:
h, act = H[L][0], A[L][0]
hw = {k: h[s:e].mean(0) for k, (s, e) in w.items()}
aw = {k: act[e - AWIN:e].mean(0) for k, (s, e) in w.items()}
for k in RECIPES:
dh[k][L].append(hw["loop"] - hw[k])
da[k][L].append(aw["loop"] - aw[k])
for q, v in (("stream", h @ u_rel[L]), ("s", act[:, rel[L]["cols"]] @ r_rel[L])):
row = np.full(PRE + POST, np.nan, dtype=np.float32)
row[pos[keep] + PRE] = v.numpy()[keep]
prof[q][L].append(row)
if ex is not None:
ex[f"stream{L}"] = (h[n0:] @ u_rel[L]).tolist()
ex[f"s{L}"] = (act[n0:, rel[L]["cols"]] @ r_rel[L]).tolist()
meta.append({"id": f["id"], "j": f["j"], "loop_starts_at": on - n0, "period": f["period"],
"answer_tokens": len(f["ids"]) - n0, "windows": {k: [s - n0, e - n0] for k, (s, e) in w.items()}})
if ex is not None:
example = {"meta": meta[-1], **ex}
del H, A
if (i + 1) % 25 == 0:
print(f" {i + 1}/{N} answers")
# 3. directions, neurons, and the checks
res = {"answers": n_total, "cut": n_cut, "verbatim_loops": len(found), "used": N, "min_before": a.min_before,
"periods": np.percentile([x["period"] for x in meta], [10, 25, 50, 75, 90]).tolist(),
"loop_starts_at": np.percentile([x["loop_starts_at"] for x in meta], [10, 25, 50, 75, 90]).tolist(),
"recipes": {}, "loops": meta}
print(f"loop period, 10/25/50/75/90th percentile: {res['periods']} tokens")
print(f"loop starts at answer token, same percentiles: {res['loop_starts_at']}")
pairs = json.load(open(os.path.join(ROOT, "labs", "data", "astra_pairs.json")))
with torch.no_grad(), hf.record_outputs(m, LAYERS) as H:
for t in pairs["texts"]:
m.model(hf.encode(tok, t), use_cache=False)
astra_tail = {L: torch.stack([h[-WIN:].mean(0) for h in H[L]]) for L in LAYERS} # [8 texts, 1024]
SIZES = sorted({n for n in (1, 2, 4, 8, 16, 32, 64, 128) if n < N // 2} | {N // 2})
problem_of = np.unique([f["id"] for f, _ in used], return_inverse=True)[1] # which problem each loop came from
n_problems = int(problem_of.max()) + 1
half_sizes = []
def split():
"""Two halves of the loops for the stability checks."""
if a.split_by == "answer": # two answers to the same problem can land in opposite halves
perm = rng.permutation(N)
h1, h2 = perm[:N // 2], perm[N // 2:]
else: # every problem's loops stay together, so the halves share no problem
first = rng.permutation(n_problems)[:n_problems // 2]
in1 = np.isin(problem_of, first)
h1, h2 = np.flatnonzero(in1), np.flatnonzero(~in1)
half_sizes.extend((len(h1), len(h2)))
return h1, h2
capstone = {}
for k, desc in RECIPES.items():
res["recipes"][k] = {"windows": desc, "layers": {}}
spec = {}
for L in LAYERS:
D, DA = torch.stack(dh[k][L]), torch.stack(da[k][L])
u = unit(D.mean(0))
score = (u @ Wd[L]) * DA.mean(0)
top_order = torch.argsort(score, descending=True)
top = sorted(top_order[:64].tolist())
halves, curve = [], {n: {"held_out_half": [], "released": [], "used": []} for n in SIZES}
for _ in range(20):
h1, h2 = split()
u2 = unit(D[h2].mean(0))
halves.append(float(unit(D[h1].mean(0)) @ u2))
for n in SIZES: # a problem split can leave fewer than N // 2 loops in h1; then the last point uses all
used_n = min(n, len(h1))
un = unit(D[rng.choice(h1, used_n, replace=False)].mean(0))
curve[n]["held_out_half"].append(float(un @ u2))
curve[n]["released"].append(float(un @ u_rel[L]))
curve[n]["used"].append(used_n)
proj = astra_tail[L] @ u
gaps = (proj[1::2] - proj[0::2]).tolist() # looping minus productive, one per pair
r = {"cos_with_released_u": float(u @ u_rel[L]),
"overlap_with_released_64": len(set(top) & set(rel[L]["cols"])),
"split_half_cos_mean_sd": [float(np.mean(halves)), float(np.std(halves))],
"data_needed": [{"answers": n, "cos_with_held_out_half": float(np.mean(c["held_out_half"])),
"cos_with_released_u": float(np.mean(c["released"])),
**({"answers_used_min_max": [min(c["used"]), max(c["used"])]} if min(c["used"]) < n
else {})} for n, c in curve.items()],
"astra_pairs_looping_minus_productive": gaps,
"gap_size": float(D.mean(0).norm()),
"top12": top_order[:12].tolist(), "cols": top,
"scores_top200": [round(float(x), 6) for x in score[top_order[:200]]]}
res["recipes"][k]["layers"][str(L)] = r
spec[str(L)] = {"alpha": a.alpha, "dir": [round(float(x), 8) for x in u], "cols": top}
print(f"recipe {k} ({desc}), layer {L}: cos with released u {r['cos_with_released_u']:+.3f}; "
f"released neurons picked {r['overlap_with_released_64']}/64; split-half cos "
f"{r['split_half_cos_mean_sd'][0]:.3f}; agent's 4 pairs, looping minus productive along u: "
f"{np.round(gaps, 2).tolist()}")
capstone[f"cap{k}-{TRY}" if TRY else f"cap{k}-a{a.alpha:g}"] = {
"about": f"Lab 8 recipe {k}: u and 64 neurons from the base model's own loops (windows {desc}), "
f"alpha {a.alpha:g}",
"layers": spec}
print(" answers needed:", [(d["answers"], round(d["cos_with_held_out_half"], 3))
for d in res["recipes"][k]["layers"]["15"]["data_needed"]])
res["cos_A_B"] = {}
for L in LAYERS:
uA, uB = (torch.tensor(spec_k["layers"][str(L)]["dir"]) for spec_k in capstone.values())
res["cos_A_B"][str(L)] = float(unit(uA) @ unit(uB))
print("cos between recipe A's u and recipe B's u:", {L: round(v, 3) for L, v in res["cos_A_B"].items()})
res["split_by"] = a.split_by
res["problems"] = n_problems
res["problems_with_two_loops"] = int((np.bincount(problem_of) > 1).sum())
res["half_sizes_min_max"] = [min(half_sizes), max(half_sizes)]
print(f"{N} loops from {n_problems} problems ({res['problems_with_two_loops']} with both answers looping); "
f"split by {a.split_by}: each half held {min(half_sizes)} to {max(half_sizes)} loops")
# 4. the onset profile: mean over loops, lined up at the loop's first token
res["onset_profile"] = {}
for q in prof:
for L in LAYERS:
P = np.stack(prof[q][L])
mean = np.nanmean(P, 0)
def avg(lo, hi):
return float(np.nanmean(mean[lo + PRE:hi + PRE]))
res["onset_profile"][f"{q}{L}"] = {"-2048..-1024": avg(-2048, -1024), "-512..-128": avg(-512, -128),
"-128..0": avg(-128, 0), "0..128": avg(0, 128), "128..256": avg(128, 256),
"mean": [round(float(x), 4) for x in mean]}
print(f"{'stream along u' if q == 'stream' else 's(t), the trigger'} at layer {L}, mean over loops: "
+ ", ".join(f"{z} {v:.2f}" for z, v in res["onset_profile"][f"{q}{L}"].items() if z != "mean"))
json.dump(res, open(os.path.join(OUT, "capstone.json"), "w"), indent=1)
json.dump(example, open(os.path.join(OUT, "example_answer.json"), "w"))
if TRY and not new_edits:
variants = plan = None
elif TRY:
variants, plan = os.path.join(OUT, "variant.json"), os.path.join(OUT, "plan.json")
json.dump(capstone, open(variants, "w"), indent=1)
json.dump({"about": f"Lab 8 experiment {TRY}: both recipes' edits on the 256 unseen practice problems",
"runs": [{"name": f"dev/{v}", "model": f"qwen35-{v}", "data": "dev256", "n": 2, "shards": 1, "est": 8}
for v in capstone]}, open(plan, "w"), indent=1)
else:
variants, plan = os.path.join(ROOT, "configs", "capstone.json"), os.path.join(ROOT, "plans", "extra.json")
json.dump(capstone, open(variants, "w"), indent=1)
# figures
import matplotlib # noqa: E402
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
def smooth(v, w=32):
v = np.asarray(v, dtype=float)
return np.convolve(v, np.ones(w) / w, mode="valid")
fig, ax = plt.subplots(2, 2, figsize=(13, 6.5), sharex=True)
xs = np.arange(-PRE, POST)
for c, L in enumerate(LAYERS):
for r_, (q, lab) in enumerate((("stream", "stream along the released u"), ("s", "s(t): the 64 neurons' push"))):
P = np.stack(prof[q][L])
lo, mid, hi = np.nanpercentile(P, [25, 50, 75], axis=0)
ax[r_, c].fill_between(xs, lo, hi, color="#0969da", alpha=0.15, label="middle half of loops")
ax[r_, c].plot(xs, mid, color="#0969da", lw=1, label="median")
ax[r_, c].axvline(0, color="black", ls="--", lw=1)
ax[r_, c].set_title(f"layer {L}: {lab}")
ax[r_, c].set_xlabel("tokens from the start of the loop")
ax[0, 0].legend(loc="upper left")
fig.suptitle(f"{N} loops from the unedited model, lined up at the token where each loop starts")
fig.tight_layout()
fig.savefig(os.path.join(FIG, "lab8_loop_onset.png"), dpi=130)
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
for c, L in enumerate(LAYERS):
for k, col in (("A", "#cf222e"), ("B", "#8250df")):
dn = res["recipes"][k]["layers"][str(L)]["data_needed"]
n = [d["answers"] for d in dn]
ax[c].plot(n, [d["cos_with_held_out_half"] for d in dn], "o-", color=col,
label=f"recipe {k} vs the other half of the answers")
ax[c].plot(n, [d["cos_with_released_u"] for d in dn], "o--", color=col, alpha=0.6,
label=f"recipe {k} vs the released u")
ax[c].axvline(4, color="#57606a", lw=0.8, ls=":")
ax[c].annotate("the agent used 4 pairs", (4, 0.05), xytext=(4, 0), textcoords="offset points", fontsize=8)
ax[c].set_xscale("log", base=2)
ax[c].set_ylim(0, 1)
ax[c].set_xlabel("answers used to build u")
ax[c].set_ylabel("cos")
ax[c].set_title(f"layer {L}: how many loops does a direction need?")
ax[0].legend(fontsize=8, loc="lower right")
fig.tight_layout()
fig.savefig(os.path.join(FIG, "lab8_data_needed.png"), dpi=130)
em = example["meta"]
fig, ax = plt.subplots(2, 1, figsize=(13, 6), sharex=True)
for r_, (q, lab) in enumerate((("stream", "stream along the released u"), ("s", "s(t): the 64 neurons' push"))):
for L, col in zip(LAYERS, ("#0969da", "#8250df")):
v = smooth(example[f"{q}{L}"])
ax[r_].plot(np.arange(len(v)) + 16, v, color=col, label=f"layer {L}")
for k, col in (("B", "#8250df"), ("A", "#cf222e"), ("loop", "#1a7f37")):
ax[r_].axvspan(*em["windows"][k], color=col, alpha=0.25)
ax[r_].axvline(em["loop_starts_at"], color="black", ls="--", lw=1)
ax[r_].set_ylabel(lab)
ax[0].legend(loc="upper left")
ax[1].set_xlabel("token position in the answer (32-token moving average)")
ax[0].set_title(f"One answer ({em['id']}): the loop starts at token {em['loop_starts_at']} and repeats every "
f"{em['period']} tokens. Shaded: window B, window A, loop window")
fig.tight_layout()
fig.savefig(os.path.join(FIG, "lab8_one_answer.png"), dpi=130)
rp = lambda p: os.path.relpath(p, ROOT) # noqa: E731
if variants is None:
print("wrote", rp(OUT), "and three figures in", rp(FIG))
print("--split-by changes only the stability checks, so the edits match the main run's (configs/capstone.json) and "
"there is nothing new to build or test (if you also changed the code, run again without --split-by)")
sys.exit(0)
print("wrote", rp(OUT), rp(variants), "and three figures in", rp(FIG))
print("next, build both edits and test them on the 256 unseen practice problems (one GPU per run):")
print(f" CUDA_VISIBLE_DEVICES=\"\" python scripts/build_models.py --variants {rp(variants)}")
print(f" python scripts/run_jobs.py {rp(plan)} --gpus 6,7 --only " + " ".join(f"dev/{v}" for v in capstone))
|