Logit Lens › Implementation

Michael BrenndoerferFebruary 12, 202610 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 452Logit Lens › Implementation
Reading section 5 of 12 · Implementation · 10 minSection 5 of 12 · 10 min

Book contents

Section 5 of 12 · 10 min read

Logit Lens › ImplementationOne section · Sections in this chapter5 / 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's implement the logit lens from scratch and apply it to GPT-2. We'll use HuggingFace Transformers to load the model and manually extract hidden states at each layer.

Setup

First, install the required packages and load GPT-2 with hooks to extract intermediate hidden states.

In[4]:
Code
# uv pip install transformers torch numpy matplotlib
import warnings

from transformers import GPT2LMHeadModel, GPT2Tokenizer
from transformers import logging as hf_logging

# Suppress HuggingFace verbosity and warnings
hf_logging.set_verbosity_error()
warnings.filterwarnings("ignore")

# Load GPT-2 (small, 12 layers, d=768, V=50257)
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
model = GPT2LMHeadModel.from_pretrained("gpt2")
model.eval()

# Extract and copy the unembedding matrix and layer norm weights
# .clone() ensures we get contiguous copies (avoids mmap alignment issues on ARM)
W_U = model.lm_head.weight.detach().float().clone()  # (V, d)
ln_weight = model.transformer.ln_f.weight.detach().float().clone()
ln_bias = model.transformer.ln_f.bias.detach().float().clone()
Out[5]:
Console
Model: GPT-2 Small
  Layers: 12
  Hidden dimension (d_model): 768
  Vocabulary size: 50257
  Unembedding matrix shape: torch.Size([50257, 768])

GPT-2 Small has 12 transformer layers, a hidden dimension of 768, and a vocabulary of 50,257 tokens. The unembedding matrix has shape (V,d)=(50257,768)(V, d) = (50257, 768), mapping each 768-dimensional hidden state to a score for every token in the vocabulary. Applying this matrix to an intermediate hidden state is the heart of the logit lens.

Extracting Hidden States

To apply the logit lens, we need the hidden state after every layer. HuggingFace's GPT2Model returns all hidden states when output_hidden_states=True. This is a built-in capability that requires no hooks or model modifications.

In[6]:
Code
def get_hidden_states(prompt):
    """Run forward pass and return all hidden states."""
    inputs = tokenizer(prompt, return_tensors="pt")
    input_ids = inputs["input_ids"]
    tokens = tokenizer.convert_ids_to_tokens(input_ids[0])

    with torch.no_grad():
        outputs = model.transformer(
            input_ids,
            output_hidden_states=True,
        )

    # hidden_states is a tuple of (n_layers + 1) tensors, each shape (1, seq_len, d)
    # .clone() ensures contiguous memory (avoids ARM alignment faults with mmap weights)
    hidden_states = tuple(h.float().clone() for h in outputs.hidden_states)
    return hidden_states, tokens, input_ids

The hidden_states tuple contains L+1L + 1 tensors: the embedding layer output at index 0 (before any transformer layers) plus one entry per transformer layer at indices 1 through LL. Each tensor has shape (1,seq_len,d)(1, \text{seq\_len}, d), where the first dimension is the batch size (always 1 here).

The embedding layer output at index 0 is the sum of the token embedding and the positional encoding. It contains no contextual information from attention or feed-forward layers, making it a useful baseline for understanding how much the transformer layers contribute.

The Logit Lens Function

Now we implement the core logit lens computation. For each hidden state, we apply LayerNorm using the model's final layer norm weights, then multiply by the unembedding matrix.

In[7]:
Code
def apply_layer_norm(x, weight, bias, eps=1e-5):
    """Apply layer normalization manually."""
    mean = x.mean(dim=-1, keepdim=True)
    var = x.var(dim=-1, keepdim=True, unbiased=False)
    x_norm = (x - mean) / (var + eps).sqrt()
    return x_norm * weight + bias


def logit_lens_decode(
    hidden_state, W_U, ln_weight, ln_bias, apply_final_norm=True
):
    """
    Apply the logit lens to a single hidden state tensor.

    Args:
        hidden_state: (seq_len, d) tensor of hidden states
        W_U: (V, d) unembedding matrix
        ln_weight, ln_bias: LayerNorm parameters

    Returns:
        logits: (seq_len, V) logit tensor
        probs: (seq_len, V) probability tensor
    """
    # Hugging Face returns the last hidden state after the model's final
    # LayerNorm. Earlier states still need that normalization for the lens.
    h_normed = (
        apply_layer_norm(hidden_state, ln_weight, ln_bias)
        if apply_final_norm
        else hidden_state
    )
    # Multiply by unembedding: (seq_len, d) x (d, V) -> (seq_len, V)
    logits = h_normed @ W_U.T
    probs = torch.softmax(logits, dim=-1)
    return logits, probs
