Skip to content
GKgkml.dev
← LLM mechanisms

Part 8 · Deep learning foundations · module 3 of 3 · 16 min

The essence of deep learning modelling

The perceptron and ANN architecture, a geometric reading, the three-part maths of forward propagation, loss and backpropagation — then both in PyTorch.

The essence

A neural network is a stack of linear transformations with a non-linearity between them. The linear part rotates and scales the space; the non-linearity bends it. Stack enough of those and you can warp the input space until a problem that was not linearly separable becomes so. Training is choosing the warp by gradient descent.

Why it matters

This is the base case of everything else. A transformer block is this pattern with a particular structure imposed on the linear parts. The forward pass, loss and backward pass here are exactly the three steps in every training loop in this curriculum, just written for one layer instead of ninety-six.

Topics covered (7)
  1. 01The perceptron and ANN architecture
  2. 02A geometric view of artificial neural networks
  3. 03ANN maths part 1: forward propagation
  4. 04ANN maths part 2: errors, loss and cost
  5. 05ANN maths part 3: backpropagation
  6. 06The forward pass in PyTorch
  7. 07Backpropagation in PyTorch

The perceptron, and what it cannot do

A perceptron computes a weighted sum of its inputs, adds a bias, and applies a threshold. Geometrically it defines a hyperplane and reports which side the input falls on — so it can only solve problems that are linearly separable.

XOR is the famous counterexample: no single straight line separates its two classes. That limitation stalled the field for years, and the resolution is the whole idea of deep learning. Add a hidden layer with a non-linearity and the network can first transform the space into coordinates where the classes are separable, then separate them.

The geometric reading

Think of each layer as acting on the whole input space rather than on one point. The weight matrix rotates, scales and shears the space; the bias translates it; the activation function bends it — ReLU folds everything on one side of a hyperplane flat against it.

A deep network is a sequence of such warps. Training searches for a sequence that leaves the classes linearly separable at the end, which is why the final layer can be a plain linear classifier. This is also the honest reading of 'the model learns representations': it learns coordinates in which the problem is easy.

Forward propagation, from scratch
import numpy as np

def relu(z):    return np.maximum(0, z)
def sigmoid(z): return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def forward(X, params):
    """X: [n_samples, n_features]. Cache everything the backward pass needs."""
    W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"]

    Z1 = X @ W1 + b1          # linear:     [n, hidden]
    A1 = relu(Z1)             # non-linear: [n, hidden]
    Z2 = A1 @ W2 + b2         # linear:     [n, out]
    A2 = sigmoid(Z2)          # output:     [n, out]

    return A2, {"X": X, "Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}

rng = np.random.default_rng(0)
n_in, n_hidden, n_out = 2, 8, 1
params = {
    # He initialisation: scale by sqrt(2/fan_in) to keep the variance of
    # activations stable through ReLU, which discards half the distribution.
    "W1": rng.normal(0, np.sqrt(2 / n_in), (n_in, n_hidden)),
    "b1": np.zeros(n_hidden),
    "W2": rng.normal(0, np.sqrt(2 / n_hidden), (n_hidden, n_out)),
    "b2": np.zeros(n_out),
}

# XOR — not linearly separable, so a hidden layer is required.
X = np.array([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = np.array([[0.], [1.], [1.], [0.]])
predictions, cache = forward(X, params)

Error, loss and cost

These three words are used loosely and mean different things. Error is the raw discrepancy for one example: prediction minus target. Loss is a function of that error for one example — squared error, or cross-entropy. Cost is the average loss over the batch, and it is the scalar you differentiate.

The choice of loss encodes what you consider a mistake. Squared error assumes symmetric, roughly Gaussian noise and is right for regression. Cross-entropy is right for classification, because it penalises confident errors without bound and its gradient is well-behaved where squared error's saturates. Using squared error with a sigmoid output is the classic mismatch: the gradient nearly vanishes exactly when the prediction is most wrong.

Losses, and why cross-entropy is used for classification
import numpy as np

def mse(y_pred, y_true):
    return float(np.mean((y_pred - y_true) ** 2))

def binary_cross_entropy(y_pred, y_true, eps=1e-12):
    p = np.clip(y_pred, eps, 1 - eps)          # clip, or log(0) = -inf
    return float(-np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p)))

y_true = np.array([[1.0]])
for p in [0.9, 0.5, 0.1, 0.01]:
    pred = np.array([[p]])
    print(f"p={p:<5} mse {mse(pred, y_true):.4f}   bce {binary_cross_entropy(pred, y_true):.4f}")
# MSE tops out at 1.0 no matter how wrong the prediction is.
# BCE grows without bound, which is the incentive you want.

# The gradient argument, which is the real reason:
#   MSE + sigmoid  ->  dL/dz = (a - y) * a * (1 - a)
#   BCE + sigmoid  ->  dL/dz = (a - y)
# The sigmoid derivative a(1-a) approaches 0 when a is near 0 or 1, so MSE's
# gradient vanishes precisely when the model is confidently wrong. BCE cancels
# that term exactly — which is why the pairing matters.

Backpropagation

Backpropagation computes the gradient of the cost with respect to every parameter by applying the chain rule from the output backwards. Its efficiency comes from reuse: the gradient at layer L is needed to compute the gradient at layer L−1, so you pass one quantity backwards through the network instead of recomputing derivatives from scratch for each parameter.

That is why the forward pass caches its intermediates. The gradient with respect to W₁ needs A₁ and the incoming gradient; without the cache you would have to recompute the forward pass, and the algorithm would be quadratic in depth rather than linear.

The full derivation for a two-layer network is four lines, and writing it once by hand is what makes autograd stop being magic.

Backpropagation by hand, verified numerically
import numpy as np

def backward(y_true, cache, params):
    n = y_true.shape[0]
    X, A1, Z1, A2 = cache["X"], cache["A1"], cache["Z1"], cache["A2"]

    # Output layer. For sigmoid + binary cross-entropy this simplifies to
    # (A2 - y) — the sigmoid derivative cancels exactly.
    dZ2 = (A2 - y_true) / n                  # [n, out]
    dW2 = A1.T @ dZ2                         # [hidden, out]
    db2 = dZ2.sum(axis=0)

    # Propagate backwards through W2, then through the ReLU.
    dA1 = dZ2 @ params["W2"].T               # [n, hidden]
    dZ1 = dA1 * (Z1 > 0)                     # ReLU'(z) = 1 if z > 0 else 0
    dW1 = X.T @ dZ1
    db1 = dZ1.sum(axis=0)

    return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}

