TAI BUI
Back to curriculum
BuildPython

Optimizers

Gradient descent points you in the right direction. It has no opinion on how far to step, or whether that step should change as training goes on. SGD is a compass. Adam is GPS with live traffic data.

TIME: ~75 minutesPREREQUISITES: Lesson 03.05 (Loss Functions)

Learning Objectives

  • Implement SGD, SGD with momentum, Adam, and AdamW optimizers from scratch in Python
  • Explain how Adam's bias correction compensates for zero-initialized moment estimates in early training steps
  • Demonstrate why AdamW produces better generalization than Adam with L2 regularization on the same task
  • Select the appropriate optimizer and default hyperparameters for transformers, CNNs, GANs, and fine-tuning

The Problem

You computed the gradients. Weight #4,721 needs to decrease by 0.003 to reduce the loss. But 0.003 in what units, scaled by what, and should you move by that same amount on step 1 as on step 1,000?

Vanilla gradient descent applies the same learning rate to every parameter on every step: w = w - lr * gradient. That single choice creates three problems that make training painful in practice.

First, oscillation. The loss landscape is rarely a smooth bowl; it's more often a long, narrow valley, where the gradient points across the valley (steep direction) instead of along it (shallow direction). Gradient descent bounces back and forth across the narrow dimension while it barely creeps along the useful one. You've seen the result: loss drops fast, then plateaus, not because the model converged but because it's oscillating.

Second, one learning rate for every parameter is wrong. Some weights need large updates because they're still early and underfitting. Others need tiny updates because they're already close to their optimal value. A rate that works for the former destroys the latter, and vice versa.

Third, saddle points. In high dimensions, the loss landscape has vast flat regions where the gradient is near zero, and vanilla SGD crawls through them at the speed of that near-zero gradient. The model looks stuck. It isn't: there's useful descent waiting on the other side of that flat region, but SGD has no mechanism to push through and find it.

Adam solves all three at once. It keeps two running averages per parameter: the mean gradient (momentum, which handles oscillation) and the mean squared gradient (adaptive rate, which handles the different scales). Add bias correction for the first few steps and you get a single optimizer that works on 80% of problems with default hyperparameters. Build it from scratch, which is exactly what this lesson does, and you'll know precisely when and why it fails on the other 20%.

Warm-up0/3
1. In gradient descent, each weight is nudged to reduce the loss. In which direction does it move relative to its gradient?Warm-up
A U-shaped loss curve over the weight w, with a point at w = 3 on the right side and a dashed tangent line there marked with slope +4.
Illustrative one-weight loss curve with its slope computed at w = 3.
2. You descend a bowl-shaped loss with gradient steps, but each step is far too large. What typically happens?Warm-up
A bowl-shaped loss curve w squared with a start point at w = -1.5 and a bracket under the curve marking the size of one step, 3.3 long, which is wider than the distance from the start to the bottom.
Illustrative bowl. The step size 3.3 is lr 1.1 times the slope 3.0 at the start.
3. A training objective includes an L2 penalty on the weights. What does that penalty discourage?Warm-up
The objective written as data loss plus lambda times the sum of squared weights, with lambda 0.1, and a table of three weights 2, -0.5 and 0.1, their squares 4, 0.25 and 0.01, and the penalty terms summing to 0.426.
Illustrative three-weight model; every number is computed from the three weights and lambda = 0.1.
Check: The Problem0/3
1. What happens to vanilla SGD at a saddle point or flat region of the loss landscape?Explain
A sigmoid-shaped loss curve that is nearly flat on the left and drops steeply around w = 3, with a start point at w = 0 on the flat part where the gradient is -0.0163.
Illustrative loss curve; the gradient at the start point is computed.
2. Training loss drops fast and then plateaus. Inspection shows the weights flip sign each step along one direction while barely advancing along another. Which ingredient directly addresses this?Analyze
Two plots from plain gradient descent in a narrow valley: the loss falling quickly and then flattening, and the across-valley weight flipping sign each step with shrinking size while the along-valley weight changes slowly.
Computed with numpy on f = 0.5 * (x^2 + 100 y^2), lr 0.019, start (-9, 1). The x weight is divided by 9 to share an axis.
3. Two weights have gradients about 100x apart. With plain SGD, a rate that suits one weight is unstable or stalled for the other. What fixes this?Analyze
Two plots of the weights w1 and w2 over steps for plain SGD on a loss with gradients 100 and 1: with lr 0.019 w1 flips sign and settles while w2 barely moves, and with lr 0.021 w1 grows larger every step.
Computed with numpy on 0.5 * (100 w1^2 + w2^2) from (1, 1); the loss is an illustrative stand-in for the stem's 100x gap.