In[8]:
Code
def get_top_tokens_per_layer(prompt, position=-1, top_k=5):
    """
    For a given prompt and token position, get the top-k predictions
    at every layer using the logit lens.
    """
    hidden_states, tokens, input_ids = get_hidden_states(prompt)

    results = []
    for layer_idx, hs in enumerate(hidden_states):
        # hs shape: (1, seq_len, d) -> take the target position
        h = hs[0, position, :]  # (d,)
        _, probs = logit_lens_decode(
            h.unsqueeze(0),
            W_U,
            ln_weight,
            ln_bias,
            apply_final_norm=layer_idx < len(hidden_states) - 1,
        )
        probs = probs[0]  # (V,)

        top_probs, top_indices = torch.topk(probs, top_k)
        top_tokens = tokenizer.convert_ids_to_tokens(top_indices.tolist())

        results.append(
            {
                "layer": layer_idx,
                "top_tokens": top_tokens,
                "top_probs": top_probs.tolist(),
            }
        )

    return results, tokens

Running the Logit Lens

Let's run the logit lens on our example prompt and examine the predictions at the last token position.

In[9]:
Code
prompt = "The Eiffel Tower is located in the city of"
results, tokens = get_top_tokens_per_layer(prompt, position=-1, top_k=5)
Out[10]:
Console
Prompt: 'The Eiffel Tower is located in the city of'
Token sequence: ['The', 'ĠE', 'iff', 'el', 'ĠTower', 'Ġis', 'Ġlocated', 'Ġin', 'Ġthe', 'Ġcity', 'Ġof']

Logit Lens: Top predictions at position 'Ġof' across layers
----------------------------------------------------------------------
Embed: 'Ġdestro'       (0.670)  |  'Ġmathemat'     (0.244)  |  'Ġchalleng'     (0.071)  |  'Ġarrang'       (0.007)  |  'Ġconclud'      (0.006)
Layer  1: 'Ġthe'          (0.707)  |  'Ġcourse'       (0.050)  |  'Ġa'            (0.032)  |  'Ġits'          (0.007)  |  'Ġthis'         (0.006)
Layer  2: 'Ġthe'          (0.815)  |  'Ġcourse'       (0.057)  |  'Ġa'            (0.017)  |  'Ġits'          (0.007)  |  'Ġour'          (0.005)
Layer  3: 'Ġthe'          (0.713)  |  'Ġcourse'       (0.122)  |  'Ġa'            (0.019)  |  'Ġlate'         (0.007)  |  'Ġits'          (0.006)
Layer  4: 'Ġthe'          (0.660)  |  'Ġcourse'       (0.057)  |  'Ġa'            (0.013)  |  'ĠAr'           (0.009)  |  'ĠGran'         (0.006)
Layer  5: 'Ġthe'          (0.318)  |  'ĠLa'           (0.054)  |  'Ġcourse'       (0.046)  |  'ĠAr'           (0.028)  |  'ĠZ'            (0.027)
Layer  6: 'ĠEngland'      (0.055)  |  'Ġthe'          (0.047)  |  'ĠLa'           (0.041)  |  'ĠG'            (0.030)  |  'ĠMir'          (0.027)
Layer  7: 'ĠEast'         (0.064)  |  'ĠLa'           (0.048)  |  'ĠGeorgia'      (0.047)  |  'ĠSouth'        (0.041)  |  'ĠEngland'      (0.038)
Layer  8: 'ĠIng'          (0.230)  |  'ĠSan'          (0.064)  |  'ĠRome'         (0.048)  |  'ĠLa'           (0.044)  |  'ĠChicago'      (0.028)
Layer  9: 'ĠRome'         (0.168)  |  'ĠSan'          (0.075)  |  'ĠLa'           (0.060)  |  'ĠLondon'       (0.059)  |  'ĠChicago'      (0.056)
Layer 10: 'ĠLondon'       (0.248)  |  'ĠParis'        (0.194)  |  'ĠAmsterdam'    (0.178)  |  'ĠRome'         (0.044)  |  'ĠChicago'      (0.031)
Layer 11: 'ĠParis'        (0.165)  |  'ĠLondon'       (0.137)  |  'ĠAmsterdam'    (0.131)  |  'ĠBerlin'       (0.073)  |  'ĠHamburg'      (0.040)
Layer 12: 'ĠParis'        (0.064)  |  'ĠLondon'       (0.046)  |  'ĠAmsterdam'    (0.034)  |  'ĠNew'          (0.032)  |  'ĠBerlin'       (0.026)

The output shows how GPT-2's prediction at the final position evolves through the layers. Early layers are often dominated by generic tokens. As layers increase, city names emerge, and by the final layer, "Paris" (tokenized as " Paris" with a leading-space marker in GPT-2's byte-pair encoding) ranks first without monopolizing the probability mass.

Notice that top-token probability is not a monotonic proxy for semantic progress. An early layer can be confidently generic, whereas a later layer can spread probability across several contextually appropriate city names. Reading rank, token identity, and distribution shape together is more informative than treating confidence alone as the signal.

