Ch. 09

Intro to Neural Networks

Backprop derived by hand, a 50-line MLP in pure NumPy, the activation-function evolution.

backpropMLPactivations

FIG 09 · Explainer video


A neural network is a sandwich of matrix multiplies with non-linearities between them. That is the entire idea. The reason the field is enormous is that once you accept that picture, three questions immediately surface: what should the non-linearity be, how do you initialize the matrices, and how do you compute the of a loss with respect to every entry in every matrix without writing the chain rule by hand. answers the third question with one trick: build the as a graph of tiny operations, then walk it backward. Karpathy's micrograd is 100 lines of Python that does this on scalars and trains an MLP on a moons dataset. By the end of this chapter you will have built that, then re-built it on tensors, then trained the result on MNIST and watched the loss curve do the thing.


FIG 09.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Implement a scalar autograd engine (Value with _backward closures and a topological sort) in 80 lines of pure Python, and pass torch.allclose tests against PyTorch.
  • Stack NeuronLayerMLP on top of that engine, the way Karpathy's micrograd.nn does, and train it on a 2D toy dataset with SGD until the decision boundary separates the moons.
  • Write a forward and backward pass for a 2-layer MLP in pure NumPy, with explicit gradients for matmul, ReLU, and softmax-cross-entropy, and verify each one numerically.
  • Explain in one paragraph why a network of affine layers without non-linearities collapses to a single affine layer, and why ReLU specifically beat sigmoid in 2010.
  • Train an MLP on MNIST to ≈98% test accuracy in under 60 seconds on a free Colab T4, using only operations you understand.
  • Diagnose three of the most common training failures in this chapter's setting: dead ReLUs, gradient saturation in sigmoids, and the loss-flat-then-explodes signature of bad initialization.

FIG 09.2 · What you need first

  • Ch 0 — Math & Python prereqspartial derivatives, the chain rule, and NumPy broadcasting. Without these the backward pass looks like alphabet soup.
  • Ch 4 — Training Modelsgradient descent on linear and logistic regression, plus the cross-entropy loss for classification. This chapter generalizes both.
  • Ch 3 — Classificationyou should already know what train/val/test, accuracy, and a confusion matrix look like, since the MNIST capstone here uses all three.

If you skipped another chapter: you can survive the early sections of this chapter, but the moment SGD and minibatching appear without ceremony, you will want to skim another chapter's §4.4 (Mini-batch gradient descent).


FIG 09.3.1

The perceptron and why one neuron isn't enough

The simplest model that anyone calls a "neural network" is one neuron. It takes a vector xRdx \in \mathbb{R}^d, computes z=wx+bz = w \cdot x + b for some vector ww and bb, then passes zz through a non-linearity σ\sigma to produce an output a=σ(z)a = \sigma(z). With σ\sigma a threshold and a binary label, this is Rosenblatt's 1958 perceptron. With σ\sigma a logistic , it is logistic regression. Either way, the decision boundary {x:wx+b=0}\{x : w \cdot x + b = 0\} is a hyperplane.

A single neuron can solve linearly separable problems and nothing else. The canonical demonstration of this limit is the XOR function, where no hyperplane separates the four points. Minsky and Papert wrote a whole book on it in 1969, which had the side effect of putting connectionist research on hold for a decade. The fix is to compose neurons: feed the outputs of one layer of neurons as the inputs of the next. Each neuron in the next layer sees a non-linear function of the previous layer, and the composition can carve out non-linear boundaries.

The mental picture: a single neuron is a linear classifier. Two neurons in a hidden layer plus a non-linearity is enough to bend the boundary into the XOR shape. By the time you have ten hidden neurons in two layers, you can draw any boundary you want, if you can find the right weights. That last clause is the entire rest of this chapter.

Library path (sklearn, just to make the limit concrete):

Python
from sklearn.linear_model import LogisticRegression
import numpy as np

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([0, 1, 1, 0])   # XOR
clf = LogisticRegression().fit(X, y)
print(clf.score(X, y))   # 0.5 — a coin flip. The boundary is linear.

From-scratch path (numpy, one neuron with sigmoid):

Python
import numpy as np

def sigmoid(z: np.ndarray) -> np.ndarray:
    return 1.0 / (1.0 + np.exp(-z))

def predict(x: np.ndarray, w: np.ndarray, b: float) -> np.ndarray:
    return sigmoid(x @ w + b)

# Fit by hand on XOR — try as many w, b as you like, the best you can do is 0.5 accuracy.
w = np.array([1.0, 1.0])
b = -0.5
preds = (predict(np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), w, b) > 0.5).astype(int)
print(preds)   # [0 1 1 1] — solves OR, not XOR.

FIG 09.3.2

The MLP, formally

A multilayer perceptron is what you get by stacking LL affine transformations with non-linearities between them. For one hidden layer with hh hidden units, mapping RdRq\mathbb{R}^d \to \mathbb{R}^q:

H=σ(XW(1)+b(1))O=HW(2)+b(2)\begin{aligned} H &= \sigma(X W^{(1)} + b^{(1)}) \\ O &= H W^{(2)} + b^{(2)} \end{aligned}

Here XRn×dX \in \mathbb{R}^{n \times d} is a minibatch of nn examples, W(1)Rd×hW^{(1)} \in \mathbb{R}^{d \times h} and W(2)Rh×qW^{(2)} \in \mathbb{R}^{h \times q} are matrices, and b(1),b(2)b^{(1)}, b^{(2)} are biases. σ\sigma is the applied element-wise. HH is the hidden representation.

The non-linearity is load-bearing. Drop it, and O=XW(1)W(2)+b(1)W(2)+b(2)=XW+bO = X W^{(1)} W^{(2)} + b^{(1)} W^{(2)} + b^{(2)} = X W' + b' for W=W(1)W(2)W' = W^{(1)} W^{(2)}, which is a single affine layer. Two boring layers compose to one boring layer. Add σ\sigma and the composition can express functions no single affine layer can.

