Deep Learning · Module 12 of 26 · 8 concepts · about 18 min

MLPs

Dense networks for fixed-size, tabular, or already-vectorised inputs.

At work: The plain neural network, still used for tabular predictions, recommendation layers and parts of larger models.

Multilayer perceptrons

What it is

An MLP alternates fully connected layers and nonlinearities. Every output unit in a dense layer can use every input feature, so the model can learn rich interactions among a fixed set of values. It has no built-in notion of image locality, token order, or graph structure; those properties must be encoded in the input or supplied by a different architecture. Width controls how many features a layer can represent, while depth lets the network compose those features into more complex functions.

Why it matters

MLPs are the right baseline for fixed-length vectors and the feed-forward sublayer inside Transformers. They are usually the wrong inductive bias for raw images and long sequences because they do not share weights across spatial positions or time. A simple MLP baseline is still valuable: if a specialised model cannot beat it honestly, the extra complexity has not earned its cost.

In the real world: Still used for predictions from tabular data and inside recommendation systems.

Visual example

Deep learning

Every dense layer mixes all features from the previous layer.

MLP

  1. input vector64 fixed-size features
  2. Linear + ReLU128 learned combinations
  3. Linear + ReLU64 higher-level combinations
  4. output headtask-specific logits or values

An MLP assumes the input is already a fixed-length vector.

import torch.nn as nn

mlp = nn.Sequential(
    nn.Linear(64, 128), nn.ReLU(),
    nn.Linear(128, 64), nn.ReLU(),
    nn.Linear(64, 3),              # logits; no softmax here
)
mlp(x).shape                       # (batch, 3) for x of shape (batch, 64)

Flattening an image into an MLP and interpreting failure as 'neural networks do not work'. The model discarded the spatial structure that a CNN can exploit with far fewer parameters.

An MLP is the pieces, assembled

What it is

There is no MLP-specific mathematics. Take nn.Linear from module 2, a nonlinearity from module 3 — without which the whole stack collapses algebraically to one linear map — a loss from module 5, an optimiser from module 8, and optionally the normalization and dropout of module 11. Stack at least one hidden layer and you have a multilayer perceptron. The only architectural decisions are the number of hidden layers, their widths, which activation to use, and where to place normalization and dropout. Everything else was already decided by the task: the input width by the feature count, the output width and the loss by what you are predicting.

Why it matters

Naming an MLP as an assembly rather than a new object makes the whole first half of this topic pay off at once, and makes the later architectures legible: a CNN replaces Linear with a weight-sharing operator, an RNN reuses one layer across timesteps, and a Transformer block wraps attention around an MLP. The pattern never changes — only which operator mixes information across positions.

In the real world: The simplest complete network: a good first model to prove a data pipeline works end to end.

Visual example

Nothing new

Every part of an MLP was introduced earlier. The module is about the assembly, not a new idea.

where each piece came from
piecemodulewhat it contributes
nn.Linear2 — neurons & layerslearned feature mixing
nn.ReLU3 — activationsthe bend; without it depth is fake
CrossEntropyLoss5 — lossesthe scalar to minimise
AdamW + schedule8 — optimizershow parameters move
BatchNorm, Dropout11 — normalizationtrainable, and generalising
import torch.nn as nn

def block(in_dim, out_dim, p=0.1):
    return nn.Sequential(
        nn.Linear(in_dim, out_dim, bias=False),   # module 2
        nn.BatchNorm1d(out_dim),                  # module 11
        nn.ReLU(),                                # module 3
        nn.Dropout(p),                            # module 11
    )

model = nn.Sequential(block(20, 128), block(128, 64), nn.Linear(64, 2))

Stacking nn.Linear(64, 128) and nn.Linear(128, 64) with no activation between them and calling it a two-layer network. Without a nonlinearity it is mathematically a single linear layer with more parameters and strictly no more expressive power.

Training an MLP end to end

What it is

A complete supervised run on a fixed-length input has six parts and no more: a Dataset/DataLoader that yields batches, a model, a loss, an optimiser, a training loop that calls zero_grad/backward/step, and a validation pass in eval() mode. Two shape contracts must hold for classification: the logits must be (batch, n_classes) and the targets (batch,) of dtype long holding class indices. Standardise the features using statistics from the training split only — an MLP is scale-sensitive in a way tree models are not, and a feature measured in millions will dominate the first layer's gradients.

Why it matters

This loop is the reference implementation the rest of the topic varies. Getting it written once, correctly, means every later architecture is a change to one line — the model — rather than a new training script with a new set of bugs. It is also the shortest path to a real baseline number, which every subsequent architectural claim has to beat.

In the real world: Training a model to predict whether a user will click a promotion.

Visual example

Deep learning

Six parts, in this order. Every later architecture changes only the model line.

One training run

  1. DataLoaderbatches of (x, y) tensors
  2. model(x)(batch, n_classes)logits
  3. loss_fn(logits, y)one scalar
  4. zero_grad -> backward -> stepparameters move once per batch
  5. eval() + no_gradthe honest validation number

Standardise with training statistics only, and keep the test set out of this loop entirely.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

mu, sd = X_train.mean(0), X_train.std(0) + 1e-8      # train stats only
train = TensorDataset((X_train - mu) / sd, y_train.long())
loader = DataLoader(train, batch_size=64, shuffle=True)

model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 3))
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(30):
    model.train()
    for xb, yb in loader:
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)      # (64, 3) logits vs (64,) indices
        loss.backward()
        opt.step()
    model.eval()
    with torch.no_grad():
        acc = (model((X_val - mu) / sd).argmax(1) == y_val).float().mean()
    print(epoch, float(acc))

Standardising with statistics computed over the full dataset before splitting. The training inputs then encode information from the validation rows, and the validation score is optimistic — the same leakage the ML syllabus's data-prep module warns about, and it is no less real here.

Universal approximation, practically

What it is

The universal approximation theorem says a single hidden layer with enough units can approximate any continuous function on a bounded region to any accuracy. Read carefully, it is an existence statement about weights, and it is silent on all three things that decide whether you can train the model: how many units 'enough' is (it can be exponential in the input dimension), whether gradient descent from a random start will find those weights, and how many labelled examples you would need to identify them from data rather than from the function itself.

Why it matters

It is the most-cited and least-useful theorem in the field, and misreading it produces a specific bad habit: widening one hidden layer indefinitely and expecting the fit to improve. Depth, weight sharing, and architectural priors matter precisely because representability was never the binding constraint — optimisation and sample efficiency are. A CNN does not beat an MLP on images because it can represent more functions; it beats it because it can *learn* the right one from the data available.

In the real world: A big enough network can fit the data, but fitting isn't the same as predicting next month well.

import torch.nn as nn

# both can represent the target; only one is practical to train
wide = nn.Sequential(nn.Linear(2, 8192), nn.ReLU(), nn.Linear(8192, 1))  # 24.6k params
deep = nn.Sequential(
    nn.Linear(2, 64), nn.ReLU(),
    nn.Linear(64, 64), nn.ReLU(),
    nn.Linear(64, 64), nn.ReLU(),
    nn.Linear(64, 1),
)                                                                          # 8.5k params
sum(p.numel() for p in deep.parameters())

Citing universal approximation as a reason to skip feature engineering or architecture choice. The theorem grants representability, not learnability; it says nothing about the optimisation landscape or the amount of data required.

Width and depth as capacity knobs

What it is

Width sets how many features one layer can hold in parallel; depth sets how many times those features can be recombined into higher-level ones. They cost differently. A hidden layer of width w between two others contributes on the order of w² parameters, so doubling width roughly quadruples that layer's parameters and its matrix-multiply cost. Adding a layer at the same width grows parameters linearly and depth-related optimisation difficulty non-linearly — which is where module 10's vanishing gradients, residual connections, and normalization become the deciding factors rather than an afterthought.

Why it matters

It turns 'make the model bigger' into two separate, differently-priced decisions. For tabular data, 2-4 hidden layers of a few hundred units is usually the whole useful search space, and time is better spent on features and regularisation. Knowing the w² scaling also explains why the first layer after flattening an image dominates a naive model's parameter count.

In the real world: Sizing a model to the data a company actually has, rather than the biggest one possible.

Visual example

Capacity vs. validation loss

Both knobs have an interior optimum. Width saturates and then overfits; depth helps until optimisation becomes the bottleneck.

wider (1 hidden layer)deeper (constant width)
01234560.20.40.60.81capacity (relative)validation lossdepth wins, until optimisation bitestoo deep without residuals

The deep curve's right-hand rise is a module 10 problem — vanishing gradients — not a capacity problem. Residual connections and normalization move it rightward.

import torch.nn as nn

def mlp(widths):
    layers = []
    for a, b in zip(widths, widths[1:]):
        layers += [nn.Linear(a, b), nn.ReLU()]
    return nn.Sequential(*layers[:-1])

for widths in [[64, 256, 10], [64, 512, 10], [64, 256, 256, 256, 10]]:
    n = sum(p.numel() for p in mlp(widths).parameters())
    print(widths, f"{n:,} parameters")
# [64, 256, 10]           19,210
# [64, 512, 10]           38,410   <- double the width, double this layer's cost
# [64, 256, 256, 256, 10] 151,050  <- depth at constant width

Growing width and depth at the same time when a run underfits. Two variables moved together tell you nothing about which one mattered; change one, record the baseline, and compare on validation data.

Why MLPs fail on images

What it is

A dense layer takes a flat vector, so an image must be flattened first — and flattening throws away the fact that pixel (10, 10) is adjacent to (10, 11). Two consequences follow. The parameter count explodes: a single Linear from a 224×224×3 image to 1000 hidden units needs 224·224·3·1000 ≈ 150 million weights, more than a whole ResNet-50. And there is no weight sharing, so a pattern learned at the top-left is stored in an entirely separate set of weights from the same pattern at the bottom-right — the model must learn every feature once per location, and has no way to generalise a shifted example.

Why it matters

This is the precise motivation for module 13. A convolution keeps the dense-layer idea and adds two assumptions — locality (a unit looks at a small neighbourhood) and translation-equivariant weight sharing (the same kernel is applied everywhere) — which together cut that 150 million to a few thousand while making a shifted cat recognisable. The architecture is not more powerful; it is better matched to the structure of the data.

In the real world: Why photo-based products use convolutional or transformer models instead.

Visual example

Parameter count

One dense layer over raw pixels costs more parameters than an entire modern CNN.

first layer on a 224×224×3 image
layerparametersshared across positions?
Linear(150528, 1000)150,529,000no
Conv2d(3, 64, k=3)1,792yes — one kernel, every position
Conv2d(64, 128, k=3)73,856yes
whole ResNet-5025,557,032yes

Common misconception

Wrong idea: an MLP on images is just less accurate.

Correct idea: it is structurally blind to position. Shuffle every image's pixels by the same fixed permutation and the MLP is unaffected — which no model that understands images could be.

import torch.nn as nn

dense = nn.Linear(224 * 224 * 3, 1000)
sum(p.numel() for p in dense.parameters())     # 150,529,000

conv = nn.Conv2d(3, 64, kernel_size=3, padding=1)
sum(p.numel() for p in conv.parameters())      # 1,792 — and applied everywhere

Thinking the flattening order matters and trying to fix an MLP on images by reshaping differently. Any fixed permutation of the pixels gives an equivalent model — which is the real statement of the problem: an MLP cannot tell a photograph from the same pixels shuffled by a fixed rule.

Dead units and vanishing signal in a plain MLP

What it is

Two module-3 and module-10 failures show up concretely here. A ReLU unit whose pre-activation is negative for every training example outputs zero always, receives exactly zero gradient always, and is permanently dead — a common outcome of a learning rate large enough to push a bias far negative in one step. Separately, a too-small initialization makes each layer shrink the activations, so by layer 8 the representations are numerically negligible and the output is effectively constant. Both look identical from the loss curve: a plateau. They are trivially distinguishable by measuring activations.

Why it matters

An MLP is the simplest place to build the habit of measuring activations rather than inferring them. Two numbers per layer — the fraction of units that are zero for *every* example in a batch, and the activation standard deviation — separate a dead-unit problem (lower the learning rate, try LeakyReLU or GELU, check the bias initialization) from a scale problem (fix the initializer, add normalization) in one forward pass.

In the real world: Why a model can have millions of parameters and still underperform.

import torch
import torch.nn as nn

acts = {}
for name, module in model.named_modules():
    if isinstance(module, nn.ReLU):
        module.register_forward_hook(
            lambda m, inp, out, k=name: acts.__setitem__(k, out.detach())
        )

model.eval()
with torch.no_grad():
    model(x_batch)

for name, a in acts.items():
    dead = (a.abs().amax(dim=0) == 0).float().mean()   # zero for EVERY example
    print(f"{name}: dead={float(dead):.1%} std={float(a.std()):.4f}")
# dead=41% -> lower the lr, or use LeakyReLU/GELU
# std=2e-6 -> an initialization scale problem (module 10)

Counting zeros in a single example's activations and calling them dead units. ReLU is *supposed* to output zero for roughly half its inputs — that is the nonlinearity working. A unit is dead only when it is zero across the whole batch, every batch.

Reading an MLP's shapes and parameter counts

What it is

Each nn.Linear(a, b) holds a (b, a) weight matrix and a (b,) bias, so a·b + b parameters, and maps (batch, a) to (batch, b). Walking that stage by stage answers most practical questions before training: where the parameters actually are (almost always the widest transition, which is why the first layer after a flatten dominates), whether the shapes chain correctly, and roughly how much memory activations will need. print(model) gives the structure and a two-line loop gives the counts — both are cheaper than discovering a shape mismatch at the last layer of a long forward pass.

Why it matters

Parameter counts are how architectural claims get checked. 'A deeper model' and 'a wider model' are meaningless comparisons unless the counts are stated, and 'this CNN beats the MLP' is only interesting if the CNN is not simply ten times larger. Making the table a habit also makes module 13's parameter-efficiency argument and module 18's scaling table read as arithmetic rather than assertion.

In the real world: Estimating memory and serving cost before deploying.

Visual example

MNIST MLP

Shapes chain left to right; the parameters concentrate in the first dense layer after the flatten.

stage-by-stage shapes and parameters
stageoutput shapeparameters
1Flatten(B, 784)0
2Linear(784, 256) + ReLU(B, 256)200,960
3Linear(256, 128) + ReLU(B, 128)32,896
4Linear(128, 10)(B, 10)1,290

Where 200,960 comes from

weight: 784 × 256 = 200,704

bias: 256

total: 200,960 — a·b + b for Linear(a, b)

import torch.nn as nn

model = nn.Sequential(
    nn.Flatten(),                 # (batch, 1, 28, 28) -> (batch, 784)
    nn.Linear(784, 256), nn.ReLU(),
    nn.Linear(256, 128), nn.ReLU(),
    nn.Linear(128, 10),
)
for name, param in model.named_parameters():
    print(f"{name:16s} {tuple(param.shape)!s:14s} {param.numel():>7,}")
print(f"{'total':16s} {'':14s} {sum(p.numel() for p in model.parameters()):>7,}")

Forgetting nn.Flatten() before the first Linear. A (batch, 1, 28, 28) tensor entering nn.Linear(784, 256) does not error in an obvious way — Linear acts on the last axis, so it fails on the size mismatch of 28 against 784 with a message about mat1 and mat2 shapes that points at the wrong thing.