Overview
In our previous lesson on RNNs, LSTMs, and GRUs, we explored the sequential approach to modeling language. While these architectures revolutionized NLP, they still suffered from fundamental limitations in handling long-range dependencies and parallelization.
This lesson introduces the Transformer architecture, a paradigm shift that replaced recurrence with attention mechanisms. First introduced in the seminal 2017 paper "Attention Is All You Need" by Vaswani et al., Transformers have become the foundation of modern NLP models like BERT, GPT, and T5 that have dramatically advanced the state of the art.
Learning Objectives
After completing this lesson, you will be able to:
- Understand the key innovations and motivations behind the Transformer architecture
- Explain self-attention and multi-head attention mechanisms in detail
- Describe positional encoding and why it's necessary
- Compare encoder-only, decoder-only, and encoder-decoder transformer variants
- Implement basic transformer components
- Recognize how transformers enable modern language models
The Need for a New Architecture
The Limitations of RNNs Revisited
As we saw in the previous lesson, RNNs and their variants face several critical limitations:
- Sequential Processing: Processing tokens one at a time creates a bottleneck for training and inference
- Limited Context Window: Even LSTMs struggle with very long-range dependencies
- Vanishing Gradients: Despite improvements, still an issue for very long sequences
Analogy: Information Highways vs. Relay Races
Think of an RNN as a relay race where information is passed from one runner (time step) to the next. If the race is long, messages can get distorted or lost along the way, and the entire race is only as fast as the slowest runner.
In contrast, a Transformer is like a highway system where every location has direct high-speed connections to every other location. Information doesn't have to flow sequentially but can take direct routes, and all routes can be traveled simultaneously.
The Transformer Architecture: A High-Level View
Architectural Overview
Key Innovations
The Transformer introduced several groundbreaking innovations:
- Self-Attention: Allows each position to directly attend to all positions
- Multi-Head Attention: Enables attention across different representation subspaces
- Positional Encoding: Captures sequence order without recurrence
- Residual Connections + Layer Normalization: Facilitates training of deep networks
- Feed-Forward Networks: Adds non-linearity and transforms representations
- Parallel Processing: Enables efficient training and inference
Self-Attention: The Core Mechanism
Understanding Attention
Attention allows a model to focus on relevant parts of the input sequence when making predictions. It computes a weighted sum of values, where weights reflect the relevance of each value to the current context.
The Intuition Behind Self-Attention
In the example above, to understand what "it" refers to, the model must determine which previous words are most relevant. Self-attention allows the model to learn these relevance patterns.
Query, Key, Value (QKV) Framework
Self-attention can be conceptualized using the Query-Key-Value framework:
- Query (Q): What we're looking for
- Key (K): What we match against
- Value (V): What we retrieve if there's a match
Think of it as a sophisticated dictionary lookup:
- The Query is like your search term
- The Keys are like the dictionary entries
- The Values are the definitions you retrieve
Self-Attention Computation: Step-by-Step
-
Projection: Generate Query, Key, and Value vectors by multiplying input embeddings by weight matrices
-
Score Calculation: Compute attention scores by multiplying Q and K matrices
-
Scaling: Divide by square root of dimension to prevent extremely small gradients
-
Masking (Decoder Only): Apply mask to prevent attending to future positions (for decoder)
-
Softmax: Apply softmax to get probability distribution across values
-
Weighted Sum: Multiply attention weights by values
Visualizing Self-Attention
Multi-Head Attention: Attending to Different Aspects
Why Multiple Attention Heads?
Self-attention with a single attention mechanism (or "head") can only capture one type of relationship between words. But language has many types of relationships (syntactic, semantic, referential, etc.).
Multiple attention heads allow the model to:
- Attend to different representation subspaces simultaneously
- Capture different types of dependencies (e.g., syntactic vs. semantic)
- Create a richer representation by combining these diverse perspectives
Multi-Head Attention Mechanism
💡 Tip: Use the attention patterns mode in the tool above to see how different heads capture different relationships.
Mathematical Formulation
For each head :
The outputs from all heads are concatenated and linearly transformed:
Analogy: Multiple Expert Consultants
Think of multi-head attention as consulting multiple experts who each focus on different aspects of a problem:
- One linguist focuses on grammar
- Another focuses on vocabulary
- A third focuses on cultural context
- A fourth focuses on tone
Each provides valuable insights from their perspective, and together they create a more comprehensive understanding than any single expert could provide.
Positional Encoding: Preserving Sequence Order
The Problem: Transformers Don't Know Position
Unlike RNNs, the self-attention mechanism is inherently permutation-invariant—it doesn't consider the order of tokens. This is a problem because word order is crucial in language understanding.
For example, these sentences have very different meanings despite using the same words:
- "The dog chased the cat"
- "The cat chased the dog"
Solution: Positional Encoding
Transformers add positional information to each word embedding using sinusoidal functions:
Where:
- is the position
- is the dimension
- is the embedding dimension
Visualizing Positional Encoding
Key Properties of Sinusoidal Positional Encoding
- Unique Pattern: Each position gets a unique encoding
- Fixed Offset: The relative encoding between positions at a fixed offset is constant
- Extrapolation: Can generalize to longer sequences than seen in training
- No New Parameters: Unlike learned positional embeddings, requires no additional parameters
Embedding + Positional Encoding
The final input to the transformer is the sum of the word embeddings and the positional encodings:
Key Insight: The same positional encoding visualization above shows how these encodings combine with word embeddings to create the final input representation.
The Building Blocks: Encoder and Decoder
Transformer Encoder
The encoder processes the input sequence and consists of:
- Multi-Head Self-Attention: Each position attends to all positions
- Feed-Forward Neural Network: A two-layer network with ReLU activation
- Residual Connections: Helps gradient flow and stabilizes training
- Layer Normalization: Normalizes inputs to each sub-layer
Feed-Forward Network (FFN)
The FFN applies the same transformation to each position independently:
This is equivalent to two dense layers with a ReLU activation in between. The FFN allows the model to transform its representations and introduces non-linearity.
Transformer Decoder
The decoder generates the output sequence and has three main components:
- Masked Multi-Head Self-Attention: Each position attends only to previous positions
- Cross-Attention: Attends to the encoder's output
- Feed-Forward Neural Network: Same structure as in the encoder
Masking in the Decoder
The decoder must generate text autoregressively (one token at a time), so it can't "see" future tokens during training. This is achieved using a look-ahead mask.
💡 Tip: Use the self-attention mode in the tool above to see how masking prevents the decoder from attending to future positions.
Common pitfalls:
- Off-by-one masking: ensure strictly upper-triangular mask so position t cannot attend to ≥ t.
- Padding mask mixing: combine causal mask with key padding mask correctly to avoid leaking pads.
Cross-Attention
Cross-attention allows the decoder to focus on relevant parts of the input sequence:
The Full Architecture: Putting It All Together
Complete Transformer Architecture
Now that we've explored the individual components, let's see how they work together in the complete architecture. Use the overview mode in the tool above to see the full encoder-decoder stack, or explore other modes to dive deeper into specific mechanisms.
Training the Transformer
Transformers are typically trained with:
- Teacher forcing: Using ground truth as decoder input during training
- Label smoothing: Preventing overconfidence by softening the target distribution
- Learning rate scheduling: Using warmup and decay for optimal convergence
- Large batch sizes: Stabilizing training with more examples per update
Computational Complexity
The self-attention mechanism has quadratic complexity with respect to sequence length:
Where:
- is the sequence length
- is the representation dimension
This can be a limitation for very long sequences, leading to various efficient transformer variants that reduce this complexity.
Transformer Variants: Encoder-Only, Decoder-Only, and Encoder-Decoder
Transformer Variants: Architecture Comparison
The transformer architecture has evolved into three main variants, each optimized for different types of tasks:
Key Distinctions:
Encoder-Only Models (BERT, RoBERTa, DistilBERT)
- Bidirectional attention across all tokens
- Suitable for understanding tasks: classification, NER, sentiment analysis
- Cannot generate text autoregressively
Decoder-Only Models (GPT, GPT-2, GPT-3, GPT-4)
- Causal (masked) attention to prevent looking ahead
- Excellent for text generation and completion
- Can be adapted for understanding tasks with prompting
Encoder-Decoder Models (T5, BART, Pegasus)
- Best of both worlds: bidirectional encoding + autoregressive decoding
- Excel at sequence-to-sequence tasks: translation, summarization
- More complex but very versatile
Implementation: Building a Simple Transformer
Implementing Self-Attention in PyTorch
import torch import torch.nn as nn import torch.nn.functional as F import math class SelfAttention(nn.Module): def __init__(self, embed_size, heads): super(SelfAttention, self).__init__() self.embed_size = embed_size self.heads = heads self.head_dim = embed_size // heads assert (self.head_dim * heads == embed_size), "Embed size must be divisible by heads" # Linear projections for Q, K, V self.q_linear = nn.Linear(embed_size, embed_size) self.k_linear = nn.Linear(embed_size, embed_size) self.v_linear = nn.Linear(embed_size, embed_size) self.out_linear = nn.Linear(embed_size, embed_size) def forward(self, query, key, value, mask=None): batch_size = query.shape[0] # Linear projections and split into heads q = self.q_linear(query).view(batch_size, -1, self.heads, self.head_dim).permute(0, 2, 1, 3) k = self.k_linear(key).view(batch_size, -1, self.heads, self.head_dim).permute(0, 2, 1, 3) v = self.v_linear(value).view(batch_size, -1, self.heads, self.head_dim).permute(0, 2, 1, 3) # Compute attention scores scores = torch.matmul(q, k.permute(0, 1, 3, 2)) / math.sqrt(self.head_dim) # Apply mask if provided (for decoder) if mask is not None: scores = scores.masked_fill(mask == 0, float("-1e20")) # Apply softmax and compute attention weights attention_weights = F.softmax(scores, dim=-1) # Compute output out = torch.matmul(attention_weights, v) out = out.permute(0, 2, 1, 3).contiguous() out = out.view(batch_size, -1, self.embed_size) out = self.out_linear(out) return out
Implementing Positional Encoding
class PositionalEncoding(nn.Module): def __init__(self, embed_size, max_len=5000): super(PositionalEncoding, self).__init__() pe = torch.zeros(max_len, embed_size) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, embed_size, 2).float() * (-math.log(10000.0) / embed_size)) # Apply sin to even indices pe[:, 0::2] = torch.sin(position * div_term) # Apply cos to odd indices pe[:, 1::2] = torch.cos(position * div_term) pe = pe.unsqueeze(0) self.register_buffer('pe', pe) def forward(self, x): # x has shape [batch_size, seq_len, embed_size] return x + self.pe[:, :x.size(1), :]
Transformer Encoder Layer
class TransformerEncoderLayer(nn.Module): def __init__(self, embed_size, heads, dropout, forward_expansion): super(TransformerEncoderLayer, self).__init__() self.attention = SelfAttention(embed_size, heads) self.norm1 = nn.LayerNorm(embed_size) self.norm2 = nn.LayerNorm(embed_size) self.feed_forward = nn.Sequential( nn.Linear(embed_size, forward_expansion * embed_size), nn.ReLU(), nn.Linear(forward_expansion * embed_size, embed_size) ) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): # Self-attention block with residual connection and layer norm attention_output = self.attention(x, x, x, mask) x = self.norm1(x + self.dropout(attention_output)) # Feed forward block with residual connection and layer norm ff_output = self.feed_forward(x) x = self.norm2(x + self.dropout(ff_output)) return x
Transformer Decoder Layer
class TransformerDecoderLayer(nn.Module): def __init__(self, embed_size, heads, dropout, forward_expansion): super(TransformerDecoderLayer, self).__init__() self.attention = SelfAttention(embed_size, heads) self.cross_attention = SelfAttention(embed_size, heads) self.norm1 = nn.LayerNorm(embed_size) self.norm2 = nn.LayerNorm(embed_size) self.norm3 = nn.LayerNorm(embed_size) self.feed_forward = nn.Sequential( nn.Linear(embed_size, forward_expansion * embed_size), nn.ReLU(), nn.Linear(forward_expansion * embed_size, embed_size) ) self.dropout = nn.Dropout(dropout) def forward(self, x, encoder_output, source_mask, target_mask): # Self-attention block with residual connection and layer norm attention_output = self.attention(x, x, x, target_mask) x = self.norm1(x + self.dropout(attention_output)) # Cross-attention block with residual connection and layer norm cross_attention_output = self.cross_attention( x, encoder_output, encoder_output, source_mask ) x = self.norm2(x + self.dropout(cross_attention_output)) # Feed forward block with residual connection and layer norm ff_output = self.feed_forward(x) x = self.norm3(x + self.dropout(ff_output)) return x
Applications: How Transformers Revolutionized NLP
Machine Translation
The original transformer model was designed for machine translation and significantly improved the state of the art on the WMT English-to-German and English-to-French translation tasks.
Qualitative Comparison
The machine translation improvements can be seen in the model comparison tool we used earlier. Switch to the "Model Comparison" mode in any of the TransformerExplorer tools above to compare translation quality across different architectures.
Language Modeling and Text Generation
Transformer-based language models like GPT can generate remarkably coherent and contextually appropriate text.
Code Example: Text Generation with a Simple Transformer
import torch import torch.nn as nn import torch.nn.functional as F import math # Simple GPT-like model for text generation class SimpleGPT(nn.Module): def __init__(self, vocab_size, embed_dim, num_heads, num_layers, max_seq_len, dropout=0.1): super().__init__() self.embed_dim = embed_dim self.token_embedding = nn.Embedding(vocab_size, embed_dim) self.position_embedding = nn.Embedding(max_seq_len, embed_dim) # Transformer decoder layers decoder_layer = nn.TransformerDecoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=embed_dim * 4, dropout=dropout, batch_first=True ) self.transformer = nn.TransformerDecoder(decoder_layer, num_layers) # Output projection self.ln_f = nn.LayerNorm(embed_dim) self.head = nn.Linear(embed_dim, vocab_size, bias=False) # Create causal mask self.register_buffer('causal_mask', self._generate_square_subsequent_mask(max_seq_len)) def _generate_square_subsequent_mask(self, sz): mask = torch.triu(torch.ones(sz, sz) * float('-inf'), diagonal=1) return mask def forward(self, x): seq_len = x.size(1) # Token and position embeddings positions = torch.arange(0, seq_len, device=x.device).unsqueeze(0) token_emb = self.token_embedding(x) pos_emb = self.position_embedding(positions) # Combine embeddings x = token_emb + pos_emb # Apply transformer with causal mask mask = self.causal_mask[:seq_len, :seq_len] x = self.transformer(x, x, tgt_mask=mask) # Apply final layer norm and projection x = self.ln_f(x) logits = self.head(x) return logits # Text generation function def generate_text(model, tokenizer, prompt, max_length=100, temperature=1.0, top_k=50): model.eval() # Tokenize prompt tokens = tokenizer.encode(prompt) input_ids = torch.tensor([tokens], dtype=torch.long) with torch.no_grad(): for _ in range(max_length - len(tokens)): # Forward pass logits = model(input_ids) next_token_logits = logits[0, -1, :] / temperature # Apply top-k filtering if top_k > 0: indices_to_remove = next_token_logits < torch.topk(next_token_logits, top_k)[0][..., -1, None] next_token_logits[indices_to_remove] = float('-inf') # Sample next token probs = F.softmax(next_token_logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1) # Add to sequence input_ids = torch.cat([input_ids, next_token.unsqueeze(0)], dim=1) # Stop if end token is generated if next_token.item() == tokenizer.eos_token_id: break # Decode generated text generated_tokens = input_ids[0].tolist() return tokenizer.decode(generated_tokens) # Example usage: # vocab_size = 50000 # model = SimpleGPT(vocab_size=vocab_size, embed_dim=512, num_heads=8, # num_layers=6, max_seq_len=1024) # # # Assuming you have a tokenizer # generated_text = generate_text(model, tokenizer, "The transformer architecture", # max_length=100, temperature=0.7) # print(generated_text)
Bidirectional Understanding and Masked Language Modeling
BERT and its variants use transformer encoders with masked language modeling to develop bidirectional understanding of text.
Code Example: Masked Language Modeling with Transformer Encoder
import torch import torch.nn as nn import torch.nn.functional as F import random # BERT-like model for masked language modeling class SimpleBERT(nn.Module): def __init__(self, vocab_size, embed_dim, num_heads, num_layers, max_seq_len, dropout=0.1): super().__init__() self.embed_dim = embed_dim self.token_embedding = nn.Embedding(vocab_size, embed_dim) self.position_embedding = nn.Embedding(max_seq_len, embed_dim) # Transformer encoder layers encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=embed_dim * 4, dropout=dropout, batch_first=True ) self.transformer = nn.TransformerEncoder(encoder_layer, num_layers) # MLM head self.ln = nn.LayerNorm(embed_dim) self.mlm_head = nn.Linear(embed_dim, vocab_size) def forward(self, input_ids, attention_mask=None): seq_len = input_ids.size(1) # Token and position embeddings positions = torch.arange(0, seq_len, device=input_ids.device).unsqueeze(0) token_emb = self.token_embedding(input_ids) pos_emb = self.position_embedding(positions) # Combine embeddings x = token_emb + pos_emb # Create attention mask if attention_mask is not None: # Convert attention mask to transformer format attention_mask = attention_mask.float() attention_mask = attention_mask.masked_fill(attention_mask == 0, float('-inf')) attention_mask = attention_mask.masked_fill(attention_mask == 1, 0.0) # Apply transformer encoder x = self.transformer(x, src_key_padding_mask=attention_mask) # Apply layer norm and MLM head x = self.ln(x) logits = self.mlm_head(x) return logits # Masked language modeling function def predict_masked_tokens(model, tokenizer, text, mask_token='[MASK]', top_k=5): model.eval() # Tokenize and find mask positions tokens = tokenizer.encode(text) input_ids = torch.tensor([tokens], dtype=torch.long) # Find mask token positions mask_token_id = tokenizer.encode(mask_token)[0] # Assuming single token mask_positions = (input_ids == mask_token_id).nonzero(as_tuple=True) if len(mask_positions[1]) == 0: return "No mask tokens found in the text" with torch.no_grad(): # Forward pass logits = model(input_ids) predictions = {} for pos in mask_positions[1]: # Get predictions for this mask position mask_logits = logits[0, pos, :] top_tokens = torch.topk(mask_logits, top_k) # Decode top predictions predicted_tokens = [] for token_id, score in zip(top_tokens.indices, top_tokens.values): token = tokenizer.decode([token_id.item()]) predicted_tokens.append((token, score.item())) predictions[f"Position {pos.item()}"] = predicted_tokens return predictions # Training function for MLM def train_mlm_step(model, tokenizer, texts, mask_prob=0.15): model.train() # Prepare batch batch_input_ids = [] batch_labels = [] for text in texts: tokens = tokenizer.encode(text) input_ids = tokens.copy() labels = [-100] * len(tokens) # -100 is ignored in loss calculation # Randomly mask tokens for i in range(len(tokens)): if random.random() < mask_prob: labels[i] = tokens[i] # Store original token as label # 80% of the time, replace with [MASK] if random.random() < 0.8: input_ids[i] = tokenizer.mask_token_id # 10% of the time, replace with random token elif random.random() < 0.5: input_ids[i] = random.randint(0, tokenizer.vocab_size - 1) # 10% of the time, keep original token batch_input_ids.append(input_ids) batch_labels.append(labels) # Convert to tensors (assuming same length, otherwise need padding) input_ids = torch.tensor(batch_input_ids, dtype=torch.long) labels = torch.tensor(batch_labels, dtype=torch.long) # Forward pass logits = model(input_ids) # Calculate loss only for masked positions loss_fct = nn.CrossEntropyLoss(ignore_index=-100) loss = loss_fct(logits.view(-1, logits.size(-1)), labels.view(-1)) return loss # Example usage: # vocab_size = 30000 # model = SimpleBERT(vocab_size=vocab_size, embed_dim=768, num_heads=12, # num_layers=12, max_seq_len=512) # # # Example prediction # text = "The transformer architecture has [MASK] natural language processing." # predictions = predict_masked_tokens(model, tokenizer, text, top_k=5) # print(f"Predictions for masked token: {predictions}")
Limitations and Future Directions
Current Limitations
- Quadratic Complexity: Self-attention scales poorly with sequence length
- Context Window: Limited by training and architecture constraints
- Interpretability: Understanding attention patterns isn't straightforward
- Data Hunger: Requires massive amounts of data for best performance
- Compute Resources: Training large models requires significant resources
Efficient Transformer Variants
Researchers are developing efficient transformers to address these limitations:
| Model | Innovation | Complexity | Performance | Max Context |
|---|---|---|---|---|
| Vanilla Transformer | Self-attention | O(n²) | Base | 512-1024 |
| Longformer | Local + global attention | O(n) | Similar | 4,096 |
| Reformer | LSH attention | O(n log n) | Slightly lower | 2,048 |
| Linformer | Linear projections | O(n) | Slightly lower | 2,048 |
| Performer | FAVOR+ mechanism | O(n) | Similar | 64,000+ |
| Transformer-XL | Recurrence mechanism | O(n²) | Better | 8,192 |
| Routing Transformer | Clustered attention | O(n√n) | Better | 16,384 |
Note: Performance is relative to vanilla Transformer of similar size.
The Future of Transformers
Transformers continue to evolve in several exciting directions:
- Multimodal Transformers: Processing text, images, audio, and video together
- Domain-Specific Architectures: Specialized for specific fields (science, medicine)
- Mixture of Experts: Using sparse activation to scale to trillions of parameters
- Retrieval-Augmented Models: Enhancing LLMs with external knowledge access
- More Efficient Attention: Continuing to reduce the quadratic complexity
Conclusion: The Foundation of Modern NLP
The transformer architecture represents one of the most significant breakthroughs in natural language processing. By introducing self-attention, positional encoding, and parallel processing, transformers solved the fundamental limitations of sequential models while enabling the creation of increasingly powerful language models.
Key innovations of the transformer:
- Self-attention: Direct modeling of relationships between all sequence positions
- Parallel processing: Elimination of sequential dependencies for faster training
- Scalability: Architecture that grows effectively with more data and compute
- Versatility: Success across numerous NLP tasks and domains
The transformer has become the foundation for modern language models like BERT, GPT, T5, and their successors. Understanding this architecture is essential for working with contemporary NLP systems.
In our next lessons, we'll explore how transformer architectures evolved into the powerful language models of today, including the deterministic and probabilistic methods for text generation, and then survey the modern landscape of language models from BERT and GPT to the latest innovations like Llama 3 and Claude 3.
Practice Exercises
-
Implement Self-Attention:
- Write a simplified version of the self-attention mechanism
- Visualize attention weights for a sample sentence
- Experiment with different scaling factors
-
Positional Encoding Analysis:
- Implement sinusoidal positional encoding
- Analyze how different positions are represented
- Visualize positional encoding vectors
-
Transformer Architecture Comparison:
- Compare performance of RNN vs. Transformer on a simple task
- Measure inference time for both architectures
- Analyze computational complexity at different sequence lengths
-
Pre-trained Model Exploration:
- Fine-tune a small pre-trained transformer for a classification task
- Analyze attention patterns in different heads
- Experiment with different layer freezing strategies
Additional Resources
- Attention Is All You Need - The original transformer paper
- The Illustrated Transformer by Jay Alammar
- Transfomer detailed visualization and explanation
- The Annotated Transformer - Implementation walkthrough
- Transformers from Scratch by Peter Bloem
- Hugging Face Transformers - Library with implementations of transformer models
- A Survey of Long-Term Context in Transformers - Overview of efficient transformer variants