Linear probes: leakage-safe splits, an obfuscation check and a causal check¶
A linear probe asks whether a property can be read off one layer by a linear classifier: extract the layer's activation for every token, label each token, train a logistic regression, and score it on held-out data. A probe score is a claim about what the layer carries, and three things undermine it: a split that leaks the test set into training, a perturbation that hides the property from the probe while the model behaves the same, and a direction the model does not use. This recipe trains the probe with scikit-learn and covers each of those, then streams the probe's score over every token of a generation. TorchLens supplies the activations, the steering and the per-token scores; scikit-learn supplies the probe and the metrics.
Minimal example on the running model¶
This page uses the decoder on the IOI prompt. A probe reads one layer's activation per token: here a logistic regression on block 1's output learns whether each token's id is even. The recipe below builds its own model and data for the full method.
# recipe-test: tool=sklearn expected=True
import torch
import transformers
import torchlens as tl
PROMPT = "When Mary and John went to the store, John gave a drink to"
words = PROMPT.replace(",", " ,").split()
vocab = sorted(set(words))
x = torch.tensor([[vocab.index(word) for word in words]])
torch.manual_seed(0)
config = transformers.GPT2Config(
vocab_size=len(vocab),
n_positions=32,
n_embd=64,
n_layer=2,
n_head=4,
use_cache=False,
attn_implementation="eager",
)
model = transformers.GPT2LMHeadModel(config).eval()
trace = tl.trace(model, x)
from sklearn.linear_model import LogisticRegression
features = trace["transformer.h.1"].out[0].detach().numpy()
labels = (x[0] % 2 == 0).numpy()
probe = LogisticRegression(max_iter=1000).fit(features, labels)
RESULT = probe.coef_.shape == (1, 64)
Real use: a trained model and a concept you label¶
With a trained model, load it with from_pretrained, label each token or each
episode with the concept from data you trust (a truthfulness label, a flagged
episode, a refusal), and keep the rest of the page. This is not run here:
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("gpt2").eval()
SITE = "transformer.h.6.ln_1" # the residual stream entering block 6
The rest of the page runs on a two-layer GPT-2 with random weights, built from a config with no download, so it shows the mechanics only. A random network has learned no concepts, so the probe reads one it carries anyway: whether the current token's id is even, which the residual stream holds because it contains the token's embedding. With a trained model and a real concept, expect lower and noisier scores, and expect the causal check to be able to fail.
1. Extract one activation per token¶
Every row is one token: its activation at SITE, its label, and the id of the
prompt it came from. SITE is the first layer norm of block 1, the normalised
residual stream entering that block; a module whose output is a tensor, not a
block, whose output is a tuple. One tl.extract call reads the whole batch of
prompts, which here use 32 token ids, 16 of them even.
# recipe-test: tool=sklearn expected=((384, 64), 48, True)
import numpy as np
import torch
from transformers import GPT2Config, GPT2LMHeadModel
import torchlens as tl
torch.manual_seed(0)
config = GPT2Config(
n_layer=2,
n_head=2,
n_embd=64,
vocab_size=512,
n_positions=64,
bos_token_id=0,
eos_token_id=0,
attn_implementation="eager",
use_cache=False,
)
model = GPT2LMHeadModel(config).eval().requires_grad_(False)
SITE = "transformer.h.1.ln_1"
N_PROMPTS, LENGTH = 48, 8
generator = torch.Generator().manual_seed(0)
prompts = torch.randint(100, 132, (N_PROMPTS, LENGTH), generator=generator)
acts = tl.extract(model, prompts, {"act": SITE})["act"] # [prompt, position, width]
X = acts.reshape(-1, config.n_embd).numpy()
tokens = prompts.reshape(-1).numpy()
y = (tokens % 2 == 0).astype(int) # the concept: the current token's id is even
prompt_id = np.repeat(np.arange(N_PROMPTS), LENGTH)
RESULT = (X.shape, len(set(prompt_id)), bool(0.4 < y.mean() < 0.6))
2. Train with scikit-learn and report the standard metrics¶
Hold out whole prompts, train a seeded LogisticRegression on the rest, and
report the five metrics a probe result should carry. Accuracy and the F-score read the
thresholded prediction; ROC AUC and average precision read the score and do not
depend on the threshold. Token identity is trivially readable from a random
network's residual stream, so every metric is 1.0 here; a real concept is not.
# recipe-test: tool=sklearn expected=True
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score,
average_precision_score,
balanced_accuracy_score,
f1_score,
roc_auc_score,
)
from sklearn.model_selection import GroupShuffleSplit
split = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=0)
train, test = next(split.split(X, y, groups=prompt_id))
clf = LogisticRegression(max_iter=1000, random_state=0).fit(X[train], y[train])
pred, score = clf.predict(X[test]), clf.decision_function(X[test])
metrics = {
"accuracy": accuracy_score(y[test], pred),
"balanced_accuracy": balanced_accuracy_score(y[test], pred),
"f1": f1_score(y[test], pred),
"roc_auc": roc_auc_score(y[test], score),
"average_precision": average_precision_score(y[test], score),
}
print({name: round(value, 3) for name, value in metrics.items()})
RESULT = all(value > 0.9 for value in metrics.values())
3. Leakage-safe splits¶
The rule: never put tokens of one prompt or episode in both train and test, and
treat repeated copies of a prompt as one prompt. Tokens of a prompt share its
context, and a repeated prompt yields identical rows, so a token-level split
lets the probe memorise prompts instead of learning the concept. The classic
case is a dataset where each prompt was logged several times (resampled
episodes). Below, twelve prompts are logged four times each and carry an
episode label that is a coin flip per prompt, so there is nothing to learn: the
honest score is chance. A token-level KFold reports about 0.8 anyway, because
every test row has an exact copy in training; GroupKFold by prompt id reports
chance. On the real concept the two splits agree,
because that signal does not depend on seeing the prompt.
# recipe-test: tool=sklearn expected=(True, True, True)
from sklearn.model_selection import GroupKFold, KFold, cross_val_score
def cv_accuracy(features, labels, cv, groups=None):
probe = LogisticRegression(max_iter=1000, random_state=0)
scores = cross_val_score(probe, features, labels, cv=cv, groups=groups)
return scores.mean()
token_split = KFold(n_splits=4, shuffle=True, random_state=0)
prompt_split = GroupKFold(n_splits=4)
logged = np.tile(np.flatnonzero(prompt_id < 12), 4) # each of 12 prompts, four times
coin = np.random.default_rng(0).permutation([0, 1] * 6) # one flip per prompt
X_log, groups_log = X[logged], prompt_id[logged]
y_log = coin[groups_log]
leaky = cv_accuracy(X_log, y_log, token_split)
honest = cv_accuracy(X_log, y_log, prompt_split, groups_log)
real_leaky = cv_accuracy(X, y, token_split)
real_honest = cv_accuracy(X, y, prompt_split, prompt_id)
print(round(leaky, 2), round(honest, 2), round(real_leaky, 2), round(real_honest, 2))
RESULT = (leaky > 0.75, honest < 0.6, abs(real_leaky - real_honest) < 0.05)
4. The few-positive case¶
A concept with few positives, here "the token is one of two ids" (about 5
percent of tokens), breaks accuracy as a metric: a probe that never fires is
right about 95 percent of the time. Weight the classes with
class_weight="balanced", stratify the folds so every fold holds positives
(StratifiedGroupKFold keeps the prompt grouping), and report precision, recall
and average precision, whose chance level is the positive rate, not one half.
# recipe-test: tool=sklearn expected=(True, True, True)
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import StratifiedGroupKFold, cross_val_predict
y_rare = np.isin(tokens, (105, 112)).astype(int)
rare_split = StratifiedGroupKFold(n_splits=4, shuffle=True, random_state=0)
weighted = LogisticRegression(class_weight="balanced", max_iter=1000, random_state=0)
rare_score = cross_val_predict(
weighted, X, y_rare, cv=rare_split, groups=prompt_id, method="decision_function"
)
rare_pred = (rare_score > 0).astype(int)
never_fires = np.zeros_like(y_rare)
report = {
"positive_rate": y_rare.mean(),
"never_fires_accuracy": accuracy_score(y_rare, never_fires),
"never_fires_recall": recall_score(y_rare, never_fires),
"precision": precision_score(y_rare, rare_pred),
"recall": recall_score(y_rare, rare_pred),
"average_precision": average_precision_score(y_rare, rare_score),
}
print({name: round(value, 3) for name, value in report.items()})
RESULT = (
report["never_fires_accuracy"] > 0.9 and report["never_fires_recall"] == 0.0,
report["recall"] > 0.8,
report["average_precision"] > 5 * report["positive_rate"],
)
5. Obfuscation check¶
The obfuscated-activations attack (Bailey et al., 2024) asks whether an input
can hide the concept from the probe while the model's behaviour stays the same.
Optimise a small perturbation of the input embeddings that pushes every flagged
token's probe score below the threshold, while a KL penalty keeps the
next-token distribution close to the clean one. One bound spec does both
halves: a rule at transformer.wte adds the perturbation, and a rule at SITE
reads the activation the probe sees, gradients included.
# recipe-test: tool=sklearn expected=(True, True, True)
import torch.nn.functional as F
w = torch.tensor(clf.coef_[0], dtype=torch.float32)
b = float(clf.intercept_[0])
held_out = np.unique(prompt_id[test])
victims = prompts[torch.as_tensor(held_out[:4])]
delta = torch.zeros(len(victims), LENGTH, config.n_embd, requires_grad=True)
seen = {}
def perturb(activation, *, ctx):
return activation + delta
def read_site(activation, *, ctx):
seen["act"] = activation
return activation
attacked = (
tl.when(tl.module("transformer.wte"), perturb)
.merge(tl.when(tl.module(SITE), read_site))
.bind(model)
)
def kl_from(clean_logp, logits):
"""Mean KL(clean || perturbed) of the next-token distribution over positions."""
return (clean_logp.exp() * (clean_logp - F.log_softmax(logits, -1))).sum(-1).mean()
with torch.no_grad():
clean_logp = F.log_softmax(model(victims).logits, -1)
attacked(victims)
flagged = seen["act"] @ w + b > 0 # tokens the probe flags before the attack
embed_norm = model.transformer.wte(victims).norm()
optimizer = torch.optim.Adam([delta], lr=3e-4)
for _ in range(40):
optimizer.zero_grad()
logits = attacked(victims).logits
evade = F.relu(seen["act"] @ w + b + 1.0)[flagged].mean()
(evade + 10.0 * kl_from(clean_logp, logits)).backward()
optimizer.step()
with torch.no_grad():
kl = kl_from(clean_logp, attacked(victims).logits).item()
evaded = (seen["act"] @ w + b)[flagged] < 0
relative_norm = (delta.norm() / embed_norm).item()
print(f"evaded {evaded.float().mean().item():.2f}, KL {kl:.2e}, relative norm {relative_norm:.2f}")
RESULT = (bool(evaded.all()), kl < 1e-2, relative_norm < 0.5)
Read the relative norm (about 0.3 here) as the cost of evasion. A probe that a perturbation small next to the input flips while the output barely moves is fragile as a monitor: an adversary, or ordinary optimisation pressure against the probe, can hide the concept from it. On a random network the KL bound is easy to meet, because the output depends weakly on the input; on a trained model the KL term is what makes the attack hard, and its size at a given evasion rate is the number to report.
6. Causal check through the intervention bundle¶
A probe direction is evidence the layer carries the concept, not that the model
uses it. Steer the site along the probe's unit direction with growing strength
and read two things from one tl.record per strength: the model output, whose
KL from the unsteered next-token distribution must grow with the strength, and
the steered site, whose probe score must move with it (the check that the edit
landed where the probe reads).
# recipe-test: tool=sklearn expected=(True, True, True)
direction = w / w.norm()
site = tl.module(SITE)
with torch.no_grad():
base_logp = F.log_softmax(model(victims).logits, -1)
kls, site_scores = [], []
for alpha in (0.0, 1.0, 2.0, 4.0, 8.0):
spec = tl.when(site, tl.steer(direction, strength=alpha, dim=-1))
rec = tl.record(model, victims, save=site, intervene=spec)
out = rec.output
rec_trace = rec.to_trace()
steered = rec_trace.find_sites(site).first().out
kls.append(kl_from(base_logp, out.logits).item())
site_scores.append((steered @ w + b).mean().item())
def increasing(values):
return all(a < b for a, b in zip(values, values[1:]))
print([f"{value:.2e}" for value in kls])
RESULT = (kls[0] < 1e-6, increasing(kls), increasing(site_scores))
This passes: the output moves further from the unsteered distribution at every step of the strength. It would fail if the KL stayed at zero as the strength grew: then the direction is a correlate the layer carries and nothing downstream reads, and the probe says nothing about what the model does. On a trained model, also compare against random directions of the same norm and check that the output moves toward the behaviour the concept predicts, not just away from the clean output.
7. Streaming the probe over a generation¶
To watch the concept token by token, give the fitted probe to the recorder as a
reducer: each forward of a cached generate() replaces the site's activation
with the probe's decision function at the positions that forward computed, and
the full activation is released. The series holds the prompt positions first
(the prefill), then one score per generated token the model read back: the last
generated token is sampled from the final forward and never fed through the
model, so max_new_tokens=16 yields 15 decode scores. The check below compares
the prefill scores with clf.decision_function on activations extracted
separately.
# recipe-test: tool=sklearn expected=(True, True, 15)
from torchlens.recording import GenerationOptions, reducers
prompt = prompts[torch.as_tensor(held_out[:1])]
P = prompt.shape[1]
gen = tl.record(
model,
prompt,
generate_kwargs={"max_new_tokens": 16, "do_sample": False, "use_cache": True},
generation=GenerationOptions(reducers={SITE: reducers.probe(clf)}),
)
series = gen.series(SITE) # TokenSeries: .positions and .values, prompt first
prefill = tl.extract(model, prompt, {"act": SITE})["act"][0].numpy()
expected = torch.from_numpy(clf.decision_function(prefill)).float()
RESULT = (
bool(torch.allclose(series.values[:P].float(), expected, atol=1e-4)),
list(series.positions) == list(range(len(series.values))),
len(series.values) - P,
)
Limits¶
- A probe finds what is linearly readable at one site; a failed probe does not show the concept is absent, and a passed one does not show the model uses it (that is what step 6 is for).
- Choose the site, the regularisation and the split before looking at test scores; sweeping them against the test folds is another leak.
- The obfuscation check here perturbs embeddings with a white-box gradient. An attacker restricted to discrete tokens is weaker; report which threat model you tested.
- Streaming scores come from one prompt per generation; batched prompts, beams
and
intervene=refuse in the generation recorder.