Skip to content
GKgkml.dev
← LLM mechanisms

Part 5 · Observational mech interp · module 3 of 5 · 20 min

Investigating layers

Depth-resolved analysis — Q/K/V similarity structure, RSA by layer, effective dimensionality, mutual information and the logit lens.

The essence

A transformer's layers do different jobs, and almost every interpretability measurement becomes more informative when computed as a function of depth. The resulting curve — a laminar profile, borrowing the term from cortical anatomy — localises where in the network a property appears, which is far more useful than knowing that it exists somewhere.

Why it matters

Depth profiles are how you turn 'the model represents X' into 'the model computes X around layer 8 and stops using it by layer 20'. That is the difference between an observation and a hypothesis you can test causally. The logit lens in particular is the single most-used tool in the field, because it makes the running prediction at every layer directly readable.

Topics covered (13)
  1. 01Token similarities within and across Q, K and V matrices
  2. 02How those similarities change across layers
  3. 03Semantic grouping and RSA in Q and K
  4. 04Laminar profiles of RSA and category selectivity
  5. 05Effective dimensionality via PCA
  6. 06Dimensionality analysis in a larger model
  7. 07Mutual information: theory and code
  8. 08Pairwise mutual information through the network
  9. 09Mutual information vs. covariance
  10. 10Mutual information and token distance
  11. 11Clusters in internal vs. terminal punctuation
  12. 12The logit lens
  13. 13Applying the logit lens to BERT

Q, K and V are not interchangeable

All three come from linear projections of the same residual stream, but they play different roles. Q and K only ever meet in a dot product, so what matters is their similarity structure relative to each other — that product decides the attention pattern. V is what gets summed and written back, so its structure is about content rather than routing.

Measuring cosine similarity within each of the three, and across them, gives a compact picture of what a layer's attention is organised around. Layers whose K-space clusters tokens by syntactic category are routing on syntax; layers whose K-space clusters by topic are routing on semantics. Doing this across all layers turns it into a profile of what each depth attends on.

Extracting Q, K, V per layer and comparing their structure
import torch, numpy as np

@torch.no_grad()
def get_qkv(model, tok, text, layer):
    """GPT-2 packs Q, K, V into one c_attn projection: split it in thirds."""
    captured = {}
    h = model.transformer.h[layer].attn.c_attn.register_forward_hook(
        lambda m, i, o: captured.__setitem__("qkv", o.detach()))
    ids = tok(text, return_tensors="pt")
    model(**ids)
    h.remove()

    dim = model.config.n_embd
    q, k, v = captured["qkv"][0].split(dim, dim=-1)      # each [T, dim]
    return q.numpy(), k.numpy(), v.numpy(), tok.convert_ids_to_tokens(ids["input_ids"][0])

def structure_profile(model, tok, text, n_layers):
    rows = []
    for L in range(n_layers):
        q, k, v, _ = get_qkv(model, tok, text, L)
        rows.append({
            "layer": L,
            "within_q": mean_offdiag(cosine_matrix(q)),
            "within_k": mean_offdiag(cosine_matrix(k)),
            "within_v": mean_offdiag(cosine_matrix(v)),
            # Do Q and K organise tokens the SAME way? RSA answers it.
            "rsa_qk": rsa(q, k),
            "rsa_qv": rsa(q, v),
        })
    return rows

def mean_offdiag(S):
    iu = np.triu_indices_from(S, k=1)
    return float(S[iu].mean())

# Reshape by head to ask the same questions per head rather than per layer:
#   q.reshape(T, n_heads, head_dim).transpose(1, 0, 2)
# Head-level structure is usually much more specific than layer-level.

Effective dimensionality

A layer is 768 or 4,096 dimensions wide, but its activations rarely fill that space. Running PCA on the activations over a set of stimuli and asking how many components carry the variance gives the effective dimensionality — and it is typically a small fraction of the nominal width.

The participation ratio is the cleanest single number: the squared sum of eigenvalues divided by the sum of squares. It equals the nominal dimension for a perfectly flat spectrum and approaches 1 when one direction dominates.

The depth profile of this quantity is a real finding rather than a summary statistic. Effective dimensionality commonly rises through early layers as the model elaborates the input, then falls in later layers as it compresses toward the prediction. Where it peaks tells you where the representation is richest, and comparing that profile across model sizes is one of the more informative things you can do with a larger model such as Pythia at a few billion parameters.

Effective dimensionality by layer
import numpy as np
from sklearn.decomposition import PCA

def participation_ratio(X):
    """Effective number of dimensions carrying variance."""
    X = X - X.mean(0, keepdims=True)
    ev = np.linalg.svd(X, compute_uv=False) ** 2
    p = ev / ev.sum()
    return float(1.0 / (p ** 2).sum())

def variance_dim(X, threshold=0.95):
    """Components needed to reach a variance threshold — the other convention."""
    pca = PCA().fit(X - X.mean(0, keepdims=True))
    return int(np.searchsorted(np.cumsum(pca.explained_variance_ratio_), threshold) + 1)

for L, acts in enumerate(hidden_by_layer):      # each [n_tokens, dim]
    print(f"layer {L:2}  nominal {acts.shape[1]:4}  "
          f"PR {participation_ratio(acts):6.1f}  "
          f"95% var {variance_dim(acts):4}")

# Control: you cannot have more effective dimensions than samples. With 200
# tokens the ceiling is 200 regardless of width, so hold the token count fixed
# across layers or the profile is measuring your sample size.

