How Linear Is a Transformer's Feed-Forward Block?¶
A transformer block has two halves: attention, which mixes information across tokens, and a feed-forward network (FFN), which transforms each token in place. The FFN is the bigger half — roughly two-thirds of the block's parameters — and it's widely read as where a model stores much of its learned computation.
So here's a simple structural question that turns out to have a surprising answer: how much of that feed-forward block is just a linear map?
Not "is it linear" — of course there's a nonlinearity bolted in the middle. The question is how much of the block's actual input→output behaviour, on the activations it really sees, a single matrix multiply could reproduce. We measured it across three pretrained models, and the headline is this: linear recoverability is learned, not architectural. Two models with the identical architecture and activation function can disagree completely about which of their blocks are linear.
This post walks through how to measure it yourself with PolyWeave, what we found, and one trap that nearly fooled us.
On this page:
- The measurement: linear recoverability
- Why closed-form, and not a trained probe
- What we found
- The payoff: a compression criterion
- What's next
- Try it out yourself
The measurement: linear recoverability¶
Treat an FFN block as a position-wise function g(x) = y, mapping a d-dimensional
token to a d-dimensional token. Decompose it as
g(x) = W*·x + b* + ρ(x)
where W*·x + b* is the best least-squares linear approximation of the block over its
own activation distribution, and ρ(x) is whatever's left over. The fraction of held-out
output variance the linear part explains is the block's linear recoverability, which
we write R²_lin. The rest, 1 − R²_lin, is its residual nonlinearity.
The key move is that W* can be solved in closed form — exact least squares, no
training loop, no learning rate to tune. That makes R²_lin an exact, reproducible
property of the block, not an artefact of how long you trained a probe.
In PolyWeave this is two steps: capture the block's (input, output) pairs as it runs,
then fit the closed-form linear map.
import torch
import torch.nn as nn
from polyweave.distill import fit_closed_form_linear
# A toy "block": a linear map plus a genuinely nonlinear residual. The (X**2 - 1)
# term is centred, so no linear map can absorb it — it shows up as residual.
torch.manual_seed(0)
d, N = 64, 8_000
X = torch.randn(N, d)
W_true = torch.randn(d, d) / d**0.5
M2 = torch.randn(d, d) / d**0.5
Y = X @ W_true.T + 0.25 * (X**2 - 1.0) @ M2
linear = nn.Linear(d, d)
result = fit_closed_form_linear(linear, X, Y)
print(f"R2_lin = {result.val_r2:.3f}") # 0.882 — mostly linear, ~12% genuine residual
fit_closed_form_linear solves the affine map on a training split and reports held-out
metrics on a fixed tail split, so the number is honest about generalisation, not just
training fit.
To run it on a real FFN, capture activations with IOCapture — a forward hook that
flattens every token into one regression row:
import torch, torch.nn as nn
from transformers import AutoModelForCausalLM, AutoTokenizer
from polyweave.distill import IOCapture, fit_closed_form_linear
model = AutoModelForCausalLM.from_pretrained("gpt2").eval()
tok = AutoTokenizer.from_pretrained("gpt2")
block = model.transformer.h[1].mlp # GPT-2's early FFN sub-block
with IOCapture(block, max_rows=30_000) as cap:
for text in corpus: # any iterable of strings
ids = tok(text, return_tensors="pt").input_ids[:, :128]
with torch.no_grad():
model(ids) # forward passes drive the hook
X, Y = cap.pairs() # [N, 768], [N, 768]
linear = nn.Linear(X.shape[1], Y.shape[1])
print(fit_closed_form_linear(linear, X, Y).val_r2) # ≈ 0.95 for GPT-2 block 1
Install the distillation extra
The real-model snippet needs transformers: pip install -e ".[distill]". The toy
example above needs only the core library.
Why closed-form, and not a trained probe¶
This is the part that nearly fooled us, and it's the reason the measurement is
trustworthy. Transformer activations are ill-conditioned: a handful of "outlier"
feature directions carry enormous variance compared to the rest. On data like that, a
plain nn.Linear trained with Adam can sit badly under-converged for tens of thousands
of steps — and an under-fit linear baseline makes the block look far more nonlinear than
it is.
Our own first runs did exactly this. A GPT-2 block that a short-trained linear probe scored at R² ≈ 0.25 is recovered by the exact closed-form map at R² ≈ 0.95. The entire "this block is nonlinear" story was an optimisation artefact.
| Linear baseline | What it measures | Failure mode |
|---|---|---|
| Trained probe (SGD/Adam) | How good a linear map you found in your budget | Under-converges on outlier-heavy activations; overstates nonlinearity by tens of R² points |
| Closed-form least squares | The exact best linear map | None — it's the true ceiling, deterministic and reproducible |
The lesson generalises: if you're probing representations with a trained linear model, make sure it has actually converged, or solve it in closed form and skip the doubt.
What we found¶
Across three pretrained decoders — GPT-2, Pythia-160m, and llama-160m — and all twelve blocks of each, three things stood out.
1. Linear recoverability is jagged across depth. There's no tidy "early layers
nonlinear, late layers linear" trend. GPT-2's per-block R²_lin runs roughly:
Near-perfectly linear blocks sit right next to strongly nonlinear ones.
2. It's not predicted by the activation function — it's learned. This is the clean result. GPT-2 and Pythia-160m are the same size with the same GELU activation, yet their depth profiles are opposite:
| Early block (1) | GPT-2 (GELU) | Pythia-160m (GELU) | llama-160m (SwiGLU) |
|---|---|---|---|
Linear recoverability R²_lin |
0.95 | 0.45 | 0.47 |
Same architecture, same nonlinearity, opposite answer. Whether a block is linearly recoverable is a property of the trained weights, not the design.
3. The residual is mostly not a simple product. Having measured the linear part
exactly, we probed the leftover ρ(x) with an explicit low-rank bilinear layer
(PolyWeave's PolyLinear) to ask: is the residual low-order multiplicative — the kind
of thing a single position-wise product could capture? Mostly, no. The bilinear probe
recovers only a few points of R², and — the clean negative result — its gain does not
scale with how nonlinear the block is (Pearson r ≈ 0 across 36 blocks). The
unrecovered computation is genuinely high-order, not one missed multiplication.
Make the depth plot
The per-block recoverability curve is the figure to look at. Once you have an
R²_lin per block, it's a few lines of matplotlib:
import matplotlib.pyplot as plt
blocks = range(1, 13)
plt.plot(blocks, gpt2_r2lin, "o-", label="GPT-2 (GELU)")
plt.plot(blocks, pythia_r2lin, "s-", label="Pythia-160m (GELU)")
plt.xlabel("FFN block"); plt.ylabel("linear recoverability R²_lin")
plt.legend(); plt.tight_layout(); plt.savefig("recoverability.png", dpi=150)
The two GELU curves crossing each other is the whole paper in one image.
The payoff: a compression criterion¶
This isn't just diagnostics. Linear recoverability is directly actionable for compression: where a block scores high, you can replace its whole FFN with a single linear layer and barely move the model.
The standard GPT-2 FFN (768 → 3072 → 768) is about 4.7M parameters. Its early block is
95% linearly recoverable — so a single closed-form linear map stands in for it at:
| Replacement for GPT-2 early FFN | Parameters | Compression | Held-out R² | ΔPerplexity |
|---|---|---|---|---|
| Closed-form linear map | 590,592 | ×8.0 | 0.954 | +0.77 |
Low-rank bilinear (PolyLinear) |
628,224 | ×7.5 | 0.956 | +0.50 |
An ×8 parameter cut on that block for +0.77 perplexity — and the recoverability map tells you which blocks admit this and which don't, before you spend any compute.
High R²_lin is necessary, not sufficient
One honest subtlety: a high recoverability score doesn't guarantee a cheap swap.
A block can be 96% linearly recoverable in variance yet still cost tens of perplexity
points when replaced, because the small residual lands on a direction the rest of the
model is sensitive to. Measure R²_lin to find candidates; confirm with a perplexity
check before trusting the swap.
What's next¶
The tools used here — fit_closed_form_linear, IOCapture, PolyLinear, and the
metrics in polyweave.distill — are all in the library; see Getting
Started to install and the Concepts page for the
multiplicative layers.
The bilinear probe in this post is a descendant of an earlier question: when does a neural network actually recruit multiplication, and can you measure it as it trains? That's the subject of the next post, on the pi-scale recruitment diagnostic — including an honest account of what that diagnostic does and doesn't tell you, which is part of what motivated the exact-measurement approach here.
Try it out yourself¶
Run every snippet in this post in your browser — no install required:
Notebook repo coming soon
The companion polyweave-notebooks repository isn't published yet — the badge above
points at its intended home. Until it lands, the snippets run as-is against an editable
install (pip install -e ".[distill]").