AI Grimoire

Building the attention weights

Part 1 ended with a weighted average and no way to compute the weights. This part derives them — additive scoring first, because that is what came first, then the dot product and the scale factor that makes it usable.

We need αti\alpha_{ti}: how much output step tt should read from input position ii. Three requirements, all forced by what it is for.

  1. It must depend on both sides — on what tt is looking for and on what ii contains. A weight that ignores either is not doing selection.
  2. The weights over ii must be non-negative and sum to one, or “weighted average” is a lie and the scale of ctc_t drifts with sequence length.
  3. It must be differentiable, or none of this trains.

The standard route is: compute an unnormalised score etie_{ti}, then normalise.

αti=exp(eti)j=1nexp(etj)\alpha_{ti} = \frac{\exp(e_{ti})}{\sum_{j=1}^{n} \exp(e_{tj})}
eq. 1 — score, then normalise

So the real question is etie_{ti}.

Attempt one: learn the comparison

Bahdanau’s answer is to let a small network decide. Concatenate the decoder state and the encoder state, push them through a tanh layer, project to a scalar.

eti=vtanh ⁣(Wsst1+Whhi)e_{ti} = v^{\top} \tanh\!\left( W_s s_{t-1} + W_h h_i \right)
eq. 2 — additive (Bahdanau) scoring

This works, and it has a real advantage: ss and hh need not have the same dimension, because WsW_s and WhW_h map them into a shared space independently.

It also has a cost that took me a while to see as decisive. For each of the n×mn \times m pairs, eq. 2 requires a tanh over a dd-vector and a dot with vv. Those cannot be folded into a single matrix multiplication, because the nonlinearity sits in the middle. You get nmn \cdot m small operations rather than one big one — which on a GPU is close to the worst possible shape for a computation.

Attempt two: just use the inner product

Drop the learned comparator. Two vectors in the same space already have a similarity function.

eti=st1hie_{ti} = s_{t-1}^{\top} h_i
eq. 3 — dot-product scoring

Every score for every pair is now a single matrix product, S=QKS = Q K^{\top}. This is the change that mattered — not because dot products are more expressive than a learned comparator (they are less), but because the whole score matrix becomes one GEMM.

The expressivity is bought back by projecting first. Q=XWQQ = X W^Q and K=XWKK = X W^K are learned, so the model still chooses which subspace similarity is measured in; it just does not get to choose the similarity function itself.

The scale factor is not cosmetic

Here is the part I originally skipped, and skipping it is why I could not reproduce a working implementation from memory.

Suppose qq and kk have independent components with mean 0 and variance 1. Then

Var ⁣[qk]=i=1dkVar[qiki]=dk\Var\!\left[\, q^{\top} k \,\right] = \sum_{i=1}^{d_k} \Var[\, q_i k_i \,] = d_k
eq. 4 — variance grows with dimension

so the logits have standard deviation dk\sqrt{d_k}. At dk=64d_k = 64 that is 8, and a gap of a few standard deviations between the largest logit and the rest sends softmax to a one-hot vector.

That is bad not because the attention is sharp but because of what it does to the gradient. The softmax Jacobian is diag(a)aa\diag(a) - a a^{\top}; as aa approaches one-hot, it approaches zero, and no gradient reaches WQW^Q or WKW^K at all. The layer stops learning while looking perfectly healthy in the forward pass.

Dividing by dk\sqrt{d_k} restores unit variance and the problem goes away.

Attention(Q,K,V)=softmax ⁣(QKdk)V\mathrm{Attention}(Q, K, V) = \softmax\!\left( \frac{Q K^{\top}}{\sqrt{d_k}} \right) V
eq. 5 — the whole thing, assembled

Checking it myself

I did not believe the saturation argument until I plotted it, and it is a five-line experiment worth doing rather than reading.

python · the saturation, measured
import torch

for d in (8, 64, 512):
    q = torch.randn(4096, d)
    k = torch.randn(4096, d)
    logits = q @ k.T

    for name, s in (("unscaled", logits), ("scaled", logits / d**0.5)):
        a = s.softmax(dim=-1)
        # Entropy in nats: log(4096) = 8.32 is uniform, 0 is one-hot.
        entropy = -(a * a.clamp_min(1e-12).log()).sum(-1).mean()
        print(f"d={d:4d} {name:9s} std={s.std():6.2f} entropy={entropy:5.2f}")

At d=512d = 512 the unscaled entropy collapses towards zero while the scaled version stays near logn\log n. The scaled row is not sharp yet — sharpness is something the model learns by growing the magnitude of WQW^Q and WKW^K during training. The point of the scale factor is that it starts diffuse and lets training decide, rather than starting saturated with no gradient to escape with.

Next

Part 3 takes the finished operation and looks at what the resulting matrix actually contains — and at how much of what people read off attention maps is really there.

Series

Reference

The settled statements of what this note works through.

References

[1]Bahdanau et al. — Neural Machine Translation by Jointly Learning to Align and Translate (2014)arXiv:1409.0473
[2]Luong et al. — Effective Approaches to Attention-based Neural Machine Translation (2015)arXiv:1508.04025
[3]Vaswani et al. — Attention Is All You Need (2017)arXiv:1706.03762

·