Xray AI Lab
Mathematics 10 min readBy Nbark Kazdabi | Xray 0xFF

Gradient Descent Explained with Python: Math, Visualization, and Code

Learn how gradient descent minimizes a loss function. Derive the gradients, code it in NumPy, and test learning rates with a plotted experiment.

Left: scattered colored dots around a query point in vector space. Right: a network of linked nodes. A diagonal band separates the two.

Gradient Descent Explained with Python: Math, Visualization, and Code

Gradient descent is how most machine learning models learn: it measures how wrong the model is with a loss function, computes which direction makes that loss grow fastest, and then steps the other way. Repeat that a few dozen or a few million times and the parameters settle near a minimum.

This article builds the method from scratch. You will derive the gradients for a linear model, implement gradient descent in about 15 lines of NumPy, and run a controlled experiment that shows exactly what the learning rate does, including the precise value above which training blows up. Every number in the results section comes from code that was actually run.

By the end you will be able to:

  • Explain gradient descent in terms of loss, gradient, and step size.
  • Derive the gradients of mean squared error (MSE) by hand and check them in code.
  • Predict whether a learning rate will converge or diverge before running the training loop.
  • Read a loss curve and a contour plot to diagnose what went wrong.

What gradient descent does, in plain terms

Imagine standing on a hilly surface in thick fog. You cannot see the lowest valley, but you can feel the slope under your feet. A sensible strategy is to take a step downhill, feel the slope again, and repeat.

Gradient descent is that strategy applied to a function L(θ)L(\theta), where θ\theta is the vector of model parameters:

θt+1=θt−η ∇L(θt)\theta_{t+1} = \theta_t - \eta \, \nabla L(\theta_t)
SymbolMeaning
θt\theta_tParameters at iteration tt
L(θ)L(\theta)Loss: a single number measuring how badly the model fits the data
∇L(θ)\nabla L(\theta)Gradient: the vector of partial derivatives, pointing toward steepest increase
η\etaLearning rate (step size), a positive hyperparameter you choose

The minus sign is the whole trick. The gradient points uphill, so we subtract it to go downhill. For a thorough survey of how this basic update is extended in practice (momentum, Adam, and others), see Sebastian Ruder’s An overview of gradient descent optimization algorithms.

The problem: fitting a line by minimizing MSE

We will use the simplest model that still has two parameters and a visible loss surface: a line y^=wx+b\hat{y} = wx + b.

Step 1: define the loss

Given nn data points (xi,yi)(x_i, y_i), the mean squared error is

L(w,b)=1n∑i=1n(wxi+b−yi)2L(w, b) = \frac{1}{n} \sum_{i=1}^{n} \left( w x_i + b - y_i \right)^2

Squaring makes every error positive and penalizes large errors more than small ones. It also makes the loss a smooth bowl in (w,b)(w, b) space, which is what makes this problem a friendly place to learn.

Step 2: derive the gradients

Let the residual be ei=wxi+b−yie_i = w x_i + b - y_i. Applying the chain rule:

∂L∂w=2n∑i=1nei xi∂L∂b=2n∑i=1nei\frac{\partial L}{\partial w} = \frac{2}{n} \sum_{i=1}^{n} e_i \, x_i \qquad \frac{\partial L}{\partial b} = \frac{2}{n} \sum_{i=1}^{n} e_i

The update rules are therefore

w←w−η ∂L∂w,b←b−η ∂L∂bw \leftarrow w - \eta \, \frac{\partial L}{\partial w}, \qquad b \leftarrow b - \eta \, \frac{\partial L}{\partial b}

Notice what the gradient is saying. If the model predicts too high on points with large positive xx, the residuals eie_i are positive there, ∂L/∂w\partial L / \partial w is positive, and the update lowers ww. The math does what your intuition would.

Step 3: the curvature behind everything

For MSE the loss is a quadratic function of (w,b)(w, b), so its second derivatives (the Hessian) are constant:

H=2nX⊤X,X=[x11⋮⋮xn1]H = \frac{2}{n} X^\top X, \qquad X = \begin{bmatrix} x_1 & 1 \\ \vdots & \vdots \\ x_n & 1 \end{bmatrix}

