Backward Lens: gradient factors in vocabulary space¶
This notebook implements Backward Lens in a few short cells on top of TransformerBridge hooks and logit_readout, applies it to a small, fixed set of GPT-2 next-token targets, then repeats one reconstruction on Pythia-70m to show the same code handles both MLP weight layouts. It reproduces the method of projecting the forward inputs and backward signals of an MLP weight gradient into vocabulary space; it is not a paper-scale replication and makes no causal claim from token rankings
alone. All examples and layers are declared before analysis, and all outcomes are shown without filtering.
The method follows Katz et al. (2024). Gradient signs below are raw d(loss) / d(tensor) signs; gradient descent subtracts them.
Geometry¶
For one linear projection and one prompt, let \(x_i\) be the projection’s input at token position \(i\) and \(\delta_i = \partial L / \partial y_i\) the loss gradient at its output. The weight gradient is a sum of one outer product per position:
That is the gradient for [in, out] storage (Conv1D, as in GPT-2); torch.nn.Linear (Pythia/GPT-NeoX) stores [out, in], so its gradient is the transpose. A dense MLP has two projections, and each has exactly one residual-width factor that can be read in vocabulary space:
Projection |
Bridge hook points |
Weight shape ( |
Residual-width factor |
|---|---|---|---|
FF1 ( |
|
|
forward input \(x_i\): the post- |
FF2 ( |
|
|
backward signal \(\delta_i\) at the MLP output |
FF1 rows describe what entered the MLP; FF2 rows are raw loss gradients at its output. They are different quantities and should not be read as one signal.
Reading a factor in vocabulary space¶
Each residual-width row \(v\) is read as \(P(v) = \operatorname{Unembed}(\operatorname{LN}_{final}(v))\) with logit_readout, which recomputes the final norm’s statistics for every row instead of reusing the scales cached during the forward pass. The values are signed, pre-softmax logits, not probabilities. The Normalized Logit Lens reads \(P(v / \lVert v\rVert_2)\) instead. The final LayerNorm has an epsilon and a bias, so raw and normalized readouts can differ, mostly for rows
with very small norms such as backward signals; exact-zero rows stay zero.
Gradient descent applies \(W \leftarrow W - \eta \nabla_W L\), so for FF2 the smallest raw-gradient logits and ascending target ranks are the ordering relevant to an update. Negating projected logits is not equivalent: the final norm’s bias and epsilon make the readout not exactly sign-symmetric.
Restrictions¶
The implementation below is deliberately narrow:
Dense, non-gated MLP. It factors the two projections of a dense MLP (GPT-2, Pythia/GPT-NeoX). A gated MLP (Llama, Mistral, Qwen, Gemma) has a third, gate projection that is not handled here. Models whose MLP runs on flattened
[batch * position, width]tensors (OPT) or that have no MLP (Mamba, RWKV) do not fit either.Raw Bridge. Gradients are taken for the original weights, the ones training would update. Compatibility mode folds norms into the weights, which changes both the factors and the weights they belong to, so it stays off.
Evaluation mode. Dropout in training mode would perturb the gradients.
model.eval()disables it without disabling autograd, so do not wrap the analysis intorch.no_grad().One prompt, one target token. The loss is next-token cross-entropy at the final position against a single token. With GPT-2’s tokenizer a leading space usually matters, and the prompt’s own tokenization (including any BOS) defines the positions.
[1]:
# NBVAL_IGNORE_OUTPUT
import gc
import os
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
import torch.nn.functional as F
from huggingface_hub.utils import logging as hf_hub_logging
from IPython.display import display
from transformers.utils import logging as hf_logging
from transformer_lens.model_bridge import TransformerBridge
from transformer_lens.tools.analysis import logit_readout
# Loading messages and progress bars would otherwise land in the checked cell outputs.
hf_hub_logging.set_verbosity_error()
hf_logging.set_verbosity_error()
hf_logging.disable_progress_bar()
DEVICE = "cpu" # Stable saved outputs and portable notebook validation.
DETAILED_LAYERS = (0, 2, 4, 6, 8, 10, 11)
EXAMPLES = (
("The capital of France is", " Paris"),
("The capital of Italy is", " Rome"),
("The largest planet in the Solar System is", " Jupiter"),
)
pd.DataFrame(EXAMPLES, columns=["prompt", "one-token target"])
[1]:
| prompt | one-token target | |
|---|---|---|
| 0 | The capital of France is | Paris |
| 1 | The capital of Italy is | Rome |
| 2 | The largest planet in the Solar System is | Jupiter |
Load a raw GPT-2 Bridge¶
Boot the Bridge without compatibility mode or weight processing, then switch it to evaluation mode. CPU float32 keeps the saved outputs portable; change DEVICE for interactive exploration.
[2]:
model = TransformerBridge.boot_transformers("gpt2", device=DEVICE, dtype=torch.float32)
model.eval()
print(
f"Loaded a raw GPT-2 TransformerBridge: {model.cfg.n_layers} layers, "
f"d_model={model.cfg.d_model}, d_mlp={model.cfg.d_mlp}."
)
Loaded a raw GPT-2 TransformerBridge: 12 layers, d_model=768, d_mlp=3072.
Capture the factors¶
mlp_gradient_factors captures both sides of every requested projection with forward hooks, runs one forward pass, and calls torch.autograd.grad once for the loss’s gradient with respect to the captured projection outputs (the \(\delta_i\)) and the projection weights. The weight gradients serve only as a check: the outer-product sum must reproduce what autograd computes for the weight itself. Nothing is written to any parameter’s .grad, and run_with_hooks removes the hooks
when the forward pass returns.
vocab_logits is the readout: logit_readout with no token selector returns the full-vocabulary logits of each row.
[3]:
PROJECTIONS = ("in", "out")
SIDES = ("hook_in", "hook_out")
def mlp_gradient_factors(model, prompt, target, layers):
"""Rank-1 factors of each requested MLP weight gradient for one next-token loss."""
if model.compatibility_mode:
raise ValueError("Backward Lens reads the original weights; boot a raw Bridge")
if any(module.training for module in model.modules()):
raise ValueError("call model.eval() first: dropout would perturb the gradients")
if any(getattr(model.blocks[layer].mlp, "gate", None) is not None for layer in layers):
raise ValueError("a gated MLP has a third projection that this code does not factor")
target_ids = model.to_tokens(target, prepend_bos=False)[0]
if target_ids.numel() != 1:
raise ValueError(f"{target!r} is {target_ids.numel()} tokens; the loss takes one")
sites = [(layer, proj) for layer in layers for proj in PROJECTIONS]
captured = {}
def capture(tensor, hook):
captured[hook.name] = tensor
names = [f"blocks.{layer}.mlp.{proj}.{side}" for layer, proj in sites for side in SIDES]
tokens = model.to_tokens(prompt)
with torch.enable_grad():
logits = model.run_with_hooks(tokens, fwd_hooks=[(name, capture) for name in names])
loss = F.cross_entropy(logits[0, -1:], target_ids.to(logits.device))
outputs = [captured[f"blocks.{layer}.mlp.{proj}.hook_out"] for layer, proj in sites]
projections = [getattr(model.blocks[layer].mlp, proj) for layer, proj in sites]
weights = [projection.original_component.weight for projection in projections]
grads = torch.autograd.grad(loss, outputs + weights)
factors = {}
for (layer, proj), delta, weight_grad in zip(sites, grads[: len(sites)], grads[len(sites) :]):
x, delta = captured[f"blocks.{layer}.mlp.{proj}.hook_in"][0].detach(), delta[0].detach()
outer = x.T @ delta
# d_model != d_mlp, so the gradient's shape tells Conv1D [in, out] from Linear [out, in].
layout = "in_out" if weight_grad.shape == outer.shape else "out_in"
rebuilt = outer if layout == "in_out" else outer.T
factors[layer, proj] = {
"residual": x if proj == "in" else delta,
"layout": layout,
"relative_error": float((rebuilt - weight_grad).norm() / weight_grad.norm()),
}
return {"tokens": tokens[0].cpu(), "target_id": int(target_ids), "factors": factors}
def vocab_logits(model, rows, normalized=False):
"""Signed logits of residual-width rows read through the final norm and unembedding."""
if normalized:
norms = rows.norm(dim=-1, keepdim=True)
rows = rows / torch.where(norms == 0, torch.ones_like(norms), norms)
return logit_readout(model, rows).values
def ascending_rank(logits, token_id):
"""Zero-based rank counted from the smallest logit; tied logits share a rank."""
return (logits < logits[..., token_id, None]).sum(dim=-1)
def decode(model, ids):
return [model.tokenizer.decode([int(i)]).replace("\n", "\\n") for i in ids]
[4]:
prompt, target = EXAMPLES[0]
primary = mlp_gradient_factors(model, prompt, target, DETAILED_LAYERS)
position_tokens = decode(model, primary["tokens"])
worst_error = max(f["relative_error"] for f in primary["factors"].values())
print(f"Prompt positions: {position_tokens}")
print(f"Target {target!r} is token id {primary['target_id']}.")
print(
f"Factored {len(primary['factors'])} weight gradients "
f"(layouts: {sorted({f['layout'] for f in primary['factors'].values()})})."
)
print(f"Outer-product sums match autograd's weight gradients to 1e-5: {worst_error < 1e-5}")
Prompt positions: ['<|endoftext|>', 'The', ' capital', ' of', ' France', ' is']
Target ' Paris' is token id 6342.
Factored 14 weight gradients (layouts: ['in_out']).
Outer-product sums match autograd's weight gradients to 1e-5: True
Vocabulary-facing factor tables¶
FF1 top tokens describe the forward residual directions entering each MLP. FF2 bottom tokens use the ascending raw-gradient ordering relevant to a subtracted gradient-descent update. The layer-by-position table keeps the two factors separate. The final-position comparison shows how unit normalization changes the FF2 ordering, next to the original VJP (vector-Jacobian product) norm. None of these tables are model predictions.
[5]:
def joined(ids):
return ", ".join(decode(model, ids))
layer_position_rows, norm_comparison_rows = [], []
for layer in DETAILED_LAYERS:
ff1_top = vocab_logits(model, primary["factors"][layer, "in"]["residual"]).topk(3).indices
delta = primary["factors"][layer, "out"]["residual"]
ff2 = vocab_logits(model, delta)
ff2_bottom = ff2.topk(3, largest=False).indices
for position, token in enumerate(position_tokens):
row = (layer, position, token, joined(ff1_top[position]), joined(ff2_bottom[position]))
layer_position_rows.append(row)
normalized = vocab_logits(model, delta[-1], normalized=True)
# fp32 backends disagree in the fifth significant digit, so the norm shows three.
norm_comparison_rows.append(
(
layer,
f"{float(delta[-1].norm()):.2e}",
joined(ff2[-1].topk(5, largest=False).indices),
joined(normalized.topk(5, largest=False).indices),
)
)
position_columns = ["layer", "position", "prompt token", "FF1 top raw directions"]
display(
pd.DataFrame(
layer_position_rows, columns=[*position_columns, "FF2 bottom raw (update ordering)"]
)
)
norm_columns = ["layer", "VJP norm", "raw FF2 bottom tokens", "normalized FF2 bottom tokens"]
display(pd.DataFrame(norm_comparison_rows, columns=norm_columns))
| layer | position | prompt token | FF1 top raw directions | FF2 bottom raw (update ordering) | |
|---|---|---|---|---|---|
| 0 | 0 | 0 | <|endoftext|> | <|endoftext|>, Advertisements, \n | %], Thumbnail, 女 |
| 1 | 0 | 1 | The | The, the, The | ixel, Medium, iop |
| 2 | 0 | 2 | capital | capital, capital, Capital | inventoryQuantity, catentry, }}} |
| 3 | 0 | 3 | of | of, of, Of | Archdemon, ortium, Difference |
| 4 | 0 | 4 | France | France, France, French | anton, Seasons, figure |
| 5 | 0 | 5 | is | is, also, was | adr, adian, umn |
| 6 | 2 | 0 | <|endoftext|> | ,, the, \n | ometric, Associates, 76561 |
| 7 | 2 | 1 | The | latest, following, Official | orescent, oro, ulhu |
| 8 | 2 | 2 | capital | capital, punishment, penalty | illary, fig, Variant |
| 9 | 2 | 3 | of | the, course, interest | uably, iqueness, uments |
| 10 | 2 | 4 | France | Connection, France, 's | Emerald, baugh, DIT |
| 11 | 2 | 5 | is | now, not, also | EStreamFrame, ORPG, Dise |
| 12 | 4 | 0 | <|endoftext|> | ,, the, and | OTOS, ë, istg |
| 13 | 4 | 1 | The | resa, latest, Bottom | ��, Eth, � |
| 14 | 4 | 2 | capital | capital, punishment, city | nings, BILITIES, cles |
| 15 | 4 | 3 | of | the, Reach, course | motivating, Appearance, Cups |
| 16 | 4 | 4 | France | 's, ,, Telecom | ��, Antar, Explosive |
| 17 | 4 | 5 | is | now, not, also | Diseases, Firstly, Feet |
| 18 | 6 | 0 | <|endoftext|> | ,, ., and | SON, leys, VAL |
| 19 | 6 | 1 | The | resa, oret, Latest | �, ��, esters |
| 20 | 6 | 2 | capital | city, capital, cities | appings, UES, =-=- |
| 21 | 6 | 3 | of | the, State, British | ヘラ, TABLE, � |
| 22 | 6 | 4 | France | 's, ,, and | , /_, ciation |
| 23 | 6 | 5 | is | now, officially, not | Tycoon, 76561, Osiris |
| 24 | 8 | 0 | <|endoftext|> | ,, \n, . | Paris, xes, SON |
| 25 | 8 | 1 | The | resa, oret, following | Notting, ournals, EngineDebug |
| 26 | 8 | 2 | capital | city, capital, cities | Paris, Denver, Philadelphia |
| 27 | 8 | 3 | of | South, British, China | Frames, Reviewer, selage |
| 28 | 8 | 4 | France | 's, ,, has | ciation, ㅋㅋ, Spac |
| 29 | 8 | 5 | is | now, officially, still | Paris, Paris, Cologne |
| 30 | 10 | 0 | <|endoftext|> | \n, the, , | Paris, ゼウス, Hollande |
| 31 | 10 | 1 | The | resa, oret, latest | �醒, ��, ��� |
| 32 | 10 | 2 | capital | city, cities, capital | Paris, ��, � |
| 33 | 10 | 3 | of | China, Kazakhstan, Ukraine | �醒, ���, Paris |
| 34 | 10 | 4 | France | has, is, 's | cffff, eteenth, mathemat |
| 35 | 10 | 5 | is | now, officially, rapidly | Paris, Paris, Copenhagen |
| 36 | 11 | 0 | <|endoftext|> | The, Still, \n | �, �, |
| 37 | 11 | 1 | The | resa, odore, latest | �, �, |
| 38 | 11 | 2 | capital | city, cities, Manila | �, �, |
| 39 | 11 | 3 | of | Romania, Kazakhstan, Armenia | �, �, |
| 40 | 11 | 4 | France | has, is, 's | �, �, |
| 41 | 11 | 5 | is | undergoing, reeling, hosting | Paris, Paris, Copenhagen |
| layer | VJP norm | raw FF2 bottom tokens | normalized FF2 bottom tokens | |
|---|---|---|---|---|
| 0 | 0 | 6.56e-01 | adr, adian, umn, itia, igmatic | adr, adian, umn, itia, igmatic |
| 1 | 2 | 1.44e+00 | EStreamFrame, ORPG, Dise, misconception, senal | EStreamFrame, ORPG, Dise, misconception, senal |
| 2 | 4 | 1.33e+00 | Diseases, Firstly, Feet, heses, Differences | Diseases, Firstly, Feet, heses, Differences |
| 3 | 6 | 1.04e+00 | Tycoon, 76561, Osiris, Modes, Memories | Tycoon, 76561, Osiris, Modes, Memories |
| 4 | 8 | 4.87e-01 | Paris, Paris, Cologne, Copenhagen, Rome | Paris, Paris, Cologne, Copenhagen, Rome |
| 5 | 10 | 2.75e-01 | Paris, Paris, Copenhagen, Cologne, Kyoto | Paris, Paris, Copenhagen, Cologne, Kyoto |
| 6 | 11 | 2.61e-01 | Paris, Paris, Copenhagen, Marse, France | Paris, Paris, Copenhagen, Marse, France |
Target-token ranks: raw versus Normalized Logit Lens¶
Ranks are zero-based and ascending in raw-gradient space: rank 0 is the smallest projected FF2 gradient logit at the final position. Lower is therefore the update-relevant ordering, but this remains a directional diagnostic rather than a prediction of an optimizer step. Ranks are shown as vocabulary percentiles at one decimal.
[6]:
# Exact ranks can move by one across numeric backends when logits nearly tie; one-decimal
# vocabulary percentiles do not, so only those are displayed.
def final_position_ranks(result, layers):
"""Ascending target ranks among the final position's FF2 logits, raw and normalized."""
rows = []
for layer in layers:
delta = result["factors"][layer, "out"]["residual"][-1]
logits = [vocab_logits(model, delta, normalized=n) for n in (False, True)]
rows.append([layer, *(int(ascending_rank(row, result["target_id"])) for row in logits)])
ranks = pd.DataFrame(rows, columns=["layer", "raw rank", "normalized rank"])
for kind in ("raw", "normalized"):
percentile = 100 * ranks[f"{kind} rank"] / model.cfg.d_vocab
ranks[f"{kind} target rank percentile"] = percentile.round(1)
return ranks
ranks = final_position_ranks(primary, DETAILED_LAYERS)
display(ranks[["layer", "raw target rank percentile", "normalized target rank percentile"]])
fig, ax = plt.subplots(figsize=(7, 4))
for kind, marker in (("raw", "o"), ("normalized", "s")):
ax.plot(ranks["layer"], ranks[f"{kind} target rank percentile"], marker=marker, label=kind)
ax.set(
xlabel="layer",
ylabel="ascending target-rank percentile (lower is smaller)",
title=f"FF2 target-rank percentiles for {target!r}",
)
ax.grid(alpha=0.25)
ax.legend()
plt.show()
| layer | raw target rank percentile | normalized target rank percentile | |
|---|---|---|---|
| 0 | 0 | 80.9 | 80.9 |
| 1 | 2 | 41.3 | 41.4 |
| 2 | 4 | 11.2 | 11.2 |
| 3 | 6 | 4.4 | 4.4 |
| 4 | 8 | 0.0 | 0.0 |
| 5 | 10 | 0.0 | 0.0 |
| 6 | 11 | 0.0 | 0.0 |
VJP norm heatmap¶
FF2 factor norms are the norms of the backward signals at each MLP output. The logarithmic heatmap shows both position and layer variation without discarding low-norm rows. The final layer’s MLP output at an earlier position never reaches the final position’s logits, because no attention layer follows it, so those rows are exactly zero and the final-layer FF2 gradient has rank one.
[7]:
vjp_norms = np.stack(
[primary["factors"][layer, "out"]["residual"].norm(dim=-1).numpy() for layer in DETAILED_LAYERS]
)
zero_mask = vjp_norms == 0
masked_log_norms = np.ma.masked_where(zero_mask, np.log10(np.where(zero_mask, 1.0, vjp_norms)))
cmap = plt.colormaps["magma"].copy()
cmap.set_bad(color="#d1d5db")
fig, ax = plt.subplots(figsize=(10, 4.5))
image = ax.imshow(masked_log_norms, aspect="auto", cmap=cmap)
ax.set_yticks(range(len(DETAILED_LAYERS)), labels=DETAILED_LAYERS)
ax.set_xticks(range(len(position_tokens)), labels=position_tokens, rotation=35, ha="right")
for layer_index, position_index in np.argwhere(zero_mask):
ax.text(position_index, layer_index, "0", ha="center", va="center")
ax.set(xlabel="prompt position", ylabel="layer", title="FF2 VJP norms (gray = exactly zero)")
fig.colorbar(image, ax=ax, label="log10 L2 norm")
fig.tight_layout()
plt.show()
final_rows_zero = bool(zero_mask[-1, :-1].all())
print(f"Final-layer FF2 rows before the last position are exactly zero: {final_rows_zero}")
Final-layer FF2 rows before the last position are exactly zero: True
Predeclared examples: report every outcome¶
The remaining examples are analyzed only at GPT-2’s final layer to keep the demo compact. The table reports one-decimal vocabulary rank percentiles so nearly tied logits remain stable across numeric backends. It also records whether unit normalization improves, worsens, or leaves unchanged the target’s exact ascending rank. No example is removed based on its result.
[8]:
final_layer = DETAILED_LAYERS[-1]
OUTCOMES = {1: "improved", 0: "unchanged", -1: "worsened"}
comparison_rows = []
for example_prompt, example_target in EXAMPLES:
result = mlp_gradient_factors(model, example_prompt, example_target, [final_layer])
summary = final_position_ranks(result, [final_layer]).iloc[0]
outcome = OUTCOMES[int(np.sign(summary["raw rank"] - summary["normalized rank"]))]
percentiles = summary[["raw target rank percentile", "normalized target rank percentile"]]
comparison_rows.append((example_prompt, example_target, *percentiles, outcome))
comparison = pd.DataFrame(
comparison_rows, columns=["prompt", "target", *percentiles.index, "normalization outcome"]
)
display(comparison)
print("Normalization outcomes:", comparison["normalization outcome"].value_counts().to_dict())
| prompt | target | raw target rank percentile | normalized target rank percentile | normalization outcome | |
|---|---|---|---|---|---|
| 0 | The capital of France is | Paris | 0.0 | 0.0 | unchanged |
| 1 | The capital of Italy is | Rome | 0.0 | 0.0 | unchanged |
| 2 | The largest planet in the Solar System is | Jupiter | 0.0 | 0.0 | unchanged |
Normalization outcomes: {'unchanged': 3}
Beyond GPT-2: the other weight layout¶
The same functions run unchanged on any raw, non-gated dense-MLP Bridge. GPT-2’s Conv1D projections store [in, out]; Pythia/GPT-NeoX’s torch.nn.Linear projections store [out, in], so their weight gradient is the transpose of the outer-product sum. The single-layer reconstruction below repeats the first example on a raw Pythia-70m Bridge and reports the layout that the gradient’s shape resolved to, alongside the reconstruction check.
[9]:
pythia_model = TransformerBridge.boot_transformers(
"EleutherAI/pythia-70m", device=DEVICE, dtype=torch.float32
)
pythia_model.eval()
pythia_factors = mlp_gradient_factors(pythia_model, prompt, target, [0])["factors"][0, "in"]
print(f"Pythia-70m input projection weight layout: {pythia_factors['layout']}")
print(
"Pythia-70m reconstruction error within tolerance: "
f"{pythia_factors['relative_error'] < 1e-5}"
)
del pythia_model
_ = gc.collect()
Pythia-70m input projection weight layout: out_in
Pythia-70m reconstruction error within tolerance: True
Interpretation checklist¶
FF1 and FF2 tables project different factors; do not compare them as if they were the same signal.
FF2 uses raw loss-gradient signs. Gradient descent subtracts the gradient, so ascending ranks are highlighted.
LayerNorm bias and epsilon make projection non-linear enough that negating projected logits is not an exact update readout.
Normalized Logit Lens isolates direction from factor norm, but it need not improve target rank. The table above records null and contradictory outcomes.
Small reconstruction error validates the factorization numerically; it does not validate a semantic interpretation.
Hooks already installed on the model still apply during the forward pass, so remove them to analyze the unmodified computation.
This fixed GPT-2 sample demonstrates the method and its diagnostics, not population-level evidence about language-model learning or memory.
References¶
Shahar Katz, Yonatan Belinkov, Mor Geva, and Lior Wolf. 2024. Backward Lens: Projecting Language Model Gradients into the Vocabulary Space. Proceedings of EMNLP 2024, pages 2390–2422.
Authors’ research repository. This notebook does not import, vendor, or depend on that repository’s code.