The Concept

Stochastic Gradient Descent (SGD)

The simplest optimizer. Compute the gradient on a mini-batch and step in the opposite direction.

w = w - lr * gradient

The "stochastic" part means you estimate the gradient from a random subset (mini-batch) rather than the full dataset. That randomness helps the optimizer escape sharp local minima, but it's also the source of the oscillation problem above.

Learning rate is the only knob. Too high: the loss diverges. Too low: training takes forever. The optimal value depends on the architecture, the data, the batch size, and the current stage of training. For vanilla SGD on modern networks, typical values range from 0.01 to 0.1. But even within a single training run, the ideal learning rate changes.

Momentum

Instead of stepping by the gradient alone, you maintain a velocity that accumulates past gradients. Call it the ball-rolling-downhill analogy if you want; overused, but accurate here.

m_t = beta * m_{t-1} + gradient
w = w - lr * m_t

Beta (typically 0.9) controls how much history to keep. With beta = 0.9, the momentum is roughly the average of the last 10 gradients (1 / (1 - 0.9) = 10).

Why this fixes oscillation: gradients that point in the same direction accumulate. Gradients that flip direction cancel out. In that narrow valley, the "across" component flips sign each step and gets dampened. The "along" component stays consistent and gets amplified. The result is smooth acceleration in the useful direction.

Real numbers: SGD alone on a badly conditioned loss landscape might take 10,000 steps. SGD with momentum (beta=0.9) typically takes 3,000-5,000 steps on the same problem. The speedup is not marginal.

RMSProp

The first per-parameter adaptive learning rate method that actually worked. Proposed by Hinton in a Coursera lecture (never formally published).

s_t = beta * s_{t-1} + (1 - beta) * gradient^2
w = w - lr * gradient / (sqrt(s_t) + epsilon)

s_t tracks the running average of squared gradients. Parameters with consistently large gradients get divided by a large number (smaller effective learning rate). Parameters with small gradients get divided by a small number (larger effective learning rate).

This solves the "one learning rate for all parameters" problem. A weight that's already been getting large updates is probably near its target: slow it down. A weight that's been getting tiny updates might be undertrained: speed it up.

Epsilon (typically 1e-8) prevents division by zero when a parameter hasn't been updated.

Adam: Momentum + RMSProp

Adam combines both ideas. It maintains two exponential moving averages per parameter:

m_t = beta1 * m_{t-1} + (1 - beta1) * gradient        (first moment: mean)
v_t = beta2 * v_{t-1} + (1 - beta2) * gradient^2       (second moment: variance)

Bias correction is the key detail most explanations skip. At step 1, m_1 = (1 - beta1) * gradient. With beta1 = 0.9, that's 0.1 * gradient: ten times too small. The moving average hasn't warmed up yet. Bias correction compensates:

m_hat = m_t / (1 - beta1^t)
v_hat = v_t / (1 - beta2^t)

At step 1 with beta1 = 0.9: m_hat = m_1 / (1 - 0.9) = m_1 / 0.1 = the actual gradient. At step 100: (1 - 0.9^100) is approximately 1.0, so the correction vanishes. Bias correction matters for the first ~10 steps and is irrelevant after ~50.

The update:

w = w - lr * m_hat / (sqrt(v_hat) + epsilon)

Adam defaults: lr = 0.001, beta1 = 0.9, beta2 = 0.999, epsilon = 1e-8. These defaults work for 80% of problems. When they don't, change lr first. Then beta2. Almost never change beta1 or epsilon.

AdamW: Weight Decay Done Right

AdamW is Adam with one change: weight decay gets applied straight to the weights, after the Adam update, instead of folded into the gradient through L2 regularization. Here's why that distinction matters.

L2 regularization adds lambda * w^2 to the loss. In vanilla SGD, this is equivalent to weight decay (subtracting lambda * w from the weight at each step). In Adam, this equivalence breaks.

The Loshchilov & Hutter insight: when you add L2 to the loss and Adam processes the gradient, the adaptive learning rate scales the regularization term too. Parameters with large gradient variance get less regularization. Parameters with small variance get more. That's the opposite of what you want: uniform regularization regardless of the gradient statistics.

