TAI BUI
Back to curriculum
BuildPython

Optimization

Training a neural network is walking downhill until you hit the bottom of the valley. How you walk is the entire job.

TIME: ~75 minutesPREREQUISITES: Phase 1, Lessons 04-05 (Calculus for Machine Learning, Chain Rule & Automatic Differentiation)

Learning Objectives

  • Implement vanilla gradient descent, SGD with momentum, and Adam from scratch
  • Compare optimizer convergence on the Rosenbrock function and explain why Adam adapts per-weight learning rates
  • Distinguish convex from non-convex loss landscapes and explain the role of saddle points in high dimensions
  • Configure learning rate schedules (step decay, cosine annealing, warmup) for training stability

The Problem

You have a loss function that tells you how wrong your model is, and gradients that tell you which direction makes it worse. Now you need a strategy for walking downhill.

The naive approach is simple: move opposite the gradient, scale the step by some number called the learning rate, and repeat. This is gradient descent, and it works, but "works" has caveats. Too large a learning rate and you overshoot the valley entirely, bouncing between walls. Too small and you crawl toward the answer over thousands of unnecessary steps. Hit a saddle point and you stop moving even though you haven't found a minimum.

Every optimizer in deep learning answers the same question: how do you get to the bottom of the valley faster and more reliably?

Warm-up0/3
1. A function's derivative at x = 3 is positive. If you want the function's value to get smaller, which way should you move x?Warm-up
A rising curve with a point marked at x = 3 where the tangent line slopes upward, with a note asking which way to move x to make the curve's value smaller.
2. The graph of f(x) = x^2 is a U-shaped curve. How many lowest points does it have?Warm-up
A single U-shaped curve on a plain grid, with no points marked, asking how many points sit at the curve's lowest value.
3. You estimate an average from random samples of data. Compared with using 1 sample, what does using 64 samples change?Warm-up
Two rows, one marked dot for a single sample and a cluster of dots for 64 samples, with a note asking what changes about the estimate.
Check: The Problem0/2
1. What happens if the learning rate is too large during gradient descent?Explain
Two bowl shapes, one with a small step staying inside the bowl and one with a big step jumping across to the opposite wall and higher up than the start, asking what happens if steps keep being this big.
2. You run vanilla gradient descent. The loss swings up and down and gets larger every few steps. What is the most likely cause?Analyze
A loss curve that climbs, dips, then climbs again to an even higher value over 10 real steps, with a note asking the most likely cause.

The Concept

What optimization means

Optimization is finding the input values that minimize (or maximize) a function. In machine learning, the function is the loss. The inputs are the model's weights. Training is optimization.

minimize L(w) where:
  L = loss function
  w = model weights (could be millions of parameters)

Gradient descent (vanilla)

The simplest optimizer. Compute the gradient of the loss with respect to every weight. Move each weight in the opposite direction of its gradient. Scale the step by the learning rate.

w = w - lr * gradient

That is the entire algorithm. One line.

Mermaid diagram

Learning rate: the most important hyperparameter

The learning rate is a hyperparameter: a value you choose before training rather than one the model learns. It controls the step size of every update, and it determines nearly everything about whether training converges.

Mermaid diagram

There is no formula for the right learning rate. You find it by experiment. Common starting points: 0.001 for Adam, 0.01 for SGD with momentum.

SGD vs batch vs mini-batch

Vanilla gradient descent computes the gradient over the entire dataset before taking a single step. That's called batch gradient descent: stable, but slow.

SGD, short for stochastic gradient descent, computes the gradient on one random sample and steps immediately. Noisy, but fast.

Mini-batch gradient descent splits the difference: compute the gradient over a small batch (32, 64, 128, 256 samples), then step. This is what everyone actually uses in practice.

VariantBatch sizeGradient qualitySpeed per stepNoise
Batch GDEntire datasetExactSlowNone
SGD1 sampleVery noisyFastHigh
Mini-batch32-256Good estimateBalancedModerate

The noise in SGD and mini-batch is not a bug. It helps escape shallow local minima and saddle points.

Momentum: the ball rolling downhill

Momentum accumulates past gradients into a velocity term instead of reacting only to the current gradient. That fixes the main weakness of vanilla gradient descent: when the gradient zigzags, which is common in narrow valleys, progress slows to a crawl.

v = beta * v + gradient
w = w - lr * v

The analogy: a ball rolling downhill. It does not stop and restart at every bump. It builds speed in consistent directions and dampens oscillations.

Mermaid diagram

beta (typically 0.9) controls how much history to keep. Higher beta means more momentum, smoother paths, but slower response to direction changes.