The universal approximation theorem (Cybenko 1989, Hornik 1991) says that a one-hidden-layer MLP with enough hidden units can approximate any continuous function on a compact set, to any desired . This is reassuring and operationally useless: it does not tell you how many units, what initialization, what optimizer, or whether you can find those weights with . In practice, deep networks (many narrow layers) outperform wide networks (one fat hidden layer) at the same budget, for reasons that are still partly mysterious and partly explained by .

The PyTorch pieces you will see below — nn.Module (the base class every model subclasses), nn.Linear (one affine layer xWT+bxW^T + b), nn.Sequential (a chain of layers run in order), and later loss.backward / opt.step / DataLoader — are used here on faith; another chapter builds the whole API up from the micrograd worldview you implement in this chapter.

nn.Linear / nn.Sequential vs. from scratch

DL primitive
LIBRARY
model = nn.Sequential(
    nn.Linear(2, 4), nn.ReLU(),
    nn.Linear(4, 4), nn.ReLU(),
    nn.Linear(4, 1),   # last layer linear, no activation
)
FROM SCRATCH
class Neuron(Module):
    def __init__(self, n_in, nonlin=True):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(n_in)]
        self.b = Value(0.0)
        self.nonlin = nonlin
    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), start=self.b)
        return act.relu() if self.nonlin else act
    def parameters(self):
        return self.w + [self.b]

class Layer(Module):
    def __init__(self, n_in, n_out, **kwargs):
        self.neurons = [Neuron(n_in, **kwargs) for _ in range(n_out)]
    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

class MLP(Module):
    def __init__(self, n_in, n_outs):
        sizes = [n_in] + n_outs
        self.layers = [Layer(sizes[i], sizes[i+1],
                       nonlin=(i < len(n_outs) - 1))
                       for i in range(len(n_outs))]
    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

from scratch: lab/solution.py: class Neuron, class Layer, class MLP

  1. 1nn.Linear(n_in, n_out) a Layer of n_out Neurons, each holding w (len n_in) and b, computing sum(wi*xi) + b
  2. 2one neuron's weight row in nn.Linear.weight + its bias entry Neuron.w (list of Value) and Neuron.b (Value)
  3. 3nn.ReLU() between linear layers act.relu() applied when nonlin=True
  4. 4last nn.Linear with no following activation nonlin=(i < len(n_outs)-1) makes only the final layer linear
  5. 5nn.Sequential forwarding x through each module in order MLP.__call__ loops `for layer in self.layers: x = layer(x)`
  6. 6model.parameters() collecting all weights/biases for the optimizer MLP.parameters() flattens every Neuron's w + [b] across layers
What the one call hides
  • nn.Linear stores weight as shape (out, in) and computes x @ weight.T + bias — transposed relative to the scratch (in, out) reading.
  • Default weight init is Kaiming-uniform (not uniform(-1,1)) and bias is a small uniform, not zero.
  • Vectorized matmul over a whole batch at once, versus the scratch per-neuron Python loop over scalars.
  • Parameter registration: nn.Module auto-tracks tensors for .parameters(), .to(device), and state_dict; the scratch parameters() is hand-rolled.
  • Broadcasting of the bias across the batch dimension.
  • Gotcha: nn.Linear's weight layout is (out_features, in_features) — copying weights to/from a from-scratch (in, out) matrix needs a .T or you get silent transpose bugs.
  • Gotcha: Forgetting nn.ReLU between two nn.Linear layers silently collapses the stack to a single affine map (no error, just an underpowered model).
  • Gotcha: PyTorch's default init differs from the toy uniform(-1,1)/zero-bias init, so loss-at-init and convergence won't match the scratch version.
  • Gotcha: nn.Sequential gives no place for non-linear forward logic; real models subclass nn.Module and write forward().

Use nn.Linear/nn.Sequential in production for vectorization, GPU, and battle-tested init; build the Neuron/Layer/MLP stack once to see that a layer is just a batch of dot-products-plus-bias followed by a non-linearity.

On the job: You compose nn.Linear/nn.ReLU (or subclass nn.Module and write forward) for your architecture; you tune widths/depths and init, not the matmul itself.

The two implementations compute the same function. The library version hides the parameter tensors behind nn.Linear; the from-scratch version puts them right there as self.W1, self.b1. When you read PyTorch code, nn.Linear(d_in, d_out) stores weight of shape (d_out, d_in) and applies x @ weight.T + bias. The .T is why a from-scratch version that stores W1 as (d_in, d_hidden) differs from nn.Linear's transposed layout. This is the kind of thing nobody tells you and that costs you an hour the first time you try to copy weights between the two.

FIG 09.3.3

Activation functions: the evolution from sigmoid to SwiGLU

The non-linearity in the middle of every MLP layer is called the . Five of them matter historically.

(σ(x)=1/(1+ex)\sigma(x) = 1/(1 + e^{-x})) was the original. It is bounded between 0 and 1, smooth, and easy to interpret as a . Its is σ(x)(1σ(x))\sigma(x)(1 - \sigma(x)), which is at most 0.250.25 at x=0x = 0 and approaches 00 for x>4|x| > 4. Stack a few sigmoid layers and the gradient at the input is the product of these small numbers. This is the problem; it is why pre-2010 deep networks did not train.

tanh (tanh(x)\tanh(x)) is sigmoid shifted to be zero-centered, in [1,1][-1, 1]. Better than sigmoid because activations are zero-mean, but still saturates and still vanishes.

(max(0,x)\max(0, x)) was the breakthrough (Nair and Hinton 2010, AlexNet 2012). It is not bounded. Its gradient is exactly 1 for x>0x > 0 and exactly 0 for x<0x < 0. No on the positive side, no vanishing for active neurons. Cheap to compute. The cost is the problem: a neuron that always outputs 0 has zero gradient and never recovers. Caveat: people overstate this; with decent initialization, dead ReLUs are rare and self-healing during training.