AdamW fixes this by applying weight decay directly to the weights, after the Adam update:

w = w - lr * m_hat / (sqrt(v_hat) + epsilon) - lr * lambda * w

The weight decay term (lr * lambda * w) is not scaled by Adam's adaptive factor. Every parameter gets the same proportional shrinkage.

This seems like a minor detail. It's not. AdamW converges to better solutions than Adam + L2 regularization on virtually every task. It's the default optimizer in PyTorch for training transformers, diffusion models, and most modern architectures. BERT, GPT, LLaMA, Stable Diffusion: all trained with AdamW.

Learning Rate: The Most Important Hyperparameter

Mermaid diagram

If you tune one hyperparameter, tune the learning rate. A 10x change in learning rate matters more than any architectural decision you'll make. Common defaults:

  • SGD: lr = 0.01 to 0.1
  • Adam/AdamW: lr = 1e-4 to 3e-4
  • Fine-tuning pretrained models: lr = 1e-5 to 5e-5
  • Learning rate warmup: linear ramp over first 1-10% of steps

Optimizer Comparison

Mermaid diagram

When Each Optimizer Wins

Mermaid diagram

Check: The Concept0/22
1. What is the learning rate in an optimizer?Define
A table of one update w = w - lr * gradient for a weight 2.0 and gradient 0.5 with lr 0.01, 0.1 and 1.0, giving 1.995, 1.95 and 1.5.
Worked by hand from the lesson's update rule; the numbers are illustrative.
2. What is stochastic gradient descent (SGD)?Define
Eight (x, y) data points for a one-weight model with w = 0, and a table of gradients: one computed from all 8 examples (-102.3) and three computed from four examples each (-88.2, -100.8, -52.8).
Computed with numpy. The four-example rows are random draws without replacement, and each gives a different gradient from the full-data one.
3. What problem does momentum solve in gradient descent?Define
Contour lines of a long narrow valley, shallow along x and steep along y, with the first 30 steps of plain gradient descent drawn as dots joined by a path that zigzags up and down across the valley while moving slowly along it.
Computed with numpy. The vertical axis is stretched about 3 times so the valley is visible.
4. What does RMSProp track for each parameter to adjust its effective learning rate?Define
A bar chart of six gradients received by one parameter: 2.0, -3.0, 2.5, -1.0, 4.0 and -2.5, next to a dashed box asking which value RMSProp keeps for this parameter between steps.
Illustrative gradient values.
5. What is Adam?Define
A table of three Adam steps with default settings on one parameter for gradients 0.5, 0.2 and 0.4, listing m, v, m_hat, v_hat and the step taken each time.
Computed with the lesson's Adam class and checked against the formulas.
6. What is bias correction in Adam?Define
Terminal output of the lesson's bias_correction_demo for a constant gradient of 1.0: a table of steps 1 to 10 with columns m_raw, m_corrected, v_raw and v_corrected.
Captured by running the lesson's own bias_correction_demo() function.
7. What is weight decay?Define
A table of three weights 4.0, -2.0 and 0.5 and their values after one step with weight decay 0.1 and zero gradients: 3.6, -1.8 and 0.45.
Worked by hand; the weights are illustrative.
8. What is AdamW?Define
Two code listings from the lesson showing the end of the step method of the Adam class and of the AdamW class.
Quoted from the lesson's Step 3 and Step 4 listings. (The decay line there follows the Adam update; main.py runs it before.)
9. What is a learning rate schedule?Define
A plot of learning rate against training step for one illustrative run of 10,000 steps: it climbs in a straight line to 3e-4 early on, then falls along a smooth cosine curve to near zero at the end.
Illustrative schedule computed from a 500-step linear ramp and cosine decay.
10. What are the standard default hyperparameters for Adam?Define
Adam's four update equations from the lesson, and below them a box showing the four settings lr, beta1, beta2 and epsilon with question marks instead of values.
Equations quoted from the lesson; the settings are the question.
11. What shape does a learning rate warmup follow in the lesson's common defaults?Define
A plot of learning rate against training step with the cosine decay part drawn from a peak of 3e-4 down to near zero at 10,000 steps, and the first stretch of the run left blank with the label warmup question mark.
Illustrative axes; only the decay part is drawn.
12. You're hiking down into a narrow valley. Every step skids sideways across the slope before drifting slightly forward, so progress is slow. What would most reliably speed up your descent?Explain
A top view of a long narrow valley drawn with contour lines, with eight footprints that alternate between the two steep sides while creeping a little along the valley floor.
Illustrative footprints, computed as gradient steps on a stretched valley (vertical axis stretched).
13. In a long narrow valley, why does momentum shrink the across-valley motion but keep the along-valley motion?Explain
A table of the gradient components for six plain descent steps in a narrow valley: the along-valley component is about -9 and shrinks slowly, while the across-valley component swings between +100 and -59.
Computed from plain gradient descent on f = 0.5 * (x^2 + 100 y^2), lr 0.019, starting at (-9, 1).
14. Why does RMSProp give a parameter with consistently large gradients a smaller effective step?Explain
Two bar charts of six consecutive gradients: parameter A receives values between 7 and 9, parameter B receives values between 0.07 and 0.09.
Illustrative gradient values.
15. While training, one parameter's squared-gradient average is still exactly zero because it hasn't received a gradient yet. What in Adam's update rule keeps this from crashing with a division by zero?Explain
A card showing a parameter with m = 0, v = 0 and square root of v_hat equal to 0, and the Adam update written with a denominator whose value is left as a question mark.
Typed from the lesson's update rule, not captured from a run.
16. Why does Adam use bias correction in early training steps?Explain
A card with Adam's starting state of m = 0 and v = 0 with a constant gradient of 1.0, and a card with the first moment update and the correction formula m_hat = m_t / (1 - beta1^t).
Typed from the lesson's formulas, not captured from a run.
17. Why does adding an L2 penalty to the loss regularize unevenly when the optimizer is Adam?Explain
A diagram: the loss gradient and the penalty gradient lambda times w are added into one gradient g, which then enters Adam, where m and v are computed from g and the step is built from m_hat and v_hat.
Diagram of the L2-in-the-loss setup.
18. You run Adam and AdamW at the same learning rate with weight decay set to zero in both. How do the results compare?Explain
A table of two runs on the lesson's circle dataset: Adam and AdamW, both with lr 0.001, beta1 0.9, beta2 0.999 and epsilon 1e-8, where AdamW has weight_decay 0.0 and Adam has none.
Settings of the two runs shown on the next view.
19. You train with Adam's default hyperparameters and the results are mediocre. What should you adjust first?Explain
Four boxes with Adam's default settings: lr 0.001, beta1 0.9, beta2 0.999 and epsilon 1e-8, and a line that states the question's premise, that the result is mediocre.
Default values quoted from the lesson. The mediocre result is the question's premise, not a measurement.
20. At step t=2 with beta1=0.9, what is the divisor in m_hat = m_t / (1 - beta1^t)?Apply
A card for Adam at step 2 with beta1 0.9: the two moment updates m_1 and m_2, the correction m_hat = m_2 divided by (1 minus beta1 to the power t), and the divisor left as a question mark.
Typed from the lesson's formulas, not captured from a run.
21. You're fine-tuning a pretrained transformer with AdamW at lr=0.1, and the loss diverges within the first few steps. What's the most likely fix?Analyze
A log-scale plot of the training loss over the first 12 fine-tuning steps with AdamW at lr 0.1: it starts at 0.11 and jumps to 65 within four steps.
Real run: a toy 5-layer MLP pretrained on one function and fine-tuned on a shifted one. It stands in for a transformer, which is not trained here.
22. A deadline is tomorrow and your current optimizer trains poorly. What is the best use of the remaining time?Design
A training-loss curve of one Adam run on the circle dataset that is still falling slowly after 300 epochs, ending at loss 0.342 with 85.5% accuracy, under a heading that the deadline is tomorrow.
Real run of the lesson's Adam class and network; settings are not shown.

