Skip to content

Gradient Descent Explained

What you’ll learn

  • the update rule θθαJ\boldsymbol{\theta} \leftarrow \boldsymbol{\theta} - \alpha\nabla J, derived rather than asserted
  • two full iterations computed by hand, matching NumPy to four decimals
  • the exact threshold at which a learning rate stops converging and starts exploding
  • batch, stochastic and mini-batch descent — the real trade-off between them
  • why feature scaling turns a slow ravine into a fast bowl
  • learning schedules, and when to stop

Intuition

You are standing on a hillside in fog. You cannot see the valley floor, but you can feel which way the ground slopes under your feet. Take a step downhill. Feel again. Step again.

That is the entire algorithm. The gradient is the direction of steepest ascent, so you move against it. The learning rate is how long a stride you take. Everything else — momentum, Adam, schedules — is refinement of those two sentences.

diagram Diagram mermaid

The Normal Equation from Multiple Linear Regression already solves linear regression exactly, so why iterate at all? Because inverting an n×nn \times n matrix costs roughly O(n3)O(n^3). At 100,000 features that is hopeless, and for models with no closed form at all — logistic regression, neural networks, gradient boosting — iteration is the only option. Gradient descent is the workhorse of the entire field; linear regression is just the clearest place to learn it.

The math

The update rule

For a cost J(θ)J(\boldsymbol{\theta}), the gradient collects the partial derivatives:

θJ=[Jθ0Jθ1Jθn]\nabla_{\boldsymbol{\theta}} J = \begin{bmatrix} \dfrac{\partial J}{\partial \theta_0} & \dfrac{\partial J}{\partial \theta_1} & \cdots & \dfrac{\partial J}{\partial \theta_n} \end{bmatrix}^\top

and each iteration steps against it:

  θθαθJ(θ)  \boxed{\;\boldsymbol{\theta} \leftarrow \boldsymbol{\theta} - \alpha\,\nabla_{\boldsymbol{\theta}} J(\boldsymbol{\theta})\;}

α\alpha is the learning rate. It is the only hyperparameter, and it decides everything.

The gradient of MSE

With J(θ)=1mXθy2J(\boldsymbol{\theta}) = \frac{1}{m}\lVert\mathbf{X}\boldsymbol{\theta} - \mathbf{y}\rVert^2, differentiating gives a strikingly simple result:

θJ=2mX(Xθy)\nabla_{\boldsymbol{\theta}} J = \frac{2}{m}\mathbf{X}^\top\left(\mathbf{X}\boldsymbol{\theta} - \mathbf{y}\right)

Read it as English: the residual vector, projected back onto the features, scaled by two over the sample count. Every feature gets pushed in proportion to how much it contributed to the current error.

When does it actually converge?

This is the part most tutorials skip. For a quadratic cost with Hessian H=2mXX\mathbf{H} = \frac{2}{m}\mathbf{X}^\top\mathbf{X}, gradient descent converges if and only if

0<α<2λmax(H)0 < \alpha < \frac{2}{\lambda_{\max}(\mathbf{H})}

where λmax\lambda_{\max} is the largest eigenvalue. Above that threshold each step overshoots by more than it corrects and the parameters diverge geometrically. The condition number κ=λmax/λmin\kappa = \lambda_{\max}/\lambda_{\min} then decides how fast convergence happens: a κ\kappa of 1 is a round bowl and descent goes straight in, while a κ\kappa of 1,000 is a narrow ravine that forces a zig-zag.

Both facts have the same practical consequence, and it is the reason for the whole preprocessing phase: scale your features.

Worked example by hand

The five-point dataset with the feature centred (xc=x3x_c = x - 3), so the Hessian is diagonal and the arithmetic stays exact. Parameters are [b,w][b, w], starting from [0,0][0, 0], with α=0.2\alpha = 0.2.

iixcx_cyy
1−22
2−14
305
414
525

The Hessian, and the stability limit.

H=25XX=25[50010]=[2004]\mathbf{H} = \frac{2}{5}\mathbf{X}^\top\mathbf{X} = \frac{2}{5}\begin{bmatrix} 5 & 0 \\ 0 & 10 \end{bmatrix} = \begin{bmatrix} 2 & 0 \\ 0 & 4 \end{bmatrix}

Eigenvalues 2 and 4, so descent diverges for any α2/4=0.5\alpha \geq 2/4 = 0.5. Our 0.2 is comfortably inside. The optimum is [b,w]=[4,0.6][b, w] = [4, 0.6].

Iteration 0. With θ=[0,0]\boldsymbol{\theta} = [0, 0] every prediction is 0, so the residual is y-\mathbf{y}:

