Skip to content

Module 02 · Chapter 4

Self-Attention: Queries, Keys, and Values

Build self-attention in PyTorch: Q/K/V projections, scaled dot-product scores, stable softmax, weighted value aggregation, verified against torch.nn.functional.scaled_dot_product_attention.

Intermediate14 min readUpdated Nov 1, 2026

Learning objectives

  • Derive Q, K, V projections from input embeddings with explicit tensor shapes
  • Implement scaled dot-product attention scores with 1/sqrt(d_k) scaling
  • Implement stable softmax (subtract row max before exp) and verify no NaN
  • Compute attention output as weighted sum of value vectors
  • Check the implementation against torch.nn.functional.scaled_dot_product_attention

Before you start

  • transformer-architecture (lesson 1): tensor shape trace from token IDs to logits
  • tokenisation-and-embeddings (lesson 2): embedding lookup producing [B, T, d]

A transformer block moves information between positions. Self-attention is the mechanism that decides, for each position, how much to look at every other position — including itself. This lesson builds it from scratch in PyTorch: project inputs into queries, keys, and values; compute scaled dot-product scores; normalize with stable softmax; and aggregate values. We verify the implementation against PyTorch’s own scaled_dot_product_attention.

Self-attention projects each position’s embedding into a query, key, and value; compares every query with every key via scaled dot products; normalizes with softmax; and returns a weighted sum of all values.

Everything else in this lesson makes that concrete, with shapes tracked at every step.

Lab: Run the companion CPU exercise at /labs/track1/01-attention/ (shapes, masks, PyTorch parity).

A decoder-only transformer processes a sequence of embeddings X of shape [B, T, d]. Self-attention projects each position into three vectors:

Tensor Shape Meaning
Q = X @ W_Q [B, T, d_k] Query: “what should I pay attention to?”
K = X @ W_K [B, T, d_k] Key: “what information do I offer?”
V = X @ W_V [B, T, d_v] Value: “what information do I contribute?”

W_Q and W_K have shape [d, d_k]; W_V has shape [d, d_v]. In the single-head case used here, d_k = d_v = 4 while d = 8; in multi-head attention (lesson 6), d_k = d_v = d / h.

Compare every query with every key:

scores = Q @ K^T / sqrt(d_k) # shape [B, T, T]

Position (i, j) of the scores matrix measures how much position i should attend to position j. The 1/sqrt(d_k) factor prevents dot products from growing too large in magnitude — without it, large scores push softmax into regions with vanishing gradients (see the failure modes table below).

Normalize each row of scores into a probability distribution:

attn_weights = softmax(scores, dim=-1) # shape [B, T, T]

Naive exp(x) / sum(exp(x)) overflows when scores are large. Stable softmax subtracts the row maximum first:

scores_shifted = scores - max(scores, dim=-1, keepdim=True)
attn_weights = exp(scores_shifted) / sum(exp(scores_shifted), dim=-1, keepdim=True)

This is mathematically identical but numerically safe. Every row of attn_weights sums to 1.

The output at each position is a weighted sum of all value vectors:

output = attn_weights @ V # shape [B, T, d_v]

Position i‘s output is a convex combination of all positions’ values, where the weights come from how much position i attends to each position j. Positions that attend more to themselves will have output close to their own value; positions that attend to earlier context will reflect that context.

5. Fully worked numeric example (verified, seed 42)

Section titled “5. Fully worked numeric example (verified, seed 42)”

Setup: B = 1, T = 4, d = 8, d_k = 4, d_v = 4, torch.manual_seed(42), torch.set_default_dtype(torch.float32).

import torch
torch.manual_seed(42)
torch.set_default_dtype(torch.float32)
X = torch.randn(1, 4, 8)
W_Q = torch.randn(8, 4)
W_K = torch.randn(8, 4)
W_V = torch.randn(8, 4)
Q = X @ W_Q # [1, 4, 4]
K = X @ W_K # [1, 4, 4]
V = X @ W_V # [1, 4, 4]
scores = Q @ K.transpose(-2, -1) / (4 ** 0.5) # [1, 4, 4]
scores_max = scores.max(dim=-1, keepdim=True)[0]
scores_shifted = scores - scores_max
exp_scores = torch.exp(scores_shifted)
attn_weights = exp_scores / exp_scores.sum(dim=-1, keepdim=True) # [1, 4, 4]
output = attn_weights @ V # [1, 4, 4]