Adam: adaptive learning rates

Adam (Adaptive Moment Estimation) tracks two things per weight:

  1. First moment (m): running average of gradients (like momentum)
  2. Second moment (v): running average of squared gradients (gradient magnitude)

The second moment is what makes the learning rate adaptive per weight: different weights need different step sizes. A weight that rarely gets large gradients should take bigger steps when it finally does. A weight that gets huge gradients constantly should take smaller steps.

m = beta1 * m + (1 - beta1) * gradient
v = beta2 * v + (1 - beta2) * gradient^2

m_hat = m / (1 - beta1^t)    bias correction
v_hat = v / (1 - beta2^t)    bias correction

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

The division by sqrt(v_hat) is the key insight. Weights with large gradients get divided by a large number (small effective step). Weights with small gradients get divided by a small number (large effective step). Each weight gets its own adaptive learning rate.

Default hyperparameters: lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8. These defaults work well for most problems.

Learning rate schedules

A learning rate schedule changes the learning rate as training progresses instead of holding it fixed. A fixed rate is always a compromise: early in training you want large steps for fast progress, late in training you want small steps to fine-tune near the minimum.

Common schedules:

ScheduleFormulaUse case
Step decaylr = lr * factor every N epochsSimple, manual control
Exponential decaylr = lr_0 * decay^tSmooth reduction
Cosine annealinglr = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T))Transformers, modern training
Warmup + decayLinear ramp up, then decayLarge models, prevents early instability

An epoch is one full pass through the training set. Step decay and similar schedules count in epochs; the loop you built in Build It counts in raw optimizer steps instead. Same idea, different unit.

Convex vs non-convex

A convex function has one minimum. Gradient descent always finds it. A quadratic like f(x) = x^2 is convex.

Neural network loss functions are non-convex. They have many local minima, saddle points, and flat regions.

Mermaid diagram

In practice, local minima in high-dimensional neural networks are rarely a problem. Most local minima have loss values close to the global minimum. Saddle points (flat in some directions, curved in others) are the real obstacle. Momentum and noise from mini-batches help escape them.

Loss landscape visualization

The loss is a function of all weights. For a model with 1 million weights, the loss landscape lives in 1,000,001-dimensional space. We visualize it by picking two random directions in weight space and plotting the loss along those directions, producing a 2D surface.

Mermaid diagram

Sharp minima generalize poorly. Flat minima generalize well. This is one reason SGD with momentum often outperforms Adam on final test accuracy: its noise prevents settling into sharp minima.