X(Xθy)=[(2+4+5+4+5)(2)(2)+(1)(4)+0+(1)(4)+(2)(5)]=[206]\mathbf{X}^\top(\mathbf{X}\boldsymbol{\theta} - \mathbf{y}) = \begin{bmatrix} -(2{+}4{+}5{+}4{+}5) \\ (-2)(-2) + (-1)(-4) + 0 + (1)(-4) + (2)(-5) \end{bmatrix} = \begin{bmatrix} -20 \\ -6 \end{bmatrix}
J=25[206]=[82.4],θ[00]0.2[82.4]=[1.60.48]\nabla J = \tfrac{2}{5}\begin{bmatrix} -20 \\ -6 \end{bmatrix} = \begin{bmatrix} -8 \\ -2.4 \end{bmatrix}, \qquad \boldsymbol{\theta} \leftarrow \begin{bmatrix} 0 \\ 0 \end{bmatrix} - 0.2\begin{bmatrix} -8 \\ -2.4 \end{bmatrix} = \begin{bmatrix} 1.6 \\ 0.48 \end{bmatrix}

Iteration 1. Predictions are now 1.6+0.48xc1.6 + 0.48x_c, giving residuals [1.36,2.88,3.4,1.92,2.44][-1.36, -2.88, -3.4, -1.92, -2.44]:

J=25[121.2]=[4.80.48],θ[2.560.576]\nabla J = \tfrac{2}{5}\begin{bmatrix} -12 \\ -1.2 \end{bmatrix} = \begin{bmatrix} -4.8 \\ -0.48 \end{bmatrix}, \qquad \boldsymbol{\theta} \leftarrow \begin{bmatrix} 2.56 \\ 0.576 \end{bmatrix}

The cost falling.

stepbbwwMSE
00.0000.000017.2000
11.6000.48006.2688
22.5600.57602.5548
33.1360.59521.2265
4.0000.60000.4800

The slope reaches its final value almost immediately while the intercept takes far longer. That is the condition number in action: the eigenvalue along ww is twice the eigenvalue along bb, so the same α\alpha makes twice the progress in that direction.

The learning rate

figureSame start, three learning rates, 70 steps eachmatplotlib
Contour plot of the cost over intercept and slope with three descent paths from the same start: a short green path that stops well short, a blue path reaching the minimum, and a red path oscillating across the valley before settling.Contour plot of the cost over intercept and slope with three descent paths from the same start: a short green path that stops well short, a blue path reaching the minimum, and a red path oscillating across the valley before settling.
Green (0.03) is still travelling when the budget runs out. Blue (0.20) walks in cleanly. Red (0.46) is near the stability limit of 0.5 and ricochets across the valley on every step.
figureThe learning curve tells you immediately which rate is wrongmatplotlib
Cost against iteration on a log scale for four learning rates. Three curves descend to the minimum at different speeds; the fourth climbs off the top of the chart.Cost against iteration on a log scale for four learning rates. Three curves descend to the minimum at different speeds; the fourth climbs off the top of the chart.
Plot cost against iteration for every run. Flat and high means the rate is too small; a rising line means it is above the stability threshold.

Reading the plots

What you seeWhat it meansWhat to do
Cost falls smoothly to a plateauHealthyNothing
Cost falls, very slowlyα\alpha too smallMultiply by 3
Cost oscillates but trends downα\alpha just under the limitHalve it
Cost rises or hits nannanα\alpha above 2/λmax2/\lambda_{\max}Divide by 10
Cost falls then flattens highUnderfitting, not an α\alpha problemMore features or capacity

A practical recipe: try α{0.001,0.01,0.1,1}\alpha \in \{0.001, 0.01, 0.1, 1\}, plot all four cost curves on one log axis, and take the largest rate that still descends smoothly.

sketch Gradient descent rolls downhill p5.js
Each step moves against the slope toward the minimum of the loss curve; the steps shrink as the slope flattens.

Notice the steps shrinking on their own as the curve flattens. The learning rate never changes — the gradient shrinks near the minimum, so descent automatically slows down as it arrives.

Why scaling matters

figureThe same optimisation problem, before and after scalingmatplotlib
Two contour plots. Left, stretched elliptical contours with a descent path zig-zagging violently down a narrow valley. Right, circular contours with a descent path running straight to the centre.Two contour plots. Left, stretched elliptical contours with a descent path zig-zagging violently down a narrow valley. Right, circular contours with a descent path running straight to the centre.
Steepest descent goes perpendicular to the contour it stands on. In a ravine that direction points across the valley rather than along it, so the path bounces off the walls and creeps toward the minimum.

Unscaled features produce an elongated cost surface, and the negative gradient points across the valley rather than down it. Standardising makes the contours near-circular and lets descent walk straight in — often an order of magnitude fewer iterations, from the same code.

scaling_and_sgd.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import SGDRegressor
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)
 
scaled = make_pipeline(
    StandardScaler(),
    SGDRegressor(max_iter=2000, tol=1e-4, eta0=0.01, random_state=0),
).fit(X_train, y_train)
 