This matrix controls how fast and whether gradient descent converges. We will use it in a moment to predict the experiment’s outcome.

Implementing gradient descent in Python

The code below generates data from the line y=3x+2y = 3x + 2 plus Gaussian noise (standard deviation 1), implements the loss and gradients from the derivation, and runs gradient descent from a deliberately poor starting point, (w,b)=(−4,−4)(w, b) = (-4, -4).

import numpy as np

# Synthetic data: y = 3x + 2 + noise
rng = np.random.default_rng(42)
n = 100
x = rng.uniform(-3, 3, size=n)
y = 3.0 * x + 2.0 + rng.normal(0, 1.0, size=n)

def loss(w, b):
    return np.mean((w * x + b - y) ** 2)

def gradients(w, b):
    err = w * x + b - y                  # residuals e_i
    return 2 * np.mean(err * x), 2 * np.mean(err)

def gradient_descent(lr, steps=50, w0=-4.0, b0=-4.0):
    w, b = w0, b0
    path = [(w, b)]
    losses = [loss(w, b)]
    for _ in range(steps):
        gw, gb = gradients(w, b)
        w, b = w - lr * gw, b - lr * gb  # simultaneous update
        path.append((w, b))
        losses.append(loss(w, b))
    return np.array(path), np.array(losses)

Two details matter. First, both parameters are updated from gradients computed at the same point, not one after the other. Second, we record the full path and loss history, because a final number alone hides most of what is interesting.

Verify against a closed-form answer

Linear regression has an exact solution, which gives us a ground truth to check against. NumPy’s least-squares solver finds the loss minimum directly:

X = np.column_stack([x, np.ones(n)])
w_star, b_star = np.linalg.lstsq(X, y, rcond=None)[0]
L_star = loss(w_star, b_star)
print(f"Closed form: w={w_star:.4f}, b={b_star:.4f}, loss={L_star:.4f}")

Output from the executed script:

Closed form: w=3.0128, b=1.9870, loss=0.9589

The fitted values are close to the true w=3w = 3 and b=2b = 2 but not identical, because the noise in the sample shifts the optimum slightly. The minimum loss, 0.9589, is close to the noise variance of 1, which is what we expect from a well-specified model. Gradient descent should land on this same point.

Experiment: how the learning rate decides everything

We now run gradient descent for 50 iterations with five learning rates and compare against the closed-form optimum.

lrs = [0.005, 0.05, 0.1, 0.2, 0.4]
for lr in lrs:
    path, losses = gradient_descent(lr)
    print(f"lr={lr:<5} final w={path[-1,0]:.4f} b={path[-1,1]:.4f} loss={losses[-1]:.4f}")

Results

Learning rateFinal wwFinal bbFinal loss (50 steps)Outcome
0.0051.1039−1.753323.5569Stable but far too slow
0.053.01121.95380.9600Converging, almost there
0.13.01281.98690.9589Converged
0.23.01281.98700.9589Converged, fastest of the five that converge
0.4−5296.32253.5975,387,418Diverged

The starting loss is 161.65 and the optimal loss is 0.9589. The table shows all three regimes: too small (0.005 barely moves), well tuned (0.1 and 0.2 reach the optimum), and too large (0.4 explodes).

To make “how fast” concrete, we also counted iterations until the loss was within 10−310^{-3} of the optimum:

Learning rateSteps until loss − optimum < 0.001
0.0551
0.124
0.211

Left: contour plot of the MSE surface with gradient descent paths for learning rates 0.05, 0.1 and 0.2 converging to the closed-form optimum marked by a star. Right: log-scale loss curves for five learning rates, showing slow progress at 0.005, fast convergence at 0.1 and 0.2, and divergence at 0.4.

Left: paths on the loss surface. Right: loss versus iteration on a log scale (values clipped at 1000 so the diverging run fits on the chart).

Reading the plots

On the contour plot, each ring is a set of (w,b)(w, b) pairs with equal loss. The rings are elongated ellipses, not circles, and that shape explains the paths. The loss changes faster along one direction than the other, so the gradient does not point straight at the minimum. Early steps cut across the valley and later steps creep along it. The lr = 0.2 path even overshoots sideways on its first step before correcting.

