The Nightly Build.
Transformers from Scratch · Foundations

Self-Attention by Hand: Queries, Keys, and Values

Lesson two builds a single attention head from a plain average of past tokens, deriving queries, keys, values, the 1/√d_k scaling, and the causal mask in short, shape-annotated PyTorch.

Where lesson one left off

Lesson one ended with a single tensor. A batch of character sequences was tokenized to integer ids of shape (B, T), a token embedding table turned each id into a vector, a position embedding table added order, and the sum was x = tok_emb + pos_emb of shape (B, T, C) — our running character-level GPT on tiny-Shakespeare, with B the batch, T the time or sequence axis, and C the channel width we call d_model, fixed at 384 for this course. Every value in that tensor describes one token in isolation. Position 5 does not yet know that position 2 was a comma or that position 0 was a capital letter. The whole purpose of attention is to fix exactly that: to let each position gather information from the other positions before it.1

In this article
  • Derive attention as a content-dependent weighted average, starting from a plain mean over past tokens and adding one idea at a time.
  • Construct the query, key, and value projections with nn.Linear, and track every tensor as it moves through a head with its shape annotated at each step.
  • Explain, not assert, why the scores are divided by √d_k and why the causal mask is lower-triangular.
  • Write a single attention head as a short, runnable PyTorch module consistent with lesson one's (B, T, C) and C = 384.

The mechanism we build is the one Vaswani and colleagues named scaled dot-product attention in "Attention Is All You Need," and the concrete code follows the causal self-attention in Andrej Karpathy's nanoGPT, the same reference implementation lesson one drew its running example from.12 We stay with one head for the whole lesson; splitting a head into many is the next lesson's job.

Start with a plain average

Before attention, take the crudest thing that lets a token see its past: replace each token's vector with the average of itself and every token before it. It throws away word order within the window and weights every past token equally, so it is a poor model — but it is a real operation on the lesson-one tensor, and it is the seed the rest of the lesson grows from. This staged reveal, from a loop to a matrix multiply to learned weights, follows Karpathy's "Let's build GPT" walkthrough.3

averaging.pyPyTorch
import torch

B, T, C = 4, 8, 384                  # batch, time (sequence), channels (d_model)
x = torch.randn(B, T, C)             # stands in for tok_emb + pos_emb from lesson 1

# a plain causal average, written as the loop it literally is
xbow = torch.zeros(B, T, C)          # "bag of words": (B, T, C)
for b in range(B):
    for t in range(T):
        past = x[b, : t + 1]         # (t+1, C): this token and everything before it
        xbow[b, t] = past.mean(dim=0)  # (C,): mean over the time axis
Fig. 1 · Each position becomes the average of itself and its past. Order within the window is discarded; every past token counts the same.

The loop hides a piece of linear algebra. Averaging position t over positions 0..t is a matrix multiply by a lower-triangular matrix whose rows sum to one. Build a (T, T) matrix of ones on and below the diagonal, normalize each row by its own sum, and multiplying it into x produces the identical result — the same average, now as one batched operation instead of a double loop.3

averaging_matmul.pyPyTorch
w = torch.tril(torch.ones(T, T))       # (T, T): 1 on/below the diagonal, 0 above
w = w / w.sum(dim=1, keepdim=True)     # normalize each row so it sums to 1
xbow2 = w @ x                          # (T, T) @ (B, T, C) -> (B, T, C)

torch.allclose(xbow, xbow2)            # True: same average, one matmul
Fig. 2 · A causal average is a lower-triangular weight matrix applied to the sequence. Row t holds 1/(t+1) in its first t+1 columns and zero after.

Now the whole idea of attention is visible in one sentence: those weights do not have to be uniform. If row t could hold any non-negative numbers that sum to one, the output at position t would be a weighted average of the past, and if those numbers were computed from the tokens themselves, the average would be content-dependent. Position t could lean hard on the one past token that matters and ignore the rest. That is self-attention. The rest of the lesson is the machinery that turns the fixed 1/(t+1) weights of Fig. 2 into learned, per-pair weights.

Self-attention A layer that replaces each position's vector with a weighted average of the other positions' vectors, where the weights are computed from the content of the positions themselves and, in a decoder, are zero for the future.2

Queries, keys, and values

To make the weights content-dependent we give every token three learned roles, each a linear projection of its (C,) vector. The query is what a token is looking for. The key is what a token offers to others as a label for itself. The value is the information a token actually contributes if it is attended to. A query meets a key to decide how much; the value is the what that then gets averaged. These are not physical quantities in the data; they are useful roles, and each is produced by its own weight matrix W_q, W_k, W_v.2

In PyTorch each projection is an nn.Linear layer, which applies x @ W (we drop the bias, as attention projections conventionally do); its two arguments are the input width and the output width.4 The output width is the head size, written d_k in the paper — the dimension the query and key live in, and the length over which their dot product is taken. It is a hyperparameter, smaller than C; we use 16 here so the tensors stay small enough to reason about.

