PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Neural Networks and Backpropagation

Intermediate 16 min· 5 exercises

Build a multilayer perceptron from first principles: neurons, activations, the forward pass, the loss, and a full backpropagation derivation, then implement and train one from scratch in NumPy and again in PyTorch.

By the end of this lesson you will be able to
  • 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 x∈Rdx \in \mathbb{R}^d, weights w∈Rdw \in \mathbb{R}^d, bias b∈Rb \in \mathbb{R} and activation function ϕ\phi computes

z=w⊤x+b,a=ϕ(z).z = w^\top x + b, \qquad a = \phi(z).

zz is the pre-activation and aa the activation. With ϕ\phi the identity this is linear regression; with ϕ\phi the sigmoid and a cross-entropy loss it is logistic regression. A neural network is many of these, wired together.

Activation functions

FunctionFormulaRangeDerivativeNotes
Sigmoidσ(z)=11+e−z\sigma(z) = \frac{1}{1+e^{-z}}(0,1)(0,1)σ(z)(1−σ(z))\sigma(z)(1-\sigma(z)), max 0.25Output layer for binary probabilities; saturates, so poor in hidden layers
Tanhtanh⁡(z)=ez−e−zez+e−z\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}(−1,1)(-1,1)1−tanh⁡2(z)1 - \tanh^2(z), max 1Zero-centred; still saturates; used in RNN gates and small MLPs
ReLUmax⁡(0,z)\max(0, z)[0,∞)[0,\infty)1 if z>0z>0, 0 if z<0z<0Cheap, non-saturating for z>0z>0; default for CNNs and MLPs; units can "die"
GELUz Φ(z)z\,\Phi(z)≈[−0.17,∞)\approx[-0.17,\infty)smoothΦ\Phi is the standard normal CDF; standard in Transformers

Saturation means the derivative is near zero over most of the input range: for ∣z∣>5|z| > 5, 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: W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)W_2(W_1 x + b_1) + b_2 = (W_2 W_1)x + (W_2 b_1 + b_2), which is just another linear map. Without a non-linear ϕ\phi, 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 nn examples is a matrix X∈Rn×dX \in \mathbb{R}^{n \times d}, and a layer with mm units has weights W∈Rd×mW \in \mathbb{R}^{d \times m} and bias b∈Rmb \in \mathbb{R}^{m} (added to every row). A two-layer network (one hidden layer) for binary classification:

Z1=XW1+b1,H=ϕ(Z1),Z2=HW2+b2,P=σ(Z2).Z_1 = X W_1 + b_1, \quad H = \phi(Z_1), \quad Z_2 = H W_2 + b_2, \quad P = \sigma(Z_2).

Here W1∈Rd×mW_1 \in \mathbb{R}^{d\times m}, H∈Rn×mH \in \mathbb{R}^{n\times m} are the hidden activations, W2∈Rm×1W_2 \in \mathbb{R}^{m\times 1}, and P∈(0,1)n×1P \in (0,1)^{n\times 1} 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 ϕ:R→R\phi: \mathbb{R}\to\mathbb{R} be continuous and not a polynomial. Let K⊂RdK \subset \mathbb{R}^d be compact and f:K→Rf: K \to \mathbb{R} continuous. Then for every ε>0\varepsilon > 0 there exist a width NN and parameters aj,bj∈Ra_j, b_j \in \mathbb{R}, wj∈Rdw_j \in \mathbb{R}^d such that

sup⁡x∈K∣f(x)−∑j=1Naj ϕ(wj⊤x+bj)∣<ε.\sup_{x \in K} \left| f(x) - \sum_{j=1}^{N} a_j \, \phi(w_j^\top x + b_j) \right| < \varepsilon .

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 NN is. For some functions the required width grows exponentially with dd. 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 yi∈{0,1}y_i \in \{0,1\} and predicted probabilities pip_i, the binary cross-entropy (negative log-likelihood of a Bernoulli model) is

L=−1n∑i=1n[yilog⁡pi+(1−yi)log⁡(1−pi)].\mathcal{L} = -\frac{1}{n}\sum_{i=1}^{n}\big[y_i \log p_i + (1-y_i)\log(1-p_i)\big].

