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}.

Measured softmax saturation

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 — a near-deterministic weighting, from vector length alone.

Saturation reduces the softmax 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:

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.258 (unscaled), 0.178 (scaled)
d =  64: Jacobian norm 0.218 (unscaled), 0.184 (scaled)
d = 512: Jacobian norm 0.103 (unscaled), 0.186 (scaled)
  • Scaled: constant at every dimension. Unscaled: decaying — about half by d = 512, still falling.
  • Division by \sqrt{d} keeps this scale independent of dimension.

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 jax.nn.softmax(X, axis=-1)
    shape = X.shape
    if valid_lens.ndim == 1:
        valid_lens = jnp.repeat(valid_lens, shape[1])
    else:
        valid_lens = valid_lens.reshape(-1)
    mask = jnp.arange(shape[-1])[None, :] < valid_lens[:, None]
    # Most negative finite score: exactly zero weight after the softmax,
    # at any precision, without the NaN risk of literal -inf
    X = jnp.where(mask, X.reshape(-1, shape[-1]), jnp.finfo(X.dtype).min)
    weights = jax.nn.softmax(X, axis=-1)
    # A fully masked query (no valid key) would be a uniform average over
    # invalid values; zero those rows so no padded position leaks through
    weights = jnp.where(mask.any(-1, keepdims=True), weights, 0.0)
    return weights.reshape(shape)

Masking in action

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

masked_softmax(jax.random.uniform(jax.random.key(0), (2, 2, 4)),
               jnp.array([2, 3]))
Array([[[0.4922724 , 0.5077276 , 0.        , 0.        ],
        [0.5997409 , 0.40025908, 0.        , 0.        ]],

       [[0.3531402 , 0.19856434, 0.4482954 , 0.        ],
        [0.31042197, 0.2873157 , 0.40226236, 0.        ]]], dtype=float32)

Per-query valid lengths work too:

masked_softmax(jax.random.uniform(jax.random.key(1), (2, 2, 4)),
               jnp.array([[1, 3], [2, 4]]))
Array([[[1.        , 0.        , 0.        , 0.        ],
        [0.35672572, 0.2543091 , 0.38896522, 0.        ]],

       [[0.43679553, 0.5632045 , 0.        , 0.        ],
        [0.2580636 , 0.2509437 , 0.258296  , 0.23269679]]], dtype=float32)

Causal masking

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

scores = jax.random.normal(jax.random.key(2), (1, 6, 6))
causal_lens = jnp.arange(1, 7)[None, :]  # query t sees keys 1..t
d2l.show_heatmaps(masked_softmax(scores, causal_lens)[None],
                  xlabel='Keys', ylabel='Queries')

  • The mask permits parallel training over all positions without exposing future tokens.

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 = jnp.ones((2, 3, 4))
K = jnp.ones((2, 4, 6))
d2l.check_shape(jax.lax.batch_matmul(Q, K), (2, 3, 6))

DotProductAttention

The implementation combines scoring, masking, dropout on the weights, and value pooling:

class DotProductAttention(nnx.Module):
    """Scaled dot product attention."""
    def __init__(self, dropout, rngs=None):
        rngs = nnx.Rngs(dropout=0) if rngs is None else rngs
        self.dropout = nnx.Dropout(dropout, rngs=rngs)

    # 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 __call__(self, queries, keys, values, valid_lens=None):
        d = queries.shape[-1]
        # Swap the last two dimensions of keys with keys.swapaxes(1, 2)
        scores = queries@(keys.swapaxes(1, 2)) / math.sqrt(d)
        attention_weights = masked_softmax(scores, valid_lens)
        # NNX idiom: return (output, weights); PyTorch stores weights on self
        return self.dropout(attention_weights) @ values, attention_weights

Masked attention, verified

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

Attention as learned alignment

Machine translation

In 2014 neural machine translation, one fixed vector connected the encoder and decoder RNNs, restricting the representation of long sentences. 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}).

key_q, key_k, k1, k2, k3 = jax.random.split(jax.random.key(0), 5)
queries = jax.random.normal(key_q, (3, 20))
keys = jax.random.normal(key_k, (6, 2))
num_hiddens = 8
W_q = jax.random.normal(k1, (num_hiddens, 20)) / math.sqrt(20)
W_k = jax.random.normal(k2, (num_hiddens, 2)) / math.sqrt(2)
w_v = jax.random.normal(k3, (num_hiddens,)) / math.sqrt(num_hiddens)
features = jnp.tanh((queries @ W_q.T)[:, None, :]
                    + (keys @ W_k.T)[None, :, :])
scores = features @ w_v
d2l.check_shape(scores, (3, 6))
jax.nn.softmax(scores, axis=-1)
Array([[0.13390937, 0.3772766 , 0.17127123, 0.1027277 , 0.06276076,
        0.15205443],
       [0.19218282, 0.31524566, 0.14511336, 0.08953383, 0.07662669,
        0.18129766],
       [0.15669951, 0.3378386 , 0.15281527, 0.11713327, 0.09428051,
        0.1412329 ]], dtype=float32)
  • Dot products are efficient on current hardware: one matrix multiplication instead of an n \times m \times h tensor of activations. Learned projections followed by a dot product give the metric back, which gives the form used in the Transformer.

Recap

  • Scaled dot product is the standard scoring function: \mathbf{q}^\top\mathbf{k}/\sqrt{d} keeps score variance at 1, so the softmax avoids dimension-induced saturation and small score gradients.
  • Masking handles padding and causality with one primitive: overwrite invalid scores with the dtype’s most negative finite value.
  • DotProductAttention uses BMM, masked softmax, dropout, and a second BMM.
  • Additive attention introduced learned soft alignment for translation.