S097 · Multi-Layer Perceptron — Forward Pass
Stack neurons into layers and you get an MLP — the universal function approximator that powers everything from tabular Kaggle solutions to the feed-forward blocks inside GPT. Learn the matrix form of a layer, the forward pass as one big product, and how to think about hidden dimensions before you touch PyTorch.
🎯 Write the forward pass of a 2-hidden-layer MLP as matrix multiplications, then implement it in ~40 lines of numpy that classifies MNIST at 96%+ accuracy without any framework.
Why this session exists
An MLP (multi-layer perceptron) is not just a stepping stone to CNNs and transformers — it's the workhorse buried inside every modern architecture. Every transformer block has an MLP; every ResNet block has an MLP-ish 1×1 conv; every recommender's tower is an MLP. Understanding the forward pass at the matrix level (not "input goes in, output comes out") is the difference between someone who can call nn.Linear and someone who can debug why their model isn't training. This session gives you that difference.
- Write the forward pass of any MLP as a sequence of matrix multiplications and activations.
- Reason about layer widths and depths — how many parameters your network has and where they live.
- Explain the universal approximation theorem in one sentence, and its practical limits in another.
- Implement a 3-layer MLP in numpy that hits 96%+ on MNIST from scratch — no PyTorch.
- Recognise MLPs where they hide inside transformers, ResNets, and recommender systems.
Prerequisites
- S096 · Perceptron & Activation Functions — you know one neuron.
- S050 · NumPy fundamentals — matmul, broadcasting, axis semantics.
- S092 · Evaluation metrics — you can score a classifier.
(a) Intuition · 5 min
A one-committee decision (perceptron) can decide "should we approve this loan?" but only using directly-observable factors like income, debt, and credit score, combined linearly.
Now suppose we add a middle committee. The first committee looks at raw data and outputs concepts: "financial stability" (0-1 score), "employment reliability", "debt trend". The second committee looks at THESE concepts and makes the final decision.
The middle committee's job is not to make the decision — it's to invent useful features. That's what a hidden layer does.
An MLP is exactly this: raw inputs → hidden layer that computes learned features → output layer that combines those features into a decision. The hidden layer isn't given the features by a human; it discovers them via gradient descent.
Stack more hidden layers and each subsequent layer works with more abstract concepts. In an image classifier, layer 1 detects edges, layer 3 detects textures, layer 6 detects eyes and wheels, layer 10 detects "cat" and "car". No human wrote those feature detectors — they emerged.
The forward pass in one paragraph
- Input x has shape (batch_size, input_dim). Every row is one example.
- Layer 1: z1 = x @ W1 + b1, then a1 = φ(z1). W1 has shape (input_dim, hidden_1); a1 has shape (batch_size, hidden_1).
- Layer 2: z2 = a1 @ W2 + b2, then a2 = φ(z2). W2 has shape (hidden_1, hidden_2); a2 has shape (batch_size, hidden_2).
- Output: logits = a2 @ W3 + b3, shape (batch_size, num_classes). Softmax if classification, no activation if regression.
- That's it. Every MLP forward pass in every framework in every codebase. The only thing that changes is the width, depth, and choice of φ.
The universal approximation theorem — and its practical trap
- 1989Hornik / Cybenko · UATA single hidden layer of enough neurons can approximate any continuous function on a compact domain. Deep learning is theoretically possible.
- 1991Hornik · UAT for general activationsAny non-constant, bounded, monotonically-increasing activation works — sigmoid, tanh, later ReLU.
- 1998LeCun · CNN on MNISTShows MLPs are wasteful for images — CNN's shared weights + locality bias train faster.
- 2012AlexNet + GPU + ReLUDeep MLP + convolutions + ReLU wins ImageNet by 10 points. Everyone realises depth beats width in practice.
- 2017Transformer feed-forward blocksMLPs come back — as the 2/3 of transformer parameters that sit between attention layers. The MLP never left.
(b) Visual walkthrough · 15 min
An MLP as a stack of matrix multiplications
Every arrow is either a matmul or an activation. The whole "deep learning" thing is just a sequence of these two operations.
Parameter counting — where the weights live
shape (D_in, H1) + (H1,). For MNIST (784 → 128): 784×128 + 128 = 100,480 params.
shape (H1, H2) + (H2,). For (128 → 64): 128×64 + 64 = 8,256 params.
shape (H2, D_out) + (D_out,). For (64 → 10): 64×10 + 10 = 650 params.
First layer dominates because D_in is largest. Rule of thumb: parameter count ≈ sum of layer_i × layer_{i+1}.
Same equation, just wider (12,288-dim) and deeper (96 layers). Same neuron. Same matmul.
Width vs depth — which one buys you what
More expressive per layer
- Can memorise more patterns per layer
- Diminishing returns past a point
- Parameter count grows quadratically per layer
- Overfits if too wide relative to data
More composed abstractions
- Each layer builds on last — hierarchical features
- Requires residual connections past ~10 layers (see S101)
- Vanishing gradient risk without care
- Empirically: depth beats width for the same param budget on most tasks
Not a good idea
- Information bottleneck — too few neurons per layer
- Hard to train — gradient signal degrades
- Avoid: bottom layers usually as wide as needed to preserve input info
The UAT special
- One hidden layer of 10,000+ neurons
- Theoretically expressive, practically weak
- No composed abstractions — memorises rather than generalises
- Deprecated except for kernel-methods-in-disguise research
The MLP inside a transformer
"The universal approximation theorem says a one-hidden-layer network can approximate any function. So depth is just an optimisation trick — in principle a wide shallow network is equally powerful, and deep networks are only about engineering convenience."
The theorem guarantees a shallow network exists that approximates the function — it says nothing about how wide it must be, whether gradient descent can find it, or how much data it needs. For certain function families, a shallow network needs a width that grows exponentially where a deep one needs only linear growth. "Representable in principle" and "learnable in practice" are different claims, and the theorem only makes the first.
The myth is sticky because the theorem is stated as a clean, strong-sounding universality result, and universality results normally do settle the question of power. It is also genuinely true and genuinely important. But it is an existence theorem with no constructive content and no complexity bound — the same category as "any continuous function can be approximated by polynomials", which nobody takes as advice to fit degree-10,000 polynomials. Depth buys you the ability to compose and reuse intermediate features, and composition is exactly what a single layer cannot do.
Confirm the forward pass is nothing but matmuls and elementwise functions, and see where the parameters actually live:
import numpy as np
def forward(x, params):
a = x
for W, b in params[:-1]:
a = np.maximum(0, a @ W + b) # ReLU hidden layer
W, b = params[-1]
return a @ W + b # linear output (logits)
# batch of 32, 100 features, hidden 64, output 10
# shapes: (32,100)@(100,64) -> (32,64) -> (32,64)@(64,10) -> (32,10)
# params: 100*64 + 64 + 64*10 + 10 = 7,114
# the first layer holds 90% of them. width of layer 1 dominates cost.Why must weights be initialised randomly, and why with a variance that depends on the layer's width rather than just "small random numbers"?
- 1Initialise all weights to the same value — zero or any constant — and every unit in a layer computes an identical output for every input, and therefore receives an identical gradient.forced by · units in a layer are structurally identical; only their weights can distinguish them
- 2Identical gradients mean identical updates, forever. The units remain clones for the entire training run, so a layer of 512 units has the representational capacity of exactly one. Symmetry must be broken by the initialisation, because nothing in training can break it.forced by · gradient descent is deterministic given the weights, so identical weights stay identical
- 3So the weights must be random. But now the scale matters: a unit's pre-activation is a sum of n terms (n = fan-in). For independent zero-mean weights of variance σ²w and inputs of variance σ²x, the sum has variance n·σ²w·σ²x — it grows with the layer's width.forced by · variances of independent terms add, so a wider sum has proportionally larger spread
- 4Left uncontrolled, that factor compounds multiplicatively through depth. If each layer scales activation variance by a factor greater than 1, activations explode exponentially with depth; less than 1, they vanish. Either way the signal is destroyed before it reaches the output, and the gradients suffer the identical fate on the way back.forced by · a forward pass through L layers multiplies L such factors together
- 5Therefore choose σ²w = 1/n so that n·σ²w = 1 and variance is preserved layer to layer. With ReLU, which zeroes roughly half its inputs and so halves the variance, you compensate with σ²w = 2/n — which is exactly He initialisation, while Xavier/Glorot uses a fan-in/fan-out average for symmetric activations like tanh.forced by · the correction must offset whatever the activation does to the variance
Therefore initialisation scale is derived, not tuned: random to break symmetry, and scaled by fan-in so that variance neither explodes nor vanishes with depth.
And note what this predicts: the deeper the network, the more sensitive it must be to getting this right, since the error compounds exponentially in L. That is exactly the historical record — deep networks were considered untrainable before principled initialisation, and it also explains why BatchNorm and LayerNorm help so much. They re-standardise activations at every layer, which makes the network far less dependent on the initialisation being correct in the first place.
Each layer does two things: an affine map (rotate, scale, shear, translate — that is Wx + b) followed by a fixed elementwise bend (the activation). One layer is therefore "move the space around, then fold it".
A deep network is that operation repeated. Data that is tangled in the input space gets progressively straightened, and by the final layer the classes are close to linearly separable — which is why the last layer is just a linear classifier. The network's real output is not the prediction; it is the coordinate system in which the prediction became easy.
- Every layer is
activation(x @ W + b). Shapes:(batch, in) @ (in, out) → (batch, out). Nearly every bug is a transposed matrix. - The output layer has no activation for regression, softmax for multiclass, sigmoid for binary — and in practice you keep logits and fold the activation into the loss for numerical stability.
- Parameter count is dominated by the widest adjacent pair of layers, since a layer costs in×out weights. Depth is usually cheaper than width.
- The batch dimension is along for the ride. Every operation is per-example except normalisation layers, which is exactly why BatchNorm behaves differently at train and inference time.
Fire this the moment you see: a shape mismatch in a matmul · a network with no activations between layers · all weights initialised to zero · activations or losses going to NaN in the first few steps · a softmax applied before a loss that already applies one · a very wide first layer on high-dimensional input eating the entire parameter budget.
You have a fixed parameter budget for an MLP. Do you spend it on depth (more layers) or width (more units per layer)?
For an MLP on tabular data, start moderately wide and shallow — two or three hidden layers — because tabular features are rarely compositional and the extra depth buys optimisation difficulty rather than accuracy. Depth pays where the signal is genuinely hierarchical, and there it pays enormously, which is why the architectures that changed the field are deep rather than wide.
The honest framing for a data engineer: before choosing a shape, check whether an MLP is the right family at all. On structured tabular data a well-tuned booster is the strong baseline and frequently the winner, and the MLP's real advantages — transfer, fine-tuning, handling raw perceptual input, joint training with embeddings for high-cardinality categories — only pay off when you actually need them.
(c) Hands-on · 25 min
Build a 3-layer MLP in pure numpy and train it on MNIST via a very simple SGD (backprop implementation deferred to S098 — here we hand-code the gradients for one layer just to run something). Save as mlp_lab.py, uv pip install scikit-learn numpy, uv run mlp_lab.py.
"""mlp_lab.py — a 2-hidden-layer MLP on MNIST, pure numpy.
Focus: THE FORWARD PASS AS MATRIX OPS.
Training uses a hand-rolled SGD with hand-derived gradients so you can see
the whole thing in one file. Session 098 will formalise backprop.
"""
from __future__ import annotations
import numpy as np
from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split
RNG = np.random.default_rng(0)
# ---------------- activations ----------------
def relu(z): return np.maximum(0, z)
def relu_grad(z): return (z > 0).astype(z.dtype)
def softmax(z):
z = z - z.max(axis=1, keepdims=True) # numerical stability
e = np.exp(z)
return e / e.sum(axis=1, keepdims=True)
# ---------------- MLP ----------------
class MLP:
"""Input → H1 → H2 → num_classes, ReLU hidden, softmax output."""
def __init__(self, layer_sizes: list[int]) -> None:
# He initialisation — right for ReLU
self.W: list[np.ndarray] = []
self.b: list[np.ndarray] = []
for i in range(len(layer_sizes) - 1):
fan_in, fan_out = layer_sizes[i], layer_sizes[i + 1]
self.W.append(RNG.normal(0, np.sqrt(2.0 / fan_in), (fan_in, fan_out)))
self.b.append(np.zeros(fan_out))
def forward(self, X: np.ndarray) -> tuple[np.ndarray, dict]:
"""Return probabilities and cache the intermediate values for backward."""
a = X
cache = {"a": [X], "z": []}
# hidden layers with ReLU
for i in range(len(self.W) - 1):
z = a @ self.W[i] + self.b[i]
a = relu(z)
cache["z"].append(z)
cache["a"].append(a)
# output layer with softmax
z_out = a @ self.W[-1] + self.b[-1]
probs = softmax(z_out)
cache["z"].append(z_out)
cache["a"].append(probs)
return probs, cache
def loss_and_grads(self, probs, y_onehot, cache):
"""Cross-entropy loss + hand-derived gradients."""
n = y_onehot.shape[0]
loss = -np.mean(np.sum(y_onehot * np.log(probs + 1e-12), axis=1))
dW, db = [None] * len(self.W), [None] * len(self.b)
dz = (probs - y_onehot) / n # dL/dz for softmax + CE
for i in reversed(range(len(self.W))):
a_prev = cache["a"][i]
dW[i] = a_prev.T @ dz
db[i] = dz.sum(axis=0)
if i > 0:
da_prev = dz @ self.W[i].T
dz = da_prev * relu_grad(cache["z"][i - 1])
return loss, dW, db
def step(self, dW, db, lr: float) -> None:
for i in range(len(self.W)):
self.W[i] -= lr * dW[i]
self.b[i] -= lr * db[i]
# ---------------- data ----------------
def load_mnist():
print("Downloading MNIST (may take 30-60s the first time)...")
ds = fetch_openml("mnist_784", version=1, as_frame=False, parser="auto")
X = (ds.data.astype(np.float32) / 255.0)
y = ds.target.astype(int)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=10_000, random_state=0)
return Xtr[:20_000], ytr[:20_000], Xte, yte # subsample for speed
def one_hot(y: np.ndarray, num_classes: int) -> np.ndarray:
out = np.zeros((y.shape[0], num_classes), dtype=np.float32)
out[np.arange(y.shape[0]), y] = 1.0
return out
# ---------------- train loop ----------------
def train(mlp: MLP, Xtr, ytr, Xte, yte, *, epochs=15, batch=128, lr=0.1) -> None:
ytr_oh = one_hot(ytr, 10)
n = Xtr.shape[0]
for ep in range(1, epochs + 1):
idx = RNG.permutation(n)
for start in range(0, n, batch):
b_idx = idx[start:start + batch]
probs, cache = mlp.forward(Xtr[b_idx])
_, dW, db = mlp.loss_and_grads(probs, ytr_oh[b_idx], cache)
mlp.step(dW, db, lr)
probs_te, _ = mlp.forward(Xte)
acc = float((probs_te.argmax(axis=1) == yte).mean())
print(f" epoch {ep:>2}: test accuracy = {acc:.4f}")
if __name__ == "__main__":
Xtr, ytr, Xte, yte = load_mnist()
print(f"Train: {Xtr.shape} Test: {Xte.shape}")
mlp = MLP(layer_sizes=[784, 128, 64, 10])
total = sum(w.size for w in mlp.W) + sum(b.size for b in mlp.b)
print(f"Model parameters: {total:,}")
train(mlp, Xtr, ytr, Xte, yte, epochs=15, batch=128, lr=0.1)Anatomy of the script
Anatomy of the script
Try three architectures, all with the same total training budget (15 epochs, lr=0.1):
- Tiny:
[784, 32, 10]— 25k params - Medium:
[784, 128, 64, 10]— the default above, 109k params - Wide:
[784, 512, 256, 10]— 535k params
Report final test accuracy and the total training time. You should see: tiny caps at ~90%, medium ~96%, wide ~97% but takes 2× as long. Diminishing returns are real. On a real problem you'd add regularisation (S101) before adding more width.
(d) Production reality · 15 min
Google released TabNet claiming to beat gradient-boosted trees on tabular data. Community reproductions found that a properly-tuned plain MLP with a few tricks (batch norm, dropout, cosine LR schedule) matched or beat TabNet on most benchmarks — and gradient boosting still won overall.
Standard tabular DL now uses MLPs with careful regularisation as the baseline, not fancy architectures. And on structured data, XGBoost / LightGBM remains the sensible first choice; DL only wins with millions of rows and lots of categoricals to embed.
Chinchilla scaling laws: optimal params ≈ 20 × training tokens. Modern LLMs (LLaMA-2, Mistral, Gemma) target this ratio. The MLP blocks inside — 2/3 of the parameters — are sized by these laws.
Neural collaborative filtering research (He et al., 2017) then Rendle et al. 2020 showed a well-tuned dot-product baseline often beats a fancy MLP recommender. Modern recsys use MLPs for feature interactions but keep matrix-factorisation baselines and always A/B against them.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three to a friend without notes, redo the session:
- Draw the forward pass of a 2-hidden-layer MLP with every matrix shape.
- What does a hidden layer do, in one sentence?
- Where do MLPs hide inside transformers and CNNs?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.