GELU (xΦ(x)x \cdot \Phi(x), where Φ(x)\Phi(x) is the standard Gaussian cumulative distribution function — the probability that a standard-normal draw lands below xx, rising smoothly from 0 to 1) is a smooth approximation to ReLU that does not have a hard zero. It is the default in BERT, GPT-2, and most pre-2023 transformers. Slightly more expensive than ReLU. In practice, marginally better.

SwiGLU (SiLU(xW+b)(xV+c)\text{SiLU}(xW + b) \odot (xV + c), where SiLU — also called Swish — is xsigmoid(x)x \cdot \text{sigmoid}(x)) is the gated variant used in Llama, PaLM, and most 2023+ transformers. It is two parallel projections multiplied , with one of them gated by a sigmoid-like function. The math says it has more expressive capacity per than GELU; the empirics agree by a small but consistent margin.

The lesson is not that one is best. It is that you should know what the current default is for the architecture you are building (ReLU for CNNs, GELU for early transformers, SwiGLU for modern LLMs) and not deviate without a reason. The architecture names here — transformers, and the specific models BERT, GPT-2, Llama, PaLM — are the dominant sequence models built on the mechanism; you do not need them yet, and another chapter builds one from scratch.

Library path (each one is a one-liner in PyTorch):

Python
import torch.nn.functional as F

x = torch.linspace(-5, 5, 100)
y_sigmoid = torch.sigmoid(x)
y_tanh = torch.tanh(x)
y_relu = F.relu(x)
y_gelu = F.gelu(x)
y_silu = F.silu(x)   # the building block of SwiGLU

From-scratch path (NumPy, with derivatives — you will need these in section 5):

Python
import numpy as np

def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-x))

def d_sigmoid(x):
    s = sigmoid(x)
    return s * (1 - s)

def relu(x):
    return np.maximum(0, x)

def d_relu(x):
    return (x > 0).astype(x.dtype)

def gelu(x):
    # Hendrycks-Gimpel exact form via erf
    from scipy.special import erf
    return 0.5 * x * (1 + erf(x / np.sqrt(2)))

# d_gelu is messier; in practice you let autograd handle it.

FIG 09.3.4

The forward pass on tensors

Computing the output of an MLP on a minibatch is one matmul, one non-linearity, one more matmul, per layer pair. The shapes are the part you have to track.

For a 2-layer MLP, XRn×dX \in \mathbb{R}^{n \times d}, hidden dim hh, output dim qq:

StepOperationShape
1Z(1)=XW(1)+b(1)Z^{(1)} = X W^{(1)} + b^{(1)}(n,h)(n, h)
2H=σ(Z(1))H = \sigma(Z^{(1)})(n,h)(n, h)
3Z(2)=HW(2)+b(2)Z^{(2)} = H W^{(2)} + b^{(2)}(n,q)(n, q)
4Y^=softmax(Z(2))\hat{Y} = \text{softmax}(Z^{(2)})(n,q)(n, q)

The b(1)b^{(1)} has shape (h,)(h,) and broadcasts across the nn dimension. The in step 4 is across the qq class axis: each row of Y^\hat{Y} is a distribution over classes. The pre-softmax outputs Z(2)Z^{(2)} — the raw class scores before normalization, which another chapter called decision-function scores — have a name from here on: logits. They are what cross_entropy expects, because it folds the softmax and the log into one numerically stable step internally.

This is the easy direction. Implementations rarely get the wrong. The hard direction is the backward pass, and to do that, you need to remember what Z(1)Z^{(1)}, HH, and Z(2)Z^{(2)} were. The forward pass caches its intermediate activations precisely so the backward pass can use them.

Library path:

Python
import torch
import torch.nn as nn
import torch.nn.functional as F

class TwoLayerMLP(nn.Module):
    def __init__(self, d, h, q):
        super().__init__()
        self.fc1 = nn.Linear(d, h)
        self.fc2 = nn.Linear(h, q)
    def forward(self, x):
        return self.fc2(F.relu(self.fc1(x)))   # PyTorch caches activations automatically

x = torch.randn(32, 784)
model = TwoLayerMLP(784, 128, 10)
logits = model(x)   # shape (32, 10) — no softmax; cross_entropy expects logits

From-scratch path (with explicit cache for the backward pass):

Python
def forward(X, params):
    W1, b1, W2, b2 = params['W1'], params['b1'], params['W2'], params['b2']
    Z1 = X @ W1 + b1
    H = relu(Z1)
    Z2 = H @ W2 + b2
    cache = {'X': X, 'Z1': Z1, 'H': H, 'Z2': Z2}
    return Z2, cache   # return logits + cache for backward

FIG 09.3.5

The chain rule and computational graphs

A neural network is a function L(θ)=(f(x;θ),y)L(\theta) = \ell(f(x; \theta), y) where ff is the model, \ell is the loss, and θ\theta are the parameters. Training means minimizing LL by : θθηθL\theta \leftarrow \theta - \eta \nabla_\theta L. The hard part is computing θL\nabla_\theta L when LL is a 10-layer chain of matrix multiplies and non-linearities.

is the answer. It is the chain rule applied carefully. The careful part is that you have to remember what activations you saw on the way in, and you have to walk the chain in reverse, so each node accumulates gradients from all of its downstream uses.

The most natural way to think about it: a computational graph is a directed acyclic graph where nodes are values (scalars, tensors) and edges are operations (+, *, matmul, relu). The builds the graph as you compute. The backward pass walks the graph in topological reverse order, calling a _backward function at each node that uses the chain rule to propagate gradients to its inputs.

