Optimisers
Why steepest descent is slow, and what momentum and Adam do about it
Plain gradient descent takes a step straight downhill. That is the obvious thing to do and it is slow, for a reason that is easier to see than to describe.
The problem with steepest descent
# A bowl 200 times steeper in one direction than the other. The steep
# direction caps the learning rate at about 2/200, and the shallow one
# then needs an enormous number of those small steps.
def loss(p):
return 0.5 * (200 * p[0] ** 2 + p[1] ** 2)
p = torch.tensor([1.0, 1.0], requires_grad=True)
lr = 0.009
print('%6s %12s %12s %12s' % ('step', 'steep x', 'shallow y', 'loss'))
for step in range(151):
if p.grad is not None:
p.grad.zero_()
value = loss(p)
value.backward()
with torch.no_grad():
p -= lr * p.grad
if step in (0, 5, 20, 60, 150):
print('%6d %12.4f %12.4f %12.5f'
% (step, p[0].item(), p[1].item(), value.item()))
0 -0.8000 0.9910 100.50000
5 0.2621 0.9472 11.19419
20 -0.0092 0.8271 0.36156
60 -0.0000 0.5761 0.16897
150 -0.0000 0.2553 0.03319
The steep direction is finished within a handful of steps. The shallow one is still most of the way from where it started after a hundred and fifty, because the rate that keeps the steep direction stable is far too small for it. Raising the rate makes the steep direction oscillate and then diverge, so you cannot. That is the tension every optimiser after this one is trying to resolve, and it gets worse as models grow, because the gap between the steepest and shallowest directions grows with them.
Momentum
def loss(p):
return 0.5 * (200 * p[0] ** 2 + p[1] ** 2)
def run(beta, lr, steps):
p = torch.tensor([1.0, 1.0], requires_grad=True)
velocity = torch.zeros(2)
for _ in range(steps):
if p.grad is not None:
p.grad.zero_()
loss(p).backward()
with torch.no_grad():
velocity = beta * velocity + p.grad
p -= lr * velocity
return loss(p).item()
print('%-16s %12s %12s %12s' % ('', '20 steps', '60 steps', '150 steps'))
for label, beta in [('plain SGD', 0.0), ('momentum 0.9', 0.9)]:
print('%-16s %12.5f %12.5f %12.5f'
% (label, run(beta, 0.009, 20), run(beta, 0.009, 60),
run(beta, 0.009, 150)))
plain SGD 0.36156 0.16897 0.03319
momentum 0.9 19.74603 0.18695 0.00001
Momentum looks worse before it looks better
At twenty steps momentum is around fifty times worse. A running average with beta = 0.9 settles at roughly ten times the size of a single gradient, so the effective step is ten times larger and it overshoots badly at the start. By a hundred and fifty steps it is thousands of times better than plain descent.
Two things follow from that. Momentum earns its place on long runs and can genuinely hurt on short ones, and if you add it without lowering the learning rate you should expect the early part of training to look wrong. Judging an optimiser on its first few hundred steps is how people talk themselves out of momentum.
Adam
def loss(p):
return 0.5 * (20 * p[0] ** 2 + p[1] ** 2)
p = torch.tensor([1.0, 1.0], requires_grad=True)
m = torch.zeros(2)
v = torch.zeros(2)
b1, b2, eps, lr = 0.9, 0.999, 1e-8, 0.1
print('%6s %10s %10s %12s' % ('step', 'x', 'y', 'loss'))
for t in range(1, 10):
if p.grad is not None:
p.grad.zero_()
value = loss(p)
value.backward()
with torch.no_grad():
m = b1 * m + (1 - b1) * p.grad
v = b2 * v + (1 - b2) * p.grad ** 2
m_hat = m / (1 - b1 ** t) # bias correction
v_hat = v / (1 - b2 ** t)
p -= lr * m_hat / (v_hat.sqrt() + eps)
if t % 2 == 1:
print('%6d %10.4f %10.4f %12.5f'
% (t, p[0].item(), p[1].item(), value.item()))
1 0.9000 0.9000 10.50000
3 0.7016 0.7016 6.72693
5 0.5080 0.5080 3.82980
7 0.3234 0.3234 1.80171
9 0.1536 0.1536 0.58612
Look at the two coordinates: they are identical at every step, on a bowl where one direction is two hundred times steeper than the other. Dividing by the square root of the running average of squared gradients has removed the scale difference entirely, so the optimiser now walks diagonally to the minimum instead of zig-zagging down one axis and crawling along the other. That is the whole of what adaptive means.
Why the bias correction is there
Both running averages start at zero, so for the first few steps they are biased towards zero and the step size would be far too small. Dividing by 1 - beta**t undoes exactly that, and the correction fades away as t grows. Leave it out and the first hundred steps of training barely move, which is a bug people reinvent regularly when writing their own optimiser.
On a real model
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
train_set = Subset(train_all, range(8000))
val_set = Subset(train_all, range(8000, 10000))
def build(seed=0):
torch.manual_seed(seed)
return nn.Sequential(nn.Flatten(), nn.Linear(784, 128), nn.ReLU(),
nn.Linear(128, 10))
def run(opt_fn, epochs=3, batch_size=64, seed=0, sched_fn=None):
torch.manual_seed(seed)
model = build(seed)
opt = opt_fn(model.parameters())
sched = sched_fn(opt) if sched_fn else None
loss_fn = nn.CrossEntropyLoss()
loader = DataLoader(train_set, batch_size=batch_size, shuffle=True)
val = DataLoader(val_set, batch_size=512)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
loss_fn(model(xb), yb).backward()
opt.step()
if sched is not None:
sched.step()
model.eval()
right = seen = 0
total = 0.0
with torch.no_grad():
for xb, yb in val:
out = model(xb)
total += loss_fn(out, yb).item() * yb.numel()
right += (out.argmax(1) == yb).sum().item()
seen += yb.numel()
return total / seen, right / seen
print('%-14s %12s %12s' % ('optimiser', 'val loss', 'val acc'))
for name, fn in [
('SGD 0.1', lambda p: torch.optim.SGD(p, lr=0.1)),
('SGD+mom 0.1', lambda p: torch.optim.SGD(p, lr=0.1, momentum=0.9)),
('Adam 1e-3', lambda p: torch.optim.Adam(p, lr=1e-3)),
('AdamW 1e-3', lambda p: torch.optim.AdamW(p, lr=1e-3)),
('RMSprop 1e-3', lambda p: torch.optim.RMSprop(p, lr=1e-3))]:
loss, acc = run(fn)
print('%-14s %12.4f %12.4f' % (name, loss, acc))
SGD 0.1 0.2639 0.9235
SGD+mom 0.1 0.3766 0.9125
Adam 1e-3 0.2715 0.9230
AdamW 1e-3 0.2682 0.9220
RMSprop 1e-3 0.2355 0.9315
Notice that nothing wins convincingly
Five optimisers, and the spread between the best and the worst is about two points of accuracy, with plain SGD in the middle and momentum at the same rate behind it for the reason given above. On a small model and an easy dataset that is the normal result: the optimiser is not usually where your accuracy comes from.
Day 6 measures how much this model varies between random seeds, and the answer makes most of this table look smaller still. AdamW is the sensible default because it needs the least tuning to get somewhere reasonable, not because it wins races.