Mutual information

Correlation and covariance detect linear relationships. Mutual information detects any statistical dependence: it is the reduction in uncertainty about one variable from knowing another, and it is zero if and only if the two are independent.

That generality is exactly what you want inside a network full of non-linearities. Two units related through a GELU, or related in a V-shape, have near-zero correlation and substantial mutual information. Computing both and comparing them is informative in itself — a large MI with near-zero covariance is a signature of a non-linear relationship.

The practical difficulty is estimation. MI on continuous variables requires either binning, which makes the answer depend on bin width, or a k-nearest-neighbour estimator. Both are biased upward on small samples, so always compute a shuffled baseline: permute one variable, re-estimate, and treat that as your zero.

Mutual information, with the shuffle baseline
import numpy as np
from sklearn.feature_selection import mutual_info_regression
from sklearn.metrics import mutual_info_score

def mi_binned(x, y, bins=16):
    """Fast, but sensitive to bin count — always report the count used."""
    c = np.histogram2d(x, y, bins=bins)[0]
    return mutual_info_score(None, None, contingency=c)

def mi_knn(x, y, k=3):
    """Kraskov-style estimator; better on continuous data."""
    return float(mutual_info_regression(x.reshape(-1, 1), y,
                                        n_neighbors=k, random_state=0)[0])

def mi_with_baseline(x, y, n=100):
    rng = np.random.default_rng(0)
    observed = mi_knn(x, y)
    null = [mi_knn(x, rng.permutation(y)) for _ in range(n)]
    return observed, float(np.mean(null)), float(np.percentile(null, 95))

# MI vs covariance: a non-monotonic relationship shows the difference clearly.
x = np.random.randn(2000)
y = x ** 2 + 0.1 * np.random.randn(2000)
print("corr", np.corrcoef(x, y)[0, 1])      # ~0 — blind to it
print("MI  ", mi_knn(x, y))                 # clearly positive

# Pairwise MI through the network: does information about a token persist,
# and how does it decay with distance?
def mi_by_distance(hidden, max_dist=20):
    out = {}
    for d in range(1, max_dist + 1):
        vals = [mi_knn(hidden[i], hidden[i + d])
                for i in range(len(hidden) - d)]
        out[d] = float(np.mean(vals))
    return out
# A slow decay means long-range dependence is being maintained; a sharp drop
# means the layer is largely local.

A worked example: punctuation

Punctuation makes a good test case because the categories are unambiguous and the hypothesis is clear. A comma continues a sentence; a full stop ends one. If a layer represents that distinction, then comma and semicolon representations should cluster together, apart from full stop and question mark.

Running RSA and clustering on these two groups across layers gives a clean profile, and the result is a genuine finding rather than a demonstration: the separation is usually weak in early layers, where the representation is dominated by token identity, and strong in middle layers, where the model has computed something about sentence structure. It then often weakens again near the output, as the representation collapses onto whatever predicts the next token specifically.

The logit lens

The residual stream is in the same space at every layer, and the unembedding matrix maps that space to vocabulary scores. So you can apply the unembedding to any layer's residual stream and read off what the model would predict if it stopped there.

The result is a layer-by-layer trace of the developing prediction, and it is remarkably legible. Early layers predict generically — high-frequency tokens, plausible parts of speech. The correct answer typically emerges in the middle-to-late layers and then sharpens. Seeing exactly which layer a fact appears at is about as direct a localisation as observational methods offer.

One detail is essential: apply the final layernorm before the unembedding. Skipping it produces garbage, because the unembedding was trained on normalised inputs. This is the most common implementation error with the technique.

The logit lens
import torch

@torch.no_grad()
def logit_lens(model, tok, prompt, k=5):
    ids = tok(prompt, return_tensors="pt")
    out = model(**ids, output_hidden_states=True)

    ln_f = model.transformer.ln_f      # MUST apply this first
    head = model.lm_head

    for layer, h in enumerate(out.hidden_states):
        logits = head(ln_f(h[0, -1]))            # last position only
        probs = torch.softmax(logits, dim=-1)
        top = probs.topk(k)
        preds = " ".join(f"{tok.decode([i])!r}({p:.2f})"
                         for p, i in zip(top.values, top.indices))
        print(f"layer {layer:2}  {preds}")

logit_lens(model, tok, "The Eiffel Tower is located in the city of")
# Early layers: generic frequent tokens.
# Middle layers: 'Paris' appears and climbs.
# Late layers: sharpens onto it.

# Track one target token's probability through depth — the cleanest summary:
@torch.no_grad()
def trace_token(model, tok, prompt, target):
    tid = tok(target)["input_ids"][0]
    out = model(**tok(prompt, return_tensors="pt"), output_hidden_states=True)
    return [torch.softmax(model.lm_head(model.transformer.ln_f(h[0, -1])), -1)[tid].item()
            for h in out.hidden_states]

Note: The lens is a projection, not the model's actual intermediate belief — later layers can still overturn what it shows. Treat it as a hypothesis generator to be confirmed by intervention.

Worth remembering

  • Q and K live in a shared space that determines attention; V carries what gets moved. Their similarity structures differ accordingly.
  • Effective dimensionality (participation ratio) is usually far below the nominal width, and varies systematically with depth.
  • Mutual information detects any statistical dependence, including non-linear ones covariance is blind to.