For a single multiplication node c=abc = a \cdot b with downstream L/c\partial L / \partial c:

  • L/a=bL/c\partial L / \partial a = b \cdot \partial L / \partial c
  • L/b=aL/c\partial L / \partial b = a \cdot \partial L / \partial c

For an addition c=a+bc = a + b:

  • L/a=L/c\partial L / \partial a = \partial L / \partial c
  • L/b=L/c\partial L / \partial b = \partial L / \partial c

For c=max(0,a)c = \max(0, a):

  • L/a=1[a>0]L/c\partial L / \partial a = \mathbb{1}[a > 0] \cdot \partial L / \partial c

These are the only local rules you need. Backprop composes them across an entire model.

FIG 09.3.6

Backprop from scratch: micrograd

Karpathy's micrograd is 100 lines of Python that implements reverse-mode autodiff on scalars. Its Value class wraps a single number and tracks the operations that produced it. Every operation produces a new Value and attaches a _backward closure that knows how to propagate gradients through that operation. Calling .backward on the final loss does a topological sort of the graph and runs the closures in reverse order.

The key trick is that closures capture the operation's inputs. When you compute out = a * b, the closure for out._backward keeps references to a and b, so when later asked to propagate out.grad to its inputs, it can do a.grad += b.data * out.grad and b.grad += a.data * out.grad.

Here is the canonical implementation, lightly annotated. Read it once, then again, then close the file and re-implement it.

Python
class Value:
    """Stores a single scalar value and its gradient."""
    def __init__(self, data, _children=(), _op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)
        self._op = _op

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad += out.grad      # d(a+b)/da = 1
            other.grad += out.grad     # d(a+b)/db = 1
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad += other.data * out.grad   # d(a*b)/da = b
            other.grad += self.data * out.grad   # d(a*b)/db = a
        out._backward = _backward
        return out

    def __pow__(self, other):
        assert isinstance(other, (int, float))
        out = Value(self.data ** other, (self,), f'**{other}')
        def _backward():
            self.grad += (other * self.data ** (other - 1)) * out.grad
        out._backward = _backward
        return out

    def relu(self):
        out = Value(0 if self.data < 0 else self.data, (self,), 'ReLU')
        def _backward():
            self.grad += (out.data > 0) * out.grad
        out._backward = _backward
        return out

    def backward(self):
        # Topological sort: children before parents
        topo, visited = [], set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)
        # Seed the gradient of the output as 1 and walk back
        self.grad = 1.0
        for v in reversed(topo):
            v._backward()

That is it. No exotic features, no symbolic differentiation, no compiler. Eighty lines. With this, plus the Neuron / Layer / MLP classes from micrograd.nn (another 50 lines), you can train a 2-hidden-layer MLP on the moons dataset and watch it learn the decision boundary.

FIG 09.3.7

Backprop on tensors: from scalars to matmuls

Micrograd does autograd one scalar at a time. PyTorch does it on tensors. The shift is mechanical but the bookkeeping is tighter.

For matmul C=ABC = A B where ARm×kA \in \mathbb{R}^{m \times k} and BRk×nB \in \mathbb{R}^{k \times n}:

  • L/A=(L/C)BT\partial L / \partial A = (\partial L / \partial C) B^T, shape (m,k)(m, k)
  • L/B=AT(L/C)\partial L / \partial B = A^T (\partial L / \partial C), shape (k,n)(k, n)

For broadcasting (a bRhb \in \mathbb{R}^h added to ZRn×hZ \in \mathbb{R}^{n \times h}):

  • L/b=iL/Zi,:\partial L / \partial b = \sum_{i} \partial L / \partial Z_{i,:}, summed over the broadcast axis

For -, the with respect to the pre-softmax logits is famously clean. If p=softmax(z)p = \text{softmax}(z) and L=iyilogpiL = -\sum_i y_i \log p_i for one-hot yy:

  • L/z=py\partial L / \partial z = p - y

That last identity is one of the most elegant results in basic deep learning. It is the reason cross-entropy on logits is the universal loss for classification: the gradient is just "predicted minus actual", with no division by tiny numbers or log-of-zero edge cases.

F.cross_entropy vs. from scratch

DL primitive
LIBRARY
logits = model(x)                  # (n, K), raw scores, NO softmax
loss = F.cross_entropy(logits, y)  # y is int class indices (n,), reduction='mean'
loss.backward()                    # d_loss/d_logits == (softmax(logits) - onehot(y)) / n
FROM SCRATCH
def softmax_xent(logits, y):
    z = logits - logits.max(axis=1, keepdims=True)   # stability shift
    exp_z = np.exp(z)
    probs = exp_z / exp_z.sum(axis=1, keepdims=True)
    n = logits.shape[0]
    loss = -np.log(probs[np.arange(n), y] + 1e-12).mean()
    grad = probs.copy()
    grad[np.arange(n), y] -= 1     # probs - one_hot(y)
    grad /= n                      # average over the batch
    return loss, grad

from scratch: draft.md §10: softmax_xent(logits, y)

  1. 1F.cross_entropy(logits, y) taking raw logits + integer labels softmax_xent(logits, y) consuming the same raw logits and class indices
  2. 2the internal LogSoftmax PyTorch applies z = logits - logits.max(...) then probs = exp_z / exp_z.sum(...)
  3. 3the negative-log-likelihood reduction (mean by default) loss = -np.log(probs[arange(n), y] + 1e-12).mean()
  4. 4loss.backward()'s gradient w.r.t. logits grad = probs; grad[arange(n), y] -= 1; grad /= n (i.e. (p - y)/n)
  5. 5PyTorch's built-in numerical stability (log-sum-exp) the max-subtraction shift before exp, avoiding overflow