raw = SGDRegressor(
    max_iter=2000, tol=1e-4, eta0=0.01, random_state=0
).fit(X_train, y_train)
 
print(f"scaled   R^2 = {scaled.score(X_test, y_test):.4f}")  # 0.4767
print(f"unscaled R^2 = {raw.score(X_test, y_test):.4f}")     # 0.4554
print(f"epochs used  = {scaled[-1].n_iter_}")                # 27
scaling_and_sgd.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import SGDRegressor
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
 
X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)
 
scaled = make_pipeline(
    StandardScaler(),
    SGDRegressor(max_iter=2000, tol=1e-4, eta0=0.01, random_state=0),
).fit(X_train, y_train)
 
raw = SGDRegressor(
    max_iter=2000, tol=1e-4, eta0=0.01, random_state=0
).fit(X_train, y_train)
 
print(f"scaled   R^2 = {scaled.score(X_test, y_test):.4f}")  # 0.4767
print(f"unscaled R^2 = {raw.score(X_test, y_test):.4f}")     # 0.4554
print(f"epochs used  = {scaled[-1].n_iter_}")                # 27

The scaled pipeline converges in 27 epochs and lands on 0.4767 — indistinguishable from the exact Normal Equation answer of 0.4773. The unscaled version burns all 2,000 epochs, emits a convergence warning, and still ends up worse.

The three variants

How much data does one gradient use?

diagram Diagram mermaid
Batch: J=2mX(Xθy)SGD: J(i)=2x(i) ⁣(θx(i)y(i))\text{Batch: } \nabla J = \frac{2}{m}\mathbf{X}^\top(\mathbf{X}\boldsymbol{\theta} - \mathbf{y}) \qquad \text{SGD: } \nabla J^{(i)} = 2\,\mathbf{x}^{(i)}\!\left(\boldsymbol{\theta}^\top\mathbf{x}^{(i)} - y^{(i)}\right)

The stochastic gradient is an unbiased estimate of the batch gradient — right on average, wrong on any individual step. That noise is not purely a cost: it lets SGD escape shallow local minima that would trap batch descent on non-convex problems, which is exactly why deep learning uses it.

BatchStochasticMini-batch
Rows per stepmm132–256
Cost per stepHighVery lowLow
PathSmoothVery noisySlightly noisy
Settles exactly?YesNo, needs a scheduleNearly
Out-of-coreNoYesYes
Hardware fitPoorPoorExcellent
scikit-learnLinearRegressionLinearRegressionSGDRegressorSGDRegressorSGDRegressor.partial_fitSGDRegressor.partial_fit
sketch Gradient descent paths in parameter space p5.js
Batch GD takes a smooth path and stops at the minimum; Stochastic GD is noisy and keeps wandering; Mini-batch GD lands in between. Click to restart.

Learning schedules

Because SGD never settles, the standard fix is to shrink the learning rate over time:

αt=α01+decayt\alpha_t = \frac{\alpha_0}{1 + \text{decay} \cdot t}

Early on the steps are long enough to cover ground; later they are short enough to sit still. Decay too fast and it freezes before arriving; too slowly and it keeps bouncing. SGDRegressorSGDRegressor exposes this as learning_rate="invscaling"learning_rate="invscaling" with eta0eta0 and power_tpower_t.

From scratch

gradient_descent.py
import numpy as np
 
 
def batch_gradient_descent(X, y, lr=0.2, n_iter=50):
    """Plain batch gradient descent. X must already include the bias column."""
    m = len(y)
    theta = np.zeros(X.shape[1])
    history = []
 
    for _ in range(n_iter):
        residual = X @ theta - y
        history.append(float((residual**2).mean()))
        gradient = (2 / m) * X.T @ residual
        theta = theta - lr * gradient
 
    return theta, history
 
 
x_centred = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) - 3.0
X = np.c_[np.ones(5), x_centred]
y = np.array([2.0, 4.0, 5.0, 4.0, 5.0])
 
theta, history = batch_gradient_descent(X, y, lr=0.2, n_iter=50)
 
print(theta.round(4))            # [4.  0.6]
print([round(h, 4) for h in history[:4]])
# [17.2, 6.2688, 2.5548, 1.2265]
 
# The stability limit, computed rather than guessed
hessian = (2 / len(y)) * X.T @ X
print(f"max stable lr = {2 / np.linalg.eigvalsh(hessian).max():.3f}")   # 0.500
gradient_descent.py
import numpy as np
 
 
def batch_gradient_descent(X, y, lr=0.2, n_iter=50):
    """Plain batch gradient descent. X must already include the bias column."""
    m = len(y)
    theta = np.zeros(X.shape[1])
    history = []
 
    for _ in range(n_iter):
        residual = X @ theta - y
        history.append(float((residual**2).mean()))
        gradient = (2 / m) * X.T @ residual
        theta = theta - lr * gradient
 
    return theta, history
 
 
