Attention is All You need
Review and implementation of the 2017 transformers paper.
Hey, welcome back! Today, we’re going to dive into one of the most influential papers in the history of AI, Attention Is All You Need. We’ll look at the Transformer architecture introduced by this paper in depth and understand the reasoning behind its design choices.
Transformers are neural networks that take sequential data as input and transform it into another sequence as output. The architecture is divided into two main parts: the encoder and the decoder.
But people had already used encoder-decoder approaches before, so what unique problem did Transformers solve?

When dealing with sequential data using recurrent models, in order to compute the next output, information from previous steps needs to be carried through the sequence. This creates several disadvantages:
- Higher sequential computation cost
- Less parallelization and inefficient use of GPU resources
- Difficulty learning relationships between tokens that are far apart in long sequences
Transformers dropped recurrence and instead compute relationships between tokens primarily through attention. Because tokens within a layer can be processed in parallel, this makes much better use of modern hardware.

Transformers are composed of two main parts:
- Encoder: Takes our input and outputs a matrix representation of that input.
- Decoder: Takes the encoded representation and autoregressively generates an output sequence.

Both contain a stack of N layers (N = 6 in the original paper).

Encoder
The encoder is composed of two main subcomponents: Multi-Head Attention (MHA) and a feed-forward network. Each sublayer additionally contains a residual connection followed by layer normalization:
LayerNorm(x + Sublayer(x))
Now, let’s dive deeper and see what is happening inside each component.

Input Embeddings
These are vectors of size 512 in the original Transformer, representing the information encoded in each token.
For the encoder, these embeddings are generated from the input tokens. For the decoder, embeddings are generated from the previously produced output tokens.

Positional Encoding
By nature, Transformers don’t have an inherent understanding of the order of the input like RNNs do. Since self-attention processes tokens without recurrence, we need to encode the positional information of the tokens in some way.
There are many ways to do this, including learned positional embeddings, where we start from randomly initialized embeddings and learn them during training.
The original paper instead uses sinusoidal positional encodings, which are combinations of sine and cosine functions. The authors hypothesized that this could make it easier for the model to learn to attend by relative positions and potentially extrapolate to sequence lengths longer than those encountered during training.

import torchimport torch.nn as nnimport math
class LearnedPositionalEncoding(nn.Module): def __init__(self, max_len, d_model): super().__init__() self.emb = nn.Embedding(max_len, d_model)
def forward(self, x: torch.Tensor): _, seq_len, _ = x.shape
pos = torch.arange(seq_len, device=x.device)
pos_emb = self.emb(pos)
# [seq_len, d_model] -> [1, seq_len, d_model] return x + pos_emb.unsqueeze(0)
class SinusoidalPositionalEncoding(nn.Module): def __init__(self, max_len, d_model): super().__init__()
pos_enc = torch.zeros(max_len, d_model)
# [max_len, 1] pos = torch.arange(max_len, dtype=torch.float32).unsqueeze(1)
# [ceil(d_model / 2)] div_term = torch.exp( torch.arange(0, d_model, 2, dtype=torch.float32) * (-math.log(10000.0) / d_model) )
pos_enc[:, 0::2] = torch.sin(pos * div_term)
# Handle odd d_model safely pos_enc[:, 1::2] = torch.cos(pos * div_term[: pos_enc[:, 1::2].shape[1]])
self.register_buffer("pos_enc", pos_enc)
def forward(self, x: torch.Tensor): _, seq_len, _ = x.shape
return x + self.pos_enc[:seq_len].unsqueeze(0)Multi-Head Self-Attention Mechanism
Self-attention takes the positionally encoded representations and projects them into three different vectors: the Query (Q), Key (K), and Value (V) vectors.
We then perform scaled dot-product attention to compute how each token relates to the other tokens.



One advantage of self-attention is that it reduces the path length between tokens. Instead of information having to travel through many sequential steps, tokens can directly attend to one another, making long-range dependencies easier to learn.
There are multiple ways to compute attention, including additive attention and dot-product attention. While they have similar theoretical complexity, dot-product attention is generally faster and more space-efficient in practice because it can take advantage of highly optimized matrix multiplication.

The problem with using dot-product attention without scaling is that when the key dimension dₖ becomes large, the dot products can grow large in magnitude. This pushes the softmax into regions where its gradients become extremely small.
To address this, we scale the dot products by the square root of the key dimension:

