12 min

The optimizer (SGD)

The learning step is one line.

Milestone A single step measurably lowers the loss.

You have a number measuring the error and gradients pointing from it into every weight. What is missing is the step that touches those weights. It is so short that on first reading you ask whether that was really all of it: push every weight a little bit against its gradient.

Against, not with. The gradient points in the direction in which the loss grows

  • it is the direction of steepest ascent. We want to go downhill, so we go backwards. That minus sign is the entire idea of the method, and the name says so too: gradient descent.

The goal

The learning step is one line. p.data -= lr * p.grad, for every parameter. The rest of the optimizer is administration: a list of parameters and a step size.

The step size lr (learning rate) is the only knob, and it has two wrong settings. Too small: training crawls and needs thousands of epochs. Too large: the step overshoots the valley, lands higher on the other side than it started, and the loss grows instead of falling. Between the two lies a surprisingly wide zone in which it simply works - which is why rates like 0.01 or 0.05 get you surprisingly far.

Zero magic in this step

NumPy is allowed, torch, jax, tinygrad and autograd are forbidden. torch.optim.SGD above all - a class that is not thirty lines in PyTorch and whose core you are about to write in one.

The exercise

A new file, meintorch/optim.py:

class SGD:
    """Stochastic gradient descent: the simplest optimizer there is."""

    def __init__(self, parameters, lr=0.01):
        self.parameters = list(parameters)
        self.lr = lr

    def step(self):
        # TODO(du): push every parameter a little against its gradient.
        # On the raw array, not via tensor operations.
        raise NotImplementedError

    def zero_grad(self):
        # TODO(du): reset all gradients to zero. Without that, the next
        # backward pass would pile up on top of the old one.
        raise NotImplementedError

Hint 1 - the direction

step() is a loop over self.parameters with exactly one line inside. The sign is the only decision: the loss should get smaller, the gradient points where it gets bigger.

zero_grad() is a loop over the same list. Your tensor already knows how to do what has to happen inside it - you wrote that method in chapter 02.

Why zero_grad() has to exist at all: gradients are accumulated (+=, see chapter 02). Without cleaning up, the gradient in epoch 10 would hold the sum of all ten backward passes, the step would be ten times too large, and training derails. This is by far the most common bug in hand-built training loops - and in PyTorch code, for that matter.

Hint 2 - more concrete

  • step(): for p in self.parameters: p.data -= self.lr * p.grad.
  • zero_grad(): for p in self.parameters: p.zero_grad().
  • list(parameters) in the constructor is deliberate: model.parameters() returns a list, but a generator would be empty after the first pass.
Show the solution
class SGD:
    def __init__(self, parameters, lr=0.01):
        self.parameters = list(parameters)
        self.lr = lr

    def step(self):
        for p in self.parameters:
            p.data -= self.lr * p.grad

    def zero_grad(self):
        for p in self.parameters:
            p.zero_grad()

In goal mode

The autonomous run does not only check that the loss falls, but also that the step has exactly the right size and leaves the graph alone:

Build prompt
Create `meintorch/optim.py` with a class `SGD(parameters, lr=0.01)`. `step()`
moves every parameter by `lr * grad` against its gradient, in-place on the
NumPy array, so that no new graph node appears. `zero_grad()` resets all
gradients.
Mandatory: NumPy allowed, torch/jax/tinygrad/autograd forbidden. No momentum,
no weight decay - bare gradient descent.
Write `tests/test_05_optimizer.py`: one step on a parameter with a known
gradient has to be exactly `lr * grad`, a step after a backward pass has to
measurably lower the loss of a small network, `zero_grad()` has to empty all
gradients, and after `step()` no parameter may have children in the graph.
Run `python -m pytest` until everything is green.

The milestone test

tests/test_05_optimizer.py. The second test is the milestone: a single step, and the loss is smaller than before. That is the moment where arithmetic turns into learning.

import numpy as np

from meintorch.loss import mse_loss
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.optim import SGD
from meintorch.tensor import Tensor


def network_and_data():
    net = Sequential(Linear(3, 4, seed=0), ReLU(), Linear(4, 1, seed=1))
    x = Tensor(np.random.default_rng(7).normal(size=(8, 3)))
    y = Tensor(np.random.default_rng(8).normal(size=(8, 1)))
    return net, x, y


def test_the_step_is_exactly_lr_times_gradient():
    p = Tensor([[1.0, -2.0]])
    p.grad = np.array([[2.0, 4.0]])
    SGD([p], lr=0.1).step()
    assert np.allclose(p.data, [[0.8, -2.4]])


def test_one_step_lowers_the_loss():
    net, x, y = network_and_data()
    opt = SGD(net.parameters(), lr=0.05)
    before = float(mse_loss(net(x), y).data)

    loss = mse_loss(net(x), y)
    opt.zero_grad()
    loss.backward()
    opt.step()

    assert float(mse_loss(net(x), y).data) < before


def test_zero_grad_cleans_up_before_the_next_step():
    net, x, y = network_and_data()
    opt = SGD(net.parameters(), lr=0.05)
    mse_loss(net(x), y).backward()
    opt.zero_grad()
    for p in net.parameters():
        assert np.allclose(p.grad, 0.0)


def test_the_step_adds_nothing_to_the_graph():
    net, x, y = network_and_data()
    opt = SGD(net.parameters(), lr=0.05)
    mse_loss(net(x), y).backward()
    opt.step()
    for p in net.parameters():
        assert p._children == ()