# Gradient checking: compare against finite differences. Do this whenever you
# implement a gradient by hand — it catches every sign and transpose error.
def gradient_check(params, X, y, key, h=1e-6):
    analytic = backward(y, forward(X, params)[1], params)[key]
    numeric = np.zeros_like(params[key])
    it = np.nditer(params[key], flags=["multi_index"])
    while not it.finished:
        i = it.multi_index
        original = params[key][i]
        params[key][i] = original + h
        loss_plus = binary_cross_entropy(forward(X, params)[0], y)
        params[key][i] = original - h
        loss_minus = binary_cross_entropy(forward(X, params)[0], y)
        params[key][i] = original
        numeric[i] = (loss_plus - loss_minus) / (2 * h)
        it.iternext()
    rel = np.abs(analytic - numeric).max() / (np.abs(analytic).max() + 1e-12)
    return rel        # below ~1e-6 means the gradient is right

print("relative error:", gradient_check(params, X, y, "W1"))

# The full training loop, now that all three pieces exist.
for step in range(5000):
    preds, cache = forward(X, params)
    grads = backward(y, cache, params)
    for k in params:
        params[k] -= 0.5 * grads[k]
    if step % 1000 == 0:
        print(step, round(binary_cross_entropy(preds, y), 4))
print(forward(X, params)[0].round(3))     # solves XOR
The same model in PyTorch
import torch, torch.nn as nn

X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = torch.tensor([[0.], [1.], [1.], [0.]])

model = nn.Sequential(
    nn.Linear(2, 8),
    nn.ReLU(),
    nn.Linear(8, 1),
)
# BCEWithLogitsLoss takes RAW logits and applies the sigmoid internally, using
# the stable log-sum-exp form. Never add a Sigmoid layer and then use BCELoss.
loss_fn = nn.BCEWithLogitsLoss()
opt = torch.optim.Adam(model.parameters(), lr=0.05)

for step in range(2000):
    logits = model(X)                # 1. forward
    loss = loss_fn(logits, y)        # 2. cost

    opt.zero_grad()                  # 3a. clear old gradients — they ACCUMULATE
    loss.backward()                  # 3b. backward: autograd does the chain rule
    opt.step()                       # 3c. update

    if step % 500 == 0:
        print(step, round(loss.item(), 4))

with torch.no_grad():
    print(torch.sigmoid(model(X)).round())

# Inspecting what autograd computed — the same numbers the hand-written
# backward produced:
loss = loss_fn(model(X), y)
loss.backward()
for name, p in model.named_parameters():
    print(f"{name:16} grad norm {p.grad.norm().item():.4f}")

Note: Forgetting opt.zero_grad() is the most common training bug in PyTorch. Gradients accumulate by default, so without it every step uses the sum of all gradients so far and the model diverges.

Worth remembering

  • Without a non-linearity, stacked linear layers collapse into a single linear layer — depth buys nothing.
  • Error is per-example, loss is per-example after a function, cost is the average over the batch.
  • Backpropagation is the chain rule with intermediate results cached, which is what makes it cheap.