projections.pyPyTorch
import torch.nn as nn

head_size = 16                         # d_k: the width of one attention head
key   = nn.Linear(C, head_size, bias=False)   # W_k: (C, head_size)
query = nn.Linear(C, head_size, bias=False)   # W_q: (C, head_size)
value = nn.Linear(C, head_size, bias=False)   # W_v: (C, head_size)

k = key(x)     # (B, T, head_size): what each token offers
q = query(x)   # (B, T, head_size): what each token wants
v = value(x)   # (B, T, head_size): what each token would contribute
Fig. 3 · Three linear projections turn the shared (B, T, C) input into per-token queries, keys, and values, each of width d_k = 16.
Query, key, value (q, k, v) Three learned linear projections of a token's vector. The query q and key k share a width d_k so their dot product measures compatibility; the value v is what a matched token passes forward.2

The scaled dot-product

Compatibility between a query and a key is their dot product: it is large and positive when the two vectors point the same way, so it reads as "this key answers this query." Every query dotted against every key is a single matmul, q @ k transposed on its last two axes, giving a (T, T) grid of raw scores per batch element. Entry (i, j) is how much position i's query matches position j's key. These raw scores are the attention logits.2

scores.pyPyTorch
import math

scores = q @ k.transpose(-2, -1)       # (B, T, hs) @ (B, hs, T) -> (B, T, T)
scores = scores / math.sqrt(head_size) # divide by sqrt(d_k): the 1/sqrt(d_k) scaling
Fig. 4 · Raw attention logits are query-key dot products, one scalar per ordered pair of positions, then divided by √d_k.

A grid of logits is not yet a set of averaging weights; the weights must be non-negative and sum to one along each row. The softmax function delivers exactly that. For a row of logits z, softmax returns exp(z_j) / Σ exp(z): every output is positive because exp is positive, and the outputs sum to one because they share the denominator. PyTorch's softmax takes the dim to normalize over, and here that is the last axis, the keys.5 Applying it to each row of the score grid turns compatibilities into a proper weighted average over the values.

Why divide by √d_k first? Suppose the components of q and k are independent with mean 0 and variance 1. Their dot product Σ q_i k_i is a sum of d_k such terms, so it has mean 0 and variance d_k: its typical magnitude grows like √d_k. The paper states the consequence directly — "for large values of d_k, the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients" — and so it scales the logits by 1/√d_k to hold their variance near one.1 The picture is concrete: feed softmax logits like [8, 0] and it returns roughly [0.9997, 0.0003], a near one-hot vector whose gradient with respect to the logits is almost zero, and learning stalls. Dividing by √d_k keeps the logits in the range where softmax stays soft and its gradients stay usable.

The scaling is not cosmetic. Without it, a wide head saturates its own softmax and stops learning.

Masking the future

The score grid as built is dense: position i has a weight for every position j, including positions after it. For a model trained to predict the next token, that is cheating: position i would see its own future, and the task would be trivial at train time and impossible at generation time, when the future does not exist yet. A decoder must be causal: position i may attend only to positions 0..i. This is the same lower-triangular structure as the averaging matrix in Fig. 2, now imposed on learned scores.2

The trick is to set the forbidden scores to negative infinity before the softmax. Since exp(-inf) = 0, those positions receive exactly zero weight, and the surviving weights in each row still sum to one on their own. torch.tril gives the lower-triangular mask of allowed positions, and masked_fill writes float("-inf") everywhere the mask is zero.67

masking.pyPyTorch
import torch.nn.functional as F

tril = torch.tril(torch.ones(T, T))                    # (T, T): 1 where j <= i
scores = scores.masked_fill(tril == 0, float("-inf"))  # (B, T, T): future -> -inf
weights = F.softmax(scores, dim=-1)                    # (B, T, T): each row sums to 1
out = weights @ v                                       # (B, T, T) @ (B, T, hs) -> (B, T, hs)
Fig. 5 · Fill the strictly-upper triangle with -inf, then softmax. Row 0 attends only to token 0; row t spreads its weight over tokens 0..t.

Read the first row of the weight matrix after masking and it is [1, 0, 0, ...]: the first token has only itself to attend to, so its output is its own value unchanged. The last row is a full learned distribution over the whole sequence. Between them, each row t is a content-dependent weighted average over exactly the tokens at or before t — the Fig. 2 average, but with the weights now chosen by the data.

A single head, end to end

Assemble the pieces into one module and the whole head is a dozen lines. The forward pass reads as the five moves we derived: project to q, k, v; score with the scaled dot product; mask the future; softmax to weights; take the weighted average of the values. This mirrors nanoGPT's CausalSelfAttention, reduced to a single head and written for readability.2

