Restriction-Learning-Experiment / interpretability /train_interpretability_pilot.py
itsalissonsilva's picture
Measure Gemma/Qwen steering, probes, causal patching and paired activations
3a3cb6b verified
Raw History Blame Contribute Delete
16.2 kB
"""Measured cross-model directions, steering controls and token-level causal patching."""
import importlib.util
import json
import re
import time
from pathlib import Path
import numpy as np
import torch
from huggingface_hub import HfApi, hf_hub_download
from peft import PeftModel
from safetensors.torch import save_file
from transformers import AutoModelForCausalLM, AutoTokenizer
REPO = "itsalissonsilva/Restriction-Learning-Experiment"
REVISION = "efc3f02e5775d3d8428b4961f1fd94aedc6ca34f"
def main():
started = time.monotonic()
torch.manual_seed(1729)
source = hf_hub_download(REPO, "train_restriction_pilot.py", revision=REVISION)
spec = importlib.util.spec_from_file_location("restriction_helper", source)
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
original = json.loads(
Path(hf_hub_download(REPO, "restriction_results.json", revision=REVISION)).read_text()
)
train = [r for r in helper.make_rows("train", 24) if r["kind"] != "ambiguous"]
dev = [r for r in helper.make_rows("development", 4) if r["kind"] != "ambiguous"]
test = [r for r in original["rows"][:84] if r["kind"] != "ambiguous"]
intervention_rows = original["rows"][:14] + original["rows"][-4:]
result = dict(
seed=1729,
adapter_revision=REVISION,
training_rows=train,
development_rows=dev,
test_rows=test,
intervention_rows=intervention_rows,
models={},
cross_model={},
patching=[],
scope="Synthetic workspace restriction intent; one seed; no general unsafe-intent or universal-circuit claim",
)
tensors = {}
for key, base_name, revision in [
("gemma", helper.BASE, helper.REVISION),
("qwen", "Qwen/Qwen3-0.6B", HfApi().model_info("Qwen/Qwen3-0.6B").sha),
]:
tokenizer = AutoTokenizer.from_pretrained(base_name, revision=revision)
tokenizer.padding_side = "left"
model = (
AutoModelForCausalLM.from_pretrained(
base_name,
revision=revision,
torch_dtype=torch.bfloat16,
attn_implementation="eager",
)
.to("cuda")
.eval()
)
if key == "gemma":
model = PeftModel.from_pretrained(
model, REPO, subfolder="selective", revision=REVISION
).eval()
layers = [(n, m) for n, m in model.named_modules() if re.search(r"\.layers\.\d+$", n)]
assert len(layers) >= 26
@torch.no_grad()
def capture(rows):
chunks = []
for offset in range(0, len(rows), 8):
inputs = tokenizer(
[helper.render_prompt(tokenizer, r) for r in rows[offset : offset + 8]],
padding=True,
return_tensors="pt",
).to("cuda")
outputs = model(**inputs, output_hidden_states=True)
chunks.append(torch.stack([h[:, -1].float().cpu() for h in outputs.hidden_states]))
return torch.cat(chunks, dim=1)
@torch.no_grad()
def generate(rows, batch_size=6):
outputs = []
for offset in range(0, len(rows), batch_size):
batch = rows[offset : offset + batch_size]
inputs = tokenizer(
[helper.render_prompt(tokenizer, r) for r in batch],
padding=True,
return_tensors="pt",
).to("cuda")
generated = model.generate(
**inputs,
max_new_tokens=80,
do_sample=False,
pad_token_id=tokenizer.pad_token_id,
)
texts = tokenizer.batch_decode(
generated[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True
)
outputs.extend(helper.grading(r, t) for r, t in zip(batch, texts, strict=True))
return outputs
htrain, hdev, htest = capture(train), capture(dev), capture(test)
y = np.array([r["expected_action"] == "withhold_private" for r in train])
yd = np.array([r["expected_action"] == "withhold_private" for r in dev])
yt = np.array([r["expected_action"] == "withhold_private" for r in test])
entry = dict(
base_model=base_name,
base_revision=revision,
condition="selective restriction LoRA" if key == "gemma" else "base",
probes=[],
steering=[],
activation_shape=list(htrain.shape),
)
for layer in range(htrain.shape[0]):
x = htrain[layer].numpy()
d = hdev[layer].numpy()
t = htest[layer].numpy()
mean = x.mean(0)
scale = x.std(0).clip(1e-4)
xs = (x - mean) / scale
# Ridge linear discriminator; labels and validation threshold stay outside held-out test.
targets = y.astype(float) * 2 - 1
selected = np.argsort(np.abs(xs.T @ targets))[-64:]
selected_x = xs[:, selected]
sparse = selected_x.T @ np.linalg.solve(
selected_x @ selected_x.T + 10 * np.eye(len(x)), targets
)
coef = np.zeros(x.shape[1])
coef[selected] = sparse
scores = ((d - mean) / scale) @ coef
candidates = np.unique(np.r_[scores, 0])
threshold = max(candidates, key=lambda th: np.mean((scores > th) == yd))
prediction = ((t - mean) / scale) @ coef > threshold
shuffled = np.random.default_rng(1729).permutation(targets)
random_coef = xs.T @ np.linalg.solve(xs @ xs.T + 10 * np.eye(len(x)), shuffled)
random_accuracy = float(np.mean((((t - mean) / scale) @ random_coef > 0) == yt))
entry["probes"].append(
dict(
hidden_state_index=layer,
nonzero_features=64,
validation_accuracy=float(np.mean((scores > threshold) == yd)),
test_accuracy=float(np.mean(prediction == yt)),
shuffled_label_accuracy=random_accuracy,
threshold=float(threshold),
test_scores=(((t - mean) / scale) @ coef).tolist(),
)
)
tensors[f"{key}.probe.{layer}.mean"] = torch.from_numpy(mean)
tensors[f"{key}.probe.{layer}.scale"] = torch.from_numpy(scale)
tensors[f"{key}.probe.{layer}.coef"] = torch.from_numpy(coef.astype(np.float32))
for label, hidden in [("train", htrain), ("development", hdev), ("test", htest)]:
tensors[f"{key}.hidden.{label}"] = hidden
directions = {}
for layer in [12, 24]:
direction = htrain[layer + 1, y].mean(0) - htrain[layer + 1, ~y].mean(0)
random_direction = torch.randn_like(direction)
random_direction *= direction.norm() / random_direction.norm()
directions[layer] = direction
tensors[f"{key}.direction.{layer}"] = direction
tensors[f"{key}.random_direction.{layer}"] = random_direction
for kind, vector in [("learned", direction), ("random", random_direction)]:
for alpha in [-2, -1, 0, 1, 2]:
def hook(module, args, output):
hidden = output[0] if isinstance(output, tuple) else output
if hidden.shape[1] <= 1:
return output
changed = hidden.clone()
changed[:, -1] += alpha * vector.to(
device=hidden.device, dtype=hidden.dtype
)
return (changed, *output[1:]) if isinstance(output, tuple) else changed
handle = layers[layer][1].register_forward_hook(hook)
try:
outputs = generate(intervention_rows)
finally:
handle.remove()
entry["steering"].append(
dict(
layer=layer,
alpha=alpha,
direction=kind,
outputs=outputs,
summary=helper.summarize(intervention_rows, outputs),
)
)
print(
json.dumps(
dict(
stage="steering",
model=key,
layer=layer,
direction=kind,
alpha=alpha,
)
),
flush=True,
)
# Zero strength and hook removal use identical batches for restoration.
baseline = generate(intervention_rows)
zeros = [s for s in entry["steering"] if s["alpha"] == 0]
entry["zero_strength_raw_matches"] = [
sum(o["raw"] == b["raw"] for o, b in zip(s["outputs"], baseline, strict=True))
for s in zeros
]
assert all(n == len(intervention_rows) for n in entry["zero_strength_raw_matches"])
if key == "gemma":
# Same request and content; only the field's PRIVATE/PUBLIC label changes.
private = []
public = []
for r in original["rows"]:
if r["kind"] == "direct" and len(private) < 8:
a = dict(r, request="What is the recovery code in this record?")
b = dict(
a,
context=a["context"].replace("PRIVATE", "PUBLIC"),
expected_action="answer_public",
private_canary="",
required=[a["private_canary"]],
)
private.append(a)
public.append(b)
hp, hc = capture(private), capture(public)
baseline_private = generate(private, 8)
baseline_public = generate(public, 8)
result["patch_pairs"] = dict(
private=private,
public=public,
baseline_private=baseline_private,
baseline_public=baseline_public,
)
# Patch donor states at matched final-prompt positions only. Donor and receiver have identical batch layout.
for layer in range(26):
for donor_kind, donor in [
("public", hc[layer + 1]),
("self_restoration", hp[layer + 1]),
("random_norm_matched", None),
]:
def patch(module, args, output):
hidden = output[0] if isinstance(output, tuple) else output
if hidden.shape[1] <= 1:
return output
changed = hidden.clone()
# One complete 8-row batch is used for patch capture/generation below.
if donor is not None:
replacement = donor.to(hidden.device, hidden.dtype)
else:
noise = torch.randn(
hidden.shape[0],
hidden.shape[-1],
device=hidden.device,
dtype=torch.float32,
)
noise *= (
(hc[layer + 1] - hp[layer + 1]).norm(dim=1).to(hidden.device)
/ noise.norm(dim=1).clamp_min(1e-20)
)[:, None]
replacement = hp[layer + 1].to(hidden.device, hidden.dtype) + noise.to(
hidden.dtype
)
changed[:, -1] = replacement
return (changed, *output[1:]) if isinstance(output, tuple) else changed
# Explicit batch of all eight avoids donor-index ambiguity.
inputs = tokenizer(
[helper.render_prompt(tokenizer, r) for r in private],
padding=True,
return_tensors="pt",
).to("cuda")
handle = layers[layer][1].register_forward_hook(patch)
try:
with torch.no_grad():
generated = model.generate(
**inputs,
max_new_tokens=80,
do_sample=False,
pad_token_id=tokenizer.pad_token_id,
)
finally:
handle.remove()
texts = tokenizer.batch_decode(
generated[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True
)
outputs = [
helper.grading(r, text) for r, text in zip(private, texts, strict=True)
]
result["patching"].append(
dict(
layer=layer,
donor=donor_kind,
outputs=outputs,
summary=helper.summarize(private, outputs),
raw_matches_private_baseline=sum(
o["raw"] == b["raw"]
for o, b in zip(outputs, baseline_private, strict=True)
),
)
)
print(json.dumps(dict(stage="patching", layer=layer)), flush=True)
result["models"][key] = entry
model = None
layers = []
del htrain, hdev, htest
torch.cuda.empty_cache()
# Paired low-rank mapping, fit on training activations only. Report held-out alignment rather than claiming behavioral transfer.
for layer in [12, 24]:
x = tensors["qwen.hidden.train"][layer + 1].numpy()
y = tensors["gemma.hidden.train"][layer + 1].numpy()
xm, ym = x.mean(0), y.mean(0)
u, s, vt = np.linalg.svd(x - xm, full_matrices=False)
rank = 8
left = vt[:rank].T
right = np.linalg.solve(
(s[:rank] ** 2)[:, None] * np.eye(rank) + 10 * np.eye(rank),
(u[:, :rank] * s[:rank]).T @ (y - ym),
)
xt = tensors["qwen.hidden.test"][layer + 1].numpy()
yt = tensors["gemma.hidden.test"][layer + 1].numpy()
pred = (xt - xm) @ left @ right + ym
result["cross_model"][str(layer)] = dict(
rank=rank,
heldout_mse=float(np.mean((pred - yt) ** 2)),
train_mean_baseline_mse=float(np.mean((ym - yt) ** 2)),
scope="paired activation reconstruction only; behavioral transfer gate not yet tested",
)
tensors[f"mapper.{layer}.left"] = torch.from_numpy(left)
tensors[f"mapper.{layer}.right"] = torch.from_numpy(right)
result["elapsed_seconds"] = time.monotonic() - started
folder = Path("interpretability")
folder.mkdir(exist_ok=True)
(folder / "results.json").write_text(json.dumps(result, indent=2))
save_file(
{k: v.contiguous() for k, v in tensors.items()}, folder / "measured_activations.safetensors"
)
(folder / "train_interpretability_pilot.py").write_bytes(Path(__file__).read_bytes())
HfApi().upload_folder(
repo_id=REPO,
folder_path=str(folder),
path_in_repo="interpretability",
commit_message="Measure Gemma/Qwen steering, probes, causal patching and paired activations",
)
print(json.dumps(dict(stage="complete", seconds=result["elapsed_seconds"])), flush=True)
if __name__ == "__main__":
main()