Build It

Step 1: Vanilla SGD

python
class SGD:
    def __init__(self, lr=0.01):
        self.lr = lr
 
    def step(self, params, grads):
        for i in range(len(params)):
            params[i] -= self.lr * grads[i]

Step 2: SGD with Momentum

python
class SGDMomentum:
    def __init__(self, lr=0.01, beta=0.9):
        self.lr = lr
        self.beta = beta
        self.velocities = None
 
    def step(self, params, grads):
        if self.velocities is None:
            self.velocities = [0.0] * len(params)
        for i in range(len(params)):
            self.velocities[i] = self.beta * self.velocities[i] + grads[i]
            params[i] -= self.lr * self.velocities[i]

Step 3: Adam

python
import math
 
class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.m = None
        self.v = None
        self.t = 0
 
    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)
 
        self.t += 1
 
        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2
 
            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)
 
            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)

Step 4: AdamW

python
class AdamW:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.weight_decay = weight_decay
        self.m = None
        self.v = None
        self.t = 0
 
    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)
 
        self.t += 1
 
        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2
 
            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)
 
            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
            params[i] -= self.lr * self.weight_decay * params[i]

Step 5: Training Comparison

Train the same two-layer network on the circle dataset from lesson 05 with all four optimizers. Compare convergence.

