Self-Attention

To understand how self-attention works, let's use a simplified example.

Suppose we have a sequence of tokens, and that our embedding dimension is 2; in real LLMs the embedding dimension is many thousands of dimensions, but here we will use 2 so that we can see what is going on.

Our example sequence

Let's say our sequence of tokens is:

the dog fetched the ball and she was happy

and that, after embedding, our sequence of tokens is:

Token embeddings (2D)
dim 0dim 1
the0.100.00
dog0.900.90
fetched0.200.10
the0.100.00
ball0.900.15
and0.050.05
she0.250.90
was0.200.10
happy0.300.35

Notice the two the tokens get identical embeddings — same token, same vector.

As explained on the previous page, the idea behind self-attention is that we want to enrich each of these embedded tokens with information from the other tokens, so that we can capture the interactions between tokens. In this simple case, understanding the meaning of the token she relies on capturing information from the token dog.

Three projections

To implement self-attention, we project each token 3 times, using 3 linear projections, called W_query, W_key, and W_value.

These projections are linear neural network layers, and, again for simplification, let's suppose they take our 2 dimensional token embeddings, and project into 3 dimensional space:

import torch.nn as nn

W_query = nn.Linear(2, 3, bias=False)   # initialised with random weights
W_key   = nn.Linear(2, 3, bias=False)   # initialised with random weights
W_value = nn.Linear(2, 3, bias=False)   # initialised with random weights

Note that these are initialised with random weight parameters, which will be optimised during training. For the purposes of this toy example, we'll construct some specific projections that mimic where the weights might end up after training, so that we can illustrate the concepts involved:

W_query012
dim 01.2-0.30.5
dim 12.61.70.9
W_key012
dim 03.00.41.6
dim 10.23.00.5
W_value012
dim 00.9-0.40.6
dim 10.31.10.5

The intuition behind the projections

To understand the intuition behind the function of these three projections, we can think of the attention process as each token querying for specific information from the other tokens. In our toy example, after training, our token she might make the following query:

tokens  →  W_query  →
the
dog
fetched
the
ball
and
she"I'm a pronoun, looking for my referent"
was
happy

Here every token makes a query, but we are just focussing on the one token of interest, she. The W_query projection is responsible for this; it projects to a representation which "advertises" the desire to find a she-matching referent.

The W_key is the counterpart to this; it projects to a representation which "advertises" features that may want to be matched again. In this example, it could take the form:

tokens  →  W_key  →
the
dog"I am a potential referent"
fetched
the
ball"I am a potential referent"
and
she
was
happy

Computing attention scores

We use these two projections to compute attention scores as follows:

attention_scores = W_query(x) @ W_key(x).T

First we project every token with W_query and W_key to get its query and key vectors:

queries = X @ W_query  ·  keys = X @ W_key
query012
the0.12-0.030.05
dog3.421.261.26
fetched0.500.110.19
the0.12-0.030.05
ball1.47-0.020.58
and0.190.070.07
she2.641.460.94
was0.500.110.19
happy1.270.510.46
key012
the0.300.040.16
dog2.883.061.89
fetched0.620.380.37
the0.300.040.16
ball2.730.811.52
and0.160.170.11
she0.932.800.85
was0.620.380.37
happy0.971.170.66

Taking she's query and its dot product with every token's key — for example against dog and ball:

score(she, dog)  = [2.64, 1.46, 0.94] · [2.88, 3.06, 1.89]
                 = 2.64×2.88 + 1.46×3.06 + 0.94×1.89  =  13.8

score(she, ball) = [2.64, 1.46, 0.94] · [2.73, 0.81, 1.52]
                 = 2.64×2.73 + 1.46×0.81 + 0.94×1.52  =   9.8

Doing that for all nine keys gives the score row for she:

Attention scores for query = "she"
thedogfetchtheballandshewashappy
she 1.0 13.8 2.5 1.0 9.8 0.8 7.3 2.5 4.9

The scores for dog (13.8) and ball (9.8) are both well above the rest, with dog highest.

Note that, because dog and ball are potential referents to the pronoun she, the attention scores for these two tokens are high for the input token she. In this case, the reason for this is because we hand-picked our projections that way, but in a real LLM, this type of relationship can be expected to be discovered during training.