On the loss curve, converging runs appear as nearly straight lines on a log axis, which means the error shrinks by a roughly constant factor each step. The lr = 0.005 curve is also a straight line, just with a very shallow slope.

Why 0.4 diverged: predicting convergence before training

The divergence at 0.4 is not bad luck. For a quadratic loss, the distance from the optimum θ\*\theta^\* evolves exactly as

θt+1−θ∗=(I−ηH) (θt−θ∗)\theta_{t+1} - \theta^* = (I - \eta H)\,(\theta_t - \theta^*)

Break this into the eigenvectors of HH. Along an eigenvector with eigenvalue λ\lambda, the error is multiplied by (1−ηλ)(1 - \eta\lambda) every step. That factor must have magnitude below 1 for the error to shrink, which gives

0<η<2λmax⁡0 < \eta < \frac{2}{\lambda_{\max}}

For our data, the Hessian eigenvalues (computed in the script with np.linalg.eigvalsh) are approximately 1.992 and 5.357. So the critical learning rate is

25.357≈0.373\frac{2}{5.357} \approx 0.373

That is why 0.2 converges and 0.4 diverges. Checking the per-step contraction factors ∣1−ηλ∣|1 - \eta\lambda| makes it concrete:

Learning rateFactor along λ=1.992\lambda = 1.992Factor along λ=5.357\lambda = 5.357Slowest direction
0.0050.9900.9730.990 (very slow)
0.050.9000.7320.900
0.10.8010.4640.801
0.20.6020.0710.602
0.40.2031.1431.143 (diverges)

At 0.4 the error along the steep direction grows by about 14% per step, so the loss, which depends on the squared error, grows by about 1.1432≈1.311.143^2 \approx 1.31 per step. In the executed run, the loss in later iterations indeed rises by roughly 31% each step (for example, from 1337 to 1745 between steps 9 and 10).

The best fixed learning rate

Both directions matter. A small step is safe in the steep direction but slow in the shallow one. For a quadratic loss, the step size that minimizes the worst-case contraction factor is

ηopt=2λmin⁡+λmax⁡≈0.272\eta_{\text{opt}} = \frac{2}{\lambda_{\min} + \lambda_{\max}} \approx 0.272

The ratio κ=λmax⁡/λmin⁡≈2.69\kappa = \lambda_{\max} / \lambda_{\min} \approx 2.69 is the condition number. The larger it is, the more elongated the contours and the slower gradient descent becomes, because the step size is capped by the steepest direction while progress in the flattest direction is throttled. This is one reason feature scaling matters in practice: it pushes the condition number toward 1.

Scope note. The exact 2/λmax⁡2/\lambda_{\max} threshold applies to quadratic losses like MSE on a linear model. For neural networks the loss is non-convex and the curvature changes from point to point, so the same idea gives a local guide, not an exact rule.

Choosing a stopping rule

The experiment used a fixed 50 iterations. Real training needs a stopping criterion, such as:

  • Gradient norm below a tolerance: ∥∇L∥<ϵ\lVert \nabla L \rVert < \epsilon means the surface is nearly flat.
  • Loss improvement below a tolerance: stop when the loss stops decreasing meaningfully.
  • Maximum iterations: always keep this as a safety limit.
  • Validation loss (for real models): stop when performance on held-out data stops improving, which guards against overfitting.

In our toy problem, every iteration used all 100 data points. That is batch gradient descent. For large datasets, stochastic and mini-batch variants compute the gradient from a subset of data per step, trading exactness for speed. Ruder’s overview, linked above, covers these variants in detail.

Common mistakes and how to diagnose them

SymptomLikely causeWhat to try
Loss grows or becomes inf/nanLearning rate too largeReduce η\eta by 2–10×; check input scaling
Loss decreases but extremely slowlyLearning rate too small, or ill-conditioned featuresIncrease η\eta cautiously; standardize features
Loss zig-zags and plateaus above the best valueStep too large near the minimum, or noisy gradientsLower η\eta or use a decaying schedule
Parameters converge to different values than expectedGradient formula bugCompare against a numerical gradient (below)

