Skip to content
GKgkml.dev
← LLM mechanisms

Part 5 · Observational mech interp · module 2 of 5 · 22 min

Investigating neurons and dimensions

Finding what an individual unit responds to — activation maximisation, PyTorch hooks, multi-token words, and statistical tests for tuning.

The essence

A neuron is one dimension of one layer's activation vector. Asking what it does means finding the inputs that drive it high, which you can do by optimising an input (gradient ascent) or by searching real data (sampling). Establishing that the answer is real rather than a coincidence then requires statistics — a t-test or a logistic regression against a proper null.

Why it matters

Single-unit analysis is where interpretability meets experimental discipline. It is trivially easy to find a neuron that appears to encode a concept and be wrong, because with thousands of neurons and a small stimulus set some will correlate by chance. Everything in this module is about the difference between a real tuning result and a multiple-comparisons artefact.

Topics covered (17)
  1. 01Activation maximisation via gradient ascent, in theory
  2. 02Activation maximisation in code
  3. 03Activation maximisation via data sampling
  4. 04Reproducibility of activation maximisation
  5. 05Extracting activations using PyTorch hooks
  6. 06How hooks relate to output.hidden_states
  7. 07What the final hidden_states entry actually contains
  8. 08Testing grammatical tuning in MLP neurons
  9. 09Context-modulated activation in the MLP
  10. 10Activation histograms by token length
  11. 11Handling multi-token words
  12. 12Category-tuned MLP projections and t-tests
  13. 13Classification via logistic regression: theory and code
  14. 14Logistic regression vs. the t-test: assumptions and uses
  15. 15Proper-noun tuning in GPT-2 medium
  16. 16Negation tuning in MLP neurons
  17. 17Negation tuning in Q, K and V

Hooks: the basic instrument

A forward hook is a function PyTorch calls when a module produces output, giving you the module, its inputs and its output. Returning a value from the hook replaces the output, which is how every intervention in the next part is implemented. Returning nothing leaves it untouched, which is how you observe.

Hooks are the right tool because they need no modification of the model. You attach to any submodule by name, run a forward pass, and read whatever you captured — MLP output, attention weights, a specific projection.

Hooks, and how they relate to hidden_states
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

tok = AutoTokenizer.from_pretrained("gpt2-medium")
model = AutoModelForCausalLM.from_pretrained("gpt2-medium").eval()

class Recorder:
    """Context manager so hooks cannot outlive their scope."""
    def __init__(self, modules):
        self.modules, self.acts, self.handles = modules, {}, []

    def __enter__(self):
        for name, module in self.modules.items():
            self.handles.append(module.register_forward_hook(self._make(name)))
        return self

    def _make(self, name):
        def hook(module, inputs, output):
            t = output[0] if isinstance(output, tuple) else output
            self.acts[name] = t.detach().cpu()      # detach or you leak
        return hook

    def __exit__(self, *exc):
        for h in self.handles:
            h.remove()

targets = {f"mlp{i}": model.transformer.h[i].mlp for i in range(6)}
with Recorder(targets) as rec:
    out = model(**tok("The cat sat on the mat", return_tensors="pt"),
                output_hidden_states=True)

# hidden_states is the RESIDUAL STREAM after each block, plus the embeddings
# at index 0. So len(hidden_states) == n_layers + 1.
print(len(out.hidden_states), out.hidden_states[0].shape)

# A hook on .mlp captures only the MLP's CONTRIBUTION, before it is added to
# the residual stream. These are different tensors and confusing them is the
# most common misreading in this area:
#   hidden_states[i+1] == hidden_states[i] + attn_out_i + mlp_out_i

# And hidden_states[-1] for a causal LM has already had the final layernorm
# applied — so it is not simply the last block's output.

Activation maximisation by gradient ascent

The idea is borrowed from vision: to see what a unit detects, optimise the input to maximise its activation. Fix the weights, make the input a variable, and ascend the gradient of the target activation.

For language it works badly, and the reason is instructive. Tokens are discrete, so you must optimise a continuous relaxation — a soft distribution over the vocabulary, or the embedding vector directly. Either way the optimum is a point in embedding space that corresponds to no real token, and often far outside the region the model ever sees. The unit's response there tells you about the extrapolated function, not about the model's behaviour on language.

It remains worth doing once, because seeing that the optimised 'input' is meaningless makes the case for the sampling approach concrete.

Gradient ascent on a neuron, with its caveat visible
import torch

def maximise_neuron(model, layer, neuron, seq_len=8, steps=300, lr=0.1):
    """Optimise a continuous embedding to drive one unit high."""
    emb_layer = model.transformer.wte
    dim = emb_layer.weight.size(1)
    x = torch.randn(1, seq_len, dim, requires_grad=True) * 0.02
    opt = torch.optim.Adam([x], lr=lr)

    captured = {}
    h = model.transformer.h[layer].mlp.register_forward_hook(
        lambda m, i, o: captured.__setitem__("a", o))

    for _ in range(steps):
        model(inputs_embeds=x)
        loss = -captured["a"][0, :, neuron].mean()      # ascent = descend -a
        opt.zero_grad(); loss.backward(); opt.step()
    h.remove()

    # Which real tokens is the optimised vector nearest to? Usually the
    # similarities are all low, which is the point: the optimum is not a
    # plausible input, so the result is hard to interpret linguistically.
    W = emb_layer.weight.detach()
    Wn = W / W.norm(dim=1, keepdim=True)
    xn = x.detach()[0] / x.detach()[0].norm(dim=1, keepdim=True)
    sims = xn @ Wn.T
    return sims.max(dim=1)          # values and token ids

Activation maximisation by data sampling

The practical alternative: run a large corpus through the model, record the target unit's activation at every token, and look at the highest-activating examples. Everything you examine is real text, so anything you conclude is about behaviour on the distribution the model actually operates in.

Two refinements make the results trustworthy. Look at the full distribution, not just the top — a unit whose top activations are dominated by one document has memorised something specific rather than encoding a concept. And examine the middle and bottom of the range too, because a unit that is high on your hypothesis and also high on unrelated text is polysemantic, which the top-k view hides completely.

Reproducibility is the other half. Repeat with a different corpus sample and check that the top examples still share the property. Units whose apparent tuning changes with the sample were fitting noise.

Top-activating examples, with the distribution
import torch, numpy as np
from collections import defaultdict

@torch.no_grad()
def scan_corpus(model, tok, texts, layer, neuron):
    """Activation of one unit at every token across a corpus."""
    records = []
    captured = {}
    h = model.transformer.h[layer].mlp.register_forward_hook(
        lambda m, i, o: captured.__setitem__("a", o.detach()))

    for text in texts:
        ids = tok(text, return_tensors="pt", truncation=True, max_length=256)
        model(**ids)
        acts = captured["a"][0, :, neuron]
        pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])
        for i, (piece, a) in enumerate(zip(pieces, acts.tolist())):
            context = "".join(pieces[max(0, i - 6): i + 1])
            records.append((a, piece, context))
    h.remove()
    return records

recs = scan_corpus(model, tok, corpus, layer=8, neuron=1423)
vals = np.array([r[0] for r in recs])

print(f"mean {vals.mean():+.3f}  std {vals.std():.3f}  "
      f"p99 {np.percentile(vals, 99):+.3f}  max {vals.max():+.3f}")
print(f"fraction above zero: {(vals > 0).mean():.2%}")

recs.sort(reverse=True)
for a, piece, ctx in recs[:15]:
    print(f"{a:+7.3f}  {piece!r:15} ...{ctx}")

# Also print a slice from the middle of the distribution. If those look like
# the top examples, the unit is not selective — it is just generally active.
mid = len(recs) // 2
for a, piece, ctx in recs[mid: mid + 5]:
    print(f"{a:+7.3f}  {piece!r:15} ...{ctx}")

Multi-token words, and length confounds

Most interesting words are several tokens, which creates an immediate methodological problem: which token's activation represents the word? The options are the first piece, the last piece, or the mean across pieces, and they are not equivalent. The last piece has seen the whole word, so it usually carries the most word-level information; the first has seen only a fragment.

Worse, token count correlates with word frequency and length, so a neuron that looks tuned to a semantic category may actually be tuned to how many pieces the word has. Plotting activation histograms grouped by token length is the diagnostic — if the effect is present within each length group, it is not a length artefact. If it disappears, it was.

Word-level activations, and the length control
import torch, numpy as np

@torch.no_grad()
def word_activation(model, tok, sentence, word, layer, neuron, how="last"):
    ids = tok(sentence, return_tensors="pt")
    captured = {}
    h = model.transformer.h[layer].mlp.register_forward_hook(
        lambda m, i, o: captured.__setitem__("a", o.detach()))
    model(**ids)
    h.remove()

    pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])
    # Find the contiguous span whose concatenation matches the word.
    span = find_span(pieces, word, tok)      # returns (start, end)
    a = captured["a"][0, span[0]:span[1], neuron]

    return {"first": a[0], "last": a[-1], "mean": a.mean(), "max": a.max()}[how]

# The control that decides whether a result is real:
def by_token_length(model, tok, items, layer, neuron):
    groups = {}
    for sentence, word, label in items:
        n = len(tok.tokenize(" " + word))
        a = word_activation(model, tok, sentence, word, layer, neuron)
        groups.setdefault(n, []).append((label, float(a)))

    for n, rows in sorted(groups.items()):
        pos = [a for lab, a in rows if lab == 1]
        neg = [a for lab, a in rows if lab == 0]
        if pos and neg:
            print(f"{n} tokens: n={len(rows):3}  "
                  f"diff {np.mean(pos) - np.mean(neg):+.3f}")
