Gradient descent
Gradient descent finds good weights by repeatedly taking a small step in the direction that lowers the error fastest.
- 14 min read
- 3 reading levels
- Published
Read these first
On this page 7
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
Gradient descent is a way to find the bottom of a valley by always stepping in the steepest downhill direction.
Picture yourself on a hillside in thick morning fog. You want to reach the lowest point, but you can see barely a metre ahead.
You can still feel the ground under your feet. Shift your weight around and you can tell which way slopes downward. So you take a step that way, then feel again, then step again.
You never see the valley floor. You reach it anyway, one careful step at a time. That is gradient descent, and it is how nearly every AI model on earth was trained.
Why this had to be invented
Backpropagation, the step that works out which knob caused the mistake, gives the network a direction for every knob. It does not say how far to move.
Turning a knob all the way in the suggested direction is a bad idea. That direction is only reliable very close to where you stand. "Downhill" on a mountain changes as you walk.
So the network takes a small step, then recalculates. The size of that step is the single most important setting in all of deep learning.
How it works
feel the slope → step downhill a little → feel the slope again
↑ │
└──────────────────────────────────────────────┘
repeat, thousands of timesThat step size has a name: the learning rate — how far you move on each step.
Getting it wrong is the most common reason training fails.
too small: . . . . . . . . . . . . . . . → still walking, hours later
about right: . . . . . . → arrives, steadily
too large: ↗ ↘ ↗ → bounces over the valley, never landsThe "too large" case surprises people. A step big enough to cross the valley lands you higher up the far side. The next step throws you further still. Instead of settling, the model flies apart, and the error grows every round.
An analogy for the step size
Think about pouring tea into a cup from a height. Pour gently and it takes a while, but the cup fills neatly. Pour hard and most of it splashes out.
You want the largest pour that does not splash. Finding it takes a few tries, and even experienced people run small experiments to settle it.
Where you have already seen this
Nothing you use displays gradient descent to you. But your keyboard suggestions, your bank's fraud alerts and every chatbot arrived at their settings this way.
It is one of the most widely run procedures in computing. It rests on the idea of feeling for downhill.
What is honestly hard here
Real error landscapes are not smooth bowls with one low point. They have ridges, flat plains and many dips, in millions of directions at once.
Nobody can picture that, including researchers. There is no guarantee of finding the very lowest point. In practice, models settle in a dip that is good enough rather than perfect.
That sounds like a weakness. It turns out to work remarkably well, and understanding exactly why is still an open research question. Anyone claiming this is a solved matter is not being straight with you.
Remember this
- Gradient descent takes small steps downhill, over and over.
- The learning rate is the step size, and it is the setting most worth tuning.
- Too large is worse than too small: the error grows instead of shrinking.
What to learn next
- Loss functions — the landscape that gradient descent walks down.
- Backpropagation — where the slope at each step comes from.
- Optimization — the mathematics underneath all of this.
Developer — Code and libraries.
Setup
pip install numpyTwo experiments below. The first shows the step-size failure directly. The second checks gradient descent against an answer we can compute exactly.
import numpy as np
# ---- part 1: walking downhill on one number ----
# The hill is f(w) = (w - 3)^2. Its lowest point is at w = 3, but pretend we do not know that.
def height(w):
return (w - 3.0) ** 2
def slope(w):
return 2.0 * (w - 3.0) # the derivative, worked out by hand
print("lr = 0.1 (a sensible step size)")
w = 0.0
for step in range(6):
print(f" step {step}: w = {w:8.4f} height = {height(w):8.4f} slope = {slope(w):8.4f}")
w = w - 0.1 * slope(w)
for _ in range(44):
w = w - 0.1 * slope(w)
print(f" ...after 50 steps: w = {w:.5f} (the true lowest point is w = 3)")
print("\nlr = 1.1 (steps too big - it climbs the walls instead)")
w = 0.0
for step in range(6):
print(f" step {step}: w = {w:12.4f} height = {height(w):14.4f}")
w = w - 1.1 * slope(w)
# ---- part 2: fitting a real line ----
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
y = np.array([3.1, 4.9, 7.2, 8.9, 11.1]) # roughly y = 2x + 1
w, b = 0.0, 0.0
lr = 0.02
n = len(x)
print("\nfitting y = w*x + b by gradient descent")
for step in range(1, 2001):
pred = w * x + b
err = pred - y
mse = float(np.mean(err ** 2))
gw = float(2.0 * np.mean(err * x)) # slope of the error wrt w
gb = float(2.0 * np.mean(err)) # slope of the error wrt b
w -= lr * gw
b -= lr * gb
if step in (1, 10, 100, 500, 2000):
print(f" step {step:4d}: w = {w:.4f} b = {b:.4f} mse = {mse:.5f}")
# The exact answer, from the closed-form least-squares formula.
A = np.vstack([x, np.ones_like(x)]).T
exact_w, exact_b = np.linalg.lstsq(A, y, rcond=None)[0]
print(f"\ngradient descent found: w = {w:.4f} b = {b:.4f}")
print(f"exact algebra says: w = {exact_w:.4f} b = {exact_b:.4f}")lr = 0.1 (a sensible step size) step 0: w = 0.0000 height = 9.0000 slope = -6.0000 step 1: w = 0.6000 height = 5.7600 slope = -4.8000 step 2: w = 1.0800 height = 3.6864 slope = -3.8400 step 3: w = 1.4640 height = 2.3593 slope = -3.0720 step 4: w = 1.7712 height = 1.5099 slope = -2.4576 step 5: w = 2.0170 height = 0.9664 slope = -1.9661 ...after 50 steps: w = 2.99996 (the true lowest point is w = 3) lr = 1.1 (steps too big - it climbs the walls instead) step 0: w = 0.0000 height = 9.0000 step 1: w = 6.6000 height = 12.9600 step 2: w = -1.3200 height = 18.6624 step 3: w = 8.1840 height = 26.8739 step 4: w = -3.2208 height = 38.6984 step 5: w = 10.4650 height = 55.7256 fitting y = w*x + b by gradient descent step 1: w = 1.0048 b = 0.2816 mse = 57.57600 step 10: w = 2.1133 b = 0.6172 mse = 0.04779 step 100: w = 2.0634 b = 0.8110 mse = 0.02408 step 500: w = 2.0042 b = 1.0248 mse = 0.01444 step 2000: w = 2.0000 b = 1.0400 mse = 0.01440 gradient descent found: w = 2.0000 b = 1.0400 exact algebra says: w = 2.0000 b = 1.0400
Reading the results
Part one, lr = 0.1. The slope column shrinks every step: -6.0, -4.8, -3.84. Steps get smaller automatically as the ground flattens, with no extra logic. Gradient descent slows down near the bottom for free.
Part one, lr = 1.1. The height column tells the story: 9.0, 12.96, 18.66, 26.87. It goes up every step, and w alternates in sign while growing. This is divergence, and it looks exactly like this in a real training log — a loss that climbs and then becomes nan.
There is a precise threshold here. For a bowl of this shape, any learning rate below 1.0 converges and anything above it diverges. Real networks have a threshold too; we cannot compute it, so we find it by experiment.
Part two. Gradient descent lands on w = 2.0000, b = 1.0400, and the closed-form least-squares solution agrees to four decimals. When an exact answer exists, gradient descent finds it. We use gradient descent for neural networks precisely because no exact answer exists there.
Notice the mse stops improving after step 500 — 0.01444 to 0.01440. It has not stalled; it has arrived. The residual error is the noise in the data, which no straight line can remove.
Common mistakes
Loss becomes nan after a few steps. Learning rate too high, almost every time. Divide it by ten and rerun before changing anything else.
Loss decreases painfully slowly. Learning rate too low, or unscaled features. If one feature ranges over thousands and another over fractions, the error surface becomes a long narrow canyon and progress crawls. Standardise your inputs.
Tuning the learning rate by guessing. Run a range test instead: start at 1e-7, multiply by 1.1 each batch, and plot loss against learning rate. Pick the value about one order of magnitude below where the loss starts rising. This takes a couple of minutes and beats a day of guessing.
Assuming a lower training loss is better. It is not, past a point. Track validation loss and stop when that stops improving — see overfitting-and-underfitting.
In practice, you use an optimiser
Nobody writes w -= lr * gw in production. Here is the identical problem in PyTorch, starting from the same zeroed weights so the numbers line up.
import torch
import torch.nn as nn
x = torch.tensor([[1.], [2.], [3.], [4.], [5.]])
y = torch.tensor([[3.1], [4.9], [7.2], [8.9], [11.1]])
model = nn.Linear(1, 1) # one input, one output: w*x + b
nn.init.zeros_(model.weight) # start from zero, matching the NumPy version above
nn.init.zeros_(model.bias)
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.02)
for step in range(1, 2001):
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad() # gradients accumulate by default, so clear them first
loss.backward() # backpropagation fills in every .grad
optimizer.step() # one downhill step for every parameter
if step in (1, 10, 100, 500, 2000):
print(f" step {step:4d}: w = {model.weight.item():.4f} "
f"b = {model.bias.item():.4f} mse = {loss.item():.5f}")
print(f"\nPyTorch found: w = {model.weight.item():.4f} b = {model.bias.item():.4f}")step 1: w = 1.0048 b = 0.2816 mse = 57.57600 step 10: w = 2.1133 b = 0.6172 mse = 0.04779 step 100: w = 2.0634 b = 0.8110 mse = 0.02408 step 500: w = 2.0042 b = 1.0248 mse = 0.01444 step 2000: w = 2.0000 b = 1.0400 mse = 0.01440 PyTorch found: w = 2.0000 b = 1.0400
Compare these numbers against the hand-written loop above. They match to every printed digit, at every checkpoint. PyTorch is doing exactly what you wrote by hand — no more, no less.
Those three calls, in that order, are the core of every PyTorch training loop you will ever read:
optimizer.zero_grad() → loss.backward() → optimizer.step()
clear old grads compute new ones move downhillFor real networks, swap SGD for AdamW and start around lr=3e-4. AdamW gives each parameter its own effective step size, which is why it copes with problems that plain SGD needs careful tuning to handle.
Try it yourself
In part one, set the learning rate to exactly 1.0 and predict what happens before running. Then try 0.99. The behaviour at the boundary is worth seeing once.
What to learn next
- Loss functions — the landscape that gradient descent walks down.
- Backpropagation — where the slope at each step comes from.
- Optimization — the mathematics underneath all of this.
Researcher — Mathematics and papers.
The update, and what guarantees it carries
For an objective $f : \mathbb{R}^{d} \to \mathbb{R}$:
$$ w_{t+1} = w_t - \eta \nabla f(w_t) $$
Where $\eta > 0$ is the step size and $\nabla f(w_t)$ is the gradient at iterate $t$.
Assume $f$ is $L$-smooth, meaning its gradient is Lipschitz continuous with constant $L$:
$$ \left| \nabla f(x) - \nabla f(y) \right| \le L \left| x - y \right| \quad \forall x, y $$
Then the descent lemma gives $f(w_{t+1}) \le f(w_t) - \eta\left(1 - \tfrac{L\eta}{2}\right) \left| \nabla f(w_t) \right|^2$. Progress is guaranteed whenever $0 < \eta < \tfrac{2}{L}$, and the optimal fixed step is $\eta = \tfrac{1}{L}$.
The toy problem above makes this concrete: $f(w) = (w-3)^2$ has $f''= 2$, so $L = 2$ and the divergence threshold is $\eta = 2/L = 1.0$. The observed behaviour matches exactly.
Rates, for reference:
| Assumption | Rate | Iterations for $\epsilon$ |
|---|---|---|
| Convex, $L$-smooth | $f(w_t) - f^* = O(1/t)$ | $O(1/\epsilon)$ |
| $\mu$-strongly convex, $L$-smooth | $O!\left(\left(1 - \tfrac{\mu}{L}\right)^{t}\right)$ | $O!\left(\kappa \log \tfrac{1}{\epsilon}\right)$ |
| Non-convex, $L$-smooth | $\min_t \left| \nabla f(w_t) \right|^2 = O(1/t)$ | $O(1/\epsilon^2)$ to a stationary point |
Where $\mu$ is the strong-convexity constant and $\kappa = L/\mu$ is the condition number. Deep networks satisfy only the last row: convergence to a stationary point, with no claim about its quality.
Stochastic gradient descent
Full-batch gradients cost $O(n)$ per step. SGD (Robbins and Monro, 1951) substitutes a mini-batch estimate:
$$ w_{t+1} = w_t - \eta_t \nabla f_{\mathcal{B}_t}(w_t), \qquad \mathbb{E}\left[\nabla f_{\mathcal{B}}\right] = \nabla f $$
The estimator is unbiased with variance $\sigma^2 / |\mathcal{B}|$. Convergence for a fixed $\eta$ stalls at a noise floor proportional to $\eta \sigma^2$, which is why decaying schedules exist. The Robbins–Monro conditions $\sum_t \eta_t = \infty$ and $\sum_t \eta_t^2 < \infty$ guarantee convergence to a stationary point.
The gradient noise is not purely a cost. It is widely believed to aid generalisation by biasing solutions toward flatter minima — see Keskar et al. (2017), On large-batch training for deep learning, though the flat-minima/generalisation link remains contested (Dinh et al., 2017).
The optimisers you will actually use
Momentum (Polyak, 1964) accumulates an exponentially weighted average of past gradients:
$$ v_{t+1} = \beta v_t + \nabla f(w_t), \qquad w_{t+1} = w_t - \eta \, v_{t+1} $$
Where $\beta \in [0, 1)$ is the momentum coefficient, typically $0.9$. This damps oscillation across a narrow canyon while accumulating speed along it, improving the strongly-convex rate from $O(\kappa)$ to $O(\sqrt{\kappa})$ in the Nesterov variant (Nesterov, 1983).
Adam (Kingma and Ba, 2015) keeps running estimates of the first and second moments:
$$ m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t, \qquad v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 $$
$$ \hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \qquad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}, \qquad w_{t+1} = w_t - \eta \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} $$
Where:
- $g_t = \nabla f_{\mathcal{B}_t}(w_t)$ — the mini-batch gradient.
- $\beta_1 = 0.9$, $\beta_2 = 0.999$ — decay rates for the two moments.
- $\epsilon = 10^{-8}$ — a small constant preventing division by zero.
- $\hat{m}_t, \hat{v}_t$ — bias-corrected estimates; without correction both are biased toward zero early in training, since $m_0 = v_0 = 0$.
Dividing by $\sqrt{\hat{v}_t}$ gives each parameter its own effective step size, which is why Adam tolerates badly scaled problems that plain SGD cannot.
AdamW (Loshchilov and Hutter, 2019), Decoupled weight decay regularization, applies weight decay directly to the parameters rather than through the gradient. In Adam, L2 regularisation added to the loss gets divided by $\sqrt{\hat{v}_t}$ along with everything else, which weakens decay exactly on the parameters with large gradients. AdamW is the correct default for transformers.
Practical notes that matter at scale
- Warmup. Adam's second-moment estimate is unreliable in the first few hundred steps, when the sample is small. A linear warmup over 1–10k steps prevents the early instability. Liu et al. (2020), On the variance of the adaptive learning rate, analyse this.
- Cosine decay from the peak to near zero is the standard schedule for large model training.
- Gradient clipping by global norm, typically at $1.0$, prevents rare large batches from destroying a run.
- Linear scaling rule (Goyal et al., 2017): when multiplying batch size by $k$, multiply the learning rate by $k$ as well. Holds up to roughly 8k examples per batch, then breaks down.
- Adam stores two extra states per parameter. For a model in
fp32, optimiser state alone costs $8$ bytes per parameter — often more memory than the weights themselves.
What to learn next
- Loss functions — the landscape that gradient descent walks down.
- Backpropagation — where the slope at each step comes from.
- Optimization — the mathematics underneath all of this.