For regression the usual choice is mean squared error, and for multiclass classification softmax with categorical cross-entropy. Training means finding parameters θ=(W1,b1,W2,b2)\theta = (W_1, b_1, W_2, b_2) that minimize L\mathcal{L} by gradient descent: θ←θ−η∇θL\theta \leftarrow \theta - \eta \nabla_\theta \mathcal{L} with learning rate η\eta.

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 δ\delta for the gradient of the loss with respect to a pre-activation.

Step 1: output layer. For a single example, with p=σ(z2)p = \sigma(z_2) and BCE loss ℓ=−[ylog⁡p+(1−y)log⁡(1−p)]\ell = -[y\log p + (1-y)\log(1-p)]:

∂ℓ∂p=−yp+1−y1−p,∂p∂z2=p(1−p).\frac{\partial \ell}{\partial p} = -\frac{y}{p} + \frac{1-y}{1-p}, \qquad \frac{\partial p}{\partial z_2} = p(1-p).

Multiplying, ∂ℓ∂z2=−y(1−p)+(1−y)p=p−y\frac{\partial \ell}{\partial z_2} = -y(1-p) + (1-y)p = p - y. 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:

Δ2=∂L∂Z2=1n(P−Y)∈Rn×1.\Delta_2 = \frac{\partial \mathcal{L}}{\partial Z_2} = \frac{1}{n}(P - Y) \in \mathbb{R}^{n\times 1}.

Step 2: output weights. Since Z2=HW2+b2Z_2 = H W_2 + b_2, each entry of W2W_2 multiplies hidden activations, so

∂L∂W2=H⊤Δ2,∂L∂b2=∑i(Δ2)i.\frac{\partial \mathcal{L}}{\partial W_2} = H^\top \Delta_2, \qquad \frac{\partial \mathcal{L}}{\partial b_2} = \sum_{i} (\Delta_2)_{i} .

Step 3: back into the hidden layer. The gradient with respect to the hidden activations is ∂L∂H=Δ2W2⊤\frac{\partial \mathcal{L}}{\partial H} = \Delta_2 W_2^\top. Passing through the element-wise activation multiplies by its derivative:

Δ1=∂L∂Z1=(Δ2W2⊤)⊙ϕ′(Z1),\Delta_1 = \frac{\partial \mathcal{L}}{\partial Z_1} = (\Delta_2 W_2^\top) \odot \phi'(Z_1),

where ⊙\odot is element-wise multiplication.

Step 4: first-layer weights.

∂L∂W1=X⊤Δ1,∂L∂b1=∑i(Δ1)i,:.\frac{\partial \mathcal{L}}{\partial W_1} = X^\top \Delta_1, \qquad \frac{\partial \mathcal{L}}{\partial b_1} = \sum_i (\Delta_1)_{i,:} .

The pattern generalizes to any depth: each layer receives an upstream gradient, multiplies by its local derivative, produces weight gradients as (layer input)⊤×δ^\top \times \delta, and passes δW⊤\delta W^\top 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 x=(1,2)x = (1, 2), two ReLU hidden units, one linear output, loss ℓ=12(y^−y)2\ell = \frac{1}{2}(\hat y - y)^2 with target y=1y = 1.

Parameters: W1=(0.5−10.250.5)W_1 = \begin{pmatrix} 0.5 & -1 \\ 0.25 & 0.5 \end{pmatrix}, b1=(0,0.5)b_1 = (0, 0.5), w2=(2,−1)w_2 = (2, -1), b2=0.5b_2 = 0.5.

Forward.

  • z1=xW1+b1=(1⋅0.5+2⋅0.25,  1⋅(−1)+2⋅0.5)+(0,0.5)=(1.0,0.0)+(0,0.5)=(1.0,0.5)z_1 = x W_1 + b_1 = (1\cdot0.5 + 2\cdot0.25,\; 1\cdot(-1) + 2\cdot0.5) + (0, 0.5) = (1.0, 0.0) + (0, 0.5) = (1.0, 0.5).
  • h=ReLU(z1)=(1.0,0.5)h = \text{ReLU}(z_1) = (1.0, 0.5).
  • y^=h⋅w2+b2=2.0−0.5+0.5=2.0\hat y = h \cdot w_2 + b_2 = 2.0 - 0.5 + 0.5 = 2.0.
  • ℓ=12(2.0−1)2=0.5\ell = \frac{1}{2}(2.0 - 1)^2 = 0.5.

