Simple Self-Attention Without Trainable Weights
SelfAttention_v2 builds its query, key, and value projections with nn.Linear layers, configured by d_in, d_out, and qkv_bias.
Reading the Class Before Running It
This article starts with code rather than theory. SelfAttention_v2 is an improved implementation of self-attention in PyTorch. Instead of managing its three projection matrices by hand, it builds them from PyTorch's Linear layers. The approach is to read the class once, predict what a call to it returns, run it, and then use the explanation to make sense of the printed result.
What do you think happens?
The class is called on an input tensor x that uses the same d_in and d_out as the earlier self-attention example. What does the call sa_v2(inputs) return? Think about how many rows it has, how many columns, and what the last dimension is tied to.
Reveal answer
A tensor with one row per input token and d_out columns. Its last dimension is set by d_out.
The context vector is built as attention weights multiplied by values. The values come from a projection to d_out, so the last dimension of the output is d_out. The attention weights form a square matrix with one row and one column per token, so the number of rows of the output matches the number of rows of the queries, which is the number of input tokens. The exact printed numbers depend on the random seed and inputs, but the shape follows from the layer dimensions.
The Constructor and Its Three Linear Layers
The constructor takes d_in and d_out, which are the input and output dimensions, and an optional qkv_bias flag that defaults to False. From these arguments it creates three Linear layers, one each for queries, keys, and values. Each layer maps an input of size d_in to an output of size d_out. Because all three layers share the same dimensions, the queries, keys, and values all have width d_out.
The bias argument, qkv_bias, is passed straight through to each of the three Linear layers. The same setting therefore applies to all three projections at once. The class does not allow the query projection and the value projection to have different bias settings.
The three projections are the only place where the input x is transformed into queries, keys, and values. Everything after the constructor works on those three outputs, so reading the constructor tells you which layers exist and what size each one maps between.
Tracing the Data Path
The forward method runs in a fixed order. It first projects the input x three times, producing keys, queries, and values. It then computes raw attention scores by multiplying the queries with the transpose of the keys. Those scores are divided by the square root of the key dimension, which is read from keys.shape[-1], and a softmax is applied along the last dimension. The result is the attention weights. Finally, the attention weights are multiplied by the values, and the product is the context vector that the method returns.
Attention Weights as a Score Matrix
The raw scores are produced by multiplying queries with the transpose of keys. Each query row is compared against each key column, so the result is a square matrix. If the input has T tokens, the score matrix is T by T. The entry in row i and column j measures how strongly token i's query matches token j's key. Each row therefore describes one token's view of every token in the input, including itself.
Here $d_k$ is the key dimension, read from keys.shape[-1]. The division by the square root of $d_k$ scales the raw scores before the softmax. The softmax is taken along the last dimension, so each row of weights is normalized on its own and each row describes how one token distributes its attention across all tokens.
From Attention Weights to the Context Vector
The final step multiplies the attention weights by the values. Each row of the attention weights picks out a mixture of the value vectors. The row for token i gives one weight to every value vector, and the context vector for token i is the weighted sum of those value vectors. Value vectors that receive larger weights contribute more to that token's context vector. One context vector is produced per input token.
The context vector is built from the values projection, which is why the output width is set by d_out. The queries and keys determine how much each value counts. The values determine what gets carried into the output.
Predicting the Output Shape
Shape of the output for six tokens
An input x has 6 tokens, each with d_in equal to 3. The layer is built with d_out equal to 2. What shape does the call sa_v2(x) return?
- 1Count the input tokens
The input has 6 rows, one per token, so queries, keys, and values each have 6 rows.
- 2Build the score matrix
Queries multiplied by the transpose of keys gives a 6 by 6 score matrix, and the softmax keeps that shape.
- 3Read the value width
Each value vector has width
d_out, which is 2. - 4Multiply weights by values
A 6 by 6 weight matrix times a 6 by 2 value matrix gives a 6 by 2 context matrix.
The output has shape 6 by 2: one context vector of width 2 for each of the 6 tokens. The printed numbers would differ with a different seed or different inputs, but the shape would not change as long as d_out stays at 2.
Replacing Hand-Built Projections with nn.Linear
The earlier version handled its projections by hand, meaning the weight matrices were managed directly in the class. The Linear-layer version uses nn.Linear modules instead. Each module holds its own weight matrix that maps d_in inputs to d_out outputs, and it can optionally hold a bias term controlled by qkv_bias. The class no longer tracks these matrices itself. It creates three modules and calls them on x, and the modules handle the stored weights.
Mistakes Beginners Make
Common mistake Assuming the output has the same width as the input
What it looks likeReading an input with d_in equal to 3 and expecting a 3-column result.
Why it failsThe context vector is built from the values projection, which maps to
d_out.Do this insteadRead the last dimension of the output from d_out, not from d_in.
Common mistake Expecting the printed numbers to be the same on every run
What it looks likeChanging the seed and expecting the same printed context vectors.
Why it failsThe printed values are specific to the seed and the inputs.
Do this insteadExpect the shape to stay the same when d_out stays the same, and expect the numbers to change.
Common mistake Looking for queries and values to interact early in the forward pass
Why it failsQueries and keys meet in the score computation, and values meet the attention weights only at the end.
Do this insteadFollow the fixed order: projections, scores, scaled softmax, then weights times values.
Common mistake Treating the bias setting as separate for each projection, when qkv_bias applies to all three layers
Practice Tracing the Data Path
An input x has 4 tokens, d_in equal to 5, and d_out equal to 3. Without running the code, state the shape of the queries, the keys, the values, the raw score matrix, the attention weights, and the context vector. Then name which tensors meet in the score computation and which tensor is multiplied by the attention weights.
Hints
- Each projection maps
d_intod_out, so queries, keys, and values all have widthd_out. - The score matrix is square, with one row and one column per token.
- The context vector is the attention weights multiplied by the values.
Key Takeaways
- SelfAttention_v2 creates its query, key, and value projections with three nn.Linear layers, each configured by
d_in,d_out, and the sharedqkv_biasflag. - The forward method runs in a fixed order: projections, raw scores from queries times keys transpose, a scaled softmax over the last dimension, and finally weights times values to produce the context vector.
- The output width is set by
d_outbecause the context vector is built from the values projection, and the printed numbers depend on the seed and inputs while the shape does not. - The nn.Linear version replaces hand-built projection matrices with modules that store their own weights and an optional bias.