Logit Lens › Implementation
Language AI HandbookAll chapters 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
# 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()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 , 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.
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_idsThe hidden_states tuple contains tensors: the embedding layer output at index 0 (before any transformer layers) plus one entry per transformer layer at indices 1 through . Each tensor has shape , 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.
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, probsdef 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, tokensRunning the Logit Lens
Let's run the logit lens on our example prompt and examine the predictions at the last token position.
prompt = "The Eiffel Tower is located in the city of"
results, tokens = get_top_tokens_per_layer(prompt, position=-1, top_k=5)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.
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)
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.
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
)
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.