Step 1 — Projections:

Q = [[[ -0.7142, -10.7618, 4.7928, 1.6591],
[ 5.9094, -2.2237, 2.2946, -1.0836],
[ -3.2318, 3.9874, -3.4993, 4.7026],
[ -3.5491, 3.5556, -3.1290, 1.0415]]]
K = [[[ 4.0115, 3.6513, 2.9872, 3.6108],
[ -3.4479, -0.5821, 1.8296, 2.9016],
[ 0.9276, -0.0589, 6.4627, 1.9089],
[ 1.3121, -0.1874, 0.3295, 1.9857]]]
V = [[[ -8.2489, -0.9326, 2.3788, -0.8355],
[ 4.1438, -1.3904, 2.1127, -1.3241],
[ -0.2973, 1.8336, 2.2672, 1.4077],
[ -0.8263, 3.7984, 3.3404, -0.7702]]]

Step 2 — Scores Q @ K^T / √4:

scores = [[[ -10.9254, 11.1552, 17.0567, 2.9766],
[ 9.2642, -9.0133, 9.1868, 3.3872],
[ 4.0607, 8.0325, -8.4355, 1.5988],
[ -3.4206, 3.7321, -10.8679, -2.1428]]]

Step 3 — Stable softmax (row-wise):

attn_weights = [[[ 7.02e-13, 2.73e-03, 9.97e-01, 7.65e-07],
[ 5.19e-01, 5.98e-09, 4.80e-01, 1.45e-03],
[ 1.85e-02, 9.80e-01, 6.91e-08, 1.57e-03],
[ 7.80e-04, 9.96e-01, 4.55e-07, 2.80e-03]]]

Each row sums to 1.0 (within floating-point precision).

Step 4 — Output attn_weights @ V:

output = [[[ -0.2852, 1.8248, 2.2668, 1.4002],
[ -4.4217, 0.4019, 2.3266, 0.2412],
[ 3.9072, -1.3738, 2.1196, -1.3142],
[ 4.1202, -1.3755, 2.1164, -1.3222]]]

Verification: Matches torch.nn.functional.scaled_dot_product_attention(Q, K, V, is_causal=False, scale=0.5) with torch.nn.attention.sdpa_kernel(backends=[torch.nn.attention.SDPBackend.MATH]) to max_diff < 1e-6 (PyTorch 2.5, CPU, float32).

Self-attention mechanism

Self-attention mechanism: Q/K/V, scaled scores, softmax, weighted valuesFour panels. Panel A: input X [B, T, d] projects to Q [B, T, d_k], K [B, T, d_k], V [B, T, d_v] via separate weight matrices. Panel B: scores = Q @ K^T / sqrt(d_k) produces a [B, T, T] attention score matrix. Panel C: stable softmax converts scores to attention weights where each row sums to 1. Panel D: attention weights @ V produces output [B, T, d_v] as weighted sum of value vectors.Panel A: Q/K/V projectionsInput X[B, T, d] = [1, 4, 8]W_Q · W_K · W_V[d, d_k], [d, d_k], [d, d_v]Q[1, 4, 4]K[1, 4, 4]V[1, 4, 4]Panel B: ScoresQ @ K^TQ [1,4,4] @ K^T [1,4,4]÷ sqrt(d_k)scale = 1/sqrt(4) = 0.5Scores[B, T, T] = [1, 4, 4]scores[i, j] = how much pos i attends to pos jEach row: raw attention logits before softmaxPanel C: Stable softmaxscores - row_maxSubtract max before expexp(shifted) / sum(exp(shifted))Row-wise normalizationAttention weights[B, T, T] = [1, 4, 4]Each row sums to 1Weights = convex combination coefficientsPanel D: Outputattn_weights @ V[1, 4, 4] @ [1, 4, 4]Weighted sum of valuesOutput[B, T, d_v] = [1, 4, 4]Context-aware representation per position
Q/K/V projections, scaled dot-product scores, stable softmax, and weighted value aggregation, with tensor shapes at each step.
Read this diagram as text

