Search Tech Journey

Find topics, journeys and posts

6-month learning plan97 / 130
back to blog
mladvanced 55m read

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.

🤖Machine LearningM12 · Deep Learning· Session 097 of 130 90 min

🎯 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.

You will be able to
  • 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 committee of committees
🌍 Real world

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.

💻 Code world

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

Every MLP is this — memorise the shape
  • 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

  1. 1989
    Hornik / Cybenko · UAT
    A single hidden layer of enough neurons can approximate any continuous function on a compact domain. Deep learning is theoretically possible.
  2. 1991
    Hornik · UAT for general activations
    Any non-constant, bounded, monotonically-increasing activation works — sigmoid, tanh, later ReLU.
  3. 1998
    LeCun · CNN on MNIST
    Shows MLPs are wasteful for images — CNN's shared weights + locality bias train faster.
  4. 2012
    AlexNet + GPU + ReLU
    Deep MLP + convolutions + ReLU wins ImageNet by 10 points. Everyone realises depth beats width in practice.
  5. 2017
    Transformer feed-forward blocks
    MLPs 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

1biggest
Layer 1: W1 + b1

shape (D_in, H1) + (H1,). For MNIST (784 → 128): 784×128 + 128 = 100,480 params.

2middle
Layer 2: W2 + b2

shape (H1, H2) + (H2,). For (128 → 64): 128×64 + 64 = 8,256 params.

3output
Layer 3: W3 + b3

shape (H2, D_out) + (D_out,). For (64 → 10): 64×10 + 10 = 650 params.

4sum
Total: ~110,000 params

First layer dominates because D_in is largest. Rule of thumb: parameter count ≈ sum of layer_i × layer_{i+1}.

5scale
GPT-3 has 175 BILLION

Same equation, just wider (12,288-dim) and deeper (96 layers). Same neuron. Same matmul.

Width vs depth — which one buys you what

Wider (bigger H_i)

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
Deeper (more layers)

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
Skinny + deep

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
Wide + shallow

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


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

Why must weights be initialised randomly, and why with a variance that depends on the layer's width rather than just "small random numbers"?

  1. 1
    Initialise 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
  2. 2
    Identical 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
  3. 3
    So 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
  4. 4
    Left 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
  5. 5
    Therefore 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

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.

Mental modelA stack of learned coordinate changes

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.
🔔 Fires when you see

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.

The tradeoff

You have a fixed parameter budget for an MLP. Do you spend it on depth (more layers) or width (more units per layer)?

Depth
+ you gain allows hierarchical composition — later layers build on features earlier ones discovered — and for compositional functions this is exponentially more parameter-efficient than width. It is also cheaper per unit of capacity, since parameters grow linearly with layers but quadratically with width.
− you pay harder to optimise: gradients traverse more multiplications, so vanishing and exploding become real, and you typically need residual connections and normalisation to train past modest depth. Inference latency is inherently sequential — layer L+1 cannot start before L finishes — so depth costs wall-clock time that width does not.
pick when the problem has genuine hierarchical structure (perception, language, anything where features compose), and you can use residuals and normalisation
Width
+ you gain much easier to optimise — shallow networks have better-behaved loss surfaces and are far less sensitive to initialisation; and wide layers parallelise beautifully on a GPU, so a wide shallow network can be dramatically faster in wall-clock terms than a deep thin one with identical parameter count
− you pay parameters grow as in×out, so width is expensive; and without depth the network cannot compose features, so it must approximate structured functions by brute-force coverage — which is precisely the exponential-width problem the universal approximation theorem does not protect you from
pick when the function is not strongly compositional (much tabular data), you need low latency, or your data volume cannot support the sample complexity of a deep model
Neither — a shallow net or a booster
+ you gain on tabular data, gradient-boosted trees typically beat MLPs of any shape while needing less tuning, less data, and no GPU; and they handle mixed types and missing values natively
− you pay gives up representation learning entirely, so it does not transfer, does not handle raw perceptual input, and cannot be fine-tuned for a related task
pick when the input is tabular and heterogeneous — which is most enterprise data, and where reaching for an MLP is usually the wrong instinct
What a senior engineer actually does

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