What the one call hides
  • Softmax and log are fused into one numerically stable log-sum-exp; you never see exp of large logits or log of a tiny probability.
  • The max-subtraction trick for overflow safety is done internally.
  • Default reduction='mean' (divide by n); switching to 'sum' or 'none' changes the gradient scale.
  • It expects raw logits, not probabilities — it applies softmax itself, so passing softmaxed inputs double-counts.
  • y is class indices, not one-hot; PyTorch indexes the true-class log-prob for you (and supports label smoothing / class weights as options).
  • Gotcha: Feeding F.cross_entropy a softmax/probability tensor instead of logits is the classic silent accuracy killer.
  • Gotcha: y must be int64 class indices (shape (n,)), not one-hot floats, or you get a shape/type error or wrong loss.
  • Gotcha: Default mean reduction means the gradient is already divided by batch size — don't divide again in your optimizer.
  • Gotcha: Adding a manual nn.Softmax before nn.CrossEntropyLoss applies softmax twice; use raw logits with cross_entropy.

Prefer F.cross_entropy on logits in production for its numerical stability; implement softmax_xent once to internalize the (probs - one_hot(y))/n identity that makes the backward pass 'predicted minus actual'.

On the job: You pick the loss and feed raw logits + int labels; you only hand-write the softmax+CE math when you need a custom variant (label smoothing, focal, masked tokens).

That is the entire algorithm. The from-scratch loss returns both the scalar and the (probs - one_hot(y)) / n gradient; PyTorch's F.cross_entropy(logits, y) followed by loss.backward is doing a generalized version of the same computation for whatever graph the produced — the full backward through Z2 = H @ W2 + b2, H = relu(Z1), Z1 = X @ W1 + b1 is just the matmul and gradient rules above composed in reverse.

FIG 09.3.8

Why deep networks were hard before 2010

For about two decades after backprop was popularized (Rumelhart, Hinton, Williams 1986), training deep networks did not work. Networks deeper than two or three hidden layers either failed to learn, learned very slowly, or got stuck in regions where the loss looked flat. Three things were going wrong simultaneously, and they only got sorted out in the late 2000s.

Vanishing gradients. With or tanh activations, the at any layer is the product of the local derivatives at all downstream layers. Sigmoid's derivative is bounded by 0.250.25. Ten layers in, the gradient at the input is at most 0.25101060.25^{10} \approx 10^{-6}. Learning never propagates back that far. The fix was switching to , whose derivative is exactly 1 on the active side.

Bad initialization. If weights are drawn from a normal with too-large variance, the pre-activations z=Wx+bz = Wx + b blow up, the sigmoid saturates, the gradient vanishes. Too-small variance and the activations collapse to zero. Glorot and Bengio (2010) derived the right scale: Var(W)=2/(fan_in+fan_out)\text{Var}(W) = 2 / (\text{fan\_in} + \text{fan\_out}), now called Xavier or Glorot initialization. He et al. (2015) refined it for ReLU: Var(W)=2/fan_in\text{Var}(W) = 2 / \text{fan\_in}, called Kaiming or He initialization. Modern frameworks do this by default; in 2008 nobody did.

Optimizers and learning rates. Plain SGD with a fixed gets stuck. helps. Adaptive methods (RMSprop, Adam) help more. Learning rate was a 2017 invention. In the late 2000s, every paper had to hand-tune all of this, and most papers reported numbers that did not reproduce.

What unblocked deep networks was the combination: ReLU + good init + Adam + lots of data + GPUs. AlexNet (2012) had all five. The chapter on training (another chapter) walks through each in detail; for now, the takeaway is that "deep networks didn't train" was a true statement in 2005 and a false statement in 2015, and the difference is not philosophical, it is mechanical.

FIG 09.3.9

MNIST as the canonical first model

MNIST is 70,000 grayscale images of handwritten digits, 28×28 pixels each, labeled 0 through 9. 60,000 are training, 10,000 are test. It has been a benchmark since 1998. Modern models reaanother chapter.7% test . A 2-layer MLP with 128 hidden units, trained for one — one full pass through the entire , minibatch by minibatch — on a free Colab GPU, reaches about 97.5%. This is the "hello world" that every framework demos with.

An MLP wants a flat vector, not a grid, so eaanother chapter×28 image is flattened row by row into a single vector of 28×28=78428 \times 28 = 784 pixel values (that is the nn.Flatten in the model and the 784 the network's first layer expects). The reason MNIST is the right first model: the input is small enough (784 features) that you can fit a working MLP in memory on any laptop, the classes are clean (digits, not "is this a cat or a dog with a fence in front"), and the dataset is balanced. You can debug your code without confounding it with messy data. Once you can train MNIST, the same code generalizes to Fashion-MNIST, CIFAR-10, and (with convolutions in another chapter) ImageNet.

Library path (the canonical 30 lines, ready to copy into Colab):

Python
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = "cuda" if torch.cuda.is_available() else "cpu"

tfm = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))])
train_ds = datasets.MNIST("./data", train=True, download=True, transform=tfm)
test_ds = datasets.MNIST("./data", train=False, download=True, transform=tfm)
train_dl = DataLoader(train_ds, batch_size=128, shuffle=True)
test_dl = DataLoader(test_ds, batch_size=512)

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(784, 128), nn.ReLU(),
            nn.Linear(128, 64), nn.ReLU(),
            nn.Linear(64, 10),
        )
    def forward(self, x): return self.net(x)

model = MLP().to(device)
opt = torch.optim.Adam(model.parameters(), lr=3e-4)

for epoch in range(3):
    model.train()
    for x, y in train_dl:
        x, y = x.to(device), y.to(device)
        loss = F.cross_entropy(model(x), y)
        opt.zero_grad(); loss.backward(); opt.step()

    model.eval()
    correct = 0
    with torch.no_grad():
        for x, y in test_dl:
            x, y = x.to(device), y.to(device)
            correct += (model(x).argmax(1) == y).sum().item()
    print(f"epoch {epoch}: test acc = {correct / len(test_ds):.4f}")

