- Explain how an artificial neuron computes its output and why non-linear activations are essential
- Compare sigmoid, tanh, ReLU and GELU by range, gradient behaviour and typical use
- State the universal approximation theorem precisely, including what it does not guarantee
- Derive the backpropagation equations for a two-layer network and verify them on a numeric example
- Implement and train a multilayer perceptron from scratch in NumPy
- Implement the same model in PyTorch and explain what autograd automates
Before this: Calculus and Gradients: How Networks Learn, Linear Algebra for AI, Linear and Logistic Regression, Optimization for Machine Learning
Every modern AI system, from image classifiers to large language models, is built from the same ingredients: layers of simple units that compute weighted sums followed by non-linear functions, trained by gradient descent with gradients computed by backpropagation. Transformers add attention and convolutional networks add weight sharing, but the training engine is identical. If you understand this lesson thoroughly, including being able to write backprop by hand, you understand what every deep learning framework is doing under the hood, and you can debug it when it misbehaves.
Intuition: a committee of simple detectors
Picture a single neuron as a tiny detector with a dial for each input. It multiplies each input by its dial setting (a weight), adds them up with an offset (a bias), and then passes the total through a "squashing" or "gating" function. One detector can only draw a single straight boundary. But a layer of many detectors, each drawing a different boundary, followed by another layer that combines their verdicts, can carve out curved and patchy regions. Learning is the process of turning all the dials, a little at a time, in whichever direction reduces the mistakes. Backpropagation is the bookkeeping that tells every dial, even ones buried deep in the network, which way to turn and by how much.
The neuron
A neuron with input vector , weights , bias and activation function computes
is the pre-activation and the activation. With the identity this is linear regression; with the sigmoid and a cross-entropy loss it is logistic regression. A neural network is many of these, wired together.
Activation functions
| Function | Formula | Range | Derivative | Notes |
|---|---|---|---|---|
| Sigmoid | , max 0.25 | Output layer for binary probabilities; saturates, so poor in hidden layers | ||
| Tanh | , max 1 | Zero-centred; still saturates; used in RNN gates and small MLPs | ||
| ReLU | 1 if , 0 if | Cheap, non-saturating for ; default for CNNs and MLPs; units can "die" | ||
| GELU | smooth | is the standard normal CDF; standard in Transformers |
Saturation means the derivative is near zero over most of the input range: for , the sigmoid's slope is below 0.007. Gradients flowing back through many saturated units shrink toward zero (the vanishing-gradient problem, covered in the next lesson). ReLU (popularized for deep networks around 2010 to 2012) avoids saturation for positive inputs, which made deep networks much easier to train; a dead ReLU is a unit whose pre-activation is negative for every input, so it never receives gradient again. GELU (Hendrycks and Gimpel, 2016) weights the input by the probability that a standard normal variable is below it, giving a smooth, slightly non-monotone curve; GELU and related gated variants such as SwiGLU are the usual choice in Transformer feed-forward blocks.
Why non-linearity is essential. Stack two linear layers: , which is just another linear map. Without a non-linear , any depth of network collapses to a single linear model.
The multilayer perceptron
A multilayer perceptron (MLP, also called a fully connected feed-forward network) stacks layers. We use the row-vector batch convention that NumPy and PyTorch use: a batch of examples is a matrix , and a layer with units has weights and bias (added to every row). A two-layer network (one hidden layer) for binary classification:
Here , are the hidden activations, , and are predicted probabilities. Computing these in order is the forward pass. The hidden layer learns a new representation of the input in which the classes are (closer to) linearly separable, and the output layer is a logistic regression on that representation. This is the core idea of deep learning: learned features.
Universal approximation, stated precisely
Theorem (Cybenko 1989 for sigmoidal activations; Hornik 1991; Leshno, Lin, Pinkus and Schocken 1993 in the general form below). Let be continuous and not a polynomial. Let be compact and continuous. Then for every there exist a width and parameters , such that
In words: a network with one hidden layer and enough units can approximate any continuous function on a bounded, closed region as closely as you like, in the uniform (worst-case) sense. ReLU, tanh, sigmoid and GELU all qualify. (Leshno et al. also cover a broader class of activations that are not necessarily continuous; the statement above is a simplified version.)
What the theorem does not say:
- Nothing about how large is. For some functions the required width grows exponentially with . Depth can be exponentially more efficient than width for certain function classes, which is one theoretical motivation for deep networks.
- Nothing about learning. Existence of good weights does not mean gradient descent will find them.
- Nothing about generalization. Fitting a function on the training set says nothing about unseen data.
- Only compact domains. Outside the region seen in training, behaviour is unconstrained, which is one reason networks extrapolate poorly.
The loss
For binary classification with labels and predicted probabilities , the binary cross-entropy (negative log-likelihood of a Bernoulli model) is
For regression the usual choice is mean squared error, and for multiclass classification softmax with categorical cross-entropy. Training means finding parameters that minimize by gradient descent: with learning rate .
Backpropagation: the derivation
Backpropagation is the chain rule applied systematically from the loss backwards, reusing intermediate results so that computing all gradients costs about the same as two or three forward passes. (The method has several independent origins; it was popularized for neural networks by Rumelhart, Hinton and Williams in 1986.) Write for the gradient of the loss with respect to a pre-activation.
Step 1: output layer. For a single example, with and BCE loss :
Multiplying, . This clean result is why sigmoid is paired with cross-entropy (and softmax with categorical cross-entropy, which gives the same form). For the batch mean loss:
Step 2: output weights. Since , each entry of multiplies hidden activations, so
Step 3: back into the hidden layer. The gradient with respect to the hidden activations is . Passing through the element-wise activation multiplies by its derivative:
where is element-wise multiplication.
Step 4: first-layer weights.
The pattern generalizes to any depth: each layer receives an upstream gradient, multiplies by its local derivative, produces weight gradients as (layer input), and passes further back. A quick sanity check: every gradient has the same shape as the parameter it belongs to.
Worked example with numbers
A tiny regression network: input , two ReLU hidden units, one linear output, loss with target .
Parameters: , , , .
Forward.
- .
- .
- .
- .
Backward.
- .
- ; .
- .
- Both pre-activations are positive, so ReLU passes the gradient through: .
- ; .
Gradient check. Nudge (row 2, column 1) from 0.25 to : the first hidden pre-activation grows by , by , and the loss by about , so the numerical derivative is 4, matching the analytic value.
Update with : , , , . New forward pass: , , loss , down from 0.5. (With this example overshoots to and loss about 1.54: a first taste of why the learning rate matters.)
A from-scratch NumPy MLP
The code below trains a 2-16-1 network with tanh hidden units and a sigmoid output on an XOR-style problem (label 1 when both coordinates have the same sign), which no linear model can solve.
import numpy as np
rng = np.random.default_rng(0)
X = rng.uniform(-1, 1, size=(400, 2))
y = (X[:, 0] * X[:, 1] > 0).astype(float).reshape(-1, 1)
def sigmoid(z): return 1 / (1 + np.exp(-z))
d, m = 2, 16
W1 = rng.normal(0, np.sqrt(1 / d), (d, m)); b1 = np.zeros(m)
W2 = rng.normal(0, np.sqrt(1 / m), (m, 1)); b2 = np.zeros(1)
lr, n = 0.5, len(X)
for step in range(5001):
Z1 = X @ W1 + b1; H = np.tanh(Z1) # forward
Z2 = H @ W2 + b2; P = sigmoid(Z2)
loss = -np.mean(y * np.log(P + 1e-12) + (1 - y) * np.log(1 - P + 1e-12))
dZ2 = (P - y) / n # backward: Delta_2
dW2 = H.T @ dZ2; db2 = dZ2.sum(0)
dZ1 = (dZ2 @ W2.T) * (1 - H**2) # Delta_1, tanh' = 1 - tanh^2
dW1 = X.T @ dZ1; db1 = dZ1.sum(0)
W1 -= lr * dW1; b1 -= lr * db1; W2 -= lr * dW2; b2 -= lr * db2
if step % 1000 == 0:
print(f"step {step:4d} loss {loss:.4f} acc {((P > 0.5) == y).mean():.3f}")
Each line of the backward pass is one equation from the derivation. Running it, the loss falls from about 0.70 (chance) to below 0.05 and training accuracy reaches about 99%. Weights are initialized randomly with variance ; if all weights started equal, every hidden unit would compute the same function and receive the same gradient forever (the symmetry problem). Initialization is covered in depth in the next lesson.
The same model in PyTorch
PyTorch builds a computation graph as the forward pass runs and computes all gradients with loss.backward() (reverse-mode automatic differentiation, which is backpropagation generalized to any differentiable program).
import torch
from torch import nn
torch.manual_seed(0)
X = torch.rand(400, 2) * 2 - 1
y = (X[:, 0] * X[:, 1] > 0).float().unsqueeze(1)
model = nn.Sequential(nn.Linear(2, 16), nn.Tanh(), nn.Linear(16, 1))
loss_fn = nn.BCEWithLogitsLoss() # sigmoid + BCE, numerically stable
opt = torch.optim.SGD(model.parameters(), lr=0.5)
for step in range(5001):
logits = model(X) # forward pass
loss = loss_fn(logits, y)
opt.zero_grad() # clear old gradients
loss.backward() # backprop: fills p.grad for every parameter
opt.step() # p -= lr * p.grad
if step % 1000 == 0:
acc = ((logits > 0).float() == y).float().mean()
print(f"step {step:4d} loss {loss.item():.4f} acc {acc.item():.3f}")
Notes: BCEWithLogitsLoss takes raw scores (logits) and applies the sigmoid internally using a numerically stable formula, so the model has no final sigmoid; a logit above 0 corresponds to probability above 0.5. zero_grad() is required because PyTorch accumulates gradients across backward() calls. nn.Linear stores its weight as (out, in) and computes , the transpose of our notation, with the same result.
Common pitfalls
- Forgetting the non-linearity, or putting a ReLU after the final layer of a regression model so it can never predict negative values.
- Applying sigmoid twice: a sigmoid layer in the model plus
BCEWithLogitsLoss. Use logits with the "with logits" losses. - Not zeroing gradients in PyTorch, so each step uses the sum of all past gradients.
- Shape bugs that broadcast silently, such as a target of shape
(n,)against predictions of shape(n, 1), producing an loss matrix. Check shapes explicitly. - Zero or identical initialization, which leaves hidden units symmetric.
- Learning rate too high or too low: divergence or loss that barely moves. Try a few values spaced by factors of 3 to 10.
- Hand-written backprop without a gradient check: compare against finite differences on a tiny network before trusting it.
In practice
- Nobody writes backprop by hand in production; frameworks (PyTorch, JAX, TensorFlow) do it. But engineers who understand it can read error messages, spot exploding or vanishing gradients, write custom layers, and reason about memory (the forward activations must be stored for the backward pass, which is why training uses far more memory than inference).
- For tabular data, MLPs are often beaten by gradient-boosted trees; their strength is learned representations of images, audio, text and other raw signals, and they appear inside every larger architecture (the feed-forward block of a Transformer is a two-layer MLP).
- Use ReLU or GELU in hidden layers, a linear output with an appropriate "with logits" loss, and the Adam or AdamW optimizer as a default; the next lesson covers making deep versions train reliably.
- When a model will not learn, shrink the problem: a tiny network, a handful of examples, a gradient check. If it cannot overfit ten examples, there is a bug.
Key takeaways
- A neuron computes a weighted sum plus bias followed by a non-linear activation, and without non-linearity any stack of layers collapses to a linear model.
- ReLU and GELU avoid the saturation that makes sigmoid and tanh hidden layers slow to train, while sigmoid remains the right output for binary probabilities.
- The universal approximation theorem guarantees that a wide enough one-hidden-layer network can approximate any continuous function on a compact set, but says nothing about size, trainability or generalization.
- Backpropagation is the chain rule applied backwards: each layer's weight gradient is its input transposed times the upstream delta, and the delta passed back is multiplied by the activation derivative.
- Sigmoid with cross-entropy gives the simple output gradient p - y, which is why the pairing (and softmax with categorical cross-entropy) is standard.
- PyTorch automates backprop with autograd, but you must zero gradients, use logit-based losses, and verify shapes.
Terms in this lesson
- Activation function
- The element-wise non-linear function applied to a neuron's pre-activation, such as ReLU, tanh, sigmoid or GELU.
- Pre-activation
- The weighted sum plus bias, z = w·x + b, computed before the activation function.
- Multilayer perceptron
- A feed-forward network of fully connected layers with non-linear activations between them.
- Forward pass
- Computing a network's outputs and loss from its inputs layer by layer, storing intermediate values for the backward pass.
- Backpropagation
- An algorithm that computes the gradient of the loss with respect to every parameter by applying the chain rule from the output backwards.
- Saturation
- The regime where an activation's derivative is near zero, so little gradient flows through the unit.
- Logit
- The raw, unnormalized score output by a model before a sigmoid or softmax converts it to a probability.
- Autograd
- Automatic differentiation that records operations during the forward pass and computes gradients in reverse.
- Gradient check
- Comparing analytic gradients with finite-difference estimates to detect bugs in a backward pass.
Check your understanding
- 1.
What is the derivative of the sigmoid at z = 0?
- 2.
Which statement about the universal approximation theorem is correct?
- 3.
A network's output layer is a sigmoid with binary cross-entropy loss. For an example with y = 1 and predicted p = 0.8, what is the gradient of the loss with respect to the pre-activation z?
- 4.
In the batch formulation Z1 = X W1 + b1 with X of shape (n, d) and Delta1 = dL/dZ1 of shape (n, m), how is dL/dW1 computed?
- 5.
Your PyTorch model ends with nn.Sigmoid() and you train with nn.BCEWithLogitsLoss. What happens?
Exercises
Work each problem before opening the solution.
- Exercise 1Core
A neuron has weights , bias and input . Compute its output with (a) sigmoid, (b) tanh, (c) ReLU, and give each activation's derivative at that point.
Show solution
.
(a) . Derivative .
(b) . Derivative .
(c) . Derivative 1.
Note how the sigmoid passes back less than a quarter of the gradient even at this modest input, while ReLU passes it all.
- Exercise 2Core
For the worked example in the lesson (, , , , ), change the bias to . Recompute the forward pass, the loss with , and the gradient with respect to . Explain the zero column.
Show solution
Forward: , so and . . Loss .
Backward: . . ReLU derivative is because , so .
, .
The second column is zero because the second hidden unit is inactive: ReLU's derivative is 0 for negative inputs, so no gradient reaches its weights. If a unit is inactive for every training input, it never updates: a dead ReLU.
- Exercise 3Core
Write a NumPy function that numerically checks the analytic gradient
dW1from the lesson's MLP using central differences on a random 5-example batch, and report the maximum relative error.Show solution
import numpy as np rng = np.random.default_rng(1) X = rng.normal(size=(5, 2)); y = rng.integers(0, 2, (5, 1)).astype(float) W1 = rng.normal(size=(2, 4)); b1 = np.zeros(4) W2 = rng.normal(size=(4, 1)); b2 = np.zeros(1) def loss_fn(W1): H = np.tanh(X @ W1 + b1); P = 1 / (1 + np.exp(-(H @ W2 + b2))) return -np.mean(y * np.log(P) + (1 - y) * np.log(1 - P)) # analytic gradient (same equations as the lesson) H = np.tanh(X @ W1 + b1); P = 1 / (1 + np.exp(-(H @ W2 + b2))) dZ2 = (P - y) / len(X) dW1 = X.T @ ((dZ2 @ W2.T) * (1 - H**2)) # numerical gradient by central differences num = np.zeros_like(W1); eps = 1e-5 for i in range(W1.shape[0]): for j in range(W1.shape[1]): Wp = W1.copy(); Wp[i, j] += eps Wm = W1.copy(); Wm[i, j] -= eps num[i, j] = (loss_fn(Wp) - loss_fn(Wm)) / (2 * eps) rel = np.abs(num - dW1) / np.maximum(1e-12, np.abs(num) + np.abs(dW1)) print("max relative error:", rel.max())A correct implementation gives a maximum relative error around or smaller in float64. Errors above about indicate a bug. Central differences have error , better than the of one-sided differences.
- Exercise 4Challenge
Extend the backprop derivation to a softmax output with categorical cross-entropy. For logits , and loss with one-hot . Show that , then compute it for with true class 1 (the first class).
Show solution
Softmax Jacobian. .
Chain rule. , using .
Numbers. , , ; sum . . With : , and the loss is .
The gradient pushes the true-class logit up and the others down in proportion to their probability. Because the result has the same form as sigmoid with BCE, the rest of the backward pass is unchanged. In practice, frameworks fuse softmax and cross-entropy (
nn.CrossEntropyLosstakes logits) for numerical stability, computing via the log-sum-exp trick. - Exercise 5Challenge
Modify the PyTorch example to use two hidden layers of 32 ReLU units, the Adam optimizer with learning rate 0.01, and mini-batches of 64 using a DataLoader. Train for 200 epochs and report final training accuracy.
Show solution
import torch from torch import nn from torch.utils.data import TensorDataset, DataLoader torch.manual_seed(0) X = torch.rand(400, 2) * 2 - 1 y = (X[:, 0] * X[:, 1] > 0).float().unsqueeze(1) loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True) model = nn.Sequential( nn.Linear(2, 32), nn.ReLU(), nn.Linear(32, 32), nn.ReLU(), nn.Linear(32, 1)) loss_fn = nn.BCEWithLogitsLoss() opt = torch.optim.Adam(model.parameters(), lr=0.01) for epoch in range(200): model.train() for xb, yb in loader: loss = loss_fn(model(xb), yb) opt.zero_grad(); loss.backward(); opt.step() model.eval() with torch.no_grad(): acc = ((model(X) > 0).float() == y).float().mean().item() print(f"train accuracy {acc:.3f}")With 400 points, 7 mini-batches per epoch and 200 epochs (1,400 steps), training accuracy should reach roughly 98 to 100%. Points to note:
shuffle=Truereshuffles each epoch;torch.no_grad()disables graph building during evaluation;model.train()/model.eval()matter once you add dropout or batch norm. To judge generalization, generate a separate test set the same way and report accuracy on it.
Further reading
- bookGoodfellow, Bengio & Courville, Deep Learning, Chapter 6: Deep Feedforward Networks (MIT Press, 2016)
- bookZhang, Lipton, Li & Smola, Dive into Deep Learning, Chapter 5: Multilayer Perceptrons
- paperRumelhart, Hinton & Williams, Learning representations by back-propagating errors (Nature, 1986)
- paperCybenko, Approximation by superpositions of a sigmoidal function (Mathematics of Control, Signals and Systems, 1989)
- paperLeshno, Lin, Pinkus & Schocken, Multilayer feedforward networks with a nonpolynomial activation function can approximate any function (NYU working paper; published in Neural Networks, 1993)
- paperHendrycks & Gimpel, Gaussian Error Linear Units (GELUs) (2016)
Go further
Sources
Fact-checked and last reviewed 2026-11-05