x_centred = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) - 3.0
X = np.c_[np.ones(5), x_centred]
y = np.array([2.0, 4.0, 5.0, 4.0, 5.0])
 
theta, history = batch_gradient_descent(X, y, lr=0.2, n_iter=50)
 
print(theta.round(4))            # [4.  0.6]
print([round(h, 4) for h in history[:4]])
# [17.2, 6.2688, 2.5548, 1.2265]
 
# The stability limit, computed rather than guessed
hessian = (2 / len(y)) * X.T @ X
print(f"max stable lr = {2 / np.linalg.eigvalsh(hessian).max():.3f}")   # 0.500

The first four cost values reproduce the hand-computed table exactly, and after 50 iterations the parameters have reached the Normal Equation’s answer of [4,0.6][4, 0.6].

algorithmBatch Gradient DescentOptimisation · First-order

APIsklearn.linear_model.SGDRegressor (stochastic and mini-batch variants)

Assumes

  • The cost is differentiable with respect to the parameters
  • The learning rate is below 2 over the largest Hessian eigenvalue
  • Features are on comparable scales, or convergence will be slow

Cost

train
O(m·n) per iteration
predict
O(n)
memory
O(n)

m = samples, n = features; total cost is per-iteration cost times the iteration count

Hyperparameters that matter

  • learning_rate (alpha, eta0)default 0.01The one that matters. Too small crawls, too large diverges. Tune on a log grid.
  • max_iterdefault 1000Iteration budget. Pair it with tol so training stops when progress stalls.
  • toldefault 1e-3Stop when the loss improves by less than this for n_iter_no_change epochs.
  • batch sizedefault 1 for SGDRegressorLarger batches mean smoother gradients and better hardware use; 32 to 256 is the usual range.

Reach for it when

  • You have too many features for a closed-form solution
  • The data does not fit in memory and must stream
  • The model has no closed form at all — logistic regression, neural networks
  • You want to warm-start or update a model online

Look elsewhere when

  • The problem is small and a closed form exists — just use LinearRegression
  • You cannot scale the features
  • You need bit-for-bit reproducibility without pinning every random seed

Pitfalls

quizCheck yourself
  1. Why does the update rule subtract the gradient instead of adding it?

    Show answer

    B — Because the gradient points in the direction of steepest increase, and we want to decrease the cost — The gradient is the direction of steepest ascent. Minimising means walking the other way.

  2. Your cost becomes nan after four iterations. What is the most likely cause?

    Show answer

    B — The learning rate exceeds the stability threshold, so each step overshoots more than the last — Above 2 divided by the largest Hessian eigenvalue, the iteration diverges geometrically and overflows within a few steps. Reduce the learning rate by a factor of ten.

  3. What is the practical effect of feature scaling on gradient descent?

    Show answer

    B — It makes the cost contours rounder, so descent needs far fewer iterations to reach the same minimum — Scaling does not change where the minimum is in terms of predictions — it changes the shape of the path to it. Rounder contours mean the negative gradient points at the minimum instead of across the valley.

  4. Why does stochastic gradient descent still work despite using one row per step?

    Show answer

    B — Because the single-row gradient is an unbiased estimate of the full gradient, so the noise averages out over many steps — Each step is wrong individually but right on average. Over many cheap steps the errors cancel, and the noise even helps escape shallow minima on non-convex problems.

🧪 Try It Yourself

Exercise 1 – One batch gradient step

Exercise 2 – Watch the cost fall

Exercise 3 – Compute the stability limit

Exercise 4 – Push it past the limit

Exercise 5 – Scaling changes everything for SGD

Recap

  • The update rule is θθαJ\boldsymbol{\theta} \leftarrow \boldsymbol{\theta} - \alpha\nabla J, and for MSE the gradient is 2mX(Xθy)\frac{2}{m}\mathbf{X}^\top(\mathbf{X}\boldsymbol{\theta} - \mathbf{y}).
  • Convergence requires α<2/λmax(H)\alpha < 2/\lambda_{\max}(\mathbf{H}) — on the worked example, 0.5.
  • Two hand iterations at α=0.2\alpha = 0.2 take the cost from 17.20 to 6.27 to 2.55, matching NumPy exactly.
  • Scaling controls the condition number, which controls how many iterations you pay for.
  • Batch is smooth and expensive, stochastic is noisy and cheap, mini-batch is the default.
  • Plot cost against iteration on every run; the shape of that curve diagnoses the learning rate immediately.

Exercise 6 – Find the learning rate that breaks it

Next

Continue to Regularization - Ridge and Lasso Regression — add a penalty term to the same cost and watch overfitting recede.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did