Deep Learning

Implementing Transformers from Scratch in PyTorch

A deep dive into the inner workings of the Transformer architecture, complete with heavily annotated PyTorch code for every layer.

PyTorchPythonLLMs

Hammad Qaiser

Implementing Transformers from Scratch in PyTorch

Understanding how Transformers work under the hood is a rite of passage for any modern AI Engineer. In this deep dive, we'll implement a complete Transformer from scratch using PyTorch.

The Core Concept: Self-Attention

At the heart of the Transformer is the self-attention mechanism. It allows the model to weigh the importance of different words in a sequence relative to each other.

Here is the mathematical formulation: Attention(Q, K, V) = softmax(QK^T / sqrt(d_k))V

Code Implementation

Let's write the code for the MultiHeadAttention block.

python
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 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 scaled_dot_product_attention(self, Q, K, V, mask=None): attn_scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: attn_scores = attn_scores.masked_fill(mask == 0, -1e9) attn_probs = torch.softmax(attn_scores, dim=-1) output = torch.matmul(attn_probs, V) return output def split_heads(self, x): batch_size, seq_length, d_model = x.size() return x.view(batch_size, seq_length, self.num_heads, self.d_k).transpose(1, 2) def combine_heads(self, x): batch_size, _, seq_length, d_k = x.size() return x.transpose(1, 2).contiguous().view(batch_size, seq_length, self.d_model) def forward(self, Q, K, V, mask=None): Q = self.split_heads(self.W_q(Q)) K = self.split_heads(self.W_k(K)) V = self.split_heads(self.W_v(V)) attn_output = self.scaled_dot_product_attention(Q, K, V, mask) output = self.W_o(self.combine_heads(attn_output)) return output

Position-wise Feed-Forward Networks

In addition to attention sub-layers, each of the layers in our encoder and decoder contains a fully connected feed-forward network.

python
class PositionWiseFeedForward(nn.Module): def __init__(self, d_model, d_ff): super(PositionWiseFeedForward, self).__init__() self.fc1 = nn.Linear(d_model, d_ff) self.fc2 = nn.Linear(d_ff, d_model) self.relu = nn.ReLU() def forward(self, x): return self.fc2(self.relu(self.fc1(x)))

Positional Encoding

Since the model contains no recurrence and no convolution, we inject some information about the relative or absolute position of the tokens in the sequence.

python
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_seq_length): super(PositionalEncoding, self).__init__() pe = torch.zeros(max_seq_length, d_model) position = torch.arange(0, max_seq_length, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): return x + self.pe[:, :x.size(1)]

Wrapping Up

By combining these three elements—Attention, Feed-Forward layers, and Positional Encoding—you can construct the full Transformer Encoder and Decoder. Experiment with the parameters d_model, num_heads, and d_ff to see how they impact your memory footprint and model capacity!