Gradient checking

When you implement gradients by hand, verify them with finite differences:

def numerical_gradient(w, b, eps=1e-6):
    gw = (loss(w + eps, b) - loss(w - eps, b)) / (2 * eps)
    gb = (loss(w, b + eps) - loss(w, b - eps)) / (2 * eps)
    return gw, gb

print(gradients(-4.0, -4.0))
print(numerical_gradient(-4.0, -4.0))

In the executed run, the analytic gradient was approximately (−36.5590, −10.8563) and the numerical one matched to about eight decimal places. If your two outputs disagree, the bug is in your derivation or code, not in gradient descent. This is the cheapest way to catch a wrong minus sign.

Limitations of what we showed

  • Convex, two-parameter problem. MSE on a line has one global minimum and no local minima or saddle points. Deep networks do not behave this cleanly.
  • One dataset, one seed. Exact numbers such as “51 steps” depend on the data and starting point. The qualitative behavior (small is slow, large diverges, a threshold in between) generalizes; the specific values do not.
  • Training loss only. We measured fit on the training data. We did not evaluate generalization to new data.
  • Fixed learning rate. Practical optimizers adapt or schedule the step size.

Complete script

The following script reproduces every number and the figure in this article. It was run with Python 3, NumPy 2.4.4, and Matplotlib 3.10.8.

import numpy as np
import matplotlib.pyplot as plt

# 1. Synthetic data: y = 3x + 2 + noise
rng = np.random.default_rng(42)
n = 100
x = rng.uniform(-3, 3, size=n)
y = 3.0 * x + 2.0 + rng.normal(0, 1.0, size=n)

def loss(w, b):
    return np.mean((w * x + b - y) ** 2)

def gradients(w, b):
    err = w * x + b - y
    return 2 * np.mean(err * x), 2 * np.mean(err)

def gradient_descent(lr, steps=50, w0=-4.0, b0=-4.0):
    w, b = w0, b0
    path = [(w, b)]
    losses = [loss(w, b)]
    for _ in range(steps):
        gw, gb = gradients(w, b)
        w, b = w - lr * gw, b - lr * gb
        path.append((w, b))
        losses.append(loss(w, b))
    return np.array(path), np.array(losses)

# 2. Closed-form reference
X = np.column_stack([x, np.ones(n)])
w_star, b_star = np.linalg.lstsq(X, y, rcond=None)[0]
L_star = loss(w_star, b_star)
print(f"Closed form: w={w_star:.4f}, b={b_star:.4f}, loss={L_star:.4f}")

# Hessian and stability threshold: lr < 2 / lambda_max
H = 2 * (X.T @ X) / n
eig = np.linalg.eigvalsh(H)
print("Hessian eigenvalues:", np.round(eig, 4), "-> critical lr =", round(2 / eig.max(), 4))

# 3. Learning-rate experiment
lrs = [0.005, 0.05, 0.1, 0.2, 0.4]
results = {}
for lr in lrs:
    path, losses = gradient_descent(lr)
    results[lr] = (path, losses)
    print(f"lr={lr:<5} final w={path[-1,0]:.4f} b={path[-1,1]:.4f} loss={losses[-1]:.4f}")

for lr in [0.05, 0.1, 0.2]:
    _, losses = gradient_descent(lr, steps=500)
    hit = np.where(losses - L_star < 1e-3)[0]
    print(f"lr={lr}: steps to reach loss - L* < 1e-3: {hit[0] if len(hit) else 'not reached'}")

# 4. Plots
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

ws = np.linspace(-5, 6, 200); bs = np.linspace(-5, 6, 200)
W, B = np.meshgrid(ws, bs)
Z = np.array([[loss(w, b) for w in ws] for b in bs])
axes[0].contour(W, B, Z, levels=np.logspace(-0.5, 2.3, 18), cmap="viridis")
for lr, c in zip([0.05, 0.1, 0.2], ["tab:blue", "tab:orange", "tab:red"]):
    p = results[lr][0]
    axes[0].plot(p[:, 0], p[:, 1], "o-", ms=3, color=c, label=f"lr={lr}")
