25 min

The proof

It runs, and it runs without torch.

Milestone Your self-generated moons dataset is separated with over 90 percent accuracy.

For seven chapters you wrote parts and tested each one green on its own. Now comes the test that counts: a task no straight line can solve, computed from start to finish with your own framework.

The task is two interlocking half moons. It is the standard example for "not linearly separable": no straight line cuts the two classes apart, whereas a network with a ReLU layer in the middle manages it easily. If your framework can do that, it can do what frameworks are built for.

The goal

It runs, and it runs without torch. That sentence is the whole point of the course, and it is checkable: this chapter's milestone test searches your own files for import torch and looks whether the module was loaded anywhere in the process. Both have to come up empty.

The dataset does not come from a library for the same reason. The obvious move would be sklearn.datasets.make_moons - one line, done. But then this course would end on a pip install scikit-learn, and the half moons would be the only piece of the way that somebody else made for you. Two shifted semicircles plus noise are ten lines of NumPy. Zero magic applies to the data as well.

Zero magic in this step

NumPy is allowed, torch, jax, tinygrad and autograd are forbidden - and in this chapter scikit-learn on top, along with any other library that would hand you datasets, splits or metrics. Dataset, split and accuracy you write yourself; together they are not twenty lines.

The exercise

Two files. First the dataset, meintorch/datasets.py:

import numpy as np


def make_moons(n=400, noise=0.15, seed=0):
    """Two interlocking half moons. Ten lines of NumPy, no library."""
    rng = np.random.default_rng(seed)
    n_upper = n // 2
    n_lower = n - n_upper
    # TODO(du): the upper moon is the unit semicircle (cos, sin) for angles
    # from 0 to pi. The lower one is the same semicircle, point-mirrored and
    # shifted down to the right so the two interlock. Stack both classes,
    # append labels 0 and 1 as a column, and finally add noise on top with
    # `rng.normal`.
    raise NotImplementedError

Then the proof script, beweis.py, in the root directory next to meintorch/:

import numpy as np

from meintorch.data import DataLoader
from meintorch.datasets import make_moons
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
from meintorch.train import fit


def accuracy(model, x, y):
    """Share of correctly guessed classes. Threshold 0.5."""
    # TODO(du): predict, threshold at 0.5, compare with y, take the mean.
    raise NotImplementedError


def main():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    # TODO(du): split 80/20 into training and test - permute once, then cut.
    # Build a network 2 -> 16 -> 16 -> 1 with ReLU in between, create a data
    # loader, train with `fit` and print both accuracies at the end.
    raise NotImplementedError


if __name__ == "__main__":
    main()

Hint 1 - the direction

For the moons: a semicircle is (cos t, sin t) for t from 0 to pi. The second moon is the same arc, the other way round and offset - try (1 - cos t, 0.5 - sin t). If you are unsure whether it fits: the two clouds should embrace each other like two brackets, not sit side by side. A scatter plot tells you in five seconds whether the shape is right.

The noise comes last, on both moons together. Without noise all points would lie exactly on two curves, and the task would be boring instead of easy.

For the split: np.random.default_rng(1).permutation(len(x)) gives you a shuffled list of indices. The first 80 percent are training, the rest is test. The test part must not appear in training anywhere - otherwise you measure memorised instead of understood.

For the accuracy: pred > 0.5 and y > 0.5 are both boolean arrays. Their elementwise comparison, averaged, is the hit rate.

Hint 2 - more concrete

  • The moons:

        angle_upper = np.linspace(0.0, np.pi, n_upper)
        angle_lower = np.linspace(0.0, np.pi, n_lower)
        upper = np.stack([np.cos(angle_upper), np.sin(angle_upper)], axis=1)
        lower = np.stack(
            [1.0 - np.cos(angle_lower), 0.5 - np.sin(angle_lower)], axis=1
        )
        x = np.concatenate([upper, lower])
        y = np.concatenate([np.zeros(n_upper), np.ones(n_lower)]).reshape(-1, 1)
        x += rng.normal(scale=noise, size=x.shape)
        return x, y
    
  • The network: Sequential(Linear(2, 16, seed=0), ReLU(), Linear(16, 16, seed=1), ReLU(), Linear(16, 1, seed=2)). One layer fewer works too, two ReLU layers leave more room.

  • Training: fit(net, DataLoader(x_train, y_train, batch_size=32, seed=2), epochs=60, lr=0.1). With those values test accuracy typically lands beyond 97 percent - the required 90 are generous on purpose.

  • Accuracy: float(((model(Tensor(x)).data > 0.5) == (y > 0.5)).mean()).