Text equivalent. Panel A: input X [B, T, d] projects to Q, K, V via separate weight matrices. Panel B: scores = Q @ K^T / sqrt(d_k) produces a [B, T, T] attention score matrix. Panel C: stable softmax converts scores to attention weights where each row sums to 1. Panel D: attention weights @ V produces the output [B, T, d_v] as a weighted sum of value vectors.

import torch
import torch.nn as nn
import torch.nn.functional as F
class SelfAttention(nn.Module):
def __init__(self, d_model, d_k, d_v, bias=False):
super().__init__()
self.W_Q = nn.Parameter(torch.randn(d_model, d_k))
self.W_K = nn.Parameter(torch.randn(d_model, d_k))
self.W_V = nn.Parameter(torch.randn(d_model, d_v))
self.scale = 1.0 / (d_k ** 0.5)
# Note: some implementations (e.g., GPT-2) use bias=False for Q/K/V projections.
# PyTorch's nn.Linear defaults to bias=True; set bias=False to match.
def forward(self, X):
Q = X @ self.W_Q # [B, T, d_k]
K = X @ self.W_K # [B, T, d_k]
V = X @ self.W_V # [B, T, d_v]
scores = Q @ K.transpose(-2, -1) * self.scale # [B, T, T]
# Stable softmax
scores_max = scores.max(dim=-1, keepdim=True)[0]
scores_shifted = scores - scores_max
exp_scores = torch.exp(scores_shifted)
attn_weights = exp_scores / exp_scores.sum(dim=-1, keepdim=True)
output = attn_weights @ V # [B, T, d_v]
return output, attn_weights

Numerical check: Compare against F.scaled_dot_product_attention(Q, K, V, is_causal=False, scale=scale) using torch.nn.attention.sdpa_kernel(backends=[torch.nn.attention.SDPBackend.MATH]) for deterministic CPU execution. Max difference should be below 1e-6 (verified with seed 42, float32).

Failure mode Symptom Root cause Test
Missing scale One attention weight dominates (~1.0); training unstable Q @ K^T without 1/sqrt(d_k) yields large dot products, pushing softmax to extremes Check scores.abs().max() is reasonable (not >> 10)
Unstable softmax NaN gradients; loss blows up exp(scores) overflows for large scores Verify with large-magnitude inputs; check no NaN in attn_weights
Wrong transpose Shape mismatch or silently wrong attention Q @ K instead of Q @ K^T, or wrong transpose dims Assert scores.shape == [B, T, T]
Value/query mismatch Output doesn’t reflect input content Using Q instead of V in final matmul Assert output.shape == [B, T, d_v]
  • Causal masking (preventing position i from attending to positions > i): lesson 5, causal-attention-masks.
  • Multi-head attention (splitting into h heads and merging): lesson 6, multi-head-attention.
  • Dropout, training loop, optimizer: covered in later training-and-debugging lessons.
  • Grouped/multi-query attention: covered in advanced modules.
Exercise Prompt
1 Given X of shape [1, 3, 6], W_Q of shape [6, 2], W_K of shape [6, 2], W_V of shape [6, 2], what are the shapes of Q, K, V, scores, attn_weights, and output?
2 If d_k = 64 and the unscaled scores have max magnitude 500, what happens to naive softmax? What does stable softmax produce instead?
3 Modify the reference implementation to return attention weights for only the first query position (row 0 of attn_weights). What does this row represent?
Source Relevance
Vaswani et al., “Attention Is All You Need” (arXiv:1706.03762), §3.2.1 Original scaled dot-product attention: Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V
PyTorch F.scaled_dot_product_attention docs (v2.5+) Reference API: query/key/value shapes, scale parameter, is_causal flag, SDP backends
PyTorch source torch/nn/functional.py (search scaled_dot_product_attention) Reference implementation: attn_weight = query @ key.transpose(-2, -1) * scale_factor, then softmax, then @ value

Sign in to unlock this chapter

“Self-Attention: Queries, Keys, and Values” is part of the int8ai curriculum. Primers are freely accessible to everyone without login. Free reader accounts unlock all chapters, code labs, and progress syncing.

or sign in with email