Repository navigation
fix: map head and neuron results through post-sublayer norms in residual decompositions - #1953
Merged
jlarson4 merged 1 commit intoOct 29, 2026
Conversation
…ual decompositions On Gemma 2/3, OLMo 2/3, EXAONE-4 and GLM-4 each sublayer output passes through ln1_post/ln2_post before the residual add, so a head or neuron contributes ln_post(x_i), not x_i. TransformerLensOrg#1648 fixed the block-level hook_attn_out/hook_mlp_out aliases, but stack_head_results, get_neuron_results/stack_neuron_results and get_full_resid_decomposition still stacked the pre-norm per-component outputs, so the full decomposition no longer summed to the residual stream (off by ~70% of its scale on tiny random models). With the cached scale the norm is affine, norm(x) = center(x) / s * w + b, so each component contributes center(x_i) / s * w and the block adds b once. The stacks now apply that map per position (folding it into W_out for neurons, so the analytic projected path stays memory-light), and the bias term of the full decomposition replaces those blocks' raw output biases with norm_map(bias) + norm bias. Pre-norm architectures are unchanged.
Collaborator
|
Looks good, thank you for putting this together @flyingdoubleG |
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
On architectures with post-sublayer norms, each attention/MLP output passes through
ln1_post/ln2_postbefore it is added to the residual stream. These are Gemma 2/3 (sandwich norms) and OLMo 2/3, EXAONE-4 and GLM-4 (post-norms). A head or neuron therefore contributesln_post(x_i)to the stream, notx_i.#1648 fixed the block-level aliases (
hook_attn_out/hook_mlp_outare now the post-norm outputs), sodecompose_residis exact on these models. But the per-component stacks built on top of them still use the pre-norm outputs:stack_head_resultsstacksattn.hook_result, the per-head output of the attention module beforeln1_post;get_neuron_results/stack_neuron_resultsusea_n * W_out_nbeforeln2_post;get_full_resid_decompositionadds the rawb_O/b_outthroughaccumulated_bias.As a result,
get_full_resid_decomposition(...).sum(0)no longer equals the residual stream on these models. On tiny random models (3 layers, d_model 64), comparing the sum at the final layer withresid_post:dev,expand_neurons=Falsedev,expand_neurons=True(max abs error relative to max abs
resid_post; the same holds in compatibility mode). For 32 other architectures I checked (Llama, Gemma 1, Granite, Qwen 2/3, Mistral, Phi, GPT-J, NeoX, Falcon, Cohere, BLOOM, GPT-2, Mixtral, Qwen2-MoE, OLMoE, …) the decomposition was already exact and is unchanged.Fix. With the cached scale
sthe post-norm is affine,norm(x) = center(x) / s * w + b. So each component contributescenter(x_i) / s * w, and the block adds the norm biasbonce. A new_post_norm_mapreadssfromln*_post.hook_scaleand the weight with the norm's own convention (uses_weight_offsetfor Gemma's1 + w, centering for LayerNorm). It is applied in:stack_head_results, per position and head. This is done at read time, soattn.hook_resultkeeps its meaning and results cached in the forward pass (use_attn_result=True) are mapped the same way;get_neuron_resultsand the analytic_stack_neuron_results_apply_ln_projectedpath, by foldingcenter(W_out_n) * wintoW_outand dividing the activations bys, so the projected path still never materializes[..., d_mlp, d_model];get_full_resid_decomposition: a per-position correction replaces those blocks' raw output biases withnorm_map(bias) + norm_bias, respectingresidual_contribution_scale.Pre-norm blocks have no
ln*_post, so nothing changes for them.Type of change
Checklist:
New
tests/integration/model_bridge/test_post_norm_head_decomposition.pybuilds seeded tiny Gemma 2, Gemma 3, OLMo 2 and (as a pre-norm control) Llama models from local configs, with no hub access. It checks:get_full_resid_decompositionsums toresid_pre/resid_mid/ the finalresid_postat every layer, with and withoutexpand_neurons. This fails ondevfor all three post-norm models and passes here;use_attn_result=Truegive the same stack as recomputed ones;apply_ln+project_output_ontopath matches LN-then-project of the dense stack.Local runs (Windows, CPU, torch 2.9.1,
--jaxtyping-packages=transformer_lens,beartype.beartype):tests/unit/test_activation_cache.py,tests/unit/model_bridge/test_mixed_norm_decomposition.py,tests/integration/model_bridge/test_{granite_embedding_decomposition,parallel_mlp_input_decomposition,parallel_residual_identities,residual_decomposition_identities,post_norm_head_decomposition}.py: 529 passed;tests/acceptance/test_activation_cache.py: 43 passed.black(the locked 23.12.1) andisort --check-onlypass, andmypy transformer_lens/ActivationCache.pyreports no issues.This was prepared with AI assistance (Claude); I reviewed and tested the change.