Skip to main content

Stochastic gradient descent

Examples

Computing the gradient of the full training loss means going through all \(n\) points for every single step. With a million points that is too slow.

Instead we compute the gradient on a small random subset, called a batch, and take a step. The gradient is then noisy, but we get many more steps for the same work.

Terminology

A batch is a subset of the training data used for one step. The batch size is how many points are in it. An epoch is one pass through the whole training set, so \(n / \text{batch size}\) steps.

With a batch size of 1 this is called stochastic gradient descent. With a batch size equal to \(n\) it is ordinary gradient descent. In practice we use something in between, often 32 or 128.

An example

import numpy as np
import torch
import matplotlib.pyplot as plt

torch.manual_seed(6)

n = 400
X = torch.randn(n, dtype=torch.float64)
y = 0.1 - 0.4 * X + 0.3 * torch.randn(n, dtype=torch.float64)


def run(batch_size, eta, epochs=30):
    beta = torch.tensor([1.5, 1.5], requires_grad=True)
    curve = []
    steps_per_epoch = max(1, n // batch_size)
    for _ in range(epochs):
        perm = torch.randperm(n)
        for s in range(steps_per_epoch):
            idx = perm[s * batch_size:(s + 1) * batch_size]
            loss = torch.mean((y[idx] - beta[0] - beta[1] * X[idx]) ** 2)
            loss.backward()
            with torch.no_grad():
                beta -= eta * beta.grad
            beta.grad.zero_()
        with torch.no_grad():
            curve.append(float(torch.mean((y - beta[0] - beta[1] * X) ** 2)))
    return np.array(curve)
The learning curve for different batch sizes and learning rates. Small batches take many more steps per epoch, so they get there faster, at the cost of a noisy curve.

The full-batch curve is smooth but it moves slowly, because it takes one step per epoch. The small batches take many steps per epoch and get there much faster, at the cost of a noisy curve.

The path itself, on the loss surface. With a batch size of 1 the steps wander, because each gradient comes from a single point. With the full batch the path is smooth and slow.

Using an optimizer

torch has optimizers that take care of the update rule. Adam adapts the learning rate per parameter and usually needs less tuning than plain gradient descent.

def advanced_gradient_descent(loss, x, optimizer, T):
    curve = []
    for t in range(T):
        optimizer.zero_grad()
        l = loss(x)
        l.backward()
        optimizer.step()
        curve.append(float(l))
    return x, np.array(curve)


beta = torch.tensor([1.5, 1.5], requires_grad=True)
opt = torch.optim.Adam([beta], lr=0.05)
beta, curve = advanced_gradient_descent(
    lambda b: torch.mean((y - b[0] - b[1] * X) ** 2), beta, opt, 400)

print("Adam:", beta.detach().numpy().round(4))
Adam: [ 0.1071 -0.3822]

Learning curves

Always plot the learning curve. It answers two questions at once.

If the curve is still going down at the end, we stopped too early. Train longer.

If the curve fluctuates wildly at the end, the learning rate is too large. Lower it.

Use a log scale on the vertical axis. Otherwise the interesting part at the end is invisible.

A degree 12 polynomial fitted by gradient descent. On the raw input it is still far from the least squares solution after thirty thousand steps. Standardize the columns and it converges quickly. Conditioning matters more than the step count.

Early stopping

If we also track the loss on a validation set, we usually see it fall, reach a minimum, and rise again. The model has started to fit the noise in the training set.

Stopping at the minimum of the validation curve is called early stopping. It is a form of regularization. The number of steps plays the same role that \(\lambda\) played for ridge regression, and for a linear model the two are approximately equivalent.

from sklearn.linear_model import Ridge
from sklearn.preprocessing import PolynomialFeatures

rng = np.random.default_rng(6)
xs = rng.uniform(0, 1, 30)
ys = np.sin(6 * xs) + rng.normal(0, 0.3, 30)
xv = rng.uniform(0, 1, 300)
yv = np.sin(6 * xv) + rng.normal(0, 0.3, 300)

P = PolynomialFeatures(14)
A = torch.tensor(P.fit_transform(xs.reshape(-1, 1)))
Av = torch.tensor(P.transform(xv.reshape(-1, 1)))
t = torch.tensor(ys)
tv = torch.tensor(yv)

w = torch.zeros(A.shape[1], dtype=torch.float64, requires_grad=True)
opt = torch.optim.SGD([w], lr=1e-3)
tr, va = [], []
for step in range(4000):
    opt.zero_grad()
    l = torch.mean((A @ w - t) ** 2)
    l.backward()
    opt.step()
    tr.append(float(l))
    with torch.no_grad():
        va.append(float(torch.mean((Av @ w - tv) ** 2)))

best = int(np.argmin(va))
fig, ax = plt.subplots()
ax.plot(tr, label="training")
ax.plot(va, label="validation")
ax.axvline(best, color="#a8452f", ls="--", lw=1.2, label=f"stop at {best}")
ax.set(xlabel="step", ylabel="loss", yscale="log")
ax.legend()
plt.show()
Figure 32.1: Training and validation loss for an overparametrized fit. The vertical line marks the minimum of the validation curve.

Feature engineering, automated

The XOR problem is the classic example of a data set that no linear model can solve. The two classes are not linearly separable.

We can solve it by hand, by adding the product \(x_1 x_2\) as a feature. That works, but it required us to know the answer.

The alternative is to let the machine find the features. That is what a neural network does, and it is the subject of week 7.

A small network on the XOR problem. The lines from the origin are the weight vectors of the four hidden units. Watch them turn until they carve the plane into the four quadrants.