Optimisation
Key takeaways Come back here to revise
- Gradient descent steps against the slope: w ← w − α · dJ/dw. Steps shrink by themselves as the curve flattens near the minimum.
- On J(w) = (w − 3)² + 1, starting from w = 8: α = 0.1 converges, α = 0.5 lands in one step, α = 1.0 bounces between 8 and −2 forever, and anything larger diverges.
- Unscaled features turn the cost surface into a long, narrow ravine that descent zigzags down. Standardise before fitting.
- A falling training loss is not a good model — watch validation loss. In PyTorch, call
optimizer.zero_grad()every step, because gradients accumulate.
Why this exists
You've built a model with one knob on it, called w. Every value of that knob produces
predictions, and every set of predictions is wrong by some amount. You want the value of
w that is least wrong.
So try them all. Except w is a real number, so there are infinitely many. Fine — sweep
it in steps of 0.01 across a sensible range and pick the winner. That works. Now your model has two
knobs, and you need to sweep a grid. Then ten knobs. A small neural network has ten thousand
knobs; a large language model has hundreds of billions. A grid search over even ten parameters at
ten values each is 1010 evaluations — and you'd still only have them to two decimal places.
Brute force is out. And solving it algebraically — setting the derivative to zero and rearranging — only works for a handful of tidy models, of which linear regression is basically the last one. You need a method that works from wherever you happen to be standing, using nothing but local information, and that scales to billions of parameters.
The one idea
You're somewhere on a hillside in thick fog. You cannot see the valley, and you have no map — but you can feel which way the ground slopes under your feet. So you take a step downhill. Then you feel the slope again, and take another. Repeat until the ground is flat.
It's an autoscaler. Your service doesn't know the correct number of replicas — that depends on traffic nobody can predict. So it doesn't try to compute the answer. It measures the error (latency above target), nudges the replica count in the direction that reduces that error, by an amount proportional to how far off it is, and repeats. Big error, big correction. Nearly on target, tiny correction. Gradient descent is exactly that control loop, with the replica count replaced by your model's weights.
Walking downhill, by hand
Let's make it concrete with the smallest possible example. Our cost function — how wrong we are as a function of the knob — is a parabola:
The slope of that curve at any point is dJ/dw = 2(w − 3). Start at w = 8
with a learning rate of 0.1 and turn the handle three times:
- At
w = 8the slope is2 × 5 = 10. Positive slope means the ground rises to the right, so go left:w ← 8 − 0.1 × 10 = 7.0 - At
w = 7the slope is2 × 4 = 8. Step of 0.8:w ← 6.2 - At
w = 6.2the slope is6.4. Step of 0.64:w ← 5.56
Look at what happened to the step sizes: 1.0, then 0.8, then 0.64. Nobody told it to slow down. As the curve flattens near the bottom, the slope shrinks, and the steps shrink with it — automatically. That self-braking behaviour is why this simple rule works at all.
Now the important part. Drag the learning rate up. At α = 0.5 it lands exactly on the minimum in a single step. At α = 1.0 it bounces between 8 and −2 forever, never getting closer. Above that, each bounce is bigger than the last and the whole thing flies apart. Same algorithm, same curve, same starting point — one number decides whether it works.
Why this curve is the easy case
That parabola is convex: pick any two points on it, draw a straight line between them, and the line never dips below the curve. Convexity buys you something enormous — there is exactly one minimum, and any path that keeps going downhill must eventually reach it. No starting point is unlucky. Linear and logistic regression both have convex cost functions, which is why they train reliably and why they're the right place to learn this.
Most interesting models are not convex. A neural network's cost surface is a landscape of ridges, valleys and plateaus, and where you end up depends on where you started. It also contains saddle points — places that slope down in one direction and up in another, so the gradient is zero without being a minimum at all. Think of a mountain pass: flat underfoot, but you're nowhere near the bottom. Plain gradient descent stalls there, because a zero gradient means a zero step. That single failure mode is the reason momentum and adaptive optimizers exist.
Here's the identical update rule doing something real: fitting a straight line to eight points. Two
knobs this time, a slope w and an intercept b. The red bars are the residuals
— the errors being squared and summed — and the inset tracks the cost falling with every iteration.
The update rule
Everything above is one line:
| Symbol | Read as | What it is |
|---|---|---|
| w | "double-u" | The parameter being tuned. In our example it started at 8. |
| := | "is assigned" | Assignment, not equality. The new value replaces the old. |
| α | "alpha" | The learning rate. How big a fraction of the slope to actually move. Ours was 0.1. |
| J | "jay" | The cost function — one number saying how wrong the model currently is. |
| ∂J/∂w | "partial J by partial w" | The slope of the cost with respect to this one parameter, holding the others still. |
Plugging the very first step in: w := 8 − 0.1 × 10 = 7. That's the whole algorithm. Every
model in this track, and every neural network you will ever train, is that line in a loop.
With more than one parameter, nothing changes conceptually — you just do it to each one. Collect the partial derivatives into a vector and you have the gradient, written ∇J:
θ := θ − α ∇J
For the line-fitting example, the cost is mean squared error and the two partials work out to:
∂J/∂w = (2/n) Σ (w·xᵢ + b − yᵢ) · xᵢ
∂J/∂b = (2/n) Σ (w·xᵢ + b − yᵢ)
w one weighted by
x — because a point far out on the x-axis is affected more by a change in slope than a
point near the origin.
Twelve lines of Python
Same curve, same starting point, same learning rate as the first visualization. Run it and compare the numbers to what you saw on the chart.
def J(w):
return (w - 3) ** 2 + 1
def grad(w):
return 2 * (w - 3) # dJ/dw, worked out by hand
w = 8.0 # where we happen to start
lr = 0.1 # the learning rate, alpha
for step in range(5):
g = grad(w)
w = w - lr * g # THE update rule. This is all of it.
print(f"step {step + 1}: gradient={g:6.2f} w={w:6.3f} J(w)={J(w):7.4f}")
Now the two-parameter version, fitting the same eight points as the second visualization. Note there is
still no library doing anything clever — NumPy is here only so that w * X multiplies the
whole array at once instead of needing a loop.
import numpy as np
X = np.array([1, 2, 3, 4, 5, 6, 7, 8], dtype=float)
y = np.array([2.1, 3.9, 6.2, 7.8, 10.1, 12.2, 13.8, 16.1])
n = len(X)
w, b = 0.0, 0.0 # start with a flat line through the origin
lr = 0.01
for i in range(1, 5001):
err = (w * X + b) - y # how wrong each prediction is
gw = (2 / n) * np.sum(err * X)
gb = (2 / n) * np.sum(err)
w -= lr * gw # same update rule, once per parameter
b -= lr * gb
if i in (1, 10, 100, 1000, 5000):
mse = np.mean(((w * X + b) - y) ** 2)
print(f"iter {i:>4}: w={w:.4f} b={b:.4f} MSE={mse:.5f}")
The exact least-squares answer for this data is w = 1.9976, b = 0.0357, with
an MSE of 0.02435. Gradient descent found it to four decimal places without ever solving
an equation — just by feeling the slope five thousand times.
One detail worth noticing: after 10 iterations w was already at 1.94, but b
took a thousand more to settle. The two parameters converge at wildly different speeds because
x ranges up to 8 while the intercept's "feature" is always 1. That's not a quirk of this
dataset — it's the reason feature scaling exists.
What you'd actually write
Nobody hand-derives gradients at work. In scikit-learn the whole loop is one fit call:
from sklearn.linear_model import SGDRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
# StandardScaler is not optional here — see the pitfalls below.
model = make_pipeline(
StandardScaler(),
SGDRegressor(
loss="squared_error",
learning_rate="invscaling", # alpha shrinks as training goes on
eta0=0.01, # the starting learning rate
max_iter=5000,
tol=1e-6, # stop early once improvement stalls
),
)
model.fit(X.reshape(-1, 1), y)
And in PyTorch, where you'd be training something with rather more than two parameters, you never write
the derivative at all — loss.backward() computes every partial derivative in the whole
network by applying the chain rule automatically:
import torch
model = torch.nn.Linear(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
loss_fn = torch.nn.MSELoss()
for epoch in range(5000):
optimizer.zero_grad() # clear last step's gradients
pred = model(X_tensor)
loss = loss_fn(pred, y_tensor)
loss.backward() # fills .grad on every parameter
optimizer.step() # w -= lr * w.grad, for every w
What the framework gives you that your version didn't: automatic differentiation, so you never derive a gradient by hand again; numerically stable reductions; and vectorised, GPU-parallel arithmetic.
Momentum, in one idea
It also gives you better update rules. The most important one is momentum, and the intuition is exactly what the word suggests: instead of stepping purely by the current slope, keep a running velocity and let each gradient nudge it.
θ := θ − α·v
v is a rolling average of recent gradients, and β (typically 0.9) sets
how much of the old velocity survives each step. Roll a ball down the hill instead of teleporting it.
Two things fall out of that. Across a plateau or a saddle point, where the gradient is near zero, the accumulated velocity carries you through instead of stalling. And down a long narrow ravine — the unscaled-features case — the side-to-side oscillations point in opposite directions on alternating steps and cancel out in the average, while the consistent downhill component adds up. You get less zigzag and faster progress at once.
Adam, the default choice for most deep learning, combines momentum with a separate
adaptive learning rate per parameter — which fixes exactly the slow-b problem you saw
above. In PyTorch it's a one-word change: torch.optim.Adam(model.parameters(), lr=0.01).
When to reach for it
Use gradient descent when
- You have many parameters — anything past a handful, and certainly any neural network.
- The cost function is differentiable and you can compute (or autodiff) its gradient.
- No closed-form solution exists, which is almost always.
- The dataset is too large to fit in memory — mini-batch variants stream it.
- You want to train online, updating as new data arrives.
Don't bother when
- A closed-form solution exists and the data is small — for plain linear regression the normal equation gives the exact answer in one shot.
- The cost function isn't differentiable — decision trees split on discrete criteria and can't use it at all.
- You have very few parameters and a cheap cost function; a direct search may genuinely be simpler.
- The surface is wildly non-convex and you need the global optimum guaranteed. Gradient descent finds a minimum, not the minimum.
That last point deserves emphasis, because it sounds fatal and isn't. On a landscape with many valleys, gradient descent settles into whichever one it happened to start above. For deep networks this turns out not to matter much in practice — in very high dimensions, most stationary points are saddle points rather than bad local minima, and the minima that exist tend to be roughly as good as each other.
What goes wrong, and the fix
Learning rate too high
- Symptom
- Loss increases instead of decreasing, oscillates between two values, or becomes
NaNwithin a few iterations. - Fix
- Divide the learning rate by 10 and try again. Reproduce it yourself: set α above 1.0 in the first visualization and watch it bounce, then diverge.
Learning rate too low
- Symptom
- Loss decreases, but so slowly the curve looks almost flat. Training "finishes" nowhere near the minimum.
- Fix
- Multiply by 10 until it starts to misbehave, then back off one notch. Always plot loss against iteration — you cannot diagnose this from the final number alone.
Unscaled features
- Symptom
- One parameter converges in ten iterations while another takes ten thousand — exactly the slow-
bbehaviour in the code above. The cost surface is a long narrow ravine and descent zigzags down it. - Fix
StandardScalerbefore fitting. This is the single highest-value habit in this lesson.
Forgetting to zero the gradients
- Symptom
- PyTorch only. Loss behaves erratically or explodes; effective step size grows every epoch.
- Fix
optimizer.zero_grad()at the top of every loop. PyTorch accumulates into.gradby design, so skipping this sums every gradient since the start of training.
Judging convergence by the training loss
- Symptom
- Training loss keeps falling and you keep training. The model gets worse on data it hasn't seen.
- Fix
- Watch validation loss and stop when it turns upward. A converged optimizer and a good model are different things — that distinction is the whole of the next lesson.
Interview questions & what's next
What does the learning rate control? Junior
Why is there a minus sign in the update rule? Junior
Explain the difference between batch, mini-batch and stochastic gradient descent. Senior
Gradient descent only finds a local minimum. Why does deep learning work anyway? Senior
Our cost was J(w) = (w − 3)² + 1, and α = 1.0 made it oscillate forever between 8 and −2. Without running any code: for which values of α does this particular cost function converge — and what property of the curve decides that threshold?
Deliberately unanswered. You have everything you need above.Quick check
Four questions on this lesson. Pick an answer and each option explains itself — the wrong ones too.
On J(w) = (w − 3)² + 1, starting at w = 8 with α = 0.1, what is w after one step?
Same curve, same start, but α = 1.0. What happens?
One weight converges in ten iterations while another needs ten thousand. What is the likely cause, and the fix?
Training loss keeps falling. When should you stop?
Understood the update rule and why the learning rate decides everything?