Backward.

  • ∂ℓ/∂y^=y^−y=1.0\partial \ell/\partial \hat y = \hat y - y = 1.0.
  • ∂ℓ/∂w2=1.0⋅h=(1.0,0.5)\partial \ell/\partial w_2 = 1.0 \cdot h = (1.0, 0.5); ∂ℓ/∂b2=1.0\partial \ell/\partial b_2 = 1.0.
  • ∂ℓ/∂h=1.0⋅w2=(2,−1)\partial \ell/\partial h = 1.0 \cdot w_2 = (2, -1).
  • Both pre-activations are positive, so ReLU passes the gradient through: δ1=(2,−1)\delta_1 = (2, -1).
  • ∂ℓ/∂W1=x⊤δ1=(1⋅21⋅(−1)2⋅22⋅(−1))=(2−14−2)\partial \ell/\partial W_1 = x^\top \delta_1 = \begin{pmatrix} 1\cdot 2 & 1\cdot(-1) \\ 2\cdot 2 & 2\cdot(-1)\end{pmatrix} = \begin{pmatrix} 2 & -1 \\ 4 & -2 \end{pmatrix}; ∂ℓ/∂b1=(2,−1)\partial \ell/\partial b_1 = (2, -1).

Gradient check. Nudge W1[1,0]W_1[1,0] (row 2, column 1) from 0.25 to 0.25+10−60.25 + 10^{-6}: the first hidden pre-activation grows by x2×10−6=2×10−6x_2 \times 10^{-6} = 2\times10^{-6}, y^\hat y by 4×10−64\times10^{-6}, and the loss by about 4×10−64\times 10^{-6}, so the numerical derivative is 4, matching the analytic value.

