Ch. 09
Intro to Neural Networks
Backprop derived by hand, a 50-line MLP in pure NumPy, the activation-function evolution.
A neural network is a sandwich of matrix multiplies with An operation done to each number on its own, not by combining them together.Full glossary → 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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → of a loss with respect to every entry in every matrix without writing the chain rule by hand. A calculation that works backward from the mistake to figure out how much each weight and bias was to blame for it.Full glossary → answers the third question with one trick: build the Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → 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 (
Valuewith_backwardclosures and a topological sort) in 80 lines of pure Python, and passtorch.allclosetests against PyTorch. - Stack
Neuron→Layer→MLPon top of that engine, the way Karpathy'smicrograd.nndoes, 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 prereqs — partial derivatives, the chain rule, and NumPy broadcasting. Without these the backward pass looks like alphabet soup.
- Ch 4 — Training Models — gradient descent on linear and logistic regression, plus the cross-entropy loss for classification. This chapter generalizes both.
- Ch 3 — Classification — you 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 , computes for some A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → vector and A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → , then passes through a non-linearity to produce an output . With a threshold and a binary label, this is Rosenblatt's 1958 perceptron. With a logistic A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary →, it is logistic regression. Either way, the decision boundary 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):
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):
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 affine transformations with An operation done to each number on its own, not by combining them together.Full glossary → non-linearities between them. For one hidden layer with hidden units, mapping :
Here is a minibatch of examples, and are A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → matrices, and are biases. is the A rule that bends the model's numbers at certain points so stacked layers can together trace a curve instead of just a straight line.Full glossary → applied element-wise. is the hidden representation.
The non-linearity is load-bearing. Drop it, and for , which is a single affine layer. Two boring layers compose to one boring layer. Add 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 The share of guesses the model got right out of all its guesses.Full glossary →. 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 The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. In practice, deep networks (many narrow layers) outperform wide networks (one fat hidden layer) at the same One of the model's internal numbers that gets adjusted as it learns.Full glossary → budget, for reasons that are still partly mysterious and partly explained by A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary →.
The PyTorch pieces you will see below — nn.Module (the base class every model subclasses), nn.Linear (one affine layer ), 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 primitivemodel = nn.Sequential(
nn.Linear(2, 4), nn.ReLU(),
nn.Linear(4, 4), nn.ReLU(),
nn.Linear(4, 1), # last layer linear, no activation
)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 xfrom scratch: lab/solution.py: class Neuron, class Layer, class MLP
- 1
nn.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
one neuron's weight row in nn.Linear.weight + its bias entryNeuron.w (list of Value) and Neuron.b (Value) - 3
nn.ReLU() between linear layersact.relu() applied when nonlin=True - 4
last nn.Linear with no following activationnonlin=(i < len(n_outs)-1) makes only the final layer linear - 5
nn.Sequential forwarding x through each module in orderMLP.__call__ loops `for layer in self.layers: x = layer(x)` - 6
model.parameters() collecting all weights/biases for the optimizerMLP.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 A rule that bends the model's numbers at certain points so stacked layers can together trace a curve instead of just a straight line.Full glossary →. Five of them matter historically.
A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → () was the original. It is bounded between 0 and 1, smooth, and easy to interpret as a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →. Its A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → is , which is at most at and approaches for . Stack a few sigmoid layers and the gradient at the input is the product of these small numbers. This is the When the learning signal fades to almost nothing as it travels back through a deep model, so the early layers barely change.Full glossary → problem; it is why pre-2010 deep networks did not train.
tanh () is sigmoid shifted to be zero-centered, in . Better than sigmoid because activations are zero-mean, but still saturates and still vanishes.
The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → () was the breakthrough (Nair and Hinton 2010, AlexNet 2012). It is not bounded. Its gradient is exactly 1 for and exactly 0 for . No When part of a model stops reacting to input because its output is already pushed to a hard limit.Full glossary → on the positive side, no vanishing for active neurons. Cheap to compute. The cost is the A neuron that always outputs zero no matter the input, so it stops contributing anything.Full glossary → 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 (, where is the standard Gaussian cumulative distribution function — the probability that a standard-normal draw lands below , 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 (, where SiLU — also called Swish — is ) is the gated variant used in Llama, PaLM, and most 2023+ transformers. It is two parallel projections multiplied An operation done to each number on its own, not by combining them together.Full glossary →, with one of them gated by a sigmoid-like function. The math says it has more expressive capacity per One of the model's internal numbers that gets adjusted as it learns.Full glossary → 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 A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → mechanism; you do not need them yet, and another chapter builds one from scratch.
Library path (each one is a one-liner in PyTorch):
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 SwiGLUFrom-scratch path (NumPy, with derivatives — you will need these in section 5):
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 An operation done to each number on its own, not by combining them together.Full glossary → non-linearity, one more matmul, per layer pair. The shapes are the part you have to track.
For a 2-layer MLP, , hidden dim , output dim :
| Step | Operation | Shape |
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| 4 |
The A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → has shape and broadcasts across the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → dimension. The A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → in step 4 is across the class axis: each row of is a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution over classes. The pre-softmax outputs — 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 Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → wrong. The hard direction is the backward pass, and to do that, you need to remember what , , and were. The forward pass caches its intermediate activations precisely so the backward pass can use them.
Library path:
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 logitsFrom-scratch path (with explicit cache for the backward pass):
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 backwardFIG 09.3.5
The chain rule and computational graphs
A neural network is a function where is the model, is the loss, and are the parameters. Training means minimizing by The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →: . The hard part is computing when is a 10-layer chain of matrix multiplies and non-linearities.
A calculation that works backward from the mistake to figure out how much each weight and bias was to blame for it.Full glossary → 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 Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → 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 with downstream A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → :
For an addition :
For The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → :
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.
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 where and :
- , shape
- , shape
For broadcasting (a A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → added to ):
- , summed over the broadcast axis
For A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →-A loss that measures how far a model's predicted chances are from the true answer.Full glossary →, the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → with respect to the pre-softmax logits is famously clean. If and for one-hot :
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 primitivelogits = 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)) / ndef 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, gradfrom scratch: draft.md §10: softmax_xent(logits, y)
- 1
F.cross_entropy(logits, y) taking raw logits + integer labelssoftmax_xent(logits, y) consuming the same raw logits and class indices - 2
the internal LogSoftmax PyTorch appliesz = logits - logits.max(...) then probs = exp_z / exp_z.sum(...) - 3
the negative-log-likelihood reduction (mean by default)loss = -np.log(probs[arange(n), y] + 1e-12).mean() - 4
loss.backward()'s gradient w.r.t. logitsgrad = probs; grad[arange(n), y] -= 1; grad /= n (i.e. (p - y)/n) - 5
PyTorch'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 Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary → produced — the full backward through Z2 = H @ W2 + b2, H = relu(Z1), Z1 = X @ W1 + b1 is just the matmul and The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → 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 A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → or tanh activations, the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → at any layer is the product of the local derivatives at all downstream layers. Sigmoid's derivative is bounded by . Ten layers in, the gradient at the input is at most . Learning never propagates back that far. The fix was switching to The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary →, 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 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: , now called Xavier or Glorot initialization. He et al. (2015) refined it for ReLU: , called Kaiming or He initialization. Modern frameworks do this by default; in 2008 nobody did.
Optimizers and learning rates. Plain SGD with a fixed How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → gets stuck. Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary → helps. Adaptive methods (RMSprop, Adam) help more. Learning rate Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → 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 The share of guesses the model got right out of all its guesses.Full glossary →. A 2-layer MLP with 128 hidden units, trained for one One full trip through every example in your training set.Full glossary → — one full pass through the entire The batch of examples the model actually studies and learns from.Full glossary →, 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 One piece of information about an example that the model looks at when making a guess.Full glossary → vector, not a grid, so eaanother chapter×28 image is flattened row by row into a single vector of 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):
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 A rule that turns one bad guess into a single number measuring how far off it was, where lower is better.Full glossary → (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 The share of guesses the model got right out of all its guesses.Full glossary → evaluation. It hits roughly 95% test accuracy in a minute on a laptop CPU.
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 is always negative outputs zero, has A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → zero, and never updates. With He initialization and reasonable learning rates, this is rare. With a How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → 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 One full trip through every example in your training set.Full glossary →. The diagnostic: after a few hundred steps, log the fraction of hidden units that are non-zero on a fixed A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary →. If it falls below ~50%, you have dead ReLUs. The fix: lower the learning rate, use Kaiming init, or use Leaky The most common bend in neural networks: it keeps positive numbers as they are and turns any negative number into zero.Full glossary → / 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, Putting a cap on how big a single training adjustment can be so one wild step doesn't wreck progress.Full glossary →, switching activations, or using LayerNorm (another chapter).
Loss flat at , then explodes. A model with classes that has not learned anything outputs uniform predictions, giving loss 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 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% The share of guesses the model got right out of all its guesses.Full glossary → 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. A calculation that works backward from the mistake to figure out how much each weight and bias was to blame for it.Full glossary → is differentiable end-to-end. That is wonderful for training and bad for adversarial robustness. The same backprop that gives for the weights can instead give — the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → of the loss with respect to the input pixels themselves — at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → 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 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 A starting number that makes a program's 'random' choices come out the same every time.Full glossary →, overfit one A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → 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 for -class classification. A 30-second check that catches an entire class of bugs.
- Overfit a 4-example batch before Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → 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 primitivea = 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)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
requires_grad=True on a leaf tensorwrapping a number in Value(...) so it carries a .grad slot and joins the graph - 2
the implicit graph PyTorch records as you do a*b+ceach op (__add__/__mul__/__pow__) returns a new Value with _children and a _backward closure - 3
y.backward() seeding dY/dY = 1 at the rootbackward() sets self.grad = 1.0 before walking the graph - 4
PyTorch's reverse-topological traversal of the autograd graphbuild() does a DFS topo sort, then `for v in reversed(topo): v._backward()` - 5
gradient accumulation when a tensor feeds multiple opsevery _backward uses += (self.grad += ...), summing contributions from all downstream uses - 6
per-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 glueopt = 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()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.datafrom scratch: lab/solution.py: train_step
- 1
optim.SGD(model.parameters(), lr=...)the loop `for p in model.parameters(): p.data -= lr * p.grad` - 2
opt.zero_grad()model.zero_grad() setting each p.grad = 0.0 before the backward pass - 3
loss.backward()total.backward() walking the graph to fill p.grad - 4
opt.step() reading .grad and writing the new parameterp.data -= lr * p.grad (update .data, not the Value object) - 5
the lr hyperparameterthe 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_roughlyand_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-backpropARENA's 113KB notebook on backprop at the tensor level. Picks up where micrograd leaves off.
13-fastbook/04_mnist_basicsHoward's chapter on building a digit classifier from foundations. Pairs well with this chapter as a second pass.
01-explorables/distill-momentuminteractive explorable on why momentum helps gradient descent. Pre-reading for another chapter.
09-udl-book/UDL-Answer-BookletPrince's Understanding Deep Learning (free PDF), the most modern textbook treatment of the basics.
16-d2l-sections/chapter_multilayer-perceptrons__mlp-implementationD2L'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_kerasGé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
.backwardis doing under the hood, so when PyTorch's API hides it, you trust it. The next chapter is whatnn.Moduleadds 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.Linearwithnn.Conv2dand add spatial structure, but the training loop and backward pass are identical.
FIG 09.10 · 24 sources
- 01-explorables/distill-momentum
- 05-safety/nanda-mech-interp-glossary
- 08-geron-notebooks/10_neural_nets_with_keras
- 08-geron-notebooks/11_training_deep_neural_networks
- 08-geron-notebooks/extra_autodiff
- 09-udl-book/UDL-Answer-Booklet
- 12-karpathy-code/micrograd-master-README
- 12-karpathy-code/micrograd-master-micrograd-engine
- 12-karpathy-code/micrograd-master-micrograd-nn
- 12-karpathy-code/micrograd_lecture_first_half_roughly
- 12-karpathy-code/micrograd_lecture_second_half_roughly
- 13-fastbook/04_mnist_basics
- 14-arena-notebooks/chapter0-part2-cnns
- 14-arena-notebooks/chapter0-part4-backprop
- 16-d2l-sections/chapter_multilayer-perceptrons__mlp
- 16-d2l-sections/chapter_multilayer-perceptrons__mlp-implementation
- 16-d2l-sections/chapter_multilayer-perceptrons__backprop
- 16-d2l-sections/chapter_multilayer-perceptrons__numerical-stability-and-init
- 18-lilian-weng/2017-08-01-interpretation
- 18-lilian-weng/2024-11-28-reward-hacking
- 22-anthropic-recent/2024-scaling-monosemanticity
- 24-founder-blogs/karpathy-breaking-convnets
- 24-founder-blogs/karpathy-recipe
- 27-framework-docs/pytorch-pytorch-org-tutorials-beginner-basics-quickstart-tutorial-html