Check: The Concept0/28
1. What does 'optimization' mean in the context of training a neural network?Define
A loss curve with the current weight marked partway up one side, with a note asking what the repeated process of moving it is called.
2. What does one step of gradient descent do to each weight?Define
A weight w = 2.0, gradient = 1.5 and lr = 0.1, with the update written as w_new = w, a blank operator, then lr times gradient, asking whether the operator adds or subtracts.
3. What is the learning rate?Define
Two arrows of different lengths from the same starting point, each labelled with a different lr value chosen before training begins, with a note asking what lr actually is.
4. Which update rule implements momentum, with velocity v and the weight update w = w - lr * v?Define
Given numbers v_old = 0.8, beta = 0.9 and gradient = 0.3, with a question mark for v_new, asking which rule combines them.
5. Which gradient descent variant computes the gradient on one random sample per step?Define
Three panels showing 1 sample, 32 to 256 samples, and the entire dataset, with the 1-sample panel highlighted, asking which variant name it belongs to.
6. What is a mini-batch?Define
The same three-panel spectrum with the 32 to 256 samples panel highlighted, asking for its name.
7. What is Adam?Define
Two boxes labelled m, running average of the gradient, and v, running average of gradient squared, feeding into a question mark over per-weight update size.
8. What is bias correction in Adam?Define
A note that Adam's m starts at 0 and is still small and biased right after the first update, with a question mark asking which operation corrects this.
9. What is a learning rate schedule?Define
Blank axes labelled training progress and lr, with a question mark in the middle and no curve drawn, asking what kind of rule would produce one.
10. What is a convex function?Define
A single smooth bowl-shaped curve labelled Function F, with a note asking what this shape of function is called.
11. What is a saddle point?Define
Two curve profiles crossing at point P: one slice curves upward on both sides of P, the other slice curves downward on both sides, with a question asking what such a point is called.
12. What is a loss landscape?Define
A flow of three boxes: all the weights, then 2 random directions, then a 2D surface, with a note asking what this resulting surface is called.
13. What does it mean for an optimizer to have converged?Define
A loss curve dropping sharply then flattening out over 40 steps, with a note asking what has happened by the right edge.
14. Why do practitioners train with mini-batches instead of the full dataset or one sample?Explain
The three-panel spectrum of 1 sample, 32 to 256 samples, and the entire dataset, with the middle panel highlighted and a note asking why practitioners usually pick it.
15. Why is the noise in mini-batch SGD considered beneficial rather than just a nuisance?Explain
A bumpy curve with a shallow dip and a deeper dip further along, with a point sitting in the shallow dip and a note asking whether noisy gradient estimates help or hurt in this spot.
16. In a narrow valley the gradient zigzags across the walls. Why does momentum still make steady progress along the valley?Explain
A narrow channel between two dashed walls with a zigzagging arrow path bouncing from side to side as it moves down the channel, with a note asking why momentum still makes steady progress here.
17. In Adam, what is the difference between the first moment m and the second moment v?Explain
The two bare update rules for m and v as written in the lesson, with a note asking which one tracks direction and which tracks magnitude.
18. In Adam's update, why does dividing by sqrt(v_hat) give each weight its own step size?Explain
Weight A with m_hat 1.0 and v_hat 100, and weight B with m_hat 1.0 and v_hat 0.01, each with a question mark for their step size, asking which gets the bigger step.
19. In the high-dimensional loss of a neural network, which obstacle usually matters more than local minima, and why?Explain
Two empty dashed boxes, one labelled local minima and one labelled saddle points, with a note asking which is the bigger obstacle in a million-weight network and why.
20. Why can SGD with momentum beat Adam on final test accuracy even though Adam converges faster in training?Explain
Two bowl shapes reaching the same loss level at their floor, minimum A narrow and minimum B wide, with a note asking which one scores better on unseen data.
21. Why is a fixed learning rate always a compromise?Explain
A curve dropping from a high starting value toward zero, with the early steps boxed on the left and the late steps boxed on the right, asking why one fixed lr can't suit both regions.
22. What does a cosine annealing learning rate schedule do?Explain
The cosine annealing formula with blank axes showing only its starting point at t = 0 and ending point at t = T, with no curve drawn between them, asking what shape connects them.
23. What does a warmup + decay learning rate schedule do?Explain
Blank axes with a starting point at t = 0 where lr is near 0, and a later point where lr is lower than some earlier peak, with no curve drawn, asking what shape connects them.
24. A cosine annealing schedule has lr_max = 0.1, lr_min = 0 and T = 100. What is the learning rate at t = 50?Apply
The cosine annealing formula with lr_max 0.1, lr_min 0, T 100 and t 50 substituted in but not simplified, asking for the resulting lr.
25. A step decay schedule starts at lr = 0.1 and multiplies by 0.5 every 10 epochs. What is the rate during epoch 25?Apply
The step decay formula with starting lr 0.1, factor 0.5 every 10 epochs and epoch 25 substituted in but not simplified, asking for the resulting lr.
26. One weight's gradients are about 1000 times larger than another's. Which design handles this better with a single shared lr?Analyze
Weight A with gradient 0.01 and weight B with gradient 10.0, a thousand times larger, sharing the same lr of 0.01, with a question mark over each weight's step, asking which design handles this well.
27. Your model's training loss stops decreasing for many steps. The gradient is near zero, but you are confident it has not reached a good minimum. What most likely happened?Analyze
A real run's loss barely moving over 60 steps, staying extremely close to zero throughout, with a note asking what happened given the gradient is near zero but this is not believed to be a good minimum.
28. Two teams train the same model on the same data. Adam reaches a lower training loss faster. SGD with momentum trains longer, ends with a higher training loss, but scores better on held-out data. What best explains this?Analyze
A table comparing Team Adam, with lower training loss and shorter training time, against Team SGD+M, with higher training loss, longer training time, but a better held-out score, asking what best explains the difference.

Build It

Step 1: Define a test function

The Rosenbrock function is a classic optimization benchmark. Its minimum is at (1, 1) inside a narrow curved valley that is easy to find but hard to follow.

f(x, y) = (1 - x)^2 + 100 * (y - x^2)^2
python
def rosenbrock(params):
    x, y = params
    return (1 - x) ** 2 + 100 * (y - x ** 2) ** 2
 
def rosenbrock_gradient(params):
    x, y = params
    df_dx = -2 * (1 - x) + 200 * (y - x ** 2) * (-2 * x)
    df_dy = 200 * (y - x ** 2)
    return [df_dx, df_dy]

