Building a Transformer-Based Language Model from Scratch
This guide walks through the process of building a character-level Transformer language model, similar in architecture to GPT, trained on the complete works of Shakespeare. You'll understand the core components that power systems like ChatGPT.
Introduction
The Transformer architecture, introduced in the seminal 2017 paper "Attention Is All You Need", forms the foundation of modern large language models (LLMs) like GPT (Generative Pre-trained Transformer). While production systems like ChatGPT involve massive models trained on internet-scale data with complex fine-tuning stages, the core architecture can be implemented in a few hundred lines of code.
This guide implements a decoder-only Transformer - the same architecture used by GPT models - trained at the character level on a dataset of Shakespeare's complete works. You'll build the model piece by piece, understanding each component: token embeddings, positional encodings, multi-head self-attention, feed-forward networks, layer normalization, and residual connections.
Prerequisites
- Proficiency in Python
- Basic understanding of PyTorch
- Familiarity with calculus and statistics fundamentals
- A machine with Python 3.8+ and PyTorch installed (GPU recommended for training)
Project Setup
Install Dependencies
Download the Dataset
The Tiny Shakespeare dataset is a concatenation of all Shakespeare's works in a single 1MB text file.
Dataset Overview
# Read the dataset
with open('input.txt', 'r', encoding='utf-8') as f:
text = f.read()
print(f"Length of dataset in characters: {len(text)}")
# Output: Length of dataset in characters: ~1,000,000
Building the Tokenizer
A tokenizer converts raw text into sequences of integers that the model can process. For simplicity, we use a character-level tokenizer.
import torch
# Get all unique characters in the dataset
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(f"Vocabulary size: {vocab_size}")
# Output: Vocabulary size: 65
# Create mapping from characters to integers
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
# Encoder: string -> list of integers
encode = lambda s: [stoi[c] for c in s]
# Decoder: list of integers -> string
decode = lambda l: ''.join([itos[i] for i in l])
# Test the encoder/decoder
print(encode("hi there"))
# Output: [46, 47, 56, 1, 58, 46, 43, 56, 43]
print(decode(encode("hi there")))
# Output: hi there
[!NOTE] Production systems like GPT use subword tokenizers (like Byte-Pair Encoding or SentencePiece) with vocabularies of 50,000+ tokens. Character-level tokenization is simpler but produces longer sequences.
Data Preparation
Train/Validation Split
# Split into train and validation (90/10)
n = int(0.9 * len(text))
train_data = text[:n]
val_data = text[n:]
# Tokenize the entire dataset
train_data = torch.tensor(encode(train_data), dtype=torch.long)
val_data = torch.tensor(encode(val_data), dtype=torch.long)
Batching
We never feed entire texts to the Transformer at once. Instead, we sample small chunks (blocks) and process them in batches.
batch_size = 4 # How many independent sequences to process in parallel
block_size = 8 # Maximum context length for predictions
def get_batch(split):
"""Generate a batch of inputs and targets"""
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i:i+block_size] for i in ix])
y = torch.stack([data[i+1:i+block_size+1] for i in ix])
return x, y
# Test the batching
xb, yb = get_batch('train')
print(f"Input shape: {xb.shape}") # torch.Size([4, 8])
print(f"Target shape: {yb.shape}") # torch.Size([4, 8])
# The batch contains 32 independent examples (4 batches ร 8 positions)
[!NOTE] For each position in the input, the target is the next character. This means a single chunk of 8 characters actually contains 8 training examples (context lengths from 1 to 8).
Building the Bigram Language Model
Start with the simplest possible language model - a Bigram model that predicts the next character based only on the current character.
import torch.nn as nn
import torch.nn.functional as F
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size):
super().__init__()
# Each token directly reads off the logits for the next token
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
# idx and targets are (B, T) tensors
logits = self.token_embedding_table(idx) # (B, T, C) where C=vocab_size
if targets is None:
loss = None
else:
B, T, C = logits.shape
logits = logits.view(B*T, C)
targets = targets.view(B*T)
loss = F.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
"""Generate new tokens autoregressively"""
for _ in range(max_new_tokens):
# Get predictions
logits, _ = self(idx)
# Focus on the last time step
logits = logits[:, -1, :] # (B, C)
# Apply softmax to get probabilities
probs = F.softmax(logits, dim=-1)
# Sample from the distribution
idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
# Append to the sequence
idx = torch.cat((idx, idx_next), dim=1) # (B, T+1)
return idx
# Test generation with random model
m = BigramLanguageModel(vocab_size)
idx = torch.zeros((1, 1), dtype=torch.long)
print(decode(m.generate(idx, max_new_tokens=100)[0].tolist()))
Training the Bigram Model
# Create optimizer
optimizer = torch.optim.AdamW(m.parameters(), lr=1e-3)
# Training loop
for steps in range(10000):
# Sample batch
xb, yb = get_batch('train')
# Evaluate loss
logits, loss = m(xb, yb)
# Backward pass
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
if steps % 1000 == 0:
print(f"Step {steps}: loss = {loss.item():.4f}")
# Output (approximate):
# Step 0: loss = 4.8732
# Step 1000: loss = 3.5176
# Step 9000: loss = 2.5018
The Mathematical Trick Behind Efficient Attention
Before implementing self-attention, understand the key mathematical operation that makes it efficient: weighted aggregation via matrix multiplication.
# Toy example: B=4, T=8, C=2
B, T, C = 4, 8, 2
x = torch.randn(B, T, C)
# Version 1: Inefficient loop-based averaging
xbow = torch.zeros((B, T, C))
for b in range(B):
for t in range(T):
xprev = x[b, :t+1] # (t, C)
xbow[b, t] = torch.mean(xprev, 0)
# Version 2: Efficient matrix multiplication approach
wei = torch.tril(torch.ones(T, T))
wei = wei / wei.sum(1, keepdim=True) # Normalize rows
xbow2 = wei @ x # (B, T, T) @ (B, T, C) -> (B, T, C)
# Version 3: Using softmax (more flexible)
tril = torch.tril(torch.ones(T, T))
wei = torch.zeros((T, T))
wei = wei.masked_fill(tril == 0, float('-inf'))
wei = F.softmax(wei, dim=-1)
xbow3 = wei @ x
# All three versions produce identical results
print(torch.allclose(xbow, xbow2)) # True
print(torch.allclose(xbow, xbow3)) # True
Implementing Single-Head Self-Attention
Self-attention allows tokens to communicate with each other in a data-dependent manner.
class Head(nn.Module):
"""One head of self-attention"""
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B, T, C = x.shape
# Compute query, key, value for all tokens
k = self.key(x) # (B, T, head_size)
q = self.query(x) # (B, T, head_size)
# Compute attention scores (affinities)
wei = q @ k.transpose(-2, -1) # (B, T, T)
wei = wei * head_size**-0.5 # Scale by sqrt(head_size) for stable gradients
# Mask out future tokens (decoder block)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
wei = F.softmax(wei, dim=-1) # (B, T, T)
# Weighted aggregation of values
v = self.value(x) # (B, T, head_size)
out = wei @ v # (B, T, head_size)
return out
[!WARNING] The scaling factor
head_size**-0.5is crucial. Without it, the softmax inputs become too large, causing attention to collapse to near one-hot vectors, especially at initialization.
Multi-Head Self-Attention
Multiple attention heads run in parallel, allowing the model to attend to different types of relationships simultaneously.
class MultiHeadAttention(nn.Module):
"""Multiple heads of self-attention in parallel"""
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(n_embd, n_embd) # Project back to residual pathway
def forward(self, x):
out = torch.cat([h(x) for h in self.heads], dim=-1)
out = self.proj(out)
return out
Feed-Forward Network
After communication via attention, each token independently processes the information it gathered.
class FeedForward(nn.Module):
"""A simple linear layer followed by a non-linearity"""
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd), # Inner layer is 4x wider
nn.ReLU(),
nn.Linear(4 * n_embd, n_embd), # Project back
)
def forward(self, x):
return self.net(x)
Transformer Block
Combine communication (multi-head attention) with computation (feed-forward), wrapped with residual connections and layer normalization.
class Block(nn.Module):
"""Transformer block: communication + computation with skip connections"""
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size)
self.ffwd = FeedForward(n_embd)
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x):
# Residual connection with pre-norm formulation
x = x + self.sa(self.ln1(x)) # Communication
x = x + self.ffwd(self.ln2(x)) # Computation
return x
Complete GPT Model
Assemble all components into the final Transformer model.
class GPTLanguageModel(nn.Module):
def __init__(self):
super().__init__()
# Token and position embeddings
self.token_embedding_table = nn.Embedding(vocab_size, n_embd)
self.position_embedding_table = nn.Embedding(block_size, n_embd)
# Transformer blocks
self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
# Final layer norm and language model head
self.ln_f = nn.LayerNorm(n_embd)
self.lm_head = nn.Linear(n_embd, vocab_size)
def forward(self, idx, targets=None):
B, T = idx.shape
# Token and position embeddings
tok_emb = self.token_embedding_table(idx) # (B, T, C)
pos_emb = self.position_embedding_table(torch.arange(T, device=device)) # (T, C)
x = tok_emb + pos_emb # (B, T, C)
# Apply transformer blocks
x = self.blocks(x) # (B, T, C)
x = self.ln_f(x) # (B, T, C)
logits = self.lm_head(x) # (B, T, vocab_size)
# Compute loss if targets provided
if targets is None:
loss = None
else:
B, T, C = logits.shape
logits = logits.view(B*T, C)
targets = targets.view(B*T)
loss = F.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
"""Generate new tokens"""
for _ in range(max_new_tokens):
# Crop idx to the last block_size tokens
idx_cond = idx[:, -block_size:]
# Get predictions
logits, _ = self(idx_cond)
logits = logits[:, -1, :] # (B, C)
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)
return idx
Training Configuration
Set hyperparameters for training. These are much smaller than production models but demonstrate the same architecture.
# Hyperparameters
batch_size = 64
block_size = 256
max_iters = 5000
eval_interval = 500
learning_rate = 3e-4
eval_iters = 200
n_embd = 384
n_head = 6
n_layer = 6
dropout = 0.2
# Device
device = 'cuda' if torch.cuda.is_available() else 'cpu'
# Initialize model
model = GPTLanguageModel()
model = model.to(device)
# Print number of parameters
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
# Output: Model parameters: ~10 million
Training Loop
# Loss estimation function
@torch.no_grad()
def estimate_loss():
out = {}
model.eval()
for split in ['train', 'val']:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
X, Y = get_batch(split)
X, Y = X.to(device), Y.to(device)
logits, loss = model(X, Y)
losses[k] = loss.item()
out[split] = losses.mean()
model.train()
return out
# Create optimizer
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
# Training loop
for iter in range(max_iters):
# Evaluate loss periodically
if iter % eval_interval == 0 or iter == max_iters - 1:
losses = estimate_loss()
print(f"Step {iter}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")
# Sample batch
xb, yb = get_batch('train')
xb, yb = xb.to(device), yb.to(device)
# Forward and backward pass
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
Generating Shakespeare-Like Text
After training, generate new text by sampling from the model.
# Generate from the model
context = torch.zeros((1, 1), dtype=torch.long, device=device)
generated = model.generate(context, max_new_tokens=2000)
print(decode(generated[0].tolist()))
The output will look something like:
verily my Lord the sites have left the again
the king coming with my curses with precious pale
and then tranos say something else
Understanding the Architecture
Decoder-Only vs. Encoder-Decoder
The model implemented here is a decoder-only Transformer, which uses a triangular mask to prevent tokens from attending to future positions. This is the architecture used by GPT models.
The original "Attention Is All You Need" paper used an encoder-decoder architecture for machine translation: - Encoder: Processes input sequence (e.g., French sentence) with full bidirectional attention - Decoder: Generates output sequence (e.g., English translation) with masked self-attention and cross-attention to encoder outputs
Key Components Summary
- Token Embeddings: Convert input integers to dense vectors
- Positional Embeddings: Add position information (since attention is permutation-invariant)
- Self-Attention: Communication mechanism where tokens aggregate information from other tokens
- Multi-Head Attention: Multiple attention channels in parallel
- Feed-Forward Networks: Computation layer where each token processes gathered information
- Layer Normalization: Stabilizes training by normalizing activations
- Residual Connections: Enable training of deep networks by providing gradient shortcuts
Scaling to Production Models
The architecture implemented here is fundamentally identical to what powers GPT-3 and ChatGPT. The differences are in scale:
| Aspect | Our Model | GPT-3 (175B) |
|---|---|---|
| Parameters | 10 million | 175 billion |
| Training data | 1 million chars | 300+ billion tokens |
| Layers | 6 | 96 |
| Embedding dim | 384 | 12,288 |
| Heads | 6 | 96 |
| Batch size | 64 | 3.2 million tokens |
| GPUs | 1 (optional) | Thousands |
The Two Stages of Training ChatGPT
- Pre-training: Train on large internet corpus to predict next token (document completion)
- Fine-tuning (RLHF): Align the model to be helpful and safe:
- Supervised fine-tuning on human demonstrations
- Train a reward model on human preferences
- Optimize using Proximal Policy Optimization (PPO)
Repository: nanoGPT
The complete codebase is available as nanoGPT, which includes:
- model.py: The GPT model definition (very similar to what we built)
- train.py: Training loop with checkpointing, learning rate scheduling, and distributed training support
Next Steps
- Experiment with different datasets (e.g., Python code, Wikipedia articles)
- Implement the encoder-decoder architecture for translation tasks
- Explore fine-tuning techniques (supervised fine-tuning, RLHF)
- Scale up the model and use larger batch sizes with multiple GPUs