After 3 epochs you should see test accuracy around 0.977. If you see anything below 0.95, something is wrong; check the normalization stats, the (it must be cross_entropy on logits, not on softmaxed probabilities), and that you are calling opt.zero_grad before each backward pass.

FIG 09.3.10

A 50-line MLP in pure NumPy

The point of doing it from scratch is not to write production code. The point is to convince yourself that the library is not doing anything you cannot do. Here is a complete two-layer MLP trained on MNIST in pure NumPy, with forward, backward, SGD, and evaluation. It hits roughly 95% test accuracy in a minute on a laptop CPU.

Python
import numpy as np

def relu(x): return np.maximum(0, x)

def softmax_xent(logits, y):
    z = logits - logits.max(axis=1, keepdims=True)
    exp_z = np.exp(z)
    probs = exp_z / exp_z.sum(axis=1, keepdims=True)
    n = logits.shape[0]
    loss = -np.log(probs[np.arange(n), y] + 1e-12).mean()
    grad = probs.copy()
    grad[np.arange(n), y] -= 1
    grad /= n
    return loss, grad

class MLP:
    def __init__(self, d_in=784, d_hid=128, d_out=10, seed=0):
        rng = np.random.default_rng(seed)
        # Kaiming init for ReLU
        self.W1 = rng.standard_normal((d_in, d_hid)) * np.sqrt(2 / d_in)
        self.b1 = np.zeros(d_hid)
        self.W2 = rng.standard_normal((d_hid, d_out)) * np.sqrt(2 / d_hid)
        self.b2 = np.zeros(d_out)

    def forward(self, X):
        Z1 = X @ self.W1 + self.b1
        H = relu(Z1)
        Z2 = H @ self.W2 + self.b2
        self._cache = (X, Z1, H)
        return Z2

    def backward(self, grad_logits):
        X, Z1, H = self._cache
        dW2 = H.T @ grad_logits
        db2 = grad_logits.sum(0)
        dH = grad_logits @ self.W2.T
        dZ1 = dH * (Z1 > 0)
        dW1 = X.T @ dZ1
        db1 = dZ1.sum(0)
        return {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2}

    def step(self, grads, lr=0.1):
        self.W1 -= lr * grads['W1']; self.b1 -= lr * grads['b1']
        self.W2 -= lr * grads['W2']; self.b2 -= lr * grads['b2']

def train(model, X_train, y_train, X_test, y_test, epochs=5, bs=128, lr=0.1):
    n = X_train.shape[0]
    for epoch in range(epochs):
        perm = np.random.permutation(n)
        for i in range(0, n, bs):
            idx = perm[i:i+bs]
            logits = model.forward(X_train[idx])
            loss, grad = softmax_xent(logits, y_train[idx])
            grads = model.backward(grad)
            model.step(grads, lr=lr)
        acc = (model.forward(X_test).argmax(1) == y_test).mean()
        print(f"epoch {epoch}: test acc = {acc:.4f}")

That is 52 lines including blank lines. It trains. You can read every operation. No nn.Module, no optimizer.step, no .backward mystery. The only thing it depends on is NumPy.

FIG 09.3.11

Diagnosing the three common failures

A neural network that fails to train can fail in many ways. Three account for ≈90% of beginner debugging time.

Dead ReLUs. A neuron whose pre-activation z=Wx+bz = Wx + b is always negative outputs zero, has zero, and never updates. With He initialization and reasonable learning rates, this is rare. With a too high (so the first big step pushes weights into the negative region) or with poor init, half your neurons can die in the first . The diagnostic: after a few hundred steps, log the fraction of hidden units that are non-zero on a fixed . If it falls below ~50%, you have dead ReLUs. The fix: lower the learning rate, use Kaiming init, or use Leaky / GELU.

Vanishing or exploding gradients. Log the gradient norm at each layer for the first 100 steps. If the gradient at the input layer is 1000× smaller than at the output layer, gradients are vanishing. If the loss spikes to NaN, gradients exploded. The fixes: better init, , switching activations, or using LayerNorm (another chapter).

Loss flat at logK\log K, then explodes. A model with KK classes that has not learned anything outputs uniform predictions, giving loss logK2.30\log K \approx 2.30 for MNIST. If your loss sits there for hundreds of steps, your gradients are not reaching the early layers, or your learning rate is too small, or your data is wrong (mislabeled, all-zero, normalized incorrectly). Karpathy's recipe: verify loss at init. The very first loss before any training step should be logK\log K for a balanced classifier with sensible initialization. If it's anything else, debug before you train.

These three diagnostics catch the vast majority of "my model is not training" situations. Build them into every training script you write.


FIG 09.4 · Safety lens · this chapter

What goes wrong with the techniques in this chapter? Three failure modes that map onto specific present-day attack surface and interp findings, not speculation.

The "trust me, the gradients descended" problem. When a model trains and the loss goes down, you have evidence that the optimizer worked, not that the model learned what you wanted. A network can reaanother chapter% on a benchmark by memorizing surface features (texture, watermark, pixel statistics) that have nothing to do with the labeled concept. Geirhos et al. (2018) showed that ImageNet-trained CNNs classify images by texture, not by shape, despite training labels and human intuition both pointing at shape. The MNIST MLP you build in this chapter has exactly the same risk on a smaller scale: if there is any spurious correlation between pixel position and digit label, the model will exploit it. The fix is not interpretability of weights (small MLPs are not magically interpretable), it is adversarial evaluation: test on out-of-distribution shifts, on rotated digits, on adversarial perturbations. See 18-lilian-weng/2024-11-28-reward-hacking §spurious-correlation and 05-safety/nanda-mech-interp-glossary §spurious-features for the framing.

