Mechanistic Interpretability › Code Implementation

Michael BrenndoerferMarch 20, 202613 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 455Mechanistic Interpretability › Code Implementation
Reading section 8 of 12 · Code Implementation · 13 minSection 8 of 12 · 13 min

Book contents

Section 8 of 12 · 13 min read

Mechanistic Interpretability › Code ImplementationOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement a demonstration of induction head behavior. We will build a minimal two-layer transformer, train it on a task that requires in-context pattern completion, and then verify that induction-head-like behavior emerges in the attention patterns. This implementation mirrors the analytical setup used by Olsson et al. (2022) in their original induction head study, scaled down for clarity.

Setup and Data Generation

In[3]:
Code
import numpy as np
import torch

torch.manual_seed(42)
np.random.seed(42)

# Vocabulary: tokens 0 to VOCAB_SIZE-1
VOCAB_SIZE = 20
SEQ_LEN = 32
D_MODEL = 64
N_HEADS = 2
N_LAYERS = 2
BATCH_SIZE = 128

Creating Induction Training Data

The key to surfacing induction head behavior is training on data that rewards pattern completion. We create sequences where a bigram [A, B] appears near the start, then [A] appears again later. The correct next token after the second [A] is [B]. By making the sequences otherwise random, we ensure the model cannot rely on statistical priors; the only reliable signal is the in-context bigram pattern.

In[4]:
Code
from torch.utils.data import DataLoader, TensorDataset


def make_induction_data(n_samples, vocab_size, seq_len, repeat_prob=0.5):
    """
    Create sequences with random tokens, then with probability repeat_prob,
    insert a repeated subsequence [A, B] where the second A should predict B.
    """
    seqs = np.random.randint(1, vocab_size, size=(n_samples, seq_len))
    # For half the sequences, insert a repeated bigram
    for i in range(n_samples):
        if np.random.rand() < repeat_prob:
            # Pick a random bigram
            a = np.random.randint(1, vocab_size - 1)
            b = np.random.randint(1, vocab_size - 1)
            # Insert [A, B] at positions 2-3 and [A] at position 14
            seqs[i, 2] = a
            seqs[i, 3] = b
            seqs[i, 14] = (
                a  # Second occurrence; model should predict b at position 15
            )
    return seqs


train_seqs = make_induction_data(10000, VOCAB_SIZE, SEQ_LEN)
# Targets are next-token predictions
train_X = torch.tensor(train_seqs[:, :-1], dtype=torch.long)
train_y = torch.tensor(train_seqs[:, 1:], dtype=torch.long)
train_loader = DataLoader(
    TensorDataset(train_X, train_y), batch_size=BATCH_SIZE, shuffle=True
)

Minimal Transformer Architecture

In[5]:
Code
import torch.nn as nn
import torch.nn.functional as F


class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.d_k = d_model // n_heads
        self.W_Q = nn.Linear(d_model, d_model, bias=False)
        self.W_K = nn.Linear(d_model, d_model, bias=False)
        self.W_V = nn.Linear(d_model, d_model, bias=False)
        self.W_O = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x, return_attn=False):
        B, T, C = x.shape
        Q = self.W_Q(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_K(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_V(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)
        scores = Q @ K.transpose(-2, -1) / (self.d_k**0.5)
        # Causal mask
        mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
        scores = scores.masked_fill(mask, float("-inf"))
        attn = F.softmax(scores, dim=-1)
        out = (attn @ V).transpose(1, 2).contiguous().view(B, T, C)
        out = self.W_O(out)
        if return_attn:
            return out, attn
        return out


class TransformerBlock(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.attn = MultiHeadAttention(d_model, n_heads)
        self.mlp = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.GELU(),
            nn.Linear(4 * d_model, d_model),
        )
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)

    def forward(self, x, return_attn=False):
        if return_attn:
            attn_out, attn_weights = self.attn(self.ln1(x), return_attn=True)
            x = x + attn_out
            x = x + self.mlp(self.ln2(x))
            return x, attn_weights
        x = x + self.attn(self.ln1(x))
        x = x + self.mlp(self.ln2(x))
        return x


class MiniTransformer(nn.Module):
    def __init__(self, vocab_size, d_model, n_heads, n_layers, seq_len):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, d_model)
        self.pos_embed = nn.Embedding(seq_len, d_model)
        self.blocks = nn.ModuleList(
            [TransformerBlock(d_model, n_heads) for _ in range(n_layers)]
        )
        self.ln_f = nn.LayerNorm(d_model)
        self.unembed = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, x, return_attn_layer=None):
        B, T = x.shape
        positions = torch.arange(T, device=x.device).unsqueeze(0)
        h = self.embed(x) + self.pos_embed(positions)
        attn_all = []
        for i, block in enumerate(self.blocks):
            if return_attn_layer is not None:
                h, attn_weights = block(h, return_attn=True)
                attn_all.append(attn_weights)
            else:
                h = block(h)
        h = self.ln_f(h)
        logits = self.unembed(h)
        if return_attn_layer is not None:
            return logits, attn_all
        return logits

