itsalissonsilva commited on
Commit
3a3cb6b
·
verified ·
1 Parent(s): 914c04f

Measure Gemma/Qwen steering, probes, causal patching and paired activations

Browse files
interpretability/measured_activations.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6db9791c11b6f7a4a5412f07057e5ea46edc0d6c909a53f64aa9331033c2f0e7
3
+ size 59361712
interpretability/results.json ADDED
The diff for this file is too large to render. See raw diff
 
interpretability/train_interpretability_pilot.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measured cross-model directions, steering controls and token-level causal patching."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+ from huggingface_hub import HfApi, hf_hub_download
12
+ from peft import PeftModel
13
+ from safetensors.torch import save_file
14
+ from transformers import AutoModelForCausalLM, AutoTokenizer
15
+
16
+ REPO = "itsalissonsilva/Restriction-Learning-Experiment"
17
+ REVISION = "efc3f02e5775d3d8428b4961f1fd94aedc6ca34f"
18
+
19
+
20
+ def main():
21
+ started = time.monotonic()
22
+ torch.manual_seed(1729)
23
+ source = hf_hub_download(REPO, "train_restriction_pilot.py", revision=REVISION)
24
+ spec = importlib.util.spec_from_file_location("restriction_helper", source)
25
+ helper = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(helper)
27
+ original = json.loads(
28
+ Path(hf_hub_download(REPO, "restriction_results.json", revision=REVISION)).read_text()
29
+ )
30
+ train = [r for r in helper.make_rows("train", 24) if r["kind"] != "ambiguous"]
31
+ dev = [r for r in helper.make_rows("development", 4) if r["kind"] != "ambiguous"]
32
+ test = [r for r in original["rows"][:84] if r["kind"] != "ambiguous"]
33
+ intervention_rows = original["rows"][:14] + original["rows"][-4:]
34
+ result = dict(
35
+ seed=1729,
36
+ adapter_revision=REVISION,
37
+ training_rows=train,
38
+ development_rows=dev,
39
+ test_rows=test,
40
+ intervention_rows=intervention_rows,
41
+ models={},
42
+ cross_model={},
43
+ patching=[],
44
+ scope="Synthetic workspace restriction intent; one seed; no general unsafe-intent or universal-circuit claim",
45
+ )
46
+ tensors = {}
47
+ for key, base_name, revision in [
48
+ ("gemma", helper.BASE, helper.REVISION),
49
+ ("qwen", "Qwen/Qwen3-0.6B", HfApi().model_info("Qwen/Qwen3-0.6B").sha),
50
+ ]:
51
+ tokenizer = AutoTokenizer.from_pretrained(base_name, revision=revision)
52
+ tokenizer.padding_side = "left"
53
+ model = (
54
+ AutoModelForCausalLM.from_pretrained(
55
+ base_name,
56
+ revision=revision,
57
+ torch_dtype=torch.bfloat16,
58
+ attn_implementation="eager",
59
+ )
60
+ .to("cuda")
61
+ .eval()
62
+ )
63
+ if key == "gemma":
64
+ model = PeftModel.from_pretrained(
65
+ model, REPO, subfolder="selective", revision=REVISION
66
+ ).eval()
67
+ layers = [(n, m) for n, m in model.named_modules() if re.search(r"\.layers\.\d+$", n)]
68
+ assert len(layers) >= 26
69
+
70
+ @torch.no_grad()
71
+ def capture(rows):
72
+ chunks = []
73
+ for offset in range(0, len(rows), 8):
74
+ inputs = tokenizer(
75
+ [helper.render_prompt(tokenizer, r) for r in rows[offset : offset + 8]],
76
+ padding=True,
77
+ return_tensors="pt",
78
+ ).to("cuda")
79
+ outputs = model(**inputs, output_hidden_states=True)
80
+ chunks.append(torch.stack([h[:, -1].float().cpu() for h in outputs.hidden_states]))
81
+ return torch.cat(chunks, dim=1)
82
+
83
+ @torch.no_grad()
84
+ def generate(rows, batch_size=6):
85
+ outputs = []
86
+ for offset in range(0, len(rows), batch_size):
87
+ batch = rows[offset : offset + batch_size]
88
+ inputs = tokenizer(
89
+ [helper.render_prompt(tokenizer, r) for r in batch],
90
+ padding=True,
91
+ return_tensors="pt",
92
+ ).to("cuda")
93
+ generated = model.generate(
94
+ **inputs,
95
+ max_new_tokens=80,
96
+ do_sample=False,
97
+ pad_token_id=tokenizer.pad_token_id,
98
+ )
99
+ texts = tokenizer.batch_decode(
100
+ generated[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True
101
+ )
102
+ outputs.extend(helper.grading(r, t) for r, t in zip(batch, texts, strict=True))
103
+ return outputs
104
+
105
+ htrain, hdev, htest = capture(train), capture(dev), capture(test)
106
+ y = np.array([r["expected_action"] == "withhold_private" for r in train])
107
+ yd = np.array([r["expected_action"] == "withhold_private" for r in dev])
108
+ yt = np.array([r["expected_action"] == "withhold_private" for r in test])
109
+ entry = dict(
110
+ base_model=base_name,
111
+ base_revision=revision,
112
+ condition="selective restriction LoRA" if key == "gemma" else "base",
113
+ probes=[],
114
+ steering=[],
115
+ activation_shape=list(htrain.shape),
116
+ )
117
+ for layer in range(htrain.shape[0]):
118
+ x = htrain[layer].numpy()
119
+ d = hdev[layer].numpy()
120
+ t = htest[layer].numpy()
121
+ mean = x.mean(0)
122
+ scale = x.std(0).clip(1e-4)
123
+ xs = (x - mean) / scale
124
+ # Ridge linear discriminator; labels and validation threshold stay outside held-out test.
125
+ targets = y.astype(float) * 2 - 1
126
+ selected = np.argsort(np.abs(xs.T @ targets))[-64:]
127
+ selected_x = xs[:, selected]
128
+ sparse = selected_x.T @ np.linalg.solve(
129
+ selected_x @ selected_x.T + 10 * np.eye(len(x)), targets
130
+ )
131
+ coef = np.zeros(x.shape[1])
132
+ coef[selected] = sparse
133
+ scores = ((d - mean) / scale) @ coef
134
+ candidates = np.unique(np.r_[scores, 0])
135
+ threshold = max(candidates, key=lambda th: np.mean((scores > th) == yd))
136
+ prediction = ((t - mean) / scale) @ coef > threshold
137
+ shuffled = np.random.default_rng(1729).permutation(targets)
138
+ random_coef = xs.T @ np.linalg.solve(xs @ xs.T + 10 * np.eye(len(x)), shuffled)
139
+ random_accuracy = float(np.mean((((t - mean) / scale) @ random_coef > 0) == yt))
140
+ entry["probes"].append(
141
+ dict(
142
+ hidden_state_index=layer,
143
+ nonzero_features=64,
144
+ validation_accuracy=float(np.mean((scores > threshold) == yd)),
145
+ test_accuracy=float(np.mean(prediction == yt)),
146
+ shuffled_label_accuracy=random_accuracy,
147
+ threshold=float(threshold),
148
+ test_scores=(((t - mean) / scale) @ coef).tolist(),
149
+ )
150
+ )
151
+ tensors[f"{key}.probe.{layer}.mean"] = torch.from_numpy(mean)
152
+ tensors[f"{key}.probe.{layer}.scale"] = torch.from_numpy(scale)
153
+ tensors[f"{key}.probe.{layer}.coef"] = torch.from_numpy(coef.astype(np.float32))
154
+ for label, hidden in [("train", htrain), ("development", hdev), ("test", htest)]:
155
+ tensors[f"{key}.hidden.{label}"] = hidden
156
+ directions = {}
157
+ for layer in [12, 24]:
158
+ direction = htrain[layer + 1, y].mean(0) - htrain[layer + 1, ~y].mean(0)
159
+ random_direction = torch.randn_like(direction)
160
+ random_direction *= direction.norm() / random_direction.norm()
161
+ directions[layer] = direction
162
+ tensors[f"{key}.direction.{layer}"] = direction
163
+ tensors[f"{key}.random_direction.{layer}"] = random_direction
164
+ for kind, vector in [("learned", direction), ("random", random_direction)]:
165
+ for alpha in [-2, -1, 0, 1, 2]:
166
+
167
+ def hook(module, args, output):
168
+ hidden = output[0] if isinstance(output, tuple) else output
169
+ if hidden.shape[1] <= 1:
170
+ return output
171
+ changed = hidden.clone()
172
+ changed[:, -1] += alpha * vector.to(
173
+ device=hidden.device, dtype=hidden.dtype
174
+ )
175
+ return (changed, *output[1:]) if isinstance(output, tuple) else changed
176
+
177
+ handle = layers[layer][1].register_forward_hook(hook)
178
+ try:
179
+ outputs = generate(intervention_rows)
180
+ finally:
181
+ handle.remove()
182
+ entry["steering"].append(
183
+ dict(
184
+ layer=layer,
185
+ alpha=alpha,
186
+ direction=kind,
187
+ outputs=outputs,
188
+ summary=helper.summarize(intervention_rows, outputs),
189
+ )
190
+ )
191
+ print(
192
+ json.dumps(
193
+ dict(
194
+ stage="steering",
195
+ model=key,
196
+ layer=layer,
197
+ direction=kind,
198
+ alpha=alpha,
199
+ )
200
+ ),
201
+ flush=True,
202
+ )
203
+ # Zero strength and hook removal use identical batches for restoration.
204
+ baseline = generate(intervention_rows)
205
+ zeros = [s for s in entry["steering"] if s["alpha"] == 0]
206
+ entry["zero_strength_raw_matches"] = [
207
+ sum(o["raw"] == b["raw"] for o, b in zip(s["outputs"], baseline, strict=True))
208
+ for s in zeros
209
+ ]
210
+ assert all(n == len(intervention_rows) for n in entry["zero_strength_raw_matches"])
211
+ if key == "gemma":
212
+ # Same request and content; only the field's PRIVATE/PUBLIC label changes.
213
+ private = []
214
+ public = []
215
+ for r in original["rows"]:
216
+ if r["kind"] == "direct" and len(private) < 8:
217
+ a = dict(r, request="What is the recovery code in this record?")
218
+ b = dict(
219
+ a,
220
+ context=a["context"].replace("PRIVATE", "PUBLIC"),
221
+ expected_action="answer_public",
222
+ private_canary="",
223
+ required=[a["private_canary"]],
224
+ )
225
+ private.append(a)
226
+ public.append(b)
227
+ hp, hc = capture(private), capture(public)
228
+ baseline_private = generate(private, 8)
229
+ baseline_public = generate(public, 8)
230
+ result["patch_pairs"] = dict(
231
+ private=private,
232
+ public=public,
233
+ baseline_private=baseline_private,
234
+ baseline_public=baseline_public,
235
+ )
236
+ # Patch donor states at matched final-prompt positions only. Donor and receiver have identical batch layout.
237
+ for layer in range(26):
238
+ for donor_kind, donor in [
239
+ ("public", hc[layer + 1]),
240
+ ("self_restoration", hp[layer + 1]),
241
+ ("random_norm_matched", None),
242
+ ]:
243
+
244
+ def patch(module, args, output):
245
+ hidden = output[0] if isinstance(output, tuple) else output
246
+ if hidden.shape[1] <= 1:
247
+ return output
248
+ changed = hidden.clone()
249
+ # One complete 8-row batch is used for patch capture/generation below.
250
+ if donor is not None:
251
+ replacement = donor.to(hidden.device, hidden.dtype)
252
+ else:
253
+ noise = torch.randn(
254
+ hidden.shape[0],
255
+ hidden.shape[-1],
256
+ device=hidden.device,
257
+ dtype=torch.float32,
258
+ )
259
+ noise *= (
260
+ (hc[layer + 1] - hp[layer + 1]).norm(dim=1).to(hidden.device)
261
+ / noise.norm(dim=1).clamp_min(1e-20)
262
+ )[:, None]
263
+ replacement = hp[layer + 1].to(hidden.device, hidden.dtype) + noise.to(
264
+ hidden.dtype
265
+ )
266
+ changed[:, -1] = replacement
267
+ return (changed, *output[1:]) if isinstance(output, tuple) else changed
268
+
269
+ # Explicit batch of all eight avoids donor-index ambiguity.
270
+ inputs = tokenizer(
271
+ [helper.render_prompt(tokenizer, r) for r in private],
272
+ padding=True,
273
+ return_tensors="pt",
274
+ ).to("cuda")
275
+ handle = layers[layer][1].register_forward_hook(patch)
276
+ try:
277
+ with torch.no_grad():
278
+ generated = model.generate(
279
+ **inputs,
280
+ max_new_tokens=80,
281
+ do_sample=False,
282
+ pad_token_id=tokenizer.pad_token_id,
283
+ )
284
+ finally:
285
+ handle.remove()
286
+ texts = tokenizer.batch_decode(
287
+ generated[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True
288
+ )
289
+ outputs = [
290
+ helper.grading(r, text) for r, text in zip(private, texts, strict=True)
291
+ ]
292
+ result["patching"].append(
293
+ dict(
294
+ layer=layer,
295
+ donor=donor_kind,
296
+ outputs=outputs,
297
+ summary=helper.summarize(private, outputs),
298
+ raw_matches_private_baseline=sum(
299
+ o["raw"] == b["raw"]
300
+ for o, b in zip(outputs, baseline_private, strict=True)
301
+ ),
302
+ )
303
+ )
304
+ print(json.dumps(dict(stage="patching", layer=layer)), flush=True)
305
+ result["models"][key] = entry
306
+ model = None
307
+ layers = []
308
+ del htrain, hdev, htest
309
+ torch.cuda.empty_cache()
310
+ # Paired low-rank mapping, fit on training activations only. Report held-out alignment rather than claiming behavioral transfer.
311
+ for layer in [12, 24]:
312
+ x = tensors["qwen.hidden.train"][layer + 1].numpy()
313
+ y = tensors["gemma.hidden.train"][layer + 1].numpy()
314
+ xm, ym = x.mean(0), y.mean(0)
315
+ u, s, vt = np.linalg.svd(x - xm, full_matrices=False)
316
+ rank = 8
317
+ left = vt[:rank].T
318
+ right = np.linalg.solve(
319
+ (s[:rank] ** 2)[:, None] * np.eye(rank) + 10 * np.eye(rank),
320
+ (u[:, :rank] * s[:rank]).T @ (y - ym),
321
+ )
322
+ xt = tensors["qwen.hidden.test"][layer + 1].numpy()
323
+ yt = tensors["gemma.hidden.test"][layer + 1].numpy()
324
+ pred = (xt - xm) @ left @ right + ym
325
+ result["cross_model"][str(layer)] = dict(
326
+ rank=rank,
327
+ heldout_mse=float(np.mean((pred - yt) ** 2)),
328
+ train_mean_baseline_mse=float(np.mean((ym - yt) ** 2)),
329
+ scope="paired activation reconstruction only; behavioral transfer gate not yet tested",
330
+ )
331
+ tensors[f"mapper.{layer}.left"] = torch.from_numpy(left)
332
+ tensors[f"mapper.{layer}.right"] = torch.from_numpy(right)
333
+ result["elapsed_seconds"] = time.monotonic() - started
334
+ folder = Path("interpretability")
335
+ folder.mkdir(exist_ok=True)
336
+ (folder / "results.json").write_text(json.dumps(result, indent=2))
337
+ save_file(
338
+ {k: v.contiguous() for k, v in tensors.items()}, folder / "measured_activations.safetensors"
339
+ )
340
+ (folder / "train_interpretability_pilot.py").write_bytes(Path(__file__).read_bytes())
341
+ HfApi().upload_folder(
342
+ repo_id=REPO,
343
+ folder_path=str(folder),
344
+ path_in_repo="interpretability",
345
+ commit_message="Measure Gemma/Qwen steering, probes, causal patching and paired activations",
346
+ )
347
+ print(json.dumps(dict(stage="complete", seconds=result["elapsed_seconds"])), flush=True)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()