The backprop-as-attack-vector problem. is differentiable end-to-end. That is wonderful for training and bad for adversarial robustness. The same backprop that gives L/θ\partial L / \partial \theta for the weights can instead give L/x\partial L / \partial x — the of the loss with respect to the input pixels themselves — at time, producing imperceptible perturbations that flip the model's prediction. Goodfellow et al. (2015) showed this with the Fast Gradient Sign Method (FGSM, which nudges every pixel by a tiny step along the sign of that input gradient) in a few lines of code; every model in this chapter is vulnerable. The diagnostic is to compute L/x\partial L / \partial x for a few test examples and see how small a step in that direction crosses the decision boundary. For MNIST, it is often <0.01 of an image's pixel range. Defenses are unsatisfying (adversarial training helps but does not solve it). The safety-relevant point is that differentiability is a property of your model that attackers can use too. See 24-founder-blogs/karpathy-breaking-convnets and 22-anthropic-recent/2024-scaling-monosemanticity §adversarial-features.

The "loss went down so I shipped it" problem. Karpathy's recipe makes the point explicitly: neural net training fails silently. A model that has a bug in the data pipeline (labels swapped, normalization wrong, augmentation flipping labels) can still train; the loss will still go down; the test accuracy will look reasonable; and the model will be quietly wrong in production. The habits to adopt before you write a production training script: fix a random , overfit one first, verify loss at init, and visualize the data immediately before it enters the model. None of these is glamorous; all of them catch real bugs. See 24-founder-blogs/karpathy-recipe (the whole thing) and 05-safety/nanda-mech-interp-glossary for why "looks right" is not "is right".

What habits to adopt from now on:

  • Verify the loss at init is logK\log K for KK-class classification. A 30-second check that catches an entire class of bugs.
  • Overfit a 4-example batch before up. If your model cannot drive loss to zero on four examples, it cannot drive it down on 60,000.
  • Always look at the data right before it enters the model. Print one batch, plot one image, sanity check one label. The most common bug in this chapter is "the normalization broke the images and the model is learning random noise".

FIG 09.5 · Under the hood

The library call, and the lines it hides

You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.

torch.autograd vs. from scratch