python
import random
 
def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))
 
def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data
 
 
class OptimizerTestNetwork:
    def __init__(self, optimizer, hidden_size=8):
        random.seed(0)
        self.hidden_size = hidden_size
        self.optimizer = optimizer
 
        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0
 
    def get_params(self):
        params = []
        for row in self.w1:
            params.extend(row)
        params.extend(self.b1)
        params.extend(self.w2)
        params.append(self.b2)
        return params
 
    def set_params(self, params):
        idx = 0
        for i in range(self.hidden_size):
            for j in range(2):
                self.w1[i][j] = params[idx]
                idx += 1
        for i in range(self.hidden_size):
            self.b1[i] = params[idx]
            idx += 1
        for i in range(self.hidden_size):
            self.w2[i] = params[idx]
            idx += 1
        self.b2 = params[idx]
 
    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(max(0.0, z))
 
        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out
 
    def compute_grads(self, target):
        eps = 1e-15
        p = max(eps, min(1 - eps, self.out))
        d_loss = -(target / p) + (1 - target) / (1 - p)
        d_sigmoid = self.out * (1 - self.out)
        d_out = d_loss * d_sigmoid
 
        grads = [0.0] * (self.hidden_size * 2 + self.hidden_size + self.hidden_size + 1)
        idx = 0
        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            d_h = d_out * self.w2[i] * d_relu
            grads[idx] = d_h * self.x[0]
            grads[idx + 1] = d_h * self.x[1]
            idx += 2
 
        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            grads[idx] = d_out * self.w2[i] * d_relu
            idx += 1
 
        for i in range(self.hidden_size):
            grads[idx] = d_out * self.h[i]
            idx += 1
 
        grads[idx] = d_out
        return grads
 
    def train(self, data, epochs=300):
        losses = []
        for epoch in range(epochs):
            total_loss = 0.0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                grads = self.compute_grads(y)
                params = self.get_params()
                self.optimizer.step(params, grads)
                self.set_params(params)
 
                eps = 1e-15
                p = max(eps, min(1 - eps, pred))
                total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append((avg_loss, accuracy))
            if epoch % 75 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses
Check: Build It0/5
1. SGD(lr=0.1).step is called with params=[1.0, 2.0] and grads=[0.5, -1.0]. What are params afterward?Apply
Two cards: the inputs params = [1.0, 2.0] and grads = [0.5, -1.0], and the lesson's SGD step code that loops over the parameters with params[i] -= self.lr * grads[i].
Inputs from the question, code quoted from the lesson.
2. SGDMomentum(lr=0.1, beta=0.9) is called twice on a single parameter that starts at 1.0, with gradient 1.0 both times. What is the parameter after the second call?Apply
A table for two calls of SGDMomentum with lr 0.1 and beta 0.9 on a parameter starting at 1.0 with gradient 1.0, velocity and parameter shown as question marks after each call, and the two lines of the lesson's step code.
Inputs from the question, code quoted from the lesson.
3. Adam(lr=0.001) takes its first step on a parameter whose gradient is 50.0. Roughly how far does the parameter move?Apply
A card with the inputs for the first Adam step: one parameter with gradient 50.0, learning rate 0.001, zero moments and the default beta and epsilon values, and a question box asking how far the parameter moves.
Inputs from the question.
4. In AdamW with lr=0.1 and weight_decay=0.01, a weight equals 4.0 when the line params[i] -= lr * weight_decay * params[i] runs. How much does that line subtract?Apply
A card with the AdamW line params[i] -= self.lr * self.weight_decay * params[i] and a card listing lr 0.1, weight_decay 0.01 and params[i] equal to 4.0.
Line quoted from the lesson, values from the question.
5. You implement "AdamW" by adding weight_decay * w to each gradient before the moment updates. What did you actually build?Analyze
A code card of a step method: the gradient is first increased by weight_decay times the parameter, then the moment updates, bias corrections and the final parameter update follow.
Typed to match the question, not captured from a run.