Step 2: Vanilla gradient descent

python
class GradientDescent:
    def __init__(self, lr=0.001):
        self.lr = lr
 
    def step(self, params, grads):
        return [p - self.lr * g for p, g in zip(params, grads)]

Step 3: SGD with momentum

python
class SGDMomentum:
    def __init__(self, lr=0.001, momentum=0.9):
        self.lr = lr
        self.momentum = momentum
        self.velocity = None
 
    def step(self, params, grads):
        if self.velocity is None:
            self.velocity = [0.0] * len(params)
        self.velocity = [
            self.momentum * v + g
            for v, g in zip(self.velocity, grads)
        ]
        return [p - self.lr * v for p, v in zip(params, self.velocity)]

Step 4: Adam

python
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
 
        self.m = [
            self.beta1 * m + (1 - self.beta1) * g
            for m, g in zip(self.m, grads)
        ]
        self.v = [
            self.beta2 * v + (1 - self.beta2) * g ** 2
            for v, g in zip(self.v, grads)
        ]
 
        m_hat = [m / (1 - self.beta1 ** self.t) for m in self.m]
        v_hat = [v / (1 - self.beta2 ** self.t) for v in self.v]
 
        return [
            p - self.lr * mh / (vh ** 0.5 + self.epsilon)
            for p, mh, vh in zip(params, m_hat, v_hat)
        ]

Step 5: Run and compare

python
def optimize(optimizer, func, grad_func, start, steps=5000):
    params = list(start)
    history = [params[:]]
    for _ in range(steps):
        grads = grad_func(params)
        params = optimizer.step(params, grads)
        history.append(params[:])
    return history
 
start = [-1.0, 1.0]
 
gd_history = optimize(GradientDescent(lr=0.0005), rosenbrock, rosenbrock_gradient, start)
sgd_history = optimize(SGDMomentum(lr=0.0001, momentum=0.9), rosenbrock, rosenbrock_gradient, start)
adam_history = optimize(Adam(lr=0.01), rosenbrock, rosenbrock_gradient, start)
 
for name, history in [("GD", gd_history), ("SGD+M", sgd_history), ("Adam", adam_history)]:
    final = history[-1]
    loss = rosenbrock(final)
    print(f"{name:6s} -> x={final[0]:.6f}, y={final[1]:.6f}, loss={loss:.8f}")

Expected output: Adam converges fastest. SGD with momentum follows a smoother path. Vanilla GD makes slow progress along the narrow valley.

Check: Build It0/6
1. In the Build It comparison on the Rosenbrock function, why does vanilla gradient descent make slow progress?Explain
A table of the three optimizers' learning rates from the Build It comparison, with a note that all three start at the same point and run for the same number of steps on the same function.
2. What does GradientDescent(lr=0.1).step([1.0, 2.0], [0.5, -1.0]) return?Apply
The GradientDescent update written out for both weights with the given numbers substituted but not resolved, asking for the resulting pair.
3. What does rosenbrock_gradient([1.0, 1.0]) return?Apply
A point marked at x = 1.0, y = 1.0 on a plain grid, with the gradient formula's two parts substituted at that point but not simplified, asking for the resulting pair.
4. An SGDMomentum with lr=0.1 and momentum=0.9 takes step([1.0], [2.0]), then step([0.8], [2.0]). What does the second call return, to two decimals?Apply
SGDMomentum with lr 0.1 and momentum 0.9 is called twice in a row, first with params [1.0] and gradient [2.0], then with params [0.8] and gradient [2.0], asking for the second call's result.
5. Adam(lr=0.01).step([1.0], [4.0]) is the first call on a new optimizer. Roughly what does it return?Apply
Adam with lr 0.01 and default betas is called once with params [1.0] and gradient [4.0], asking for the resulting weight.
6. A colleague's momentum optimizer updates the velocity list as beta * v + g but returns p - lr * g for each weight. How does it behave?Analyze
A colleague's optimizer code that updates and stores self.velocity each call, but returns p minus lr times g, using the raw gradient g rather than velocity, asking how this actually behaves.

Use It

In practice, you won't hand-roll any of this. Use PyTorch or JAX optimizers: they handle parameter groups, weight decay, gradient clipping, and GPU acceleration for you.

python
import torch
 
model = torch.nn.Linear(784, 10)
 
sgd = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
adam = torch.optim.Adam(model.parameters(), lr=0.001)
adamw = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
 
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(adam, T_max=100)