Update with η=0.05\eta = 0.05: W1→(0.4−0.950.050.6)W_1 \to \begin{pmatrix} 0.4 & -0.95 \\ 0.05 & 0.6\end{pmatrix}, b1→(−0.1,0.55)b_1 \to (-0.1, 0.55), w2→(1.95,−1.025)w_2 \to (1.95, -1.025), b2→0.45b_2 \to 0.45. New forward pass: z1=(0.4,0.8)z_1 = (0.4, 0.8), y^=1.95(0.4)−1.025(0.8)+0.45=0.41\hat y = 1.95(0.4) - 1.025(0.8) + 0.45 = 0.41, loss =12(0.59)2≈0.174= \frac12(0.59)^2 \approx 0.174, down from 0.5. (With η=0.1\eta = 0.1 this example overshoots to y^≈−0.75\hat y \approx -0.75 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 1/fan-in1/\text{fan-in}; 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 xW⊤+bxW^\top + b, 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 n×nn\times n 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. 1.

    What is the derivative of the sigmoid at z = 0?

  2. 2.

    Which statement about the universal approximation theorem is correct?

  3. 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. 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. 5.

    Your PyTorch model ends with nn.Sigmoid() and you train with nn.BCEWithLogitsLoss. What happens?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    A neuron has weights w=(0.5,−0.3)w = (0.5, -0.3), bias b=0.1b = 0.1 and input x=(2,1)x = (2, 1). Compute its output with (a) sigmoid, (b) tanh, (c) ReLU, and give each activation's derivative at that point.

    Show solution

    z=0.5⋅2+(−0.3)⋅1+0.1=1.0−0.3+0.1=0.8z = 0.5\cdot2 + (-0.3)\cdot1 + 0.1 = 1.0 - 0.3 + 0.1 = 0.8.

    (a) σ(0.8)=1/(1+e−0.8)=1/(1+0.4493)≈0.690\sigma(0.8) = 1/(1 + e^{-0.8}) = 1/(1 + 0.4493) \approx 0.690. Derivative 0.690×0.310≈0.2140.690 \times 0.310 \approx 0.214.

    (b) tanh⁡(0.8)≈0.664\tanh(0.8) \approx 0.664. Derivative 1−0.6642≈0.5591 - 0.664^2 \approx 0.559.

    (c) ReLU(0.8)=0.8\text{ReLU}(0.8) = 0.8. 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.

  2. Exercise 2Core

    For the worked example in the lesson (x=(1,2)x = (1,2), W1=(0.5−10.250.5)W_1 = \begin{pmatrix} 0.5 & -1 \\ 0.25 & 0.5\end{pmatrix}, b1=(0,0.5)b_1 = (0, 0.5), w2=(2,−1)w_2 = (2,-1), b2=0.5b_2 = 0.5), change the bias to b1=(0,−0.5)b_1 = (0, -0.5). Recompute the forward pass, the loss with y=1y = 1, and the gradient with respect to W1W_1. Explain the zero column.

    Show solution

    Forward: xW1=(1.0,0.0)xW_1 = (1.0, 0.0), so z1=(1.0,−0.5)z_1 = (1.0, -0.5) and h=ReLU(z1)=(1.0,0)h = \text{ReLU}(z_1) = (1.0, 0). y^=2(1.0)+(−1)(0)+0.5=2.5\hat y = 2(1.0) + (-1)(0) + 0.5 = 2.5. Loss =12(2.5−1)2=1.125= \frac12(2.5 - 1)^2 = 1.125.

    Backward: ∂ℓ/∂y^=1.5\partial\ell/\partial\hat y = 1.5. ∂ℓ/∂h=1.5 w2=(3,−1.5)\partial\ell/\partial h = 1.5\,w_2 = (3, -1.5). ReLU derivative is (1,0)(1, 0) because z1,2<0z_{1,2} < 0, so δ1=(3,0)\delta_1 = (3, 0).

    ∂ℓ/∂W1=x⊤δ1=(3060)\partial\ell/\partial W_1 = x^\top\delta_1 = \begin{pmatrix} 3 & 0 \\ 6 & 0\end{pmatrix}, ∂ℓ/∂b1=(3,0)\partial\ell/\partial b_1 = (3, 0).

    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.

  3. Exercise 3Core

    Write a NumPy function that numerically checks the analytic gradient dW1 from 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 10−810^{-8} or smaller in float64. Errors above about 10−410^{-4} indicate a bug. Central differences have error O(ε2)O(\varepsilon^2), better than the O(ε)O(\varepsilon) of one-sided differences.

  4. Exercise 4Challenge

    Extend the backprop derivation to a softmax output with categorical cross-entropy. For logits z∈RKz \in \mathbb{R}^K, pk=ezk/∑jezjp_k = e^{z_k}/\sum_j e^{z_j} and loss ℓ=−∑kyklog⁡pk\ell = -\sum_k y_k \log p_k with one-hot yy. Show that ∂ℓ/∂z=p−y\partial \ell/\partial z = p - y, then compute it for z=(2,1,0)z = (2, 1, 0) with true class 1 (the first class).

    Show solution

    Softmax Jacobian. ∂pk/∂zj=pk(1[k=j]−pj)\partial p_k/\partial z_j = p_k(\mathbb{1}[k=j] - p_j).

    Chain rule. ∂ℓ∂zj=−∑kykpk pk(1[k=j]−pj)=−∑kyk(1[k=j]−pj)=−yj+pj∑kyk=pj−yj\frac{\partial \ell}{\partial z_j} = -\sum_k \frac{y_k}{p_k}\,p_k(\mathbb{1}[k=j] - p_j) = -\sum_k y_k(\mathbb{1}[k=j] - p_j) = -y_j + p_j\sum_k y_k = p_j - y_j, using ∑kyk=1\sum_k y_k = 1.

    Numbers. e2=7.389e^2 = 7.389, e1=2.718e^1 = 2.718, e0=1e^0 = 1; sum =11.107= 11.107. p≈(0.665,0.245,0.090)p \approx (0.665, 0.245, 0.090). With y=(1,0,0)y = (1, 0, 0): ∂ℓ/∂z=(−0.335,0.245,0.090)\partial\ell/\partial z = (-0.335, 0.245, 0.090), and the loss is −ln⁡0.665≈0.408-\ln 0.665 \approx 0.408.

    The gradient pushes the true-class logit up and the others down in proportion to their probability. Because the result has the same p−yp - y form as sigmoid with BCE, the rest of the backward pass is unchanged. In practice, frameworks fuse softmax and cross-entropy (nn.CrossEntropyLoss takes logits) for numerical stability, computing log⁡p\log p via the log-sum-exp trick.

  5. 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=True reshuffles 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

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026