Use It

PyTorch optimizers handle parameter groups, gradient clipping, and learning rate scheduling:

python
import torch
import torch.optim as optim
 
model = torch.nn.Sequential(
    torch.nn.Linear(784, 256),
    torch.nn.ReLU(),
    torch.nn.Linear(256, 10),
)
 
optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
 
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)
 
for epoch in range(100):
    optimizer.zero_grad()
    output = model(torch.randn(32, 784))
    loss = torch.nn.functional.cross_entropy(output, torch.randint(0, 10, (32,)))
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    scheduler.step()

CosineAnnealingLR glides the learning rate down along a cosine curve toward zero over T_max steps, instead of dropping it in sudden jumps.

The pattern is always: zero_grad, forward, loss, backward, (clip), step, (schedule). Memorize this order. Getting it wrong (e.g., calling scheduler.step() before optimizer.step()) is a common source of subtle bugs.

For CNNs, many practitioners still prefer SGD + momentum (lr=0.1, momentum=0.9, weight_decay=1e-4) with a step or cosine schedule. SGD finds flatter minima, which often generalize better. For transformers and LLMs, AdamW with warmup + cosine decay is the universal default. For GANs, drop Adam's beta1 from the default 0.9 to about 0.5: high momentum amplifies the back-and-forth instability between generator and discriminator and pushes training toward mode collapse, a fix that traces back to the DCGAN paper. Some setups go further and give the generator and discriminator separate learning rates entirely (TTUR). Don't fight the consensus without a measured reason.

Check: Use It0/9
1. What is gradient clipping?Define
A plane with a circle of radius 1.0 and two gradient vectors from the origin: a short one (0.4, -0.3) with norm 0.5 inside the circle, and a long one (3.0, 4.0) with norm 5.0 reaching far outside it.
Illustrative vectors with norms computed.
2. Why do many practitioners still reach for SGD + momentum on CNNs instead of AdamW?Explain
A row of CNN layers Conv, ReLU, Conv, ReLU and Linear, and below it two optimizer setups: SGD with momentum 0.9, lr 0.1 and weight decay 1e-4, and AdamW with warmup and cosine decay.
Layers are schematic; the SGD settings are quoted from the lesson.
3. In the PyTorch training loop, which mistake is named as a common source of subtle bugs?Explain
The seven lines of the lesson's PyTorch training loop body: zero_grad, forward pass, loss, backward, gradient clipping, optimizer step and scheduler step.
Quoted from the lesson; the loss line is shortened.
4. The Use It loop uses CosineAnnealingLR(optimizer, T_max=100) and runs 100 epochs, calling scheduler.step() each epoch. Where is the learning rate near the end of training?Apply
Axes of learning rate against epoch from 0 to 100 with a point at 3e-4 on epoch 0 and a question mark at epoch 99.
Setup quoted from the lesson's Use It loop.
5. clip_grad_norm_(model.parameters(), max_norm=1.0) runs when the gradient vector has norm 4.0. What happens?Apply
A bar chart of a gradient vector with five components 1, 1, 1, 3 and 2 whose norm is 4.0, with max_norm 1.0 given in the title.
Illustrative vector; the norm is computed.
6. Training is stable with a sensible learning rate, but the loss occasionally spikes when a rare batch produces a huge gradient and the update blows up. Which ingredient targets this?Analyze
Two stacked plots over 120 SGD steps: the held-out loss on a log scale, which dips to near zero and jumps up at steps 50 and 90, and the gradient norm, which is small except for two tall spikes at the same steps.
Real numpy run on a toy linear regression; the two corrupted batches (labels shifted by 30) stand in for rare outlier batches.
7. You are training a transformer language model from scratch. Which setup does the lesson treat as the default?Design
A transformer language model drawn as tokens, embedding, N blocks of attention and MLP, and a next-token head, with a box below it listing optimizer and schedule as question marks.
Schematic diagram.
8. You are training an image CNN and care most about generalization. Which starting point fits the lesson's advice?Design
A CNN drawn as image, two Conv plus ReLU stages and a Linear layer, with a box below stating the goal of best accuracy on unseen images and a question mark for the starting optimizer setup.
Schematic diagram.
9. You're training a GAN with Adam's default beta1=0.9, and training between the generator and discriminator is unstable and prone to mode collapse. What's the standard fix?Design
A GAN diagram: noise goes into a generator that makes a fake image, and that fake image and a real image both feed a discriminator that outputs real or not, with a note that the two networks keep undoing each other and the generator outputs a narrow set of samples.
Schematic diagram; Adam settings are the lesson's defaults.