DL primitive
LIBRARY
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-3.0, requires_grad=True)
c = torch.tensor(10.0, requires_grad=True)
y = (a * b + c) ** 2
y.backward()          # populates a.grad, b.grad, c.grad
print(a.grad, b.grad, c.grad)
FROM SCRATCH
class Value:
    def __init__(self, data, _children=(), _op=''):
        self.data = float(data)
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad += other.data * out.grad   # d(a*b)/da = b
            other.grad += self.data * out.grad   # d(a*b)/db = a
        out._backward = _backward
        return out

    def backward(self):
        topo, visited = [], set()
        def build(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build(child)
                topo.append(v)
        build(self)
        self.grad = 1.0
        for v in reversed(topo):
            v._backward()

from scratch: lab/solution.py: class Value (__add__, __mul__, __pow__, relu, backward)

  1. 1requires_grad=True on a leaf tensor wrapping a number in Value(...) so it carries a .grad slot and joins the graph
  2. 2the implicit graph PyTorch records as you do a*b+c each op (__add__/__mul__/__pow__) returns a new Value with _children and a _backward closure
  3. 3y.backward() seeding dY/dY = 1 at the root backward() sets self.grad = 1.0 before walking the graph
  4. 4PyTorch's reverse-topological traversal of the autograd graph build() does a DFS topo sort, then `for v in reversed(topo): v._backward()`
  5. 5gradient accumulation when a tensor feeds multiple ops every _backward uses += (self.grad += ...), summing contributions from all downstream uses
  6. 6per-op gradient formulas baked into autograd (Mul, Pow, Relu) the hand-written local derivatives: other.data*out.grad for mul, (other*x**(other-1)) for pow, (out.data>0) for relu
What the one call hides
  • The topological sort and DAG bookkeeping — PyTorch builds and orders the graph for you on every forward call.
  • Gradient accumulation semantics: .grad keeps accumulating across .backward() calls unless you zero it, exactly like the += in _backward.
  • Tensor/broadcasting generalization: PyTorch runs the same chain rule on whole arrays at once, not one scalar at a time.
  • The graph is freed after backward() by default (retain_graph=False); the scratch version keeps Python references alive.
  • no_grad / requires_grad=False contexts that detach tensors so no _backward closure is recorded.
  • Gotcha: .grad accumulates by default — forgetting to zero it (the += behavior) gives stale/summed gradients across steps.
  • Gotcha: Calling .backward() twice on the same graph errors unless retain_graph=True, because the graph was freed.
  • Gotcha: Only leaf tensors with requires_grad=True get a .grad; intermediate results don't unless you call .retain_grad().
  • Gotcha: In-place ops on tensors needed for backward raise a runtime error — the scratch engine has no such guard.

Prefer torch.autograd on the job; the from-scratch Value engine exists only to prove that .backward() is a topological walk applying local chain-rule rules with accumulation, nothing magical.

On the job: You write the forward pass and call .backward(); you essentially never hand-roll an autodiff engine, but you do debug grad flow (None grads, detached tensors, zero_grad bugs).

optim.SGD step / training loop vs. from scratch

DL glue
LIBRARY
opt = optim.SGD(model.parameters(), lr=0.05)   # plain SGD: p <- p - lr*p.grad
# per step:
opt.zero_grad()
loss = F.cross_entropy(model(x), y)
loss.backward()
opt.step()
FROM SCRATCH
def train_step(model, xs, ys, lr=0.01):
    scores = [model(x) for x in xs]
    losses = [(1 + -yi * si).relu() for yi, si in zip(ys, scores)]  # hinge loss
    total = sum(losses, start=Value(0.0))
    model.zero_grad()        # reset every p.grad to 0.0
    total.backward()         # accumulate gradients
    for p in model.parameters():
        p.data -= lr * p.grad   # the SGD update
    return total.data

from scratch: lab/solution.py: train_step

  1. 1optim.SGD(model.parameters(), lr=...) the loop `for p in model.parameters(): p.data -= lr * p.grad`
  2. 2opt.zero_grad() model.zero_grad() setting each p.grad = 0.0 before the backward pass
  3. 3loss.backward() total.backward() walking the graph to fill p.grad
  4. 4opt.step() reading .grad and writing the new parameter p.data -= lr * p.grad (update .data, not the Value object)
  5. 5the lr hyperparameter the lr argument multiplying p.grad in the update
What the one call hides
  • Vanilla SGD is the simplest case; optim.SGD also offers momentum, dampening, Nesterov, and weight_decay (L2) that default to off but are one kwarg away.
  • opt.step() updates parameters in-place under no_grad so the update itself isn't tracked by autograd.
  • Why zero_grad is mandatory: PyTorch accumulates .grad across backward calls, exactly like the += in the engine.
  • The optimizer holds its own per-parameter state (none for plain SGD, but buffers for momentum/Adam) hidden from you.
  • Operating on a list of named parameter groups, each with its own lr/weight_decay.
  • Gotcha: Forgetting opt.zero_grad() makes gradients accumulate across steps, silently inflating the effective learning rate.
  • Gotcha: Updating p (the wrapper) instead of p.data — `p -= lr*p.grad` builds a new node and loses the parameter; in torch, arithmetic outside no_grad pollutes the graph.
  • Gotcha: Plain SGD has no momentum/weight decay by default; beginners assume optim.SGD includes them and under-regularize.
  • Gotcha: Calling opt.step() before loss.backward() applies stale or zero gradients.

In production reach for optim.SGD/Adam for momentum, weight decay, and per-param state; write the `p.data -= lr * p.grad` loop once to see that an optimizer step is one line of arithmetic per parameter and that zero_grad exists only because gradients accumulate.

On the job: You DO write the training loop by hand at work (zero_grad -> forward -> loss -> backward -> step, plus logging/clipping/scheduling); you just call the optimizer rather than coding the update math.



FIG 09.7 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A scalar autograd engine (Value with _backward closures and a topological sort), checked element-for-element against torch.autograd.
  • Neuron -> Layer -> MLP stacked on that engine, trained on a 4-point toy until its loss collapses.
  • A forward and backward pass for a 2-layer MLP in pure NumPy, every gradient verified by finite differences, then trained on a FashionMNIST subset to a real accuracy.
  • A working version of the canonical micrograd bug (gradients that overwrite instead of accumulate), experienced and then fixed.

~4 min on CPU · 98 cells · 12 checked exercises · runs in Colab


FIG 09.8 · Going further

  • 12-karpathy-code/micrograd_lecture_first_half_roughly and _second_half_roughly — Karpathy's two-hour YouTube walkthrough of micrograd, transcribed. The single best resource on autograd intuition in existence.
  • 14-arena-notebooks/chapter0-part4-backprop

    ARENA's 113KB notebook on backprop at the tensor level. Picks up where micrograd leaves off.

  • 13-fastbook/04_mnist_basics

    Howard's chapter on building a digit classifier from foundations. Pairs well with this chapter as a second pass.

  • 01-explorables/distill-momentum

    interactive explorable on why momentum helps gradient descent. Pre-reading for another chapter.

  • 09-udl-book/UDL-Answer-Booklet

    Prince's Understanding Deep Learning (free PDF), the most modern textbook treatment of the basics.

  • 16-d2l-sections/chapter_multilayer-perceptrons__mlp-implementation

    D2L's hands-on MLP-from-scratch implementation; useful as a second view of the same content in this chapter.

  • 08-geron-notebooks/10_neural_nets_with_keras

    Géron's bridging chapter from classical ML to neural nets, in Keras. Read after this chapter if you need a third pass.


FIG 09.9 · What this enables

Chapters you can now read, with the connecting idea written out.

  • You now understand what .backward is doing under the hood, so when PyTorch's API hides it, you trust it. The next chapter is what nn.Module adds on top of the micrograd worldview.

  • You have seen the dead ReLU problem, the vanishing gradient problem, the initialization sensitivity. another chapter fixes all three properly.

  • An MLP for images is a baseline. CNNs replace nn.Linear with nn.Conv2d and add spatial structure, but the training loop and backward pass are identical.


FIG 09.10 · 24 sources
  1. 01-explorables/distill-momentum
  2. 05-safety/nanda-mech-interp-glossary
  3. 08-geron-notebooks/10_neural_nets_with_keras
  4. 08-geron-notebooks/11_training_deep_neural_networks
  5. 08-geron-notebooks/extra_autodiff
  6. 09-udl-book/UDL-Answer-Booklet
  7. 12-karpathy-code/micrograd-master-README
  8. 12-karpathy-code/micrograd-master-micrograd-engine
  9. 12-karpathy-code/micrograd-master-micrograd-nn
  10. 12-karpathy-code/micrograd_lecture_first_half_roughly
  11. 12-karpathy-code/micrograd_lecture_second_half_roughly
  12. 13-fastbook/04_mnist_basics
  13. 14-arena-notebooks/chapter0-part2-cnns
  14. 14-arena-notebooks/chapter0-part4-backprop
  15. 16-d2l-sections/chapter_multilayer-perceptrons__mlp
  16. 16-d2l-sections/chapter_multilayer-perceptrons__mlp-implementation
  17. 16-d2l-sections/chapter_multilayer-perceptrons__backprop
  18. 16-d2l-sections/chapter_multilayer-perceptrons__numerical-stability-and-init
  19. 18-lilian-weng/2017-08-01-interpretation
  20. 18-lilian-weng/2024-11-28-reward-hacking
  21. 22-anthropic-recent/2024-scaling-monosemanticity
  22. 24-founder-blogs/karpathy-breaking-convnets
  23. 24-founder-blogs/karpathy-recipe
  24. 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-quickstart-tutorial-html