Skip to content

Commit c5ca0e8

Browse files
authored
Merge pull request #5696 from neetcode-gh/ml/pr5-solution-articles
ML Course: Solution Articles for New Problems
2 parents 71c89ae + eaba89b commit c5ca0e8

7 files changed

Lines changed: 900 additions & 11 deletions

articles/code-gpt.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Before attempting this problem, you should be comfortable with:
44

55
- **Transformer Blocks** - Multi-headed attention, FFN, layer normalization, and residual connections, because the GPT model stacks multiple transformer blocks
66
- **Word and Position Embeddings** - Token embeddings map IDs to vectors, position embeddings encode order, and they are added together before entering the transformer stack
7-
- **Softmax** - The final operation that converts logits into a probability distribution over the vocabulary
7+
- **Output Projection ($W_O$)** - Each multi-head attention layer projects the concatenated head outputs through a final linear layer
88

99
---
1010

@@ -16,10 +16,9 @@ GPT (Generative Pre-trained Transformer) assembles everything from the course in
1616
2. **Position embeddings**: Add learned position vectors using a second `nn.Embedding`. Unlike the sinusoidal encoding from earlier, GPT uses learned positions.
1717
3. **$N$ Transformer blocks**: Each block applies multi-headed self-attention (for inter-token communication) and a feed-forward network (for per-token computation), connected by residual paths and layer normalization.
1818
4. **Final layer normalization**: Stabilizes the output of the last transformer block.
19-
5. **Vocabulary projection**: A linear layer that maps from $d_{\text{model}}$ to vocabulary size, producing logits for every possible next token.
20-
6. **Softmax**: Converts logits to probabilities.
19+
5. **Vocabulary projection**: A linear layer that maps from $d_{\text{model}}$ to vocabulary size, producing **logits** (raw unnormalized scores) for every possible next token.
2120

22-
At each position $t$, the model outputs a probability distribution over the vocabulary, predicting what token should come at position $t+1$. Causal masking inside the attention layers ensures position $t$ only sees tokens $0$ through $t$, so the model can be used autoregressively: generate one token, append it, and repeat.
21+
At each position $t$, the model outputs logits over the vocabulary, predicting what token should come at position $t+1$. During training, `cross_entropy` applies softmax internally. During generation, you apply softmax yourself to sample the next token. Causal masking inside the attention layers ensures position $t$ only sees tokens $0$ through $t$, so the model can be used autoregressively: generate one token, append it, and repeat.
2322

2423
This architecture scales remarkably well. GPT-2 Small uses $d=768$, 12 blocks, 12 heads. GPT-3 uses $d=12288$, 96 blocks, 96 heads. The structure is identical; only the numbers change.
2524

@@ -29,7 +28,7 @@ This architecture scales remarkably well. GPT-2 Small uses $d=768$, 12 blocks, 1
2928

3029
### Intuition
3130

32-
Compose all previously built components: embedding layers, a sequence of transformer blocks, final normalization, and a linear projection to vocabulary logits. The forward pass adds token and position embeddings, processes through all blocks, normalizes, projects, and applies softmax.
31+
Compose all previously built components: embedding layers, a sequence of transformer blocks, final normalization, and a linear projection to vocabulary logits. The forward pass adds token and position embeddings, processes through all blocks, normalizes, and projects to logits. Note: the model returns raw logits, not probabilities — this matches how GPT models work in practice, since `cross_entropy` and generation each handle softmax separately.
3332

3433
### Implementation
3534

@@ -63,8 +62,7 @@ class GPT(nn.Module):
6362
output = self.final_norm(self.transformer_blocks(embedded))
6463
logits = self.vocab_projection(output) # (B, T, vocab_size)
6564

66-
probabilities = nn.functional.softmax(logits, dim=-1)
67-
return torch.round(probabilities, decimals=4)
65+
return torch.round(logits, decimals=4)
6866

6967
class TransformerBlock(nn.Module):
7068

