Ch 1 — Neural Networks from Scratch¶
Learning Objectives¶
By the end of this chapter you will be able to:
- Explain the biological inspiration for artificial neurons and formalise the perceptron model mathematically.
- Compare activation functions — Sigmoid, Tanh, ReLU, Leaky ReLU, GELU, Swish — and justify when to use each.
- Derive the forward-propagation equations for a multi-layer perceptron (MLP) and trace tensor shapes layer by layer.
- Derive backpropagation from the chain rule and implement it without automatic differentiation.
- Implement a fully functional 2-layer MLP in NumPy and reproduce it in PyTorch using
nn.Module.
1. The Perceptron¶
1.1 Biological Inspiration¶
The neuron is the fundamental computational unit of the brain. A biological neuron receives electrochemical signals through dendrites, integrates them in the cell body (soma), and — if the integrated signal exceeds a threshold — fires an action potential along the axon to downstream neurons through synaptic junctions.
Warren McCulloch and Walter Pitts (1943) proposed the first mathematical abstraction: a binary threshold unit. Frank Rosenblatt's perceptron (1958) added learnable weights and a simple update rule, making it the ancestor of every modern neural network.
1.2 Mathematical Model¶
A single neuron computes a weighted sum of its inputs and passes the result through a nonlinear activation function:
where:
| Symbol | Shape | Meaning |
|---|---|---|
| \(x\) | \(\mathbb{R}^n\) | Input feature vector |
| \(w\) | \(\mathbb{R}^n\) | Learnable weight vector |
| \(b\) | \(\mathbb{R}\) | Learnable bias (threshold shift) |
| \(\sigma\) | \(\mathbb{R} \to \mathbb{R}\) | Nonlinear activation function |
| \(y\) | \(\mathbb{R}\) | Neuron output |
Why the Bias?
The bias \(b\) shifts the activation function horizontally, allowing the neuron to fire even when all inputs are zero. Without a bias, every decision boundary would pass through the origin — a severe restriction.
2. Activation Functions¶
Without nonlinearity, composing linear transformations produces another linear transformation. Nonlinear activations are what give deep networks their expressive power.
2.1 Sigmoid¶
Derivative: \(\sigma'(z) = \sigma(z)(1 - \sigma(z))\)
Maps any real input to \((0, 1)\). Historically used in output layers for binary classification and in early hidden layers.
2.2 Tanh¶
Derivative: \(\tanh'(z) = 1 - \tanh^2(z)\)
Maps inputs to \((-1, 1)\). Zero-centred, which helps gradient flow compared to Sigmoid.
2.3 ReLU (Rectified Linear Unit)¶
Derivative: \(\text{ReLU}'(z) = \mathbf{1}[z > 0]\) (1 if \(z > 0\), else 0)
The workhorse of modern deep learning. Sparse activation, no saturation for positive inputs, computationally cheap.
2.4 Leaky ReLU¶
Typically \(\alpha = 0.01\). Solves the "dying ReLU" problem where neurons get stuck outputting zero permanently.
2.5 GELU (Gaussian Error Linear Unit)¶
where \(\Phi\) is the cumulative distribution function of the standard normal. Approximation used in practice:
Used in BERT, GPT-2, and most modern Transformers. Smooth, stochastic interpretation: gates inputs by how likely they are under a standard normal.
2.6 Swish¶
Discovered via neural architecture search (Ramachandran et al., 2017). Similar to GELU in practice, unbounded above, bounded below.
2.7 Comparison Table¶
| Activation | Range | Gradient Saturation | Zero-Centred | Common Uses |
|---|---|---|---|---|
| Sigmoid | \((0, 1)\) | Yes (both sides) | No | Output (binary clf) |
| Tanh | \((-1, 1)\) | Yes (both sides) | Yes | RNN hidden states |
| ReLU | \([0, \infty)\) | Partial (negative side) | No | CNN, MLP hidden layers |
| Leaky ReLU | \((-\infty, \infty)\) | No | No | CNN when dying ReLU is a problem |
| GELU | \(\approx(-0.17, \infty)\) | Rarely | Approximately | Transformers, BERT, GPT |
| Swish | \(\approx(-0.28, \infty)\) | Rarely | Approximately | EfficientNet, modern CNNs |
The Dying ReLU Problem
If a ReLU neuron receives consistently negative pre-activations during training, its gradient is exactly zero. The neuron never updates and is permanently "dead." Solutions: Leaky ReLU, careful weight initialisation, smaller learning rates.
3. Multi-Layer Perceptron (MLP)¶
3.1 Architecture and Notation¶
An MLP stacks \(L\) layers. We index layers \(l = 1, 2, \ldots, L\) where \(l=0\) is the input:
| Symbol | Dimensions | Description |
|---|---|---|
| \(h^{(0)} = x\) | \(\mathbb{R}^{d_0}\) | Network input |
| \(W^{(l)}\) | \(\mathbb{R}^{d_l \times d_{l-1}}\) | Weight matrix at layer \(l\) |
| \(b^{(l)}\) | \(\mathbb{R}^{d_l}\) | Bias at layer \(l\) |
| \(z^{(l)} = W^{(l)} h^{(l-1)} + b^{(l)}\) | \(\mathbb{R}^{d_l}\) | Pre-activation |
| \(h^{(l)} = \sigma_l(z^{(l)})\) | \(\mathbb{R}^{d_l}\) | Post-activation |
| \(\hat{y} = h^{(L)}\) | \(\mathbb{R}^{d_L}\) | Network output |
3.2 Batch Processing¶
In practice we process a mini-batch of \(N\) examples simultaneously. All vectors become matrices:
(or equivalently, with the transposed weight convention):
where PyTorch uses (N, d_in) @ (d_in, d_out) + (d_out,) via broadcasting.
4. Forward Propagation — Concrete 2-Layer Example¶
Consider a network with: - Input dimension \(d_0 = 3\) - Hidden layer \(d_1 = 4\) neurons, ReLU activation - Output layer \(d_2 = 2\) neurons, Softmax (for 2-class classification) - Mini-batch \(N = 5\)
Step 1 — Layer 1 pre-activation:
Shapes: \((5, 3) \times (3, 4) + (4,) = (5, 4)\)
Step 2 — Layer 1 post-activation (ReLU):
Step 3 — Layer 2 pre-activation:
Shapes: \((5, 4) \times (4, 2) + (2,) = (5, 2)\)
Step 4 — Output (Softmax):
Each row of \(\hat{Y}\) sums to 1, giving a probability distribution over 2 classes.
5. Loss Functions¶
5.1 Mean Squared Error (Regression)¶
Penalises large errors quadratically. Gradient: \(\nabla_{\hat{y}_i} \mathcal{L} = \frac{2}{N}(\hat{y}_i - y_i)\).
5.2 Cross-Entropy Loss (Classification)¶
For multi-class classification with \(C\) classes, the true labels \(y_i\) are one-hot vectors:
Derivation with Softmax output:
Let logits be \(z_c\) and softmax probabilities be \(\hat{y}_c = e^{z_c} / \sum_k e^{z_k}\). The combined cross-entropy + softmax gradient is elegantly:
This is why the softmax + cross-entropy combination is almost always used together — the gradient has no exponential, making optimisation well-conditioned.
Numerical Stability of Softmax
To avoid overflow in \(e^{z_c}\) when logits are large, subtract the maximum: \(\hat{y}_c = e^{z_c - z_{\max}} / \sum_k e^{z_k - z_{\max}}\). This is mathematically equivalent but numerically stable.
6. Backpropagation¶
6.1 Intuition: The Chain Rule¶
Given a composite function \(\mathcal{L} = f(g(h(x)))\), the chain rule gives:
Backpropagation is the systematic application of the chain rule through the computational graph of a neural network, propagating error signals from the output back to every weight.
6.2 Full Derivation¶
Define the error signal at layer \(l\) as:
Output layer (\(l = L\)): For cross-entropy loss with softmax:
Hidden layers (propagating backwards): Apply the chain rule through the activation:
where \(\odot\) denotes element-wise multiplication. This is the core backpropagation recursion.
Weight gradients: Once we have \(\delta^{(l)}\), gradients for weights and biases at each layer:
In batch form (\(N\) examples):
6.3 Weight Update¶
With learning rate \(\eta\):
7. Vanishing and Exploding Gradients¶
7.1 Why They Happen¶
In a deep network, the error signal \(\delta^{(l)}\) is the product of many Jacobian matrices stacked through the chain rule. At layer \(l\) from the output:
If the spectral norm of each Jacobian factor is less than 1 (e.g. Sigmoid derivatives max at 0.25), gradients shrink exponentially — vanishing gradients. If spectral norms exceed 1, gradients grow exponentially — exploding gradients.
7.2 Gradient Clipping¶
Clip the global gradient norm before each update:
A common value is \(\tau = 1.0\). In PyTorch: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0).
7.3 Weight Initialisation¶
Poor initialisation can cause vanishing/exploding gradients from the very first forward pass.
Xavier / Glorot Initialisation (designed for Sigmoid/Tanh):
He Initialisation (designed for ReLU):
The factor of 2 compensates for ReLU setting half of activations to zero, preserving variance.
| Activation | Recommended Init | PyTorch Default |
|---|---|---|
| Sigmoid, Tanh | Xavier uniform | nn.init.xavier_uniform_ |
| ReLU | He normal | nn.init.kaiming_normal_ |
| GELU, Swish | He normal (approximately) | nn.init.kaiming_normal_ |
8. NumPy Implementation: 2-Layer MLP¶
"""
Two-layer MLP implemented from scratch in NumPy.
Architecture: Linear(d_in, d_hidden) -> ReLU -> Linear(d_hidden, d_out) -> Softmax
Loss: Cross-entropy
Optimiser: Mini-batch SGD
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
# ---------------------------------------------------------------------------
# Activation functions
# ---------------------------------------------------------------------------
def relu(z: NDArray[np.float64]) -> NDArray[np.float64]:
"""Rectified linear unit: max(0, z)."""
return np.maximum(0.0, z)
def relu_grad(z: NDArray[np.float64]) -> NDArray[np.float64]:
"""Element-wise gradient of ReLU."""
return (z > 0).astype(np.float64)
def softmax(z: NDArray[np.float64]) -> NDArray[np.float64]:
"""Numerically stable row-wise softmax.
Args:
z: Pre-softmax logits, shape (N, C).
Returns:
Probability matrix, shape (N, C). Each row sums to 1.
"""
shifted = z - z.max(axis=1, keepdims=True)
exp_z = np.exp(shifted)
return exp_z / exp_z.sum(axis=1, keepdims=True)
# ---------------------------------------------------------------------------
# Loss
# ---------------------------------------------------------------------------
def cross_entropy_loss(
y_hat: NDArray[np.float64],
y: NDArray[np.int64],
) -> float:
"""Categorical cross-entropy loss.
Args:
y_hat: Predicted probabilities, shape (N, C).
y: Ground-truth class indices, shape (N,).
Returns:
Scalar mean loss.
"""
n = y_hat.shape[0]
log_probs = np.log(y_hat[np.arange(n), y] + 1e-12)
return -float(log_probs.mean())
# ---------------------------------------------------------------------------
# MLP
# ---------------------------------------------------------------------------
class MLP:
"""Two-layer MLP with ReLU hidden activation and softmax output.
Args:
d_in: Input feature dimension.
d_hidden: Number of hidden units.
d_out: Number of output classes.
lr: Learning rate for SGD.
seed: Random seed for reproducibility.
"""
def __init__(
self,
d_in: int,
d_hidden: int,
d_out: int,
lr: float = 0.01,
seed: int = 42,
) -> None:
rng = np.random.default_rng(seed)
# He initialisation for ReLU-activated hidden layer
self.W1: NDArray[np.float64] = rng.normal(
0.0, np.sqrt(2.0 / d_in), size=(d_hidden, d_in)
)
self.b1: NDArray[np.float64] = np.zeros(d_hidden)
# Xavier initialisation for output layer (no ReLU)
self.W2: NDArray[np.float64] = rng.normal(
0.0, np.sqrt(1.0 / d_hidden), size=(d_out, d_hidden)
)
self.b2: NDArray[np.float64] = np.zeros(d_out)
self.lr = lr
# Cache for backprop
self._x: NDArray[np.float64] | None = None
self._z1: NDArray[np.float64] | None = None
self._h1: NDArray[np.float64] | None = None
self._z2: NDArray[np.float64] | None = None
self._y_hat: NDArray[np.float64] | None = None
# ------------------------------------------------------------------
# Forward pass
# ------------------------------------------------------------------
def forward(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
"""Run a forward pass.
Args:
x: Input batch, shape (N, d_in).
Returns:
Softmax probabilities, shape (N, d_out).
"""
self._x = x # (N, d_in)
self._z1 = x @ self.W1.T + self.b1 # (N, d_hidden)
self._h1 = relu(self._z1) # (N, d_hidden)
self._z2 = self._h1 @ self.W2.T + self.b2 # (N, d_out)
self._y_hat = softmax(self._z2) # (N, d_out)
return self._y_hat
# ------------------------------------------------------------------
# Backward pass
# ------------------------------------------------------------------
def backward(self, y: NDArray[np.int64]) -> None:
"""Compute gradients and update parameters.
Args:
y: Ground-truth class indices, shape (N,).
"""
n = self._y_hat.shape[0] # type: ignore[union-attr]
# Output layer delta: softmax + cross-entropy combined gradient
delta2 = self._y_hat.copy() # (N, d_out)
delta2[np.arange(n), y] -= 1.0
delta2 /= n
# Gradients for W2, b2
dW2 = delta2.T @ self._h1 # (d_out, d_hidden)
db2 = delta2.sum(axis=0) # (d_out,)
# Propagate through hidden layer
delta1 = delta2 @ self.W2 # (N, d_hidden)
delta1 *= relu_grad(self._z1) # element-wise mask
# Gradients for W1, b1
dW1 = delta1.T @ self._x # (d_hidden, d_in)
db1 = delta1.sum(axis=0) # (d_hidden,)
# SGD update
self.W1 -= self.lr * dW1
self.b1 -= self.lr * db1
self.W2 -= self.lr * dW2
self.b2 -= self.lr * db2
# ------------------------------------------------------------------
# Training loop
# ------------------------------------------------------------------
def fit(
self,
x_train: NDArray[np.float64],
y_train: NDArray[np.int64],
epochs: int = 100,
batch_size: int = 32,
verbose: bool = True,
) -> list[float]:
"""Train the MLP using mini-batch SGD.
Args:
x_train: Training inputs, shape (N, d_in).
y_train: Training labels, shape (N,).
epochs: Number of full passes over the data.
batch_size: Mini-batch size.
verbose: Print loss every 10 epochs.
Returns:
List of per-epoch mean losses.
"""
n = x_train.shape[0]
history: list[float] = []
rng = np.random.default_rng(0)
for epoch in range(epochs):
# Shuffle data
perm = rng.permutation(n)
x_shuf, y_shuf = x_train[perm], y_train[perm]
epoch_losses: list[float] = []
for start in range(0, n, batch_size):
xb = x_shuf[start : start + batch_size]
yb = y_shuf[start : start + batch_size]
y_hat = self.forward(xb)
loss = cross_entropy_loss(y_hat, yb)
self.backward(yb)
epoch_losses.append(loss)
mean_loss = float(np.mean(epoch_losses))
history.append(mean_loss)
if verbose and (epoch + 1) % 10 == 0:
print(f"Epoch {epoch + 1:4d}/{epochs} loss={mean_loss:.4f}")
return history
def predict(self, x: NDArray[np.float64]) -> NDArray[np.int64]:
"""Return predicted class indices."""
probs = self.forward(x)
return np.argmax(probs, axis=1).astype(np.int64)
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X, y = make_classification(
n_samples=1000, n_features=10, n_classes=3,
n_informative=6, random_state=0
)
X = StandardScaler().fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
model = MLP(d_in=10, d_hidden=64, d_out=3, lr=0.05)
history = model.fit(X_train, y_train, epochs=50, batch_size=32)
preds = model.predict(X_test)
acc = (preds == y_test).mean()
print(f"\nTest accuracy: {acc:.3f}")
9. PyTorch Implementation: 2-Layer MLP¶
"""
Two-layer MLP implemented in PyTorch using nn.Module.
Equivalent architecture to the NumPy version above.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
from torch.utils.data import DataLoader, TensorDataset
class MLP(nn.Module):
"""Two-layer MLP with ReLU hidden activation.
Args:
d_in: Input feature dimension.
d_hidden: Number of hidden units.
d_out: Number of output classes.
dropout: Dropout probability applied after hidden layer.
"""
def __init__(
self,
d_in: int,
d_hidden: int,
d_out: int,
dropout: float = 0.0,
) -> None:
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_in, d_hidden),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_hidden, d_out),
)
self._init_weights()
def _init_weights(self) -> None:
"""Apply He and Xavier initialisation to linear layers."""
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.kaiming_normal_(module.weight, nonlinearity="relu")
nn.init.zeros_(module.bias)
# Output layer: Xavier
nn.init.xavier_uniform_(self.net[-1].weight) # type: ignore[arg-type]
def forward(self, x: Tensor) -> Tensor:
"""Forward pass returning logits (pre-softmax).
Args:
x: Input batch, shape (N, d_in).
Returns:
Logits, shape (N, d_out).
"""
return self.net(x)
def train_one_epoch(
model: MLP,
loader: DataLoader,
optimiser: torch.optim.Optimizer,
device: torch.device,
) -> float:
"""Train for one epoch and return mean loss."""
model.train()
total_loss = 0.0
for x_batch, y_batch in loader:
x_batch, y_batch = x_batch.to(device), y_batch.to(device)
optimiser.zero_grad()
logits = model(x_batch)
loss = F.cross_entropy(logits, y_batch)
loss.backward()
optimiser.step()
total_loss += loss.item() * x_batch.size(0)
return total_loss / len(loader.dataset) # type: ignore[arg-type]
@torch.no_grad()
def evaluate(
model: MLP,
loader: DataLoader,
device: torch.device,
) -> tuple[float, float]:
"""Return (mean_loss, accuracy) on a dataset."""
model.eval()
total_loss, correct = 0.0, 0
for x_batch, y_batch in loader:
x_batch, y_batch = x_batch.to(device), y_batch.to(device)
logits = model(x_batch)
total_loss += F.cross_entropy(logits, y_batch, reduction="sum").item()
correct += (logits.argmax(dim=1) == y_batch).sum().item()
n = len(loader.dataset) # type: ignore[arg-type]
return total_loss / n, correct / n
if __name__ == "__main__":
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# --- Data ---
X, y = make_classification(
n_samples=1000, n_features=10, n_classes=3,
n_informative=6, random_state=0
)
X = StandardScaler().fit_transform(X).astype(np.float32)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_ds = TensorDataset(torch.from_numpy(X_tr), torch.tensor(y_tr))
test_ds = TensorDataset(torch.from_numpy(X_te), torch.tensor(y_te))
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=64)
# --- Model ---
model = MLP(d_in=10, d_hidden=64, d_out=3, dropout=0.1).to(device)
optimiser = torch.optim.Adam(model.parameters(), lr=1e-3)
# --- Training ---
for epoch in range(50):
train_loss = train_one_epoch(model, train_loader, optimiser, device)
if (epoch + 1) % 10 == 0:
_, acc = evaluate(model, test_loader, device)
print(f"Epoch {epoch+1:3d} train_loss={train_loss:.4f} test_acc={acc:.3f}")
Exercises¶
-
Activation gradient derivation. Derive the derivative of the Swish function \(f(z) = z \sigma(z)\) with respect to \(z\). Show that it equals \(\sigma(z) + z\sigma(z)(1 - \sigma(z))\).
-
Shape tracing. For an MLP with input dimension 784, hidden layers of sizes [512, 256, 128], and 10 output classes with a batch of 64, write out the shape of every weight matrix, bias vector, pre-activation matrix, and post-activation matrix.
-
Backprop by hand. Given a single neuron with \(w = [0.5, -0.3]\), \(b = 0.1\), input \(x = [1.0, 2.0]\), and sigmoid activation, compute (a) the forward pass, (b) the loss gradient \(d\mathcal{L}/dy = 1.0\), and © the gradients \(d\mathcal{L}/dw\) and \(d\mathcal{L}/db\).
-
Initialisation experiment. Modify the NumPy MLP to accept a choice of initialisation strategy (zeros, random uniform, Xavier, He). Train each variant on the
make_classificationdataset for 50 epochs and plot the loss curves. What do you observe? -
XOR problem. The XOR function is not linearly separable. Train the NumPy MLP on the four XOR data points \(\{(0,0) \to 0, (0,1) \to 1, (1,0) \to 1, (1,1) \to 0\}\). What is the minimum hidden layer size required for 100% training accuracy? Why?
Summary¶
| Concept | Key Formula | Practical Note |
|---|---|---|
| Perceptron | \(y = \sigma(w^Tx + b)\) | Single computational unit |
| ReLU | \(\max(0, z)\) | Default hidden activation |
| Forward pass | \(h^{(l)} = \sigma(W^{(l)} h^{(l-1)} + b^{(l)})\) | Cache \(z^{(l)}\) for backprop |
| Cross-entropy | \(-\frac{1}{N}\sum y \log \hat{y}\) | Combined with softmax |
| Backpropagation | \(\delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot \sigma'(z^{(l)})\) | Core recursion |
| He init | \(W \sim \mathcal{N}(0, 2/d_{\text{in}})\) | For ReLU activations |
The key insight of backpropagation is that it is not a special algorithm — it is the chain rule, applied systematically and cached efficiently. Modern automatic differentiation frameworks (PyTorch, JAX) do exactly this, but understanding the manual derivation is essential for debugging, designing custom architectures, and reasoning about gradient flow in novel network structures.