Part 8 · Machine Learning · Module 58

Neural Networks from Scratch

A neural network is logistic regression stacked in layers, trained with the same "error times input" gradient you derived in M55. You will build one in NumPy, from a single neuron to backpropagation through a computational graph, train it on problems a straight line cannot solve, and then see how PyTorch automates every step you wrote by hand.

⏱ ~3.5 hoursPrerequisites: M55, M49

The neuron

An artificial neuron takes inputs x1…xd, multiplies each by a weight, adds a bias, and passes the total through a nonlinear activation function g:

a = g(w1x1 + … + wdxd + b) = g(w · x + b)

With g = sigmoid this is exactly logistic regression from M55. The name comes from a loose analogy with biological neurons that "fire" when their inputs are strong enough; the analogy stops there. The power comes from layers: the outputs of many neurons become the inputs of the next layer. A network with an input layer, one or more hidden layers, and an output layer is a multi-layer perceptron (MLP), also called a fully connected or dense network.

Why bother? A single neuron's decision boundary is a straight line. The classic counterexample is XOR: output 1 when exactly one of two inputs is 1. No line separates {(0,1), (1,0)} from {(0,0), (1,1)}. A hidden layer can bend space so that a line works afterwards. Explore a 2-4-1 network below: pick an input, watch values flow forward, and switch to training mode to see it learn XOR.

Remember: neuron = weighted sum + bias + nonlinearity. Layers of neurons can represent boundaries no single line can.

Activation functions

Without a nonlinearity, stacking layers is pointless: W2(W1x) is just (W2W1)x, another linear map. The activation is what lets depth add power. Three you must know:

Remember: the activation supplies the nonlinearity; sigmoid/tanh saturate (tiny derivatives for large |z|), ReLU does not for positive inputs, which is why it is the hidden-layer default.

The forward pass in matrix form