axes[0].plot(w_star, b_star, "k*", ms=14, label="closed-form optimum")
axes[0].set_xlabel("w (slope)"); axes[0].set_ylabel("b (intercept)")
axes[0].set_title("Gradient descent paths on the MSE surface")
axes[0].legend()

for lr in lrs:
    axes[1].plot(np.clip(results[lr][1], None, 1e3), label=f"lr={lr}")
axes[1].axhline(L_star, color="k", ls="--", lw=1, label="optimal loss")
axes[1].set_yscale("log")
axes[1].set_xlabel("Iteration"); axes[1].set_ylabel("MSE (log scale, clipped at 1000)")
axes[1].set_title("Effect of the learning rate")
axes[1].legend()
plt.tight_layout()
plt.savefig("gradient-descent-learning-rate-experiment.png", dpi=150)

Install the dependencies with pip install numpy matplotlib if you do not already have them.

Exercises

  1. Predict, then verify. Using ηopt=2/(λmin⁡+λmax⁡)\eta_{\text{opt}} = 2/(\lambda_{\min}+\lambda_{\max}), run gradient descent at that value. Does it reach the 10−310^{-3} threshold in fewer steps than 0.2? (Compute your prediction from the contraction factor first.)
  2. Break the conditioning. Replace x with 100 * x and rerun. What happens to the critical learning rate? Then standardize x and compare.
  3. Add a stopping rule. Modify gradient_descent to stop when the gradient norm falls below 10−410^{-4} and report the iteration count for each stable learning rate.

Conclusion

Gradient descent is a short loop: compute the loss gradient, step against it, repeat. What makes it work, or fail, is the relationship between the step size and the curvature of the loss. In our experiment, the Hessian predicted a critical learning rate near 0.373, and the runs agreed: 0.2 converged in 11 steps while 0.4 diverged. The same logic explains why unscaled features slow training, why loss curves look the way they do, and why optimizers such as momentum and Adam were invented.

Where to go next: try the exercises above, then move from this convex, two-parameter case to mini-batch training on a small neural network, where the loss surface is no longer a clean bowl. For a broader technical map of the optimizer family, read Ruder’s overview, and for the underlying calculus and numerical-computation background, the numerical computation chapter of Deep Learning by Goodfellow, Bengio, and Courville is a standard reference.

Frequently asked questions

What is gradient descent in simple terms?

It is an iterative method for minimizing a function. At each step you compute the gradient (the direction of steepest increase) and move a small distance in the opposite direction, repeating until the function stops decreasing.

What is the learning rate in gradient descent?

The learning rate η\eta is the multiplier that sets the step size. Too small and training is slow; too large and the updates overshoot and the loss can grow. For a quadratic loss, convergence requires η<2/λmax⁡\eta < 2/\lambda_{\max}, where λmax⁡\lambda_{\max} is the largest eigenvalue of the Hessian.

How do I know when gradient descent has converged?

Common signs are a gradient norm close to zero, a loss that has stopped improving beyond a tolerance, or (when training real models) validation loss that has stopped improving. Always set a maximum number of iterations as well.

Does gradient descent always find the global minimum?

Only for convex problems such as MSE on a linear model, given a suitable learning rate. For non-convex problems such as neural networks, it can converge to local minima or stall near saddle points, and results can depend on initialization.

What is the difference between batch, stochastic, and mini-batch gradient descent?

Batch gradient descent computes the gradient from the full dataset each step. Stochastic gradient descent uses a single example, and mini-batch uses a small subset. Smaller batches are cheaper per step but give noisier gradient estimates.

Why does feature scaling help gradient descent?

Unscaled features make the loss surface elongated (a high condition number), so the largest safe step is limited by the steepest direction while progress in the flattest direction is slow. Standardizing features makes the contours rounder and speeds convergence.

Share

Portrait of Nbark Kazdabi

Written by Nbark Kazdabi | Xray 0xFF

Master’s Student in Artificial Intelligence and Data Science

I am a Master’s student in Artificial Intelligence and Data Science with a background in Mathematics and Computer Science. Through this blog, I share practical tutorials, technical explanations, and lessons from my learning journey.

About the authorGitHubLinkedIn