Week 11: Neural Network Foundations

Every architecture from here to the end of the course — CNNs, LSTMs, and the transformer itself — is built from the same handful of primitives you'll implement by hand this week: a weighted sum, an activation function, a loss, and a gradient computed backward through the network. No PyTorch yet — understanding backpropagation in raw NumPy first means the framework in Week 12 will feel like a convenience, not a black box.

Phase 4 of 8 Week 11 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why a single perceptron can't solve non-linearly-separable problems
  • Derive and implement backpropagation for a small network by hand
  • Train a two-layer neural network in raw NumPy, no framework required

1. Perceptrons & the Limits of Linear Models

A perceptron computes a weighted sum of its inputs, plus a bias, and passes the result through a step function — the exact dot product from Week 2's linear algebra lesson, now wearing a neural network's name.

perceptron.py
import numpy as np

def perceptron(x, weights, bias):
    z = x @ weights + bias      # the Week 2 dot product, plus a bias term
    return 1 if z > 0 else 0

A single perceptron can only draw a straight-line (linear) decision boundary — it can learn AND and OR, but famously cannot learn XOR, because no single straight line separates XOR's true and false outputs. Stacking perceptrons into layers, with a non-linear activation function between them (Section 2), is precisely what lets a network bend that boundary into something curved enough to solve XOR — and, at much larger scale, everything from image recognition to language modeling.

2. Activation Functions

Without a non-linear activation function between layers, stacking many linear layers would still collapse into one big linear function — depth would buy you nothing. Activation functions are what let depth actually add expressive power.

activations.py
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))          # squashes to (0, 1) -- classic, but saturates for large |z|

def relu(z):
    return np.maximum(0, z)               # zero below 0, identity above -- the modern default

def softmax(z):
    shifted = z - np.max(z, axis=-1, keepdims=True)   # the stability trick from Week 2
    exp_z = np.exp(shifted)
    return exp_z / np.sum(exp_z, axis=-1, keepdims=True)

Sigmoid is intuitive (it looks like a probability) but "saturates" — for large positive or negative inputs, its output barely changes, and its gradient shrinks toward zero, slowing learning in deep networks. ReLU avoids this for positive inputs (constant gradient of 1) and is dramatically cheaper to compute, which is why it's the default choice inside hidden layers of most modern networks. Softmax, which you already built in Week 2, is reserved for a network's final layer in multi-class classification, converting raw scores into a probability distribution over classes.

3. Loss Functions

A loss function measures how wrong a prediction is — the single number the entire training process tries to minimize.

losses.py
import numpy as np

def mse_loss(y_true, y_pred):
    return np.mean((y_true - y_pred) ** 2)          # regression: Week 6's MSE, revisited

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

Mean squared error is standard for regression outputs. Cross-entropy is standard for classification, and it connects directly back to Week 2's probability foundations: it's exactly the negative log-likelihood of the true label under the model's predicted probability distribution — a network that assigns high probability to the correct class gets low loss; one that's confidently wrong gets heavily penalized, because -log(p) grows sharply as p approaches 0.

4. Backpropagation & the Chain Rule, by Hand

Training means adjusting every weight to reduce the loss. Backpropagation computes exactly how much each weight contributed to the final error, using the calculus chain rule to propagate the loss's gradient backward, layer by layer, from output to input.

forward_and_backward.py — a single hidden layer
import numpy as np

def relu_derivative(z):
    return (z > 0).astype(float)

# --- Forward pass ---
z1 = X @ W1 + b1        # hidden layer pre-activation
a1 = relu(z1)            # hidden layer activation
z2 = a1 @ W2 + b2        # output layer pre-activation
y_pred = sigmoid(z2)     # final prediction

loss = binary_cross_entropy(y_true, y_pred)

# --- Backward pass: the chain rule, one layer at a time ---
dz2 = y_pred - y_true                      # derivative of loss w.r.t. z2 (a clean result for this loss+sigmoid pairing)
dW2 = a1.T @ dz2 / len(X)
db2 = dz2.mean(axis=0)

da1 = dz2 @ W2.T                            # gradient flowing backward into the hidden layer
dz1 = da1 * relu_derivative(z1)             # chain rule through ReLU's derivative
dW1 = X.T @ dz1 / len(X)
db1 = dz1.mean(axis=0)

