Attention Scoring and Masking

Dive into Deep Learning · §10.2

Attention scoring and masking
dot-product scores · the 1/√d factor, measured · masked softmax · batched attention · from alignment to attention

From kernels to dot products

Expand the Gaussian kernel’s exponent:

-\tfrac{1}{2}\|\mathbf{q} - \mathbf{k}_i\|^2 = \mathbf{q}^\top \mathbf{k}_i - \tfrac{1}{2}\|\mathbf{k}_i\|^2 - \tfrac{1}{2}\|\mathbf{q}\|^2.

The query term cancels in the softmax; the key-norm term cancels too when all key norms are equal. Keeping only the dot product is a modeling choice: a compatibility score that learned representations can shape freely.

For unit-variance entries, \operatorname{Var}(\mathbf{q}^\top\mathbf{k}) = d — scores grow with vector length for no semantic reason. Hence the Transformer’s scoring function:

a(\mathbf{q}, \mathbf{k}_i) = \mathbf{q}^\top \mathbf{k}_i / \sqrt{d}.

Softmax saturation, measured

Random queries and keys, attention over 64 candidates, entropy of the weight distribution as d grows:

  • Uniform over 64 keys ≈ 4.2 nats; scaled scores hold ≈ 3.7 nats at every d.
  • Unscaled: below 0.2 nats by d = 512 — top weight above 0.9, from vector length alone.

Saturation kills the gradient

The softmax Jacobian \partial \boldsymbol{\alpha} / \partial \mathbf{a} = \mathrm{diag}(\boldsymbol{\alpha}) - \boldsymbol{\alpha}\boldsymbol{\alpha}^\top tends to zero as \boldsymbol{\alpha} approaches one-hot:

torch.manual_seed(0)
for d in (4, 64, 512):
    _, jac_unscaled = attention_stats(d, scaled=False)
    _, jac_scaled = attention_stats(d, scaled=True)
    print(f'd = {d:3d}: Jacobian norm {jac_unscaled:.3f} (unscaled), '
          f'{jac_scaled:.3f} (scaled)')
d =   4: Jacobian norm 0.259 (unscaled), 0.178 (scaled)
d =  64: Jacobian norm 0.208 (unscaled), 0.182 (scaled)
d = 512: Jacobian norm 0.093 (unscaled), 0.184 (scaled)
  • Scaled: constant at every dimension. Unscaled: decaying — about half by d = 512, still falling.
  • One division by \sqrt{d} removes the problem. Part of the definition, not a tuning trick.

Two reasons to mask

Padding — variable-length sequences share a minibatch:

Dive  into  Deep    Learning
Learn to    code    <blank>
Hello world <blank> <blank>

Causality — a language model computes all positions in parallel, but query t must not see keys beyond t.

Same fix for both: overwrite invalid scores before the softmax.

Not with -10^6 (overflows float16), not with -\infty (a fully masked query → NaN). Use the dtype’s most negative finite value.

masked_softmax

def masked_softmax(X, valid_lens):
    """Perform softmax operation by masking elements on the last axis."""
    # X: 3D tensor, valid_lens: 1D or 2D tensor
    if valid_lens is None:
        return F.softmax(X, dim=-1)
    shape = X.shape
    if valid_lens.dim() == 1:
        valid_lens = torch.repeat_interleave(valid_lens, shape[1])
    else:
        valid_lens = valid_lens.reshape(-1)
    mask = torch.arange(shape[-1], device=X.device)[None, :]
    mask = mask < valid_lens[:, None]
    # Most negative finite score: exactly zero weight after the softmax,
    # at any precision, without the NaN risk of literal -inf
    X = X.reshape(-1, shape[-1]).masked_fill(~mask, torch.finfo(X.dtype).min)
    return F.softmax(X.reshape(shape), dim=-1)

Masking in action

Valid lengths 2 and 3 — weights beyond the prefix are exactly zero, rows still sum to one:

masked_softmax(torch.rand(2, 2, 4), torch.tensor([2, 3]))
tensor([[[0.7005, 0.2995, 0.0000, 0.0000],
         [0.5080, 0.4920, 0.0000, 0.0000]],

        [[0.2296, 0.3997, 0.3707, 0.0000],
         [0.1914, 0.4130, 0.3956, 0.0000]]])

Per-query valid lengths work too:

masked_softmax(torch.rand(2, 2, 4), torch.tensor([[1, 3], [2, 4]]))
tensor([[[1.0000, 0.0000, 0.0000, 0.0000],
         [0.3538, 0.3878, 0.2584, 0.0000]],

        [[0.5266, 0.4734, 0.0000, 0.0000],
         [0.2870, 0.2921, 0.2345, 0.1864]]])