Process a whole batch of n examples at once. Put them in rows of X (shape (n, d)). A dense layer with h neurons has a weight matrix W of shape (d, h) (column j holds neuron j's weights) and a bias vector b of shape (h,):

Z = X W + b (shape (n, h)),   A = g(Z)

NumPy broadcasting adds b to every row. A two-layer network for binary classification is then

Z1 = X @ W1 + b1        # (n, d) @ (d, h) -> (n, h)
A1 = np.tanh(Z1)         # hidden activations
Z2 = A1 @ W2 + b2        # (n, h) @ (h, 1) -> (n, 1)
P  = sigmoid(Z2)         # probability of class 1

For k classes the output layer has k neurons and the softmax turns their scores (logits) into probabilities that sum to 1: softmax(z)j = ezj / Σm ezm. Subtract the row maximum before exponentiating; it does not change the result and prevents overflow. The loss is cross-entropy: minus the log of the probability given to the true class.

Remember: each layer is g(XW + b); W is (inputs, outputs); softmax + cross-entropy for multi-class outputs; print shapes.

Backpropagation and the computational graph

Training needs the gradient of the loss with respect to every weight. Backpropagation is the chain rule applied efficiently: run the forward pass, keep the intermediate values, then walk backwards from the loss, multiplying local derivatives. It is easiest to see on a computational graph, where each node is one simple operation.

x w × b + σ L y u = w·xz = u + bp = σ(z) ∂L/∂p× σ'(z)× 1× x solid: forward pass computes values dashed: backward pass multiplies local derivatives
One neuron as a graph. ∂L/∂w = ∂L/∂p · σ'(z) · 1 · x: the chain rule, read right to left.

Each node only needs to know its own local derivative. Multiplication u = w·x sends back x to w (and w to x); addition passes gradients through unchanged; sigmoid multiplies by σ(z)(1 − σ(z)). With binary cross-entropy, the product ∂L/∂p · σ'(z) simplifies to just p − y, which is why output-layer gradients look so clean.

For the two-layer network, backprop in matrix form (averaging over the batch of n) is:

dZ2 = (P - y) / n                 # (n, 1)   output error
dW2 = A1.T @ dZ2                  # (h, 1)   "error times input" again
db2 = dZ2.sum(axis=0)             # (1,)
dA1 = dZ2 @ W2.T                  # (n, h)   send the error back through W2
dZ1 = dA1 * (1 - A1 ** 2)         # (n, h)   times tanh's local derivative
dW1 = X.T @ dZ1                   # (d, h)
db1 = dZ1.sum(axis=0)             # (h,)

Every gradient has the same shape as the parameter it belongs to: a quick sanity check. The authoritative check is a numerical gradient: nudge one weight by a tiny ε both ways and measure the change in loss, (L(w + ε) − L(w − ε)) / 2ε. If it matches your analytic gradient to about 6 decimal places, your backprop is right.

Remember: forward computes and caches values; backward multiplies local derivatives from the loss back to each weight; gradients match parameter shapes; verify with a numerical check.

Training a two-layer network: XOR and moons

Put the pieces together: initialize, then repeat forward, loss, backward, update. First, XOR, the problem a single neuron cannot solve.

Now a slightly bigger problem with a curved boundary, plotted:

The neural-net visualizer can train XOR too; set it to train mode and compare its loss curve with the cell's.

Remember: the training loop is forward → loss → backward → update; neural losses have local minima, so seeds and learning rates matter.

Mini-batches and stochastic gradient descent

So far every step used the whole dataset (full-batch gradient descent). With a million images that is far too slow per step. Stochastic gradient descent (SGD) estimates the gradient from a small random mini-batch (typically 32–512 examples), takes a step, and moves on. One pass through all the data is an epoch. The gradient estimates are noisy, but there are many more steps per epoch, and the noise even helps escape poor regions. Shuffle the data every epoch so batches differ.

Plain SGD has popular refinements: momentum (keep a running average of gradients, like a ball rolling downhill) and Adam (per-parameter adaptive step sizes). Try momentum on the Rosenbrock valley below, then compare with plain descent.

Remember: SGD uses mini-batches for cheap, noisy, frequent steps; shuffle each epoch; momentum and Adam are the usual upgrades.

Initialization and vanishing gradients

If all weights start at zero, every hidden neuron computes the same thing and receives the same gradient, forever: the symmetry never breaks. So weights start random. But the scale matters. Each layer multiplies the signal by its weights; too large and activations explode, too small and they shrink toward zero layer after layer. The fix is to scale the random weights by the number of inputs: Xavier/Glorot initialization (variance about 1/fan_in) for tanh and sigmoid, He initialization (variance 2/fan_in) for ReLU.

Backpropagation multiplies one local derivative per layer. Sigmoid's derivative is at most 0.25, so after 10 sigmoid layers the gradient reaching the first layer can be smaller than 0.25¹⁰ ≈ 10⁻⁶: early layers barely learn. That is the vanishing gradient problem, and it held deep learning back for years. ReLU (derivative 1 when active), careful initialization, normalization layers, and residual (skip) connections are the cures.

Remember: random, fan-in-scaled initialization (Xavier for tanh/sigmoid, He for ReLU); sigmoid stacks vanish gradients; ReLU and skip connections keep deep networks trainable.

Overfitting and regularization

A network with thousands of weights can memorize a small dataset. Everything from M55 applies: watch training vs validation loss, and regularize:

Remember: weight decay, early stopping, and dropout fight overfitting; dropout only runs during training, with inverted scaling.

PyTorch: the same network, automated

Writing backprop by hand teaches you what is happening. In practice you use a framework that computes gradients automatically. PyTorch is the most widely used for research and much of industry. Its core ideas map one-to-one onto what you just built:

You wrotePyTorch
NumPy arraystorch.Tensor (same idea, can live on a GPU)
The backward pass by handautograd: loss.backward() fills every parameter's .grad
W1, b1, … and the forward functionan nn.Module with layers such as nn.Linear
W -= lr * dWan optimizer: torch.optim.SGD or Adam, optimizer.step()
Shuffled mini-batchestorch.utils.data.DataLoader

Tensors and autograd

An nn.Module and a training loop

The GPU idea: a graphics card runs thousands of simple arithmetic operations in parallel, and neural networks are mostly big matrix multiplications, exactly that workload. Moving the model and the tensors to the same device (.to("cuda"), or "mps" on Apple silicon) can make training 10–100× faster for large models. For the tiny networks in this module, the CPU is plenty.

Remember: tensors + autograd + nn.Module + optimizer = the loop you wrote, with zero_grad → forward → loss → backward → step.

Where deep learning shines

On small tabular datasets, gradient-boosted trees (M56) usually beat neural networks with far less effort. Deep learning wins when the input has rich structure and there is a lot of data, because architectures can build that structure in:

Convolutional networks (CNNs)

Images. A small filter (say 3×3 weights) slides over the image, so the same pattern detector is reused everywhere; stacked layers detect edges, then textures, then parts, then objects. Far fewer weights than a dense layer on raw pixels.

Recurrent networks (RNNs, LSTMs)

Sequences processed one step at a time with a hidden state carried forward. Historically used for text, speech, and time series; largely replaced by transformers for language.

Transformers

Sequences processed with attention: every position computes how much to look at every other position, in parallel. The architecture behind modern language models, and increasingly used for images, audio, and proteins.

Embeddings and transfer learning

Networks pretrained on huge datasets produce embeddings (M57) you can reuse, or fine-tune on your small dataset. Most practical deep learning starts from a pretrained model.

Remember: trees for tables, deep learning for images, audio, and text; start from pretrained models when you can.

Practice

NumPy is available. The tests check values, shapes, and gradients numerically.

Write relu(z) returning max(0, z) element-wise, and relu_grad(z) returning 1.0 where z > 0 and 0.0 elsewhere (use 0 at exactly z = 0). Both take and return NumPy float arrays of the same shape.

Hint

np.maximum(0, z) and (z > 0).astype(float).

Show solution
import numpy as np

def relu(z):
    return np.maximum(0.0, z)

def relu_grad(z):
    return (np.asarray(z) > 0).astype(float)

Write softmax(Z) for a 2-D array of logits (one row per example) that returns probabilities with each row summing to 1. Subtract each row's maximum first so that huge logits such as 1000 do not overflow.

Hint

Z - Z.max(axis=1, keepdims=True), exponentiate, divide by the row sums with keepdims=True.

Show solution
import numpy as np

def softmax(Z):
    Z = np.asarray(Z, dtype=float)
    E = np.exp(Z - Z.max(axis=1, keepdims=True))
    return E / E.sum(axis=1, keepdims=True)

Write forward(X, layers) where layers is a list of (W, b) tuples. Apply X @ W + b for each layer, with ReLU after every layer except the last, and return the final output (the logits).

Hint

Loop with enumerate(layers) and apply ReLU when i < len(layers) - 1.

Show solution
import numpy as np

def forward(X, layers):
    A = np.asarray(X, dtype=float)
    for i, (W, b) in enumerate(layers):
        A = A @ W + b
        if i < len(layers) - 1:
            A = np.maximum(0.0, A)
    return A

Write numerical_gradient(f, w, eps=1e-6). f takes an array shaped like w and returns a float. Return an array of the same shape where each entry is the central difference (f(w + ε·eᵢ) − f(w − ε·eᵢ)) / 2ε. Do not modify the caller's w.

Hint

Work on w = w.astype(float).copy(); loop over np.ndindex(w.shape), nudge one entry up and down, and restore it.

Show solution
import numpy as np

def numerical_gradient(f, w, eps=1e-6):
    w = np.array(w, dtype=float, copy=True)
    grad = np.zeros_like(w)
    for idx in np.ndindex(w.shape):
        old = w[idx]
        w[idx] = old + eps
        up = f(w)
        w[idx] = old - eps
        down = f(w)
        w[idx] = old
        grad[idx] = (up - down) / (2 * eps)
    return grad

Write backprop(X, y, W1, b1, W2, b2) for the network A1 = tanh(X W1 + b1), P = sigmoid(A1 W2 + b2) with mean binary cross-entropy loss. y has shape (n, 1). Return (loss, grads) where grads is a dict with keys "W1", "b1", "W2", "b2", each the same shape as its parameter. The tests compare against numerical gradients.

Hint

Follow the matrix-form backprop in this module: dZ2 = (P - y) / n, dZ1 = dZ2 @ W2.T * (1 - A1 ** 2), then dW = input.T @ dZ and db = dZ.sum(axis=0).

Show solution
import numpy as np

def backprop(X, y, W1, b1, W2, b2):
    n = len(X)
    A1 = np.tanh(X @ W1 + b1)
    P = 1 / (1 + np.exp(-(A1 @ W2 + b2)))
    loss = float(-np.mean(y * np.log(P) + (1 - y) * np.log(1 - P)))
    dZ2 = (P - y) / n
    dZ1 = dZ2 @ W2.T * (1 - A1 ** 2)
    grads = {"W1": X.T @ dZ1, "b1": dZ1.sum(axis=0), "W2": A1.T @ dZ2, "b2": dZ2.sum(axis=0)}
    return loss, grads

Mini-project

Mini-projectA NumPy MLP that reads handwritten digits

Build a small neural-network library in pure NumPy and train it on sklearn.datasets.load_digits (1,797 grayscale 8×8 images of the digits 0–9) to more than 90% test accuracy. Set up with pixi init numpy-mlp and pixi add numpy scikit-learn matplotlib pytest (scikit-learn only for the dataset and the split).

  1. mlp.py: a Dense layer class with forward(x) (caching its input) and backward(grad_out) (storing dW, db and returning the gradient for its input); ReLU with the same interface; a softmax_cross_entropy(logits, labels) returning the loss and the gradient (softmax − one_hot) / n.
  2. An MLP class holding a list of layers, He initialization, and predict(X).
  3. train.py: scale pixels to [0, 1] (they range 0–16), stratified split into train/validation/test, mini-batch SGD (batch 32–64, learning rate about 0.1), optional momentum, 30–60 epochs, and early stopping on validation accuracy. Architecture: 64 → 64 → 10 is enough.
  4. Plot training and validation loss per epoch, and show a grid of misclassified test digits with predicted and true labels.
  5. test_mlp.py: a numerical gradient check for Dense and for the full network, a shape test, and a test that one training step reduces the loss on a fixed batch.
  • Backprop passes a numerical gradient check to within 1e-6
  • Test accuracy above 90% (the reference reaches about 96–97%)
  • The test set is used once, after early stopping chose the epoch on validation data
  • Training is reproducible with a fixed seed
  • Loss curves and a misclassification grid are saved as PNG files
  • You can explain, in your own words, every line of your backward pass

Stretch: add dropout and weight decay and measure their effect; implement Adam; then rewrite the model in PyTorch (pixi add pytorch) and confirm you get similar accuracy with a tenth of the code.

Quiz

Recap

Going further