Self-attention lets every position in a sequence look at every other position and build a context-aware representation by taking a weighted average of value vectors, where the weights come from the compatibility between queries and keys.
Scaled Dot-Product Attention
Each token is projected into three vectors: a query , a key , and a value . Stacking these over a sequence of length gives matrices and . Attention is defined as:
The score matrix holds a dot product for every query-key pair. Softmax over the last axis turns each row into a probability distribution, and multiplying by produces a convex combination of value vectors.
Why divide by ?
If the components of and are independent with mean 0 and variance 1, then has variance . Large-magnitude scores push softmax into saturated regions where gradients vanish. Scaling by restores unit variance and keeps the softmax well conditioned.
Causal Masking
For autoregressive decoding a token must not attend to future positions. We add a mask to the scores before softmax, setting disallowed entries to so their softmax weight becomes 0:
Complexity
The score matrix and the softmax are both in memory, and forming scores plus the context costs in compute. This quadratic dependence on sequence length is the main scaling bottleneck of attention and the motivation behind the KV Cache at inference time and many efficient-attention variants.
Multi-Head Attention
Rather than one attention with dimension , we run heads in parallel, each of width . Heads can specialize (some track syntax, some track long-range coreference), and their outputs are concatenated and projected:
Code: einsum and einops
import torch
import torch.nn.functional as F
from einops import rearrange
def scaled_dot_product_attention(q, k, v, causal=False):
# q, k, v: (batch, seq, d_k)
d_k = q.shape[-1]
scores = torch.einsum('bqd,bkd->bqk', q, k) / (d_k ** 0.5)
if causal:
T = scores.shape[-1]
mask = torch.triu(torch.ones(T, T, device=q.device, dtype=torch.bool), diagonal=1)
scores = scores.masked_fill(mask, float('-inf'))
attn = F.softmax(scores, dim=-1)
context = torch.einsum('bqk,bkd->bqd', attn, v)
return context, attnThe multi-head version uses einops.rearrange to split the model dimension into heads, run attention per head, then merge back:
import torch.nn as nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
assert d_model % n_heads == 0
self.n_heads = n_heads
self.qkv = nn.Linear(d_model, 3 * d_model)
self.out = nn.Linear(d_model, d_model)
def forward(self, x, causal=False):
# x: (b, t, d_model)
qkv = self.qkv(x)
q, k, v = qkv.chunk(3, dim=-1)
# split heads: (b, t, (h d)) -> (b, h, t, d)
q, k, v = (rearrange(t, 'b t (h d) -> b h t d', h=self.n_heads) for t in (q, k, v))
d = q.shape[-1]
scores = torch.einsum('bhqd,bhkd->bhqk', q, k) / (d ** 0.5)
if causal:
T = scores.shape[-1]
mask = torch.triu(torch.ones(T, T, device=x.device, dtype=torch.bool), diagonal=1)
scores = scores.masked_fill(mask, float('-inf'))
attn = torch.softmax(scores, dim=-1)
ctx = torch.einsum('bhqk,bhkd->bhqd', attn, v)
# merge heads back: (b, h, t, d) -> (b, t, (h d))
ctx = rearrange(ctx, 'b h t d -> b t (h d)')
return self.out(ctx)Shape discipline
Keeping named axes with einops (
b h t d) makes the head split/merge unambiguous and removes error-pronetranspose/viewchains.
Related
Self-attention is the core operation of Transformers. Because it is permutation-equivariant, order information must be injected via Positional Encoding. Masked self-attention drives Decoder-Only Transformers, and caching past keys and values via the KV Cache avoids recomputing attention over the whole prefix at every generation step.