Optimizers
Momentum and Adam.
Create a free account Try part of it here
Why it matters
After every batch, training needs a rule for changing the weights. That rule is the optimizer. Plain gradient descent works, but it's slow on the kind of landscape real networks have. Two ideas fix most of that: momentum, and a separate step size for each weight. Adam combines both, and its cousin AdamW is the optimizer behind nearly every large language model (LLM) you've used. Once you know what each one does, you'll know which settings to change when training misbehaves.
What you'll be able to do
- Explain why plain SGD zig-zags in long, narrow valleys
- Describe momentum and per-parameter step sizes, and how Adam combines them
- Compare SGD, momentum and Adam on a real training run
- Know why AdamW is the default for training transformers, and what it costs in memory
What it covers
- The problem: narrow valleys
- Momentum: keep rolling
- Adam: momentum plus a step size per parameter
- Worked example: Adam's first step for a loud and a quiet gradient
- Three optimizers in one valley
- On a real network
- Rules of thumb
The problem: narrow valleys
Picture the loss as a landscape. Each spot on it is one choice of weights, and its height is the loss. Training is a walk downhill. Gradient descent always steps the steepest way down.
Now picture a long, narrow canyon. Its walls are steep, but its floor slopes gently toward the lowest point, far away. Stand on one wall, and the steepest way down points across the canyon, not along it. So you step across and overshoot onto the other wall. Then you step back. You bounce from wall to wall while creeping along the floor.
Real loss landscapes are often like this. They're much steeper in some directions than in others.
Here's a small one with two weights. Call them and . The loss is:
In plain words: square x, square y, multiply the y part by 50, and add them. The lowest point is at x = 0, y = 0. Move the same distance away from it, and the loss climbs 50 times faster in the y direction than in the x direction.
Start at x = −4, y = 1. The loss there is 16 + 50 × 1 = 66.
From the derivatives lessons, the slope of is . In the same way, the slope of is . Plain gradient descent subtracts the learning rate times the slope. Take a learning rate of 0.019:
- For x, the slope is 2 × (−4) = −8. Subtracting 0.019 × (−8) means adding 0.152, so x becomes −3.848.
- For y, the slope is 100 × 1 = 100. The step is 0.019 × 100 = 1.9, so y becomes 1 − 1.9 = −0.9.
So y shot right past 0 to the other side. Here are five steps:
lr = 0.019 x, y = -4.0, 1.0 for step in range(5): x, y = x - lr * 2 * x, y - lr * 100 * y # one plain gradient descent step print(step + 1, round(x, 3), round(y, 3))
The y column flips sign every step: −0.9, 0.81, −0.729. That's the bounce across the canyon. The x column shrinks by only 3.8% per step: −3.848, −3.702, −3.561. That's the slow creep along the floor.
Why not use a bigger learning rate to speed up x? Because y would then overshoot by more than it started with. Its bounce would grow every step until the numbers blow up. Here, any learning rate above 0.02 does that.
Go deeper Why 0.02 is the limit here
Each plain step changes y like this:
Here (eta) is the learning rate.
In plain words: every step multiplies y by the same number, 1 minus 100 times the learning rate. If that number is between −1 and 1, y shrinks toward 0. If it's beyond −1, y grows.
With a learning rate of 0.019, the multiplier is 1 − 1.9 = −0.9. The minus sign flips y to the other side each step, and 0.9 shrinks it by 10%. With 0.03, the multiplier is 1 − 3 = −2. Now y doubles and flips each step, and the loss explodes. The tipping point is 0.02, where the multiplier is exactly −1.
For x, the multiplier is 1 − 2 × 0.019 = 0.962. That's why x keeps 96.2% of its size each step.
for lr in [0.019, 0.02, 0.03]: print(lr, "y multiplier:", round(1 - 100 * lr, 3), " x multiplier:", round(1 - 2 * lr, 3))
On a narrow valley, a step big enough for the gentle direction overshoots in the steep one. Momentum and Adam exist to fix exactly this.
Momentum: keep rolling
Now swap the careful walker for a heavy ball. A rolling ball doesn't stop and pick a fresh direction each moment. It carries speed from before. Pushes that keep pointing the same way, along the canyon floor, build up speed. Pushes that flip back and forth, from wall to wall, mostly cancel out.
Momentum gives gradient descent that memory. It keeps a running velocity: one number per weight, written . Each step, the old velocity shrinks a little and the new gradient is added on. Then the weight moves by the velocity.
Here are the symbols, one at a time:
- is this step's gradient for the weight.
- (theta) is the weight itself.
- (eta) is the learning rate: how big a step to take.
- (mu) is how much of the old velocity to keep. It's usually 0.9.
- The arrow means "becomes".
In plain words: keep 90% of the old velocity and add the new gradient. Then move the weight downhill by the learning rate times the velocity. Plain gradient descent would move by the learning rate times the gradient. Momentum swaps the gradient for the velocity.
Try it with small numbers. The velocity starts at 0 and .
- Gradients that agree: 2, 2, 2, 2. The velocity goes 2, then 0.9 × 2 + 2 = 3.8, then 0.9 × 3.8 + 2 = 5.42, then 6.878. It keeps growing. That's speed building up along the floor.
- Gradients that flip: 2, −2, 2, −2. The velocity goes 2, then 0.9 × 2 − 2 = −0.2, then 0.9 × (−0.2) + 2 = 1.82, then −0.362. It stays small. The pushes across the canyon cancel. The across-valley components of the gradient flip sign and cancel; the along-valley components add up.
mu = 0.9 # how much old velocity to keep for grads in [[2, 2, 2, 2], [2, -2, 2, -2]]: v = 0.0 # velocity starts at zero speeds = [] for g in grads: v = mu * v + g # keep 90% of the old velocity, add the new gradient speeds.append(round(v, 3)) print(grads, "->", speeds)
It prints [2.0, 3.8, 5.42, 6.878] for the gradients that agree. For the ones that flip, it prints [2.0, -0.2, 1.82, -0.362].
Go deeper Momentum's top speed
If the gradient stays at forever, the velocity keeps growing but levels off. Each step adds , and older gradients fade by 0.9 each step. So the velocity heads toward:
In plain words: with , a steady gradient builds up to 10 times its own size.
So momentum can move up to 10 times as far per step as plain gradient descent at the same learning rate. That's why momentum runs often use a smaller learning rate. The comparison later in this lesson does: 0.004 for momentum, against 0.019 for plain gradient descent.
v = 0.0 for step in range(100): v = 0.9 * v + 2 # a steady gradient of 2 print(round(v, 2)) # close to 10 x 2 = 20
This prints 20.0: after 100 steps, the velocity is within a hair of its top speed.
v, mu = 0.0, 0.9 for g in [1.0, -1.0]: v = mu * v + g print(round(v, 2))
Show the answer
-0.1
After the first step, v is 1.0. Then 0.9 × 1.0 − 1.0 prints as -0.1. Two pushes in opposite directions leave almost no velocity.
Adam: momentum plus a step size per parameter
Momentum fixes the bouncing. A second problem remains. In a network, some weights get big gradients and others get tiny ones. With one learning rate for all of them, the big-gradient weights take huge steps. The tiny-gradient weights barely move.
Think of the volume-leveling setting in a music app. Loud songs get turned down and quiet songs get turned up, so everything plays at about the same volume. Adam does this for gradients. It tracks how big each weight's gradients usually are, and divides by that. Big gradients get turned down, and small ones get turned up. Adam also uses momentum. Its name is short for "adaptive moment estimation".
Two running averages
Picture tracking your weekly grocery bill with a running average. Each week, you keep 90% of the old average and mix in 10% of this week's bill. Recent weeks count most, and old weeks slowly fade away.
Adam keeps two averages like that for every weight. The first, , is a running average of the gradient. Its keep-fraction is (beta one), usually 0.9.
In plain words: keep 90% of the old average, and add 10% of this step's gradient. It's momentum again, in a slightly different form.
The second average uses the squared gradient, . Squaring makes every gradient positive, so this average ignores direction. It only tracks how big the gradients usually are. Adam calls it . (That's a new meaning for v. In Adam, it isn't a velocity.) Its keep-fraction is (beta two), usually 0.999.
In plain words: keep 99.9% of the old average of squared gradients, and add 0.1% of this step's squared gradient.
Both averages start at 0. Say the first gradient is 2. Then m = 0.9 × 0 + 0.1 × 2 = 0.2. And v = 0.999 × 0 + 0.001 × 4 = 0.004.
b1, b2 = 0.9, 0.999 g = 2.0 # this step's gradient m = b1 * 0.0 + (1 - b1) * g # average of gradients, starting from 0 v = b2 * 0.0 + (1 - b2) * g ** 2 # average of squared gradients, starting from 0 print(round(m, 4), round(v, 4))
It prints 0.2 and 0.004. Both are far too small, because starting at 0 drags them down. Adam fixes this with a bias correction, written and ("m-hat" and "v-hat"). On this first step, it turns 0.2 back into 2 and 0.004 back into 4. Its effect fades as training goes on.
Go deeper How the bias correction works
Number the steps , starting from 1. Then:
In plain words: early on, divide each average by a small number to scale it back up. As the steps go by, shrinks toward 0, so you're dividing by almost 1. The correction then does nothing.
On step 1, the first divisor is 1 − 0.9 = 0.1, and 0.2 ÷ 0.1 = 2. The second divisor is 1 − 0.999 = 0.001, and 0.004 ÷ 0.001 = 4.
t = 1 print(round(0.2 / (1 - 0.9 ** t), 4), round(0.004 / (1 - 0.999 ** t), 4))
This prints 2.0 and 4.0: the true gradient, 2, and its square, 4.
The update
One more symbol: (epsilon) is a tiny number, such as 0.00000001. It's there only so Adam never divides by zero.
In plain words: divide the averaged gradient by the square root of the averaged squared gradient. Multiply by the learning rate, and step downhill.
The square root undoes the squaring, so the top and bottom of that fraction have similar sizes. When a weight's gradients are steady, the fraction comes out close to 1, however big or small the gradients are. In the example above, it's 2 ÷ √4 = 2 ÷ 2 = 1. When gradients keep flipping direction, averages out small, and the step shrinks.
So weights with large, steady gradients don't take huge steps, and weights with tiny gradients aren't left behind. Each gets a step of roughly size . That makes Adam much less sensitive to the learning rate. Its defaults are , and , which is 0.001.
Show the answer
Scales each parameter's step by how large its gradients usually are
Dividing by √v gives every parameter a step of roughly the learning rate's size. It's the volume leveling: big gradients turned down, small ones turned up.
Worked example: Adam's first step for a loud and a quiet gradient
Two weights get very different gradients on the first step: 100 and 0.001. The learning rate is 0.01. Compare how far plain gradient descent and Adam move each weight.
import math lr = 0.01 # learning rate b1, b2, eps = 0.9, 0.999, 1e-8 # Adam's defaults for g in [100.0, 0.001]: # a loud gradient and a quiet one m = (1 - b1) * g # average of gradients (it started at 0) v = (1 - b2) * g ** 2 # average of squared gradients (it started at 0) m_hat = m / (1 - b1) # bias correction for step 1 v_hat = v / (1 - b2) adam = lr * m_hat / (math.sqrt(v_hat) + eps) sgd = lr * g # plain gradient descent, for comparison print(f"gradient {g}: plain SGD moves {sgd:.5f}, Adam moves {adam:.4f}")
Plain gradient descent moves the first weight by 0.01 × 100 = 1, a huge jump. It moves the second by 0.01 × 0.001 = 0.00001, almost nothing. The two steps differ by a factor of 100,000.
Adam moves both by 0.01. For the loud gradient, the bias correction gives back m-hat = 100 and v-hat = 10,000. The square root of 10,000 is 100, so the fraction is 100 ÷ 100 = 1. For the quiet gradient, it's 0.001 ÷ 0.001 = 1. Each step is the learning rate times 1.
Three optimizers in one valley
Now watch all three on the canyon from the start, , from the same starting point. As you saw, plain gradient descent's learning rate can't go above 0.02 here. Any bigger, and the steep y direction blows up.
The code uses PyTorch's built-in optimizers. torch.optim.SGD is plain gradient descent, and adding momentum=0.9 turns it into the momentum rule above. Adam gets a learning rate of 0.3: since it moves each weight about one learning rate per step, that number sets its stride directly.
The plot draws each path over gray contour lines. They work like the height lines on a hiking map: each ring joins spots with the same loss.
import numpy as np import torch import matplotlib.pyplot as plt def path(make_opt, steps=60): p = torch.tensor([-4.0, 1.0], requires_grad=True) opt = make_opt([p]) points = [p.detach().numpy().copy()] for _ in range(steps): opt.zero_grad() loss = p[0] ** 2 + 50 * p[1] ** 2 loss.backward() opt.step() points.append(p.detach().numpy().copy()) return np.array(points), loss.item() runs = {"SGD, lr 0.019": lambda ps: torch.optim.SGD(ps, lr=0.019), "SGD + momentum 0.9, lr 0.004": lambda ps: torch.optim.SGD(ps, lr=0.004, momentum=0.9), "Adam, lr 0.3": lambda ps: torch.optim.Adam(ps, lr=0.3)} gx, gy = np.meshgrid(np.linspace(-5, 5, 200), np.linspace(-1.3, 1.3, 200)) plt.contour(gx, gy, gx ** 2 + 50 * gy ** 2, levels=15, colors="gray", linewidths=0.5) for name, make in runs.items(): pts, final = path(make) plt.plot(pts[:, 0], pts[:, 1], marker=".", markersize=3, label=name) print(f"{name:<29} loss after 60 steps: {final:.4f}") plt.legend()
Plain SGD zig-zags across the valley. After 60 steps, it's still far from the lowest point. Momentum builds up speed along the valley floor. Adam takes roughly equal-sized steps in both directions from the start. Both end several times closer to the lowest point than plain SGD.
On a real network
Here's the digit classifier again, trained for 10 epochs with batches of 32. Each optimizer gets a sensible learning rate. The code prints each one's loss and accuracy on the validation set, and plots the validation loss after every epoch.
import torch import torch.nn as nn import matplotlib.pyplot as plt from torch.utils.data import DataLoader, TensorDataset from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split digits = load_digits() X = torch.tensor(digits.data / 16.0, dtype=torch.float32) y = torch.tensor(digits.target) X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0) loss_fn = nn.CrossEntropyLoss() choices = {"SGD (lr 0.05)": lambda ps: torch.optim.SGD(ps, lr=0.05), "SGD + momentum (lr 0.05)": lambda ps: torch.optim.SGD(ps, lr=0.05, momentum=0.9), "Adam (lr 0.001)": lambda ps: torch.optim.Adam(ps, lr=0.001)} for name, make in choices.items(): torch.manual_seed(0) model = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10)) opt = make(model.parameters()) loader = DataLoader(TensorDataset(X_train, y_train), batch_size=32, shuffle=True) curve = [] for epoch in range(10): for xb, yb in loader: opt.zero_grad() loss_fn(model(xb), yb).backward() opt.step() with torch.no_grad(): curve.append(loss_fn(model(X_val), y_val).item()) acc = (model(X_val).argmax(1) == y_val).float().mean().item() print(f"{name:<26} val loss {curve[-1]:.3f} val accuracy {acc:.3f}") plt.plot(range(1, 11), curve, marker="o", label=name) plt.xlabel("epoch") plt.ylabel("validation loss") plt.legend()
Both momentum and Adam beat plain SGD here. With these settings, momentum edges out Adam. That's a reminder that which optimizer "wins" depends on tuning. Adam's advantage is that its default settings work reasonably well almost everywhere.
AdamW and weight decay
Weight decay shrinks every weight a little on every step. Weights the data doesn't keep pushing on drift toward zero, which helps the model do well on new data. It's the L2 idea from Phase 3's regularization lesson, which penalized big weights.
There are two ways to add weight decay to Adam. The old way adds it to the gradient. But then Adam's volume leveling rescales it along with everything else. Weights with big gradients end up with less decay than you asked for. That interacts badly with the per-parameter scaling.
AdamW applies the decay directly to the weights instead, outside the leveling. This is called "decoupled" weight decay. It works better, and it's the standard choice for transformers, the model design behind LLMs. In PyTorch, each AdamW step multiplies every weight by 1 − learning rate × weight decay, on top of the usual Adam step.
A typical LLM setting is:
torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
Here 3e-4 means 3 × 10⁻⁴, or 0.0003. So each step multiplies every weight by 1 − 0.0003 × 0.1 = 0.99997. That's a tiny shrink, but training runs take many thousands of steps.
The memory bill
Better optimizers need memory to remember things:
- Plain SGD needs nothing extra.
- Momentum stores one velocity per parameter.
- Adam stores two numbers per parameter, and .
That adds up fast. A 32-bit number takes 4 bytes. So a model with 7 billion parameters needs 28 GB for its weights alone: 7 billion × 4 bytes. Adam's state is twice that, 56 GB. And that's before the gradients and activations (the in-between results saved for the backward pass).
Optimizer memory is a big part of why training needs far more GPU memory than running a model. It's also why memory-saving optimizers exist, and low-precision tricks that store each number in fewer bytes.
Show the answer
12 GB
The weights take 1 billion × 4 bytes = 4 GB. Adam stores two more numbers per parameter (m and v), another 8 GB. That's 12 GB in total.
Rules of thumb
- Start with AdamW (or Adam). Use a learning rate of for small networks, and or lower for transformers. If the loss explodes, lower it.
- SGD with momentum is still competitive for some vision models, and can generalize slightly better. But it needs more learning-rate tuning.
- Pair any optimizer with a schedule: a warmup, then a decay. You met these in the learning-rate lesson in Phase 3.
Practice what you just read
- 9 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
In a narrow valley, why does momentum help?