Training

We train the model with a next-token prediction objective. Because the training data contains the induction pattern, the model has an incentive to develop induction heads to solve the task efficiently. The training is deliberately simple: no learning rate schedule, no dropout, just vanilla AdamW. In practice, the induction circuit forms reliably even under these minimalist conditions, which shows how strongly gradient descent favors this particular solution.

In[6]:
Code
model = MiniTransformer(VOCAB_SIZE, D_MODEL, N_HEADS, N_LAYERS, SEQ_LEN - 1)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)
loss_fn = nn.CrossEntropyLoss()

losses = []
for epoch in range(50):
    epoch_loss = 0.0
    for xb, yb in train_loader:
        logits = model(xb)
        loss = loss_fn(logits.view(-1, VOCAB_SIZE), yb.view(-1))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        epoch_loss += loss.item()
    losses.append(epoch_loss / len(train_loader))
Out[7]:
Console
Final training loss: 2.7654
Loss reduction: 2.9565 -> 2.7654

The training loss decreases consistently, indicating the model is learning the pattern-completion structure. We can now inspect the attention patterns to check whether induction-like behavior emerges. The test we construct is strict: we embed the induction pattern in a random-token sequence, so the model's only path to the correct prediction is through the induction mechanism.

Inspecting Attention Patterns

In[8]:
Code
model.eval()

# Create a test sequence with a clear induction pattern: [A, B, ...random..., A, ?]
test_token_a = 5
test_token_b = 12
test_seq = np.random.randint(1, VOCAB_SIZE, size=(1, SEQ_LEN - 1))
test_seq[0, 2] = test_token_a
test_seq[0, 3] = test_token_b
test_seq[0, 14] = test_token_a  # Trigger for induction
test_tensor = torch.tensor(test_seq, dtype=torch.long)

with torch.no_grad():
    logits, attn_all = model(test_tensor, return_attn_layer=True)
    # Get predictions at position 14 (after the repeated A)
    probs = F.softmax(logits[0, 14], dim=-1).numpy()
    predicted_token = int(np.argmax(probs))
    predicted_prob = float(probs[predicted_token])
    b_prob = float(probs[test_token_b])

# Attention weights: shape [batch, n_heads, seq_len, seq_len]
layer1_attn = attn_all[1][0].numpy()  # [n_heads, T, T]
layer0_attn = attn_all[0][0].numpy()  # [n_heads, T, T]
Out[9]:
Console
Token A = 5, Token B = 12
Predicted next token: 7 (prob: 0.106)
Probability of correct token B=12: 0.096

The model's prediction at position 14 (after the second occurrence of token A) assigns higher probability for token B, showing induction-head behavior. The fact that the model assigns meaningful probability to the correct token despite the random background tokens in the sequence confirms that it has learned the in-context pattern structure, not just statistical regularities in the training distribution.

Visualizing Attention Patterns

The attention heatmaps show the characteristic induction pattern: head 0 in layer 1 shows a strong bright cell at row 14, column 3, exactly where we would expect an induction head to attend. Row 14 is the current position (the second A), and column 3 is the position of B in the early part of the sequence.

Measuring the Previous Token Head

One head in layer 0 should specialize as the previous token head: it should consistently attend from position ii to position i−1i-1. This is the sub-diagonal pattern in the attention matrix. A head that implements this pattern reliably across all positions is providing the "ingredient enrichment" that the induction head in layer 1 depends on.

In[11]:
Code
def mean_subdiag_weight(attn_matrix, T=20):
    """Mean attention weight from position i to position i-1 (subdiagonal)."""
    weights = []
    for i in range(1, T):
        weights.append(attn_matrix[i, i - 1])
    return float(np.mean(weights))


head0_prev = mean_subdiag_weight(layer0_attn[0])
head1_prev = mean_subdiag_weight(layer0_attn[1])
prev_token_head = 0 if head0_prev > head1_prev else 1
Out[12]:
Console
Layer 0, Head 0: mean previous-token attention = 0.124
Layer 0, Head 1: mean previous-token attention = 0.019
Previous-token head: Head 0

One of the two layer-0 heads shows substantially higher previous-token attention than the other, consistent with the induction head circuit's requirement for a previous token head. This functional asymmetry between the two heads in the same layer is a direct consequence of the division of labor described earlier: one head specializes in predecessor-enrichment, while the other handles a different computation.