@@ -100,13 +98,14 @@ class GPT(nn.Module):
10098
self.att_heads = nn.ModuleList()
10199
for i in range(num_heads):
102100
self.att_heads.append(self.SingleHeadAttention(model_dim, model_dim // num_heads))
101+
self.output_proj = nn.Linear(model_dim, model_dim, bias=False)
103102

104103
def forward(self, embedded: TensorType[float]) -> TensorType[float]:
105104
head_outputs = []
106105
for head in self.att_heads:
107106
head_outputs.append(head(embedded))
108107
concatenated = torch.cat(head_outputs, dim = 2)
109-
return concatenated
108+
return self.output_proj(concatenated)
110109

111110
class VanillaNeuralNetwork(nn.Module):
112111

@@ -152,9 +151,8 @@ For `vocab_size = 100`, `context_length = 8`, `model_dim = 16`, `num_blocks = 2`
152151
| Block 2 | Same architecture, further refining | $(1, 5, 16)$ |
153152
| Final LN | LayerNorm across dim 16 | $(1, 5, 16)$ |
154153
| Vocab proj | Linear $16 \to 100$ | $(1, 5, 100)$ |
155-
| Softmax | Probabilities over vocabulary | $(1, 5, 100)$ |
156154

157-
Each of the 5 positions outputs a distribution over 100 tokens, predicting the next token.
155+
Each of the 5 positions outputs logits over 100 tokens, predicting the next token.
158156

159157
### Time & Space Complexity
160158

@@ -211,6 +209,6 @@ This becomes `model/gpt.py`. This is the culmination of the entire course: every
211209

212210
## Key Takeaways
213211

214-
- GPT composes token embeddings, position embeddings, a stack of transformer blocks, final normalization, and a vocabulary projection into a complete autoregressive language model.
212+
- GPT composes token embeddings, position embeddings, a stack of transformer blocks (each with $W^O$ output projection in multi-head attention), final normalization, and a vocabulary projection into raw logits.
215213
- Learned position embeddings (rather than sinusoidal) let the model discover its own positional representation during training.
216214
- The same architecture scales from tiny models (this problem) to GPT-3 (175 billion parameters) by increasing the model dimension, number of blocks, and number of heads.

articles/dead-relu-detector.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
## Prerequisites
2+
3+
Before attempting this problem, you should be comfortable with:
4+
5+
- **ReLU Activation** - A neuron is "dead" because ReLU outputs exactly 0 for all negative inputs, and its gradient is also 0, so the weights can never update
6+
- **Training Diagnostics** - This problem builds on the diagnostic mindset: inspecting internal network state to find problems before they waste training time
7+
- **PyTorch Module Iteration** - You need to iterate through `model.children()` and check `isinstance(module, nn.ReLU)` to find the right layers
8+
9+
---
10+
11+
## Concept
12+
13+
Dead ReLU neurons output zero for every sample in the batch. Because ReLU's gradient is zero for negative inputs, these neurons receive no gradient updates and are permanently stuck. The `detect_dead_neurons` method measures this per ReLU layer, and `suggest_fix` maps the severity pattern to the most appropriate intervention.
14+
15+
The fix priority reflects real debugging experience: severe death (> 50%) means the activation function itself is the problem (switch to LeakyReLU). Early-layer death (> 30% in layer 1) means initialization is bad (re-init). Depth-increasing death means the learning rate is too aggressive (reduce it).
16+
17+
---
18+
19+
## Solution
20+
21+
### Intuition
22+
23+
Forward the input through the model layer by layer. After each ReLU, check which neurons output zero for all samples in the batch -- those are dead. For `suggest_fix`, apply the priority-ordered rules based on the pattern of dead fractions.
24+
25+
### Implementation
26+
27+
::tabs-start
28+
```python
29+
import torch
30+
import torch.nn as nn
31+
from typing import List
32+
33+
34+
class Solution:
35+
36+
def detect_dead_neurons(self, model: nn.Module, x: torch.Tensor) -> List[float]:
37+
dead_fractions = []
38+
with torch.no_grad():
39+
for module in model.children():
40+
x = module(x)
41+
if isinstance(module, nn.ReLU):
42+
# A neuron is dead if it outputs 0 for ALL samples in the batch
43+
dead = (x == 0).all(dim=0).float().mean().item()
44+
dead_fractions.append(round(dead, 4))
45+
return dead_fractions
46+
47+
def suggest_fix(self, dead_fractions: List[float]) -> str:
48+
if len(dead_fractions) == 0:
49+
return 'healthy'
50+
51+
max_frac = max(dead_fractions)
52+
53+
# Any layer > 0.5 dead -> use LeakyReLU
54+
if max_frac > 0.5:
55+
return 'use_leaky_relu'
56+
57+
# First layer > 0.3 dead -> reinitialize weights
58+
if dead_fractions[0] > 0.3:
59+
return 'reinitialize'
60+
61+
# Dead fraction increases with depth -> reduce learning rate
62+
if len(dead_fractions) >= 2:
63+
increasing = all(
64+
dead_fractions[i] < dead_fractions[i + 1]
65+
for i in range(len(dead_fractions) - 1)
66+
)
67+
if increasing and dead_fractions[-1] > 0.1:
68+
return 'reduce_learning_rate'
69+
70+
# All layers < 0.1 dead -> healthy
71+
if max_frac < 0.1:
72+
return 'healthy'
73+
74+
return 'healthy'
75+
```
76+
::tabs-end
77+
78+
79+
### Walkthrough
80+
81+
**Healthy network** (Kaiming init): dead fractions = `[0.0, 0.0312, 0.0156]`. Max is 0.0312 < 0.1, so `suggest_fix` returns `'healthy'`.
82+
83+
**Broken network** (biases pushed very negative): dead fractions = `[0.5312, 0.8828, 0.9688]`. The first layer already has 0.5312 > 0.5, so the check `max_frac > 0.5` triggers and `suggest_fix` returns `'use_leaky_relu'`.
84+
85+
**Decision logic for `suggest_fix`:**
86+
87+
| Dead Fractions | Rule Triggered | Fix |
88+
|---|---|---|
89+
| `[0.02, 0.03, 0.01]` | All < 0.1 | `'healthy'` |
90+
| `[0.1, 0.4, 0.65]` | Layer 3 has 0.65 > 0.5 | `'use_leaky_relu'` |
91+
| `[0.35, 0.1, 0.05]` | Layer 1 has 0.35 > 0.3 | `'reinitialize'` |
92+
| `[0.05, 0.08, 0.15]` | Strictly increasing, last > 0.1 | `'reduce_learning_rate'` |
93+
94+
### Time & Space Complexity
95+
96+
- Time: $O(N \cdot d \cdot L)$ where $N$ is batch size, $d$ is layer width, $L$ is number of ReLU layers
97+
- Space: $O(d)$ per layer for the boolean dead-neuron mask
98+
99+
---
100+
101+
## Common Pitfalls
102+
103+
### Checking After Linear Instead of After ReLU
104+
105+
Dead neurons are a ReLU-specific concept. The Linear layer's output can be negative (that's fine -- it hasn't been activated yet). You need to check after the ReLU to see which neurons are stuck at exactly zero.
106+
107+
::tabs-start
108+
```python
109+
# Wrong: checking after Linear
110+
if isinstance(module, nn.Linear):
111+
dead = (x == 0).all(dim=0).float().mean().item()
112+
113+
# Correct: checking after ReLU
114+
if isinstance(module, nn.ReLU):
115+
dead = (x == 0).all(dim=0).float().mean().item()
116+
```
117+
::tabs-end
118+
119+
120+
### Using `<= 0` Instead of `== 0` for ReLU Output
121+
122+
After ReLU, the output is either 0 (dead) or positive. Using `<= 0` is equivalent to `== 0` in this context, but conceptually we are checking for exactly zero output, which is what ReLU produces for negative inputs.
123+
124+
### Missing the "Strictly Increasing" Check
125+
126+
The `reduce_learning_rate` fix requires dead fractions to be **strictly** increasing across all layers. If any layer has the same or lower dead fraction as the previous one, the pattern doesn't match.
127+
128+
::tabs-start
129+
```python
130+
# Wrong: non-strict comparison
131+
increasing = all(
132+
dead_fractions[i] <= dead_fractions[i + 1] # allows equal!
133+
for i in range(len(dead_fractions) - 1)
134+
)
135+
136+
# Correct: strictly increasing
137+
increasing = all(
138+
dead_fractions[i] < dead_fractions[i + 1]
139+
for i in range(len(dead_fractions) - 1)
140+
)
141+
```
142+
::tabs-end
143+
144+
145+
---
146+
147+
## In the GPT Project
148+
149+
GPT uses GELU activation, not ReLU, so the dead neuron problem doesn't apply directly. But the diagnostic technique is universal: inspecting per-layer activation patterns to find silent failures. The same approach works for detecting saturated sigmoids, collapsed layer norms, or any other "the model trains but doesn't learn" scenario.
150+
151+
---
152+
153+
## Key Takeaways
154+
155+
- A dead ReLU neuron outputs zero for every sample in the batch. It receives zero gradient and can never recover. This is a permanent failure mode.
156+
- The severity pattern determines the fix: widespread death needs a new activation function, early-layer death needs re-initialization, and depth-correlated death needs a lower learning rate.
157+
- Detection requires checking after the ReLU layer, not after the Linear layer. The Linear output being negative is expected; it's the ReLU output being zero for all samples that indicates death.
158+
- LeakyReLU, PReLU, ELU, and GELU all avoid this problem by having non-zero gradients for negative inputs.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
## Prerequisites
2+
3+
Before attempting this problem, you should be comfortable with:
4+
5+
- **Multi-Head Self-Attention** - GQA modifies how heads share K and V, so understanding standard MHA with independent KV heads per query head is essential
6+
- **KV-Cache** - GQA's primary motivation is reducing the KV-Cache memory footprint during inference, so understanding the cache size problem comes first
7+
- **Tensor Reshaping** - The implementation requires `view`, `transpose`, and `repeat_interleave` to reshape between `(B, T, D)` and `(B, heads, T, head_dim)` formats
8+
9+
---
10+
11+
## Concept
12+
13+
Standard Multi-Head Attention gives every query head its own K and V projections. During inference with KV-Cache, all those K and V tensors must be stored, and the memory cost scales linearly with the number of heads. Grouped Query Attention reduces this by sharing K, V across groups of query heads.
14+
15+
With $h$ query heads and $g$ KV heads, each KV head serves $h/g$ query heads. The key operation is `repeat_interleave`: it expands the $g$ KV heads to $h$ by repeating each one $h/g$ times, making the shapes match for standard attention math. When $g = h$, GQA is identical to MHA. When $g = 1$, it becomes Multi-Query Attention.
16+
17+
---
18+
19+
## Solution
20+
21+
### Intuition
22+
23+
Project x into Q with `num_heads` heads and K, V with `num_kv_heads` heads. Reshape into the multi-head format. Expand K and V by repeating each KV head to match Q's head count. From here, it's standard scaled dot-product attention with a causal mask. Concatenate the heads and apply the output projection.
24+
25+
### Implementation
26+
27+
::tabs-start
28+
```python
29+
import torch
30+
import torch.nn as nn
31+
from torchtyping import TensorType
32+
33+
class GroupedQueryAttention(nn.Module):
34+
def __init__(self, model_dim: int, num_heads: int, num_kv_heads: int):
35+
super().__init__()
36+
torch.manual_seed(0)
37+
self.num_heads = num_heads
38+
self.num_kv_heads = num_kv_heads
39+
self.head_dim = model_dim // num_heads
40+
41+
self.q_proj = nn.Linear(model_dim, num_heads * self.head_dim, bias=False)
42+
self.k_proj = nn.Linear(model_dim, num_kv_heads * self.head_dim, bias=False)
43+
self.v_proj = nn.Linear(model_dim, num_kv_heads * self.head_dim, bias=False)
44+
self.output_proj = nn.Linear(num_heads * self.head_dim, model_dim, bias=False)
45+
46+
def forward(self, x: TensorType[float]) -> TensorType[float]:
47+
B, T, D = x.shape
48+
49+
# Project to Q, K, V and reshape into heads
50+
q = self.q_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
51+
k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2)
52+
v = self.v_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2)
53+
54+
# Expand K, V to match Q's num_heads by repeating each KV head
55+
repeats = self.num_heads // self.num_kv_heads
56+
k = k.repeat_interleave(repeats, dim=1)
57+
v = v.repeat_interleave(repeats, dim=1)
58+
59+
# Scaled dot-product attention with causal mask
60+
scores = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5)
61+
mask = torch.tril(torch.ones(T, T, device=x.device))
62+
scores = scores.masked_fill(mask == 0, float('-inf'))
63+
weights = torch.softmax(scores, dim=-1)
64+
65+
out = (weights @ v).transpose(1, 2).contiguous().view(B, T, -1)
66+
return torch.round(self.output_proj(out), decimals=4)
67+
```
68+
::tabs-end
69+
70+
71+
### Walkthrough
72+
73+
For `model_dim=8`, `num_heads=4`, `num_kv_heads=2`:
74+
75+
| Step | Operation | Shape |
76+
|---|---|---|
77+
| Q projection | `q_proj(x)` then reshape | `(B, 4, T, 2)` -- 4 query heads, head_dim=2 |
78+
| K projection | `k_proj(x)` then reshape | `(B, 2, T, 2)` -- only 2 KV heads |
79+
| V projection | `v_proj(x)` then reshape | `(B, 2, T, 2)` -- only 2 KV heads |
80+
| Expand K | `repeat_interleave(2, dim=1)` | `(B, 4, T, 2)` -- each KV head repeated 2x |
81+
| Expand V | `repeat_interleave(2, dim=1)` | `(B, 4, T, 2)` -- now matches Q |
82+
| Attention | Standard scaled dot-product | `(B, 4, T, 2)` per head |
83+
| Concat + project | Merge heads, output projection | `(B, T, 8)` |
84+
85+
**Memory savings:** K and V projections produce `num_kv_heads * head_dim = 2 * 2 = 4` values per token instead of `num_heads * head_dim = 4 * 2 = 8`. In the KV-Cache, this halves the memory per layer. For Llama 2 70B (64 query heads, 8 KV heads), the savings are 8x.
86+
87+
### Time & Space Complexity
88+
89+
- Time: $O(T^2 \cdot d)$ for attention (same as MHA, since K, V are expanded before the attention computation)
90+
- Space: $O(g \cdot T \cdot d_h)$ for KV-Cache storage, where $g$ is the number of KV heads and $d_h$ is the head dimension. This is $g/h$ of standard MHA.
91+
92+
---
93+
94+
## Common Pitfalls
95+
96+
### Using `repeat` Instead of `repeat_interleave`
97+
98+
`repeat` tiles the entire tensor, while `repeat_interleave` repeats each element individually. With 2 KV heads and 4 query heads, you want `[KV0, KV0, KV1, KV1]`, not `[KV0, KV1, KV0, KV1]`.
99+
100+
::tabs-start
101+
```python
102+
# Wrong: repeat tiles the whole tensor
103+
k = k.repeat(1, repeats, 1, 1) # [KV0, KV1, KV0, KV1] -- wrong grouping!
104+
105+
# Correct: repeat_interleave repeats each element
106+
k = k.repeat_interleave(repeats, dim=1) # [KV0, KV0, KV1, KV1] -- correct groups
107+
```
108+
::tabs-end
109+
110+
111+
### Forgetting the Causal Mask
112+
113+
GQA is used in decoder-only models (GPT, Llama) where tokens must not attend to future positions. Without the lower-triangular mask, the model breaks autoregressive generation.
114+
115+
::tabs-start
116+
```python
117+
# Wrong: no causal mask
118+
weights = torch.softmax(scores, dim=-1)
119+
120+
# Correct: apply causal mask before softmax
121+
mask = torch.tril(torch.ones(T, T, device=x.device))
122+
scores = scores.masked_fill(mask == 0, float('-inf'))
123+
weights = torch.softmax(scores, dim=-1)
124+
```
125+
::tabs-end
126+
127+
128+
### Wrong Reshape Order
129+
130+
The view/transpose sequence matters. Q has `num_heads` heads, but K and V have `num_kv_heads` heads. Using `num_heads` for the K reshape would produce incorrect head dimensions.
131+
132+
::tabs-start
133+
```python
134+
# Wrong: reshaping K with num_heads
135+
k = self.k_proj(x).view(B, T, self.num_heads, self.head_dim) # Shape mismatch!
136+
137+
# Correct: reshaping K with num_kv_heads
138+
k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim)
139+
```
140+
::tabs-end
141+
142+
143+
---
144+
145+
## In the GPT Project
146+
147+
GQA is the attention variant used by Llama 2/3, Mistral, and Gemma. In a full GPT implementation, you would combine GQA with KV-Cache from the previous problem: the cache stores only `num_kv_heads` worth of K, V per layer (not `num_heads`), giving a direct memory reduction proportional to the head ratio.
148+
149+
---
150+
151+
## Key Takeaways
152+
153+
- GQA shares K and V across groups of query heads, reducing KV-Cache memory by a factor of `num_heads / num_kv_heads` with minimal quality loss.
154+
- `repeat_interleave` is the key operation: it expands each KV head to serve its assigned group of query heads, making the shapes compatible for standard attention.
155+
- GQA generalizes both MHA ($g = h$) and MQA ($g = 1$). Most production models use an intermediate value (e.g., Llama 2 70B uses 64 query heads with 8 KV heads).
156+
- The attention computation after expansion is identical to standard MHA. The savings come entirely from the smaller projection layers and smaller cache.

0 commit comments

Comments
 (0)