Rules of thumb:

  • Start with Adam (lr=0.001). It works for most problems without tuning.
  • Switch to SGD with momentum (lr=0.01, momentum=0.9) when you need the best final accuracy and can afford more tuning.
  • Use AdamW (Adam with decoupled weight decay) for transformers.
  • Always use a learning rate schedule for training runs longer than a few epochs.
  • If training is unstable, reduce the learning rate. If training is too slow, increase it.
Check: Use It0/4
1. In the PyTorch snippet, CosineAnnealingLR(adam, T_max=100) is created. Which optimizer's learning rate does it adjust?Apply
The lesson's own PyTorch snippet creating sgd, adam and adamw, then a scheduler built from CosineAnnealingLR(adam, T_max=100), asking whose rate the scheduler adjusts.
2. You are training a large transformer and worry about early instability. Which setup fits best?Design
A scenario card: training a large transformer, worried about early instability, no learning rate schedule chosen yet, asking what setup fits best.
3. You have spare compute and want the best test accuracy. Adam gives the fastest drop in training loss. What should you do?Design
A scenario card: spare compute is available, Adam gives the fastest drop in training loss, the goal is the best possible test accuracy, asking what to do next.
4. You face a new problem with no time to tune. Which starting point does the lesson's advice support?Design
A scenario card: a new problem with no prior experiments and no time to tune, needing a starting point today, asking what that starting point should be.

Ship It

outputs/prompt-optimizer-guide.md walks you through choosing the right optimizer for whatever problem you're actually facing, instead of defaulting to Adam out of habit.

The optimizer classes you just built reappear in Phase 3, when we train a neural network from scratch.

Author's Note

Here's the part that trips people up in practice: Adam winning your training curve doesn't mean Adam wins your model. It converges faster because it's aggressive about scaling each weight's step, and that same aggressiveness is what steers it into sharp, narrow minima. SGD with momentum crawls there slower, but the noise it carries along the way keeps knocking it out of the narrow ones, so it tends to land somewhere flatter and more forgiving on data it hasn't seen. If you're optimizing for a leaderboard number and have the compute to spare, don't assume Adam's faster loss curve means a better model. Run SGD with momentum as a second pass and compare test accuracy, not training speed.

Exercises

  1. Learning rate sweep. Run vanilla gradient descent on the Rosenbrock function with learning rates [0.0001, 0.0005, 0.001, 0.005, 0.01]. Plot or print the final loss after 5000 steps for each. Find the largest learning rate that still converges.

  2. Momentum comparison. Run SGD with momentum values [0.0, 0.5, 0.9, 0.99] on the Rosenbrock function. Track the loss at every step. Which momentum value converges fastest? Which overshoots?

  3. Saddle point escape. Define the function f(x, y) = x^2 - y^2 (a saddle point at the origin). Start at (0.01, 0.01). Compare how vanilla GD, SGD with momentum, and Adam behave. Which escapes the saddle point?

  4. Implement learning rate decay. Add an exponential decay schedule to the GradientDescent class: lr = lr_0 * 0.999^step. Compare convergence with and without decay on the Rosenbrock function.

Key Terms

TermWhat people sayWhat it actually means
Gradient descent"Go downhill"Update weights by subtracting the gradient scaled by the learning rate. The most basic optimizer.
Learning rate"Step size"A scalar that controls how far each update moves the weights. Too large causes divergence. Too small wastes compute.
Momentum"Keep rolling"Accumulate past gradients into a velocity vector. Dampens oscillations and accelerates movement through consistent directions.
SGD"Random sampling"Stochastic gradient descent. Compute gradient on a random subset instead of the full dataset. Almost always means mini-batch SGD in practice.
Mini-batch"A chunk of data"A small subset of training data (32-256 samples) used to estimate the gradient. Balances speed and gradient accuracy.
Adam"The default optimizer"Adaptive Moment Estimation. Tracks per-weight running averages of gradients and squared gradients to give each weight its own learning rate.
Bias correction"Fix the cold start"Adam's first and second moments are initialized to zero. Bias correction divides by (1 - beta^t) to compensate during early steps.
Learning rate schedule"Change lr over time"A function that adjusts the learning rate during training. Large steps early, small steps late.
Convex function"One valley"A function where any local minimum is the global minimum. Gradient descent always finds it. Neural network losses are not convex.
Saddle point"Flat but not a minimum"A point where the gradient is zero but it is a minimum in some directions and a maximum in others. Common in high dimensions.
Loss landscape"The terrain"The loss function plotted over weight space. Visualized by slicing along two random directions.
Convergence"Getting there"The optimizer has reached a point where further steps do not meaningfully reduce the loss.

Further Reading

Code

Ships With