Read this from the bottom up and the pattern is exactly the chain rule from calculus: the gradient at each layer is the gradient from the layer after it, multiplied by that layer's own local derivative. dz2 = y_pred - y_true looks suspiciously simple — that's not a shortcut, it's what the chain rule for sigmoid-plus-cross-entropy actually simplifies to, which is exactly why that pairing is so common in practice.

This is what PyTorch's .backward() automates

Every gradient you just wrote by hand is exactly what autograd computes automatically in Week 12 — the framework doesn't do anything conceptually different, it just tracks every operation and applies the chain rule for you, for networks far too large to differentiate by hand.

5. Gradient Descent, Learning Rate & Convergence

With gradients computed, gradient descent updates every weight a small step in the direction that reduces the loss — the negative of the gradient, scaled by a learning rate.

gradient_descent_step.py
learning_rate = 0.1

W1 -= learning_rate * dW1
b1 -= learning_rate * db1
W2 -= learning_rate * dW2
b2 -= learning_rate * db2

# Repeat forward pass -> backward pass -> update, for many epochs

The learning rate is the single most consequential hyperparameter in this whole process: too small, and training crawls, needing far more epochs to converge; too large, and updates overshoot the loss's minimum, sometimes diverging entirely (loss increasing every step instead of decreasing). Plotting the loss curve over training — a direct cousin of Week 5's learning curves — is the standard way to catch this: a healthy loss curve drops smoothly; a too-large learning rate produces a jagged or exploding one.

6. Hands-on Exercise

Hands-on

Implement and train a two-layer network in raw NumPy — no PyTorch

You're given the classic XOR dataset, deliberately chosen because a single perceptron provably cannot solve it (Section 1).

starter data
import numpy as np

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([[0], [1], [1], [0]])   # XOR

rng = np.random.default_rng(42)
W1 = rng.normal(scale=0.5, size=(2, 4))   # 2 inputs -> 4 hidden units
b1 = np.zeros((1, 4))
W2 = rng.normal(scale=0.5, size=(4, 1))   # 4 hidden units -> 1 output
b2 = np.zeros((1, 1))

Requirements:

  1. Implement the forward pass (Section 4) using ReLU for the hidden layer and sigmoid for the output.
  2. Implement binary cross-entropy loss (Section 3) and the full backward pass (Section 4) computing gradients for W1, b1, W2, b2.
  3. Write a training loop that runs forward → loss → backward → gradient descent update for at least 2,000 epochs, recording the loss every 100 epochs.
  4. Plot the loss curve, and confirm the trained network correctly classifies all four XOR inputs.
  5. Re-run training with a learning rate 100x larger and again 100x smaller than what worked, and describe what goes wrong in each case, referencing Section 5.
Hint

If the loss plateaus without reaching a low value, try re-initializing the random weights — with such a tiny network, a small number of epochs can occasionally get stuck depending on the random starting point, which is itself a useful thing to observe firsthand.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can't a single perceptron learn the XOR function?

A perceptron can only represent a single straight-line (linear) decision boundary. XOR's true and false outputs are arranged so that no single straight line can separate them — you need at least one hidden layer with a non-linear activation to bend the decision boundary into a shape that can.

Q2

Why does stacking linear layers without a non-linear activation between them fail to add any real expressive power?

A linear function of a linear function is still just a linear function — algebraically, two stacked matrix multiplications collapse into one equivalent matrix multiplication. A non-linear activation function between layers is what prevents this collapse, letting depth actually represent more complex functions.

Q3

In backpropagation, what determines the gradient at a given layer?

The chain rule: the gradient flowing into a layer is the gradient from the layer after it (closer to the output), multiplied by that layer's own local derivative. This is why the process runs backward from the output toward the input, and why it's called "back"-propagation.

Q4

Training loss increases every epoch instead of decreasing. What's the most likely cause, and the most direct fix?

A learning rate that's too large, causing each gradient descent update to overshoot the loss's minimum instead of approaching it. The direct fix is to reduce the learning rate — plotting the loss curve, as in this week's exercise, is the standard way to catch this early instead of training blindly.