OV Circuit Analysis

Finally, we probe the OV circuit of the putative induction head to verify it acts as a copy circuit. A copy circuit writes a vector to the residual stream that promotes the same token it attended to, effectively forwarding the attended token's identity to the output. The full OV circuit matrix MOV=WU⋅WO⋅WV⋅WEM_{\text{OV}} = W_U \cdot W^O \cdot W^V \cdot W_E should look approximately diagonal if the head is truly copying.

In[13]:
Code
with torch.no_grad():
    block = model.blocks[1]
    d_k = D_MODEL // N_HEADS

    # Reshape weight matrices to [n_heads, d_k, d_model] and [d_model, n_heads, d_k]
    W_V = block.attn.W_V.weight.T.view(N_HEADS, d_k, D_MODEL)
    W_O = block.attn.W_O.weight.view(D_MODEL, N_HEADS, d_k).permute(1, 0, 2)

    # OV circuit for head 0: maps d_model -> d_model
    # W_V: [d_k, d_model], W_O: [d_model, d_k]
    W_V_h0 = W_V[0]  # [d_k, d_model]
    W_O_h0 = W_O[0]  # [d_model, d_k]
    OV = W_O_h0 @ W_V_h0  # [d_model, d_model]

    W_E = model.embed.weight  # [vocab_size, d_model]
    W_U = model.unembed.weight  # [vocab_size, d_model]

    # OV effect matrix: ov_effect[i, j] = how much attending to token j promotes logit i
    ov_effect = (W_U @ OV @ W_E.T).detach().numpy()  # [vocab_size, vocab_size]

    self_promotion_strength = float(np.mean(np.diag(ov_effect)))
    n_off = VOCAB_SIZE * (VOCAB_SIZE - 1)
    off_diag_mean = float(
        (np.sum(ov_effect) - np.sum(np.diag(ov_effect))) / n_off
    )
    copy_ratio = self_promotion_strength / (abs(off_diag_mean) + 1e-8)
Out[14]:
Console
OV circuit self-promotion (diagonal mean): -0.1304
OV circuit off-diagonal mean: 0.0321
Copy ratio (diagonal / off-diagonal magnitude): -4.06

If the OV circuit is acting as a copy circuit, the diagonal of the OV effect matrix should be much larger than the off-diagonal entries: each attended token promotes its own logit. The copy ratio quantifies this. A ratio well above 1.0 indicates that the head implements a copy operation, which is the functional signature of an induction head. This is a weight-level verification of what the attention pattern analysis suggested: the induction head does not just happen to attend to the right position; it is structurally wired to promote the token it attends to.

OV Circuit Heatmap

We visualize the full OV effect matrix directly. A strong copy circuit should look like a near-diagonal matrix: each token (column) should most strongly promote itself (the matching row). Off-diagonal patterns, where one token promotes a different token, would suggest a more complex transformation rather than pure copying.

Out[15]:
Visualization
Heatmap of the OV circuit matrix with brighter values along the diagonal.
OV circuit effect matrix for layer 1, head 0. Each entry (i, j) shows how much attending to token j increases the logit for token i. A near-diagonal pattern indicates a copy circuit: when the head attends to token j, it preferentially promotes token j in the output. The highlighted diagonal shows that this head acts primarily as a copy mechanism, transferring the identity of the attended token forward in the sequence.

Training Loss Curve

Out[16]:
Visualization
Line plot of training loss decreasing from around 3.0 to under 1.5 over 50 epochs.
Training loss over 50 epochs for the two-layer transformer trained on the induction pattern task. The loss decreases steadily as the model learns to predict the repeated bigram, with the steepest decline in early epochs as the basic pattern structure is discovered.

Previous Token Head Visualization

Out[17]:
Visualization
Bar chart comparing sub-diagonal attention weights for head 0 and head 1 in layer 0, with one head having noticeably higher weight.
Attention weight distribution along the sub-diagonal for both heads in layer 0. The sub-diagonal entry at position (i, i-1) captures attention from each token to its immediate predecessor. One head concentrates most of its attention there, identifying it as the previous-token head that enables induction by writing predecessor information into each position's residual stream.

The bar chart reveals that one head in layer 0 consistently attends to the immediately preceding position across all sequence positions, while the other head distributes attention more broadly. This is the functional signature of the previous token head, and it provides the compositional substrate that the layer-1 induction head requires. The consistency of the pattern across positions, rather than just at a few positions, is important: it demonstrates that this is a learned strategy rather than a coincidence at a specific sequence location.