Neural Network from Scratch · lesson 06/7
Adam Optimizer
Plain SGD moves every weight by the same learning rate times its own gradient, so parameters with tiny gradients crawl while large-gradient parameters lurch. Adam keeps two running averages per parameter — the gradient and its square — and uses them to give every parameter a similar step size.
The idea
At step , with gradient :
- is the first moment — a smoothed gradient, which is momentum. It cancels the noise that flips sign step to step.
- is the second moment — a smoothed squared gradient, which measures how large this parameter's gradients usually are.
Both start at zero, so the early estimates are biased toward zero. Bias correction divides that out:
The ratio is close to ±1 whenever the gradient is consistent, so the parameter moves about per step regardless of gradient scale. Defaults: β₁ = 0.9, β₂ = 0.999, ε = 1e-8.
One caveat: mixing L2 regularization into the gradient (adding λθ to g) also gets rescaled by . AdamW decouples it by shrinking the weight directly, which is why transformers use it.
Worked example
Feed a constant gradient of 1.0 to a parameter with η = 0.1. Start from m = v = 0.
| Step | m | v | m̂ | v̂ | θ |
|---|---|---|---|---|---|
| 1 | 0.100 | 0.001 | 1.0 | 1.0 | 0.9 |
| 2 | 0.190 | 0.002 | 1.0 | 1.0 | 0.8 |
| 3 | 0.271 | 0.003 | 1.0 | 1.0 | 0.7 |
Both estimates correct to 1.0, so every step is exactly η = 0.1. Without bias correction the first step would use 0.1/√0.001 ≈ 3.162, a step of 0.3162 — three times too large.
In code
import torch
p = torch.nn.Parameter(torch.tensor([1.0]))
opt = torch.optim.Adam([p], lr=0.1)
for _ in range(3):
opt.zero_grad()
(1.0 * p).sum().backward() # constant gradient of 1.0
opt.step()
print(round(p.item(), 6)) # 0.9, then 0.8, then 0.7
m = v = 0.0
w, b1, b2, lr, eps = 1.0, 0.9, 0.999, 0.1, 1e-8
for t in range(1, 4):
g = 1.0
m = b1 * m + (1 - b1) * g
v = b2 * v + (1 - b2) * g ** 2
m_hat, v_hat = m / (1 - b1 ** t), v / (1 - b2 ** t)
w -= lr * m_hat / (v_hat ** 0.5 + eps)
print(t, round(w, 6), round(m_hat, 6), round(v_hat, 6)) # t 0.9 1.0 1.0, then 0.8, then 0.7Check yourself
- What quantity does
vestimate, and why does dividing by its square root make the step size independent of gradient scale? - A parameter has had one gradient so far, equal to
0.1. What arem̂andv̂, and what step does Adam take? - Why does
AdamWexist as a separate optimizer fromAdamwithweight_decayset?
Key takeaways
- Adam stores a smoothed gradient and a smoothed squared gradient for every parameter.
- Bias correction is what makes the first few steps the right size instead of 0.1× or 30× too big.
- The normalizer gives each parameter a step near
η, which is why Adam tolerates less tuning than SGD.