Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Gradient Descent, Adam, and Learning Rate Stability

Welcome back. In the previous lesson, you computed gradients for an affine layer followed by ReLU and checked them against autograd. A gradient tells you how a loss changes locally; an optimizer decides how to use that information to change the parameters.

This lesson takes you from gradients to updates. You will implement gradient descent and Adam in PyTorch, then use a small loss landscape to identify when a learning rate is too large. The same update machinery will train your first multilayer perceptron in the next lesson and, later, models for 3D data. Allow about 40 minutes, including the videos and code.


From a gradient to a parameter update

Let collect a model’s trainable parameters, and let be its loss. At each step, gradient descent evaluates the gradient at the current parameters and moves in the opposite direction:

The positive number is the learning rate. It scales the update, not the gradient calculation. A small learning rate can make progress needlessly slow; a large one can jump across a minimum and increase the loss.

PyTorch’s autograd supplies the gradient, but a manual update still needs care: change the parameter without recording the update as part of the computation graph, and clear its stored gradient before the next backward pass. The following video shows those mechanics in a one-parameter example.

PyTorch Tutorial 05 - Gradient Descent with Autograd and Backpropagation

Watch “PyTorch Tutorial 05 - Gradient Descent with Autograd and Backpropagation” by Patrick Loeber for the transition from a computed gradient to a working PyTorch update.

Watch the PyTorch update. Focus on loss.backward(), the update inside torch.no_grad(), and why the stored gradient must be reset between steps. The example has one weight; the same rules apply to every tensor of weights or biases.

Three sketches of loss \(J(\theta)\) against a single parameter \(\theta\). Red update paths show slow progress with a small learning rate, faster progress with a suitable rate, and large jumps that move away from the minimum when the rate is too high.

The picture is useful, but “too high” is not simply “a step crossed the minimum.” A step can cross it and still end up closer. To see the distinction, consider the one-dimensional quadratic

Its gradient is . If denotes the error after an update, gradient descent gives

Thus the error shrinks when , or . It alternates sides of the minimum when , yet still converges. Above , those alternating errors grow. This is an exact result for this quadratic, not a universal learning-rate formula for neural networks. It does show why the scale of the loss’s curvature matters.


Adam: direction and scale from recent gradients

Ordinary gradient descent uses the current gradient alone. Adam keeps two running, elementwise summaries of past gradients: one for their signed direction and one for their squared magnitude. Watch the conceptual explanation before implementing it.

Adam Optimizer from scratch | Gradient descent made better | Foundations for ML [Lecture 26]

Watch “Adam Optimizer from scratch | Gradient descent made better | Foundations for ML [Lecture 26]” by Vizuara to see why Adam keeps state between updates.

Begin with Adam’s motivation, then watch the moment estimates, bias correction, and the final update. Track which quantities persist from one step to the next; in particular, the second moment is an average of squared gradients, not a mean-centered variance.

Using for the first update, let . Adam starts with and computes

Squares, roots, and division here are elementwise. Common starting choices are , , and . The first running average follows the direction of recent gradients; the second helps scale updates separately for each parameter component. The small prevents division by zero.

Why divide by and ? Both averages started at zero, so their early values are pulled toward zero. These bias corrections compensate for that initialization. On the first step, for example, they yield and .

Adam does not remove the need to choose a learning rate. Nor should you expect either optimizer to lower the loss on every step of a noisy training run.


Implement both updates

We will optimize a two-component parameter rather than a neural network so that the behavior is easy to inspect. The minimum of this loss is at :

The second direction is ten times more curved than the first. For gradient descent on this particular loss, the earlier quadratic calculation says both components shrink only if . The code uses autograd for gradients but implements both parameter updates explicitly.

import torch

def objective(theta):
    target = torch.tensor([1.0, 2.0], dtype=theta.dtype)
    curvature = torch.tensor([1.0, 10.0], dtype=theta.dtype)
    return 0.5 * (curvature * (theta - target).square()).sum()

def gd_step(params, lr):
    with torch.no_grad():
        for p in params:
            if p.grad is None:
                raise ValueError("Compute every parameter gradient before updating.")
            p.add_(p.grad, alpha=-lr)

def init_adam(params):
    return {
        "step": 0,
        "m": [torch.zeros_like(p) for p in params],
        "v": [torch.zeros_like(p) for p in params],
    }