Line 19 · softmax(z) with subtraction of max
Numerical stability trick: exp(1000) overflows, exp(1000 - max) doesn't. Every real softmax implementation does this. Never omit.
stability
Line 33 · He initialisation N(0, √(2/fan_in))
Right for ReLU (Kaiming He, 2015). Xavier init N(0, √(1/fan_in)) is right for tanh. Wrong init = network won't train, and the fix is one number.
init
Line 41 · forward cache
We save z and a at every layer because the backward pass needs them. In PyTorch, autograd does this for you.
core
Line 54 · dz = (probs - y_onehot) / n
The famously clean gradient of softmax + cross-entropy: the derivative of the loss with respect to pre-activation of output is just (predicted − true). One line, no messy algebra.
math
Line 57 · reversed(range(...))
Backpropagation walks the layers in reverse. This will be the entire subject of session 098.
backprop
Line 60 · dz = da_prev * relu_grad(z)
Chain rule at ReLU: gradient flows through if pre-activation was positive, otherwise blocked. This is where ‘dead ReLU’ mathematically happens.
chain-rule
Line 76 · fetch_openml('mnist_784')
Real MNIST — 70k images. First download caches to disk. We subsample to 20k train / 10k test to keep the pure-numpy trainer fast.
data
Line 96 · lr=0.1
A tuned constant for this setup. Session 099 replaces this with Adam and you'll stop tuning learning rate manually.
hyperparam
Try itCount parameters and change layer widths — feel the trade-off

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.

💡 Hint · A model with fewer params trains faster but caps out lower. A model with far more params overfits.

(d) Production reality · 15 min

War story Google — TabNet vs XGBoost comparisons· 2019published benchmarks on tabular data
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
‘Fancy new architecture beats MLP’ claims should be met with skepticism. Plain MLPs, tuned properly, are competitive on tabular data. Deep learning's win zone is unstructured data (images, text, audio) where inductive bias (CNN, attention) matters.
Post-mortem
War story OpenAI · GPT scaling laws· 2020chinchilla / GPT-3 / GPT-4
🔥 What broke
Early transformer scaling: teams increased depth or width somewhat arbitrarily. GPT-3 at 175B params trained on 300B tokens showed the diminishing-returns curve — and Chinchilla (DeepMind, 2022) proved GPT-3 was under-trained: same compute, more data, smaller model = better.
🧯 The fix

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.

🎓 Lesson to steal
Even the biggest models in the world are made of the MLP you built today. Choosing width and depth is not free intuition — there are empirical scaling laws, and the current best answer for LLMs is ‘moderate width, moderate depth, feed it much more data than you think.’
Post-mortem
War story Recommender systems · common failure modeevery large ecommerce or streaming platform
🔥 What broke
Early neural recommender: a 3-layer MLP takes user embedding + item embedding and outputs a rating. Team trains it, offline metrics great, A/B test flat. Root cause: the MLP is essentially learning a bias table plus small corrections — the embeddings are doing all the work, the MLP contributes marginal lift over a simple dot-product.
🧯 The fix

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.

🎓 Lesson to steal
MLPs are strong but not magical. A dot product + embedding is sometimes all you need. Always beat a simple baseline before shipping a complicated network.
Post-mortem

Where this shows up in the rest of the plan

The MLP is a subcomponent of everything downstream
S098 · Backpropagation
Formalises the hand-derived gradient in this session's script.
S099 · Optimisers
Adam replaces the SGD `w -= lr*dW` line.
S100 · PyTorch fundamentals
This numpy MLP becomes 20 lines with nn.Linear + autograd.
S101 · Regularisation
Dropout / batch norm / weight decay — plug into the MLP forward pass.
S102 · CNNs
Trade dense matmul for convolution. Same forward-pass philosophy.
S105 · Transformers
The feed-forward block IS this MLP, applied position-wise.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three to a friend without notes, redo the session:

  1. Draw the forward pass of a 2-hidden-layer MLP with every matrix shape.
  2. What does a hidden layer do, in one sentence?
  3. 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.