Skip to content

Multi-Layer Perceptron (MLP)

A perceptron is one line. Stack them with non-linearities between and you get a multi-layer perceptron, which can represent essentially any continuous function. This page measures what that buys: the same linear classifier scores 0.6800 on raw features and 0.9667 on the hidden layer’s version of them.

  • The forward pass as three matrix operations, checked against Keras to 3×1083 \times 10^{-8}.
  • How to count parameters, and why the first layer is usually 98.6% of them.
  • What a hidden layer does, measured: a linear model on learned features goes from 0.6800 → 0.9667.
  • The universal approximation theorem at 1, 3, 8 and 40 units — including how bad one unit is (MSE 0.4700).
  • Width against depth at a fixed budget: 4×60 beat 1×128 with 42% fewer parameters, and 8×40 was worse than both.

An MLP with one hidden layer is three operations:

H=ϕ(XW1+b1),Y^=softmax(HW2+b2)\mathbf{H} = \phi(\mathbf{X}\mathbf{W}_1 + \mathbf{b}_1), \qquad \hat{\mathbf{Y}} = \mathrm{softmax}(\mathbf{H}\mathbf{W}_2 + \mathbf{b}_2)

For a batch of mm rows with nn features, hh hidden units and kk classes, the shapes are forced:

TensorShape
X\mathbf{X}(m,n)(m, n)
W1\mathbf{W}_1(n,h)(n, h)
b1\mathbf{b}_1(h,)(h,) — broadcast across all mm rows
H\mathbf{H}(m,h)(m, h)
W2\mathbf{W}_2(h,k)(h, k)
Y^\hat{\mathbf{Y}}(m,k)(m, k)
diagram Diagram mermaid

Written in NumPy and compared against the Keras model holding the same weights:

forward_by_hand.py
hidden = np.maximum(x @ W1 + b1, 0.0)          # relu
logits = hidden @ W2 + b2
shifted = logits - logits.max(axis=1, keepdims=True)
probabilities = np.exp(shifted) / np.exp(shifted).sum(axis=1, keepdims=True)
 
print(probabilities)                  # [[0.570055 0.429945]]
print(model.predict(x, verbose=0))    # [[0.570055 0.429945]]

Maximum difference: 2.98×1082.98 \times 10^{-8} — float32 rounding, nothing more. A Dense layer is a matmul, a broadcast add and an activation.

Each layer contributes (inputs×outputs)(\text{inputs} \times \text{outputs}) weights plus outputs\text{outputs} biases:

ArchitectureWeightsBiasesTotal
784-128-10101,632138101,770
784-90-90-1079,56019079,750
784-60-60-60-60-1058,44025058,690
784-32-1025,4084225,450

In 784-128-10, the first layer’s 784×128=100,352784 \times 128 = 100{,}352 weights are 98.6% of the whole model. Two consequences: the input dimension dominates a small MLP’s size, and adding hidden layers is far cheaper than widening the first one.

The clearest test keeps the classifier fixed and changes only the representation. On the three-class spiral, with 450 training points:

ModelTest accuracy
logistic regression on the raw 2 features0.6800
the MLP (32 hidden units, then softmax)0.9600
logistic regression on the MLP’s 32 hidden units0.9667

The third row is the point. It is the same algorithm as the first row, on the same labels, and it scores 0.2867 higher — because the hidden layer has re-expressed the two input coordinates as 32 features in which the classes are close to linearly separable.

A hidden layer is a learned change of coordinates. The final layer is still just a line; the hidden layers move the data until a line is enough. That is exactly what the hand-built XOR network did with OR and NAND, except learned rather than designed.

The universal approximation theorem, at four widths

Section titled “The universal approximation theorem, at four widths”

The theorem says a network with one hidden layer and enough units can approximate any continuous function on a bounded interval to any accuracy. It does not say how many units, and it does not say training will find them. Fitting y=sin(2x)+0.3sin(6x)y = \sin(2x) + 0.3\sin(6x) with one tanh layer:

figure One hidden layer, 600 epochs, widths 1 / 3 / 8 / 40 matplotlib
Four panels of the same wavy target curve with a fitted curve overlaid. With 1 hidden unit the fit is a straight sloping line. With 3 units it captures the broad shape but misses the small wiggles. With 8 it is closer. With 40 it tracks the target including the secondary bumps. Four panels of the same wavy target curve with a fitted curve overlaid. With 1 hidden unit the fit is a straight sloping line. With 3 units it captures the broad shape but misses the small wiggles. With 8 it is closer. With 40 it tracks the target including the secondary bumps.
One tanh unit can only produce one S-shaped curve, so its best effort is a sloping line at MSE 0.4700. Three units find the primary wave. Forty capture the secondary wiggles at MSE 0.0187 — a 25x improvement for 121 parameters instead of 4.
Hidden unitsParametersMSE
140.469952
3100.066197
8250.044981
401210.018692

“Universal” is an existence claim, not a recipe. The gap between 1 and 3 units is a factor of 7 in MSE; between 8 and 40, a factor of 2.4. Capacity has diminishing returns, the same shape of curve that more training data produces.

If the theorem says one wide layer suffices, why go deep? Four architectures with comparable parameter budgets, MNIST, 8,000 rows, 6 epochs, three seeds each:

figure Similar budgets, spent on width or on depth matplotlib
Bar chart of MNIST validation accuracy for four architectures with parameter counts annotated: 1x128 with 101,770 params at 0.9038, 2x90 with 79,750 at 0.9112, 4x60 with 58,690 at 0.9115, and 8x40 with 43,290 at 0.8850. Bar chart of MNIST validation accuracy for four architectures with parameter counts annotated: 1x128 with 101,770 params at 0.9038, 2x90 with 79,750 at 0.9112, 4x60 with 58,690 at 0.9115, and 8x40 with 43,290 at 0.8850.
The two middle configurations win while carrying fewer parameters — 4x60 matches 2x90 using 58,690 against 79,750, and beats the single wide layer's 101,770. At 8 layers of 40 units the accuracy drops to 0.8850: this plain MLP has no normalisation or residual connections, and depth without them stops paying.
ArchitectureParametersAccuracy (mean of 3 seeds)
1 × 128101,7700.9038 (0.9015–0.9050)
2 × 9079,7500.9112 (0.9045–0.9170)
4 × 6058,6900.9115 (0.9035–0.9205)
8 × 4043,2900.8850 (0.8820–0.8885)

Read it carefully, because two of the readings cut against the usual slogans.

Depth is more parameter-efficient than width, up to a point. 4×60 reached 0.9115 with 42% fewer parameters than 1×128’s 0.9038. Layers compose features; width can only add more features at the same level of abstraction.

But “deeper is better” fails at 8 layers here. 0.8850 is worse than the single wide layer. Nothing is wrong with the code — a plain MLP with no batch normalisation, no residual connections and no learning-rate schedule runs into exactly the gradient problems measured on the activations page. Phase 2 is the toolkit that makes depth pay, and this row is the reason it exists.

The seed spread is not negligible. 4×60 ranged 0.9035–0.9205 across three seeds — a 0.0170 spread, which is larger than the gap between the top three architectures. Any comparison of “0.9112 vs 0.9115” without a spread attached is noise reporting.

The units you are paying for and not using

Section titled “The units you are paying for and not using”

A ReLU unit whose pre-activation is negative for every input in the dataset outputs zero always. Its gradient is zero always too, so it can never recover. It is a parameter you store, multiply and never benefit from — and the count is not negligible:

figure MNIST, 8,000 rows, 8 epochs, 3 seeds, 64 units per layer matplotlib
Left: bars of the share of hidden units that never fire on any test image, rising from 1.6% at one hidden layer to 10.9% at eight. Right: test accuracy against depth, nearly flat between 0.8998 and 0.9075 across all four depths. Left: bars of the share of hidden units that never fire on any test image, rising from 1.6% at one hidden layer to 10.9% at eight. Right: test accuracy against depth, nearly flat between 0.8998 and 0.9075 across all four depths.
Dead units grow with depth — 1.56% at one layer, 10.87% at eight — while accuracy stays inside 0.0077 across the whole range. The eight-layer network is carrying 512 units of which roughly 56 do nothing at all, for no gain over the one-layer network's 64. This is one concrete reason 'just add layers' stops paying long before the universal approximation theorem runs out of room.
Hidden layersUnitsDead shareWorst seedTest accuracy
1 × 64641.56%3.12%0.8998
2 × 641282.34%4.69%0.9075
4 × 642566.64%8.59%0.9040
8 × 6451210.87%12.11%0.9047

The accuracy column is the control. If depth were buying something, the dead-unit cost would be a trade; here it is 7× the waste for 0.0049 less accuracy than the two-layer model. Leaky ReLU, a smaller learning rate and better initialisation all reduce the count — but the first thing to do is measure it, because nothing in the training log mentions it.

mlp_keras.py
from tensorflow import keras
 
model = keras.Sequential([
    keras.layers.Input((784,)),
    keras.layers.Dense(128, activation="relu"),
    keras.layers.Dense(64, activation="relu"),
    keras.layers.Dense(10, activation="softmax"),
])
model.compile(optimizer="adam",
              loss="sparse_categorical_crossentropy",
              metrics=["accuracy"])
model.summary()

model.summary() prints the same arithmetic you did by hand:

LayerOutput shapeParameters
dense(None, 128)100,480
dense_1(None, 64)8,256
dense_2(None, 10)650
total109,386

None is the batch axis: pass 7 rows and you get (7, 10) out. Use sparse_categorical_crossentropy for integer labels and categorical_crossentropy for one-hot ones — mixing them up is the most common compile-time error in this phase.

sketch A tiny feed-forward network with weighted edges p5.js
Watch one forward pass ripple through the network: input signals travel to the hidden layer, ReLU fires, then the result travels on to the output.

