Finite-Sum Objectives#
Nearly all supervised learning objectives have the form:
where is the loss on the -th training example. This is the ERMEmpirical Risk Minimization objective from Week 1.
The full gradient is , which requires scanning all examples — one full pass through the dataset — for a single parameter update. When is large (millions or billions), this is impossibly slow.
SGD replaces the full sum with an estimate computed from a small, randomly chosen mini-batch.
Mini-Batch SGD#
At iteration , sample a mini-batch of size uniformly at random (without replacement). The mini-batch gradient estimate is:
The SGD update is then .
Two key properties of :
-
Unbiased: . On average, the mini-batch gradient points in the right direction. The expectation is over the random choice of .
-
Noisy: is proportional to . A smaller batch has higher variance (noisier gradient estimate) but is cheaper to compute.
Computational cost per step:
- Full-batch GD: to compute , then to update
- Mini-batch SGD (size ): to compute , then to update
SGD makes roughly times as many updates in the time it takes GD to do one update.
Noise and Variance#
The variance of the mini-batch gradient estimate is:
The term measures how different individual example gradients are from the average. When examples are "similar" (homogeneous data), is small and SGD works well even with . When examples are very different (heterogeneous data), you need larger batches to reduce variance.
The batch-size tradeoff:
- Small : cheap per iteration, many noisy updates per epoch → better exploration
- Large : expensive per iteration, fewer but more accurate updates → reliable descent
- : recovers full-batch GD (zero variance, maximum cost per step)
In practice, is typically chosen as a power of 2 (32, 64, 128, 256) to fit well with GPU memory and vectorised computation.
Step Sizes Under Noise#
With a fixed step size , SGD does not converge to the exact optimum — it oscillates in a ball around whose radius is proportional to . To converge exactly, we must decay the step size:
The schedule is common in theory; in practice, step-decay (halve every epochs) and cosine annealing (Week 9) are more popular.
A useful heuristic: for SGD with batch size , a rule of thumb is . Doubling the batch size approximately allows doubling the learning rate (because the gradient estimate is twice as accurate).
Epoch vs Step#
- Step (or iteration): one mini-batch processed, one parameter update
- Epoch: one full pass through the entire dataset ( steps)
At the end of each epoch, the data is typically shuffled (random permutation) to avoid periodicity effects. Shuffling ensures each mini-batch is an independent random sample, which is important for the unbiasedness property.
Tracking loss per epoch is more meaningful than loss per step, because epoch-level loss estimates the true objective while step-level loss is computed on a single mini-batch and is noisier.
If the mini-batch size B is doubled, the variance of the gradient estimate is approximately:
The mini-batch gradient g_k is an unbiased estimate of the full gradient ∇f(w) because:
Browser lab#
Compare GD, SGD(B=1), and SGD(B=16) on a finite-sum quadratic objective. Plot loss vs epoch and observe the noise–convergence tradeoff.
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
d, n = 3, 200
w_true = np.array([1.5, -0.8, 0.6])
X = rng.normal(size=(n, d))
y = X @ w_true + rng.normal(scale=0.3, size=n)
def loss(w, X, y):
return 0.5 * np.mean((X @ w - y)**2)
def run_method(X, y, w0, n_epochs, method, B=None, eta=None):
w = w0.copy()
n = len(X)
epoch_losses = []
for ep in range(n_epochs):
perm = rng.permutation(n)
for start in range(0, n, B or n):
idx = perm[start:start + (B or n)]
g = X[idx].T @ (X[idx] @ w - y[idx]) / len(idx)
w -= eta * g
epoch_losses.append(loss(w, X, y))
return epoch_losses
w0 = np.zeros(d)
n_epochs = 40
L = np.linalg.eigvalsh(X.T @ X / n).max()
eta = 1.0 / L
losses_gd = run_method(X, y, w0, n_epochs, "GD", B=n, eta=eta)
losses_sgd1 = run_method(X, y, w0, n_epochs, "SGD1", B=1, eta=eta*0.05)
losses_sgd16 = run_method(X, y, w0, n_epochs, "SGD16", B=16, eta=eta*0.5)
plt.figure(figsize=(8, 5))
plt.plot(losses_gd, label="GD (full batch)", linewidth=2)
plt.plot(losses_sgd1, alpha=0.8, label="SGD B=1 (noisy)", linewidth=1)
plt.plot(losses_sgd16, alpha=0.8, label="SGD B=16", linewidth=1.5)
plt.yscale("log")
plt.xlabel("Epoch"); plt.ylabel("Loss")
plt.title("GD vs SGD: Loss per Epoch")
plt.legend(); plt.grid(True, alpha=0.3)
plt.show()