Neural networks (the parts that survived)
Layers, activations, backprop, and the training loop in the form it still lives in today.
Why this matters
Every Transformer is, ultimately, a stack of fully-connected layers, residuals, and normalisations. If you understand a deep MLP β initialisation, optimisation, residual connections, regularisation β you understand 70% of a modern LLM block. This lesson rebuilds that foundation in a practical way (no derivatives by hand), aimed at someone who will spend the next year reading transformer papers.
Learning objectives
- Build a feed-forward network in PyTorch.
- Reason about activation functions, initialisation, and depth.
- Understand residual connections and why they matter.
- Apply dropout and weight decay correctly.
- Diagnose common training pathologies (NaN, dead neurons, exploding loss).
1. The MLP and what each piece does
A feed-forward network is just Linear β activation β Linear β activation β ... β output.
import torch.nn as nn
class MLP(nn.Module):
def __init__(self, d_in, d_hidden, d_out, depth=3):
super().__init__()
layers = [nn.Linear(d_in, d_hidden), nn.GELU()]
for _ in range(depth - 2):
layers += [nn.Linear(d_hidden, d_hidden), nn.GELU()]
layers += [nn.Linear(d_hidden, d_out)]
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)Even after 50 papers, the transformer FFN is just this: Linear β activation β Linear, with d_hidden = 4 Γ d_model.
Activations β the modern shortlist
| Activation | Used by | Note |
|---|---|---|
| ReLU | Older models | max(0, x) β fast but "dies" (zero gradient when negative). |
| GELU | GPT-2/3, BERT | Smoother ReLU; standard in Transformers until 2023. |
| SiLU / Swish | Llama 1/2 | x * sigmoid(x) β slightly better gradient flow. |
| SwiGLU | Llama 2/3, Mistral, Qwen | Gated variant: (SiLU(xW1) * (xW3)) W2. ~SOTA for FFN today. |
| GeGLU | PaLM, Gemma | Gated GELU. |
You will reimplement SwiGLU in Phase 2.
Initialisation matters more than you think
Random init is not zero-or-one β the scale matters:
- Xavier (Glorot) β for tanh.
- Kaiming (He) β for ReLU/GELU.
std = sqrt(2 / fan_in). - Scaled init in transformers β
std = 0.02(GPT-2 convention) or scaled by1/sqrt(2L)for residual layers (Megatron / GPT-J).
def init_linear(m):
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
model.apply(init_linear)Get this wrong and your loss diverges in step 200.
2. Residual connections (the single most important deep-learning trick)
Without residuals, networks beyond ~10 layers stop training (the He et al. ResNet paper, 2015).
y = layer(x) + x # residualTwo ideas this enables:
- Gradient flow β the identity path lets gradients reach early layers without vanishing.
- Iterative refinement β each layer can be a small correction to the running representation.
Every Transformer block is:
x = x + Attention(LayerNorm(x)) # pre-norm style
x = x + FFN(LayerNorm(x))Without that x + ..., GPT-style models would not train past 6-12 layers.
Pre-norm vs post-norm
Original Transformer ("Attention Is All You Need") used post-norm: LayerNorm(x + Sublayer(x)). Modern LLMs use pre-norm: x + Sublayer(LayerNorm(x)). Pre-norm is much more stable at depth β every model after 2020 uses it.
3. Regularisation β when and why
Pretraining LLMs almost never uses dropout (the data is huge; dropout actively hurts at scale). But fine-tuning small models, training small heads, or building from scratch β you will use these tricks:
- Dropout
p=0.1β randomly zeros activations; classical regulariser. - Weight decay
0.01-0.1β L2 penalty on weights, decoupled inAdamW. - Label smoothing β replace one-hot target with
(1-Ξ΅) one_hot + Ξ΅/Vuniform; less common in modern LLM SFT. - Stochastic depth β randomly drop residual blocks during training (used in some vision transformers).
- Early stopping β irrelevant for big LLM pretraining (one pass over data).
For LLM SFT in 2026 you will typically run:
dropout = 0.0weight_decay = 0.0for embeddings/biases,0.1elsewhere- 1-3 epochs (you usually overfit after that)
4. Optimisers under the hood (just enough)
AdamW keeps two running averages per parameter:
m_tβ exponential average of gradients (momentum).v_tβ exponential average of squared gradients (variance).
Update step (simplified):
m_t = Ξ²1 m_{t-1} + (1-Ξ²1) g_t
v_t = Ξ²2 v_{t-1} + (1-Ξ²2) g_t^2
ΞΈ β ΞΈ - lr * m_t / (sqrt(v_t) + Ξ΅) - lr * weight_decay * ΞΈThe m / sqrt(v) term gives per-parameter adaptive learning rates: parameters with consistently large gradients get smaller effective lrs. That is why Adam works on noisy LLM gradients where vanilla SGD would diverge.
Memory cost: 2 extra tensors per parameter β roughly 3x the model's memory just for optimiser state. That is why 8-bit Adam (bitsandbytes) and FSDP optimizer sharding matter at scale.
5. Common training pathologies (read this twice)
- Loss = NaN at step 1 β bad init or learning rate too high. Halve the lr, retry.
- Loss explodes mid-training β gradient spike. Add
clip_grad_norm_(1.0). If still bad, lower lr or use bf16 instead of fp16. - Loss stuck at log(V) β model output is uniform. Check that the targets are shifted by 1 token (next-token prediction, not same-token).
- Loss decreases then plateaus high β too small a model, or learning rate too low, or data is too noisy.
- Train loss << val loss β overfitting. Reduce model size, add regularisation, get more data.
- Loss the same at any lr β masking bug; gradients are not flowing where you think.
The senior trick: plot per-layer parameter norms and gradient norms. If gradient norm is exploding in layer 1 only, you have a vanishing-gradient analogue from the top.
6. Putting it together β train an MLP on MNIST
import torch, torch.nn as nn, torch.nn.functional as F
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
train = datasets.MNIST(".", train=True, download=True, transform=transforms.ToTensor())
test = datasets.MNIST(".", train=False, download=True, transform=transforms.ToTensor())
tr = DataLoader(train, batch_size=256, shuffle=True)
te = DataLoader(test, batch_size=512)
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1, self.fc2, self.fc3 = nn.Linear(784, 256), nn.Linear(256, 256), nn.Linear(256, 10)
def forward(self, x):
x = x.view(x.size(0), -1)
x = F.gelu(self.fc1(x))
x = F.gelu(self.fc2(x))
return self.fc3(x)
m = MLP().cuda()
opt = torch.optim.AdamW(m.parameters(), lr=3e-4, weight_decay=0.01)
for epoch in range(3):
m.train()
for x, y in tr:
x, y = x.cuda(), y.cuda()
loss = F.cross_entropy(m(x), y)
loss.backward()
torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
opt.step(); opt.zero_grad()
m.eval()
with torch.no_grad():
correct = sum((m(x.cuda()).argmax(-1) == y.cuda()).sum().item() for x, y in te)
print(f"epoch {epoch}: acc={correct/len(test):.4f}")You should hit ~98% in three epochs.
Hands-on lab (3 hours)
mlp_drills.ipynb:
- Modify the MLP above to use ReLU instead of GELU. Train. Compare convergence.
- Add a residual connection between fc1 and fc2 outputs. (Hint: project shapes if mismatched.) Show training curve.
- Increase depth to 12 layers without residuals. Observe training fails or stagnates.
- Add residuals to the same 12-layer net β show it now trains.
- Add
nn.LayerNorm(256)before each linear. Retrain. - Plot per-layer gradient norms during training. Discuss what you see.
- Bonus: implement SwiGLU as a module:
out = (SiLU(x W1) * (x W3)) @ W2. Replace GELU layer.
Common pitfalls
- No init β PyTorch's default Linear init is fine for small nets but unstable for deep ones.
- Forgetting LayerNorm before residuals in deep custom networks.
- Calling
.zero_grad()after.step()β fine, but easy to forget; loss seems "sticky." - Mixing data on CPU and GPU β silent data-copy slowdowns.
- Using too-small
batch_sizewith AdamW β noisy gradient stats (v_t) β instability.
Self-check
- Why did residual connections enable deep networks?
- What is SwiGLU and why is it preferred over GELU in modern LLMs?
- Why is dropout typically off during LLM pretraining?
- What is the memory cost of Adam vs SGD?
- Pre-norm vs post-norm β which do modern LLMs use, and why?
References
- He et al. (2015), "Deep Residual Learning" (ResNet).
- Ba et al. (2016), "Layer Normalization."
- Loshchilov & Hutter (2019), "Decoupled Weight Decay Regularization" (AdamW).
- Shazeer (2020), "GLU Variants Improve Transformer" (SwiGLU/GeGLU origin).
- 3Blue1Brown, "Backpropagation, intuitively."
Sign in to save your progress and earn badges.