Ship It

This lesson produces:

  • outputs/prompt-optimizer-selector.md, a decision prompt for choosing the right optimizer and learning rate for any architecture
Author's Note

Most online arguments about which optimizer is "best" are really arguments about learning rate that got mislabeled. Swap AdamW for Adam at the same learning rate with weight decay set to zero and you'll barely see a difference; the gap only opens up once weight decay is actually doing something, which in practice is most of the time. If you're short on time before a deadline, don't burn it on an optimizer bake-off. Sweep the learning rate across two or three orders of magnitude on whatever optimizer you already have running. That's the knob that decides whether training works at all, and it's the one people reach for last.

Exercises

  1. Implement Nesterov momentum, where you compute the gradient at the "lookahead" position (w - lr * beta * v) instead of the current position. Compare convergence to standard momentum on the circle dataset.

  2. Implement a learning rate warmup schedule: linear ramp from 0 to max_lr over the first 10% of training steps, then cosine decay to 0. Train with Adam + warmup vs Adam without warmup. Measure how many epochs it takes to reach 90% accuracy on the circle dataset.

  3. Track the effective learning rate for each parameter during Adam training. The effective rate is lr * m_hat / (sqrt(v_hat) + eps). Plot the distribution of effective rates after 10, 50, and 200 steps. Are all parameters being updated at the same speed?

  4. Implement gradient clipping (clip by global norm). Set the max gradient norm to 1.0. Train with and without clipping using a high learning rate (lr=0.01 for Adam). Count how many runs diverge (loss goes to NaN) with and without clipping over 10 random seeds.

  5. Compare Adam vs AdamW on a network with large weights. Initialize all weights to random values in [-5, 5] (much larger than normal). Train for 200 epochs with weight_decay=0.1. Plot the L2 norm of weights over training for both optimizers. AdamW should show faster weight shrinkage.

Key Terms

TermWhat people sayWhat it actually means
Learning rate"Step size"The scalar multiplier on the gradient update; the single most impactful hyperparameter in training
SGD"Basic gradient descent"Stochastic gradient descent: update weights by subtracting lr * gradient, computed on a mini-batch
Momentum"Rolling ball analogy"Exponential moving average of past gradients; dampens oscillation and accelerates consistent directions
RMSProp"Adaptive learning rate"Divides each parameter's gradient by the running RMS of its recent gradients; equalizes learning rates
Adam"The default optimizer"Combines momentum (first moment) and RMSProp (second moment) with bias correction for the initial steps
AdamW"Adam done right"Adam with decoupled weight decay; applies regularization directly to weights rather than through the gradient
Bias correction"Warmup for running averages"Dividing by (1 - beta^t) to compensate for the zero-initialization of Adam's moment estimates
Weight decay"Shrink the weights"Subtracting a fraction of the weight value at each step; a regularizer that penalizes large weights
Learning rate schedule"Changing lr over time"A function that adjusts the learning rate during training; warmup + cosine decay is the modern default
Gradient clipping"Capping the gradient norm"Scaling down the gradient vector when its norm exceeds a threshold; prevents exploding gradient updates

Further Reading

  • Kingma & Ba, "Adam: A Method for Stochastic Optimization" (2014) — the original Adam paper with convergence analysis and the bias correction derivation
  • Loshchilov & Hutter, "Decoupled Weight Decay Regularization" (2017) — proved that L2 regularization and weight decay are not equivalent in Adam, and proposed AdamW
  • Smith, "Cyclical Learning Rates for Training Neural Networks" (2017) — introduced the LR range test and cyclical schedules that remove the need to tune a fixed learning rate
  • Ruder, "An Overview of Gradient Descent Optimization Algorithms" (2016) — the best single survey of all optimizer variants, with clear comparisons and intuitions

Code

Ships With