Mu & Viswanath 2018 · Ethayarajh 2019 · Li et al. 2020 · Su et al. 2021

Anisotropy: The Narrow Cone Problem

Every embedding you have ever shipped lives inside a thin wedge of its own vector space. Two unrelated words score 0.99. This is what causes it, how to measure it, and which of the four famous fixes actually helps you.

Prerequisites: dot products and what a matrix does to a vector. Covariance, PCA, whitening, normalising flows and contrastive uniformity are all built from zero.
10
Chapters
5
Interactive Sims
0.99
GPT-2 Random-Word Cosine
7
Hand-Worked Examples

Chapter 0: The Symptom

You have built a semantic deduplicator. Every incoming support ticket gets embedded by a pretrained language model, and if a new ticket's cosine similarity to an existing one exceeds 0.8, you merge them.

You ship it. Every ticket merges with every other ticket.

So you raise the threshold to 0.9. Everything still merges. 0.95. Still merging. At 0.99 you finally get some separation, and now the system is wildly unstable — a single word changed in a ticket flips it from "duplicate" to "unique."

You start debugging the obvious things. Is the tokenizer right? Are you accidentally embedding the same string twice? Is the pooling broken — are you averaging over padding tokens? All fine. So you run the one experiment that actually diagnoses this, and it takes four lines:

the four-line diagnosisimport numpy as np
V = embed(["kumquat", "antitrust", "bicycle", "melancholy", "tungsten"])   # totally unrelated
V = V / np.linalg.norm(V, axis=1, keepdims=True)
print(V @ V.T)

You expect a matrix with ones on the diagonal and something near zero everywhere else. Five words with nothing in common should point in five unrelated directions. What comes back instead, if the model is a mid-sized decoder-only language model, is a matrix where every single entry is above 0.9.

It is worth seeing both outcomes side by side, because "what healthy looks like" is the part nobody shows you:

the same four lines, two different modelsa CONTRASTIVELY TRAINED sentence embedder:        a DECODER LM's last hidden layer:

 1.00  0.07 -0.02  0.11  0.04                     1.00  0.97  0.98  0.96  0.97
 0.07  1.00  0.09 -0.05  0.02                     0.97  1.00  0.99  0.97  0.98
-0.02  0.09  1.00  0.03  0.08                     0.98  0.99  1.00  0.98  0.99
 0.11 -0.05  0.03  1.00 -0.01                     0.96  0.97  0.98  1.00  0.97
 0.04  0.02  0.08 -0.01  1.00                     0.97  0.98  0.99  0.97  1.00

off-diagonal mean = 0.036                         off-diagonal mean = 0.977
off-diagonal range = 0.16                         off-diagonal range = 0.03

Read the last two lines rather than the matrices. The healthy model puts unrelated words 0.036 apart on average with a spread of 0.16 — the spread is four times the mean, so a real similarity has plenty of room to stand out. The sick model puts them at 0.977 with a spread of 0.03. Every measurement you will ever make on that second model happens inside a window narrower than the width of the first model's noise.

The model is not confused about the words. The model is confused about nothing at all. The embeddings are perfectly informative — the information is just sitting on top of an enormous constant offset that the cosine cannot see past. Your similarity function is measuring the offset, not the words. This is anisotropy: the property that a set of vectors is not spread evenly in all directions, but concentrated in a narrow cone.

What "anisotropic" means, before any maths

Take a jar and fill it with tiny arrows, all pointing outward from the centre in every direction. That is an isotropic cloud — from Greek isos (equal) and tropos (turn). Every direction is equally represented. Pick two arrows at random and their angle is, on average, a right angle. Their cosine averages zero.

Now take the same jar and glue every arrow to a broom handle, so that they all point roughly north, wobbling by a few degrees. That is anisotropic — the directions are not equal. Pick two arrows at random and the angle between them is a few degrees, so their cosine is near 1. The wobble — the only part carrying information — is drowned out by the broom handle.

Pretrained language-model embeddings are the second jar. And it is not subtle. The measured numbers, from the papers this lesson is built on:

RepresentationAverage cosine between two random itemsSource
An honestly isotropic cloud in 768 dimensions0.000, with a spread of about 0.036Derived in Chapter 1
word2vec / GloVe static vectorsSmall but clearly non-zero — the mean vector has a norm roughly a sixth to a half of the average word-vector normMu & Viswanath 2018
BERT, middle layersWell above zero; rises with depthEthayarajh 2019
GPT-2, last layerNearly 1.0 — two uniformly random words are almost perfectly similarEthayarajh 2019

That last row deserves a moment. In the final layer of GPT-2, the average cosine similarity between the contextual representations of two words drawn uniformly at random from a corpus is approximately 0.99. Not 0.99 for related words. For random words. The entire useful signal lives in the last two decimal places.

The pairwise-cosine histogram

Ten thousand random pairs drawn from a synthetic embedding cloud, histogrammed by cosine similarity. The dashed outline is what an honestly isotropic cloud of the same dimension would look like — centred on zero, width 1/√d. Slide the common-direction offset up and watch the entire distribution slide right and squeeze. The shape never changes. The information is all still there; it has just been pushed into a sliver.

Common offset 0.00
Dimension d 768

Push the dimension slider around with the offset at zero. Notice that the isotropic histogram gets narrower as d grows — in high dimensions, random directions are very reliably near-orthogonal. That is the baseline the model destroys.

"Just recalibrate the threshold"

The first response every engineer has, and it is a reasonable one: if all my cosines live in [0.95, 0.999], I have plenty of floating-point precision left. Rescale the interval to [0, 1] and carry on.

This works if and only if the squashing is order-preserving — if the mapping from "true semantic similarity" to "observed cosine" is monotone. If it is, you have lost nothing but readability. If it is not, your ranking is corrupted and no threshold, no calibration, no temperature will recover it.

So: is it monotone? Let us find out with actual arithmetic, because this is the hinge of the entire lesson.

The anatomy of a 0.99, by hand

Work in three dimensions so every number is visible. Suppose the corpus mean — the average of all embeddings in your vocabulary — is a large vector pointing down the first axis:

μ = (10, 0, 0),   ‖μ‖ = 10

Every word's embedding is that mean plus a small individual residual — the part that actually says which word it is. Here are four words. The first two are one pair; the second two are another:

WordResidual xEmbedding μ + x
A(0.5, 0.8, 0.2)(10.5, 0.8, 0.2)
B(0.3, −0.6, 0.5)(10.3, −0.6, 0.5)
A′(3.0, 0.8, 0.2)(13.0, 0.8, 0.2)
B′(2.8, −0.6, 0.5)(12.8, −0.6, 0.5)

Look carefully at what is the same and what is different. The components perpendicular to μ — the second and third coordinates, the only coordinates that distinguish one word from another once you know they are all in the same cloud — are identical between the two pairs. A and A′ have perpendicular residual (0.8, 0.2). B and B′ have perpendicular residual (−0.6, 0.5). The only difference between pair one and pair two is how far along the common direction they sit: 0.5 and 0.3 versus 3.0 and 2.8.

By any semantic reading, the two pairs are equally similar. Their distinguishing content is literally the same vector. Now compute the cosines.

Pair one. First the dot product:

v · w = (10.5)(10.3) + (0.8)(−0.6) + (0.2)(0.5) = 108.15 − 0.48 + 0.10 = 107.77

Then the norms:

‖v‖ = √(110.25 + 0.64 + 0.04) = √110.93 = 10.53233
‖w‖ = √(106.09 + 0.36 + 0.25) = √106.70 = 10.32957
cos(A, B) = 107.77 / (10.53233 × 10.32957) = 107.77 / 108.79447 = 0.99058

Pair two. Same three steps:

v′ · w′ = (13.0)(12.8) + (0.8)(−0.6) + (0.2)(0.5) = 166.4 − 0.48 + 0.10 = 166.02
‖v′‖ = √(169 + 0.68) = √169.68 = 13.02613
‖w′‖ = √(163.84 + 0.61) = √164.45 = 12.82381
cos(A′, B′) = 166.02 / (13.02613 × 12.82381) = 166.02 / 167.04455 = 0.99387
There it is. Two pairs whose distinguishing content is bit-for-bit identical score 0.99058 and 0.99387. The second pair ranks above the first. Nothing about their meaning differs; the only difference is their position along the common direction. Any ranking that mixes both pairs is now wrong, and it is wrong in a way that no monotone recalibration can repair — because the corruption is not a monotone function of the true similarity, it is a function of an unrelated third variable.

Why that happened — the second-order expansion

The two numbers above are not a coincidence, and we can get the general law with one short derivation. Write each embedding as μ + x with ‖x‖ small compared to ‖μ‖. Define the unit vector μ̂ = μ/‖μ‖, and rescale the residuals so the algebra is dimensionless:

a = x / ‖μ‖ ,   b = y / ‖μ‖ ,   p = μ̂ · a ,   q = μ̂ · b

Split each residual into the part along the common direction and the part perpendicular to it: a = pμ̂ + a. Now the numerator of the cosine:

(μ+x) · (μ+y) = ‖μ‖2 ( 1 + p + q + a·b ) = ‖μ‖2 ( 1 + p + q + pq + a·b )

And one of the norms, expanded to second order using √(1+ε) ≈ 1 + ε/2 − ε2/8:

‖μ+x‖ = ‖μ‖ √(1 + 2p + ‖a‖2) ≈ ‖μ‖ ( 1 + p + ½‖a2 )

where the p2 terms cancelled because ‖a‖2 = p2 + ‖a2. Multiply the two norms, divide, and keep second order:

cos(μ+x, μ+y) ≈ 1 − ‖a − b2 / ( 2(1 + p + q) )

Read that formula slowly, because it explains everything in this chapter and predicts every fix in the rest of the lesson.

What the formula saysWhat it means in practice
The whole expression is 1 minus something smallEvery cosine is near 1. That is the symptom, derived rather than observed
The something small is a squared Euclidean distance between residualsCosine in a strongly offset cloud is not measuring an angle at all. It is secretly measuring distance — and doing it badly
The residual distance is divided by ‖μ‖2 (hidden in the rescaling a = x/‖μ‖)The bigger the offset, the more the real signal is compressed. This is the "squeeze" you see in the sim
It is divided by (1 + p + q)This is the ranking corruptor. Two pairs with the same residual distance get different scores because they sit at different places along μ̂

Check it against the hand computation. For pair one, p = 0.5/10 = 0.05 and q = 0.3/10 = 0.03; the perpendicular residuals divided by ‖μ‖ are a = (0, 0.08, 0.02) and b = (0, −0.06, 0.05), so their difference is (0, 0.14, −0.03) with squared length 0.0196 + 0.0009 = 0.0205. Then:

1 − 0.0205 / (2 × 1.08) = 1 − 0.00949 = 0.99051  (exact: 0.99058)

For pair two, p = 0.30 and q = 0.28, the perpendicular residuals are unchanged, so:

1 − 0.0205 / (2 × 1.58) = 1 − 0.00649 = 0.99351  (exact: 0.99387)

Both within four decimal places of the truth, from a two-term Taylor expansion. And the ratio of the two error terms is exactly 1.58/1.08 = 1.463 — the pair sitting further out along the common direction has its dissimilarity shrunk by 46%, for no semantic reason whatsoever.

Inline concept check — answer before reading on. What single quantity, if you knew it for every word, would let you undo the (1 + p + q) corruption exactly?  …  The projection of each embedding onto μ̂. And "remove the component of every vector along a particular direction" is precisely the operation Chapter 3 is about. Everything after this point is variations on the theme of finding the right directions to delete.

How wide is the band your signal lives in?

The formula gives us something better than a diagnosis: it gives us a budget. We can compute, in advance, how much of the [−1, 1] cosine range the model has left us, and how much of that is corruption rather than signal. Two short calculations, and both of them are the kind of thing you should be able to do on a napkin before you start debugging.

Write ρ for the typical norm of a residual and keep ‖μ‖ for the offset, so r = ρ/‖μ‖ is the residual size in units of the offset. Two independent residuals in high dimensions are close to orthogonal, so the squared distance between their perpendicular parts is

‖a − b2 = ‖a2 + ‖b2 − 2 a·b ≈ r2 + r2 − 0 = 2r2

Substitute into the expansion with p and q small, and the cosine between two unrelated items is about 1 − r2. A genuine duplicate has near-identical residuals, so its distance term is near zero and its cosine is near 1. Everything you will ever measure therefore lives in:

the usable band = [ 1 − r2 , 1 ] ,   width r2 = ( ρ / ‖μ‖ )2

Put the chapter's own numbers in. Residual norms of about 1, offset of 10, so r = 0.1 and the band is 0.01 wide — the entire dynamic range of your similarity function is one part in a hundred, and everything from "identical" to "utterly unrelated" is packed into it. That is a compression of the honest range by a factor of (‖μ‖/ρ)2 = 100.

Offset ratio ‖μ‖/ρUsable cosine bandCompression vs an honest [0, 1]
0 — no offset[0.00, 1.00], width 1.001× — nothing lost
1[0.00, 1.00], width 1.00
3[0.889, 1.000], width 0.111
10[0.990, 1.000], width 0.010100×
30[0.9989, 1.0000], width 0.0011900×

Now the question everybody asks next: is that a precision problem? Do we run out of floating-point resolution? Run the arithmetic and the answer is a clear no. A float32 near 1.0 has a spacing of 2−24 = 5.96×10−8, so a band of width 0.01 still contains 0.01/5.96×10−8 = about 168,000 distinguishable values. You could rank a corpus of a hundred thousand documents with no ties at all. Precision is not the enemy.

The enemy is the second calculation. Differentiate the expansion with respect to the position term, holding the residual distance fixed:

∂cos / ∂(p+q) = ‖a − b2 / ( 2(1+p+q)2 ) ≈ r2/2  (at small p, q)

So a change of Δ in (p+q) moves the cosine by about r2Δ/2 — and the entire signal band was only r2 wide. Divide one by the other and the r2 cancels completely:

corruption / signal ≈ Δ(p+q) / 2 ,    Δ(p+q) ≈ 2 × (spread along μ̂) / ‖μ‖
Read what just cancelled. The size of the offset sets how narrow the band is, and narrowness is harmless — you have 168,000 float32 levels inside it. What decides whether your ranking survives is a completely different quantity: how much the items vary in their position along the common direction, relative to the offset. In our four-word example the first-coordinate residuals were 0.5, 0.3, 3.0, 2.8, whose standard deviation is 1.254, so Δ(p+q) ≈ 2 × 1.254/10 = 0.251 — a quarter of the whole signal band is spurious, and that is exactly the 46% max-to-min shrink we measured. Two clouds with identical ‖μ‖ can have completely different amounts of corruption. This is why "how offset is the cloud" and "how pencil-shaped is the cloud" are two separate measurements, which is the entire subject of Chapter 1.

Why this is not fixed by normalising

A common half-fix: L2-normalise every embedding before storing it. Does that help? No, and it is worth understanding exactly why, because it is the most common wasted afternoon in this problem space.

Cosine similarity already normalises — it is defined as the dot product of the unit vectors. Pre-normalising your stored vectors changes the arithmetic you do at query time (a plain dot product instead of a cosine) but not a single one of the resulting numbers. Normalisation moves points onto the unit sphere; it does not move them apart on that sphere. The broom handle is still a broom handle after you cut all the arrows to the same length.

What normalising does change is the mean. Before normalising, the mean of the four words above is dominated by whichever ones are longest. After normalising, every word contributes equally to the mean direction. That matters for Chapter 3's arithmetic and is worth knowing, but it does not touch the symptom.

Does not help
L2-normalise the vectors — cosine already does this. Zero change to any similarity.
Helps a little
Rescale the observed cosine range to [0,1]. Fixes readability and thresholds. Does not fix ranking — the corruption is not monotone in the true similarity.
Actually helps
Remove the offending directions from the vectors themselves — subtract the mean, delete the dominant principal components, or reshape the whole covariance. Chapters 3 and 6.
Helps most
Train so the geometry never degenerates in the first place — a contrastive objective with an explicit uniformity term. Chapter 7.

Does Euclidean distance escape it?

The other reflexive fix, and the more interesting one, because half of it works. Cosine is the problem; is plain Euclidean distance immune?

To the offset, completely and exactly. Write two embeddings as μ + x and μ + y and subtract:

(μ + x) − (μ + y) = x − y

The mean cancels identically. No approximation, no small-residual assumption — Euclidean distance simply cannot see a common offset, because translation does not change distances. Every one of the 0.99-everything symptoms that comes from ‖μ‖ being large is invisible to it. That is a real and underused fact.

But run it on the four words from Chapter 3 and watch what happens anyway. Nearest neighbour of cat by Euclidean distance:

euclidean distances from cat = (4.6, 1.0, 0.5)cat - dog   = (3.2,  0.2, -0.4)   ||.|| = sqrt(10.24 + 0.04 + 0.16) = sqrt(10.44) = 3.2311
cat - car   = (3.2,  1.9,  1.1)   ||.|| = sqrt(10.24 + 3.61 + 1.21) = sqrt(15.06) = 3.8807
cat - truck = (0.0,  1.9,  1.3)   ||.|| = sqrt( 0.00 + 3.61 + 1.69) = sqrt( 5.30) = 2.3022

nearest neighbour of cat = truck          # the SAME wrong answer as cosine
Euclidean distance eliminated the offset and got the identical wrong answer. The reason is the one this chapter has been circling: the damage is not done by μ itself but by variation along μ̂. Look at the first coordinate of each difference. cat and truck differ by exactly 0 along the shared axis, so that axis contributes nothing to their distance; cat and dog differ by 3.2, and 10.24 of their 10.44 squared distance — 98% of it — is that one meaningless coordinate. Euclidean distance weights every axis equally, so an axis carrying nothing but frequency gets the same vote as an axis carrying meaning.

Which sets up the fix precisely. Neither metric is wrong; both are reading a space whose axes are not equally informative. The repair is not to change the metric, it is to change the space so that equal weighting is correct — delete the offending axis, or rescale every axis by how much variance it deserves. Those are Chapters 3 and 6, and this is why Chapter 9's cross-domain bridge ends up at the Mahalanobis distance: "Euclidean distance after rescaling by the covariance" is the metric that would have got this right.

The same bug in four costumes

You met this as a deduplication bug. It is worth recognising the other three disguises, because they arrive on different teams' dashboards and get filed as four unrelated tickets.

Costume one: k-means will not separate anything. Run k-means on an offset cloud and every centroid inherits the offset, because a centroid is an average and the average of μ + xi is μ + x̄. Do the arithmetic on our four words. Suppose the clustering correctly assigns {cat, dog} and {car, truck}, so:

two centroids, both dominated by the offsetc1 = (cat + dog)/2   = ((4.6+1.4)/2, (1.0+0.8)/2, (0.5+0.9)/2) = (3.00,  0.90,  0.70)
c2 = (car + truck)/2 = ((1.4+4.6)/2, (-0.9-0.9)/2, (-0.6-0.8)/2) = (3.00, -0.90, -0.70)

separation between centroids = ||c1 - c2|| = ||(0, 1.8, 1.4)|| = sqrt(3.24+1.96) = 2.2804
distance of each centroid from the origin = sqrt(9 + 0.81 + 0.49) = 3.2094

cos(c1, c2) = (9 - 0.81 - 0.49) / (3.2094 x 3.2094) = 7.70 / 10.30 = 0.74757

Two centroids representing categorically different things, at cosine 0.75. Now feed a new point and ask which centroid it belongs to. If your assignment step uses cosine — and spherical k-means does — you are choosing between two options that are three-quarters identical, so a tiny amount of noise flips the assignment. The clusters exist; the assignment rule cannot see them.

Costume two: your approximate-nearest-neighbour index stops pruning. Every ANN structure — HNSW, IVF, ScaNN — earns its speed by discarding candidates: it computes a cheap bound and skips whole regions whose best possible score is below the current best. In a narrow cone there are no bad regions. Everything is a plausible candidate, so the graph traversal cannot terminate early and the index degrades toward a brute-force scan. The symptom is that recall is fine but latency is three times what the benchmark promised, and adding more shards does not help.

Costume three: your reranker looks broken. A cross-encoder reranker is trained on pairs and outputs a calibrated relevance score. Feed it the top-100 from an anisotropic retriever and the reranker's scores look uncorrelated with the retriever's. That is not a reranker bug — the retriever handed it 100 documents whose ordering was substantially decided by position along the common direction, so the correct document may not be in the 100 at all. You will spend a week tuning the reranker before you check the retriever's recall@100.

Costume four: diversity selection collapses. Maximal marginal relevance picks the next document by trading off relevance against similarity to what is already selected: score = λ·rel − (1−λ)·maxselected cos. If every cos is 0.99 regardless of content, the penalty term is a constant, so MMR degenerates into plain relevance ranking and the results are as redundant as before. Somebody will conclude MMR does not work.

Ticket as filedWhat it actually is
"Dedup merges everything"The band [1−r2, 1] is narrower than your threshold's resolution
"Clustering gives one giant cluster"Centroids inherit μ, so they are all mutually similar
"Vector search got slow after we changed models"The new model is more anisotropic; the index cannot prune
"The reranker disagrees with the retriever"The retriever's top-k was ordered by the common direction
"MMR does not diversify"A near-constant similarity penalty is not a penalty

Costing the corruption: what it does to the deduplicator

We now have enough machinery to answer the question the opening story raised and never settled: how much worse does the deduplicator actually get? Not "it feels unstable" — a number.

Model the two populations of pairs the system sees. Work in units of the band width r2, and describe each pair by its dissimilarity δ = 1 − cos, which the expansion tells us is the squared residual distance over 2(1+p+q).

PopulationDissimilarity δ, in units of r2Where the spread comes from
True duplicates0.10 ± 0.03Genuine small differences in wording
Unrelated pairs1.00 ± 0.30Genuine variation in how unrelated things are

The standard way to score a two-population separation is d′ (spoken "d-prime"), the gap between the two means measured in units of their pooled standard deviation. It is the right summary because it is exactly what determines the error rate at the best possible threshold, and because it is invariant to any rescaling — which is why the "just recalibrate" proposal cannot change it.

d′ = ( μrand − μdup ) / √( (σdup2 + σrand2) / 2 )
before the corruption termd' = (1.00 - 0.10) / sqrt((0.03^2 + 0.30^2)/2)
   = 0.90 / sqrt((0.0009 + 0.0900)/2)
   = 0.90 / sqrt(0.04545) = 0.90 / 0.21320 = 4.2216