We then apply softmax to convert the scaled attention scores into probability-like attention weights. These weights are multiplied by the Value (V) vectors to produce the final output of the attention head.
Instead of performing one attention operation across the entire embedding dimension, we split the representation across multiple attention heads. Each head performs attention in parallel on different learned projections of the input.
import mathimport torchimport torch.nn as nnimport torch.nn.functional as F
from causal_masking import causal_masking
class SingleHeadAttention(nn.Module): def __init__(self, d_model: int, d_k: int, dropout: float = 0.0): super().__init__()
self.q = nn.Linear(d_model, d_k, bias=False) self.k = nn.Linear(d_model, d_k, bias=False) self.v = nn.Linear(d_model, d_k, bias=False)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor): _, seq_len, _ = x.shape
# (batch, seq_len, d_k) Q = self.q(x) K = self.k(x) V = self.v(x)
# 1 / sqrt(d_k) scale = 1 / math.sqrt(Q.size(-1))
# (B, S, d_k) @ (B, d_k, S) # -> (B, S, S) attn = torch.matmul(Q, K.transpose(-2, -1)) * scale
# causal_masking returns (1, 1, S, S) mask = causal_masking(seq_len, device=x.device)
# Remove the extra head dimension: # (1, 1, S, S) -> (1, S, S) mask = mask.squeeze(1)
attn = attn.masked_fill(mask, float("-inf"))
# Attention probabilities weight = F.softmax(attn, dim=-1)
weight = self.dropout(weight)
# (B, S, S) @ (B, S, d_k) # -> (B, S, d_k) outp = torch.matmul(weight, V)
return outp, weightFinally, the outputs of all the heads are concatenated and passed through another linear projection.

import torchimport torch.nn as nnimport torch.nn.functional as funfrom causal_masking import causal_maskingimport math
class MultiHeadAttention(nn.Module): def __init__(self, d_model: int, n_head: int, dropout: float = 0.0): super().__init__() if d_model % n_head == 0: self.n_head = n_head self.d_head = d_model // n_head self.qkv = nn.Linear(d_model, 3 * d_model, bias=False) self.proj = nn.Linear(d_model, d_model, bias=False) self.dropout = nn.Dropout(dropout) else: print("d_model and n_head are incompatable")
def forward(self, x: torch.Tensor): batch_s, seq_len, emb_dim = x.shape qkv = self.qkv(x) qkv = qkv.view(batch_s, seq_len, 3, self.n_head, self.d_head) q, k, v = qkv.unbind(dim=2) q = q.transpose(1, 2) k = k.transpose(1, 2) v = v.transpose(1, 2) scale = 1 / math.sqrt(self.d_head) attn = torch.matmul(q, k.transpose(-2, -1)) * scale mask = causal_masking(seq_len, device=x.device) attn = attn.masked_fill(mask, float("-inf")) weight = fun.softmax(attn, dim=-1) weight = self.dropout(weight) ctx = torch.matmul(weight, v) out = ctx.transpose(1, 2).contiguous().view(batch_s, seq_len, emb_dim) out = self.proj(out) return out, weightNormalization and Residual Connections
To help stabilize training and improve gradient flow, the original Transformer applies a residual connection followed by layer normalization around each sublayer.
A residual connection takes the input to a sublayer, lets the sublayer transform it, and then adds the original input back:
LayerNorm(x + Sublayer(x))
This allows information and gradients to flow more easily through the deep network.

Feed-Forward Network
Each encoder layer also contains a position-wise feed-forward network composed of two linear transformations with a ReLU activation between them:
FFN(x) = max(0, xW₁ + b₁)W₂ + b₂
The same feed-forward network is applied independently to every token position.

The output of the encoder will be a rich contextual representation of the input sequence, which will then be used by the decoder.
import torch.nn as nnimport torch
class FFN(nn.Module): def __init__(self, d_model, mult: int, dropout: float = 0.0): super().__init__() self.network = nn.Sequential( nn.Linear(d_model, d_model * mult), nn.GELU(), nn.Linear(d_model * mult, d_model), nn.Dropout(dropout), )
def forward(self, x: torch.Tensor): return self.network(x)Decoder
The decoder transforms the rich contextual representations produced by the encoder into an output sequence.
It contains similar Multi-Head Attention and Feed-Forward Network components, but it also introduces masked multi-head self-attention.
The decoder additionally contains encoder-decoder attention (cross-attention), where the decoder attends to the output representations produced by the encoder.

Masked Self-Attention Mechanism
In order to preserve the autoregressive nature of generation and make sure we predict the next token using only the tokens that came before it, we mask attention to future positions.
This prevents a token from attending to tokens that have not yet been generated.

import torch
def causal_masking(seq_len: int, device=None): mask = torch.triu( torch.ones((seq_len, seq_len), dtype=torch.bool, device=device), diagonal=1 ) return mask.view(1, 1, seq_len, seq_len)Linear Classifier and Softmax
After the representation passes through the decoder stack, we use a final linear layer followed by softmax to produce a probability distribution over the vocabulary for the next token.
During generation, the next token can then be selected according to a decoding strategy such as greedy decoding, sampling, or top-k sampling.

import torchimport torch.nn as nnfrom ffn import FFNfrom multi_head import MultiHeadAttention
class TransformerBlock(nn.Module): def __init__(self, d_model: int, n_head: int, dropout: float = 0.0): super().__init__() self.first = nn.LayerNorm(d_model) self.attn = MultiHeadAttention(d_model, n_head, dropout) self.second = nn.LayerNorm(d_model) self.ffn = FFN(d_model, mult=4, dropout=dropout)
def forward(self, x: torch.Tensor): x = x + self.attn(self.first(x))[0] x = x + self.ffn(self.second(x)) return xReferences
Original paper: Vaswani, A., Shazeer, N., Parmar, N., et al. (2017). Attention Is All You Need. Advances in Neural Information Processing Systems (NeurIPS), 30.
image source: DataCamp How Transformers Work
Code implementation: https://github.com/yeabwang/transformer