Causal masking

Query t sees keys 1, \ldots, t: valid lengths are just (1, 2, \ldots, n). The pattern is lower triangular:

torch.manual_seed(0)
scores = torch.randn(1, 6, 6)
causal_lens = torch.arange(1, 7)[None, :]  # query t sees keys 1..t
d2l.show_heatmaps(masked_softmax(scores, causal_lens)[None],
                  xlabel='Keys', ylabel='Queries')

  • On the attention side, this mask is the key difference between reading a sequence and being trainable, in parallel, to generate one.

Composing masks

The general interface is a boolean tensor — entry (i, j): may query i see key j? Requirements compose by AND, broadcasting does the bookkeeping: padding (\textrm{batch}, 1, \textrm{keys}) ∧ causal (1, \textrm{queries}, \textrm{keys}).

The composite must still leave every query at least one valid key — masks that are harmless alone can intersect to an empty row.

Batched attention

Attention over a minibatch is two batched matrix multiplications and one softmax:

\mathrm{softmax}\left(\frac{\mathbf{Q}\mathbf{K}^\top}{\sqrt{d}}\right)\mathbf{V} \in \mathbb{R}^{n \times v}.

Q = torch.ones((2, 3, 4))
K = torch.ones((2, 4, 6))
d2l.check_shape(torch.bmm(Q, K), (2, 3, 6))

DotProductAttention

Scoring, masking, dropout on the weights, value pooling — a dozen lines the rest of the book reuses:

class DotProductAttention(nn.Module):
    """Scaled dot product attention."""
    def __init__(self, dropout):
        super().__init__()
        self.dropout = nn.Dropout(dropout)

    # Shape of queries: (batch_size, no. of queries, d)
    # Shape of keys: (batch_size, no. of key-value pairs, d)
    # Shape of values: (batch_size, no. of key-value pairs, value dimension)
    # Shape of valid_lens: (batch_size,) or (batch_size, no. of queries)
    def forward(self, queries, keys, values, valid_lens=None):
        d = queries.shape[-1]
        # Swap the last two dimensions of keys with keys.transpose(1, 2)
        scores = torch.bmm(queries, keys.transpose(1, 2)) / math.sqrt(d)
        self.attention_weights = masked_softmax(scores, valid_lens)
        return torch.bmm(self.dropout(self.attention_weights), values)

Masked attention, verified

Valid lengths (2, 6): the stored weights vanish beyond the second and sixth key.

From alignment to attention

Where all of this came from

2014 machine translation: one fixed vector between encoder and decoder RNNs — long sentences don’t fit. Bahdanau, Cho & Bengio: let the decoder state query all encoder states, one fresh summary per output token — “jointly learning to align and translate”.

Learned weights behave like soft alignments — monotone, except where the languages reorder words.

Additive scoring

Decoder state and encoder states had different sizes, so the original score was a tiny MLP:

a(\mathbf{q}, \mathbf{k}) = \mathbf{w}_v^\top \tanh(\mathbf{W}_q \mathbf{q} + \mathbf{W}_k \mathbf{k}).

torch.manual_seed(0)
queries, keys = torch.randn(3, 20), torch.randn(6, 2)
num_hiddens = 8
W_q = torch.randn(num_hiddens, 20) / math.sqrt(20)
W_k = torch.randn(num_hiddens, 2) / math.sqrt(2)
w_v = torch.randn(num_hiddens) / math.sqrt(num_hiddens)
features = torch.tanh((queries @ W_q.T)[:, None, :]
                      + (keys @ W_k.T)[None, :, :])
scores = features @ w_v
d2l.check_shape(scores, (3, 6))
F.softmax(scores, dim=-1)
tensor([[0.1692, 0.1612, 0.1816, 0.1560, 0.1502, 0.1818],
        [0.1664, 0.1849, 0.1657, 0.1338, 0.1623, 0.1870],
        [0.1517, 0.2188, 0.1441, 0.1317, 0.2166, 0.1371]])
  • Dot products won on hardware: one matmul vs. an n \times m \times h tensor of activations. Learned projections + dot product give the metric back — exactly the Transformer’s form.

Recap

  • Scaled dot product is the scoring function: \mathbf{q}^\top\mathbf{k}/\sqrt{d} keeps score variance at 1, so the softmax neither saturates nor starves its gradient — we measured both.
  • Masking handles padding and causality with one primitive: overwrite invalid scores with the dtype’s most negative finite value.
  • DotProductAttention = bmm → masked softmax → dropout → bmm.
  • Additive attention started it all, as learned soft alignment for translation; it survives as history.