error rate at the best threshold = Phi(-d'/2) = Phi(-2.1108) = 1.74%

Now switch the corruption on. The (1 + p + q) denominator multiplies every δ by a factor whose mean is 1 and whose spread we computed above as Δ(p+q) ≈ 0.25. That is multiplicative noise, so it scales with the value it corrupts and adds in quadrature to each population's own spread:

after the corruption termduplicates:  sd = sqrt(0.03^2 + (0.10 * 0.25)^2) = sqrt(0.0009 + 0.000625) = 0.03905
unrelated :  sd = sqrt(0.30^2 + (1.00 * 0.25)^2) = sqrt(0.0900 + 0.0625) = 0.39051

d' = 0.90 / sqrt((0.03905^2 + 0.39051^2)/2)
   = 0.90 / sqrt((0.001525 + 0.152498)/2)
   = 0.90 / sqrt(0.077012) = 0.90 / 0.27751 = 3.2431

error rate = Phi(-1.6216) = 5.25%
The error rate goes from 1.74% to 5.25% — it triples — and no choice of threshold recovers it. That is the answer to "just recalibrate", stated in the only currency that matters. A rescaling moves both populations by the same monotone map, so it changes neither d′ nor the error rate; it just makes the numbers on the dashboard prettier. The 3× is bought back only by removing the corruption at its source, which means removing the component along the common direction. Notice also which quantity did the damage: not the offset ‖μ‖, which merely narrowed the band, but the variation in position along μ̂. Same conclusion as the last section, now priced.
One test settles all five. Embed a handful of deliberately unrelated strings, normalise, and print the Gram matrix — the four lines at the top of this chapter. If the off-diagonal entries are not near zero, stop debugging the component that filed the ticket. Every one of these symptoms is downstream of the same geometry, and no amount of work on the downstream component will fix it.

Four papers, one problem

This lesson is built on four papers that form a clean intellectual arc across four years. Each one is a complete answer to a different part of the question.

PaperIts one contributionChapter
Mu & Viswanath 2018
All-but-the-Top
Diagnosed the common mean plus a handful of dominant principal components in static word vectors, and showed that simply deleting them improves nearly every downstream task. Three lines of numpy, no training3
Ethayarajh 2019
How Contextual are Contextualized Word Representations?
Measured anisotropy layer by layer in ELMo, BERT and GPT-2, showed it gets worse with depth, and established that every similarity claim about contextual embeddings must be anisotropy-adjusted or it is meaningless4
Li et al. 2020
BERT-flow
Reframed the problem as a distribution-matching problem and learned an invertible map from BERT's embedding distribution to a standard Gaussian, which is isotropic and has no holes5
Su et al. 2021
Whitening
Showed that the flow's benefit is captured almost entirely by a closed-form linear transform — centre, rotate, rescale — that needs no training at all and can reduce dimension at the same time6

And then, in the same year, SimCSE made most of them obsolete for anyone who can afford a training run — which is Chapter 7's honest reckoning, and the reason this lesson is not a manifesto for post-processing.

Where we are going

Chapters 1–2 — understand it
Two exact ways to measure anisotropy, including an identity you can check by hand → then the three mechanisms that cause it, one of which is a symmetry the loss function literally cannot see
Chapters 3–6 — the four fixes
All-but-the-Top derived and worked by hand in three dimensions → the layer-by-layer anatomy of contextual models → the flow → the closed-form whitening, with a 2×2 covariance done in full
Chapters 7–9 — interrogate it
When these fixes hurt, and why contrastive training won → anisotropy and superposition as two lenses on one geometry → a diagnosis recipe you can run on your own embeddings this afternoon
Your embeddings have an average pairwise cosine of 0.97. A colleague proposes rescaling the observed range [0.94, 0.999] linearly onto [0, 1] so the numbers look normal again. What does this fix, and what does it not?

Chapter 1: Measuring It

Chapter 0 established that something is wrong. Before we can fix it we need a number — ideally several, because "anisotropy" turns out to be two different geometric facts wearing one name, and the fixes in later chapters attack them separately.

We will build two measurements. The first is the one everybody uses and the one every paper reports. The second is the one that actually tells you which fix to reach for.

Measurement one: average pairwise cosine

The obvious thing. Take n embeddings, compute the cosine between every pair, average.

A = (1 / (n(n−1))) ∑ij≠i cos(vi, vj)

Ethayarajh calls this the anisotropy baseline, and the name is exactly right: it is what a similarity of "unrelated" looks like in this space. If two random words score A, then a pair scoring A tells you nothing. Only the excess over A is information.

Computing A the naive way is O(n2d), which for a million embeddings in 768 dimensions is 7.7×1014 multiplications — not happening. But you never need to. There is an exact identity that reduces it to a single mean.

The identity that makes it free

Let v̂i = vi/‖vi‖ be the unit-normalised embeddings, so that cos(vi, vj) = v̂i · v̂j. Let μ̂ be their mean:

μ̂ = (1/n) ∑ii

Now expand the squared norm of that mean. This is the whole trick:

‖μ̂‖2 = μ̂ · μ̂ = ( (1/n) ∑ii ) · ( (1/n) ∑jj ) = (1/n2) ∑iji · v̂j

The right-hand side is the average of all n2 pairwise cosines, including the n self-pairs, each of which equals exactly 1. Peel those off:

n2 ‖μ̂‖2 = n · 1 + ∑i≠ji · v̂j = n + n(n−1) A

Solve for A:

A = ( n ‖μ̂‖2 − 1 ) / ( n − 1 )  —  and as n grows, A → ‖μ̂‖2
The average pairwise cosine is the squared length of the mean unit vector. That is not an approximation, it is an identity. It costs one pass over your data and one dot product. And it means the whole "average cosine" measurement is really a statement about one thing: how far the cloud's centre of mass has drifted from the origin, measured on the unit sphere.

Worked example: five vectors, by hand

Five unit vectors in the plane, at angles 10°, 25°, 40°, 5°, 30°. A cone 35 degrees wide — a caricature of a real embedding cloud, but the arithmetic is checkable.

The slow way. Ten pairs. For unit vectors in the plane the cosine of the pair is just the cosine of the angle difference, so:

all ten pairwise cosines(10,25) -> cos 15 = 0.9659    (25,40) -> cos 15 = 0.9659
(10,40) -> cos 30 = 0.8660    (25, 5) -> cos 20 = 0.9397
(10, 5) -> cos  5 = 0.9962    (25,30) -> cos  5 = 0.9962
(10,30) -> cos 20 = 0.9397    (40, 5) -> cos 35 = 0.8192
                              (40,30) -> cos 10 = 0.9848
                              ( 5,30) -> cos 25 = 0.9063

sum = 9.3799        A = 9.3799 / 10 = 0.93799

The identity way. Sum the five unit vectors component by component:

Anglecossin
10°0.984810.17365
25°0.906310.42262
40°0.766040.64279
0.996190.08716
30°0.866030.50000
sum4.519381.82622
μ̂ = (4.51938/5, 1.82622/5) = (0.90388, 0.36524)
‖μ̂‖2 = 0.81700 + 0.13340 = 0.95040
A = (5 × 0.95040 − 1)/4 = (4.75200 − 1)/4 = 3.75200/4 = 0.93800

Two methods, ten pairwise cosines versus one sum, and they agree to five decimal places. Also note ‖μ̂‖ = 0.9749 — the mean of five unit vectors has length 0.975. If those five vectors were pointing in genuinely unrelated directions, the mean of five unit vectors would have length around 1/√5 = 0.447. The cloud's centre of mass has barely moved off the sphere. That is the narrow cone, quantified.

The floor: why zero is not the target

An obvious idea now presents itself: subtract the mean, and A becomes 0. Isotropy achieved. Except it does not, and the reason is a constraint you cannot escape.

Suppose we centre the vectors so that ∑i ui = 0. Then the same expansion, run backwards:

0 = ‖∑i ui2 = ∑i ‖ui2 + ∑i≠j ui · uj  ⇒   ∑i≠j ui · uj = − ∑i ‖ui2

The off-diagonal dot products are forced to sum to a negative number. If all the vectors happen to have the same length, dividing through by n(n−1)‖u‖2 gives:

Acentred = − 1 / (n − 1)
Centring does not produce A = 0. It produces A = −1/(n−1) exactly. For n = 5 that is −0.25; for a million embeddings it is −0.000001, which rounds to zero. This is a finite-sample bookkeeping fact, not a property of the geometry: if the average of a set of vectors is zero, they must on balance point away from each other. Keep it in your head, because it will appear as the exact answer to two later hand computations — and if you ever see A land precisely on −1/(n−1), you have not achieved isotropy, you have merely centred.

The isotropic baseline — what "unrelated" should look like

What should A be for a genuinely isotropic cloud? Zero, by symmetry — but the more useful question is how tightly it concentrates around zero, because that sets the scale of "surprising."

Take v fixed and u uniform on the unit sphere in Rd. Rotate coordinates so v is the first basis vector; then cos(u,v) = u1, the first coordinate of a uniformly random unit vector. By symmetry every coordinate has the same second moment, and they must sum to ‖u‖2 = 1:

d · E[u12] = 1  ⇒   E[u12] = 1/d  ⇒   sd(cos) = 1/√d

One line, no integrals. For d = 768 that is 1/27.71 = 0.0361. So in an honest 768-dimensional space, two random items score 0.00 ± 0.04, and 0.2 would already be a striking similarity.

dsd of random cosineHow many sd is a measured A = 0.99?
20.7071.4 — unremarkable
500.1417.0
3000.057717.1
7680.036127.4
40960.015663.4

This table is also why the problem got worse as models got bigger. The isotropic baseline shrinks as 1/√d, so the same absolute drift in the mean becomes a more extreme statistical anomaly, and the useful signal has to fight harder against it.

How many vectors do you need before you believe A?

Every measurement in this lesson is estimated from a sample, so the first question a careful engineer asks is how noisy the estimate is. For A the answer is surprising, and it is worth deriving because it tells you where to stop worrying.

Suppose the truth is isotropy, and you draw n unit vectors. What is the spread of your measured A? Start from the identity: A is the average of the n(n−1) off-diagonal terms ûi·ûj. Each individual term has mean 0 and variance 1/d, which we just derived. If they were independent, the variance of their average would be (1/d)/(n(n−1)) — but they are not independent, because each vector appears in n−1 of the terms.

The dependence turns out to help rather than hurt, and the reason is a small structural fact. Fix one vector ûi and average over its partner: E[ ûi·ûj ] over random ûj is ûi·E[ûj] = ûi·0 = 0, whatever ûi is. No single vector can bias the average in a predictable direction. In the language of U-statistics this is a degenerate kernel, and it means the leading 1/n term of the variance vanishes entirely, leaving:

Var(A) = 2 / ( n(n−1) d )  ⇒   sd(A) = √( 2 / (n(n−1)d) )
ndPredicted sd(A)Measured over 300 simulated draws
200640.0008860.000881
1,000640.0001770.000175
5,000640.00003540.0000335
1,0007680.0000511
10,0007680.0000051
A is one of the most precisely estimable quantities you will ever measure. A thousand vectors in 768 dimensions pin it down to five decimal places. The error falls as 1/n, not the usual 1/√n, because the estimator is a degenerate U-statistic. Practically: if you measure A = 0.757 on a sample of 20,000, you do not need a bigger sample, and any change in A above about 0.0001 is real. That is a rare luxury — and it makes the contrast with the next measurement sharper than you would expect.

Measurement two: the spectrum

Average cosine measures one thing: how far the cloud's centre has drifted from the origin. It is blind to a completely different pathology — a cloud that is centred on the origin but is shaped like a pencil rather than a ball.

To see that you need second moments. Define two matrices. The second-moment matrix is the average outer product of the raw vectors:

S = (1/n) ∑i vi viT  ∈ Rd×d

The covariance matrix is the same thing computed after subtracting the mean μ = (1/n)∑vi:

Σ = (1/n) ∑i (vi − μ)(vi − μ)T

And they are related by one line of algebra that is the conceptual key to this whole chapter:

S = Σ + μμT

Expand the definition of S, add and subtract μ, and the cross terms vanish because ∑(vi−μ) = 0 by construction. The mean contributes a rank-one spike to the second-moment spectrum: one enormous eigenvalue of size ‖μ‖2 in the direction μ̂, and nothing else.

Anisotropy is two problems wearing one name.
(1) The offset. The cloud's centre of mass is far from the origin. This adds a single rank-one direction and it is the thing average cosine measures. Fixing it costs one subtraction.
(2) The ill-conditioning. Even after centring, the covariance has wildly unequal eigenvalues — the cloud is a pencil, not a ball. Average cosine is nearly blind to this, because a centred pencil still averages to zero cosine. Fixing it needs a rescaling, not a subtraction.
All-but-the-Top (Chapter 3) attacks both, crudely. Whitening (Chapter 6) attacks both, exactly. Plain mean-centring attacks only the first.

Reading a spectrum: explained variance and effective rank

Eigendecompose Σ = UΛUT and sort the eigenvalues λ1 ≥ λ2 ≥ … ≥ λd ≥ 0. Each λk is the variance of the cloud along direction uk. Normalise them into a probability distribution:

pk = λk / ∑j λj

Two summary numbers follow. Top-k explained variance is just p1 + … + pk — the fraction of the cloud's spread living in the first k directions. And effective rank, the number of dimensions the cloud genuinely uses, is the exponential of the entropy of that distribution:

H = − ∑k pk log pk    erank = eH

Why the exponential of the entropy? Because it has exactly the property you want: if k directions share the variance equally and the rest are zero, then p is uniform over k, H = log k, and erank = k on the nose. If one direction has everything, H = 0 and erank = 1. It interpolates smoothly between those, and it does not care about the units of λ.

Worked example. A three-dimensional cloud whose covariance eigenvalues are λ = (0.90, 0.06, 0.04) — already normalised, so p = λ.

H = −[ 0.90 ln 0.90 + 0.06 ln 0.06 + 0.04 ln 0.04 ]

Term by term. ln 0.90 = −0.10536, so the first term is 0.90 × 0.10536 = 0.09483. ln 0.06 = −2.81341, giving 0.06 × 2.81341 = 0.16880. ln 0.04 = −3.21888, giving 0.04 × 3.21888 = 0.12876. Sum:

H = 0.09483 + 0.16880 + 0.12876 = 0.39239  →   erank = e0.39239 = 1.4805

Three dimensions available; 1.48 dimensions used. That single number is the most compact statement of "this cloud is a pencil" available, and you can compute it for a 768-dimensional embedding matrix in about two seconds.

Second worked example: the effective rank of a power law

Real embedding spectra almost never look like the toy above. They look like power laws: λk ∝ k−α, a long slow decay with no obvious cliff. Working one out by hand builds the intuition for what α you are looking at when you plot your own.

Take five eigenvalues following λk = 1/k, that is (1, 1/2, 1/3, 1/4, 1/5). Their sum is the fifth harmonic number:

∑λ = 1 + 0.500000 + 0.333333 + 0.250000 + 0.200000 = 2.283333

Normalise, then take each term of the entropy sum. The arithmetic is five divisions and five logarithms:

entropy of a 1/k spectrum, term by termk   lambda_k    p_k = lambda_k/2.283333    ln p_k      -p_k ln p_k
1   1.000000    0.437956                  -0.825636    0.361593
2   0.500000    0.218978                  -1.518784    0.332580
3   0.333333    0.145985                  -1.924249    0.280912
4   0.250000    0.109489                  -2.211931    0.242182
5   0.200000    0.087591                  -2.435074    0.213291
                                                       ----------
                                          H         =  1.430558

erank = e^1.430558 = 4.181    out of 5 available

Five dimensions, 4.18 used. A 1/k spectrum is remarkably healthy — the top direction holds only 43.8% of the variance and the effective rank is 84% of the ambient dimension. That is worth internalising, because a plot of a 1/k spectrum on a linear axis looks alarming: the first bar is five times the last. Effective rank is the number that tells you not to panic.

Push the exponent and the picture changes fast. Computing the same quantity over 768 eigenvalues at several exponents:

Spectrum λk ∝ k−αTop-1 varianceTop-10 varianceEffective rank (of 768)
α = 0 — flat, perfectly isotropic0.00130.013768.0
α = 0.50.0190.093607.9
α = 1.00.1390.406152.0
α = 1.50.3940.78617.9
α = 2.00.6080.9435.1
α = 3.00.8320.9962.0

Notice how violently effective rank responds between α = 1 and α = 2 — it falls thirtyfold for a doubling of the exponent. Fit a straight line to your log-log spectrum plot and the slope you read off is almost the entire diagnosis. And the earlier report in Chapter 9 with top-1 variance 0.41 and effective rank 12.4 sits at roughly α = 1.6, which is the regime where the space is genuinely being wasted.

The null spectrum: finite samples are anisotropic even when the truth is not

Here is the trap that mirrors the one for A, and it runs in the opposite direction. A is estimated to five decimals from a thousand vectors. The spectrum is not, and if you forget this you will diagnose a healthy space as diseased.

Take n samples of genuinely isotropic d-dimensional data, so the true covariance is exactly the identity and every eigenvalue is exactly 1. Compute the sample covariance and eigendecompose it. You do not get eigenvalues of 1. You get a spread, and random matrix theory tells you exactly how wide it is: with γ = √(d/n), the sample eigenvalues fill the interval

[ (1 − γ)2 , (1 + γ)2 ] ,   γ = √(d/n)

This is the Marchenko–Pastur law, and it is the single most useful null hypothesis in this whole subject. Work an example. At d = 768 and n = 5,000, γ = √0.1536 = 0.3919, so the predicted top eigenvalue is (1.3919)2 = 1.937 and the bottom is (0.6081)2 = 0.370. A simulation on actual Gaussian noise returns 1.934 and 0.367.

d, nγ = √(d/n)Predicted λ1, λdSimulatedCondition number from noise alone
768, 20,0000.1961.430, 0.6471.420, 0.6502.2
768, 5,0000.3921.937, 0.3701.934, 0.3675.3
768, 2,0000.6202.623, 0.1452.593, 0.15017.3
With 2,000 samples in 768 dimensions, perfectly isotropic data produces a sample covariance with a condition number of 17. Your strongest direction appears to carry eighteen times the variance of your weakest, and none of it is real. Two consequences follow immediately. First, before you call a spectrum pathological, compare it against (1 ± √(d/n))2 — and if you cannot beat that null, collect more vectors rather than reaching for a transform. Second, and this is Chapter 6's whole warning in advance: whitening divides each direction by √λk, so it will faithfully amplify these fictitious small eigenvalues by 1/0.38 = 2.6× relative to the fictitious large ones. Whitening a rank-starved sample amplifies noise by construction.

The effective rank has the same bias, in a milder form: that same 768-dimensional isotropic noise at n = 5,000 reports an effective rank of 711 rather than 768, and at n = 2,000 it reports 633. So effective rank is downward biased by finite sampling — a healthy space always looks slightly less healthy than it is. Reassuringly, the bias is small compared to the effect we are hunting: a real embedding cloud reporting 12.4 out of 768 is nowhere near explainable by sampling.

SHOWCASE — cone collapse and the spectrum

Left: the cloud, projected onto the common direction (horizontal) and the widest orthogonal direction (vertical), with the cone that contains it drawn from the origin. Right: the eigenvalue spectrum. Push offset up and watch the cone close while a single spike swallows the spectrum. Push decay up and watch a centred cloud become a pencil — the cone stays open but the spectrum still collapses. Those are the two independent pathologies. Toggle centred to see exactly which one survives a mean subtraction.

Mean offset 2.20
Spectrum decay 0.60

Press pencil only and then mean-centred. The average pairwise cosine reads close to zero — the cloud passes the standard anisotropy test with flying colours — and the effective rank is still about 2 out of 12. That configuration is exactly the trap: a paper that reports only average cosine would call this space isotropic, and every retrieval query run against it would still be dominated by one direction.

Two more measures you will meet in the literature

The partition-function ratio. Arora et al. (2016) showed that if word vectors are isotropic then the partition function Z(c) = ∑w exp(cTvw) is approximately constant in the unit vector c. Mu & Viswanath turn this into a diagnostic: compute Z(c) at each eigenvector of the embedding matrix and report

I(V) = minc Z(c) / maxc Z(c)  ∈ [0, 1]

A value of 1 means the cloud looks the same from every direction; values well below 1 mean it does not. This is a more sensitive instrument than average cosine because the exponential weights the tails — a few far-out points in one direction move Z a great deal. Mu & Viswanath report that their post-processing pushes this ratio substantially closer to 1 for both word2vec and GloVe.

IsoScore. Rudman et al. (2022) catalogue what is wrong with all of the above: average cosine conflates the offset with the shape; the partition-function ratio is sensitive to the number of samples; neither scales linearly with the fraction of dimensions actually used. Their replacement compares the covariance's eigenvalue vector against the all-ones vector and normalises so that "uses k of d dimensions" scores k/d. You do not need it to follow this lesson, but you should know that "average cosine" is a proxy, not a definition, and that a paper reporting only that number has told you about the offset and almost nothing about the shape.

Computing all of it in one streaming pass

Everything in this chapter can be computed without ever holding your embedding matrix in memory, which matters the moment your corpus is larger than your laptop. Two accumulators do the whole job, and their sizes do not depend on n at all.

the whole diagnostic panel, streamingimport numpy as np

class GeometryAccumulator:
    def __init__(self, d):
        self.n     = 0
        self.sum_u = np.zeros(d)          # (d,)   sum of UNIT vectors  -> gives A
        self.sum_v = np.zeros(d)          # (d,)   sum of RAW vectors   -> gives mu
        self.gram  = np.zeros((d, d))     # (d,d)  sum of v v^T          -> gives Sigma

    def add(self, V):                    # V: (B, d) — any batch size
        U = V / np.linalg.norm(V, axis=1, keepdims=True)
        self.n     += V.shape[0]
        self.sum_u += U.sum(0)
        self.sum_v += V.sum(0)
        self.gram  += V.T @ V             # the only O(B d^2) step

    def report(self):
        n      = self.n
        mu_hat = self.sum_u / n
        offset = float(np.linalg.norm(mu_hat))
        A      = (n * offset**2 - 1) / (n - 1)
        mu     = self.sum_v / n
        # the identity that makes streaming work: Sigma = E[vv^T] - mu mu^T
        cov    = self.gram / n - np.outer(mu, mu)
        lam    = np.linalg.eigvalsh(cov)[::-1]
        p      = lam / lam.sum()
        return dict(A=A, offset=offset, top1=p[0],
                    erank=float(np.exp(-(p * np.log(p + 1e-12)).sum())))

The line that makes this possible is cov = gram/n - outer(mu, mu), which is S = Σ + μμT rearranged. You accumulate the uncentred second moment — which needs no knowledge of the mean and therefore no second pass — and subtract the mean's contribution at the end.

ResourceCost at d = 768Scales with n?
Memory for the Gram accumulator7682 × 8 bytes = 4.7 MB in float64No
Arithmetic per vectord2 = 590k multiply-addsLinear
The eigendecomposition, once at the endO(d3) = 4.5×108 operations, well under a secondNo
Vectors you must hold at onceOne batchNo
One numerical warning, and it is a real one. Accumulating gram in float32 over ten million vectors will lose you precision exactly where you need it, because the μμT term you subtract at the end is large — it is the offset pathology, after all — and subtracting two nearly-equal large numbers is the classic route to catastrophic cancellation. The trailing eigenvalues are the small differences that survive that subtraction, and they are the ones whitening will amplify by 1/√λ. Accumulate in float64. If you cannot, subtract a rough running mean from each batch before accumulating, which keeps the Gram matrix small and the cancellation harmless.
The diagnostic panel, in the order you should compute it. (1) ‖μ‖/mean‖v‖ — how offset is the cloud? (2) A from the identity — the anisotropy baseline your similarities are sitting on. (3) p1 of the centred covariance — how pencil-like is the shape once the offset is gone? (4) erank — how many dimensions are genuinely in play? Numbers 1 and 2 are the same fact twice; 3 and 4 are the fact that average cosine cannot see. Chapter 9 turns this into runnable code.

What to report, so that someone else can use it

A short checklist, because "the space is anisotropic" is not a reportable measurement and this chapter has now produced five things that are.

ReportWithout it, the reader cannot…
n and d…know whether your spectrum is measurable at all. The Marchenko–Pastur null needs both, and at n < 10d a chunk of what you are reporting is sampling noise
A, and 1/√d beside it…judge the size. A = 0.4 is 11 standard deviations at d = 768 and 0.6 at d = 2
What population you sampled…compare to anything. Tokens, types, sentences and documents give different numbers on the same model, and pooled vectors inflate A by the token count
The top-10 eigenvalue shares, or the log-log slope…see the shape at all. A is blind to it, and the slope α is the single most compressed description of the spectrum
Effective rank and the count of nonzero eigenvalues…distinguish an anisotropic space from a rank-collapsed one
Whether the activations are sign-constrained…pick the right null. Post-ReLU, zero is not the baseline

What this buys us

We now have a language. "The space is anisotropic" splits into "the mean is at ‖μ‖/mean‖v‖ = 0.85 of a typical vector length" and "the top principal component holds 41% of the centred variance, and the effective rank is 12 out of 768." Those two sentences point at two different fixes, and both are diagnosable in one pass over your data.

The next question is the interesting one. Nobody designed this. No loss function contains a term that says "please collapse into a cone." So why does every large model do it?

You centre your embedding matrix and re-measure the average pairwise cosine over your 10,000 stored vectors. It reads −0.0001. A colleague concludes the space is now isotropic. What is the flaw?

Chapter 2: Why It Happens

Nobody wrote a loss term that says "collapse into a cone." No architecture diagram contains a box labelled "common direction." And yet every large pretrained model does it, reliably, across architectures and training corpora and decades of hardware. That reliability is a clue: it means the cause is structural, not incidental.

There are three mechanisms. The first is the one that surprises people, so we start there.

Mechanism one: the loss cannot see it

Start with the last operation in a language model. You have a hidden state h ∈ Rd for the current position and an unembedding matrix E whose rows ew are one output embedding per vocabulary item. The score for token w is a dot product, and the probabilities come from a softmax:

w = h · ew ,   pw = exp(ℓw) / ∑v exp(ℓv)

Now perform the following vandalism. Pick any vector c ∈ Rd you like, and add it to every single row of E:

e′w = ew + c  for all w

What happens to the logits?

ℓ′w = h · (ew + c) = h · ew + h · c = ℓw + (h · c)

The added term h · c does not depend on w. It is the same number for every token in the vocabulary. And softmax is invariant to adding a constant to all its inputs — that is the same shift-invariance you exploit numerically when you subtract the max before exponentiating:

exp(ℓw + k) / ∑v exp(ℓv + k) = ekexp(ℓw) / (ekv exp(ℓv)) = pw
The probabilities are exactly unchanged. The loss is exactly unchanged. Every gradient the model has ever computed is exactly unchanged. You have moved the entire output embedding table by an arbitrary vector and the training objective did not notice. This is a gauge freedom — a direction in parameter space along which the loss is perfectly flat, so nothing pushes the parameters back.

Worked example: 3 tokens, 2 dimensions, and a catastrophe

Concrete numbers, small enough to check on paper. Hidden state h = (1, 2) and three output embeddings:

ea = (1, 0),  eb = (0, 1),  ec = (−1, 0)

Logits: h·ea = 1, h·eb = 2, h·ec = −1. Softmax them — subtract the max, 2, then exponentiate:

softmax beforeshifted   = (1-2, 2-2, -1-2) = (-1, 0, -3)
exps      = (0.367879, 1.000000, 0.049787)
sum       = 1.417666
p         = (0.259501, 0.705385, 0.035119)

Now the vandalism: c = (3, 3), added to all three rows.

e′a = (4, 3),  e′b = (3, 4),  e′c = (2, 3)
softmax afterlogits    = (1*4+2*3, 1*3+2*4, 1*2+2*3) = (10, 11, 8)
                                       h.c = 1*3 + 2*3 = 9  -> every logit shifted by exactly 9
shifted   = (10-11, 11-11, 8-11) = (-1, 0, -3)
p         = (0.259501, 0.705385, 0.035119)      # identical to seven decimals

Identical predictions. Now measure the geometry. Before, the three embeddings were mutually orthogonal or opposite: cos(a,b) = 0, cos(b,c) = 0, cos(a,c) = −1, so A = −1/3 = −0.3333 — exactly the −1/(n−1) floor from Chapter 1, because those three vectors happened to be centred.

After, compute each cosine longhand. Norms first: ‖e′a‖ = √(16+9) = 5, ‖e′b‖ = √(9+16) = 5, ‖e′c‖ = √(4+9) = √13 = 3.60555.

cos(a′,b′) = (12 + 12)/(5 × 5) = 24/25 = 0.96000
cos(a′,c′) = (8 + 9)/(5 × 3.60555) = 17/18.02776 = 0.94300
cos(b′,c′) = (6 + 12)/(5 × 3.60555) = 18/18.02776 = 0.99846
A′ = (0.96000 + 0.94300 + 0.99846)/3 = 0.96715
Average pairwise cosine went from −0.333 to +0.967 while the loss function's value, and every gradient it produces, stayed bit-identical. If you take one thing from this lesson, take this. Anisotropy is not a bug the optimiser introduced. It is a coordinate the optimiser is not allowed to have an opinion about — and an unconstrained coordinate will wander.

Where the wandering comes from

A perfectly flat direction does not have to drift; a ball on a level table stays put. So what actually moves it? Three candidates, and the honest answer is that the first two are weaker than most write-ups suggest, which sharpens the case for the third.

Initialisation. The starting E has a mean of roughly zero, but "roughly" means order 1/√|V| times the typical row norm. For a 50,000-token vocabulary that is 1/224 = 0.45% of a row norm — small, nonzero, and with no force to correct it. Note this is a starting offset, not a drift; it stays about that size unless something moves it.

Weight decay. This one is worth doing properly, because the exact answer is the opposite of what you might guess. Sum the cross-entropy gradient over the whole vocabulary at a single training position:

w ∂L/∂ew = ∑w ( pw − 1[w = t] ) h = ( ∑w pw − 1 ) h = (1 − 1) h = 0

Exactly zero, at every position, for every model, always — because the softmax probabilities sum to one by construction. Cross-entropy therefore applies no net force whatsoever to the mean of the output embedding table. That is the gauge invariance of mechanism one, restated as a gradient statement rather than a loss statement, and it is a stronger version of the same fact.

Now add decoupled weight decay, which subtracts ηλew from every row each step. Averaging over the vocabulary, the mean m = (1/|V|)∑wew obeys m ← (1 − ηλ)m with no forcing term at all, so it decays geometrically. With a learning rate of 10−4, decay 0.1, over 106 steps, the shrink factor is (1 − 10−5)1000000 = e−10 = 0.000045. Weight decay does not merely help — if it is applied to the embedding table, it annihilates the mean.

So why is anything anisotropic? Two answers, and both matter. First, many training recipes explicitly exclude embeddings, biases and normalisation parameters from weight decay, in which case the paragraph above never happens. Second, and more importantly: the mean is not where the disease lives. Mechanism two below does not move the mean at all — we will prove that it cannot — and yet it produces a savage cone. This is Chapter 1's two-pathology split doing real work: a fix that annihilates the offset can leave the shape untouched.

Adam on a flat direction. Adam divides each coordinate's step by the running root-mean-square of its own gradients. On a direction where the true gradient is zero and only noise remains, that division normalises the noise to unit scale — so the parameter takes a step of about the full learning rate every time, in a random direction. Over T steps that is a random walk of size η√T. With η = 10−4 and T = 106, that is 10−4 × 1000 = 0.1, or roughly a tenth of a typical row norm, accumulated from pure noise. Plain SGD on the same direction would move ησ√T, which is smaller by the factor σ — the actual gradient magnitude, which on a flat direction is tiny. Adam is the optimiser that most aggressively converts a flat direction into a wandering one, precisely because its whole purpose is to make small-gradient directions move as fast as large-gradient ones.

Asymmetric gradients in the softmax. This is the big one, and it deserves its own mechanism.

Mechanism two: rare tokens are pushed, never pulled

Gao et al. (2019) gave this a name — the representation degeneration problem — and a derivation. Start with the gradient of the cross-entropy loss for one training position with target token t. The derivative with respect to a logit is the single most useful fact in all of classification:

∂L / ∂ℓw = pw − 1[w = t]

And since ℓw = h · ew, the chain rule gives the gradient with respect to the output embedding itself:

∂L / ∂ew = ( pw − 1[w = t] ) · h

Read the sign. If w is the target, the coefficient pw − 1 is negative, so gradient descent moves ew toward h — "look more like the context that predicted you." If w is not the target, the coefficient pw is positive, so ew moves away from h.

Now consider a genuinely rare token — a rare surname, an obscure unit symbol, a fragment of a URL. Over a training run of, say, 106 gradient steps with a batch of 105 positions, this token might be the target a few hundred times and a non-target 1011 times. Its update is, to an excellent approximation, entirely made of the "push away" term:

Δew = −η ∑positions pw h ≈ −η N p̄w

where h̄ is the average hidden state over the corpus. Every rare token feels the same push, along the same direction, for the same reason. They are all being shoved away from the corpus's average context — and "away from h̄" is one specific direction, not many.

Worked example: two rare tokens meet in the corner

Two dimensions again. Take the average hidden direction to be h̄ = (0.8, 0.6), a unit vector. Suppose the accumulated drift magnitude works out to 1.0 — say average p̄w = 0.001, learning rate η = 10−3, and N = 106 effective updates, giving 10−3 × 10−3 × 106 = 1.0.

Both tokens started at random small values. Token 1 started at (0.05, −0.03); token 2 at (−0.04, 0.06). After drift:

e1 = (0.05, −0.03) − (0.8, 0.6) = (−0.75, −0.63)
e2 = (−0.04, 0.06) − (0.8, 0.6) = (−0.84, −0.54)

Their cosine, computed longhand:

e1 · e2 = (−0.75)(−0.84) + (−0.63)(−0.54) = 0.6300 + 0.3402 = 0.9702
‖e1‖ = √(0.5625 + 0.3969) = √0.9594 = 0.97949
‖e2‖ = √(0.7056 + 0.2916) = √0.9972 = 0.99860
cos(e1, e2) = 0.9702 / (0.97949 × 0.99860) = 0.9702 / 0.97812 = 0.99190

Before training, those two vectors had cosine (0.05)(−0.04) + (−0.03)(0.06) = −0.0038 over norms 0.0583 × 0.0721 = 0.0042, which is −0.90 — nearly opposite. After training on data in which neither ever appears, they are at 0.9919. The model has learned that two completely unrelated tokens are nearly the same thing, and it learned it from the absence of evidence.

The deep point. Cross-entropy's "push away from contexts that are not yours" is a perfectly sensible instruction with one blind spot: it does not say which way to go. There is a whole hyperplane of directions away from h̄, and the gradient picks the single most direct one for everybody. Softmax has no repulsion between the non-target embeddings themselves — only between each of them and h. Chapter 7's contrastive fix is, in one sentence, "add the missing repulsion."

Mechanism two attacks the shape, not the mean — worked

The previous example moved two rare tokens and stopped. But we proved above that the total gradient over the vocabulary is exactly zero, which means every unit of "push" applied to a rare token is balanced somewhere by a unit of "pull" applied to a frequent one. Let us follow the whole balance sheet, because the result is not what the phrase "everything drifts into a cone" suggests.

Three tokens in two dimensions, with the average hidden direction h̄ = (0.8, 0.6) again. Token f is frequent: it is the target often, so its accumulated update is a pull toward h̄, say of size 2. Tokens 1 and 2 are rare: each is pushed away by size 1. Two pushes of 1 balance one pull of 2, which is the zero-sum constraint made concrete. Give them initialisations that sum to zero so we can see the effect cleanly:

the whole vocabulary, before and afterinit:   e_f = (-0.01, -0.03)    e_1 = (0.05, -0.03)    e_2 = (-0.04, 0.06)
        sum = (0.00, 0.00)                                # centred at the start

drift:  e_f += 2*(0.8, 0.6) = (1.6, 1.2)                  # frequent: PULLED toward h-bar
        e_1 -=   (0.8, 0.6)                               # rare: PUSHED away
        e_2 -=   (0.8, 0.6)                               # rare: PUSHED away

after:  e_f = ( 1.59,  1.17)    e_1 = (-0.75, -0.63)    e_2 = (-0.84, -0.54)
        sum = (0.00, 0.00)                                # STILL exactly centred

The mean has not moved by a hair, and it never could: the drifts were constructed to cancel, because the gradient made them cancel. Now measure. Norms first: ‖ef‖ = √(2.5281 + 1.3689) = √3.8970 = 1.974082, ‖e1‖ = 0.979490, ‖e2‖ = 0.998599.

cos(ef, e1) = (−1.19250 − 0.73710) / (1.974082 × 0.979490) = −1.92960 / 1.933593 = −0.99793
cos(ef, e2) = (−1.33560 − 0.63180) / (1.974082 × 0.998599) = −1.96740 / 1.971316 = −0.99801
cos(e1, e2) = 0.97020 / (0.979490 × 0.998599) = 0.97020 / 0.978117 = +0.99191
A = (−0.99793 − 0.99801 + 0.99191)/3 = −1.00403/3 = −0.33468

Look at what the standard diagnostic says. A is negative, sitting near the centring floor, and a dashboard reporting only average pairwise cosine would call this space perfectly healthy. Now compute the second-moment matrix instead:

the spectrum tells a different storyS = (1/3) * sum_w e_w e_w^T

S11 = (1.59^2 + 0.75^2 + 0.84^2)/3 = (2.5281 + 0.5625 + 0.7056)/3 = 1.2654
S22 = (1.17^2 + 0.63^2 + 0.54^2)/3 = (1.3689 + 0.3969 + 0.2916)/3 = 0.6858
S12 = (1.59*1.17 + 0.75*0.63 + 0.84*0.54)/3 = (1.8603 + 0.4725 + 0.4536)/3 = 0.9288

trace = 1.9512    det = 1.2654*0.6858 - 0.9288^2 = 0.867811 - 0.862669 = 0.005142

eigenvalues = (1.9512 +/- sqrt(1.9512^2 - 4*0.005142))/2 = (1.9512 +/- 1.945923)/2
            = 1.948561  and  0.002639

top-1 share  = 1.948561 / 1.9512 = 99.86%
effective rank = 1.010  out of 2

for comparison, the INITIALISATION had eigenvalues 0.002817 and 0.000383,
a top-1 share of 88% and no dominant scale at all.
Zero offset, and 99.86% of the variance in one direction. Mechanism two does not push the cloud away from the origin — it cannot, because the softmax gradient sums to zero over the vocabulary. What it does is stretch the cloud along h̄: frequent tokens go one way, rare tokens go the other, and the axis they separate along becomes the dominant principal component. That is why Mu and Viswanath found the top principal components correlate with word frequency, and it is why deleting them helps. It is also a clean illustration of Chapter 1's warning: this space would pass the average-cosine test and fail catastrophically at retrieval, because every query would be ranked mostly by how frequent its words are.

One caveat to state, since we just leaned on it: A landed at −0.335 rather than the exact centring floor −1/(n−1) = −0.5 for n = 3. The reason is the norms — 1.974, 0.979, 0.999 — are wildly unequal, and the floor identity requires equal norms. The dot products still obey the exact constraint: −1.9296 − 1.9674 + 0.9702 = −2.9268, which is exactly −(‖ef2 + ‖e12 + ‖e22)/2 = −(3.8970 + 0.9594 + 0.9972)/2 = −2.9268. The identity is about dot products; only the cosine version needs equal lengths.

The empirical fingerprints

If mechanism two is real, it makes falsifiable predictions, and all four papers report the corresponding measurements.

PredictionWhat was actually observedSource
The dominant directions should correlate with token frequency, since frequency is what determines how much "push" versus "pull" a token gotThe top principal components of word2vec and GloVe encode frequency information. Deleting them improves similarity tasksMu & Viswanath 2018
Rare and frequent tokens should occupy geometrically different regionsIn BERT's embedding space, high-frequency words sit closer to the origin and are densely packed; low-frequency words are farther out and sparse, leaving "holes" between themLi et al. 2020
The effect should compound with depth, since each layer inherits the previous layer's common componentAnisotropy increases monotonically through the layers of ELMo, BERT and GPT-2, reaching near-1.0 average random-word cosine in GPT-2's last layerEthayarajh 2019
Removing the dominant directions should help, not hurt, if they are mostly frequency artefactsIt does — consistently, across word similarity, categorisation, analogy and downstream sentence tasksMu & Viswanath 2018
A prediction the mechanism makes that you can check on your own model in five minutes. If rare tokens are pushed along h̄ and frequent ones pulled toward it, then the projection of each output embedding onto the top principal component should be an increasing function of log frequency — not a scatter, a trend. Take your unembedding matrix, centre it, take u1, project, and plot against the token's corpus count on a log axis. A clean monotone trend confirms mechanism two on your model. A flat scatter says something else is producing your dominant direction, and the attention-sink test below is where to look next.

Mechanism three: the residual stream accumulates

Mechanisms one and two are both about the output embedding table. But Ethayarajh measured anisotropy in the hidden states themselves, at every layer, and found it there too. So there must be a third cause living inside the network.

A transformer's hidden state is a residual stream: layer ℓ produces h(ℓ) = h(ℓ−1) + δ(ℓ), where δ is what the attention and MLP blocks wrote this layer. Nothing ever replaces the stream; everything only adds to it.

Now suppose each layer's contribution decomposes into a part that is roughly the same for every input — a bias, a "default" direction, whatever the attention sink or the always-on features write — plus a part that genuinely depends on the token. Call them c and gi, both of unit scale, with the gi independent across different inputs i:

hi = ∑ℓ=1..L ( c + gi(ℓ) ) = L·c + ∑ gi(ℓ)

Here is the asymmetry that does all the work. The shared parts add coherently: L copies of c give a vector of length L. The individual parts add incoherently — they are independent random directions, so their sum is a random walk of length √L, not L. Write hi ≈ L·c + √L·gi with gi a fresh unit random vector. The cosine between two different inputs:

hi · hj = L2 + L3/2(c·gi + c·gj) + L (gi·gj) ≈ L2
‖hi2 = L2 + 2L3/2(c·gi) + L ≈ L2 + L

because in high dimensions independent unit vectors have dot products of order 1/√d, which vanish. So:

cos(hi, hj) ≈ L2 / (L2 + L) = L / (L + 1)
Depth LPredicted random-pair cosineReal model at that depth
10.500Input embeddings — low anisotropy in all three models
60.857Middle layers — clearly elevated
120.923BERT-base / GPT-2-small final layers — high
240.960
480.980GPT-2-large scale — Ethayarajh reports near 1.0

This is a toy model, and it should be read as one: it assumes every layer writes the same shared vector at the same scale, which is not literally true, and it ignores the layer norms that partly rescale the stream. But it reproduces the reported curve's shape from one structural fact — that shared components add linearly and idiosyncratic ones add as a square root — and that fact is not going away. Anisotropy is what residual addition does to any signal with a shared component.

Where does the toy break? In two named places, and knowing them is what keeps it a tool rather than a slogan. First, it assumes the shared contribution c has the same magnitude at every layer, which is false — real transformers write much larger updates in some layers than others, and the curve of anisotropy against depth is correspondingly bumpier than L/(L+1). Second, it ignores the LayerNorm between blocks, which rescales the stream to a fixed norm and therefore partially resets the accumulation. Both corrections push the predicted curve down, which is consistent with real measurements sitting below the toy at intermediate depths and catching up at the top. What survives both corrections is the structural asymmetry: nothing about a LayerNorm or a variable write magnitude changes the fact that coherent terms add as L and incoherent ones as √L.

And it is not even specific to language. Godey et al. (2024) found the same cone geometry inside the self-attention blocks of transformers trained on non-language data and without any softmax-over-vocabulary bottleneck at all. That rules out mechanism two as the sole cause, and it argues that anisotropy is closer to a property of the architecture than a property of the corpus.

What LayerNorm does and does not fix

A reasonable objection: modern transformers run a LayerNorm right before the unembedding. Does that not remove the common component?

It removes exactly one direction of it. LayerNorm subtracts the mean across features of the hidden vector, which is the same as projecting out the all-ones vector 1 = (1,1,…,1)/√d. After LayerNorm, h · 1 = 0. So the gauge freedom in the direction c = 1 is genuinely eliminated: shifting every output embedding along the all-ones direction now has no effect, because h has no component there to pick it up.

Three dimensions, three lines, and you can see both halves of that claim.

what LayerNorm does and does not removeh = (3, 1, 2)
mean across features = (3 + 1 + 2)/3 = 2
h_centred = (1, -1, 0)                    # this is the "sub-mean" half of LayerNorm
check: h_centred . (1,1,1) = 1 - 1 + 0 = 0     # the all-ones component is gone

now shift every output embedding by c = (1, 1, 1):
  delta logit for token w = h_centred . c = 0       # NOTHING happens. Gauge killed.

but shift by c = (1, -1, 0) instead:
  delta logit = h_centred . c = 1*1 + (-1)*(-1) + 0*0 = 2
  ...which is again the SAME shift for every token w, since it does not
  depend on w — so softmax still cannot see it, and the gauge survives.

# LayerNorm closed 1 of the d gauge directions. 767 remain open at d = 768.

But c can be any of the other d − 1 directions, and for those the invariance is untouched. LayerNorm removes one degree of gauge freedom out of 768. It also rescales h to a fixed norm, which fixes nothing about direction. Empirically, models with final LayerNorm are still severely anisotropic — Ethayarajh's GPT-2 measurements are taken on a model that has one.

Anisotropy is not rank collapse — keep them apart

One terminological clean-up, because these two get used interchangeably and they are different failures with different fixes.

AnisotropyRank collapse
What is true of the cloudThe vectors are spread over many directions, but unevenly — and offset from the originThe vectors genuinely lie in a low-dimensional subspace, or converge to a single point
SpectrumAll eigenvalues nonzero; wildly unequalMost eigenvalues are exactly zero
InformationFully preserved. It is a readout problemDestroyed. Two distinct inputs map to the same output
Fixable after the fact?Yes — every chapter from here onNo. Nothing recovers a distinction the model did not make
Typical causeThe mechanisms in this chapterDegenerate training — a contrastive run without negatives, a bottleneck that is genuinely too narrow, an over-regularised layer

The distinguishing measurement takes one line: count how many eigenvalues exceed a small tolerance. An anisotropic 768-dimensional cloud has 768 nonzero eigenvalues with an effective rank of 12; a rank-collapsed one has 12 nonzero eigenvalues and 756 exact zeros. Effective rank alone cannot tell them apart — which is a genuine limitation of that statistic and the reason to report the raw spectrum alongside it.

How big is the gauge group, exactly?

Shifting is not the only thing the loss cannot see. It is worth counting the whole family, because the count tells you precisely how much of your embedding geometry was ever determined by training — and the answer is "less than you think, but the part that matters is small and specific."

Three transformations leave every logit h · ew unchanged:

TransformationWhy the logits surviveFree parametersDoes it change a cosine?
Shift. ew → ew + cEvery logit moves by the same h·c; softmax is shift-invariantd = 768Yes — catastrophically
Rotate. ew → Rew and h → Rh, for orthogonal R(Rh)·(Rew) = hTRTRew = h·ewd(d−1)/2 = 294,528No — a shared rotation preserves every dot product and every norm
Rescale. ew → αew and h → h/αThe two factors cancel in the product1No — cosine divides the scale out
total flat directions = 294,528 + 768 + 1 = 295,297   of which 768 corrupt similarity
This is the cleanest statement of the problem in the lesson. Training determines your embedding table only up to a 295,297-dimensional family of transformations, and it has no opinion about which member of that family you got. That sounds terrifying until you notice that 294,529 of those directions are rotations and scalings, which cosine similarity is already invariant to — they cost you nothing. The entire disease lives in the remaining 768-dimensional shift subgroup, and centring is exactly the act of picking a canonical member of it. Mean subtraction is not a heuristic. It is choosing the gauge the loss declined to choose.

It also explains something otherwise puzzling about Chapter 6. Whitening's solution W = UΛ−1/2 is famously non-unique — WR works for any orthogonal R. That is not a defect of the derivation; it is the rotation row of the table above showing up again. The ambiguity is real and it is harmless, for exactly the same reason.

A fourth candidate: the attention sink writes a constant

Mechanism three assumed each layer contributes "a part that is roughly the same for every input." That was left abstract. In modern decoder-only transformers there is a concrete, well-documented source of it.

Attention heads in these models place a large, near-constant fraction of their attention mass on the first token of the sequence — the beginning-of-sequence token, or whatever occupies position zero. The phenomenon is usually called an attention sink: the head has to distribute a probability distribution that sums to one, and when it has nothing in particular to attend to, dumping the mass somewhere harmless is the cheapest option available.

Now trace what that does to the residual stream. If a head puts weight a on the sink token, its output at every position includes a · vsink, where vsink is the sink's value vector. The same vector. At every position. In every sentence. Sum it over H heads and L layers:

shared contribution ≈ L · H · ā · v̄sink

Put plausible numbers on it: 12 layers, 12 heads, an average sink weight of 0.3, and a value-vector norm comparable to the per-head output norm. That is 12 × 12 × 0.3 = 43 units of the same vector added into the stream, against idiosyncratic contributions that add as a square root. This is mechanism three's c, with a name, a location in the architecture, and a measurable weight.

And it is testable in ten minutes. Take any decoder model, run a batch of sentences, and compute the top principal component of the last-layer hidden states. Then compute the value vector of the first token, propagated forward. If the cosine between them is large, the dominant direction of your embedding space is the attention sink, and no amount of reasoning about vocabulary frequency was ever going to explain it. This is also the most satisfying possible answer to Chapter 7's "is the top component an artefact or the signal?" — a sink vector is neither semantics nor frequency, it is bookkeeping, and deleting it is safe.

The three mechanisms, side by side

1 — Gauge freedom
Softmax is invariant to shifting all output embeddings by any c. The loss is flat along d directions of parameter space, so nothing holds the mean at zero. Fixed by: centring — you are choosing a gauge the loss never chose.
2 — Asymmetric gradients
Rare tokens are almost never pulled toward a context and almost always pushed away from the average one, so they pile up in a common direction. Fixed by: deleting the frequency-aligned principal components, or by adding real repulsion (contrastive).
3 — Residual accumulation
Shared per-layer contributions add as L while idiosyncratic ones add as √L, so the shared fraction grows with depth. Fixed by: nothing post-hoc can undo depth — but a linear transform can rebalance the resulting covariance. Chapter 6.

Notice that all three point at the same corrective action from different directions: find the directions carrying the shared component and neutralise them. Which is the algorithm Chapter 3 makes precise, in three lines of numpy.

Could you train an isotropic model on purpose?

The best test of a causal story is whether it tells you how to prevent the effect. Take each mechanism in turn and ask what intervention would kill it, and what that intervention would cost. The answers are instructive, and two of them are things people actually do.

InterventionWhich mechanism it targetsDoes it work?
Re-centre the output embedding table after every optimiser step — literally E -= E.mean(0)1 — gauge freedomYes, exactly, and it is free. The loss is provably unchanged, since subtracting a constant from every row is precisely the transformation the softmax cannot see. You are choosing the canonical gauge rather than letting it wander. It does nothing for mechanisms 2 or 3
Weight decay on the embedding table1 — gauge freedomYes, as derived above — the mean decays geometrically because the cross-entropy gradient contributes exactly zero to it. Many recipes exclude embeddings from decay and lose this for free
Sampled softmax or noise-contrastive estimation2 — asymmetric gradientsPartially, and in the wrong direction. Sampling negatives means a rare token is a non-target far less often, so it drifts less — but it also gets a much noisier estimate of where it should be, and the sampling distribution is usually frequency-based, which reintroduces frequency structure by another route
Add an explicit uniformity penalty to the pretraining loss — a term proportional to ‖μ̂‖2 over the batch2 and 3Yes, and this is exactly what Chapter 7's contrastive objective turns out to contain. Gao et al. proposed a cosine-regularisation term for precisely this reason. The cost is that you are now optimising something other than next-token likelihood, and you must decide how much of the model's quality you will trade for geometry
Remove the residual connections3 — residual accumulationYes, and it destroys the model. The coherent accumulation of shared components is inseparable from the mechanism that makes deep transformers trainable at all. This is the clearest sense in which anisotropy is a feature of the architecture rather than a bug in it
Put a LayerNorm everywhere1, partiallyRemoves one gauge direction out of d, as derived above. Models with final LayerNorm are still severely anisotropic; GPT-2 has one
Read the table as a whole and a pattern falls out. The interventions that are free — re-centring, weight decay — only touch mechanism one, the offset. The intervention that touches the shape, an explicit uniformity term, is not free: it changes what you are optimising. And the mechanism that is most structural, residual accumulation, cannot be removed at all. That is the real reason post-hoc fixes exist and the real reason contrastive fine-tuning beats them: the cheap prevention only reaches the cheap half of the problem, and the expensive half needs an objective that mentions geometry. Chapters 3 through 6 are what you do when you were not the one who trained the model.

Which table is anisotropic — input or output?

One loose end worth tying, because it is a question people get wrong and it has a clean answer. A language model has (at least conceptually) two embedding tables: the input lookup that turns a token id into a vector, and the output unembedding that turns a hidden state into logits. Which one does mechanism two attack?

The output table, unambiguously. Every gradient in the derivation above — the pw − 1[w = t] coefficient, the push away from h̄ — is a gradient with respect to ew, a row of the unembedding. The input table receives a completely different signal: a token's input embedding is updated only when that token actually appears, and always in the direction that improves prediction at that position. There is no "push away from every context you did not appear in", because an input embedding that is never looked up receives no gradient at all.

Which is exactly why weight tying matters here. Most modern models tie the two tables — one matrix serving both roles. Tying means the output table's pathology is inherited by the input table for free, and it means the input side's gradients partially oppose the output side's drift. The empirical result reported across this literature is that tied models are still strongly anisotropic, so the opposition is not enough — but if you are ever measuring an untied model and find the input table healthier than the output table, that is the mechanism, and it is a satisfying confirmation of the story rather than an anomaly.
You add a constant vector c to every row of a language model's output embedding matrix. Which statement is exactly correct?

Chapter 3: All-but-the-Top, Derived

In 2018 Jiaqi Mu and Pramod Viswanath published a paper whose entire method fits in three lines of numpy and whose title is also its algorithm. It works on every static word embedding anybody had trained, it needs no training, no labels, and no hyperparameter search worth the name, and it improves results on word similarity, categorisation, analogy, and downstream sentence tasks.

Here it is, in full.

Step 1 — subtract the mean
μ = (1/|V|) ∑w v(w),   then ṽ(w) = v(w) − μ
Step 2 — find the dominant directions
Run PCA on the centred vectors. Keep the top D principal components u1, …, uD
Step 3 — delete them
v′(w) = ṽ(w) − ∑i=1..D (uiT ṽ(w)) ui

That is the whole paper's method. The interesting part is why each of the three steps is there, and the answer is Chapter 1's two-pathology decomposition, applied literally.

Why two operations and not one

Recall that the second-moment matrix decomposes as S = Σ + μμT. Step 1 removes the μμT rank-one spike — the offset pathology. Steps 2 and 3 attack Σ itself — the shape pathology. They are different operations because they fix different diseases, and doing only one of them leaves the other in place.

You can see the necessity of both directly from Chapter 0's expansion. We had:

cos(μ+x, μ+y) ≈ 1 − ‖a − b2 / ( 2(1 + p + q) )

Subtracting μ kills the leading 1 and the ‖μ‖2 compression — you go from measuring cos(μ+x, μ+y) to measuring cos(x, y), and the whole [0.99, 1.00] sliver expands to the full [−1, 1]. But the p and q terms, the ones that corrupt the ranking, live in the components of x and y along μ̂. They survive centring. Only removing that direction — which is step 3, if μ̂ happens to align with a top principal component, which it usually does — kills them.

Centring fixes the scale. Removing principal components fixes the ranking. That is the cleanest one-line summary of the algorithm, and it tells you what to expect: mean subtraction alone gives you readable numbers and a modest quality bump; the PC removal is where the real gains live.

What removing a direction actually does

Step 3 is a projection. Removing one unit direction u from a vector v is:

v′ = v − (uTv) u = (I − uuT) v = P v

P = I − uuT is an orthogonal projector: it maps Rd onto the (d−1)-dimensional subspace perpendicular to u, and it is idempotent (PP = P — once a direction is gone, removing it again does nothing) and symmetric. Removing D directions at once is P = I − UUT where U stacks the D orthonormal components.

The consequence for similarity is exact and worth stating precisely: after projection, cos(Pv, Pw) is the cosine of v and w as measured inside the surviving subspace only. Everything the deleted directions had to say about either vector — including whatever genuine information they carried — is gone, irreversibly. That is why D matters.

How large should D be?

Mu and Viswanath's rule of thumb, from their experiments across dimensions and corpora: D ≈ d/100. For 300-dimensional GloVe vectors, D = 3. For 768 dimensions, D = 7 or 8.

The rule has a sensible shape. Too small and you leave the frequency directions in place, so nothing improves. Too large and you start deleting real semantic axes — the components after the first handful are no longer dominated by a shared artefact, and each one you remove takes real content with it. The paper reports the characteristic inverted-U: performance rises, plateaus, then falls as D grows past the useful range.

Do not let this rule travel outside its home. D ≈ d/100 was tuned on static word-embedding vocabularies — hundreds of thousands of items, one vector each, trained by word2vec or GloVe. For a set of 5,000 sentence embeddings from a contextual model the right D is a different number and you must sweep it against your own evaluation. Chapter 7 has an example where the correct D is zero.

Worked example: four words in three dimensions, by hand

Everything above is now going to happen in full arithmetic. Three dimensions, four words, every number computed.

Our four words are cat, dog, car, truck. The intended semantics are obvious: cat should be nearest to dog, car nearest to truck. The embeddings are:

WordEmbedding v‖v‖Corpus frequency
cat(4.6, 1.0, 0.5)4.73392high
dog(1.4, 0.8, 0.9)1.84662low
car(1.4, −0.9, −0.6)1.76918low
truck(4.6, −0.9, −0.8)4.75500high

The first coordinate is the shared component — every word has a lot of it, and how much varies with frequency, exactly as Chapter 2's mechanisms predict. Coordinates two and three carry the meaning: cat and dog have positive second and third components, car and truck negative ones.

Before: the six raw cosines. Longhand for the first one, then the table.

cat · dog = (4.6)(1.4) + (1.0)(0.8) + (0.5)(0.9) = 6.44 + 0.80 + 0.45 = 7.69
‖cat‖ × ‖dog‖ = 4.733920 × 1.846619 = 8.741745
cos = 7.69 / 8.741745 = 0.87969

One arithmetic habit worth adopting right here: carry the unrounded norm product into the division. If you round each norm to five decimals first and then multiply, you get 8.74192 and a cosine of 0.87966 — wrong in the fifth decimal. That sounds pedantic until you notice that the whole failure this chapter is about turns on a margin of 0.0026. When the signal lives in the fourth decimal, your rounding lives in the fifth.

PairDot productProduct of normsCosine
cat · dog7.698.7417450.87969
cat · car5.248.3751600.62566
cat · truck19.8622.5097780.88228
dog · car0.703.2670020.21426
dog · truck5.008.7806660.56943
car · truck7.738.4124490.91888
A = (0.87969 + 0.62566 + 0.88228 + 0.21426 + 0.56943 + 0.91888)/6 = 4.09020/6 = 0.68170
Look at the bolded row. cat's nearest neighbour is truck, at 0.88228, edging out dog at 0.87969. A margin of 0.0026 — and it is entirely produced by the fact that cat and truck are both frequent words with a large first coordinate. The retrieval is wrong, and it is wrong by a hair, which in a real system means it flips unpredictably with any perturbation. This is the failure Chapter 0 predicted from the (1+p+q) term, now happening in an actual retrieval.

Step 1: the mean. Add the four vectors component by component and divide by four.

∑v = (4.6+1.4+1.4+4.6, 1.0+0.8−0.9−0.9, 0.5+0.9−0.6−0.8) = (12.0, 0.0, 0.0)
μ = (3.0, 0.0, 0.0)

The mean points straight down the shared axis, with a norm of 3.0 against typical vector norms of 1.8 to 4.8. That single number — ‖μ‖ comparable to a typical ‖v‖ — is the offset pathology in one measurement.

Step 1 continued: centre.

Wordvṽ = v − μ
cat(4.6, 1.0, 0.5)(1.6, 1.0, 0.5)
dog(1.4, 0.8, 0.9)(−1.6, 0.8, 0.9)
car(1.4, −0.9, −0.6)(−1.6, −0.9, −0.6)
truck(4.6, −0.9, −0.8)(1.6, −0.9, −0.8)

Step 2: the scatter matrix. PCA needs the covariance; we will build the scatter matrix S = ∑iiiT, which is four times the covariance and has the same eigenvectors. Six entries to compute, since it is symmetric.

building the 3x3 scatter matrix by handS11 = 1.6^2 + (-1.6)^2 + (-1.6)^2 + 1.6^2                  = 4 x 2.56  = 10.24
S22 = 1.0^2 + 0.8^2 + (-0.9)^2 + (-0.9)^2                  = 1.00+0.64+0.81+0.81 = 3.26
S33 = 0.5^2 + 0.9^2 + (-0.6)^2 + (-0.8)^2                  = 0.25+0.81+0.36+0.64 = 2.06

S12 = (1.6)(1.0) + (-1.6)(0.8) + (-1.6)(-0.9) + (1.6)(-0.9)
    = 1.60 - 1.28 + 1.44 - 1.44                            = 0.32
S13 = (1.6)(0.5) + (-1.6)(0.9) + (-1.6)(-0.6) + (1.6)(-0.8)
    = 0.80 - 1.44 + 0.96 - 1.28                            = -0.96
S23 = (1.0)(0.5) + (0.8)(0.9) + (-0.9)(-0.6) + (-0.9)(-0.8)
    = 0.50 + 0.72 + 0.54 + 0.72                            = 2.48
S = [ 10.24  0.32  −0.96 ;  0.32  3.26  2.48 ;  −0.96  2.48  2.06 ],   trace = 15.56

Step 2 continued: power iteration. We need the top eigenvector. The simplest algorithm that works: start with a guess, multiply by S, normalise, repeat. Each multiplication amplifies the dominant direction relative to the others by the ratio of their eigenvalues, so it converges geometrically.

Start with the first basis vector, since S11 is obviously the largest diagonal entry.

power iteration, three roundsu0 = (1, 0, 0)

S u0 = (10.24, 0.32, -0.96)
       norm = sqrt(104.8576 + 0.1024 + 0.9216) = sqrt(105.8816) = 10.28988
u1   = (0.995150, 0.031098, -0.093295)

S u1 = (10.28985, 0.188456, -1.070409)
       norm = sqrt(105.88100 + 0.035516 + 1.145775) = sqrt(107.06229) = 10.34709
u2   = (0.994469, 0.018213, -0.103450)

S u2 = (10.288503, 0.121048, -1.122629)
       norm = sqrt(105.85327 + 0.014653 + 1.260296) = sqrt(107.12822) = 10.35028
u3   = (0.994031, 0.011695, -0.108464)

... two more rounds and it stops moving:
u*   = (0.9936, 0.0060, -0.1129)      lambda1 = 10.3513

Sanity check the eigenvector: 0.99362 + 0.00602 + 0.11292 = 0.98724 + 0.00004 + 0.01275 = 1.00003. Unit length, to rounding. And the eigenvalue tells us how bad the shape pathology is:

λ1 / trace = 10.3513 / 15.56 = 66.5% of the variance in one direction out of three

For the arithmetic below we use the rounded u1 = (0.994, 0.006, −0.113).

Step 3a: project each centred vector onto u1.

the four projection coefficientsp_cat   = (1.6)(0.994) + (1.0)(0.006) + (0.5)(-0.113)
        =  1.5904 + 0.0060 - 0.0565 =  1.5399
p_dog   = (-1.6)(0.994) + (0.8)(0.006) + (0.9)(-0.113)
        = -1.5904 + 0.0048 - 0.1017 = -1.6873
p_car   = (-1.6)(0.994) + (-0.9)(0.006) + (-0.6)(-0.113)
        = -1.5904 - 0.0054 + 0.0678 = -1.5280
p_truck = (1.6)(0.994) + (-0.9)(0.006) + (-0.8)(-0.113)
        =  1.5904 - 0.0054 + 0.0904 =  1.6754

check: 1.5399 - 1.6873 - 1.5280 + 1.6754 = 0.0000   # must sum to zero: the data is centred

Step 3b: subtract the projections. v′ = ṽ − p·u1.

removing the top componentcat':   (1.6, 1.0, 0.5)     - 1.5399 x (0.994, 0.006, -0.113)
      = (1.6, 1.0, 0.5)     - (1.530661,  0.009239, -0.174009)
      = ( 0.069339,  0.990761,  0.674009)

dog':   (-1.6, 0.8, 0.9)    + 1.6873 x (0.994, 0.006, -0.113)
      = ( 0.077176,  0.810124,  0.709335)

car':   (-1.6, -0.9, -0.6)  + 1.5280 x (0.994, 0.006, -0.113)
      = (-0.081168, -0.890832, -0.772664)

truck': (1.6, -0.9, -0.8)   - 1.6754 x (0.994, 0.006, -0.113)
      = (-0.065348, -0.910052, -0.610680)

check columns sum to zero:
  x:  0.069339 + 0.077176 - 0.081168 - 0.065348 = -0.000001
  y:  0.990761 + 0.810124 - 0.890832 - 0.910052 =  0.000001
  z:  0.674009 + 0.709335 - 0.772664 - 0.610680 =  0.000000

After: the six cosines again. Norms first: ‖cat′‖ = 1.200293, ‖dog′‖ = 1.079543, ‖car′‖ = 1.182023, ‖truck′‖ = 1.097905. Then the one done longhand:

cat′ · dog′ = (0.069339)(0.077176) + (0.990761)(0.810124) + (0.674009)(0.709335)
= 0.005351 + 0.802639 + 0.478098 = 1.286088
cos = 1.286088 / (1.200293 × 1.079543) = 1.286088 / 1.295768 = 0.99253
PairBefore ABTTAfter ABTT (D = 1)Change
cat – dog (true pair)0.87969+0.99253pulled together
car – truck (true pair)0.91888+0.99238pulled together
cat – car0.62566−0.99312pushed apart
cat – truck (the bad neighbour)0.88228−0.99998maximally separated
dog – car0.21426−0.99999pushed apart
dog – truck0.56943−0.99176pushed apart
Average A0.68170−0.33332

Three things to notice, in increasing order of importance.

One: cat's nearest neighbour is now dog, at +0.99253, with truck at −0.99998. The retrieval is fixed, and not narrowly — the margin went from −0.0026 to +1.99.

Two: the average pairwise cosine is −0.33332, which is −1/3 = −1/(n−1) for n = 4 to within four decimal places. Be precise about why it is not exact. Chapter 1's identity says a centred set of vectors satisfies ∑i≠j ui·uj = −∑i‖ui2 exactly — that is an identity about dot products, and it holds here to machine precision. Turning it into a statement about the average cosine requires dividing each term by ‖ui‖‖uj‖, and only if all four norms are identical does that division factor out cleanly. Ours are 1.200, 1.080, 1.182 and 1.098 — close but not equal — so we land at −0.333323 instead of −0.333333. Chapter 1 stated the equal-norm condition; this is what violating it slightly costs you.

Which assertion to actually put in the unit test. Do not assert on the average cosine, because it only equals −1/(n−1) under a condition your data will not satisfy. Assert on the thing that is exact: np.abs(X.mean(0)).max() < 1e-10 after centring, and np.allclose(X @ U.T, 0) after projection — every vector must have exactly zero component along every removed direction. Those two hold to floating-point regardless of norms. Then, separately, assert the average cosine is near −1/(n−1) with a tolerance that reflects how spread your norms are: the deviation is roughly the relative variance of the norms, which is 0.2% here.

Three — and this is the honest one: every single cosine after the transform has magnitude above 0.99. That is not a triumph, it is a symptom of over-correction. We had 3 dimensions, we removed the mean (costing one degree of freedom) and one principal component (costing another), so the four points now live in a 2-dimensional residual space and happen to lie almost along a line in it. The ordering is right, but the geometry has been flattened.

This is exactly why the rule is D ≈ d/100. We just removed 33% of the available directions. In a real 300-dimensional space, removing 3 costs you 1% of the space and the residual has 296 dimensions left to be interesting in. The toy exaggerates both the disease and the cure, which is what toys are for — but do not carry the exaggeration into production.

What the transform did to the spectrum

Cosines are what you consume, but the spectrum is what you should watch, because it is the thing Chapter 1 told us average cosine cannot see. Track it through all three stages of the algorithm on the same four words.

StageEigenvaluesTop-1 shareEffective rank (of 3)Average cosine A
Raw — second moment S11.5656, 1.3022, 0.022289.7%1.41+0.68170
After step 1 — centred covariance Σ2.5878, 1.2973, 0.004966.5%1.91−0.33 (the floor)
After steps 2–3 — ABTT with D = 11.2973, 0.0049, 099.6%1.02−0.33332

Three separate lessons live in that table, and they are all worth more than the cosine numbers.

Centring alone did most of the spectral work. Top-1 share fell from 89.7% to 66.5% and effective rank rose from 1.41 to 1.91, from one subtraction. That is the rank-one spike μμT being removed from S = Σ + μμT, exactly as Chapter 1 predicted — and it is why "just centre" is the single highest-value line in this entire lesson.

Average cosine went blind after step 1. It reads about −1/3 both after centring and after the full transform, because centring forces that value and nothing afterwards can change it. Every bit of the difference between rows two and three is invisible to A. If you were tuning D by watching average cosine, you would see a flat line and conclude D does not matter.

Removing the top component made the remaining spectrum worse, not better. Top-1 share went from 66.5% to 99.6% and effective rank collapsed from 1.91 to 1.02. This is not a bug and it is not a contradiction — it is arithmetic. Deleting λ1 leaves λ2 as the new largest, and shares are computed against a smaller total, so whatever survives is automatically a bigger fraction of it. In three dimensions with a lopsided tail, that is a violent effect.

The metric you tune D on must be the one you deploy on. Watch average cosine and D looks irrelevant. Watch top-1 share and D = 1 looks like a disaster. Watch the actual retrieval — cat's nearest neighbour flipping from truck to dog — and D = 1 is exactly right. Three metrics, three opposite verdicts, one transform. Chapter 7 makes this a rule; here it is already unavoidable in a four-word toy.

Choosing D without a rule of thumb

D ≈ d/100 is a starting point, not an answer. The procedure that actually finds the right D takes about twenty minutes and needs no labels for the first two steps.

Step A — look for a gap
Plot λk on a log axis. Frequency and drift artefacts often sit above a visible elbow, with the semantic bulk below it. If there is a clean gap after component 4, D = 4 is a well-motivated guess. If the spectrum is a smooth power law with no elbow, there is no gap to find and you must use step B.
Step B — correlate each component
For k = 1 … 20, compute the correlation between the projection X uk and every piece of metadata you have: token count, character length, corpus frequency of the rarest token, document source. Components whose |correlation| exceeds about 0.5 are artefact candidates. This directly measures the thing D is supposed to be counting, instead of guessing at it.
Step C — sweep against the real metric
Run D = 0, 1, 2, 4, 8, 16, 32 and score each on your own evaluation — recall@10, clustering purity, false-merge rate. Expect the inverted U the paper reports. Take the peak, then back off one step, because the peak is estimated with noise and the left side of the curve is safer than the right.

Step B is the one people skip and the one that pays. It converts "how many components are contaminated?" from a hyperparameter into a measurement, and it gives you the artefact evidence Chapter 7 will demand before you are allowed to delete anything.

SHOWCASE — All-but-the-Top on eight words

Eight words in three semantic clusters, embedded in a space with a big shared direction and a frequency-aligned direction. The heatmap is the full pairwise cosine matrix; warm cells are high similarity. On the right, each word's actual nearest neighbour, ticked when it lands in the correct cluster. It opens on the raw vectors — a uniformly warm wall with three wrong neighbours. Turn subtract mean on, then drag D upward and watch the block structure emerge — then keep dragging and watch it fall apart again as you delete real semantic axes.

D (PCs removed) 0
Shared offset 3.00

Run the sim with subtract mean: off and D = 1. You will find the first principal component of the uncentred data is essentially the mean direction, so removing it does most of the centring for you — which is a common source of confusion when people report that "PCA removal alone" works. It works because it accidentally did step 1 with its first component. Doing them separately is cheaper and clearer, and it frees the first component to attack the shape rather than the offset.

The implementation, and the part everyone gets wrong

all-but-the-top, fitted and applied correctlyimport numpy as np

def abtt_fit(V, D):
    """V: (n, d) matrix of embeddings from your CORPUS. Returns a transform."""
    mu = V.mean(axis=0)                          # (d,)
    X  = V - mu                                     # (n, d) centred
    # right singular vectors of X == eigenvectors of X^T X == PCs of the covariance
    _, _, Vt = np.linalg.svd(X, full_matrices=False)
    U = Vt[:D]                                      # (D, d), orthonormal rows
    return mu, U

def abtt_apply(V, mu, U):
    X = V - mu                                      # same mu as fit time
    return X - (X @ U.T) @ U                        # (n,D) @ (D,d) -> (n,d)

The shapes are the documentation. X @ U.T is (n, D) — one projection coefficient per vector per removed component. Multiplying that by U (D, d) reconstructs the part of each vector that lives in the deleted subspace, which is exactly what we subtract.

Two details in abtt_fit are not stylistic. The SVD is taken on the centred X, not on V, because the right singular vectors of X are the eigenvectors of XTX, which is the covariance up to a factor of n — run it on the uncentred V and you get the eigenvectors of the second moment S instead, whose top component is essentially μ and which therefore wastes your first removal on redoing step 1. And full_matrices=False matters for cost, not correctness: with it, the SVD returns min(n,d) singular vectors instead of building a full d×d orthogonal matrix you were going to slice down to D rows anyway.

The mistake that quietly destroys retrieval quality. μ and U are fitted parameters. If you fit them on your document corpus and then, at query time, fit a fresh μ on the single incoming query — or worse, forget the transform for queries entirely — documents and queries end up in different coordinate systems and every similarity is garbage. Store μ and U next to the index. Apply the identical transform to everything, forever, including any vector you add later. If you re-fit, you must re-index the entire corpus.

Shipping it when you cannot re-index

The re-index requirement is the operational objection that stops most of these projects, and it has a workaround worth knowing.

ABTT is linear, and linear transforms can be moved to the other side of a dot product. If your index stores raw vectors v and you cannot rewrite fifty million of them, apply the transform to the query twice instead:

P v · P q = vTPTP q = vT P q   (since PT = P and PP = P)

The projector's symmetry and idempotence mean transforming both sides is identical to transforming one side twice — and since P is idempotent, that is identical to transforming the query once and leaving the documents alone. So a plain dot-product index over raw vectors, queried with Pq, returns exactly the ABTT-transformed dot products, for free, with no re-indexing at all.

The catch, and it is a real one. That identity holds for the dot product, not the cosine — the cosine divides by ‖Pv‖, which you did not compute and cannot recover from the stored ‖v‖. So the trick works cleanly for an inner-product index and not for a cosine index. It also does not survive quantisation or a graph index built on the old metric, since the neighbourhood structure those rely on was constructed under the untransformed geometry. Use it for a brute-force or IVF-flat inner-product index; do not assume it for HNSW.

What the paper actually reported

Mu and Viswanath ran this on word2vec and GloVe across a wide battery: word similarity (RG65, WordSim-353, MEN, SimLex-999 and others), concept categorisation, word analogy, semantic textual similarity, and supervised sentence-classification tasks with the embeddings frozen. The post-processed vectors improved on essentially all of them, at zero training cost. They also verified the mechanism directly: the top principal components of the raw vectors correlate with word frequency, and the isotropy measure I(V) — the partition-function ratio from Chapter 1 — moves substantially closer to 1 after processing.

Two details worth carrying:

DetailWhy it matters
The gain is largest on similarity tasks and smallest on tasks that were never geometry-limitedConfirms the diagnosis: the fix repairs a similarity-measurement defect, not a representation-quality defect
It composes with dimensionality reduction — you can apply ABTT, then PCA down, then apply ABTT againBecause reduction re-concentrates variance, a second dominant direction can appear. This foreshadows the whitening-k trick in Chapter 6

And one warning the paper is explicit about: the vectors coming out of ABTT are not drop-in replacements in a system that assumed the old geometry. Everything downstream — thresholds, cached similarities, approximate-nearest-neighbour indexes — must be rebuilt.

Does it compose with itself?

A question worth settling because the answer is instructive. If removing one component helps, does running the whole algorithm twice help twice?

Applied to the same data with the same fitted U, no — and this is exactly the idempotence of the projector from earlier in this chapter. P = I − UUT satisfies PP = P, so the second application changes nothing at all. Running abtt_apply twice is a no-op, and if your pipeline accidentally does it, you will see zero difference and correctly conclude nothing is wrong.

Applied with a refitted U, yes, and this is the composition the paper points at. After removing D components, re-run the fit: the covariance of the output has a new top eigenvector, and if the variance is still concentrated it may be worth removing too. The reason it is not simply the same as choosing a larger D up front is that the intermediate step usually includes a dimensionality reduction — ABTT, then PCA down to k, then ABTT again — and the reduction re-concentrates variance into the surviving directions, which can manufacture a new dominant direction that did not exist before. Whitening-k in Chapter 6 is the closed-form version of the same idea, doing the flatten and the reduce in one step instead of alternating.

You apply All-but-the-Top with D = 1 to a set of n embeddings and measure the average pairwise cosine of the result. It comes out at exactly −1/(n−1). What have you learned?

Chapter 4: The Layer-Wise Anatomy

Everything so far assumed each word has one vector. Contextual models broke that assumption, and in doing so they made anisotropy dramatically worse — while also making it much harder to reason about, because now the same word has thousands of vectors and you have to decide what you are even comparing.

Kawin Ethayarajh's 2019 paper is the measurement that sorted this out. It asks a deceptively simple question — how contextual are contextualised word representations, really? — and to answer it honestly he had to first establish that you cannot read a cosine similarity off a contextual model without correcting for the layer's anisotropy. That correction is the part of the paper this lesson needs most, but the findings that come out of it are worth the whole chapter.

Four measurements, defined exactly

Fix a model and a layer ℓ. Write f(s, i) for the layer-ℓ representation of the token at position i of sentence s.

1. The anisotropy baseline. Sample two words uniformly at random from the corpus, each in its own context, and take the expected cosine. This is A from Chapter 1, computed on contextual vectors:

baseline(ℓ) = Ew,w′ random [ cos( f(s, i), f(s′, i′) ) ]

2. Self-similarity. Take a single word w that occurs in n different sentences, and average the cosine over all pairs of its occurrences:

SelfSim(w) = ( 1 / (n(n−1)) ) ∑jk≠j cos( f(sj, ij), f(sk, ik) )

That is structurally identical to the anisotropy baseline — average pairwise cosine — just restricted to occurrences of one word. Which means Chapter 1's identity applies: SelfSim(w) = (n‖μ̂w2 − 1)/(n−1), where μ̂w is the mean of the unit-normalised occurrence vectors. A word whose occurrences all point the same way has a long mean and a self-similarity near 1; a word whose meaning is entirely determined by context has a short mean and a low one.

3. Intra-sentence similarity. Within one sentence, take the sentence vector to be the mean of its word vectors, then average each word's cosine to it:

IntraSim(s) = (1/m) ∑i cos( f(s, i), s̄ ),   s̄ = (1/m) ∑i f(s, i)

4. Maximum explainable variance. Collect a word's occurrence vectors into a matrix and run PCA. MEV(w) is the fraction of variance the first principal component explains — the answer to "how much of this word's contextual behaviour could a single static vector capture?"

Note the different shapes of these four, because it is easy to conflate them. Measurement 1 averages over all words in all contexts — one number per layer. Measurements 2 and 4 are per-word, computed over that word's occurrences — they answer "how stable is this word." Measurement 3 is per-sentence, computed over the tokens inside it — it answers "how much do the words in a sentence converge on one representation." Same operation, three different populations, and the population is what gives each number its meaning. A common error is to compute intra-sentence similarity, find it high, and report it as evidence of anisotropy — it is not, until you subtract the baseline, because words in a sentence and words drawn at random both inherit the same cone.

The correction that makes all of it meaningful

Here is the methodological hinge. Every one of measurements 2, 3, 4 is a cosine similarity, and Chapter 0 showed that cosines in an anisotropic space are inflated by an amount that has nothing to do with the quantity you meant to measure. So each must be reported relative to the layer's own baseline:

SelfSimadj(w) = SelfSim(w) − baseline(ℓ)

Worked example: the same word, two layers. Take the word run, appearing in four sentences, and measure at two depths of the same model.

layer 12 — the last layerthe six pairwise cosines among "run"'s four occurrence vectors:
  0.991  0.988  0.983  0.990  0.986  0.984
  sum = 5.922      SelfSim = 5.922 / 6 = 0.987

anisotropy baseline for this layer  = 0.985
adjusted self-similarity            = 0.987 - 0.985 = 0.002
layer 1 — near the bottomthe six pairwise cosines among the same four occurrences:
  0.81  0.77  0.79  0.76  0.80  0.75
  sum = 4.68       SelfSim = 4.68 / 6 = 0.780

anisotropy baseline for this layer  = 0.250
adjusted self-similarity            = 0.780 - 0.250 = 0.530
Read the raw numbers and you get the story backwards. Raw, the last layer looks far more consistent: 0.987 versus 0.780. Adjusted, the last layer's representations of run are essentially as unrelated to each other as two random words are — 0.002 — while in layer 1 the word retains half its identity across contexts. The last layer has not made the word more coherent; it has made everything more similar, and the word's own signal is what got buried.

This is the reason you should be suspicious of any paper, blog post, or dashboard that reports a raw cosine between contextual embeddings without saying what the baseline was. A cosine of 0.85 could be an extraordinary match or a below-average one, and the number alone cannot tell you which.

The correction reorders words — it is not a rescaling

A tempting misreading: "subtracting a per-layer constant is a monotone transform, so it moves all the numbers down together and the ranking is safe." That is true within a layer and false the moment you compare across layers — which is exactly what anyone selecting a layer, or building a per-layer feature, is doing. Here is the flip, in five rows of arithmetic.

five words, raw self-similarity measured at whichever layer each was measuredword      layer   raw SelfSim   layer baseline   adjusted
------------------------------------------------------------------
mortgage    11        0.962          0.955          +0.007
crane        6        0.804          0.410          +0.394
the         12        0.978          0.981          -0.003
photosynth.  6        0.851          0.410          +0.441
bat          9        0.905          0.760          +0.145

RAW ranking (most self-similar first):
   the 0.978 > mortgage 0.962 > bat 0.905 > photosynthesis 0.851 > crane 0.804

ADJUSTED ranking:
   photosynthesis 0.441 > crane 0.394 > bat 0.145 > mortgage 0.007 > the -0.003

# the ordering is very nearly REVERSED. Every conclusion you would draw
# from the raw column about which words carry stable identity is wrong.

The reversal is not a coincidence, it is structural. Raw self-similarity is dominated by the layer's baseline, and the baseline rises with depth, so the raw column is mostly ranking which layer you measured at. The word with the most stable identity in this table, photosynthesis, has one sense and appears in one kind of context; it scores fifth from the top on the raw number purely because it was measured at layer 6.

The operational rule. A raw contextual cosine is only comparable to another raw contextual cosine from the same model and the same layer. The moment either changes, subtract the baseline first or throw the comparison away. This applies to your own dashboards: if you log "average similarity of retrieved documents" and someone later switches the pooling layer, your metric silently changes meaning and the graph will look like a quality regression that is really a coordinate change.

Mean-pooling multiplies the anisotropy — derived and checked

Nearly every sentence-embedding pipeline averages token vectors. That operation has a large, predictable, and almost never mentioned effect on the geometry, and Chapter 2's coherent-versus-incoherent argument gives it to us in three lines.

Model each token vector as a shared part plus an idiosyncratic part, hi = c + gi, with ‖c‖ = C, ‖gi‖ = G, and the gi independent across tokens. Average m of them for a sentence:

s = (1/m) ∑i ( c + gi ) = c + (1/m) ∑i gi

The shared part comes through untouched — averaging m copies of c gives c. The idiosyncratic part is an average of m independent random vectors, so its norm is G/√m, not G. Pooling shrinks the signal and leaves the noise floor exactly where it was. Feed that into the same expansion Chapter 0 used and the anisotropy baseline for pooled vectors is:

A(m) = C2 / ( C2 + G2/m )   ⇒    usable band = 1 − A(m) ≈ G2 / (m C2)

Take a model whose single-token residual is half the size of its shared component, G/C = 0.5, and read off what pooling does. The right column is a simulation on 768-dimensional vectors built to exactly that recipe, 2,000 sentences each:

Tokens pooled, mPredicted A(m)Simulated AUsable band 1 − A
1 — a single token0.800000.799870.200
4 — a short phrase0.941180.941240.059
20 — a sentence0.987650.987640.0123
100 — a paragraph0.997510.997510.0025
The usable band shrinks by a factor of m. Pool a hundred tokens and you have one hundredth of the similarity range you had for a single token — from the pooling alone, before the model contributes anything. This explains a family of otherwise mysterious observations at a stroke: why longer documents cluster more tightly than short ones in the same index, why chunk-size changes move your similarity thresholds, and why paragraph-level retrieval feels "mushier" than sentence-level retrieval on the same encoder. It also tells you that a threshold tuned on 20-token chunks is simply invalid on 100-token chunks, by a factor of five in the band.

Two practical consequences follow immediately. First, if your chunks have variable length, your anisotropy is variable too, and long chunks get a systematic similarity bonus to everything — which is precisely the "corr(u1, token_count) = 0.78" line you will meet in Chapter 9's diagnostic report. Second, centring helps here more than almost anywhere else, because c is exactly what mean-pooling preserved perfectly and μ is exactly an estimate of c.

Finding one: it gets worse with depth

Across ELMo, BERT and GPT-2, the anisotropy baseline rises monotonically through the network. The input layer is close to isotropic — that layer is a lookup table, non-contextual, and it never went through the residual accumulation of Chapter 2's mechanism three. Every layer above it is anisotropic, and the last layer is dramatically so.

GPT-2 is the extreme case: in its last layer, two words sampled uniformly at random have an average cosine similarity of approximately 0.99. Effectively, every representation in that layer points the same way.

SHOWCASE — the layer-wise anatomy

Curves by relative depth for the three models Ethayarajh measured. Switch between the anisotropy baseline, raw self-similarity, and the anisotropy-adjusted version, and drag the cursor to read exact values with the subtraction shown. These curves are schematic — shaped to match the reported figures so you can reason about them, not digitised from the paper. The qualitative claims are the point: anisotropy climbs with depth, and adjusted self-similarity falls.

Depth cursor 1.00

Switch to self-similarity (raw) and look at GPT-2's curve. It dips through the middle of the network and then climbs back to nearly 1.0 at the top — which, read naively, says the last layer has recovered a stable word identity. Now switch to adjusted. The climb disappears entirely; the curve keeps falling. The apparent recovery was the anisotropy baseline rising underneath it.

Finding two: upper layers are more context-specific

With the correction applied, adjusted self-similarity decreases monotonically with depth in all three models. The higher you go, the less a word's representation is determined by which word it is and the more by where it sits.

That is what "contextualised" was supposed to mean, and it is satisfying that the measurement confirms it. But the same measurement gives a much less obvious result one step further in.

Finding three: stopwords are the most contextual words

Which words have the lowest adjusted self-similarity — the most context-dependent representations? Not the famous polysemes like bank or bat. Ethayarajh found that among the least self-similar words are stopwords: the, of, and, to.

The reason, once you see it, is inevitable. A word's representation is built out of its own identity plus its context. Bank carries a lot of its own identity — a handful of distinct senses, each with a stable signature. The carries almost none. It appears in every context, contributes nearly nothing semantically, and so its representation is close to a pure readout of whatever surrounds it. A word with no meaning of its own has nothing to be self-similar about.

Consequence for pooling. This is a concrete argument against mean-pooling all token vectors to get a sentence embedding, and a concrete argument for it, depending on your goal. Stopword vectors in upper layers are the most context-saturated things in the sentence — they are, in a sense, tiny sentence summaries. Averaging them in is not obviously noise. But they are also the tokens whose vectors move most between paraphrases, which is bad for stability. There is no free answer here; the point is that "stopwords are uninformative" is a claim about the static world that does not survive contact with contextual geometry.

Finding four: contextual is not "static plus noise"

The most consequential negative result in the paper. If contextual representations were just a static vector with contextual jitter added, then a word's first principal component would explain most of the variance in its occurrences. It does not: on average, less than 5% of the variance in a word's contextualised representations is explained by its first principal component.

Get a feel for how small that is. If a word occurs 100 times, the centred occurrence matrix has at most 99 non-zero eigenvalues. Perfectly uniform variance across all of them would give each component 1/99 = 1.01%. A measured 4% is four times uniform — a real dominant direction exists — but it is nowhere near the 60–90% you would need to say "this word has an embedding, plus noise."

And yet: Ethayarajh also showed that if you do take that first principal component from lower layers and use it as a static embedding, it outperforms GloVe and FastText on many static-embedding benchmarks. Both facts are true at once. The static component is a small fraction of what is going on, and it is still a very good static embedding.

There is one arithmetic subtlety to keep straight when you compute MEV yourself, and it is the same finite-sample effect Chapter 1 warned about. A word with m occurrences has a centred occurrence matrix of rank at most m − 1, so the uniform baseline for its top component is 1/(m − 1), not 1/d. For m = 10 occurrences the uniform floor is 11%, so a measured MEV of 12% is nothing. For m = 1,000 the floor is 0.1%, and the same 12% is enormous. Always compare MEV against 1/(m − 1) for that specific word, and never pool words with very different occurrence counts into one average without doing so — otherwise your "average MEV" is mostly a measurement of how often each word appears.

Read findings three and four together and a design principle falls out. The most context-dependent tokens are the ones with the least meaning of their own, and no word's contextual behaviour is well summarised by a single vector. Both statements point the same way: a contextual model is not a better lookup table. It is computing something about the position, and the token identity is one input to that computation rather than its subject. Pipelines that treat contextual embeddings as "word2vec but better" — averaging them, caching them per word type, comparing them across sentences without a baseline — are importing an assumption the measurements refute.

Finding five: the three models are contextual in different ways

Intra-sentence similarity separates the architectures, and the differences are not cosmetic.

ModelWhat happens to words in the same sentence, going up the layersReading
ELMoWords in the same sentence become more similar to each other in upper layersThe biLSTM pushes toward a shared sentence-level representation — context-specificity by convergence
BERTWords in the same sentence become more distinct from one another in upper layers, once you adjust for the baselineContext-specificity by differentiation — each token specialises rather than converging on a sentence gist
GPT-2Intra-sentence similarity stays low relative to the baseline — two words in the same sentence are barely more similar than two random wordsThe extreme case: nearly all of the geometry is the shared cone, and what is left is highly individuated

Three models, three geometries, all called "contextual embeddings" in the same sentence of a hundred papers. If you swap one for another in a pipeline and your similarity thresholds do not move, you have not been measuring what you thought.

Is the anisotropy doing something useful?

It is worth resisting the assumption that anisotropy is purely pathological. Ethayarajh raises the possibility that it is a byproduct of context-specificity — that a model which must make representations depend heavily on context may unavoidably produce a geometry like this, and that the two are linked rather than one being a defect on top of the other.

There is a plausible mechanism. If upper layers are computing something closer to "what should be predicted next given everything" than "what does this token mean," then all positions in all sentences are computing answers to the same kind of question, and answers to the same question live in the same region. The cone might be the model's answer space, not a bug in its coordinate system.

This matters practically, because it predicts that aggressive isotropy fixes on the last layer will destroy something. Which is exactly what Chapter 7 finds.

Worked example: self-similarity two ways

Because self-similarity is an average pairwise cosine, Chapter 1's identity computes it in one pass instead of n2. Worth verifying once on numbers you can check, because on a real corpus a word can occur a hundred thousand times and the quadratic route is not available.

Take a word with four occurrences whose (unit-normalised) representations sit at angles 20°, 25°, 65° and 70° in a two-dimensional slice — two occurrences in one sense, two in another.

route one — all six pairs(20,25) -> cos  5 = 0.99619      (25,65) -> cos 40 = 0.76604
(20,65) -> cos 45 = 0.70711      (25,70) -> cos 45 = 0.70711
(20,70) -> cos 50 = 0.64279      (65,70) -> cos  5 = 0.99619

sum = 4.81543          SelfSim = 4.81543 / 6 = 0.802572
route two — one mean, one dot productangle   cos        sin
 20     0.93969    0.34202
 25     0.90631    0.42262
 65     0.42262    0.90631
 70     0.34202    0.93969
sum     2.61064    2.61064
mu_hat  0.65266    0.65266

||mu_hat||^2 = 0.65266^2 + 0.65266^2 = 0.425965 + 0.425965 = 0.851930

SelfSim = (n * ||mu_hat||^2 - 1) / (n - 1)
        = (4 * 0.851930 - 1) / 3 = 2.407720 / 3 = 0.802573

Six cosines versus one mean and one dot product, agreeing to six decimals. On a hundred thousand occurrences the first route is 5×109 cosines and the second is still one mean and one dot product.

Computing your own baseline — the code

The baseline is layer-specific and corpus-specific, so the published numbers are not yours. Fortunately it is the cheapest measurement in this lesson.

layer-wise anisotropy baseline for any HF modelimport torch, numpy as np

def layer_baselines(model, tok, sentences):
    """Returns one anisotropy baseline per layer, using the Chapter 1 identity."""
    sums, counts = None, 0
    for s in sentences:
        enc = tok(s, return_tensors="pt", truncation=True)
        out = model(**enc, output_hidden_states=True).hidden_states   # tuple of (1, T, d)
        if sums is None:
            sums = [torch.zeros(h.shape[-1]) for h in out]
        for L, h in enumerate(out):
            v = h[0]                                             # (T, d)
            v = v / v.norm(dim=-1, keepdim=True)                    # unit vectors
            sums[L] += v.sum(0).detach()
        counts += out[0].shape[1]
    return [float((counts * (m / counts).pow(2).sum() - 1) / (counts - 1))
            for m in sums]

Accumulate one running sum of unit vectors per layer, then square its length. One pass over a few thousand sentences, and you have the number that makes every future cosine on that model interpretable. Store it beside the model, and never report a similarity without it again.

The static-embedding recipe, in five lines

Ethayarajh's fourth finding is also a small free win, and it takes about an hour to build. Take the occurrences of a word in a corpus, collect their layer-ℓ representations, and take the first principal component:

a static embedding table distilled from a contextual modelocc = collect_occurrences(word, corpus, layer=5)      # (m, d) — a LOW layer, not the last
X   = occ - occ.mean(0)
u1  = np.linalg.svd(X, full_matrices=False)[2][0]        # (d,) the dominant direction
# fix the sign so the component points with the majority of occurrences
static[word] = u1 * np.sign((X @ u1).sum())

Two details carry the result. Use a low layer, because upper layers are more context-specific and less self-similar, so their first component captures less. And fix the sign, because a principal component is only defined up to sign and a library will hand you either one — a sign flip turns a word into its own antonym as far as cosine is concerned, and this bug is silent.

So which layer should you actually pool from?

Everything in this chapter converges on one decision that every sentence-embedding pipeline has to make and that most make by default. The anatomy gives us the reasoning, and the reasoning contradicts the default.

Three forces are in tension as you move up the network.

Going up the layers…DirectionConsequence for a frozen similarity system
Anisotropy baselineRises, sharply near the topBad. Your usable band shrinks; ranking corruption grows
Context-specificity (falling adjusted self-similarity)RisesGood for disambiguation — the representation knows which sense of the word this is
Specialisation toward the training objectiveRisesBad for similarity. The last layer is optimised to predict the next token, not to describe the sentence, so its geometry is an answer space, not a meaning space

Two of the three say do not use the last layer, and the third says do not use the first. That is the whole argument for the middle, and it is why the standard strong baseline in this literature is first-last averaging — pool the token vectors of layer 0 and layer L and average the two — rather than taking the final layer alone.

It is worth seeing why that particular combination works, because it is not obvious. Layer 0 is the input embedding table: non-contextual, but also barely anisotropic, since it has never been through the residual accumulation of Chapter 2's mechanism three. The last layer is highly contextual and highly anisotropic. Averaging them is a crude but effective trade: you get some contextualisation, and you dilute the cone with a layer that does not have one.

the pooling decisions, ranked by what they cost you1. WHICH LAYER    — biggest effect, costs one line
     last layer only ......... worst geometry, most specialised
     first-last average ...... the standard strong baseline
     a middle layer .......... often best; sweep 6..9 of 12 on your own eval
     layer 0 only ............ isotropic but non-contextual — a static embedding

2. WHICH TOKENS   — second biggest
     [CLS] ................... only meaningful if something TRAINED it. In a plain
                               MLM checkpoint the CLS vector was trained for next-
                               sentence prediction or nothing at all
     mean over tokens ........ the default. See Chapter 4's pooling derivation:
                               it multiplies the anisotropy by the token count
     mean over content words . lower m, so a wider usable band — but stopwords are
                               the MOST context-saturated tokens (finding three),
                               so you are discarding sentence-level signal
     max over tokens ......... rarely better; unstable under paraphrase

3. NORMALISE?     — no effect on cosine whatsoever (Chapter 0). Do it for storage
                    hygiene if you like, but do not expect a quality change.
Sweep the layer before you reach for any transform in Chapters 3 to 6. Changing the pooling layer is one line, costs nothing at inference, introduces no fitted artefact to version and re-fit, and on decoder-pooled embeddings it is frequently a larger win than whitening. The order of operations that follows from this whole chapter is: pick the layer, then pick the pooling, then measure the baseline, and only then consider a transform. Most teams do these in exactly the reverse order.

A note on subwords, which quietly breaks all of this

Every definition in this chapter said "the representation of the word at position i." Real tokenizers do not give you words. They give you subword pieces, and a rare word — exactly the kind whose geometry you most want to inspect — is the one most likely to be split into several.

That creates two distinct problems and they need different answers.

ProblemWhat goes wrongWhat to do
A word spans several tokensYou have three vectors where the definition wanted one. Averaging them is the usual choice, but Chapter 4's pooling derivation says that averaging m vectors multiplies the anisotropy by m — so multi-piece words get a systematically higher self-similarity than single-piece words, for purely mechanical reasonsEither compare only single-piece words, or record the piece count and check that your conclusions do not correlate with it. Ethayarajh's finding that stopwords are the least self-similar is safe here, since stopwords are always single pieces
The same word tokenizes differently in different contextsLeading-space and capitalisation variants are distinct token ids in a byte-level vocabulary, so " bank", "bank" and "Bank" are three different lookups with three different input embeddings. Self-similarity across them is measuring the tokenizer as much as the modelNormalise the surface form before collecting occurrences, or report per-variant
The anisotropy baseline itself is token-weightedSampling tokens uniformly over a corpus samples frequent tokens far more often, so your baseline is dominated by punctuation and function wordsDecide deliberately. A token-weighted baseline is right if your system embeds running text; a type-weighted one is right if you are studying the vocabulary. They are different numbers and papers do not always say which they used

What the baseline costs to compute, and how often

One practical objection to "always report the anisotropy-adjusted number" is that it sounds like extra infrastructure. It is not, and the arithmetic is worth doing so you can say so with confidence.

QuestionAnswer
How many sentences do I need?A few thousand tokens is plenty. Chapter 1's variance result says sd(A) = √(2/(n(n−1)d)); at n = 10,000 tokens and d = 768 that is 5×10−6. You are limited by whether the sample matches your distribution, not by its size
What does it cost?One forward pass over that sample with output_hidden_states=True, plus one running sum per layer. For a 12-layer base model that is 13 vectors of length d — 80 KB of state, and a few seconds of GPU time
How often must I refit it?Whenever the model changes. The baseline is a property of the weights plus the input distribution, so a model upgrade invalidates it and a corpus shift moves it. Pin it next to the model version
Where should it live?In the same config as the model name and the pooling layer, because those three together determine what a cosine of 0.85 means. A similarity value without all three is not interpretable
What breaks if I skip it?Every threshold you set becomes model-specific and silently wrong after any upgrade — and the failure mode is a quality regression that looks like the new model being worse

What to do with this, today

SituationAction
Reporting or thresholding a cosine similarity from a contextual modelCompute the layer's anisotropy baseline on your own corpus first, and report the excess. A single number, computed once, that makes every downstream similarity interpretable
Choosing which layer to embed fromDo not default to the last. Middle layers are typically less anisotropic and often better for similarity. This is why the standard BERT sentence-embedding baselines pool the first and last layers, or the last two, rather than taking the final one
Building a static embedding table from a contextual modelTake the first principal component of each word's occurrences from a lower layer. It beats GloVe and FastText, and it is a two-hour job
Interpreting a high self-similaritySubtract the baseline before you believe it. Raw self-similarity rises in the last layers of GPT-2 purely because everything does
In a model's last layer, the word light has a raw self-similarity of 0.96 across its occurrences, and the layer's anisotropy baseline is 0.97. In layer 4, the same word has raw self-similarity 0.61 with a baseline of 0.18. Which layer represents light more consistently across contexts, and what does the last-layer number mean?

Chapter 5: BERT-flow — Learning the Map

All-but-the-Top asks: which directions are bad? Then it deletes them. It is surgery.

Li et al. in 2020 asked a different question: what distribution do I wish my embeddings had? Then they learned an invertible function that turns the one they have into the one they want. It is not surgery, it is a change of coordinates — nothing is deleted, everything is rearranged.

The reframing is worth dwelling on, because it changes what counts as a solution. Under the surgical view, the fix is a projection and you must decide how many dimensions to sacrifice. Under the distributional view, the fix is a bijection and you sacrifice nothing — the transform is reversible in principle, and the only question is whether the target distribution is a good one.

The three complaints

The paper's diagnosis of BERT's sentence-embedding space has three parts, and only the first is the one we have been discussing.

Complaint 1 — anisotropy
The embeddings occupy a narrow cone. Cosine similarity is inflated and compressed. Everything from Chapters 0 through 4.
Complaint 2 — frequency bias
Word position in the space is systematically related to corpus frequency. High-frequency words sit closer to the origin; low-frequency words sit farther out. So a sentence's embedding depends partly on how common its words are, independent of what it means.
Complaint 3 — holes
Low-frequency words are also sparse: their neighbourhoods are nearly empty. The distribution has low-density gaps. In those gaps the geometry is untrained — a point there is not "between two meanings", it is nowhere.

Complaint 3 is the one that motivates the whole method, and it is easy to miss. Suppose you embed two sentences and they land on either side of a hole. The straight-line distance between them is small; the amount of trained, meaningful space between them is zero. Any similarity you compute across a hole is an extrapolation the model never had reason to get right. A distribution with no holes is not a cosmetic preference — it is the condition under which distance means something everywhere.

The information is there; the readout is bad. Li et al. argue on theoretical grounds — via the well-known relationship between language-modelling objectives and pointwise mutual information between contexts and words — that BERT's representations should already contain the co-occurrence statistics that semantic similarity needs. Their claim is not "BERT does not know", it is "cosine similarity cannot read what BERT knows out of this particular distribution." That distinction sets the fix: do not retrain BERT, retrain the readout.

Why a standard Gaussian is the right target

They pick N(0, I) — the standard multivariate normal. Three properties, each answering one complaint.

Property of N(0, I)Complaint it answers
Its covariance is the identity, so every direction has the same variance and no direction is special. It is isotropic by definition1 — anisotropy
It is centred at the origin, so there is no offset for cosine to trip over, and no systematic radial ordering for frequency to hide in2 — frequency bias
Its density is positive everywhere and log-concave, so the high-probability region is a ball with no interior gaps. There is nowhere to fall through3 — holes

There is also a practical reason: we know exactly how to build invertible maps to a Gaussian, because the machine-learning community spent five years doing it for image generation. The tool is called a normalising flow.

Normalising flows from zero

A flow is an invertible function f between two spaces, used to move a probability distribution from one to the other. The single fact you need is the change-of-variables formula, and it is easier to derive in one dimension than to look up.

Suppose z is a random variable with density pZ, and u = f(z) for an invertible, differentiable f. Probability mass is conserved: the chance of landing in a tiny interval around u must equal the chance of landing in the corresponding interval around z.

pU(u) |du| = pZ(z) |dz|  ⇒   pU(u) = pZ(f−1(u)) · | df−1/du |

The derivative factor is a bookkeeping correction: if f stretches a region, the density there must thin out to compensate. In d dimensions, "how much does f stretch volume" is the absolute determinant of the Jacobian:

pU(u) = pZ( f−1(u) ) · | det ( ∂f−1(u) / ∂u ) |

Check it on a case you already know. Let z ~ N(0,1) and f(z) = 2z + 3, so u should be N(3, 4). Then f−1(u) = (u−3)/2 and the derivative is 1/2. The formula predicts:

pU(u) = φ( (u−3)/2 ) · ½

At u = 3: φ(0) = 1/√(2π) = 0.398942, halved gives 0.199471. And the true N(3,4) density at its mean is 1/(σ√(2π)) = 1/(2 × 2.506628) = 0.199471. At u = 5: φ(1) = 0.241971, halved gives 0.120985; the true value is 0.199471 × e−0.5 = 0.199471 × 0.606531 = 0.120985. Exact both times.

The objective

Now the setup. Let u = BERT(sentence) be a frozen sentence embedding, drawn from whatever unknown distribution BERT induces. We want an invertible fφ mapping a standard Gaussian z into that distribution, so that fφ−1(u) is the Gaussianised version of our embedding. Train φ by maximum likelihood:

maxφ  Eu ∼ U [ log pZ( fφ−1(u) ) + log | det ( ∂fφ−1(u) / ∂u ) | ]

Two terms, and they are in tension — which is the whole reason the method does not collapse.

The first term wants every mapped point to land where the Gaussian's density is high, that is, near the origin. On its own, it would be maximised by the constant map f−1(u) = 0 for all u. Everything to one point, infinite likelihood, zero information. That is representation collapse, arriving by a different road than in contrastive learning but landing in the same ditch.

The second term forbids it. If f−1 squashes volume, its Jacobian determinant goes to zero and log|det| goes to −∞. The log-determinant is an explicit, unavoidable anti-collapse penalty, and it is not a heuristic — it is required by the change-of-variables formula for the first term to be a valid density at all.

Every fix in this lesson is the same two-term fight. Something has to pull related things together and something has to stop everything from collapsing into a point. In a flow it is likelihood versus log-determinant. In whitening it is the covariance constraint (Chapter 6). In contrastive learning it is alignment versus uniformity (Chapter 7). Once you see the pattern, the methods stop looking like unrelated tricks.

Note what is not in that objective: labels. No sentence pairs, no similarity annotations, no NLI data. You need only a pile of sentences from the domain you care about, which means you can fit the flow on your own unlabelled corpus. BERT stays frozen throughout — only φ is trained.

Coupling layers: invertible networks that are actually cheap

The catch in the objective is det(∂f−1/∂u). For a general 768×768 Jacobian, that determinant costs O(d3) per example and the Jacobian itself costs a backward pass per output. Unusable.

The Glow family, which BERT-flow uses, solves this with additive coupling layers. Split the vector in half, u = [ua; ub], and define:

va = ua
vb = ub + m(ua)

where m is any neural network at all — it does not need to be invertible, monotone, or anything else. Two consequences fall out immediately.

It inverts in closed form. Given v, recover ua = va, then compute m(va) — the same network, forward — and set ub = vb − m(va). One forward pass, no solving.

Its log-determinant is zero. Write the Jacobian in block form:

∂v/∂u = [ I  0 ;  ∂m/∂ua  I ]

Lower block-triangular with identity blocks on the diagonal, so its determinant is the product of the diagonal blocks' determinants: 1 × 1 = 1. The log-determinant term is exactly 0, free of charge, for any m whatsoever.

Worked example in four dimensions. Let u = (1, 2, 3, 4), split as ua = (1, 2), ub = (3, 4), and let m(a1, a2) = (0.5a1 + a2, a1 − 0.5a2).

one additive coupling layer, forward and backforward:
  m(1, 2) = (0.5*1 + 2,  1 - 0.5*2) = (2.5, 0.0)
  v_a = (1, 2)
  v_b = (3, 4) + (2.5, 0.0) = (5.5, 4.0)
  v   = (1, 2, 5.5, 4.0)

inverse (uses m in the FORWARD direction only):
  u_a = v_a = (1, 2)
  m(1, 2) = (2.5, 0.0)
  u_b = (5.5, 4.0) - (2.5, 0.0) = (3.0, 4.0)
  u   = (1, 2, 3, 4)          # exact round trip

jacobian dv/du =
  [ 1     0    0  0 ]
  [ 0     1    0  0 ]
  [ 0.5   1.0  1  0 ]         # row from dm_1/du_a
  [ 1.0  -0.5  0  1 ]         # row from dm_2/du_a
  lower triangular, unit diagonal  ->  det = 1  ->  log|det| = 0

One coupling layer leaves half the coordinates untouched, so you stack many and permute the coordinates between them. After enough layers every coordinate has been transformed as a function of every other, the composite is still exactly invertible, and the total log-determinant is still zero.

Worked example: the collapse, and the term that stops it

The claim that the log-determinant prevents collapse is easy to assert and easy to verify, because the simplest possible flow makes the whole thing a one-variable calculus problem.

Let the flow be a single scalar rescaling: f−1(u) = su for some s > 0, in d dimensions. Take one data point at radius r, so ‖u‖ = r. The two terms are:

log pZ(su) = − s2r2/2 − (d/2) log(2π)
log | det(∂f−1/∂u) | = log | det(sI) | = d log s

So the objective, dropping the constant, is:

L(s) = − s2r2/2 + d log s

Without the second term, dL/ds = −sr2, which is negative for every s > 0. Gradient ascent drives s to 0 and every data point to the origin. The "likelihood" grows without bound, and the flow has learned to throw the data away.

With the second term, differentiate and set to zero:

dL/ds = −s r2 + d/s = 0  ⇒   s2 = d / r2  ⇒   s = √d / r

Read the answer: the optimum maps a point of radius r to radius sr = √d. And √d is exactly the typical radius of a standard d-dimensional Gaussian, since E‖z‖2 = d. The flow has done the correct thing — matched the data's scale to the target's scale — and it did so because the log-determinant made shrinking expensive.

Put numbers on it. Take d = 768 and a typical sentence-embedding norm r = 12:

the objective at three values of soptimum:  s = sqrt(768)/12 = 27.7128/12 = 2.3094
   L = -(2.3094^2 * 144)/2 + 768 * ln(2.3094)
     = -(5.3333 * 144)/2 + 768 * 0.83698
     = -384.00 + 642.80 = +258.80

no change:  s = 1
   L = -(1 * 144)/2 + 768 * ln(1) = -72.00 + 0 = -72.00

collapsing:  s = 0.1
   L = -(0.01 * 144)/2 + 768 * ln(0.1) = -0.72 - 1768.39 = -1769.11

# drop the log-det term and the third row becomes the BEST of the three (-0.72),
# with s -> 0 better still. That is the collapse, in one column of numbers.

Why "no holes" is a theorem, not a hope

Complaint 3 was that BERT's distribution has low-density gaps in which distance is meaningless. Why does mapping to a Gaussian fix that, rather than merely rearranging where the gaps are?

Because the Gaussian density is log-concave: log p is a concave function, since log p(z) = −‖z‖2/2 + const and −‖z‖2 is concave. And log-concavity has exactly the property you want, in one line. For any two points x, y and any λ in [0,1]:

p( λx + (1−λ)y ) ≥ p(x)λ p(y)1−λ ≥ min( p(x), p(y) )

The density along the straight line between any two points never dips below the lower of the two endpoints. There is no interior valley, anywhere, ever. If both of your sentences landed in trained, high-density regions, then every point between them is at least as well-supported as the worse of the two — so interpolation and distance are meaningful along the whole segment.

That is the actual argument for the Gaussian, and it is stronger than "it is isotropic." Isotropy is a statement about the second moment; you could get it from many distributions, including a uniform shell that is full of holes in the interior. Log-concavity is a statement about the entire density's shape, and it is what guarantees the space has no dead regions. Read the paper's choice of target this way and it stops looking like a convention borrowed from generative modelling.

Is a Gaussian not itself a thin shell?

An objection that should occur to you, because it occurs to everyone who has met concentration of measure: in high dimensions a standard Gaussian does not look like a filled ball at all. Its samples nearly all sit on a thin spherical shell. Have we just swapped one degenerate geometry for another?

Get the numbers first. For z ~ N(0, Id), the squared norm ‖z‖2 is a chi-squared variable with d degrees of freedom, so it has mean d and variance 2d. Propagate that through the square root with a first-order expansion — ‖z‖ = √d · √(1 + δ) ≈ √d (1 + δ/2) with δ = (‖z‖2 − d)/d — and the variance of the norm is (1/4)·(2d/d) = 1/2:

E‖z‖ ≈ √d ,    sd(‖z‖) ≈ 1/√2 = 0.707

At d = 768: every sample has norm 27.71 ± 0.71, a shell of relative thickness 2.6%. So yes, it is a shell.

But a shell is not a cone, and the distinction is the whole point. The shell is centred on the origin and covers every direction equally. Two independent samples from it have cosine 0 ± 1/√d, which is the isotropic ideal from Chapter 1 — measured, not assumed. A cone is a small patch of a shell. Concentration of measure controls the radial coordinate; anisotropy is entirely about the angular ones, and the Gaussian is perfect in exactly the coordinates that matter.

Radial concentration is harmless; angular concentration is the disease. This also retro-justifies the log-concavity argument rather than undermining it. Log-concavity says there is no interior valley in the density along any segment — the density at the centre is the highest value anywhere, even though almost no samples land there. "Few samples near the origin" and "no low-density gap between two samples" are compatible statements, and it is the second that makes distances meaningful.

What no invertible map can do

Before we admire the method, we should fence in what it can possibly achieve, because the fence is exactly where Chapter 6's result lands.

A bijection cannot separate points that coincide. If f is invertible and BERT put two semantically different sentences at the same vector, then f maps them to the same vector too — that is what "invertible" means, read in reverse. No flow, however deep, can recover a distinction the encoder never made. The method's ceiling is the information already in the frozen embedding.

The objective never mentions a pair. Look again at what is being maximised: the likelihood of individual embeddings under a density. There is no term containing two sentences, no similarity label, no notion of "these should be close." Any improvement in semantic similarity is a side effect of reshaping the marginal distribution. That is a remarkable thing for it to achieve, and it is also a hard ceiling — a method with no pairwise signal cannot repair a pairwise ordering error that the encoder made deliberately.

So what class of defect can a flow fix? Exactly one: a bad global reparametrisation of a space whose local ordering was already correct. If the encoder ranks the right neighbours first but crams all its scores into [0.98, 1.00] with an offset-dependent squeeze, a smooth bijection can undo the squeeze. If the encoder ranks the wrong neighbour first — the cat-truck failure of Chapter 3 — a flow trained on the marginal has no way to know, and may well preserve the error. Hold on to this, because it makes Chapter 6's near-tie between a deep flow and a two-line matrix much less surprising than it first appears: if the defect is a bad reparametrisation of the first two moments, then a transform that fixes the first two moments has already done the whole job.

The stack, and how it is trained

How many layers do you need? Count what one layer can reach. A single additive coupling makes each of the d/2 coordinates in ub a function of the d/2 coordinates in ua, and leaves ua alone entirely. So after one layer, half the coordinates have not been transformed at all. Add a permutation and a second coupling and the previously untouched half is now transformed as a function of the previously transformed half — which means every output coordinate depends on every input coordinate after two couplings, and the composite has a full Jacobian rather than a block-triangular one.

Two is the minimum for full mixing; it is not enough for expressiveness, because the composite of two additive couplings is still a fairly constrained family. In practice BERT-flow-style stacks use on the order of 8 to 16, and the diminishing return is easy to reason about: each additional layer adds capacity but also adds an inference-time forward pass on the query path, and the whole method's justification is that BERT is frozen and the fix should be cheap. If you find yourself needing 64 coupling layers, you are training a second encoder and should consider whether Chapter 7's answer — retrain the first one — is not simply better value.

One coupling layer transforms half the coordinates. The Glow recipe interleaves it with a permutation so that over several layers every coordinate is transformed as a function of every other:

the flow, in the shape it is actually builtclass Coupling(nn.Module):
    def __init__(self, d, hidden=1024):
        super().__init__()
        self.half = d // 2
        self.m = nn.Sequential(nn.Linear(self.half, hidden), nn.ReLU(),
                               nn.Linear(hidden, d - self.half))

    def forward(self, u):                 # u: (B, d)
        a, b = u[:, :self.half], u[:, self.half:]
        return torch.cat([a, b + self.m(a)], dim=-1)   # log|det| = 0

    def inverse(self, v):
        a, b = v[:, :self.half], v[:, self.half:]
        return torch.cat([a, b - self.m(a)], dim=-1)   # same m, forward only

# flow = [Coupling, Permute, Coupling, Permute, ...] x K
# total log|det| = 0 for additive couplings; permutations contribute 0 too
# so the objective reduces to  max  E[ log p_Z( flow.inverse(u) ) ]
# which for a standard Gaussian is  min  E[ ||flow.inverse(u)||^2 / 2 ]
#
# WAIT. That is the collapse objective again — and it does not collapse only
# because an additive coupling CANNOT shrink volume. The constraint is
# architectural rather than a penalty: det = 1 is enforced by construction,
# so the map is volume-preserving and s -> 0 is not in the hypothesis class.

The permutation between couplings deserves a word, because Glow's contribution was to make it learnable. A fixed permutation — reverse the coordinates, say — works and has determinant ±1, so log|det| is 0 and it is free. Glow replaces it with an invertible 1×1 convolution, which in this setting is just a learned d×d invertible matrix applied between couplings. That is strictly more expressive, and its log-determinant is not zero — it is log|det W|, computed once per layer from an LU factorisation, which is cheap because it does not depend on the batch. So a Glow stack's total log-determinant is the sum of those matrix terms and nothing else, since the additive couplings contribute zero. If you build the stack with fixed permutations instead, the total is exactly zero and the objective genuinely reduces to minimising ‖f−1(u)‖2.

That comment is the detail most write-ups skip, and it is the whole trick. With additive couplings the log-determinant is identically zero, so it disappears from the loss — but it disappears because the architecture has already made collapse impossible, not because it was safe to ignore. If you swap in an affine coupling (vb = ub·exp(g(ua)) + m(ua)), the determinant is no longer 1, the log-det term comes back into the loss, and forgetting it will collapse your flow within a few hundred steps.

Worked example: the affine coupling, and the bug you will write. Same four-dimensional vector as before, u = (1, 2, 3, 4) split into ua = (1,2) and ub = (3,4). Keep m(a1,a2) = (0.5a1 + a2, a1 − 0.5a2) and add a scale network g(a1,a2) = (0.5a1 − 0.2a2, 0.3a1 − 0.1a2).

affine coupling — the determinant is no longer freeg(1, 2) = (0.5*1 - 0.2*2,  0.3*1 - 0.1*2) = (0.1, 0.1)
exp(g)  = (1.105171, 1.105171)
m(1, 2) = (2.5, 0.0)

v_a = (1, 2)
v_b = (3, 4) * (1.105171, 1.105171) + (2.5, 0.0)
    = (3.315513, 4.420684) + (2.5, 0.0) = (5.815513, 4.420684)

jacobian: the b-block diagonal is exp(g), so
  log|det| = g_1 + g_2 = 0.1 + 0.1 = 0.2          # NOT zero any more
  det      = e^0.2 = 1.221403                     # this block EXPANDS volume by 22%

inverse, still one forward pass:
  u_b = (v_b - m(v_a)) / exp(g(v_a))
      = (5.815513 - 2.5, 4.420684 - 0.0) / 1.105171
      = (3.315513, 4.420684) / 1.105171 = (3.000000, 4.000000)   # exact

Now watch the failure. Suppose you copied the additive-coupling training loop, where log|det| was identically zero and could be dropped, and you kept that shortcut. Your loss is then just ‖f−1(u)‖2/2, and the fastest way to reduce it is for g to become large and negative, because the inverse divides by exp(g).

what the missing term costs, over 8 coupling layers at d = 768suppose g drifts to -1 on every coordinate of every layer:

  scale per layer  = e^-1 = 0.367879
  after 8 layers   = e^-8 = 0.000335              # every embedding is now ~1/3000 its size
  so ||z||^2 / 2   shrinks by e^-16 = 1.1e-7      # the logged loss goes to ~0 — it looks GREAT

  the log-det you dropped:
  per layer  = sum over 384 scaled coords of (-1) = -384
  8 layers   = -3072

  so the TRUE objective went DOWN by 3072 nats while your logged loss went down too.
  the flow has learned the identity-with-a-shrink and destroyed all your geometry,
  and the training curve looks perfect the whole way.
This is the single most common way a flow silently fails. The tell is not in the loss curve — it cannot be, since you removed the term that would have complained. The tell is in the output: measure ‖f−1(u)‖ on a validation batch and compare it against √d. Chapter 5's scalar derivation told us the optimum maps everything to radius √d = 27.7 at d = 768. If your Gaussianised embeddings have norm 0.01, the flow collapsed. That one assertion, run once per epoch, catches the bug the loss curve hides.
Training detailWhat it is, and why
What is trainedOnly the flow's parameters. BERT is frozen throughout — the method is a readout fix, not a model fix
What dataUnlabelled sentences. The paper fits on the target task's own sentences (the "target" setting) or on NLI text, and the target setting is the one that matters for deployment
ObjectiveMaximum likelihood of the observed embeddings under the flow-induced density. No pairs, no labels, no similarity annotations
At inferenceEmbed the sentence with BERT, push it through flow.inverse, then take cosines in the Gaussianised space. Both queries and documents, always
Failure mode to watchThe flow is fitted to a sentence distribution. Domain shift silently invalidates it — the same trap as Chapter 7's failure two, with more parameters to hide in

Why insist on invertibility at all?

A fair challenge: if the goal is "make the distribution look Gaussian", why not fit a Gaussian mixture to the embeddings and map each point through its own component, or train any old network with a distribution-matching loss? Why pay for the constraint that the map be a bijection?

Three reasons, in ascending order of importance.

It makes the objective computable. Maximum likelihood needs a density, and change of variables only gives you a density if the map is invertible and differentiable. Drop invertibility and there is no likelihood to maximise; you are back to adversarial or moment-matching objectives, which are far harder to train and offer no guarantee about what happened to individual points.

It guarantees nothing is destroyed. A non-invertible map can improve every distributional statistic you measure by simply collapsing the distinctions you were not measuring. That is the failure mode of "make it look Gaussian" as a loss: mapping everything to fresh Gaussian noise satisfies it perfectly and is useless. Invertibility rules that out by construction, in the same structural way that additive couplings rule out volume collapse. Note the pattern — twice now, the method has been made safe by an architectural constraint rather than a penalty term.

It makes the result auditable. Because f is invertible you can push a Gaussianised vector back and check you recover the original embedding to machine precision. That single round-trip assertion catches essentially every implementation bug in the transform, and no non-invertible method offers an equivalent.

And this is precisely why the mixture idea fails on its own terms. A per-component affine map is not globally invertible — two points from different components can land on the same output — and where the components overlap the map is discontinuous, so two near-identical sentences that fall on opposite sides of a component boundary get sent to distant places. You would have introduced exactly the "holes" the method set out to remove, at every boundary in the mixture.

The results, and the number that stung

Evaluated on the standard semantic textual similarity suite — STS-B and STS 2012 through 2016 — measured by Spearman correlation with human similarity ratings. Using the comparison table later compiled in the SimCSE paper, which puts all these methods on identical footing:

Method (BERT-base)Average STS SpearmanCost
Averaged GloVe embeddings61.32None — a 2014 baseline
BERT, first-last layer averaging56.70None
BERT-flow66.55Train a flow on unlabelled target-domain text
BERT-whitening (Chapter 6)66.28One closed-form linear algebra step
Unsupervised SimCSE (Chapter 7)76.25Retrain the encoder contrastively
Read row two next to row one. Out-of-the-box BERT sentence embeddings score below averaged GloVe vectors on semantic similarity. A 110-million-parameter bidirectional transformer, beaten by a 2014 count-based model with the mean taken over its word vectors. That result is the reason this entire line of research exists, and it is almost entirely a geometry problem rather than a knowledge problem — because the same frozen BERT, run through a learned change of coordinates, jumps ten points and comfortably passes GloVe.

What it costs you

CostDetail
A second modelThe flow is parameters you must train, version, ship, and run at inference on both documents and queries
Corpus specificityThe flow is fitted to a particular sentence distribution. Move to a new domain and the map is wrong — possibly worse than no map
Extra inference latencyEvery coupling layer is a forward pass through m. Small next to BERT itself, but nonzero, and it is on the query path
DebuggabilityWhen retrieval quality drops you now have two learned components to blame instead of one
What to take from this chapter even though the next one supersedes the method. Three ideas outlive BERT-flow's practical relevance. One: reframing a geometry problem as a distribution-matching problem tells you what "fixed" means — you get a target to aim at instead of a symptom to suppress. Two: every method that pulls things together needs an explicit force stopping them collapsing, and it is worth identifying that force by name in any method you meet. Three: when a constraint can be enforced architecturally rather than by a penalty — det = 1 by construction, invertibility by construction — take the architectural version, because a penalty can be dropped by a tired engineer and a construction cannot.

And then the awkward question, which the next chapter answers with a single line of linear algebra: given that the target is a Gaussian, and a Gaussian is fully specified by its mean and covariance, how much of that ten-point gain actually required a nonlinear map?

Why does the flow objective include the log-determinant of the Jacobian, and what would happen without it?

Chapter 6: Whitening — the Closed Form

Chapter 5 ended on an uncomfortable observation. BERT-flow's target is a standard Gaussian. A Gaussian is completely determined by two things: its mean and its covariance. So if the only defects in your embedding distribution are that its mean is wrong and its covariance is wrong, the minimal transform that fixes them is affine — a translation and a matrix multiply. No network, no training, no gradient steps.

Su et al. (2021) tried it. It works about as well as the flow, it takes one call to a linear-algebra routine, and it can reduce your dimension by a factor of three on the way. This chapter derives it completely and then does a 2×2 example in full arithmetic.

Deriving the transform

We want a map from raw embeddings x to new vectors z such that the transformed cloud has zero mean and identity covariance. Write it as an affine map with a matrix W (using the row-vector convention, so vectors are rows and matrices act on the right — this matches the paper and matches numpy):

z = (x − μ) W

The mean is handled: subtracting μ makes the mean of z zero for any W. Now impose the covariance condition. Since z is a linear function of the centred vectors, its covariance is:

cov(z) = E[ zTz ] = WT E[ (x−μ)T(x−μ) ] W = WT Σ W

and we want that to equal the identity:

WT Σ W = I

Now solve for W. Σ is a covariance matrix, so it is symmetric and positive semidefinite, which means the spectral theorem hands us an eigendecomposition with an orthogonal U and a non-negative diagonal Λ:

Σ = U Λ UT,   UTU = I,   Λ = diag(λ1, …, λd)

Guess W = UΛ−1/2, where Λ−1/2 is the diagonal matrix of 1/√λk. Substitute and watch it collapse:

WTΣW = Λ−1/2UT · (UΛUT) · UΛ−1/2 = Λ−1/2 (UTU) Λ (UTU) Λ−1/2 = Λ−1/2 Λ Λ−1/2 = I
That is the entire method. Compute μ and Σ over your corpus, eigendecompose Σ, set W = UΛ−1/2, and store (μ, W). Two matrix multiplies at inference. It is called whitening because the output has a flat covariance spectrum, the way white light has a flat frequency spectrum.

One subtlety worth naming: the solution is not unique. If W works then so does WR for any orthogonal R, because RT(WTΣW)R = RTIR = I. There is a whole rotational family of valid whitening matrices. For our purposes this does not matter at all, because cosine similarity is invariant under a shared rotation — all members of the family give identical similarities. If you ever need a canonical choice, the symmetric one W = Σ−1/2 = UΛ−1/2UT is the one that moves each point the least.

The geometry: three moves

Move 1 — translate
x − μ. Slides the cloud onto the origin. Kills the offset pathology, and with it the whole "everything scores 0.99" symptom.
Move 2 — rotate
Multiply by U. Turns the cloud so its own principal axes line up with the coordinate axes. Changes no distances and no cosines — it is purely a change of viewpoint that makes the next step diagonal.
Move 3 — rescale
Multiply by Λ−1/2. Divides each axis by its own standard deviation. This is the move that changes cosines — the cigar becomes a ball.

Whitening versus All-but-the-Top

Both methods reshape the covariance spectrum. Put them side by side and the relationship is exact:

MethodWhat it does to eigenvalue λkConsequence
All-but-the-Top, D componentsλk → 0 for k ≤ D; unchanged otherwiseA step function. Total deletion of the top directions, no change to the rest. Information in the deleted directions is destroyed
Whiteningλk → 1 for every kA flat reweighting. Every direction survives, each rescaled to matter equally. Invertible in principle
Whitening-kλk → 1 for k ≤ k0; direction dropped otherwiseFlatten the informative directions, discard the noisy tail. In practice usually the best of the three

So ABTT is the crude special case: instead of asking "how much should this direction count?", it answers "zero" or "unchanged." Whitening answers "exactly one, for everything." That sounds strictly better, and Chapter 7 explains the circumstances under which it is not.

Worked example: five sentences, two dimensions, everything by hand

Five sentence embeddings. Two dimensions, so we can draw them and so the eigendecomposition is a quadratic formula rather than a library call.

s1 = (10, 8),  s2 = (8, 7),  s3 = (6, 6),  s4 = (4, 3),  s5 = (2, 1)

The symptom first. Their pairwise cosines, all ten of them:

before whitening — the ten cosines|s1|=12.80625  |s2|=10.63015  |s3|=8.48528  |s4|=5.00000  |s5|=2.23607

s1.s2 = 136 / (12.80625 x 10.63015) = 136 / 136.1345 = 0.999012
s1.s3 = 108 / (12.80625 x  8.48528) = 108 / 108.6647 = 0.993884
s1.s4 =  64 / (12.80625 x  5.00000) =  64 /  64.0312 = 0.999512
s1.s5 =  28 / (12.80625 x  2.23607) =  28 /  28.6357 = 0.977800
s2.s3 =  90 / (10.63015 x  8.48528) =  90 /  90.1998 = 0.997784
s2.s4 =  53 / (10.63015 x  5.00000) =  53 /  53.1507 = 0.997164
s2.s5 =  23 / (10.63015 x  2.23607) =  23 /  23.7697 = 0.967622
s3.s4 =  42 / ( 8.48528 x  5.00000) =  42 /  42.4264 = 0.989949
s3.s5 =  18 / ( 8.48528 x  2.23607) =  18 /  18.9737 = 0.948681
s4.s5 =  11 / ( 5.00000 x  2.23607) =  11 /  11.1803 = 0.983870

sum = 9.855278        A = 0.985528

Average pairwise cosine 0.9855, in a two-dimensional space, from five points chosen without any conspiracy. This is the whole disease reproduced in a toy: the cloud simply does not surround the origin.

Step 1: the mean.

μ = ( (10+8+6+4+2)/5 , (8+7+6+3+1)/5 ) = (30/5, 25/5) = (6, 5)

Centred vectors: (4,3), (2,2), (0,1), (−2,−2), (−4,−4). They sum to (0,0), as they must.

Step 2: the covariance. Three numbers, each a sum of five products divided by five.

the 2x2 covariance, entry by entrySxx = (16 + 4 + 0 + 4 + 16) / 5 = 40 / 5 = 8.0
Syy = ( 9 + 4 + 1 + 4 + 16) / 5 = 34 / 5 = 6.8
Sxy = (12 + 4 + 0 + 4 + 16) / 5 = 36 / 5 = 7.2

Sigma = [ 8.0  7.2 ]
        [ 7.2  6.8 ]

correlation = 7.2 / sqrt(8.0 x 6.8) = 7.2 / 7.37564 = 0.97619    # a very thin cigar

Step 3: eigenvalues. For a 2×2 symmetric matrix the characteristic polynomial is λ2 − (trace)λ + (det) = 0.

trace = 8.0 + 6.8 = 14.8    det = (8.0)(6.8) − (7.2)2 = 54.40 − 51.84 = 2.56
λ = ( 14.8 ± √(14.82 − 4·2.56) ) / 2 = ( 14.8 ± √(219.04 − 10.24) ) / 2 = ( 14.8 ± √208.80 ) / 2
√208.80 = 14.44991  →   λ1 = 14.62496,   λ2 = 0.17504

Check: λ1λ2 = 14.62496 × 0.17504 = 2.5600 = det. Correct.

And look at the ratio: λ12 = 83.55. In two dimensions the cloud is 83 times more spread along one axis than the other. That is the shape pathology, in a number.

Step 4: eigenvectors. For λ1, solve (Σ − λ1I)u = 0 using the first row:

(8.0 − 14.624957) ux + 7.2 uy = 0  →   −6.624957 ux + 7.2 uy = 0  →   uy = (6.624957/7.2) ux = 0.920133 ux

Take u = (1, 0.920133), whose length is √(1 + 0.846645) = √1.846645 = 1.358913. Normalise:

u1 = (0.735882, 0.677109)    u2 = (−0.677109, 0.735882)

u2 is u1 rotated by 90 degrees, which is forced in two dimensions by orthogonality. Check u1: 0.541522 + 0.458477 = 0.999999.

Step 5: assemble W. The scale factors are the reciprocal square roots:

√λ1 = 3.824259 →  1/√λ1 = 0.261489
√λ2 = 0.418382 →  1/√λ2 = 2.390162

W = UΛ−1/2 scales each column of U by the corresponding factor:

building Wcolumn 1 = u1 x 0.261489 = ( 0.735882, 0.677109) x 0.261489 = ( 0.192425, 0.177056)
column 2 = u2 x 2.390162 = (-0.677109, 0.735882) x 2.390162 = (-1.618401, 1.758878)

W = [  0.192425  -1.618401 ]
    [  0.177056   1.758878 ]

Verify WTΣW = I — one entry longhand, because this is the kind of thing that is worth doing once in your life.

checking the top-left entrySigma . col1 = ( 8.0*0.192425 + 7.2*0.177056 ,  7.2*0.192425 + 6.8*0.177056 )
             = ( 1.539398 + 1.274806 , 1.385459 + 1.203983 )
             = ( 2.814204 , 2.589442 )

col1 . that  = 0.192425*2.814204 + 0.177056*2.589442
             = 0.541523 + 0.458477
             = 1.000000          # exactly 1, to six decimals

off-diagonal:
Sigma . col2 = ( 8.0*(-1.618401) + 7.2*1.758878 , 7.2*(-1.618401) + 6.8*1.758878 )
             = ( -12.947210 + 12.663920 , -11.652489 + 11.960369 )
             = ( -0.283290 , 0.307880 )
col1 . that  = 0.192425*(-0.283290) + 0.177056*0.307880 = -0.054512 + 0.054512 = 0.000000

Step 6: transform the five points. Applying z = (x−μ)W is the same as computing each coordinate as a projection divided by a standard deviation: zk = (uk · (x−μ)) / √λk. Longhand for s1:

transforming s1 = (10, 8), centred to (4, 3)u1 . (4,3) =  0.735882*4 + 0.677109*3 =  2.943529 + 2.031328 =  4.974857
u2 . (4,3) = -0.677109*4 + 0.735882*3 = -2.708438 + 2.207647 = -0.500791

z1 = ( 4.974857 / 3.824259 , -0.500791 / 0.418382 ) = ( 1.30087 , -1.19697 )
PointRawCentredWhitened z
s1(10, 8)(4, 3)(1.30087, −1.19697)
s2(8, 7)(2, 2)(0.73896, 0.28095)
s3(6, 6)(0, 1)(0.17706, 1.75888)
s4(4, 3)(−2, −2)(−0.73896, −0.28095)
s5(2, 1)(−4, −4)(−1.47792, −0.56191)

Step 7: verify the output covariance really is the identity. This is the payoff, so compute it rather than trusting the algebra.

covariance of the whitened pointsSxx = (1.30087^2 + 0.73896^2 + 0.17706^2 + 0.73896^2 + 1.47792^2) / 5
    = (1.692262 + 0.546062 + 0.031350 + 0.546062 + 2.184247) / 5
    = 4.999983 / 5 = 0.999997

Syy = (1.19697^2 + 0.28095^2 + 1.75888^2 + 0.28095^2 + 0.56191^2) / 5
    = (1.432737 + 0.078933 + 3.093659 + 0.078933 + 0.315743) / 5
    = 5.000005 / 5 = 1.000001

Sxy = (1.30087*(-1.19697) + 0.73896*0.28095 + 0.17706*1.75888
       + (-0.73896)*(-0.28095) + (-1.47792)*(-0.56191)) / 5
    = (-1.557105 + 0.207612 + 0.311408 + 0.207612 + 0.830449) / 5
    = -0.000024 / 5 = -0.000005

Identity covariance, to five decimal places, done by hand. The cigar is a ball.

Step 8: the new cosines. All ten again, on the whitened points:

PairBeforeAfter whitening
s1, s20.99901+0.44721
s1, s30.99388−0.60000
s1, s40.99951−0.44721
s1, s50.97780−0.44721
s2, s30.99778+0.44721
s2, s40.99716−1.00000
s2, s50.96762−1.00000
s3, s40.98995−0.44721
s3, s50.94868−0.44721
s4, s50.98387+1.00000
Average A0.98553−0.24944

Three of those after-values are exactly ±1, and the reason is worth naming rather than glossing. Look at the centred column: s2 → (2,2), s4 → (−2,−2), s5 → (−4,−4). Those three lie on a single line through the origin — s5 = 2s4 = −2s2. Whitening is a linear map applied after centring, and a linear map sends a line through the origin to a line through the origin, so those three points must still be scalar multiples of each other afterwards, and their cosines must be exactly ±1. The raw column does not show this because those cosines were computed on the uncentred vectors (8,7), (4,3), (2,1), which are not collinear at all. Centring created the exact ±1; whitening merely could not destroy it. And the recurring ±0.44721 is 1/√5 — the sort of clean surd a five-point hand-built example produces. Do not expect either pattern at d = 768.

−0.24944, against a predicted floor of −1/(n−1) = −0.25 for n = 5. Chapter 1's identity, hit for the third time in this lesson, and it is not luck: whitening centres the data, and centring forces exactly that value when the norms are similar. The useful reading is the spread: before, all ten cosines lived in [0.949, 0.9995], a band 0.05 wide. After, they range over [−1.000, +1.000] — the full available dynamic range, restored by two matrix multiplies.
SHOWCASE — the whitening transform, one move at a time

The five worked points (labelled) inside a cloud with the same covariance, with the 1-sigma ellipse and both eigen-axes drawn. Drag stage from 0 to 3 to translate, then rotate, then rescale, and watch the average pairwise cosine collapse from near 1 to the centring floor. The view auto-zooms and the final scale is normalised for display — a uniform rescale changes no cosine, so this is free.

Stage 0.00
Correlation 0.98

Step from 1 to 2 — the rotation — and watch the average cosine readout not move at all. Rotation is an orthogonal transform; it preserves every dot product and every norm, so it cannot change a single cosine. It is there purely so that step 3 can be a diagonal scaling. Skipping it and scaling in the original coordinates would not whiten anything, because the cloud's axes are not the coordinate axes.

Whitening-k: the free lunch

Keep only the first k columns of U. Then W is d×k, the output has k dimensions, and it is still exactly white in those k directions.

Su et al. report that on 768-dimensional BERT embeddings, keeping k = 256 typically matches or beats full whitening — while cutting storage and retrieval cost by three. Getting better results from throwing information away deserves an explanation, and there is a good one.

Whitening multiplies direction k by 1/√λk. The smallest eigenvalues get the largest amplification. And the smallest eigenvalues are exactly the directions where your covariance estimate is least reliable — a direction with tiny true variance is dominated by sampling noise, and whitening then blows that noise up to the same scale as your signal.

Quantify it on the worked example. The amplification factors were 0.261 for the big direction and 2.390 for the small one — a relative reamplification of 2.390/0.261 = 9.14×, which is exactly √(λ12) = √83.55. In a 768-dimensional embedding cloud the condition number routinely runs into the thousands, so the ratio is 30× to 100×. You are handing a megaphone to your noisiest measurement.

The shrinkage fix, worked. If you must whiten in full dimension with a limited sample, regularise the covariance before inverting it: Σ′ = (1−α)Σ + α(trΣ/d)I. On our matrix with α = 0.1: trΣ/d = 14.8/2 = 7.4, so Σ′ = 0.9Σ + 0.74I = [7.94, 6.48; 6.48, 6.86]. Its trace is still 14.8, but its determinant is 54.4684 − 41.9904 = 12.478, giving λ = (14.8 ± √169.128)/2 = 13.9025 and 0.8975. The condition number falls from 83.55 to 15.49 and the reamplification factor from 9.14× to 3.94×, for a 10% nudge that barely moves the dominant direction. This is the single highest-value line of code in any whitening implementation.

The implementation

whitening, fitted and appliedimport numpy as np

def whiten_fit(V, k=None, alpha=0.0):
    """V: (n, d) corpus embeddings.  Returns (mu, W) with W of shape (d, k)."""
    mu  = V.mean(axis=0, keepdims=True)              # (1, d)
    X   = V - mu
    cov = (X.T @ X) / X.shape[0]                     # (d, d)
    if alpha:                                        # shrinkage toward a scaled identity
        cov = (1 - alpha) * cov + alpha * np.trace(cov) / cov.shape[0] * np.eye(cov.shape[0])
    lam, U = np.linalg.eigh(cov)                     # eigh: ascending, orthonormal U
    lam, U = lam[::-1], U[:, ::-1]                  # descending
    lam = np.maximum(lam, 1e-12)                     # guard the numerical zeros
    W = U / np.sqrt(lam)                             # broadcasts over columns == U @ diag(lam**-0.5)
    return mu, (W[:, :k] if k else W)

def whiten_apply(V, mu, W):
    Z = (V - mu) @ W                                 # (n, d) @ (d, k) -> (n, k)
    return Z / np.linalg.norm(Z, axis=1, keepdims=True)

Three implementation notes that matter more than they look.

Use eigh, not eig. A covariance matrix is symmetric; eigh exploits that, returns real eigenvalues in ascending order, and guarantees an orthonormal U. eig returns complex types and a non-orthogonal basis, and the whole derivation above assumed UTU = I.

Clamp the eigenvalues. If n < d, the covariance is rank-deficient by construction — you cannot estimate d(d+1)/2 covariance entries from fewer than d points. Some λk will be zero to machine precision, and 1/√0 is an inf that will silently poison every embedding. Either clamp, or use whitening-k with k < n, or apply shrinkage. Ideally all three.

Fit once, apply everywhere. As with ABTT: μ and W are fitted parameters of your retrieval system. Store them, version them, and apply the identical transform to queries and documents alike. Refitting on new data means re-indexing everything.

Whitening when you have fewer vectors than dimensions

The clamp in the code above hides a structural fact worth stating plainly: if n < d, the covariance is guaranteed to be singular, and no amount of numerical care changes that. The centred matrix X is n×d, so its rank is at most n − 1, and XTX therefore has at least d − n + 1 eigenvalues that are exactly zero in exact arithmetic and around 10−16 in floating point. Whitening asks you to divide by their square roots.

Do the arithmetic on what that means. A zero eigenvalue that lands at 10−16 because of rounding gets amplified by 1/√(10−16) = 108. Your embedding now has a coordinate a hundred million times larger than every real one, and it is pure floating-point noise. Every cosine you compute afterwards is a cosine between two noise vectors.

There is also a genuine efficiency win available in this regime, and it is the same trick that makes PCA on images tractable. Do not build the d×d matrix at all — build the n×n one:

the dual formulation — never form a d x d matrix# X is (n, d) with n << d.  X^T X is (d, d) and mostly zeros in its spectrum.
# X X^T is (n, n) and has the SAME nonzero eigenvalues.

G = X @ X.T                       # (n, n)   — for n = 2000, d = 4096 this is 16x smaller
lam, W = np.linalg.eigh(G)        # n eigenvalues, n eigenvectors
lam, W = lam[::-1], W[:, ::-1]

# recover the right singular vectors (the PCs in d-space) from the left ones
U = X.T @ W / np.sqrt(lam + 1e-12)      # (d, n), orthonormal columns

# the identity that makes this work:
#   X = P S Q^T  ->  X X^T = P S^2 P^T   and   X^T X = Q S^2 Q^T
#   so Q = X^T P / S, and the nonzero spectra are identical.
SituationWhat to doWhy
n > 10dFull whitening is defensible. Still consider shrinkageThe Marchenko–Pastur spread is [(1−√0.1)2, (1+√0.1)2] = [0.47, 1.73], a condition number of 3.7 from noise alone — modest, but not nothing
d < n < 10dWhitening-k with k around d/3, plus shrinkage α ≈ 0.1At n = 2d the noise-only condition number is already 34. You cannot whiten the tail because you have not measured it
n < dUse the dual form, and take k < n − 1 strictlyThe remaining directions do not exist. Anything you compute in them is rounding error
n < 100 — you are "fitting" on a tiny sampleCentre only. Do not whitenThe mean is estimable from a hundred vectors; the covariance is not, in any dimension you care about

The other reason to reduce k: it is free money

The quality argument for whitening-k is the noise argument above. The engineering argument is independent of it and often the one that actually gets the change approved.

what 768 -> 256 actually buys, for 10 million vectorsstorage, float32:
  768 dims : 10e6 * 768 * 4 bytes = 30.7 GB
  256 dims : 10e6 * 256 * 4 bytes = 10.2 GB     -> 20.5 GB saved

brute-force scan of the whole index, per query:
  768 dims : 10e6 * 768  = 7.68e9 multiply-adds
  256 dims : 10e6 * 256  = 2.56e9 multiply-adds  -> 3x faster, exactly

memory bandwidth, which is what actually limits a scan:
  the same 3x, and bandwidth is the binding constraint on almost every
  vector search that is not already using a quantised index.

and the quality change, per Su et al.: zero to slightly positive.
A threefold cost reduction with no quality loss is a rare thing, and it is worth understanding why it is available. It is available because the embedding was never using 768 dimensions. Chapter 1's effective rank told us that directly — a cloud with erank 12 out of 768 is paying for 64 times the geometry it has. Whitening-k is simply charging you for what you use. This is also the conceptual ancestor of Matryoshka representation learning, which trains the model so that its own leading coordinates are a valid short embedding, removing the need for a fitted projection at all.

What the paper reported

Su et al. found that whitening matches BERT-flow's semantic-textual-similarity results almost exactly — 66.28 versus 66.55 average Spearman for BERT-base in the compiled comparison — while requiring no training whatsoever, and that whitening-256 preserved or improved that result with a threefold reduction in storage and retrieval cost.

The scientific content of that result is a negative one, and it is valuable. The nonlinear flow's advantage over an affine map is, on these benchmarks, within noise. Which means the defect in BERT's sentence-embedding distribution really was just a wrong mean and a wrong covariance — the first two moments and nothing more. That is a much stronger and more useful claim than "our method also works", and it is the kind of result you should look for whenever a complicated method beats a baseline: what is the simplest transform that captures the gain?
You whiten 5,000 embeddings of dimension 768 with no shrinkage and no dimensionality reduction, and retrieval quality gets worse than the untransformed baseline. What is the most likely cause?

Chapter 7: When the Fixes Hurt

Four chapters of increasingly elegant machinery, all of it real, all of it published, all of it reproducible. This chapter is the counterweight, because every one of these methods has a regime where applying it makes your system worse, and two of those regimes are common enough that you will hit them.

Then, at the end, the reason none of this is the state of the art any more.

Failure one: the direction you deleted was the answer

The diagnostics from Chapter 1 detect concentration of variance. They cannot detect whether that concentration is an artefact or the signal. Here is a case where every measurement screams "anisotropic" and the correct action is to do nothing.

Take a corpus with two well-separated topics. Model it in three dimensions: cluster A centred at (+2, 0, 0), cluster B at (−2, 0, 0), each with isotropic spread σ = 0.5 in every direction, equal numbers of each.

the covariance of two separated clustersglobal mean = (0, 0, 0)                      # the clusters cancel

Sigma_xx = 2^2 + 0.5^2 = 4.25                 # separation^2 + within-cluster variance
Sigma_yy = Sigma_zz = 0.5^2 = 0.25

eigenvalues: (4.25, 0.25, 0.25)      trace = 4.75
top component explains 4.25 / 4.75 = 89.5% of the variance

p = (0.8947, 0.0526, 0.0526)
H = 0.8947*0.11125 + 2*(0.0526*2.94504) = 0.09954 + 0.30982 = 0.40935
effective rank = e^0.40935 = 1.506   out of 3

Every number says the same thing: 89.5% of the variance in one direction, effective rank 1.5 out of 3. By the criteria of Chapters 1 and 3, this is a textbook narrow cone and you should remove the top principal component.

Do it, and the two clusters land on top of each other. The x coordinate was the topic label. A classifier that was at 100% is now at chance. You have deleted the only thing the embedding knew.

Concentration of variance is not evidence of an artefact. A top principal component holding 89% of the variance can be a frequency artefact, or it can be the single most important semantic axis in your data. The eigenvalue is identical in both cases. The only way to tell them apart is to check what the direction correlates with — project your data onto u1 and see whether the ordering tracks token frequency and document length, or whether it tracks your labels. Ten minutes with a scatter plot beats any amount of spectral reasoning.

This is also the mechanism behind a real finding in the literature. Cai et al. (ICLR 2021) showed that contextual embedding spaces are globally anisotropic but locally isotropic — inside individual clusters, the geometry looks fine. The global cone is substantially a story about a handful of well-separated clusters, not a single uniform drift. Rajaee and Pilehvar followed with cluster-based isotropy enhancement: subtract the cluster means and remove the dominant directions within each cluster, so the between-cluster structure survives.

When the top component is artefact and signal at once

The advice "check what u1 correlates with" implies a clean verdict. Often there is not one, because the same direction can be both — and this case is common enough to deserve its own answer.

The archetype: your top component correlates 0.7 with document length and separates your two document types, because one type happens to be longer. Delete it and you lose the type distinction; keep it and every long document floats to the top of every result list. Neither of the two prescriptions is right.

OptionWhat it doesWhen to choose it
Partial removal. Shrink the component instead of deleting it: v′ = ṽ − β(u1Tṽ)u1 with β between 0 and 1Interpolates continuously between "keep" (β = 0) and ABTT (β = 1). Sweep β on your evaluation like any other hyperparameterAlmost always the right first move when the verdict is ambiguous. Nothing forces the choice to be binary, and the paper's step-function is only a convention
Regress the artefact out. Fit and subtract the part of each embedding that is linearly predictable from length: v′ = v − (α0 + α1·len)Removes the measured confound specifically, rather than the whole direction it happens to live inWhen you can actually measure the nuisance variable. Strictly better than deleting a direction, because it targets the confound and spares whatever else shared that direction
Fix the cause instead. Make your chunks equal lengthRemoves the confound before it is ever embeddedChapter 4 derived why unequal chunk lengths create a dominant direction. If you control the chunker, this is a one-line change that beats every transform below it
Cluster then centre. Per-cluster means, as abovePreserves between-cluster structure while removing the within-cluster offsetWhen the direction separates groups you care about
The general lesson, and it outlives this chapter. "Remove the top principal component" is a blunt instrument that assumes the artefact and the signal are conveniently orthogonal. They rarely are. Whenever you can name the nuisance variable and measure it, regress it out directly — you will remove less signal for the same amount of artefact. Deleting a principal component is what you do when you can see that something is wrong but cannot say what it is.

Failure two: the transform is fitted, and your queries are not from that distribution

μ and W are estimated from a sample. That makes them parameters, and parameters can be wrong for the data they are applied to.

Concretely: you fit whitening on your document corpus — product descriptions, say — and at serve time you apply it to user queries, which are short, keyword-shaped, and grammatically nothing like a product description. Their true mean is somewhere else.

Write the query mean as μq = μc + δ. Subtracting the corpus mean leaves every query carrying the same residual offset δ. If typical centred vectors have norm 1 and ‖δ‖ = 0.3, then by Chapter 1's identity the queries have an induced anisotropy among themselves of:

Aqueries ≈ ‖δ‖2 / E‖q‖2 = 0.09 / (1 + 0.09) = 0.083

That is not catastrophic on its own. What is worse is the asymmetry: the documents are properly centred and the queries are not, so every query gets a systematic bonus toward documents that happen to align with δ. The same handful of documents drifts up every result list, for a reason that has nothing to do with any query. That failure looks like a relevance bug and is a preprocessing bug.

Symptom in productionLikely cause
A small set of documents appears in the top-10 for unrelated queriesQuery/document mean mismatch; the shared query residual aligns with those documents
Quality was fine offline on the eval set, bad in productionThe transform was fitted on the eval distribution, not the live one
Quality degrades slowly over monthsCorpus drift — μ and Σ are stale. Refit and re-index on a schedule
Quality collapsed the day you added a new document sourceThe new source shifted the true corpus mean; the stored μ now belongs to neither population

Failure two and a half: your corpus is a mixture, and the mean belongs to nobody

Failure two assumed two populations — queries and documents — and one fitted transform. The nastier version has two populations inside the corpus you fitted on, in unequal proportions, and it is the common case: 90% product descriptions and 10% support-ticket text, or 95% English and 5% everything else.

The arithmetic is short and the conclusion is unpleasant. Let the two sub-populations be centred at a and b with ‖a − b‖ = 1, in proportions 0.9 and 0.1, with within-population residuals of typical norm 1. The fitted global mean is

μ = 0.9 a + 0.1 b

Subtract it. Population A's vectors now carry a residual offset of a − μ = 0.1(a − b), of norm 0.1 — negligible. Population B's carry b − μ = 0.9(b − a), of norm 0.9 — nearly as large as their own spread. Apply Chapter 1's identity to population B alone:

Awithin B ≈ 0.92 / ( 1 + 0.92 ) = 0.81 / 1.81 = 0.448
Minority shareResidual offset carried by the minorityTheir internal average cosine after "centring"
50%0.500.20
20%0.800.39
10%0.900.45
2%0.980.49
Centring fixed the majority and did almost nothing for the minority. Ninety per cent of your corpus is now well-behaved and the report says the space is isotropic — because the report is dominated by the majority too. The remaining 10% still has an internal average cosine of 0.45, so within that slice everything still looks like everything, and every one of those documents also carries a shared 0.9-norm offset that makes them mutually retrievable for reasons unrelated to content. The symptom in production is oddly specific: retrieval is fine except on one content type, where it returns the same handful of documents forever.

The fix is the one Rajaee and Pilehvar arrived at from a different direction: cluster first, then centre within each cluster. In this framing it is obvious — a single μ can only be correct for a single population, and you had two. Practical recipe: run k-means with a modest k (say 8 to 32) on a sample, store one μk per cluster along with the cluster assignment model, and subtract the mean of whichever cluster a vector lands in. It costs one nearest-centroid lookup per embedding and it is the cheapest fix for the most common failure.

The five-minute check. Before fitting μ, project a sample onto its own top principal component and plot a histogram of the projection. If it is unimodal, one mean is defensible. If it is bimodal or has a long shoulder, you have a mixture — and the top principal component you were about to delete is the domain label, which is Chapter 7's failure one wearing yet another hat. Two plots, one before-and-after, and you have avoided both failures at once.

Failure three: the fix is invisible on your actual task

Isotropy calibration is measured with geometric metrics and justified with geometric arguments, and the connection to downstream quality is weaker than the literature's enthusiasm suggests. Follow-up work has repeatedly found that making a space more isotropic by these measures does not reliably improve fine-tuned task performance, and sometimes degrades it.

There is a coherent reason. If you are going to fine-tune a task head on top of these embeddings, the head can learn any linear reweighting of directions it likes — including the whitening transform itself. Applying it beforehand gives the head nothing it could not have learned, and costs it the ability not to, in the directions where the raw scaling was correct.

The rule that follows. Post-hoc geometry fixes pay off when you are stuck with cosine similarity on frozen embeddings — retrieval, clustering, deduplication, nearest-neighbour lookup. They pay off much less, and can cost you, when a trainable layer sits downstream. Ask "who consumes these vectors?" before you reach for a transform. If the answer is "a dot product", fix the geometry. If the answer is "a trained model", let it learn.
A useful way to hold failure three in your head. Whitening is a linear map. A trained linear head is also a linear map. Composing them gives you a linear map — the same hypothesis class you started with. So pre-whitening cannot expand what the head can express; it can only change the optimisation landscape the head searches. Sometimes that helps, because a better-conditioned input makes gradient descent converge faster. Sometimes it hurts, because you have destroyed the scale information that told the head which directions deserved more weight, and the head has to relearn it from a finite training set. Neither outcome is predictable from geometry alone, which is exactly why the literature reports both.

Failure four: it may not even be about the vocabulary

Chapter 2's mechanism two — rare tokens pushed into a common direction by the softmax — is a satisfying story that predicts frequency-aligned principal components, which are observed. But Godey et al. (2024) found the same cone geometry inside the self-attention blocks of transformers trained without any vocabulary softmax at all, and on non-language data.

If anisotropy shows up in models that do not have the mechanism, the mechanism is not the whole cause. Their conclusion is that anisotropy is closer to an intrinsic property of trained self-attention than a corpus artefact — which, if right, means the honest expectation for post-hoc fixes is "reshapes a symptom" rather than "removes a defect."

The modern answer: build the geometry in during training

Every method in Chapters 3 through 6 is a change of coordinates applied to a frozen encoder. None of them can create a distinction the encoder never made. If two sentences landed at the same point, no invertible map will separate them — that is what invertible means.

Contrastive learning attacks the problem one level up: change what the encoder puts in the space.

SimCSE (Gao, Yao and Chen, 2021) is the canonical instance, and its unsupervised version is almost absurdly simple. Take a sentence. Run it through the encoder twice. Because dropout is on, the two passes produce slightly different vectors — and that is your positive pair. Every other sentence in the batch is a negative. The loss is InfoNCE:

i = − log [ exp( sim(hi, hi+) / τ ) / ∑j=1..N exp( sim(hi, hj+) / τ ) ]

where sim is cosine similarity and τ is a temperature, typically 0.05. The supervised version swaps the dropout trick for natural-language-inference pairs: entailment pairs are positives, contradiction pairs are explicit hard negatives.

Alignment and uniformity — and why the loss contains an anisotropy penalty

Wang and Isola (2020) decomposed what a contrastive objective is actually asking for, into two measurable quantities on the unit hypersphere:

Lalign = E(x,x+) ‖f(x) − f(x+)‖2
Luniform = log Ex,y exp( −2‖f(x) − f(y)‖2 )

Alignment wants positive pairs close. Uniformity wants everything else spread evenly over the sphere. Both lower is better, and they pull in opposite directions — the same two-term tension as the flow's likelihood-versus-log-determinant in Chapter 5.

That uniformity formula looks arbitrary until you evaluate it, at which point it turns into Chapter 1's A with a constant in front. Do the substitution. For unit vectors, ‖f(x) − f(y)‖2 = 2 − 2cos, so:

Luniform = log E[ exp( −2(2 − 2cos) ) ] = −4 + log E[ exp(4 cos) ]

Now evaluate the expectation in the two extreme cases, with actual numbers.

uniformity, evaluatedPERFECTLY UNIFORM on the sphere in d = 768:
  cos is centred at 0 with sd 1/sqrt(768) = 0.0361  (Chapter 1)
  for a roughly normal cos, E[exp(4 cos)] = exp(16 * 0.0361^2 / 2) = exp(0.01042) = 1.01047
  L_uniform = -4 + 0.01042 = -3.990          # essentially the floor, -4

GPT-2's LAST LAYER, where A = 0.99 and the spread is tiny:
  E[exp(4 cos)] = exp(4 * 0.99) = exp(3.96)
  L_uniform = -4 + 3.96 = -0.040            # essentially the ceiling, 0

and in general, whenever the cosine distribution is tight around A:
  L_uniform = -4 (1 - A)
Wang and Isola's uniformity is Chapter 1's anisotropy baseline, rescaled onto [−4, 0]. Not analogous to it — equal to it, up to an affine map, whenever the cosine distribution is concentrated. A = 0 gives −4, the best achievable; A = 1 gives 0, total collapse. That means the famous alignment–uniformity plane, on which every contrastive method gets plotted, has your anisotropy measurement as its horizontal axis under a different name. It also gives you a way to read that plane: a point at uniformity −3.5 corresponds to A = 0.125, and a point at −1.0 corresponds to A = 0.75. Two literatures, two names, one number.

Now the connection that closes the loop on this whole lesson. SimCSE's authors analyse the asymptotic form of the loss's denominator and show it reduces to minimising the sum of all entries of the Gram matrix of the (unit-norm) embeddings:

ij hiThj

Look at that expression next to Chapter 1. It is exactly the double sum we expanded to derive the identity — and we showed it equals n2‖μ̂‖2, which is n(n−1)A + n. Minimising it is minimising the average pairwise cosine.

The contrastive objective contains an explicit anisotropy penalty as a term. Not a side effect, not an empirical observation — the uniformity half of InfoNCE, expanded asymptotically, is the anisotropy baseline from Chapter 1 with a minus sign in front of it. SimCSE's paper takes the argument one step further: since the embeddings are unit-norm, the trace of the Gram matrix is fixed, so pushing down the sum of all its entries pushes down the largest eigenvalue — which flattens the singular spectrum. That is whitening's goal, achieved by gradient descent instead of by matrix decomposition.

And the results are not close:

Method (BERT-base)Average STS SpearmanWhere the geometry comes from
BERT, first-last averaging56.70Whatever pretraining left behind
Averaged GloVe61.32Count statistics, 2014
BERT-whitening66.28Closed-form affine map, no training
BERT-flow66.55Learned invertible map, frozen encoder
Unsupervised SimCSE76.25Contrastive fine-tuning, dropout as augmentation
Supervised SimCSE81.57Contrastive fine-tuning on NLI pairs

Ten points from post-hoc geometry; another ten from training the geometry in. Every strong text embedder shipped since — the E5 family, GTE, BGE, the commercial APIs — is contrastively trained, and none of them ship a whitening step, because they do not need one.

Put the same table on a cost axis, because the ranking changes when you do:

Step upPoints gainedWhat it costs to buildWhat it costs forever
Raw BERT → whitening
56.70 → 66.28
+9.58One eigendecomposition. MinutesA stored (μ, W), applied on both paths, re-fitted on drift, and a full re-index whenever you re-fit
Whitening → flow
66.28 → 66.55
+0.27Training a normalising flowA second model to version, serve, and debug, plus latency on the query path
Flow → unsupervised SimCSE
66.55 → 76.25
+9.70A fine-tuning run on unlabelled sentences. Hours on one GPUNothing. The encoder is just better; there is no artefact to maintain
Unsupervised → supervised SimCSE
76.25 → 81.57
+5.32The same run, on NLI pairsNothing, plus a dependency on labelled pairs existing
The middle row is the one to stare at. A learned invertible neural network buys 0.27 points over two lines of linear algebra, and costs you a second model in production forever. Meanwhile a fine-tuning run buys 9.70 points and costs you nothing ongoing, because the fix is baked into the weights rather than bolted on beside them. That is the strongest practical argument in this lesson, and it is not about geometry at all — it is about where the fix lives. A transform is a permanent liability; a better encoder is an asset.

Where the repulsion comes from, in gradients

Chapter 2 diagnosed the missing ingredient precisely: cross-entropy pushes each non-target output embedding away from the hidden state h, but it never pushes two output embeddings away from each other. There is no term in the loss whose gradient separates two representations. Contrastive learning adds exactly that term, and you can see it in one differentiation.

Write the InfoNCE loss for anchor i with similarities sij = cos(hi, hj+):

i = − sii/τ + log ∑j exp( sij/τ )

Differentiate with respect to any similarity. The log-sum-exp derivative is a softmax, so with pij = softmaxj(sij/τ):

∂ℓi / ∂sij = ( pij − 1[j = i] ) / τ

The same shape as Chapter 2's logit gradient — but the objects are different. Here sij is a similarity between two representations, so a positive gradient on sij means gradient descent actively decreases the similarity between hi and hj. That is real, direct repulsion between the embeddings, which the language-modelling loss never had.

Worked example: where the repulsion actually goes. Take a batch of three with τ = 0.05. Anchor 1's cosines against its own positive and two negatives:

easy negatives — almost no gradientcosines   = (0.90, 0.40, 0.35)
logits    = (18.0,  8.0,  7.0)          # divided by tau = 0.05
shift by 18 -> (0, -10, -11)
exps      = (1.000000, 0.0000454, 0.0000167)
sum       = 1.0000621
p         = (0.999938, 0.0000454, 0.0000167)

gradient pushing away from negative 2 = 0.0000454
gradient pushing away from negative 3 = 0.0000167
one hard negative — the gradient relocates entirelycosines   = (0.90, 0.85, 0.35)
logits    = (18.0, 17.0,  7.0)
shift by 18 -> (0, -1, -11)
exps      = (1.000000, 0.367879, 0.0000167)
sum       = 1.367896
p         = (0.731050, 0.268938, 0.0000122)

gradient pushing away from the hard negative = 0.268938

ratio to the easy case: 0.268938 / 0.0000454 = 5,920x
Nearly six thousand times more gradient goes to the one negative that was already nearly indistinguishable. That is what "uniformity" means operationally — not a gentle pressure spread over the sphere, but an almost-exclusive focus on the pairs the model currently cannot tell apart. It is also why batch size matters so much in contrastive training: the negatives come from the batch, so a larger batch is a larger chance that some genuinely hard negative is present to receive that gradient.

Temperature is the knob that sets how hard "hard" is

That 5,920× ratio was computed at τ = 0.05, and it is worth seeing how violently it depends on that number, because τ is the hyperparameter people copy without understanding and then wonder why their contrastive run either collapses or does nothing.

Recompute both scenarios — the easy batch (0.90, 0.40, 0.35) and the hard batch (0.90, 0.85, 0.35) — across a range of temperatures. The quantity that matters is pij, which is the gradient on that negative:

τGradient on the hard negativeGradient on the easy negativeRatioReading
0.500.40440.21631.9×Nearly uniform pressure. The loss barely distinguishes hard from easy, so training is slow and the geometry stays blurry
0.200.42260.07165.9×Mild selectivity
0.100.37660.0066756×Clearly hard-negative driven
0.050.26890.00004545,920×SimCSE's setting. Almost all gradient goes to the confusable pairs
0.020.07591.4×10−115×109Only near-ties get any signal at all
0.010.006692×10−223×1019Effectively no gradient anywhere. The positive already has p = 0.993, so the loss is satisfied and learning stops

Read the second column down its length and notice it is not monotone. It rises to a peak around τ = 0.2 and then falls. That is the whole story of temperature in one column, and it has a clean explanation: as τ shrinks, the softmax concentrates, which first sharpens the focus onto the hard negative (good) and then concentrates so hard onto the positive that even the hard negative stops receiving anything (bad). The positive's own probability in the last row is 0.993 — the loss is essentially zero and there is nothing left to learn from.

Temperature is not a scaling constant, it is a hardness threshold. The useful reading is that τ sets how big a cosine gap counts as "already solved". A negative contributes meaningfully when its cosine is within roughly τ of the positive's — at τ = 0.05, that means within 0.05, and everything further away is invisible to the gradient. So choose τ by asking what similarity margin you actually care about resolving in your application, not by copying a number. And note the loop this closes: the same τ that decides which pairs get gradient also decides how strongly the uniformity term pushes on the anisotropy baseline, because the uniformity term is the sum over those same pij.

The evaluation that is allowed to decide

Every section of this chapter ends in "measure it on your own data", so it is worth being concrete about what that measurement has to look like. Geometry transforms produce small effects on the metrics that matter, and small effects are exactly where sloppy evaluation goes wrong.

Pair the comparison
Run the same query set through both systems and compare per-query, not in aggregate. Retrieval metrics have enormous query-to-query variance — recall@10 is 0 or 1 for most single queries — and pairing removes all of it. An unpaired comparison of two systems on different query samples can hide a real 3-point difference entirely.
Count how many queries you need
For a paired difference with standard deviation s per query, detecting an effect of size δ at the usual thresholds needs about n ≈ 8(s/δ)2 queries. With recall@10 per query having s ≈ 0.45 and a hoped-for gain of δ = 0.02, that is 8×(22.5)2 = 4,050 queries. If you have 200, you cannot resolve a two-point gain and should not pretend otherwise.
Report the interval, not the point
A paired bootstrap over queries takes four lines and gives you an interval. "Recall@10 went from 0.612 to 0.634, 95% interval on the difference [−0.004, +0.048]" is an honest report of a result that has not been established. "Recall improved by 2.2 points" is not.
Stratify by the thing you changed
Break the result down by document length, source, and language. A geometry transform that helps overall while destroying one content type is a common outcome — see the mixture failure above — and the aggregate number will not show it.
The asymmetry that should govern the decision. A geometry transform has a permanent cost — a fitted artefact to store, version, apply on both paths, and re-fit as the corpus drifts — against a one-off, uncertain quality gain. That asymmetry means the burden of proof sits on the transform. If your evaluation cannot distinguish it from no-op, the correct action is no-op, and you have still learned something worth knowing: your embeddings' geometry was not the bottleneck.

Where the positive pairs come from when you have no labels

The obvious objection to "just train contrastively" is that you do not have labelled similar pairs. Usually you do not need them.

Source of positivesHow it worksWhen to use it
Dropout (unsupervised SimCSE)Encode the same sentence twice with dropout active. The two vectors differ slightly; treat them as a positive pairAlways available. Requires literally no data beyond raw sentences, and scores 76.25
Adjacent spansTwo consecutive paragraphs from the same document are a positive pairAny document corpus. Strong for retrieval over long-form text
Title and bodyA document's title and a passage from its bodyWeb pages, product catalogues, support articles — you already have both fields
Query logsA query and the document that got the clickAny system already in production. The highest-value source you own
Round-trip translationTranslate out and back; the two versions are a paraphrase pairWhen you need paraphrase invariance specifically
NLI entailment (supervised SimCSE)Entailment pairs as positives, contradiction pairs as explicit hard negativesPublic data exists. The contradiction hard negatives are worth several points on their own — see the gradient calculation above for why

Note the last row against the gradient arithmetic. Supervised SimCSE beats the unsupervised version by roughly five points, and the mechanism is visible in the numbers above: a contradiction pair is a sentence that is lexically close and semantically opposite, which is precisely a hard negative, which is precisely where the gradient concentrates.

The same arithmetic explains why batch size is not a mere throughput parameter here. In-batch negatives means a batch of N gives each anchor N − 1 negatives, and the temperature table showed that only the negatives within about τ of the positive contribute anything. So the useful quantity is not N, it is the expected number of hard negatives in the batch — N times the probability that a random other sentence is confusably similar. If that probability is 1 in 500, a batch of 64 contains a hard negative about 12% of the time and 88% of your steps are learning almost nothing about uniformity. A batch of 512 contains one about 64% of the time. That is the entire reason contrastive training papers report batch sizes in the thousands and reach for gradient caching and cross-device negatives to get there, and it is also why a single well-chosen hard negative per example — the NLI contradiction — is worth more than a tenfold batch increase.

Failure six: correcting something that was already corrected

The last failure is the one that arrives after this lesson has been read by more than one person on the team, and it is entirely self-inflicted.

Modern embedding APIs and open-weights embedding models are contrastively trained. Their uniformity term, as the previous section showed, already minimises the anisotropy baseline directly. If you then apply centring and whitening on top, you are correcting a defect that is not there, and you pay the full price of the transform for it: a fitted artefact, a re-index requirement, and the noise amplification of Chapter 6.

The arithmetic of the harm is straightforward. Chapter 6 showed that whitening amplifies direction k by 1/√λk. On a well-trained contrastive embedder the spectrum is already fairly flat by design, so λk is close to uniform for the leading directions and small only in the tail — which is exactly the tail that is estimation noise. You have removed no real anisotropy and amplified the noisiest directions in the space.

Where your vectors came fromExpected A before any transformIs a post-hoc fix indicated?
Last layer of a decoder-only LM, mean-pooled0.9 and upYes. This is the most anisotropic case there is
BERT, mean-pooled, no fine-tuningRoughly 0.5 to 0.8Yes — this is the setting all four papers studied
A contrastively trained sentence embedderOften below 0.3Probably not. Measure A first; if it is already low, there is nothing to fix
A commercial embedding APIUsually low, but you cannot know without measuringMeasure. Ten lines, five minutes, and it settles the question
The measurement that should precede every decision in this lesson. Compute A on a sample of your live vectors. If it is 0.05, stop reading and go and work on something else — your geometry is fine and any transform you apply is pure downside. If it is 0.9, you have found real money. The whole apparatus of Chapters 3 to 6 is conditional on a number that takes one line to compute, and the most common mistake in this area is applying the cure without ever confirming the disease.

So when do the post-hoc fixes still earn their keep?

Often, actually. "Contrastive training won" is a statement about what to do if you can train. Most people, most of the time, cannot.

Your situationWhat to doExpected gain
You can fine-tune the encoder and have (or can mine) positive pairsContrastive fine-tuning. Do not bother with post-hoc geometry afterwards — the objective already handled itLarge. This is the real fix
You are using a frozen open-weights model for retrievalMean-centre on an in-domain sample. Then try whitening-k and measure on your own evaluation setModerate; centring alone is usually free money
You are using a commercial embedding API and cannot see insideSame — you can still estimate μ and Σ from the vectors you get back. Fit on a sample that matches your live traffic, not on a public corpusModerate
You are pooling raw hidden states out of a decoder-only LMCentring is close to mandatory. Also stop taking the last layer — Chapter 4Large, because this is the most anisotropic case there is
Your embeddings feed a trained downstream headNothing. Let the head learn its own reweightingZero to negative
Your top principal component correlates with your labelsNothing. Check this before anything elseStrongly negative if you proceed
The one rule that survives all of this. Never ship a geometry fix on the evidence of a geometry metric. "Average cosine dropped from 0.97 to 0.02" is not a result — it is a confirmation that your matrix multiply ran. The result is what happened to recall@10, or clustering purity, or the deduplication false-merge rate, on your data. Chapters 3, 5 and 6 give you transforms; only your evaluation set can tell you whether to keep one.
Your document embeddings have 89% of their variance in the top principal component and an effective rank of 1.5 out of 768. Which action should come first?

Chapter 8: Anisotropy vs Superposition

Two communities have spent the last several years staring at the same vectors and describing what they see in incompatible language.

The embedding-geometry literature — every paper in this lesson — says: the representations occupy a narrow cone, the space is badly used, and the fix is to reshape the distribution.

The interpretability literature says: the model packs far more features into the space than it has dimensions, giving each a nearly orthogonal direction, and the fix is to recover the dictionary.

Those sound like they cannot both be right. A space full of nearly orthogonal directions is the definition of well-used; a narrow cone is the definition of badly used. This chapter resolves that, and the resolution turns out to be practically useful, not merely tidy.

Superposition, from zero

Start with the problem it solves. Suppose a layer has d = 768 dimensions, and the world it is modelling has far more than 768 things worth tracking — is this text code, is it French, is the subject plural, is the tone sarcastic, is there a date nearby, is the current token inside a quotation. Tens of thousands of such features, plausibly.

If each feature needed its own orthogonal direction, the layer could hold 768 of them and no more. But features are sparse: at any moment almost all of them are off. Sarcasm and Fortran syntax and Japanese honorifics are not simultaneously active in the same token position.

Superposition is the strategy that exploits this: assign each feature a direction, allow the directions to be merely nearly orthogonal, and accept the resulting interference — because with sparse activation, two interfering features are rarely on at once, so the interference rarely costs anything. Elhage et al.'s Toy Models of Superposition demonstrated this happening in networks small enough to analyse completely, and showed it appears exactly when features are sparse and there are more of them than dimensions.

How much capacity does near-orthogonality buy?

This is quantifiable, and the numbers make the trade-off concrete. For two independent uniformly random unit vectors in Rd, Chapter 1 gave us the standard deviation of their cosine as 1/√d, and the distribution concentrates sharply:

P( |cos(u, v)| > ε ) ≤ 2 exp( − dε2 / 2 )

So if you scatter N random directions, the expected number of pairs violating an interference budget ε is roughly N2exp(−dε2/2). Setting that to order 1 and solving gives the capacity:

N ≈ exp( dε2 / 4 )
Interference tolerated (ε)Directions that fit in d = 768
0.00 — exact orthogonality768
0.10about 7
0.20about 2,200
0.30about 3 × 107
0.40about 2 × 1013

That bound is the Johnson–Lindenstrauss lemma seen from the other side. JL is usually quoted as "you can embed N points into O(log N / ε2) dimensions with distortion ε" — invert it and you get "d dimensions hold exp(dε2) points at distortion ε", which is the same exponential. The interpretability framing and the dimensionality-reduction framing are the same theorem, and the only difference is which variable you solve for.

Two things to read off. First, the growth is savage — going from tolerating 0.2 interference to 0.4 buys ten orders of magnitude. Superposition is not a marginal trick; it is the difference between hundreds and trillions. Second, the top row is a warning: demanding exact orthogonality is worse than demanding almost-orthogonality with a tiny budget, because the exact constraint caps you at d while the loose one is exponential. The interior of that table is where trained networks live.

Worked example: three features in two dimensions

Capacity tables are abstract. The smallest case where superposition actually happens is three features in a two-dimensional space, and every number in it fits on one line, so let us do it completely.

The model needs three directions in R2. The best it can do — the arrangement that maximises the smallest angle — is to space them 120 degrees apart:

uA = (1, 0),   uB = (−0.5, 0.866025),   uC = (−0.5, −0.866025)

Every pair has cosine −0.5. Check one: uA·uB = (1)(−0.5) + (0)(0.866025) = −0.5. So the interference budget is a whopping ε = 0.5 — ten times what you would tolerate in a capacity calculation. This should be a disaster. It is not, and the reason is the single most elegant idea in the toy-models paper.

The model reads feature B out with ReLU(uB · x). Run three cases.

case 1 — only A fires, with magnitude 1x = 1 * u_A = (1, 0)

readouts before ReLU:  u_A.x =  1.0
                       u_B.x = (-0.5)(1) + (0.866025)(0)  = -0.5
                       u_C.x = (-0.5)(1) + (-0.866025)(0) = -0.5

after ReLU:            (1.0, 0.0, 0.0)      # EXACT. Interference of -0.5 was clipped to 0.

That is the trick. The interference is negative, and a ReLU discards negative values for free. A budget of ε = 0.5 costs nothing at all when only one feature is on, because the false readings land below zero and never survive the nonlinearity. This is why a network will happily choose a highly non-orthogonal arrangement: with sparse, non-negative activations, negative interference is not interference.

case 2 — A and B both fire, each with magnitude 1x = u_A + u_B = (1 - 0.5, 0 + 0.866025) = (0.5, 0.866025)

readouts:  u_A.x = (1)(0.5) + (0)(0.866025)             =  0.500
           u_B.x = (-0.5)(0.5) + (0.866025)(0.866025)   = -0.25 + 0.75 =  0.500
           u_C.x = (-0.5)(0.5) + (-0.866025)(0.866025)  = -0.25 - 0.75 = -1.000

after ReLU: (0.5, 0.5, 0.0)     # both features DETECTED — but at HALF magnitude

Two active features and the readout is still qualitatively right: A and B are on, C is off, and C's false reading has gone more negative, not less. What breaks is the magnitude — each feature reads 0.5 instead of 1.0, because each has absorbed −0.5 of interference from the other. A downstream consumer that only asks "is this feature present?" is fine. One that uses the magnitude is now wrong by a factor of two.

case 3 — all three fire, each with magnitude 1x = u_A + u_B + u_C = (1 - 0.5 - 0.5, 0 + 0.866025 - 0.866025) = (0, 0)

readouts:  (0, 0, 0)            # TOTAL information loss. Three features on reads
                                # identically to zero features on.
Three cases, three verdicts, one arrangement. One feature active: perfect. Two active: present/absent correct, magnitudes halved. Three active: catastrophic — the state is indistinguishable from nothing happening at all. Nothing about the geometry changed between the cases; only the sparsity did. That is the phase transition Elhage et al. mapped by sweeping sparsity, reproduced here in nine lines of arithmetic, and it is why the honest statement of superposition is not "networks pack features tightly" but "networks pack features tightly and are betting that almost none of them fire together."
The first reconciliation. Superposition is a statement about the dictionary — the set of directions the model uses to write features. Anisotropy is a statement about the activations — the cloud of vectors the model actually produces. Those are different objects and they can have different geometries. The dictionary can be beautifully spread over the sphere while the activations sit in a cone, because the activations are a sparse, non-negative, biased combination of dictionary directions, not samples from the dictionary.

The second reconciliation: non-negativity alone produces a cone

This one is a derivation, and it is the most underappreciated fact in the area.

Suppose a representation's coordinates are all non-negative — because they came through a ReLU, or because they are the codes of a sparse autoencoder, which are non-negative by construction. Then any two such vectors have a non-negative dot product, so their cosine is at least 0. The cone is forced by the sign constraint before any learning happens.

How large is the effect? Take two vectors with independent, identically distributed non-negative coordinates, with mean m and second moment s = E[x2]. The expected dot product factorises because the coordinates are independent:

E[ x · y ] = ∑k E[xk] E[yk] = d m2

and the squared norm concentrates around its mean:

‖x‖2 = ∑k xk2 ≈ d s

so, for large d, the cosine between two completely unrelated such vectors is:

cos ≈ d m2 / (d s) = m2 / s

Now plug in the two cases you actually meet.

Post-ReLU Gaussian pre-activations. If the pre-activation is standard normal, then x = max(0, g). Its mean is the half-normal mean, m = 1/√(2π), and its second moment is E[max(0,g)2] = ½E[g2] = ½, since exactly half the mass is kept. Therefore:

cos ≈ ( 1/(2π) ) / ( 1/2 ) = 1/π = 0.31831

Exponentially distributed activations (a reasonable model for sparse positive codes). For Exp(1), m = 1 and s = 2, so cos ≈ 1/2.

A layer of pure, independent, meaningless post-ReLU noise has an average pairwise cosine of 1/π = 0.318. No training, no drift, no frequency effect, no gauge freedom — just the activation function. If you measure an anisotropy baseline of 0.35 on a post-ReLU representation, you have measured almost nothing about the model. The baseline for "an anisotropy measurement means something" is not zero; it is whatever the sign structure of your activation function forces.

This is a genuinely load-bearing correction. Much of the anisotropy literature works on residual-stream or attention outputs, which are sign-symmetric and where zero is the right null. But the moment you measure the geometry of MLP hidden states, sparse-autoencoder codes, or anything after a ReLU or GELU, you must compare against the sign-constrained baseline instead.

Sparsity rescues the sign constraint: the q/2 floor

The 1/π result assumed dense post-ReLU activations — every coordinate is a fresh half-normal, roughly half of them positive. Sparse codes are a different regime, and running the same formula on them gives a result that is genuinely reassuring.

Model a sparse non-negative code: each coordinate is zero with probability 1 − q, and when it fires it is drawn from Exp(1). Compute the two moments the formula needs.

m = E[x] = q · 1 = q     s = E[x2] = q · E[Exp(1)2] = q · 2 = 2q

The second moment of an exponential with rate 1 is 2, because Var = 1 and mean = 1, so E[x2] = 1 + 1 = 2. Substituting into cos ≈ m2/s:

cos ≈ q2 / (2q) = q/2
Fraction of coordinates active, qPredicted floor q/2Measured at d = 4096, 2,000 samples
0.50 — dense0.2500.2501
0.200.1000.1003
0.050.0250.0252
0.02 — a typical SAE0.0100.0102
0.010.0050.0052
The sign constraint costs you almost nothing once the code is sparse. At the 2% activation rate typical of a trained sparse autoencoder, the forced floor is 0.010 — against 0.318 for a dense post-ReLU layer, a thirty-fold reduction. Sparsity and non-negativity are usually described as two separate design choices; this says they interact, and that sparsity is what makes non-negativity affordable. It also gives you the right null for interpreting a measured code anisotropy: if your SAE fires 2% of latents and you measure an average pairwise cosine of 0.15, that is 15× the structural floor and it is real structure, not a sign artefact.

The amplification law: a nearly-isotropic dictionary still makes a cone

Now the quantitative version of the reconciliation, which is the most useful thing in this chapter. We claimed the dictionary and the activations can have different geometries. They can — but they are not independent, and the relationship between them has a factor in it that nobody expects.

An activation is a sparse non-negative combination of dictionary directions, x = ∑i aiui, with the same coefficient statistics as above: mean m, second moment s, each coordinate independent. Take two such activations and compute the expected dot product, using independence of the coefficients:

E[ x · y ] = ∑ij E[ai]E[bj] (ui·uj) = m2 ‖∑i ui2 = m2 N2 ‖ū‖2

where ū is the mean of the N dictionary directions. Chapter 1's identity says ‖ū‖2 is the dictionary's own anisotropy, Adict. The squared norm of a single activation is dominated by the diagonal terms, N·s. Assemble the cosine, write K = qN for the expected number of features active at once, and after cancelling:

Aact ≈ k / (1 + k) ,    k = K · Adict / 2

Read the factor K. The dictionary's anisotropy is multiplied by the number of simultaneously active features before it reaches the activations. Sum many vectors that each lean slightly the same way and the lean adds coherently — the exact same coherent-versus-incoherent argument as Chapter 2's residual stream, now applied across features instead of across layers.

Put numbers on it. Simulate N = 4,096 dictionary directions in d = 512, activation rate q = 0.02 (so K = 82 features active per vector), and tilt the dictionary by adding a small common direction:

Dictionary's own Adictk = K·Adict/2Predicted AactSimulated AactAmplification
0.0002 — isotropic (the 1/N floor)0.0100.0100.009
0.0381.550.6080.62216×
0.2008.170.8910.9124.6×
0.50020.50.9530.9762.0×
A dictionary with an average pairwise cosine of 0.038 — which any reasonable person would call isotropic — produces activations with an average pairwise cosine of 0.62. That is the reconciliation, made quantitative. Both communities are looking at the same model and both are reporting correctly: the dictionary really is well spread, and the activations really do live in a cone. The bridge between the two readings is a factor of K/2, and K — the number of features on at once — is in the dozens for a real layer. Anisotropy in the activations is not evidence that the dictionary is degenerate. It is evidence that the dictionary has a slight tilt and that many features fire together.

Three consequences follow, and they are all actionable.

ConsequenceWhat to do about it
You cannot infer dictionary geometry from activation geometry. A cone in the activations is consistent with an almost perfectly spread dictionaryIf you care about the dictionary, measure the dictionary — learn it and compute A on the decoder rows, not on the layer's outputs
Removing the top principal component of the activations removes ū, the dictionary's mean direction — not any single featureSafe for retrieval; misleading for interpretability, because the deleted direction is a blend of every feature the model has, weighted by how often each fires
A layer where many features fire at once will look far more anisotropic than a sparse one, at identical dictionary qualityCompare anisotropy across layers only after accounting for their activation density. An MLP mid-layer and a residual stream are not on the same scale

What a cone does to a sparse autoencoder, in practice

The theory above has a very concrete operational consequence for anyone who trains one of these.

If the activation cloud has a large mean offset μ, the autoencoder must reconstruct that offset for every single input. Without a decoder bias, the cheapest way to do that is for a handful of latents to fire on everything and encode the mean between them. Those latents are then uninterpretable by construction — they are not features, they are the offset — and the capacity they consume is gone. Worse, the sparsity penalty is fighting them, so the loss lands in an unhappy compromise where the mean is only partly reconstructed and the reconstruction error is large everywhere.

That is why the standard form subtracts bdec on the way in and adds it back on the way out, and why initialising bdec to the empirical mean of the activations is standard practice rather than a refinement. It removes the offset from the problem before the dictionary is asked to represent anything.

Estimate what it saves. If ‖μ‖ is comparable to a typical activation norm — which Chapter 9's diagnostic says is exactly the regime — then roughly half of the squared norm the autoencoder is asked to reconstruct is the offset. With a dictionary of N latents at activation rate q, reconstructing a constant direction with sparse codes needs at least one always-on latent, so you have paid one latent plus a permanently violated sparsity constraint, forever, for a vector you could have stored in d floats.

What interference costs, in numbers

The capacity table above only counted directions. To understand when superposition is actually a good deal, you have to price the interference, and that is a short calculation.

Suppose feature B has direction uB, and the model reads B out by taking a dot product with uB. Now feature A fires with magnitude 1 while B is off. The readout for B picks up:

uB · ( 1 · uA ) = cos(uA, uB) = ε

A false reading of ε on a feature that is not there. With one interferer and a detection threshold of, say, 0.5, an ε of 0.3 is harmless.

But k features are typically active at once, and their interference contributions have random signs, so they add as a random walk rather than linearly:

interference ≈ √k · ε
Features simultaneously active (k)Interference at ε = 0.3Past a 0.5 threshold?
10.30No — safe
20.42No
40.60Yes — false positives begin
90.90Yes, badly
251.50The readout is noise
Superposition is a bet on sparsity, and the exchange rate is explicit. Turning the relation around, the interference budget you can afford is ε < θ/√k, and the capacity that buys you is exp(dε2/4) = exp(dθ2/(4k)). Capacity falls exponentially in the number of features active at once. If your data has many features but only a handful fire per token, superposition is enormously profitable; if half of them are on at any moment, it is worthless and the model will use an orthogonal basis for the few features it can afford. That is the phase transition Elhage et al. observed by sweeping sparsity in their toy models.

Why the two pictures use different tools

The interference calculation also explains why interpretability work reaches for sparse autoencoders rather than PCA, which is worth spelling out because both produce "directions" and they are not the same kind of object.

PCASparse autoencoder
How many directionsAt most d — an orthogonal basis cannot be larger than the spaceOvercomplete: typically 8× to 64× d
OrthogonalityEnforced. Every component is perpendicular to every otherNot enforced. Directions are merely nearly orthogonal — which is the whole point
What it optimisesVariance captured per directionReconstruction under a sparsity penalty on the codes
CodesDense, signed projectionsSparse, non-negative activations
What it returns for three always-on featuresOne component: their mixture, because that is where the variance isThree separate dictionary entries, because sparsity prefers separate ones

That last row is the crux. If a model represents three co-occurring features, PCA is structurally obliged to return a mixture — nothing in its objective rewards separating them, and orthogonality actively forbids returning three directions that are not perpendicular. Anisotropy analysis is built on PCA, so its "top component" is a variance-weighted blend of whatever the model does most. Reading that blend as "a thing the model represents" is a category error, and it is a common one.

What a cone does to cosine-based feature analysis

One last practical consequence, because interpretability work uses cosine similarity constantly — between activations and feature directions, between features across layers, between a probe direction and a candidate concept — and every one of those numbers sits on the same inflated baseline.

Take the standard move of asking "which dictionary feature is this activation most aligned with?" by ranking cos(x, ui). If the activations sit in a cone around a direction c, then every activation has a large component along c, and the feature whose direction happens to be closest to c wins every single query, regardless of content. You will find one feature that appears to fire on everything and conclude it is a general-purpose feature. It is the mean.

The correction is the same one Chapter 4 demanded for contextual embeddings, applied to a different object. Report cos(x − μ, ui) rather than cos(x, ui), or equivalently report the excess over the baseline. And check the ranking's stability: if removing the mean reshuffles your top-5 features, the original ranking was measuring the offset. This is not a hypothetical — it is the same failure as the "one feature fires on everything" observation, and it is why the decoder bias exists.

Where the two lenses disagree: the top principal component

Now the practical fork, and it is the same fork as Chapter 7's failure one, stated in the other vocabulary.

Anisotropy lensSuperposition lens
Object of studyThe distribution of activation vectors — its mean and covarianceThe dictionary of feature directions the model writes into the space
What a dominant direction isAn artefact. Frequency, drift, a gauge the loss never fixedPossibly an always-on feature — "this is English", "this is a document start", a learned bias — carrying real information
PrescriptionRemove or rescale itIdentify it, name it, and keep it
What "good geometry" meansΣ ≈ I: variance spread evenly over directionsFeature directions spread as uniformly as sparsity allows; activations sparse and interpretable
Preferred toolCentring, PCA removal, whiteningSparse autoencoders, dictionary learning

Both are legitimate, and which one is right for you is decided by what you plan to do next. If the vectors are going into a cosine similarity, the anisotropy lens is the operative one — an always-on feature is noise for retrieval no matter how meaningful it is, because it is present in every query and every document equally. If the vectors are going into an analysis of what the model computed, the superposition lens is operative, and deleting the top component means deleting evidence.

Where they agree: subtract the mean

Here is the pleasing convergence. A sparse autoencoder, in its standard form, does not encode its input directly. It first subtracts a learned decoder bias:

z = ReLU( Wenc(x − bdec) + benc ),   x̂ = Wdecz + bdec

That subtraction of bdec is there because the activation cloud has a large mean offset and no useful feature should have to spend capacity re-encoding it. In practice bdec is often initialised to the empirical mean of the activations — which is, exactly and literally, step 1 of All-but-the-Top.

Two literatures, two theories, one line of code. Mu and Viswanath subtract the mean because it is a rank-one spike in the second-moment matrix that inflates every cosine. Sparse-autoencoder practitioners subtract the mean because it is a constant offset that would otherwise consume dictionary capacity. Neither cites the other. Both do x - x.mean(0), and both are right for their own reasons. When two independent lines of reasoning converge on the same operation, that operation is usually safe.
The one-sentence resolution, if you remember nothing else from this chapter. Superposition describes how the model writes; anisotropy describes what the writing piles up into. A well-spread set of pens can still produce a page that is mostly one colour, if you use some of them far more than others and lay them all over each other. Neither community is wrong, and the exchange rate between their measurements is the factor K/2 derived above.

One thing not to do

Do not whiten before running a sparse autoencoder, or before any dictionary-learning analysis.

Whitening sets every direction's variance to 1. That is exactly the information a dictionary-learning method needs: a feature that fires strongly and often occupies a high-variance direction, and that magnitude is signal about the feature's importance. Whitening erases the distinction between a direction the model uses constantly and one it barely touches, and it does so by construction, since flattening the spectrum is its definition.

The same warning applies in reverse to a subtler case. If you whiten and then interpret the resulting principal axes as "the model's features", you are interpreting an artefact of the transform — after whitening, the covariance is the identity, every orthogonal basis is equally principal, and the axes your library happens to return are arbitrary. There is nothing to interpret.

The formal statement is the rotational ambiguity from Chapter 6, read as a warning instead of a convenience. Whitening's solution is unique only up to W → WR for orthogonal R, and after whitening every direction has variance exactly 1, so no direction is distinguished by the data any more. Rerun the eigendecomposition on a machine with a different BLAS and you will get a different basis, equally valid, with different "features" in it. If your interpretability result changes when the library version changes, that is the tell.

A shared measurement protocol

The two lenses do not have to talk past each other. The amplification law says exactly which quantities each community should report so the other can use them, and it is a short list.

Report thisHow to compute itWhy the other side needs it
Aact — the activation anisotropyChapter 1's identity on the layer's outputsThe standard number. Necessary but, on its own, uninterpretable
K — the mean number of active features per vectorThe L0 of the sparse code, or the fraction of post-ReLU coordinates above thresholdWithout it you cannot convert Aact into a statement about the dictionary, and every cross-layer comparison is confounded
Adict — the dictionary anisotropyChapter 1's identity on the decoder rows of a trained sparse autoencoderThis is the quantity that says whether the model's feature directions are well spread. It is what the interpretability claim is actually about
The sign-constrained nullm2/s for your activation distribution: 1/π for dense post-ReLU, q/2 for a sparse codeOtherwise every non-negative representation looks anisotropic and no comparison across activation functions means anything
both lenses, one functiondef geometry_report(acts, sae=None):
    """acts: (n, d) layer activations.  sae: an optional trained autoencoder."""
    def aniso(M):
        U = M / np.linalg.norm(M, axis=1, keepdims=True)
        n = M.shape[0]; mu = U.mean(0)
        return float((n * (mu @ mu) - 1) / (n - 1))

    rep = {"A_act": aniso(acts)}

    # the correct null depends on the SIGN structure, not on the model
    if (acts < 0).mean() < 0.01:                    # effectively non-negative
        q = float((acts > 1e-6).mean())
        rep["null"] = q / 2 if q < 0.3 else 1 / np.pi
        rep["K"]    = q * acts.shape[1]           # active features per vector
    else:
        rep["null"] = 0.0                            # sign-symmetric: zero is right

    if sae is not None:
        rep["A_dict"] = aniso(sae.W_dec)              # (N, d) — the FEATURE directions
        rep["K"]      = float((sae.encode(acts) > 0).sum(1).mean())
        # the amplification law, used as a consistency check
        k = rep["K"] * rep["A_dict"] / 2
        rep["A_act_predicted"] = k / (1 + k)
    return rep
The consistency check at the end is the interesting line. If A_act_predicted matches the measured A_act, the cone is fully explained by "a slightly tilted dictionary, many features firing" and there is nothing further to find. If the measured value is much higher than predicted, something else is adding a shared component that the dictionary does not account for — an attention sink, a large decoder bias, a always-on feature the autoencoder failed to capture — and that discrepancy is a lead worth chasing. A quantitative bridge between two literatures is most useful precisely when it fails to balance.

A synthesis you can act on

OperationAnisotropy lensSuperposition lensVerdict
Subtract the meanRemoves the rank-one spike; fixes the inflated-cosine symptomRemoves the always-on offset so capacity is not wasted on itBoth endorse. Do it
Remove the top D principal componentsDeletes the frequency/drift artefactsMay delete a real, high-importance featureOnly with evidence — check what u1 correlates with first
WhitenThe exact fix for the shape pathologyDestroys feature-magnitude information; makes the basis arbitraryGood before a cosine. Never before an interpretability analysis
Measure anisotropy on post-ReLU activationsReport average cosineThe null is 1/π, not 0Always compare against the sign-constrained baseline
Contrastive fine-tuningDirectly minimises the anisotropy baseline (Chapter 7)Reshapes which features the encoder writes at allStrongest available fix if you can train
You measure the average pairwise cosine of a transformer MLP's post-ReLU hidden activations and get 0.34. What can you conclude?

Chapter 9: Diagnose Your Own Embeddings

Everything in this lesson now becomes one script and one decision. The script takes a sample of your actual embeddings and returns five numbers. The decision takes those five numbers and tells you what, if anything, to do.

Before you run anything: sample from the right distribution. The single most common way to get a misleading diagnosis is to fit on a convenient public corpus rather than on the vectors your system will really see. Take your sample from live traffic if you have it — and take queries and documents separately, because Chapter 7's failure two is precisely the case where their means differ and nobody noticed.

The script

the whole diagnosisimport numpy as np

def diagnose(V):
    """V: (n, d) embeddings sampled from YOUR live distribution. n should be >> d."""
    n, d = V.shape
    U = V / np.linalg.norm(V, axis=1, keepdims=True)      # unit vectors

    # 1 — the offset: how far has the centre of mass drifted from the origin?
    mu_hat = U.mean(0)
    offset = float(np.linalg.norm(mu_hat))                # in [0, 1]

    # 2 — the anisotropy baseline, EXACTLY, in one pass (the Chapter 1 identity)
    A = (n * offset**2 - 1) / (n - 1)

    # 3 — the shape, from the CENTRED covariance spectrum
    X   = V - V.mean(0)
    sv  = np.linalg.svd(X, compute_uv=False)
    lam = sv**2 / n                                       # covariance eigenvalues
    p   = lam / lam.sum()
    erank = float(np.exp(-(p * np.log(p + 1e-12)).sum()))     # effective rank

    # 4 — the dominant direction itself, so you can ask what it encodes
    u1   = np.linalg.svd(X, full_matrices=False)[2][0]
    proj = X @ u1

    return dict(n=n, d=d,
                offset_ratio   = offset,
                anisotropy_A   = float(A),
                isotropic_sd   = float(1 / np.sqrt(d)),
                top1_var       = float(p[0]),
                top10_var      = float(p[:10].sum()),
                effective_rank = erank,
                erank_frac     = erank / d,
                u1_projection  = proj)

And the fifth number, which does not fit in the function because it needs your metadata — and which is the one that decides whether any fix is appropriate at all:

the artefact test — do this before you transform anythingrep = diagnose(V)
proj = rep["u1_projection"]

# is the dominant direction an artefact?
print("corr with token count :", np.corrcoef(proj, token_counts)[0, 1])
print("corr with doc length  :", np.corrcoef(proj, char_lengths)[0, 1])

# or is it the signal?
for label in set(labels):
    print(label, proj[labels == label].mean())      # do the classes separate along u1?

The whole diagnosis, worked by hand on six vectors

Before you trust that function on twenty thousand embeddings, run it on something you can check with a pencil. Here is a six-vector, three-dimensional set built to have every pathology this lesson describes, with every number computed longhand. Paste it into your test suite and you will never again wonder whether an implementation bug is masquerading as a finding.

ivi‖viunit vector v̂i"length" metadata
1(3, 1, 0)√10 = 3.162278(0.948683, 0.316228, 0)10
2(3, 0, 1)3.162278(0.948683, 0, 0.316228)10
3(3, −1, 0)3.162278(0.948683, −0.316228, 0)10
4(3, 0, −1)3.162278(0.948683, 0, −0.316228)10
5(4, 0.5, 0.5)√16.5 = 4.062019(0.984732, 0.123091, 0.123091)14
6(2, −0.5, −0.5)√4.5 = 2.121320(0.942809, −0.235702, −0.235702)6

Number one — the offset ratio. Average the six unit vectors, coordinate by coordinate.

the mean of the unit vectorsx: 4*(0.948683) + 0.984732 + 0.942809 = 3.794733 + 1.927541 = 5.722274   -> /6 = 0.953712
y: 0.316228 + 0 - 0.316228 + 0 + 0.123091 - 0.235702 = -0.112611        -> /6 = -0.018769
z: same as y, by the symmetry of the construction                        -> /6 = -0.018769

||mu_hat||^2 = 0.953712^2 + 2*(0.018769^2) = 0.909567 + 0.000704 = 0.910271
offset_ratio = sqrt(0.910271) = 0.954082

Number two — the anisotropy baseline. Straight from the Chapter 1 identity, and worth verifying once against the brute-force route so you believe it forever:

A = (6 × 0.910271 − 1)/5 = (5.461626 − 1)/5 = 4.461626/5 = 0.892325

Summing all thirty ordered pairwise cosines directly gives 26.769756, and 26.769756/30 = 0.892325. Identical to six decimals, one pass versus thirty dot products.

Number three — the isotropic scale. 1/√d = 1/√3 = 0.577350. So A = 0.892 is 1.5 isotropic standard deviations up — unremarkable at d = 3, which is exactly why the same A would be a screaming result at d = 768, where the same calculation gives 24.7 standard deviations. The dimension is doing all the interpretive work here, and that is the point of reporting both numbers together.

Number four — the centred spectrum. The raw mean is μ = (3, 0, 0), so the centred vectors are (0,1,0), (0,0,1), (0,−1,0), (0,0,−1), (1,0.5,0.5) and (−1,−0.5,−0.5).

the 3x3 covariance, then its spectrumSxx = (0+0+0+0+1+1)/6           = 2.0/6 = 0.333333
Syy = (1+0+1+0+0.25+0.25)/6     = 2.5/6 = 0.416667
Szz = (0+1+0+1+0.25+0.25)/6     = 2.5/6 = 0.416667
Sxy = (0+0+0+0+0.5+0.5)/6       = 1.0/6 = 0.166667
Sxz = (0+0+0+0+0.5+0.5)/6       = 1.0/6 = 0.166667
Syz = (0+0+0+0+0.25+0.25)/6     = 0.5/6 = 0.083333

trace = 0.333333 + 0.416667 + 0.416667 = 1.166667 = 7/6

eigenvalues (exact fractions, which is why this example was chosen):
  lambda = 2/3, 1/3, 1/6   ->  sum = 7/6  # check against the trace
  p      = 4/7, 2/7, 1/7   =  0.571429, 0.285714, 0.142857

entropy, term by term:
  (4/7) * 0.559616 = 0.319781        # -ln(4/7) = 0.559616
  (2/7) * 1.252763 = 0.357932
  (1/7) * 1.945910 = 0.277987
  H = 0.955700       erank = e^0.955700 = 2.6005   out of 3

Number five — the artefact test. The top eigenvector is u1 = (1,1,1)/√3 = (0.577350, 0.577350, 0.577350). Project the centred vectors onto it and correlate against the metadata column:

is the dominant direction an artefact?projections:  (0,1,0).u1     =  0.577350
              (0,0,1).u1     =  0.577350
              (0,-1,0).u1    = -0.577350
              (0,0,-1).u1    = -0.577350
              (1,0.5,0.5).u1 =  2 * 0.577350 =  1.154701
              (-1,-0.5,-0.5) = -1.154701

lengths:      10, 10, 10, 10, 14, 6          mean 10

covariance   = (1.154701*4 + (-1.154701)*(-4)) / 6 = 9.237604/6 = 1.539601
sd(proj)     = sqrt((4*0.333333 + 2*1.333333)/6) = sqrt(0.666667) = 0.816497
sd(lengths)  = sqrt((16 + 16)/6) = sqrt(5.333333) = 2.309401

correlation  = 1.539601 / (0.816497 * 2.309401) = 1.539601/1.885618 = +0.8165
Verdict on the toy: fix it. Offset ratio 0.954 against an isotropic expectation of √(1/6) = 0.408 for a sample this small; A = 0.892; effective rank 2.60 of 3; and the dominant direction correlates at +0.82 with document length. That last number is what licenses the transform — without it the first three would merely be describing a space, not diagnosing one. Note also how the correlation arose: vectors 5 and 6 are the only ones whose length differs from the rest, and they are also the only ones with a component along the offset. Length variation creating a dominant direction is precisely the mean-pooling effect derived in Chapter 4.
the golden test — paste this into your repodef test_diagnose_golden():
    V = np.array([[3,1,0], [3,0,1], [3,-1,0], [3,0,-1],
                  [4,0.5,0.5], [2,-0.5,-0.5]], dtype=float)
    r = diagnose(V)
    assert abs(r["offset_ratio"]   - 0.954082) < 1e-5
    assert abs(r["anisotropy_A"]   - 0.892325) < 1e-5
    assert abs(r["top1_var"]       - 4/7)      < 1e-6
    assert abs(r["effective_rank"] - 2.6005)   < 1e-3
    # the identity that must hold for ANY input: A == offset^2 in the large-n limit,
    # and exactly A = (n*offset^2 - 1)/(n - 1) for every n
    n = V.shape[0]
    assert abs(r["anisotropy_A"] - (n*r["offset_ratio"]**2 - 1)/(n-1)) < 1e-12
    # and the sign-free property: the top eigenvector is only defined up to sign,
    # so always test |corr|, never corr
    assert abs(abs(np.corrcoef(r["u1_projection"], [10,10,10,10,14,6])[0,1])
               - 0.816497) < 1e-5

That last assertion is not decoration. A principal component is defined only up to sign, and different library versions, different BLAS backends, and different machines will hand you either one. A test that asserts a signed correlation will pass on your laptop and fail in continuous integration for reasons that will consume an afternoon.

Reading the report

Here is a real-shaped output and how to read every line of it.

a typical decoder-pooled embedding setn              : 20000
d              : 768
offset_ratio   : 0.870
anisotropy_A   : 0.757
isotropic_sd   : 0.036
top1_var       : 0.41
top10_var      : 0.72
effective_rank : 12.4
erank_frac     : 0.016
corr(u1, token_count) : 0.78
LineWhat it means
offset_ratio 0.870The mean of the unit-normalised embeddings has length 0.87 out of a maximum of 1. The cloud barely surrounds the origin at all. Compare against √(1/n) ≈ 0.007, which is what you would see for a truly isotropic sample of this size
anisotropy_A 0.757Two random items score 0.757 before any similarity exists. Everything you measure sits on top of this. Note it equals offset_ratio2 to three decimals, as the identity requires — a free correctness check on the code
0.757 / 0.036 = 21Twenty-one standard deviations above the isotropic baseline for d = 768. Not marginal
top1_var 0.41After removing the mean entirely, one direction still holds 41% of the remaining variance. The shape pathology is severe and independent of the offset
effective_rank 12.4The cloud uses about 12 of its 768 dimensions. You are paying for 768 floats per vector and getting 12 dimensions of geometry
corr(u1, token_count) 0.78The verdict. The dominant direction is mostly encoding how long the text is. That is an artefact for a semantic-similarity system, so the fixes apply
If that last line had instead read "the three topic classes have mean projections −4.1, +0.2, +3.9", the correct action would have been to change nothing. Identical spectrum, opposite decision. The spectrum tells you a direction is dominant; only the correlation tells you whether dominance is a disease.

The decision tree

First — is it an artefact?
Correlate the top-component projection with length, frequency, and your labels. If it tracks the labels, stop. Nothing below applies.
↓ it is an artefact
Second — can you train?
If you can fine-tune the encoder and can mine or already have positive pairs, do contrastive fine-tuning and skip everything else. Ten points against post-hoc's ten (Chapter 7).
↓ cannot train
Third — centre
Fit μ on an in-domain sample, subtract it from documents and queries, store it beside the index. Cheap, safe, endorsed by both lenses in Chapter 8. Measure recall@k before and after.
↓ want more
Fourth — whitening-k with shrinkage
k around d/3, α around 0.1. Sweep both against your own evaluation, never against a geometry metric. Expect a storage and latency win alongside any quality win.
↓ frozen last layer of a decoder?
Fifth — change layers
Stop pooling the final layer. Middle layers are less anisotropic and usually better for similarity (Chapter 4). This is often a larger win than any transform, and it costs one line.

The checklist of things that will bite you

TrapSymptomFix
Fitting μ or W on the queries at query timeEvery similarity is nonsense; a single query gets a mean of itselfFit once on a corpus sample, store, apply forever
Transforming documents but not queriesRetrieval collapses; the two sides live in different coordinate systemsOne transform function, called on both paths, with a test that asserts it
n < d when whiteningSilent inf or nan in some coordinates; or wild quality lossClamp eigenvalues, use whitening-k with k < n, apply shrinkage
Not re-indexing after a refitSlow quality decay as new documents use the new transform and old ones the oldVersion the transform; refuse to serve a mismatch; re-index on refit
Judging the fix by average cosineThe geometry metric always improves. That was guaranteed by the arithmeticJudge by recall@k, clustering purity, or the false-merge rate
Measuring anisotropy on post-ReLU activations against a null of 0Everything looks anisotropicThe null is m2/s — 1/π for post-ReLU Gaussians (Chapter 8)
Reporting a contextual cosine with no baseline0.85 is uninterpretable; it might be excellent or below averageCompute the layer's anisotropy baseline once and report the excess (Chapter 4)
Whitening before an interpretability analysisFeature importances vanish; principal axes become arbitraryDo not. Centre only (Chapter 8)

Glossary of every bolded term

TermOne lineChapter
AnisotropyVectors concentrated in a narrow cone rather than spread over all directions0
IsotropicEvery direction equally represented; two random items average zero cosine0
ResidualThe individual part of an embedding, once the shared mean is removed0
Anisotropy baseline AAverage pairwise cosine — the score of "unrelated" in this space1
Second-moment matrix SAverage outer product of the raw vectors; equals Σ + μμT1
Effective ranke raised to the entropy of the normalised eigenvalues — dimensions actually used1
Gauge freedomA direction in parameter space along which the loss is exactly flat2
Representation degenerationRare tokens drifting into a shared direction because they are only ever pushed away2
Residual streamThe additively-updated hidden state that every transformer layer writes into2
Orthogonal projectorP = I − UUT; idempotent, symmetric, deletes a subspace3
Self-similarityAverage cosine between a word's representations across different contexts4
Maximum explainable varianceFraction of a word's occurrence variance explained by its first principal component4
FlowAn invertible map used to move one probability distribution onto another5
Additive coupling layerAn invertible block whose Jacobian determinant is exactly 1, for any inner network5
WhiteningThe affine map making the covariance the identity: z = (x − μ)UΛ−1/26
ShrinkageBlending the covariance toward a scaled identity before inverting it6
Alignment / uniformityThe two forces a contrastive objective balances on the hypersphere7
SuperpositionPacking more features than dimensions into nearly orthogonal directions, accepting interference8
FeaturesThe sparse, mostly-off directions a model writes information into8

The cheat sheet — every formula in one place

QuantityFormulaNotes
Anisotropy baselineA = (n‖μ̂‖2 − 1)/(n−1) → ‖μ̂‖2Exact identity on unit vectors. One pass, not O(n2)
Centring floorAcentred = −1/(n−1)Forced by ∑ui = 0, not a sign of isotropy
Isotropic spreadsd(cos) = 1/√d0.036 at d = 768. The scale of "surprising"
Cosine in an offset cloudcos ≈ 1 − ‖a−b2/(2(1+p+q))The (1+p+q) is what corrupts ranking
Moment decompositionS = Σ + μμTThe offset is a rank-one spike on the shape
Effective rankerank = exp(−∑pk log pk), pk = λk/∑λEquals k exactly for k equal eigenvalues
Gauge invariancew → ℓw + h·c for ew → ew + cSoftmax unchanged; geometry changed arbitrarily
Logit gradient∂L/∂ew = (pw − 1[w=t]) hNon-targets are always pushed away from h
Depth toy modelcos ≈ L/(L+1)Shared parts add as L, individual as √L
All-but-the-Topv′ = (v − μ) − ∑i≤D(uiT(v−μ))uiD ≈ d/100 for static word vectors
Anisotropy adjustmentmetricadj(ℓ) = metric(ℓ) − baseline(ℓ)Mandatory for contextual embeddings
Change of variablespU(u) = pZ(f−1(u))·|det ∂f−1/∂u|The log-det term is the anti-collapse penalty
Coupling layerva = ua, vb = ub + m(ua)det = 1 for any m; inverts with one forward pass
Whiteningz = (x − μ)UΛ−1/2, so WTΣW = IUnique up to a rotation, which cosine cannot see
Noise amplificationdirection k scaled by 1/√λkRelative reamplification √(λ1d)
ShrinkageΣ′ = (1−α)Σ + α(trΣ/d)Iα = 0.1 cut our condition number 83.6 → 15.5
Contrastive uniformityminimise ∑ij hiThj = n2‖μ̂‖2An explicit anisotropy penalty inside InfoNCE
Superposition capacityN ≈ exp(dε2/4)From P(|cos| > ε) ≤ 2exp(−dε2/2)
Non-negative floorcos ≈ m2/s; 1/π for post-ReLU GaussiansThe correct null for sign-constrained activations

The numbers worth remembering

NumberWhat it is
0.99Average cosine between two random words in GPT-2's last layer
0.036Standard deviation of a random cosine at d = 768 — the scale everything should be judged against
−1/(n−1)The average pairwise cosine of any centred set. Not isotropy
d/100Mu & Viswanath's rule for how many principal components to delete
< 5%Variance in a word's contextual representations explained by a single static direction
56.70Untreated BERT-base average STS Spearman — below averaged GloVe's 61.32
66.55 / 66.28BERT-flow and BERT-whitening. A learned flow and a closed-form matrix, within noise of each other
76.25 / 81.57Unsupervised and supervised SimCSE. Training the geometry in beats fixing it afterwards, twice over
1/π = 0.318Average cosine of two independent post-ReLU noise vectors. The null for sign-constrained activations
√(λ1d)How much whitening amplifies your noisiest direction relative to your strongest

Where to go from here

If you want…Go to
The contrastive fix that made all of this optionalSimCSE and contrastive learning
The sentence-embedding architecture this all sits insideSentence-BERT and E5 text embeddings
The model whose geometry Chapters 4–6 dissectBERT
The static vectors All-but-the-Top was designed forword2vec, GloVe, PMI word embeddings
How many dimensions you actually needDimensionality of word embeddings and Matryoshka representations
The linear algebra underneath every chapterPCA and eigenvalues
What a cosine is actually measuringSimilarity metrics and vector embeddings
Where these vectors end up in productionVector databases, RAG, ColBERT
How to tell whether your fix actually helpedEmbedding benchmarks and evaluating word embeddings
The other lens on the same geometryInterpretability
Normalisation, the operation people confuse with all of thisNormalisation and embedding layers

References

  1. Mu, J., Viswanath, P. "All-but-the-Top: Simple and Effective Postprocessing for Word Representations." ICLR 2018 — arXiv:1702.01417. Chapter 3.
  2. Ethayarajh, K. "How Contextual are Contextualized Word Representations? Comparing the Geometry of BERT, ELMo, and GPT-2 Embeddings." EMNLP 2019 — arXiv:1909.00512. Chapter 4.
  3. Li, B., Zhou, H., He, J., Wang, M., Yang, Y., Li, L. "On the Sentence Embeddings from Pre-trained Language Models" (BERT-flow). EMNLP 2020 — arXiv:2011.05864. Chapter 5.
  4. Su, J., Cao, J., Liu, W., Ou, Y. "Whitening Sentence Representations for Better Semantics and Faster Retrieval." 2021 — arXiv:2103.15316. Chapter 6.
  5. Gao, J., He, D., Tan, X., Qin, T., Wang, L., Liu, T.-Y. "Representation Degeneration Problem in Training Natural Language Generation Models." ICLR 2019 — arXiv:1907.12009. The rare-token mechanism in Chapter 2.
  6. Gao, T., Yao, X., Chen, D. "SimCSE: Simple Contrastive Learning of Sentence Embeddings." EMNLP 2021 — arXiv:2104.08821. Chapter 7, and the source of the comparison table used throughout.
  7. Wang, T., Isola, P. "Understanding Contrastive Representation Learning through Alignment and Uniformity on the Hypersphere." ICML 2020 — arXiv:2005.10242. Chapter 7.
  8. Arora, S., Li, Y., Liang, Y., Ma, T., Risteski, A. "A Latent Variable Model Approach to PMI-based Word Embeddings." TACL 2016 — arXiv:1502.03520. The partition-function isotropy measure in Chapter 1.
  9. Cai, X., Huang, J., Bian, Y., Church, K. "Isotropy in the Contextual Embedding Space: Clusters and Manifolds." ICLR 2021. Global anisotropy, local isotropy — Chapter 7.
  10. Rajaee, S., Pilehvar, M. T. "A Cluster-based Approach for Improving Isotropy in Contextual Embedding Space." ACL 2021. Cluster-wise correction — Chapter 7.
  11. Rudman, W., Gillman, N., Rayne, T., Eickhoff, C. "IsoScore: Measuring the Uniformity of Embedding Space Utilization." Findings of ACL 2022. The critique of average cosine in Chapter 1.
  12. Godey, N., de la Clergerie, É., Sagot, B. "Anisotropy Is Inherent to Self-Attention in Transformers." EACL 2024. Anisotropy without a vocabulary softmax — Chapters 2 and 7.
  13. Elhage, N. et al. "Toy Models of Superposition." Anthropic, 2022 — transformer-circuits.pub. Chapter 8.
  14. Kingma, D. P., Dhariwal, P. "Glow: Generative Flow with Invertible 1×1 Convolutions." NeurIPS 2018 — arXiv:1807.03039. The flow architecture BERT-flow borrows.
  15. Reimers, N., Gurevych, I. "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." EMNLP 2019 — arXiv:1908.10084. The setting all the STS numbers live in.
Cross-domain bridge
Whitening an embedding space is the Mahalanobis distance, and you have met it before
In estimation and control, you never compare a measurement residual in raw units — you compare it in units of its own uncertainty, using the Mahalanobis distance d2 = (x−μ)TΣ−1(x−μ). A Kalman filter's innovation gate is exactly this: a residual is only surprising relative to how much spread that direction was expected to have. Chapter 6's whitening is the same operation, factored differently — Σ−1 = WWT, so the Mahalanobis distance is the plain Euclidean distance after whitening. Anisotropic embeddings are an ungated filter: you are treating a 14.6-variance direction and a 0.175-variance direction as equally informative, which no estimation engineer would ever do. If you have built a Kalman filter, you have already built Chapter 6 — see our similarity metrics lesson for the same geometry under different names.
"What I cannot create, I do not understand."
Take 10,000 embeddings out of any model you have lying around. Twenty lines of numpy gives you the offset ratio, the anisotropy baseline, the spectrum and the effective rank. Another ten gives you the whitening transform. You will know more about your retrieval system in an afternoon than the geometry section of most papers reports.
Exit gate — teach it back before you leave.

Without scrolling up: (1) derive A = ‖μ̂‖2 from the double sum, and say why the centred value is −1/(n−1) rather than 0; (2) explain why adding a constant vector to every row of an unembedding matrix changes nothing in the loss and everything in the geometry; (3) write All-but-the-Top's three steps and say which pathology each one attacks; (4) derive W = UΛ−1/2 from the requirement WTΣW = I, and say why whitening amplifies noise; (5) give one concrete case where the correct action is to change nothing at all, and the single measurement that would reveal it. If any of the five stalls, its chapter is one tap away.

Which single sentence best captures what this whole line of research established?