Index Ai Academy
Phase 4 · Deep learning · Training

Optimizers

Momentum and Adam.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The problem: narrow valleys
  2. Momentum: keep rolling
  3. Adam: momentum plus a step size per parameter
  4. Worked example: Adam's first step for a loud and a quiet gradient
  5. Three optimizers in one valley
  6. On a real network
  7. 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 xx and yy. The loss is:

f(x,y)=x2+50y2f(x, y) = x^2 + 50 y^2

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 x2x^2 is 2x2x. In the same way, the slope of 50y250y^2 is 100y100y. 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))
It runs right here in your browser, with nothing to install.

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:

ynew=y−η⋅100y=y (1−100η)y_{\text{new}} = y - \eta \cdot 100y = y\,(1 - 100\eta)

Here η\eta (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))
Try it now

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 vv. 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:

  • gg is this step's gradient for the weight.
  • θ\theta (theta) is the weight itself.
  • η\eta (eta) is the learning rate: how big a step to take.
  • μ\mu (mu) is how much of the old velocity to keep. It's usually 0.9.
  • The arrow ←\leftarrow means "becomes".
v←μv+gθ←θ−η vv \leftarrow \mu v + g \qquad \theta \leftarrow \theta - \eta\, v

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 μ=0.9\mu = 0.9.

  • 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 gg forever, the velocity keeps growing but levels off. Each step adds gg, and older gradients fade by 0.9 each step. So the velocity heads toward:

g (1+0.9+0.81+0.729+⋯ )=g1−0.9=10gg\,(1 + 0.9 + 0.81 + 0.729 + \cdots) = \frac{g}{1 - 0.9} = 10g

In plain words: with μ=0.9\mu = 0.9, 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.

Quick check
Gradients that flip sign should cancel out. What does this print?
v, mu = 0.0, 0.9
for g in [1.0, -1.0]:
    v = mu * v + g
print(round(v, 2))
  1. 0.0
  2. -1.0
  3. -0.1
  4. 1.9
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, mm, is a running average of the gradient. Its keep-fraction is β1\beta_1 (beta one), usually 0.9.

m←β1m+(1−β1) gm \leftarrow \beta_1 m + (1 - \beta_1)\, g

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, g2g^2. Squaring makes every gradient positive, so this average ignores direction. It only tracks how big the gradients usually are. Adam calls it vv. (That's a new meaning for v. In Adam, it isn't a velocity.) Its keep-fraction is β2\beta_2 (beta two), usually 0.999.

v←β2v+(1−β2) g2v \leftarrow \beta_2 v + (1 - \beta_2)\, g^2

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 m^\hat m and v^\hat v ("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 t=1,2,3,…t = 1, 2, 3, \ldots, starting from 1. Then:

m^=m1−β1 tv^=v1−β2 t\hat m = \frac{m}{1 - \beta_1^{\,t}} \qquad \hat v = \frac{v}{1 - \beta_2^{\,t}}

In plain words: early on, divide each average by a small number to scale it back up. As the steps go by, βt\beta^t 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 (epsilon) is a tiny number, such as 0.00000001. It's there only so Adam never divides by zero.

θ←θ−ηm^v^+ϵ\theta \leftarrow \theta - \eta \frac{\hat m}{\sqrt{\hat v} + \epsilon}

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, m^\hat m 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 η\eta. That makes Adam much less sensitive to the learning rate. Its defaults are β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999 and η=10−3\eta = 10^{-3}, which is 0.001.

Quick check
What does Adam's second running average (v, of squared gradients) do?
  1. Scales each parameter's step by how large its gradients usually are
  2. Applies weight decay
  3. Stores the previous weights
  4. Adds momentum
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

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}")
In the lesson you can change this and run it.

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, f(x,y)=x2+50y2f(x, y) = x^2 + 50 y^2, 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, mm and vv.

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.

Quick check
A model has 1 billion parameters, stored as 32-bit numbers (4 bytes each). You train it with Adam. How much memory do the weights plus Adam's state take, before gradients and activations?
  1. 4 GB
  2. 12 GB
  3. 6 GB
  4. 8 GB
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 10−310^{-3} for small networks, and 3×10−43 \times 10^{-4} 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

Create a free account

Try a quiz question

In a narrow valley, why does momentum help?

  1. Across-valley gradient components flip sign and cancel; along-valley ones add up
  2. It increases the learning rate automatically whenever the loss stops falling on a step
  3. It removes noise from the data by averaging each batch with the batches before it
  4. It makes the valley wider by rescaling the weights so that every direction slopes equally

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson