Math for ML

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

Intuition

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.

Analogy

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:

J(w) = (w − 3)² + 1
We can see by eye that the minimum is at w = 3, where the cost bottoms out at 1. The algorithm can't see that. It only ever gets to ask one question: what is the slope right here?

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 = 8 the slope is 2 × 5 = 10. Positive slope means the ground rises to the right, so go left: w ← 8 − 0.1 × 10 = 7.0
  • At w = 7 the slope is 2 × 4 = 8. Step of 0.8: w ← 6.2
  • At w = 6.2 the slope is 6.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:

w := w − α · ∂J/∂w
In words: the new weight is the old weight, moved a little way in the direction that makes the cost go down. The minus sign is doing the work — the derivative points uphill, and we want the opposite.
SymbolRead asWhat 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 = [ ∂J/∂w₁ , ∂J/∂w₂ , … , ∂J/∂wn ]

θ := θ − α ∇J
In words: the gradient is a vector pointing in the direction of steepest increase. Subtracting it moves every parameter simultaneously in the direction that reduces cost fastest. θ (theta) is just shorthand for "all the parameters at once".

For the line-fitting example, the cost is mean squared error and the two partials work out to:

J(w, b) = (1/n) Σ (w·xᵢ + b − yᵢ)²

∂J/∂w = (2/n) Σ (w·xᵢ + b − yᵢ) · xᵢ
∂J/∂b = (2/n) Σ (w·xᵢ + b − yᵢ)
In words: both gradients are just the average error, with the 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.

gradient_descent_1d.py
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}")
step 1: gradient= 10.00 w= 7.000 J(w)=17.0000 step 2: gradient= 8.00 w= 6.200 J(w)=11.2400 step 3: gradient= 6.40 w= 5.560 J(w)= 7.5536 step 4: gradient= 5.12 w= 5.048 J(w)= 5.1943 step 5: gradient= 4.10 w= 4.638 J(w)= 3.6844

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.

gradient_descent_fit.py
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}")
iter 1: w=1.0220 b=0.1805 MSE=23.04574 iter 10: w=1.9436 b=0.3332 MSE=0.04262 iter 100: w=1.9607 b=0.2434 MSE=0.03322 iter 1000: w=1.9966 b=0.0414 MSE=0.02435 iter 5000: w=1.9976 b=0.0357 MSE=0.02435

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:

scikit-learn
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:

PyTorch — the same loop, autodifferentiated
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 + ∇J
θ := θ − α·v
In words: 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 NaN within 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-b behaviour in the code above. The cost surface is a long narrow ravine and descent zigzags down it.
Fix
StandardScaler before 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 .grad by 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
How far along the negative gradient each update moves. Too large and you overshoot the minimum and can diverge; too small and training takes impractically long.
Why is there a minus sign in the update rule? Junior
The gradient points in the direction of steepest increase in cost. We want to reduce cost, so we move the opposite way.
Explain the difference between batch, mini-batch and stochastic gradient descent. Senior
Batch uses the whole dataset per update — accurate gradient, expensive, and needs the data in memory. Stochastic uses one sample — very noisy but fast and able to escape shallow local minima. Mini-batch (typically 32–256 samples) is the practical middle ground and is what essentially everyone actually uses.
Gradient descent only finds a local minimum. Why does deep learning work anyway? Senior
In very high-dimensional spaces the overwhelming majority of stationary points are saddle points rather than local minima, and the local minima that do exist tend to have similar loss. Momentum and adaptive optimizers also help escape flat regions and saddles.
Comes from Calculus — derivatives, chain rule, gradients
Leads to Core ML — bias-variance & cross-validation
Check yourself

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?

  • 9.0 — you step in the direction of the slope. The slope points uphill. The update subtracts it.
  • 7.9 — each step moves by the learning rate. The step is α times the slope (0.1 × 10 = 1.0), not α itself.
  • 3.0 — gradient descent jumps to the minimum. Only α = 0.5 does that on this curve. α = 0.1 moves by 1.0.
  • 7.0 — the slope is 2(8 − 3) = 10, and 8 − 0.1 × 10 = 7. Positive slope means the ground rises to the right, so the step goes left.

Same curve, same start, but α = 1.0. What happens?

  • It bounces between 8 and −2 forever, never getting closer. Each step overshoots by exactly as much as it corrects.
  • It flies off to infinity. That happens above 1.0, where each bounce is bigger than the last.
  • It lands on the minimum in a single step. That is α = 0.5.
  • It converges, just slowly. It never gets closer: the distance to the minimum stays 5 on every step.

One weight converges in ten iterations while another needs ten thousand. What is the likely cause, and the fix?

  • Unscaled features make the cost surface a narrow ravine. Standardise the features. With features on similar scales, the bowl is round and every weight converges at a similar pace.
  • The learning rate is too high. Divide it by 10. Too high a rate makes the loss rise or go NaN — and a smaller rate makes the slow weight even slower.
  • It is stuck in a bad local minimum. Restart from elsewhere. The loss is still falling; it is just zigzagging down a ravine.

Training loss keeps falling. When should you stop?

  • When training loss reaches zero. A converged optimiser and a good model are different things; zero training loss usually means memorisation.
  • When validation loss stops improving and turns upward. From there on, the model gets worse on data it hasn't seen.
  • Never — a lower training loss always means a better model. Training loss says how well you fit the training data, not how well you will do on new data.

Understood the update rule and why the learning rate decides everything?