Back to Blog

The Transformer Architecture: Self-Attention Under the Hood

9 min read

Before the introduction of the Transformer architecture in 2017, Recurrent Neural Networks (RNNs) and Long Short-Term Memory networks (LSTMs) ruled sequential data processing. However, they had a fundamental limitation: they process tokens sequentially, one step at a time. This sequence-dependent processing makes parallelization impossible, limiting training speed on modern GPUs.

Transformers solved this with the Self-Attention mechanism. By looking at all words in a sequence simultaneously and calculating how much attention to pay to each, the Transformer achieved state-of-the-art results while enabling massive parallelization.

Transformer Architecture Breakdown: 3D block diagram of Transformer encoder-decoder architectureTransformer Architecture Breakdown: 3D block diagram of Transformer encoder-decoder architecture


1. The Core Idea: Query, Key, and Value

To understand self-attention, think of it like searching a database:

  • A Query (Q) represents the token currently being processed (what we are looking for).
  • A Key (K) represents all the tokens in the sequence (what we compare the query against).
  • A Value (V) represents the actual content of the tokens (what we retrieve).

For each input token vector x, we project it into Query, Key, and Value vectors using weight matrices W^Q, W^K, and W^V:

q = x W^Q, \quad k = x W^K, \quad v = x W^V

2. Scaled Dot-Product Attention

The similarity between a Query and a Key is calculated using their dot product. Higher values indicate that the tokens are highly related. The mathematical formulation of Scaled Dot-Product Attention is:

\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V

Where:

  • Q, K, V are the query, key, and value matrices.
  • d_k is the dimension of the key vectors.
  • \sqrt{d_k} is the scaling factor.

Why do we scale by \sqrt{d_k}?

For large values of d_k, the dot product grows large in magnitude. This pushes the softmax function into regions with extremely small gradients (vanishing gradients). Dividing by \sqrt{d_k} stabilizes training by keeping the values in a range where softmax has active gradients.

       Q Matrix       K^T Matrix          Attention Map           V Matrix       Final Output
      [  q1  ]   x   [ k1  k2 ]   --->   [ s11  s12 ]   x   [  v1  ]   --->   [   o1   ]
      [  q2  ]                           [ s21  s22 ]       [  v2  ]          [   o2   ]

3. Multi-Head Attention

Instead of performing self-attention once, the Transformer splits the queries, keys, and values into multiple subspaces, allowing the model to attend to information from different representation subspaces at different positions simultaneously.

The formulas for Multi-Head Attention are:

\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h) W^O
\text{head}_i = \text{Attention}(Q W_i^Q, K W_i^K, V W_i^V)

Where the projection matrices are:

  • W_i^Q \in \mathbb{R}^{d_{model} \times d_k}
  • W_i^K \in \mathbb{R}^{d_{model} \times d_k}
  • W_i^V \in \mathbb{R}^{d_{model} \times d_v}
  • W^O \in \mathbb{R}^{h d_v \times d_{model}}

4. PyTorch Implementation of Multi-Head Attention

Let's write a complete, raw implementation of Multi-Head Attention in PyTorch using tensor reshaping operations.

import torch
import torch.nn as nn
import math

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
        
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        # Projection matrices
        self.w_q = nn.Linear(d_model, d_model)
        self.w_k = nn.Linear(d_model, d_model)
        self.w_v = nn.Linear(d_model, d_model)
        self.w_o = nn.Linear(d_model, d_model)

    def forward(self, q, k, v, mask=None):
        batch_size = q.size(0)
        
        # 1. Project inputs and split into heads
        # Shape changes: (batch, seq_len, d_model) -> (batch, seq_len, num_heads, d_k) -> transpose to (batch, num_heads, seq_len, d_k)
        Q = self.w_q(q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        K = self.w_k(k).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        V = self.w_v(v).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        
        # 2. Compute Scaled Dot-Product Attention scores
        # (batch, num_heads, seq_len, d_k) x (batch, num_heads, d_k, seq_len) -> (batch, num_heads, seq_len, seq_len)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
        
        if mask is not None:
            # Mask out invalid padding or future tokens (causal masking)
            scores = scores.masked_fill(mask == 0, -1e9)
            
        attention_weights = torch.softmax(scores, dim=-1)
        
        # 3. Multiply attention weights by values
        # (batch, num_heads, seq_len, seq_len) x (batch, num_heads, seq_len, d_k) -> (batch, num_heads, seq_len, d_k)
        context = torch.matmul(attention_weights, V)
        
        # 4. Concatenate heads back and apply output projection
        # Transpose back: (batch, num_heads, seq_len, d_k) -> (batch, seq_len, num_heads, d_k)
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
        
        return self.w_o(context)

# Testing the implementation
batch_size, seq_len, d_model, num_heads = 2, 8, 64, 4
qkv_tensor = torch.randn(batch_size, seq_len, d_model)

mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
out = mha(qkv_tensor, qkv_tensor, qkv_tensor)
print("Output tensor shape:", out.shape)  # Expected: [2, 8, 64]

5. Positional Encoding

Because the Self-Attention mechanism computes attention across all tokens in parallel, it is completely invariant to sequence order. In other words, the sequence "the cat ate the fish" and "the fish ate the cat" would result in identical self-attention representations.

To restore sequence order, we inject Positional Encodings into the input token embeddings. These are vectors containing unique periodic signals based on sine and cosine functions:

PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)
PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right)

This mathematical formulation gives the model a unique, geometry-based positional coordinate for every token.

Conclusion

The Transformer architecture's shift away from recurrence to self-attention marked the start of the modern LLM era. By projecting representations into Queries, Keys, and Values, computing parallelizable dot-product weights, and layering multi-head tracking, it handles sequence data with unprecedented scale and speed.