Show the solution
# in datasets.py
def make_moons(n=400, noise=0.15, seed=0):
    rng = np.random.default_rng(seed)
    n_upper = n // 2
    n_lower = n - n_upper
    angle_upper = np.linspace(0.0, np.pi, n_upper)
    angle_lower = np.linspace(0.0, np.pi, n_lower)
    upper = np.stack([np.cos(angle_upper), np.sin(angle_upper)], axis=1)
    lower = np.stack(
        [1.0 - np.cos(angle_lower), 0.5 - np.sin(angle_lower)], axis=1
    )
    x = np.concatenate([upper, lower])
    y = np.concatenate([np.zeros(n_upper), np.ones(n_lower)]).reshape(-1, 1)
    x += rng.normal(scale=noise, size=x.shape)
    return x, y


# in beweis.py
def accuracy(model, x, y):
    pred = model(Tensor(x)).data
    return float(((pred > 0.5) == (y > 0.5)).mean())


def main():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    idx = np.random.default_rng(1).permutation(len(x))
    cut = int(0.8 * len(x))
    train_idx, test_idx = idx[:cut], idx[cut:]

    net = Sequential(
        Linear(2, 16, seed=0),
        ReLU(),
        Linear(16, 16, seed=1),
        ReLU(),
        Linear(16, 1, seed=2),
    )
    loader = DataLoader(x[train_idx], y[train_idx], batch_size=32, seed=2)
    history = fit(net, loader, epochs=60, lr=0.1)

    print("loss first %.4f, last %.4f" % (history[0], history[-1]))
    print("training %.3f" % accuracy(net, x[train_idx], y[train_idx]))
    print("test     %.3f" % accuracy(net, x[test_idx], y[test_idx]))

In goal mode

The last autonomous brief is the only one that checks the whole stretch:

Build prompt
Create `meintorch/datasets.py` with `make_moons(n=400, noise=0.15, seed=0)`: two
interlocking half moons from pure NumPy, returning (x of shape (n, 2), y of
shape (n, 1) with labels 0/1), classes of equal size. Write `beweis.py` that
builds an 80/20 split from it, trains a network 2-16-16-1 with ReLU via `fit`
and a data loader, and prints training and test accuracy.
Mandatory: NumPy allowed, torch/jax/tinygrad/autograd forbidden, plus no
scikit-learn - write dataset, split and metric yourself.
Write `tests/test_08_beweis.py`: the moons have to give two equally sized
classes in the right shape, no file under `meintorch/` may contain
`import torch` and `torch` must not appear in `sys.modules`, and the trained
network has to reach over 90 percent accuracy on the test part.
Run `python -m pytest` until everything is green.

The milestone test

tests/test_08_beweis.py, run from the root directory. The second test is the title of the course as an assertion, the third is the milestone: over 90 percent on data the network has never seen.

import pathlib
import sys

import numpy as np

from meintorch.data import DataLoader
from meintorch.datasets import make_moons
from meintorch.nn import Linear, ReLU, Sequential
from meintorch.tensor import Tensor
from meintorch.train import fit


def test_the_moons_are_two_equally_sized_classes():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    assert x.shape == (600, 2)
    assert y.shape == (600, 1)
    assert int(y.sum()) == 300


def test_no_torch_involved():
    assert "torch" not in sys.modules
    for file in pathlib.Path("meintorch").glob("*.py"):
        assert "import torch" not in file.read_text()


def test_the_network_separates_the_moons_above_90_percent():
    x, y = make_moons(n=600, noise=0.12, seed=0)
    idx = np.random.default_rng(1).permutation(len(x))
    cut = int(0.8 * len(x))
    train_idx, test_idx = idx[:cut], idx[cut:]

    net = Sequential(
        Linear(2, 16, seed=0),
        ReLU(),
        Linear(16, 16, seed=1),
        ReLU(),
        Linear(16, 1, seed=2),
    )
    loader = DataLoader(x[train_idx], y[train_idx], batch_size=32, seed=2)
    history = fit(net, loader, epochs=60, lr=0.1)
    assert history[-1] < history[0] / 5

    pred = net(Tensor(x[test_idx])).data
    accuracy = float(((pred > 0.5) == (y[test_idx] > 0.5)).mean())
    assert accuracy > 0.9

What you have now

A directory with seven files and no dependency other than NumPy. Inside it: a tensor that computes and remembers what it came from; an autograd that handles the chain rule as bookkeeping; layers, a loss, an optimizer; a loop that turns all of it into training, and a data loader that feeds it.

The difference to before is not that you can train networks now - you could do that with three lines of PyTorch before. The difference is that loss.backward() is no longer a black box for you. When a training run does not converge, you now have candidates instead of bafflement: a forgotten zero_grad(), too large a step size, a gradient that falls out of the graph somewhere because somebody computed on a raw array.

And the way there was the same as with the agents next door: build it once yourself. The five lines from chapter 06 are the same figure as the loop in the tools and loop building block - repeat first, and then it gets clever.