Visualizing Predictions as a Heatmap

Looking at one position at a time is useful for detailed analysis, but the logit lens becomes especially powerful when visualized across all token positions simultaneously. A heatmap showing the top-1 predicted token and its probability at every (layer, position) cell gives a complete picture of how the model processes the entire input.

In[11]:
Code
def logit_lens_heatmap_data(prompt):
    """
    For each (layer, position) pair, get the top-1 predicted token and its probability.
    """
    hidden_states, tokens, input_ids = get_hidden_states(prompt)
    n_layers = len(hidden_states)
    seq_len = len(tokens)

    top_token_matrix = []
    prob_matrix = np.zeros((n_layers, seq_len))

    for layer_idx, hs in enumerate(hidden_states):
        h = hs[0]  # (seq_len, d)
        _, probs = logit_lens_decode(
            h,
            W_U,
            ln_weight,
            ln_bias,
            apply_final_norm=layer_idx < len(hidden_states) - 1,
        )

        top_probs, top_indices = torch.topk(probs, 1, dim=-1)
        top_tokens_row = [
            tokenizer.convert_ids_to_tokens([idx.item()])[0]
            for idx in top_indices[:, 0]
        ]

        top_token_matrix.append(top_tokens_row)
        prob_matrix[layer_idx] = top_probs[:, 0].numpy()

    return top_token_matrix, prob_matrix, tokens


top_token_matrix, prob_matrix, tokens_hm = logit_lens_heatmap_data(prompt)
Out[12]:
Visualization
Annotated heatmap of top-1 logit lens probabilities across 13 GPT-2 states and 11 token positions.
Logit lens probability heatmap for GPT-2 processing the prompt 'The Eiffel Tower is located in the city of'. Each cell shows the top-1 predicted token probability at that layer and position, with an abbreviated token label overlaid. The changing colors and labels show that confidence evolves differently across positions rather than increasing uniformly with depth.

The heatmap reveals a position-dependent pattern rather than a uniform rise in confidence. Some positions begin with a concentrated token-identity signal, while others change predictions repeatedly across layers. The rightmost column, where the model predicts the next word, is particularly informative for tracing how the factual completion emerges.

Notice also that positions in the middle of the input (like "Tower" or "located") show relatively high confidence early, because the model's "prediction" for those positions is largely determined by the token itself before much contextual processing has occurred. The prediction for the last position is harder and takes more layers to resolve, because it requires integrating information from the entire context.

Tracking a Single Position Across Layers

For detailed analysis, it helps to track the evolution of probabilities for specific candidate tokens across all layers at a single position.

In[13]:
Code
def track_token_probs_across_layers(prompt, candidate_tokens, position=-1):
    """
    Track the probability of specific candidate tokens across all layers.
    Returns a dict mapping each token to its probability at each layer.
    """
    hidden_states, tokens, input_ids = get_hidden_states(prompt)

    # Get token IDs for candidates
    candidate_ids = {
        tok: tokenizer.encode(tok, add_special_tokens=False)[0]
        for tok in candidate_tokens
    }

    layer_probs = {tok: [] for tok in candidate_tokens}

    for layer_idx, hs in enumerate(hidden_states):
        h = hs[0, position, :]  # (d,)
        _, probs = logit_lens_decode(
            h.unsqueeze(0),
            W_U,
            ln_weight,
            ln_bias,
            apply_final_norm=layer_idx < len(hidden_states) - 1,
        )
        probs = probs[0]
        for tok, tok_id in candidate_ids.items():
            if tok_id != tokenizer.unk_token_id:
                layer_probs[tok].append(probs[tok_id].item())
            else:
                layer_probs[tok].append(0.0)

    return layer_probs, tokens


# Track candidates across layers for the Eiffel Tower prompt
candidates = [" Paris", " London", " France", " Berlin"]
layer_probs, _ = track_token_probs_across_layers(
    prompt, candidates, position=-1
)
Out[14]:
Visualization
Line plot of token probabilities across 13 GPT-2 layers for Paris, London, France, and Berlin.
Probability trajectories for four candidate next tokens across GPT-2 layers for the prompt 'The Eiffel Tower is located in the city of'. Paris rises to rank first at the final layer, while London, Berlin, and France remain visible competitors, showing how the residual stream separates related geographic candidates.

This line plot shows the probability trajectory for each candidate token across all layers. The key insight is that the model's confidence in the correct answer, "Paris", typically starts low and rises sharply in later layers as the model integrates contextual evidence from the entire prompt. Competing tokens like "London" or "Berlin" may register brief peaks in early or middle layers before being overtaken.

The relative ordering is more informative than any one probability in isolation. Paris ultimately ranks above the other tracked candidates, but the nonzero alternatives show that GPT-2 Small retains uncertainty among related locations. This fine-grained trajectory view is something you cannot get from just looking at the final argmax; the logit lens makes the competition visible.