# A consistent difference WITHIN each length group is evidence.
# An effect that only appears when lengths are pooled is a confound.

Testing tuning: the t-test

The basic question is whether a neuron's activation differs between two stimulus categories — proper nouns against common nouns, negated against affirmative sentences, grammatical against ungrammatical. An independent-samples t-test answers exactly that, and it is the right first tool.

The assumptions matter here because activations often violate them: it assumes roughly normal distributions and independent samples. GELU-gated activations are frequently skewed with a mass near zero, so use Welch's version (unequal variances) as the default, and consider the Mann-Whitney U test when the distributions are clearly non-normal. Independence fails if you take multiple tokens from the same sentence, which inflates significance — sample one token per sentence, or use a mixed model.

Scanning for tuned neurons, corrected properly
import numpy as np
from scipy import stats
from statsmodels.stats.multitest import multipletests

def find_tuned_neurons(acts, labels, alpha=0.05):
    """acts: [n_stimuli, n_neurons]; labels: 0/1 per stimulus."""
    a, b = acts[labels == 1], acts[labels == 0]

    t, p = stats.ttest_ind(a, b, axis=0, equal_var=False)   # Welch

    # Effect size matters more than p when n is large.
    pooled = np.sqrt((a.var(0, ddof=1) + b.var(0, ddof=1)) / 2)
    d = (a.mean(0) - b.mean(0)) / np.maximum(pooled, 1e-9)   # Cohen's d

    reject, p_adj, *_ = multipletests(p, alpha=alpha, method="fdr_bh")

    hits = np.where(reject & (np.abs(d) > 0.8))[0]           # large effect only
    order = hits[np.argsort(-np.abs(d[hits]))]
    return [(int(i), float(t[i]), float(p_adj[i]), float(d[i])) for i in order]

for idx, t_, p_, d_ in find_tuned_neurons(activations, labels)[:10]:
    print(f"neuron {idx:5}  t={t_:+6.2f}  p_adj={p_:.2e}  d={d_:+.2f}")

# Then the step that actually matters: re-test the survivors on stimuli held
# out from the search. Most will not replicate, and that is the useful result.

Logistic regression, and when to prefer it

A t-test asks whether one neuron differs between categories. Logistic regression inverts the question: can the category be predicted from the activations? That difference matters, because information can be distributed across many units with no single unit showing a reliable difference — a t-test finds nothing and a probe reads it out easily.

So use a t-test when the claim is about a specific unit, and a probe when the claim is that the information is present anywhere in the layer. Report cross-validated accuracy, never training accuracy: with 4,096 features and a few hundred stimuli a linear model can separate anything, including random labels.

The essential control is a label-permutation baseline. Shuffle the labels, retrain, and record the accuracy. That is your chance level given the real feature count and sample size, and it is often far above the naive 50%.

A linear probe, with the controls that make it meaningful
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

def probe(acts, labels, C=1.0, folds=5):
    pipe = make_pipeline(
        StandardScaler(),
        LogisticRegression(C=C, max_iter=2000, penalty="l2"),
    )
    cv = StratifiedKFold(folds, shuffle=True, random_state=0)
    return cross_val_score(pipe, acts, labels, cv=cv).mean()

def permutation_baseline(acts, labels, n=50):
    rng = np.random.default_rng(0)
    return [probe(acts, rng.permutation(labels)) for _ in range(n)]

real = probe(activations, labels)
null = permutation_baseline(activations, labels)
print(f"probe {real:.3f}   permuted {np.mean(null):.3f} "
      f"(95th pct {np.percentile(null, 95):.3f})")
# Only the margin over that 95th percentile is evidence.

# Laminar profile: where does the information first become linearly available?
for L in range(n_layers):
    print(L, round(probe(acts_by_layer[L], labels), 3))
# A sharp rise at some depth is the interesting result — it localises where
# the feature is computed, which is what makes this worth doing per layer.

Context modulation, and Q/K/V versus MLP

A unit's response to a token is rarely fixed — the same token in different contexts drives it differently, which is expected given that its input is a contextual representation. Measuring that modulation directly (same target token, systematically varied context) separates units that encode token identity from units that encode role or relation.

Applying the same tests to the Q, K and V projections rather than the MLP asks a different question. MLP units do per-position computation, so tuning there is about the current token's properties. Q and K participate in deciding what attends to what, so tuning there is about relational structure. A feature like negation, which scopes over a span, is a good candidate for showing up more strongly in Q/K than in the MLP — and checking whether it does is exactly the kind of experiment this module equips you to run.

Worth remembering

  • Hooks are the standard way to read or modify any internal tensor; always remove them afterwards.
  • Data sampling beats gradient ascent for language models, because optimised inputs are off-distribution.
  • With thousands of neurons you must correct for multiple comparisons or you will find tuning in noise.