Gradient Descent in Depth — and Building It in NumPy
In Lesson 7 you met gradient descent as an idea: stand on the loss surface, feel which way is downhill, take a step, repeat. In Lesson 10 you solved Linear Regression exactly with a formula — and saw why that formula stops scaling. This lesson closes the loop. We take the same model and the same mean-squared-error loss, derive its gradient by hand, turn that gradient into a NumPy loop, and watch the optimizer crawl to the very numbers the closed form already gave us. Because we own the exact answer, we can grade the optimizer — a luxury you will never have again. Along the way we'll see why the learning rate makes or breaks everything, why feature scaling turns a hopeless crawl into a one-step jump, and how batch, mini-batch, and stochastic descent trade accuracy for speed.
Because the formula is a special privilege, not the rule. Linear Regression is almost the only model in this course whose minimum you can write down; Logistic Regression, neural networks, and nearly everything else in modern ML have no closed form — the only way to their parameters is to descend a loss surface step by step. So gradient descent is not a backup plan for Linear Regression; it is the main engine of the entire field, and Linear Regression is simply the one place clean enough to build that engine and verify it works. We spend this lesson making the abstract walk of Lesson 7 into concrete, running code, on the one problem where we can check every step against ground truth.
What this lesson delivers
- The gradient of the MSE loss, derived from scratch and then written in one vectorized line.
- A from-scratch batch gradient-descent loop in NumPy that converges to the Lesson 10 answer.
- A working feel for the learning rate: too small crawls, too large explodes, and there's a sweet spot you can find by reading a loss curve.
- Why feature scaling is not optional for gradient descent — the same fit that needs 40,000 steps on raw data lands in one step when standardized.
- Batch vs. mini-batch vs. stochastic gradient descent, each as a small edit to the same loop.
- How to decide when the optimizer has converged and it's time to stop.
1 · Why walk down when you could jump?
Recall the closing tension of Lesson 10. The normal equation θ = (XTX)−1XTy hands you the exact best weights in one shot — but forming and inverting XTX costs on the order of d3 arithmetic for d features, and needs the whole dataset in memory at once. With a dozen features that's nothing; with fifty thousand features, or ten million rows that don't fit in RAM, it is impossible. And for models whose loss is not a neat convex bowl, there is no formula to invert in the first place.
Gradient descent trades one expensive exact answer for many cheap approximate steps. Each step looks at the slope of the loss and nudges the parameters a little way downhill; enough steps and you arrive at (essentially) the same minimum, having never inverted a matrix. Its per-step cost is only the cost of evaluating the gradient — linear in the data, not cubic in the features — and, as we'll see with the stochastic variant, you don't even need all the data in memory to take a step.
The loss J(w, b) from Lesson 10 is a bowl sitting over the plane of all possible parameter pairs (w, b). The closed form teleports to the bottom of the bowl. Gradient descent is a marble released on the inside wall: at every instant it rolls in the steepest downhill direction, and if you release it gently enough it settles at the bottom. Everything in this lesson — the gradient, the learning rate, feature scaling — is about controlling that roll so the marble actually reaches the bottom instead of crawling forever or flying out of the bowl.
2 · The gradient of the loss, derived by hand
Gradient descent needs one thing the concept lesson took on faith: the actual gradient of this loss. Let's derive it — it is three lines of the same calculus from Lesson 10's closed-form section, stopped one step earlier. We never set the derivative to zero this time; we keep it, because the derivative is the downhill direction.
Write the residual of example i as r(i) = ŷ(i) − y(i) = (w x(i) + b) − y(i). The loss is the mean of the squared residuals:
Differentiate with respect to each parameter. The square's exponent drops to a factor of 2 (chain rule), and the inside's derivative is x(i) for w and 1 for b — exactly the two derivatives you computed in Lesson 10, only now we stop before setting them to zero:
Both partials are weighted averages of the residuals. The bias gradient is just the average residual — if the model is systematically too high, this is positive, and the update pushes b down. The weight gradient averages each residual multiplied by its feature: an over-prediction on a big house pushes the slope down harder than the same error on a small house, because big-x points have more leverage on the slope. Descent doesn't need to understand the data — it just moves each parameter opposite to the direction that is currently making the loss grow.
The vectorized gradient — one line for any number of features
Writing two separate sums doesn't scale to fifty features. As in Lesson 10, fold the bias into the weights: add a column of 1s to the data so the parameter vector is θ = (b, w1, …) and the predictions for all n rows are the single matrix–vector product Xθ. Then the whole residual vector is r = Xθ − y, and both partials collapse into one gradient vector:
That single expression is the entire mathematical content of gradient descent for Linear Regression. Everything else is a loop around it. Notice it reuses the exact quantities from the normal equation — XTX and XTy live inside it — which is why setting ∇J = 0 gives back that formula. The two methods are the same landscape; one jumps to the bottom, the other walks.
3 · The update rule, and the algorithm in full
The update is Lesson 7's rule, now with a gradient we can actually compute. Move the parameters a small step in the direction opposite the gradient (downhill is the negative of "the way the loss grows"), scaled by the learning rate α:
The full algorithm is nothing more than repeating that line:
4 · From scratch in NumPy
We'll fit the same six-house dataset from Lesson 10, so we can check the optimizer against the closed-form answer w ≈ 2.104, b ≈ 47.87. One preparatory step needs a word of justification now and a full one in section 6: we first standardize the feature — subtract its mean and divide by its standard deviation. Skip this and, as we'll see, raw metres force a learning rate near 0.0001 and tens of thousands of steps. Standardized, the same fit is almost instantaneous. Trust the move for now; section 6 shows exactly why.
First the transparent, two-scalar version — a direct transcription of the two partials from section 2, so you can see each gradient by name:
import numpy as np
# Same toy houses as Lesson 10: size (m2) -> price (thousands $)
x = np.array([ 50, 60, 80, 95, 120, 150], dtype=float)
y = np.array([150, 175, 210, 260, 300, 360], dtype=float)
# Standardize the feature: z has mean 0, std 1 (see section 6 for why)
x_mean, x_std = x.mean(), x.std()
z = (x - x_mean) / x_std
n = len(y)
w, b = 0.0, 0.0 # start at the origin
alpha = 0.1 # learning rate
for step in range(500):
y_hat = w * z + b # predictions
resid = y_hat - y # r = y_hat - y
grad_w = (2 / n) * np.sum(resid * z) # dJ/dw (section 2)
grad_b = (2 / n) * np.sum(resid) # dJ/db
w -= alpha * grad_w # step downhill
b -= alpha * grad_b
print(w, b) # ~ 72.3 242.5 (parameters in STANDARDIZED space)
Those aren't the Lesson 10 numbers — they're the parameters of the line in standardized coordinates. That's expected: we changed the feature's units, so the slope and intercept changed with them. Converting back to real metres and dollars is pure algebra (a slope divided by the feature's spread, an intercept shifted to undo the centring):
w_real = w / x_std # undo the /std on the slope
b_real = b - w * x_mean / x_std # undo the centring
print(w_real, b_real) # ~ 2.104 47.87 <- exactly the Lesson 10 closed form
An iterative walk that never once inverted a matrix, started from (0, 0) knowing nothing, arrived at w ≈ 2.104, b ≈ 47.87 — the same digits the closed-form formula produced in Lesson 10. That agreement is the whole reason we did Linear Regression first. From here on, most models you meet only have this iterative road; you'll trust it because you watched it hit a target you could independently compute. This is the optimizer earning its credibility on the one problem where credibility can be checked.
The vectorized version — the one you'll actually reuse
The two-scalar loop only handles a single feature. Here is the general form, straight from ∇J = (2/n) XTr. Swap in a wider X and it fits any number of features without another line changing — and it records the loss each step so we can plot convergence:
# Design matrix: bias column of 1s beside the standardized feature
X = np.column_stack([np.ones_like(z), z]) # shape (6, 2): [1, z] per row
theta = np.zeros(X.shape[1]) # [b, w], generalizes to [b, w1, w2, ...]
alpha = 0.1
history = []
for step in range(500):
resid = X @ theta - y # r = X.theta - y (all rows at once)
grad = (2 / n) * (X.T @ resid) # the whole gradient in one line
theta -= alpha * grad
history.append(np.mean(resid ** 2)) # track the loss to watch it fall
b, w = theta
print(w / x_std, b - w * x_mean / x_std) # ~ 2.104 47.87 (same answer)
That one history.append line is the cheapest diagnostic in machine learning. Plotting history — loss against step number — instantly tells you whether the optimizer is healthy: a smooth curve bending down to a floor means it's converging; a curve that rises or thrashes means the learning rate is too big; a nearly flat line that never bottoms out means it's too small. You will read this plot more often than any other in your ML life. Never run a descent blind.
5 · The learning rate: the one knob that decides everything
Lesson 7 warned that the step size α is the single most consequential number in the procedure. Now you can watch it happen. On the standardized house data, run the loop above at several learning rates and look at the loss curve:
| Learning rate α | What the loss curve does | Verdict |
|---|---|---|
| 0.001 | Drifts down in a nearly straight, shallow line; still far from the bottom after 500 steps. | Too small — correct direction, wastefully slow. |
| 0.1 | Drops fast and flattens onto the minimum within ~100 steps. | Good. |
| 0.5 | Reaches the exact minimum in a single step (see the note below). | Optimal, for this particular bowl. |
| 1.0 | Bounces between two points forever, never settling. | On the knife-edge of instability. |
| 1.5 | The loss grows each step and overflows to inf within a few dozen iterations. | Diverged. |
For a squared-error loss the update multiplies the distance-to-minimum by a fixed factor (1 − 2α) along each direction of the (standardized, round) bowl. Convergence needs that factor to shrink distances, i.e. |1 − 2α| < 1, which is exactly 0 < α < 1. At α = 0.5 the factor is zero — one step annihilates the entire distance, landing on the minimum. Above α = 1 the factor exceeds 1 in magnitude: every step overshoots the bottom and lands farther up the opposite wall than it started, so the marble climbs out of the bowl. That is not a bug — it's the arithmetic of stepping too far, and it's why a diverging loss curve is a symptom you fix by cutting α, not by running longer.
You don't derive it — you search. Try a geometric ladder (0.001, 0.01, 0.1, 1…), watch each loss curve for a few hundred steps, and take the largest α that still decreases smoothly without thrashing. Slightly too small merely wastes time; too large wastes everything by diverging, so err small when unsure. Practitioners also decay the rate — start bold to cover ground, then shrink it to settle precisely — but a single well-chosen constant is plenty for this course, and knowing the "largest stable step" heuristic is what matters.
6 · Feature scaling: why the bowl's shape is destiny
Now the debt from section 4 comes due. Why standardize at all — the closed form in Lesson 10 didn't need it? Because gradient descent, unlike the formula, is exquisitely sensitive to the shape of the loss bowl, and the shape is set by the scale of the features.
Feed the loop the raw feature (sizes up to 150) instead of the standardized one, and it falls apart. The gradient's weight-component contains x2 terms as large as 1502 = 22{,}500, while the bias-component is of order 1. The two parameters therefore live on wildly different slopes: the loss surface is not a round bowl but a long, narrow valley — for this data, about 80,000 times longer than it is wide. That is precisely the failure mode Lesson 7 promised would "actually bite you."
# The SAME loop on RAW x instead of standardized z -- watch it struggle
X_raw = np.column_stack([np.ones_like(x), x]) # [1, size], size up to 150
# alpha = 0.1 here -> loss overflows to inf almost immediately.
# To even stay stable you must shrink the step to about:
alpha = 0.0001
# ...and then it needs on the order of 40,000 steps to reach
# the answer that standardized data reached in ~100.
Picture a canyon that's a mile long and a foot wide. The steep across-canyon walls demand a tiny step, or you ricochet off the far wall (divergence). But the canyon floor slopes toward the exit almost imperceptibly, so that same tiny step barely moves you along the length. One learning rate has to serve both directions at once, and the steep direction sets the ceiling — so progress along the long direction is agonizing. Standardizing the features rescales the axes until the canyon becomes a round bowl: now the same step is appropriate in every direction, and descent walks straight to the middle. This is why feature scaling is a prerequisite for gradient descent, not a nicety.
To standardize (also called z-scoring) a feature, replace each value with z = (x − μ) / σ, where μ is the feature's mean and σ its standard deviation. Every feature then has mean 0 and spread 1, so no feature dominates the geometry by accident of its units. Fit the model in this scaled space, then convert the learned parameters back if you want them in original units — exactly the two-line un-scaling we did in section 4. One discipline to bank now for Lesson 19: compute μ and σ on the training data only, and apply those same numbers to test data — peeking at the test set to scale is a form of data leakage.
Feature scaling gets its own full treatment in Lesson 12, alongside multiple and polynomial regression — because the moment you have several features on different units (square metres, bedrooms, age in years) the valley problem is the rule, not the exception. What you've seen here is the why: scaling isn't cosmetic tidying, it's what makes the optimizer's single step size meaningful across every parameter at once.
7 · Batch, mini-batch, and stochastic — how much data per step
Every loop so far computed the gradient over all n examples before taking one step. That's batch gradient descent: the most accurate downhill direction, but each step pays for a full pass through the data. When the data is millions of rows, one step becomes painfully expensive — and you'd like to start improving before you've even read everything once. The fix is to estimate the gradient from a subset of the data each step. This is the same update rule; only the rows feeding the gradient change.
Here is stochastic gradient descent as a small edit to the same loop — shuffle the data, then step once per example. An epoch is one full sweep through the dataset:
theta = np.zeros(X.shape[1])
alpha = 0.01 # noisier steps -> usually a smaller rate than batch
for epoch in range(50): # 50 sweeps through the data
order = np.random.permutation(n) # reshuffle each epoch
for i in order:
xi = X[i:i+1] # one row, kept 2-D: shape (1, features)
yi = y[i:i+1]
resid = xi @ theta - yi # gradient from a SINGLE example
grad = 2 * (xi.T @ resid) # (2/1) * x_i^T r_i
theta -= alpha * grad
For mini-batch, change one thing: instead of one index i, slice the shuffled order into chunks and feed each chunk (a small X) through the very same vectorized gradient (2/m) XTr, where m is the chunk size. The gradient line you already wrote handles it unchanged — that's the payoff of vectorizing.
You'd expect one-example gradients to be strictly worse — and for this tiny convex bowl, batch is indeed cleaner. But on the messy, non-convex loss surfaces of the models beyond this course, the noise in stochastic steps is quietly useful: it lets the marble rattle out of shallow dips and bad flat spots that a perfectly smooth batch descent would settle into. That's a large part of why mini-batch SGD, not the exact batch gradient, is the workhorse that trains essentially every modern model. You're learning the exact algorithm the whole field runs — on the smallest possible dataset so you can see every step.
Knowing when to stop
The closed form finishes when the algebra ends; an iterative method has to decide it's done. Three common criteria, usually combined:
- The loss stops improving. When the change in loss between steps falls below a small tolerance, the marble has effectively reached the floor — further steps only shuffle floating-point dust.
- The gradient goes (nearly) to zero. A tiny gradient magnitude means the ground is flat — the definition of a minimum, and the same condition the normal equation solved for directly.
- A step budget. A hard cap on iterations (or epochs) so a badly-tuned run can't loop forever — and, later, a way to stop before a model starts overfitting (Lesson 13).
8 · What to keep honest about
- Convexity was a gift. Linear Regression's bowl has exactly one minimum, so any stable descent finds the answer. Later models have bumpy surfaces with many valleys — where you start and how you step then change which minimum you reach. Enjoy the guarantee while it lasts.
- The learning rate is unforgiving. There is no universal good value; it depends on the loss and the data's scale. Too large silently diverges, too small silently wastes hours. Reading the loss curve is the skill that saves you.
- Scaling is mandatory, not optional. Unscaled features turn descent into the canyon crawl of section 6. If a gradient-based fit "won't converge," suspect the feature scales before anything else.
- Convergence is approximate. You stop at "close enough," never exactly at the minimum. For most purposes that's fine — but it's a real difference from the closed form, and the tolerance you choose is a genuine decision.
How this connects to the next lesson
You now own both roads to a linear model's minimum: the exact formula and the iterative descent, and you've seen that descent's health is decided by the learning rate and the scale of the features. Lesson 12 — Multiple & Polynomial Regression and Feature Scaling cashes that in. Real problems have many features at once — and your vectorized loop already handles them, unchanged. We'll add features, bend the straight-line model into curves with polynomial features (still linear in its parameters, so the same machinery fits it), and make feature scaling a first-class step rather than the quiet prerequisite it was here. The engine is built; next we drive it on real terrain.
Exercises & reflection
The first is with pencil; the rest are at the keyboard, extending the code above. If you can do them, gradient descent is genuinely yours.
Starting from J(w,b) = (1/n)∑(w x(i) + b − y(i))2, take the partial derivatives with respect to w and b and confirm you land on the two formulas in section 2. Then explain, in one sentence each, why the b-gradient is the average residual and why the w-gradient weights each residual by its feature.
Show the key step
Chain rule on the square: the exponent 2 comes down as a factor, and you multiply by the derivative of the inside. For b the inside's derivative is 1, giving (2/n)∑r(i); for w it is x(i), giving (2/n)∑x(i)r(i). The b-gradient is the mean residual because b shifts every prediction equally, so it responds to the overall bias. The w-gradient weights by x because tilting the slope changes far-out predictions more than near-ones — points with large x have more leverage on the line's angle.
Run the vectorized batch loop on the standardized data. Convert the final theta back to real units and confirm you recover w ≈ 2.104, b ≈ 47.87. Then plot history against the step index and describe the curve's shape.
Show discussion
The recovered parameters should match Lesson 10's closed form to several digits — that agreement is the point of the exercise. The loss curve drops steeply at first (the gradient is large far from the minimum, so early steps cover the most ground) then bends into a flat floor as the gradient shrinks near the bottom. A curve that never flattens means too small a rate; one that rises or oscillates means too large.
Rerun the loop at α = 0.001, 0.1, 0.5, 1.0, 1.5, plotting all five loss curves together. Match each to a row of the section-5 table, and confirm that α = 0.5 reaches the minimum in a single step while α = 1.5 overflows to inf.
Show discussion
At 0.5 the distance-shrink factor (1 − 2α) is exactly zero, so one step lands on the bottom — the curve is at the floor from step 1. At 1.0 the factor is −1: the marble bounces symmetrically forever without settling. Above 1 the factor exceeds 1 in magnitude, so each step overshoots farther than the last and the loss explodes. This whole exercise is the section-5 math made visible.
Run the batch loop on the raw feature x (no standardizing) at α = 0.1 — it should overflow immediately. Now shrink α until it stays stable, and count roughly how many steps it needs to reach the closed-form answer. Compare with the ~100 steps the standardized version took.
Show discussion
You'll need α down around 10−4 just to avoid divergence, and then on the order of tens of thousands of steps to converge — because the raw loss surface is a valley roughly 80,000× longer than wide, and the steep direction caps the step size while the flat direction governs progress. This is the canyon of section 6, and the single most convincing argument for standardizing features before gradient descent.
Run the SGD loop and record the loss after each epoch. Plot it beside the batch loss curve. How do the two differ in smoothness, and does SGD's curve ever tick back up even while trending down? Explain why from the "one noisy example per step" nature of SGD.
Show discussion
The batch curve descends smoothly and monotonically; the SGD curve trends down but jitters, occasionally rising for a step or an epoch. Each SGD update follows the gradient of a single example, which points toward reducing that example's error — not necessarily the whole dataset's — so individual steps can nudge the overall loss up even as the average march is downhill. That noise is the same property that helps escape bad spots on non-convex surfaces (section 7).
Gradient descent reaches a loss minimum by repeated small downhill steps instead of one exact leap, which makes it the universal optimizer for the vast majority of models that have no closed form. For Linear Regression the gradient of the mean-squared-error loss is ∇J = (2/n) XT(Xθ − y) — a weighted average of the residuals — and the algorithm is simply θ ← θ − α∇J repeated until the loss stops falling. Built from scratch in NumPy on the Lesson 10 house data, it converges to the exact closed-form answer w ≈ 2.104, b ≈ 47.87 — the optimizer graded against ground truth. Two numbers govern its fate: the learning rate, where too small crawls and too large diverges, with a stable sweet spot you find by reading the loss curve; and the scale of the features, because unscaled inputs bend the loss into a long narrow valley that no single step size can navigate — standardizing rounds the bowl and, here, lets one well-chosen step land exactly on the minimum. Finally, how much data feeds each step defines batch (all rows, smooth and exact), stochastic (one row, cheap and noisy), and mini-batch (a small chunk, the practical default that trains nearly every real model) — all the same update rule, differing only in the rows behind the gradient.