head.pyPyTorch
class Head(nn.Module):
    """One causal self-attention head."""

    def __init__(self, C, head_size, block_size):
        super().__init__()
        self.key   = nn.Linear(C, head_size, bias=False)
        self.query = nn.Linear(C, head_size, bias=False)
        self.value = nn.Linear(C, head_size, bias=False)
        # a (block_size, block_size) lower-triangular buffer, not a parameter
        self.register_buffer("tril", torch.tril(torch.ones(block_size, block_size)))

    def forward(self, x):                       # x: (B, T, C)
        B, T, C = x.shape
        k = self.key(x)                          # (B, T, head_size)
        q = self.query(x)                        # (B, T, head_size)
        v = self.value(x)                        # (B, T, head_size)

        scores = q @ k.transpose(-2, -1)         # (B, T, T)
        scores = scores / k.shape[-1] ** 0.5     # scale by 1/sqrt(d_k)
        scores = scores.masked_fill(self.tril[:T, :T] == 0, float("-inf"))
        weights = F.softmax(scores, dim=-1)      # (B, T, T), rows sum to 1
        return weights @ v                       # (B, T, head_size)
Fig. 6 · The full head. Input (B, T, C) in, output (B, T, head_size) out. The tril buffer is sliced to the live sequence length T so the module works for any sequence up to block_size.

Every tensor's shape is annotated at the point it is made, and the shapes are the proof the code is right: (B, T, C) enters, three (B, T, head_size) projections come out, the score grid is (B, T, T), and the final weighted average returns to (B, T, head_size). In production this exact computation is one fused call, F.scaled_dot_product_attention(q, k, v, is_causal=True), which applies the same scaling and causal mask and runs a memory-efficient kernel; writing it by hand first is how you earn the right to trust the one-liner.8

  1. With x of shape (B=4, T=8, C=384) and head_size = 16, give the shape of q, k, v, scores, weights, and the head output. Which of these does not depend on head_size?
  2. Why is the mask lower-triangular rather than upper-triangular? State what would leak if you filled the lower triangle with -inf instead.
  3. A colleague removes the / math.sqrt(head_size) line and trains with head_size = 4096. Predict what happens to the softmax weights and to the gradients, and name the paper section that explains it.
  4. Hand-compute a length-2 head with head_size = 1. The scaled scores before masking are [[2, 6], [4, 4]] and the values are v = [[10], [20]]. Apply the causal mask, softmax each row, and give the two output scalars.
  5. In Fig. 6, why is tril registered as a buffer rather than created with nn.Parameter? What would go wrong if it were a parameter?

Go deeper

Read Section 3.2.1 of "Attention Is All You Need": it is the half-page that defines softmax(QK^T/√d_k)V and gives the variance argument for the scaling you just derived.1 Then open nanoGPT's model.py and read CausalSelfAttention; every line of Fig. 6 is there, and the parts this lesson deferred — reshaping into multiple heads and the output projection — are the few lines you cannot yet fully account for.2 To feel the mechanism in your hands, print the weights matrix from Fig. 5 for a real batch and confirm each row is lower-triangular and sums to one, then swap the hand-rolled block for F.scaled_dot_product_attention and check the outputs agree.8 For lineage, the idea of a learned, content-dependent weighting over a sequence predates the transformer: Bahdanau, Cho, and Bengio introduced an attention mechanism for machine translation in 2014, which "Attention Is All You Need" distilled into the pure dot-product form used here.9

Next lesson One head can track only one kind of relationship at a time: a single (T, T) weight matrix forces the whole sequence through one notion of relevance. "Multi-Head Attention: Many Views of One Sequence" runs several of these heads in parallel over slices of C, then concatenates and projects their outputs — letting one layer attend to syntax, position, and content at once. It consumes exactly the Head you built here.

Sources

  1. Vaswani et al. — "Attention Is All You Need" (§3.2.1 scaled dot-product attention; softmax(QK^T/√d_k)V; the 1/√d_k scaling and its small-gradient rationale)
  2. Karpathy — nanoGPT model.py (CausalSelfAttention: query/key/value linear projections, scaled scores, lower-triangular causal mask, softmax, weighted sum of values)
  3. Karpathy — "Let's build GPT: from scratch, in code, spelled out" (the averaging trick: a causal mean as a lower-triangular matrix multiply, generalized to learned attention weights)
  4. PyTorch — torch.nn.Linear documentation (applies y = xA^T + b; in_features and out_features; bias optional)
  5. PyTorch — torch.nn.functional.softmax documentation (rescales along dim so outputs are in [0, 1] and sum to 1)
  6. PyTorch — torch.tril documentation (lower-triangular part of a matrix; elements above the diagonal set to zero)
  7. PyTorch — torch.Tensor.masked_fill documentation (fills elements where a boolean mask is true with a given value, here float("-inf"))
  8. PyTorch — F.scaled_dot_product_attention documentation (fused scaled dot-product attention with is_causal masking; the production equivalent of Fig. 6)
  9. Bahdanau, Cho & Bengio — "Neural Machine Translation by Jointly Learning to Align and Translate" (the origin of learned, content-dependent attention weights over a sequence)