From scores to attention weights

Next we scale these scores so that they all add up to 1, and call these the attention weights. Doing this is needed to stabilise training and increase likelihood of convergence to a good model:

attention_weights = torch.softmax(attention_scores / 3**0.5, dim=-1)

Here, softmax is a widely used scaling function in machine learning. Note that we also scale the scores before applying softmax, by dividing them by the square root of the dimension of the space. This is a measure that helps to avoid outlying weights being boosted too much by softmax. Applied to she's score row, we get:

Attention weights for query = "she"
the 0.001 dog 0.883 fetched 0.001 the 0.001 ball 0.087 and 0.000 she 0.021 was 0.001 happy 0.005

Softmax concentrates almost all of the weight (0.883) onto dog, with ball a distant second (0.087). The weights sum to 1.

Causal attention

In the example so far, every token attends to every other token — including ones that come later in the sentence. But an LLM generates text one token at a time, from left to right: when it is processing a given token, the tokens that come after it don't exist yet. So each token must only attend to itself and the tokens before it.

We enforce this with a causal mask. Laying the attention weights out as a grid — one row per query token, one column per key token — we block out every cell above the diagonal, so no token can attend to a later one:

Causal mask — each row attends only up to the diagonal
thedogfetchtheballandshewashappy
the××××××××
dog×××××××
fetched××××××
the×××××
ball××××
and×××
she××
was×
happy

Amber cells are allowed — a token may attend to itself and to earlier tokens. Grey (×) cells are masked out, so no token can attend to one that comes after it.

In practice we apply the mask to the scores just before the softmax, setting every masked position to -inf so that softmax sends its weight to 0 (and the surviving weights still sum to 1):

# block attention to future tokens (everything above the diagonal)
mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
attention_scores = attention_scores.masked_fill(mask, float('-inf'))

For our query she, this zeroes out the (already tiny) weights on the later tokens was and happy; for a token near the start, like the first the, it leaves nothing to attend to but itself.

The W_value projection

Finally, we come to the W_value projection. We use W_value to project the input, and sum over it using the attention weights we just calculated:

result = attention_weights @ W_value(x)

Here are the value vectors, and the output for she — a weighted sum that is 0.883 × dog + 0.087 × ball + …:

values = X @ W_value, and the output for "she"
value012
the0.09-0.040.06
dog1.080.630.99
fetched0.210.030.17
the0.09-0.040.06
ball0.86-0.200.61
and0.060.040.06
she0.490.890.60
was0.210.030.17
happy0.380.270.35
output012
she1.040.560.94
dog1.080.630.99

The output for she lands almost exactly on dog's value vector — she has been enriched with the meaning of dog.

So we used W_query and W_key to understand how important each input token is to each other, then we use W_value to extract the meaning of the result in accordance to the weights.

Summary

In plain English, we can think of the whole process as follows. Our input:

"The dog fetched the ball and she was happy"

Below we can see what we've learned so far in a simple implementation that could be plugged into a neural network like an LLM:

import torch
import torch.nn as nn


class SelfAttention(nn.Module):

    def __init__(self, input_dimension, output_dimension):  # in our example the dimensions were 2 and 3
        super().__init__()
        self.W_query = nn.Linear(input_dimension, output_dimension, bias=False)
        self.W_key   = nn.Linear(input_dimension, output_dimension, bias=False)
        self.W_value = nn.Linear(input_dimension, output_dimension, bias=False)

    def forward(self, x):
        keys    = self.W_key(x)
        queries = self.W_query(x)
        values  = self.W_value(x)

        attention_scores = queries @ keys.T

        # causal mask: a token can only attend to itself and earlier tokens
        seq_len = x.shape[0]
        mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
        attention_scores = attention_scores.masked_fill(mask, float('-inf'))

        attention_weights = torch.softmax(
            attention_scores / keys.shape[-1] ** 0.5, dim=-1
        )

        result = attention_weights @ values
        return result

What's next

The above example covers the main concept behind self-attention, but it is a slightly simplified version. In the next sections we will look at the final pieces which will give us fully-fledged self attention: