ml-models / Neural network fundamentals / Neurons, layers and the MLP intro

Neurons, layers and the MLP

A neuron is logistic regression with a different nonlinearity; a layer is many neurons sharing an input, which is one matrix multiply; a multilayer perceptron stacks layers with nonlinearities between them. This page shows why the nonlinearity is the whole point, what "universal approximation" does and doesn't promise, how to count an MLP's parameters, and how nn.Module, nn.Linear and nn.Sequential build one.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The forward pass
  5. Why the nonlinearity is needed
  6. Universal approximation
  7. Counting parameters
  8. How PyTorch builds one: nn.Module
  9. Worked example
  10. Implementation
  11. Tradeoffs
  12. Check yourself
  13. Connections
  14. Read first
  15. Read next
  16. References

Problem

Logistic regression and linear regression are limited by their features: they can only draw straight boundaries in the space you give them. For decades the fix was hand-made features: products of inputs, polynomials, edge detectors, word counts. Someone had to guess which features made the problem linear.

A neural network learns those features. Its early layers compute new features from the input, and its last layer is an ordinary linear or logistic model on top of them. Everything is trained together, by gradient descent, so the features are whatever makes the final layer's job easy. The multilayer perceptron (MLP) is the simplest network of this kind, and it is still everywhere: as the feed-forward block inside every Transformer layer, as the classification head on top of vision and language models, and as the standard model for tabular embeddings.

Intuition

One neuron takes a weighted sum of its inputs, adds a bias, and passes the result through a nonlinear activation: h = φ(w·x + b). With φ the sigmoid, that is logistic regression exactly. Modern networks mostly use the ReLU, φ(z) = max(0, z), which passes positive evidence through and blocks negative evidence.

A layer is many neurons reading the same input, each with its own weights. Stack the weight vectors as the rows of a matrix W and the whole layer is one matrix multiply, h = φ(Wx + b), followed by the activation applied element by element. A network is layers fed into layers. Each hidden layer re-describes the input in a new set of coordinates, and the output layer only has to draw a straight line in the last of them.

One neuron, and a small multilayer perceptron built from them Left: a single neuron. Three inputs x1, x2 and x3 feed arrows labelled w1, w2 and w3 into a circle that computes z = w·x + b, which feeds a box applying the activation φ, producing the output h. Right: a network with 2 inputs, a hidden layer of 3 neurons and 1 output. Every input connects to every hidden neuron, through the weight matrix W1 of shape 3 by 2 and bias b1 of 3 entries; every hidden neuron connects to the output through W2 of shape 1 by 3 and bias b2. That is 9 + 4 = 13 parameters. x1 w1 x2 w2 x3 w3 Σ + b φ h z = w·x + b,  h = φ(z) one neuron x1 x2 h1 h2 h3 y W1 [3, 2], b1 [3] W2 [1, 3], b2 [1] a 2-3-1 MLP: 9 + 4 = 13 parameters

Left: a neuron is a dot product, a bias and a nonlinearity. Right: a layer is several neurons on the same input. Each arrow is one weight, and each layer's arrows together are its weight matrix.

Mechanics

The forward pass

For a batch X of shape (N, d0), an MLP with layer widths d0, d1, …, dL computes

H0 = X,   Hℓ = φ(Hℓ−1Wℓᵀ + bℓ) for ℓ = 1 … L−1,   output = HL−1WLᵀ + bL

where Wℓ has shape (dℓ, dℓ−1), PyTorch's (out, in) convention, and bℓ is broadcast across the batch's rows (see tensors and shapes). The last layer has no activation: its outputs are logits or regression values, and the loss supplies the sigmoid or softmax, exactly as on the loss-functions page. "Depth" is the number of weight layers, L; "width" is the size of the hidden layers.

Why the nonlinearity is needed

Remove the activations and the network collapses. Two linear layers compose into one:

W2(W1x + b1) + b2 = (W2W1)x + (W2b1 + b2)

which is again just Wx + b. However many layers you stack, a network without activations can only represent linear functions, so depth buys nothing. (It does change the training dynamics, and a product of matrices can have lower rank than any of its factors; but the set of functions is the same.)

The classic counterexample is XOR: output 1 when exactly one of two binary inputs is 1. No line separates {(0, 1), (1, 0)} from {(0, 0), (1, 1)}; the best least-squares linear fit predicts 0.50 for all four. Two ReLU neurons fix it:

h1 = ReLU(x1 + x2),   h2 = ReLU(x1 + x2 − 1),   y = h1 − 2h2

The hidden layer maps the four corners to (0, 0), (1, 0), (1, 0) and (2, 1). In those coordinates, the two positives land on the same point and a line separates them from the negatives. The ReLU did the work: h2 is zero for three corners and switches on only for (1, 1), a bend no linear map can make.

XOR in input space and in the hidden layer's space Left: the four inputs of XOR on the unit square. (0, 1) and (1, 0) have target 1 and are filled; (0, 0) and (1, 1) have target 0 and are hollow. They sit on opposite diagonals, so no straight line separates them. Right: the same four inputs after the hidden layer h1 = ReLU(x1 + x2), h2 = ReLU(x1 + x2 − 1). They land at (0, 0), (1, 0), (1, 0), (2, 1): both positives at (1, 0), and the line h1 − 2·h2 = 0.5 separates them from the two negatives. The output y = h1 − 2·h2 is 0, 1, 1, 0, exactly XOR. no line works (0, 0) (0, 1) (1, 0) (1, 1) input space (x1, x2) h1 − 2·h2 = 0.5 (1, 0) ×2 (0, 0) (2, 1) hidden space (h1, h2)

The hidden layer moves the points so that a line works. Filled points are XOR's 1s and hollow points its 0s.

Universal approximation

How much can one hidden layer do? The universal approximation theorem (Cybenko 1989 for sigmoids; Hornik 1991 and Leshno et al. 1993 for any non-polynomial activation) says: any continuous function on a bounded region can be approximated to any accuracy by a network with one hidden layer, if the layer is wide enough.

For ReLU in one dimension the proof is a picture. Each unit ReLU(x − k) is a hinge that bends the output at x = k, so a sum of n hinges is a piecewise-linear curve with n bends. Put the bends at evenly spaced points, choose each output weight to be the change in slope there, and the network joins the dots of the target function:

y(x) = f(k0) + Σi ci ReLU(x − ki),   ci = slopei − slopei−1

More units, more bends, smaller error. What the theorem does not say matters as much. It doesn't say how wide "wide enough" is (in d dimensions this construction needs a number of units exponential in d); it doesn't say gradient descent will find the weights; and it says nothing about generalizing beyond the training data. In practice depth is what makes networks efficient: a deep network can reuse features, and some functions need exponentially more units when computed with one layer instead of several.

One-hidden-layer ReLU networks approximating sin(x) The function sin(x) from 0 to 2π, drawn as a smooth curve, with two ReLU networks that interpolate it at evenly spaced knots, marked by dots. With 3 hidden units (10 parameters) the network is a polyline with 3 pieces and a largest error of 0.437. With 8 hidden units (25 parameters) the network is a polyline with 8 pieces and a largest error of 0.070. -1 0 1 0 2 4 6 sin(x) 3 units: max error 0.437 8 units: max error 0.070 x (one hidden ReLU layer, output = sum of hinges)

Each hidden unit contributes one bend. With 3 units the network is a crude zigzag; with 8 the error is 6.21 times smaller. For a smooth target, error falls roughly as 1/n² for n evenly spaced bends.

Counting parameters

A layer from width din to dout has dout × din weights and dout biases. Activations have none. So an MLP with widths d0, …, dL has

Σℓ (dℓ−1 + 1) · dℓ parameters

dominated by the widest adjacent pair. The cost of a forward pass is about two floating-point operations per weight per example (one multiply, one add), so parameters and compute go together for an MLP. That's not true of every network: a convolution reuses its few weights at every position.

How PyTorch builds one: nn.Module

Everything with parameters in PyTorch is an nn.Module. A module does two jobs. It holds state, and it defines forward. The state bookkeeping is automatic: Module.__setattr__ intercepts attribute assignment, so writing self.fc = nn.Linear(4, 3) inside __init__ registers a submodule, and assigning an nn.Parameter registers a parameter. That registry is what parameters(), state_dict(), .to(device), train()/eval() and zero_grad() walk, recursively through submodules. A plain tensor attribute is not registered and silently won't be trained, moved or saved; use register_buffer for non-trained state.

Calling a module, model(x), runs Module.__call__, which checks for hooks and otherwise goes straight to your forward. Call the module, not forward directly, or hooks are skipped.

nn.Linear(in_features, out_features) stores weight with shape (out, in) and bias with shape (out,), computes x @ weight.T + bias for any number of leading batch dimensions, and initializes both uniformly in ±1/√in_features. nn.Sequential(*modules) registers its arguments as submodules named "0", "1", … and its forward is a four-line loop that feeds each module's output to the next. It covers any straight-line stack; anything with branches or skip connections needs a Module subclass with its own forward, and nn.ModuleList to hold a variable number of layers.

TermShape/ParamsMeaningSource
nn.Module _parameters, _buffers, _modules Base class of every layer and model: holds registered state and defines forward. module.py
Module.__setattr__ name, value Registers Parameters and submodules assigned as attributes. __setattr__
register_parameter, add_module name, tensor or module The explicit forms of that registration. module.py
Module.__call__ model(x) Runs hooks if any are registered, else calls forward directly. _call_impl
parameters, named_parameters iterator of tensors Walks the registry recursively; what you hand to an optimizer. module.py
nn.Linear weight (out, in), bias (out,) One layer of neurons: x @ weight.T + bias, uniform ±1/√in init. linear.py, reset_parameters, forward
nn.ReLU no parameters The activation max(0, z), element by element. activation.py
nn.Sequential modules named "0", "1", … A straight-line stack: forward feeds each output to the next module. container.py, forward
nn.ModuleList list of modules A registered list, for a variable number of layers in a custom forward. container.py

Worked example

XOR, layer by layer. Weights W1 = [[1, 1], [1, 1]], b1 = [0, −1], W2 = [[1, −2]], b2 = [0].

xz = W1x + b1h = ReLU(z)y = W2h + b2target
(0, 0)(0, -1)(0, 0)00
(0, 1)(1, 0)(1, 0)11
(1, 0)(1, 0)(1, 0)11
(1, 1)(2, 1)(2, 1)00

Only the first input's z2 = −1 is negative, so ReLU changes only that entry; that one clipped value is the nonlinearity that makes XOR possible. Now delete the ReLU. The network collapses to W2W1 = [-1, -1] and W2b1 + b2 = 2, that is y = 2 − x1 − x2, which outputs 2, 1, 1, 0: wrong on (0, 0), where z2 = −1 now flows through as −1 and W2's −2 turns it into +2.

Hinges for sin. With 3 units, the knots are at 0.000, 2.094, 4.189, 6.283 and sin there is 0.000, 0.866, -0.866, 0.000. The slopes of the three segments are 0.413, -0.827 and 0.413, so the output weights are their changes, c = (0.413, -1.240, 1.240), and the network is y = 0.413·ReLU(x) − 1.240·ReLU(x − 2.094) + 1.240·ReLU(x − 4.189). Each hidden unit has a weight (1) and a bias (−k), plus one output weight, plus the output bias: 3·3 + 1 = 10 parameters.

Counting an MNIST classifier. Widths 784 → 256 → 128 → 10 (28 × 28 pixels in, 10 digits out):

layerweightsbiasestotal
Linear(784, 256)784 × 256 = 200,704256200,960
Linear(256, 128)256 × 128 = 32,76812832,896
Linear(128, 10)128 × 10 = 1,280101,290
all235,146

The first layer holds 85% of the parameters. The feed-forward block in each layer of the base Transformer is the same object, 512 → 2048 → 512 with a ReLU between: 2,099,712 parameters, about two thirds of that layer.

Implementation

import torch
from torch import nn

# XOR with the hand-set weights from this page
xor = nn.Sequential(nn.Linear(2, 2), nn.ReLU(), nn.Linear(2, 1))
with torch.no_grad():
    xor[0].weight.copy_(torch.tensor([[1., 1.], [1., 1.]]))
    xor[0].bias.copy_(torch.tensor([0., -1.]))
    xor[2].weight.copy_(torch.tensor([[1., -2.]]))
    xor[2].bias.zero_()
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
xor(X).squeeze(1)                              # tensor([0., 1., 1., 0.])

# the MNIST-sized MLP, written as a Module subclass
class MLP(nn.Module):
    def __init__(self, sizes=(784, 256, 128, 10)):
        super().__init__()
        self.layers = nn.ModuleList(
            nn.Linear(a, b) for a, b in zip(sizes, sizes[1:])
        )

    def forward(self, x):                      # x: [batch, 784]
        for layer in self.layers[:-1]:
            x = torch.relu(layer(x))
        return self.layers[-1](x)              # logits: [batch, 10]

model = MLP()
sum(p.numel() for p in model.parameters())     # 235146
[name for name, _ in model.named_parameters()][:2]
# ['layers.0.weight', 'layers.0.bias']
model(torch.randn(32, 784)).shape              # torch.Size([32, 10])

The same forward pass in plain Python:

def relu(values):
    return [max(0, v) for v in values]

def linear(weight, bias, x):                   # weight: out rows of in columns
    return [sum(w * xi for w, xi in zip(row, x)) + b for row, b in zip(weight, bias)]

def mlp(layers, x):
    for i, (weight, bias) in enumerate(layers):
        x = linear(weight, bias, x)
        if i < len(layers) - 1:                  # no activation on the output layer
            x = relu(x)
    return x

layers = [([[1, 1], [1, 1]], [0, -1]), ([[1, -2]], [0])]
[mlp(layers, x)[0] for x in ([0, 0], [0, 1], [1, 0], [1, 1])]   # [0, 1, 1, 0]

Tradeoffs

  • Width versus depth. One wide layer can approximate anything in principle, but deep, narrower networks usually reach the same accuracy with far fewer parameters, because later layers reuse features computed by earlier ones. Depth makes optimization harder: gradients have to pass through every layer, which is why deep networks need careful initialization, normalization and residual connections.
  • No built-in structure. An MLP treats its input as an unordered vector. It knows nothing about neighbouring pixels or word order, so on images it needs far more parameters than a convolutional network, which shares weights across positions. Its strength is in places where the structure has already been handled: on top of learned embeddings, or as the per-token block inside a Transformer.
  • Parameters cost memory and data. A dense layer between two wide vectors is expensive: the first layer of a 4096 → 4096 MLP alone has 16.8 million weights. More parameters need more data or more regularization to avoid overfitting.
  • Non-convex training. Unlike the linear and logistic models, an MLP's loss has many minima and saddle points, and permuting hidden units gives identical networks. In practice gradient descent from a good random initialization finds good solutions, but there is no closed form and no guarantee.

Check yourself

A network has ten nn.Linear layers and no activations. What can it represent?

Only affine functions Wx + b. The layers multiply into a single matrix and a single bias, so it is no more expressive than one nn.Linear.

How many parameters does nn.Sequential(nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 5)) have?

10 × 20 + 20 = 220 for the first layer, 20 × 5 + 5 = 105 for the second, none for the ReLU: 325.

Inside __init__ you write self.scale = torch.ones(8, requires_grad=True). Why won't the optimizer update it?

A plain tensor isn't registered by Module.__setattr__, so it doesn't appear in model.parameters() and is never given to the optimizer (nor moved by .to() or saved in the state_dict). Wrap it in nn.Parameter.

Does the universal approximation theorem mean a one-hidden-layer network is all you need?

No. It promises that some wide enough network exists, not that it is small, that training will find it, or that it will generalize. Deeper networks are usually far more efficient.

Connections

References

  • Rosenblatt. The Perceptron: A Probabilistic Model for Information Storage and Organization in the Brain. Psychological Review, 1958.
  • Minsky and Papert. Perceptrons. MIT Press, 1969 (the XOR limitation of single-layer networks).
  • Rumelhart, Hinton and Williams. Learning Representations by Back-Propagating Errors. Nature, 1986.
  • Cybenko. Approximation by Superpositions of a Sigmoidal Function. Mathematics of Control, Signals and Systems, 1989.
  • Hornik. Approximation Capabilities of Multilayer Feedforward Networks. Neural Networks, 1991.
  • Leshno, Lin, Pinkus and Schocken. Multilayer Feedforward Networks with a Nonpolynomial Activation Function Can Approximate Any Function. Neural Networks, 1993.
  • Telgarsky. Benefits of Depth in Neural Networks. COLT, 2016 (arXiv 1602.04485).
  • Goodfellow, Bengio and Courville. Deep Learning, chapter 6. MIT Press, 2016.
  • PyTorch v2.15.0: torch/nn/modules/module.py, torch/nn/modules/linear.py and torch/nn/modules/container.py.