The second sketch is the parameter budget. Move the sliders and watch where the parameters actually go — the first layer’s bar dwarfs everything else until the input dimension shrinks.

sketch Where the parameters go p5.js
Adjust the input dimension, hidden width and number of hidden layers, and see the parameter count per layer. The first layer dominates whenever the input is high-dimensional.

Reading the universal approximation theorem as a recipe. It guarantees a width exists; it says nothing about how large, and nothing about whether gradient descent will find those weights. One unit’s best fit here was a sloping line at MSE 0.4700.

Assuming deeper is always better. 8×40 scored 0.8850 against 4×60’s 0.9115 on the same data. Plain MLPs stop benefiting from depth without the Phase 2 toolkit.

Comparing architectures on one seed. The 4×60 spread was 0.9035–0.9205. Differences smaller than that spread are not results.

Widening the first layer to add capacity. On a 784-input model, the first layer is already 98.6% of the parameters. An extra hidden layer is far cheaper than doubling that one.

Mismatching the loss to the label format. Integer labels need sparse_categorical_crossentropy; one-hot labels need categorical_crossentropy. The error message points at shapes, not at the loss.

Forgetting the activation between Dense layers. Two Dense layers with activation=None collapse into one, exactly as measured on the activations page — the parameters stay, the capacity does not.

  • The forward pass is matmul, broadcast add, activation — verified against Keras to 2.98×1082.98 \times 10^{-8}.
  • Parameters are inputs×outputs+outputs\text{inputs} \times \text{outputs} + \text{outputs} per layer, and the first layer is 98.6% of a 784-128-10 model.
  • A hidden layer is a learned change of coordinates: the same logistic regression scored 0.6800 on raw features and 0.9667 on the MLP’s 32 hidden units.
  • One hidden layer can approximate anything in principle; in practice 1 unit gave MSE 0.4700 and 40 units gave 0.0187 on the same curve.
  • At matched budgets, 4×60 (0.9115, 58,690 params) beat 1×128 (0.9038, 101,770) — depth is more parameter-efficient — but 8×40 fell to 0.8850, which is what Phase 2 exists to fix.
  • Seed spread on this task was up to 0.0170, larger than the gaps between the top three architectures.
pch.quizTag pch.quizDefaultTitle
  1. A logistic regression scores 0.6800 on the spiral's two raw features and 0.9667 on the 32 hidden activations of a trained MLP. What does that demonstrate?

    pch.quizShowAnswer

    B — That the hidden layer's job is representation: it re-expresses the inputs in coordinates where a linear boundary is nearly sufficient — the classifier and the labels never changed — The final layer of an MLP is still a linear model. Everything before it exists to move the data until a line is enough, which is exactly what the hand-built XOR network did with OR and NAND.

  2. The universal approximation theorem says one hidden layer suffices. Why use more?

    pch.quizShowAnswer

    B — It is an existence result with no bound on width and no guarantee that training finds the weights; measured here, 4x60 beat 1x128 while using 42% fewer parameters — Depth composes features, so it often reaches the same accuracy with fewer parameters. But it is not unconditional: 8x40 scored 0.8850 against 4x60's 0.9115 on the same data.

  3. In a 784-128-10 network, how much of the model is the first layer?

    pch.quizShowAnswer

    B — 98.6% — its 784 x 128 = 100,352 weights out of 101,770 parameters total, because the input dimension multiplies the first weight matrix — This is why an extra hidden layer (width x width + width) is cheap while widening the first layer is expensive, and why convolutional layers exist for images.

  4. You compare two architectures across 3 seeds: 0.9112 and 0.9115, with individual runs ranging 0.9045-0.9205. What can you conclude?

    pch.quizShowAnswer

    B — Nothing — the 0.0003 gap is far smaller than the 0.0170 seed spread, so the comparison needs more seeds or more data before it says anything — Reporting a mean without a spread is how architecture folklore gets created. Deep-learning results are draws from a distribution, and the spread here is larger than the effect.

  5. You build Sequential([Dense(64), Dense(64), Dense(10, activation='softmax')]) with no activation on the hidden layers. What have you built?

    pch.quizShowAnswer

    B — Effectively a single linear layer feeding a softmax — the two hidden weight matrices multiply into one, so the parameters remain but the capacity does not — The collapse is measured on the activations page: two linear layers agree with one equivalent layer to 8.88e-16. The model runs, trains and quietly underperforms.

You know what layers buy and what they cost. Continue to Activation Functions (ReLU, Sigmoid, Softmax) for the non-linearity that makes the stack more than one layer — then to Autograd from Scratch to build the gradient machinery these layers are trained with.

Exercise 2 – Run the forward pass yourself

Section titled “Exercise 2 – Run the forward pass yourself”

Exercise 5 – Measure what the hidden layer buys

Section titled “Exercise 5 – Measure what the hidden layer buys”

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading