AdamW is the default optimizer for training transformers. This notebook builds it step by step, showing what each component does and why.
We’ll implement: 1. SGD — the simplest optimizer (just follow the gradient) 2. Momentum — remember past gradients for smoother updates 3. Adam — adaptive learning rates per parameter 4. AdamW — fix weight decay to be decoupled from gradients 5. Comparison — train with each and see the difference
Implement AdamW yourself — moments, bias correction, decoupled weight decay — until the update rule has no mystery left.
🏗️ Chapter milestone: M8 — prove your from-scratch optimizer on your own model — the exercise at the end of this chapter; see The Project.
Tip⏱️ Session S2.8
One session, ending in 🏗️ M8 — prove your from-scratch optimizer on your model. Cards, prerequisites, and done-when tests: the Session Guide.
Setup
# Setup — works from a repo checkout (via ../src) or standalone on Colabimport syssys.path.insert(0, '../src')try:import ai_playground # noqa: F401exceptImportError:%pip install -q git+https://github.com/ggreg/ai_playground.git%config InlineBackend.figure_format ='retina'from ai_playground.plotting import apply_plot_styleapply_plot_style()
Code
import syssys.path.insert(0, '../src')import torchimport torch.nn as nnimport torch.nn.functional as Fimport matplotlib.pyplot as pltimport numpy as npimport mathfrom ai_playground.models import Transformer, TransformerConfigfrom ai_playground.training.optimizers import AdamWFromScratchtorch.manual_seed(42)device ='cpu'
1. SGD: The Simplest Optimizer
Stochastic Gradient Descent: move parameters in the opposite direction of the gradient.
θ = θ - lr × gradient
Simple, but has two problems: - Same LR for all parameters — some weights need big steps, others small - No memory — doesn’t learn from past gradients, easily zig-zags
Code
# Implement SGD from scratchclass SGDFromScratch:def__init__(self, params, lr=0.01):self.params =list(params)self.lr = lrdef step(self):with torch.no_grad():for p inself.params:if p.grad isnotNone:# The entire algorithm: move opposite to gradient p -=self.lr * p.graddef zero_grad(self):for p inself.params:if p.grad isnotNone: p.grad.zero_()print('SGD: θ = θ - lr × ∇loss')print('One line of math. Simple but slow to converge.')
SGD: θ = θ - lr × ∇loss
One line of math. Simple but slow to converge.
2. Adding Momentum
Instead of following only the current gradient, accumulate a running average of past gradients. This smooths out noise and accelerates through consistent directions (like a ball rolling downhill).
m = β × m + (1 - β) × gradient # exponential moving average
θ = θ - lr × m
Typical β = 0.9 (remember ~10 past gradients).
Code
class SGDMomentumFromScratch:def__init__(self, params, lr=0.01, beta=0.9):self.params =list(params)self.lr = lrself.beta = betaself.m = [torch.zeros_like(p) for p inself.params] # momentum bufferdef step(self):with torch.no_grad():for p, m inzip(self.params, self.m):if p.grad isnotNone:# Update momentum: weighted average of past and current gradient m.mul_(self.beta).add_(p.grad, alpha=1-self.beta)# Step in the smoothed direction p -=self.lr * mdef zero_grad(self):for p inself.params:if p.grad isnotNone: p.grad.zero_()print('SGD + Momentum: m = β×m + (1-β)×∇loss, θ = θ - lr×m')print('Smooths noisy gradients and accelerates in consistent directions.')
SGD + Momentum: m = β×m + (1-β)×∇loss, θ = θ - lr×m
Smooths noisy gradients and accelerates in consistent directions.
3. Adam: Adaptive Learning Rates
Different parameters need different learning rates. Adam tracks two things:
m (first moment): running average of gradients → direction
v (second moment): running average of squared gradients → magnitude
Parameters with large gradients get smaller effective LR. Parameters with small gradients get larger effective LR. Self-tuning!
m = β1 × m + (1 - β1) × g # momentum (direction)
v = β2 × v + (1 - β2) × g² # RMS of gradients (scale)
θ = θ - lr × m / (√v + ε) # adaptive step size
class AdamFromScratch:"""Adam without weight decay — for comparison with AdamW."""def__init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8):self.params =list(params)self.lr = lrself.beta1, self.beta2 = betasself.eps = epsself.t =0self.m = [torch.zeros_like(p) for p inself.params] # first momentself.v = [torch.zeros_like(p) for p inself.params] # second momentdef step(self):self.t +=1with torch.no_grad():for p, m, v inzip(self.params, self.m, self.v):if p.grad isNone:continue g = p.grad# Update moments m.mul_(self.beta1).add_(g, alpha=1-self.beta1) v.mul_(self.beta2).addcmul_(g, g, value=1-self.beta2)# Bias correction (moments start at 0, so they're# underestimated in early steps) m_hat = m / (1-self.beta1 **self.t) v_hat = v / (1-self.beta2 **self.t)# Adaptive update: large v → small step, small v → large step p.addcdiv_(m_hat, v_hat.sqrt().add_(self.eps), value=-self.lr)def zero_grad(self):for p inself.params:if p.grad isnotNone: p.grad.zero_()print('Adam: m = β1×m + (1-β1)×g, v = β2×v + (1-β2)×g²')print(' θ = θ - lr × m̂ / (√v̂ + ε)')print('Each parameter gets its own effective learning rate.')
Adam: m = β1×m + (1-β1)×g, v = β2×v + (1-β2)×g²
θ = θ - lr × m̂ / (√v̂ + ε)
Each parameter gets its own effective learning rate.
4. AdamW: Decoupled Weight Decay
Weight decay prevents overfitting by shrinking parameters toward zero. Original Adam applied decay through the gradient (L2 regularization), but this interacts badly with adaptive learning rates.
AdamW fixes this by applying weight decay directly to parameters, independent of the gradient:
θ = θ × (1 - lr × λ) # shrink weights (decoupled!)
θ = θ - lr × m̂ / (√v̂ + ε) # then do the Adam update
This is the standard optimizer for all modern LLM training.
See src/ai_playground/training/optimizers.py for the full implementation.
Code
# Show our from-scratch AdamW matches PyTorch'storch.manual_seed(42)cfg = TransformerConfig( vocab_size=8, dim=32, n_layers=2, n_heads=4, n_kv_heads=2, max_seq_len=16, ffn_dim_multiplier=2.0,)# Model 1: PyTorch AdamWmodel_pt = Transformer(cfg)opt_pt = torch.optim.AdamW(model_pt.parameters(), lr=1e-3, weight_decay=0.01)# Model 2: Our from-scratch AdamWtorch.manual_seed(42)model_ours = Transformer(cfg)opt_ours = AdamWFromScratch(model_ours.parameters(), lr=1e-3, weight_decay=0.01)# One training step on same dataVOCAB = ['<pad>', 'the', 'cat', 'dog', 'sat', 'ran', 'on', 'mat']tok2id = {t: i for i, t inenumerate(VOCAB)}train_x = torch.tensor([[tok2id[w] for w in'the cat sat on the'.split()]])train_y = torch.tensor([[tok2id[w] for w in'cat sat on the mat'.split()]])for name, model, opt in [('PyTorch', model_pt, opt_pt), ('Ours', model_ours, opt_ours)]: logits = model(train_x) loss = F.cross_entropy(logits.view(-1, 8), train_y.view(-1)) opt.zero_grad() loss.backward() opt.step()# Compare weights after one stepmax_diff =max( (p1 - p2).abs().max().item()for p1, p2 inzip(model_pt.parameters(), model_ours.parameters()))print(f'Max weight difference after 1 step: {max_diff:.10f}')print(f'Match: {max_diff <1e-5} ✅'if max_diff <1e-5elsef'Mismatch! ❌')
Max weight difference after 1 step: 0.0000000075
Match: True ✅
5. Optimizer Comparison: Training a Tiny Transformer
Let’s train the same model with SGD, SGD+Momentum, Adam, and AdamW and compare convergence.
Show plotting code
sentences = ['the cat sat on the mat', 'the dog ran on the mat','the cat ran on the mat', 'the dog sat on the mat']all_tok = [[tok2id[w] for w in s.split()] for s in sentences]data_x = torch.tensor([t[:-1] for t in all_tok])data_y = torch.tensor([t[1:] for t in all_tok])def train_with_optimizer(make_opt_fn, name, n_steps=200, seed=42): torch.manual_seed(seed) model = Transformer(cfg) opt = make_opt_fn(model.parameters()) losses = [] model.train()for step inrange(n_steps): logits = model(data_x) loss = F.cross_entropy(logits.view(-1, 8), data_y.view(-1))ifhasattr(opt, 'zero_grad'): opt.zero_grad()else:for p in model.parameters():if p.grad isnotNone: p.grad.zero_() loss.backward() opt.step() losses.append(loss.item())print(f'{name:<25s} final loss: {losses[-1]:.4f}')return lossesoptimizers = [ ('SGD (lr=0.1)', lambda p: SGDFromScratch(p, lr=0.1), '#94A3B8'), ('SGD+Momentum (lr=0.1)', lambda p: SGDMomentumFromScratch(p, lr=0.1), '#F97316'), ('Adam (lr=1e-3)', lambda p: AdamFromScratch(p, lr=1e-3), '#FF6B6B'), ('AdamW (lr=1e-3)', lambda p: AdamWFromScratch(p, lr=1e-3, weight_decay=0.01), '#4ECDC4'),]fig, ax = plt.subplots(figsize=(14, 6))for name, make_fn, color in optimizers: losses = train_with_optimizer(make_fn, name) ax.plot(losses, label=name, color=color, linewidth=2)ax.axhline(y=np.log(8), color='#CBD5E1', linestyle=':', label='Random guess')ax.set_xlabel('Training Step', fontsize=12)ax.set_ylabel('Loss', fontsize=12)ax.set_title('Optimizer Comparison on Tiny Transformer', fontsize=14, fontweight='bold')ax.legend(fontsize=10)ax.grid(True, alpha=0.3)plt.tight_layout()plt.show()
SGD (lr=0.1) final loss: 0.2833
SGD+Momentum (lr=0.1) final loss: 0.2815
Adam (lr=1e-3) final loss: 0.3153
AdamW (lr=1e-3) final loss: 0.3156
6. Visualize What Adam Tracks
Let’s peek inside Adam’s state during training to see the first moment (m) and second moment (v) for one parameter.
Show plotting code
# Train and record Adam's internal statetorch.manual_seed(42)model = Transformer(cfg)opt = AdamWFromScratch(model.parameters(), lr=1e-3, weight_decay=0.01)# Track the first parameter of the embeddingtarget_param = model.tok_embeddings.weightgradient_history = []m_history = []v_history = []lr_effective = []model.train()for step inrange(200): logits = model(data_x) loss = F.cross_entropy(logits.view(-1, 8), data_y.view(-1)) opt.zero_grad() loss.backward()# Record state before step g = target_param.grad[1, 0].item() # "the" embedding, dim 0 gradient_history.append(g) opt.step()# Read Adam's state for this parameter state = opt.state[target_param] m_val = state['m'][1, 0].item() v_val = state['v'][1, 0].item() t = state['step']# Bias-corrected values m_hat = m_val / (1-0.9** t) v_hat = v_val / (1-0.999** t) eff_lr =1e-3*abs(m_hat) / (math.sqrt(v_hat) +1e-8) if v_hat >0else0 m_history.append(m_hat) v_history.append(math.sqrt(v_hat)) lr_effective.append(eff_lr)fig, axes = plt.subplots(4, 1, figsize=(14, 12), sharex=True)axes[0].plot(gradient_history, color='#94A3B8', linewidth=1, alpha=0.7)axes[0].set_ylabel('Raw gradient', fontsize=11)axes[0].set_title('AdamW Internals for "the" embedding[0]', fontsize=14, fontweight='bold')axes[0].grid(True, alpha=0.3)axes[1].plot(m_history, color='#FF6B6B', linewidth=2)axes[1].set_ylabel('m̂ (first moment)', fontsize=11)axes[1].set_title('Smoothed gradient direction (momentum)', fontsize=12)axes[1].grid(True, alpha=0.3)axes[2].plot(v_history, color='#F97316', linewidth=2)axes[2].set_ylabel('√v̂ (second moment)', fontsize=11)axes[2].set_title('Gradient magnitude (for adaptive LR)', fontsize=12)axes[2].grid(True, alpha=0.3)axes[3].plot(lr_effective, color='#4ECDC4', linewidth=2)axes[3].set_ylabel('Effective step size', fontsize=11)axes[3].set_xlabel('Training Step', fontsize=12)axes[3].set_title('lr × |m̂| / (√v̂ + ε) — how far this parameter actually moves', fontsize=12)axes[3].grid(True, alpha=0.3)plt.tight_layout()plt.show()print('- Raw gradient is noisy → m̂ (momentum) smooths it')print('- √v̂ tracks gradient scale → dividing by it normalizes the step size')print('- Effective step size adapts per-parameter: big gradients → smaller steps')
- Raw gradient is noisy → m̂ (momentum) smooths it
- √v̂ tracks gradient scale → dividing by it normalizes the step size
- Effective step size adapts per-parameter: big gradients → smaller steps
🏗️ Project milestone M8 — trust your optimizer
Run 20 training steps of your model twice from identical init and data order: once with AdamWFromScratch, once with torch.optim.AdamW (same hyperparameters, fp32). Compare every parameter tensor:
worst =max((a - b).abs().max().item()for a, b inzip(m1.parameters(), m2.parameters()))assert worst <1e-5, worst
Accept when: the assert passes on your model — you now understand every update your release run made. Part of The Project: Serve Your Own LLM.
Session S2.8 — record the worst deviation in metrics.json['m8_optimizer_max_diff']; verify: uv run pytest tests/milestones/test_m08_adamw.py.
Key Takeaways
SGD is simple but slow — same LR for everything, no memory of past gradients.
Momentum smooths out noise by averaging past gradients — a big improvement.
Adam adds adaptive per-parameter LRs via the second moment — the standard for deep learning.
AdamW fixes weight decay by decoupling it from the gradient update — the standard for LLM training.
The entire AdamW algorithm is ~10 lines of math — see optimizers.py:70-85.
Common hyperparameters: β1=0.9, β2=0.95 (LLMs) or 0.999 (default), ε=1e-8, weight_decay=0.1.
🔨 From-scratch project: p2 — The training stack from a blank file
This chapter built AdamW with the repo’s code in front of you; M8 proved it on your model. The phase’s from-scratch project takes the reference away: your own AdamW (matched to torch.optim.AdamW to 1e-5), your own warmup+cosine schedule, and gradient accumulation proven exactly equivalent to the full batch — including the mean-loss rescaling trap this phase kept warning you about.