def adam_step(params, state, lr, beta1=0.9, beta2=0.999, eps=1e-8):
    state["step"] += 1
    t = state["step"]

    with torch.no_grad():
        for p, m, v in zip(params, state["m"], state["v"]):
            if p.grad is None:
                raise ValueError("Compute every parameter gradient before updating.")
            g = p.grad
            m.mul_(beta1).add_(g, alpha=1 - beta1)
            v.mul_(beta2).addcmul_(g, g, value=1 - beta2)

            m_hat = m / (1 - beta1**t)
            v_hat = v / (1 - beta2**t)
            p.add_(-lr * m_hat / (v_hat.sqrt() + eps))

def run(method, lr, steps=25):
    theta = torch.tensor([3.0, -3.0], dtype=torch.float64,
                         requires_grad=True)
    params = [theta]
    state = init_adam(params) if method == "adam" else None
    losses = []

    for _ in range(steps):
        # backward() accumulates gradients; start this step with none.
        for p in params:
            p.grad = None

        loss = objective(theta)
        losses.append(loss.item())
        loss.backward()

        if method == "gd":
            gd_step(params, lr)
        else:
            adam_step(params, state, lr)

    with torch.no_grad():
        losses.append(objective(theta).item())
        final_theta = theta.clone()
    return final_theta, losses

# Check the first updates against values calculated directly.
gd_theta, _ = run("gd", 0.05, steps=1)
adam_theta, _ = run("adam", 0.1, steps=1)
torch.testing.assert_close(
    gd_theta, torch.tensor([2.9, -0.5], dtype=torch.float64)
)
torch.testing.assert_close(
    adam_theta, torch.tensor([2.9, -2.9], dtype=torch.float64),
    rtol=1e-7, atol=1e-7,
)

for method, lr in [
    ("gd", 0.005), ("gd", 0.05), ("gd", 0.25), ("adam", 0.25)
]:
    theta, losses = run(method, lr)
    print(f"{method:4s} lr={lr:.3f}  "
          f"losses at steps 0, 1, 10, 25: "
          f"{[round(losses[i], 3) for i in (0, 1, 10, 25)]}  "
          f"final theta={theta.tolist()}")

The first-update checks catch several common mistakes. Initially, the gradient is . Gradient descent with therefore changes the parameters from to . Adam’s bias-corrected first step with changes each component by approximately in the direction opposite its gradient, giving .

In the longer runs, expect gradient descent at to make slow progress, to reduce the loss, and to grow unstable. Adam at may behave quite differently on this example because it rescales components—but that is not a promise that is safe for Adam on another loss. Notice also that each new run creates fresh parameters and fresh Adam state. Carrying moment estimates over to an unrelated run would change the experiment.


Diagnose an unstable learning rate

On the toy loss, the diagnosis is unusually clear: the component with curvature multiplies its error by each gradient-descent step. At , that factor is : the component crosses the minimum and moves farther away each time. Its loss contribution consequently grows.

Real training curves are less tidy. Minibatches produce varying gradients, and a single upward loss step is not proof of instability. Look instead for sustained or repeated sharp rises, widening oscillations, or non-finite losses or parameters. Compare runs from the same initialization while changing the learning rate; log the loss frequently enough to see early behavior. If a large gradient spike accompanies a loss spike, the gradient norm can help locate the problem.

For a short, practical discussion of what to monitor, read the following excerpt.

FAQ | Machine Learning

Read Google’s Machine Learning tuning-playbook FAQ for its distinction between a poor-looking loss curve and evidence you can monitor during an unstable run.

Find the subsection “Identifying unstable workloads.” Read the instability guidance, beginning with its statement about large learning rates and ending with the recommendation to log the full loss-gradient norm. Continue through the early warning immediately below it. Focus on why infrequent loss measurements can conceal an early spike.

A useful first response to suspected learning-rate instability is to lower the learning rate and rerun the same setup. If that produces a substantially steadier loss curve, the comparison supports the diagnosis. Gradient clipping or a learning-rate warmup can address particular patterns, but neither substitutes for checking the update scale; clipping nearly every update can simply disguise an overly large rate.


Takeaways

Gradient descent subtracts a learning-rate-scaled current gradient. Adam also maintains running averages of gradients and squared gradients, corrects their initial bias, and scales updates componentwise. In PyTorch, manual updates belong inside torch.no_grad(), and gradients must be cleared between backward passes.

The learning rate determines whether useful local gradient information becomes steady progress, slow progress, or unstable jumps. You now have both update rules and a way to inspect their behavior. Next, you will put the loss, backpropagation, and an optimizer together to train a small multilayer perceptron.

Can't find a good explanation? Sign up and we'll make it for you

Sign up