Module B · Lecture 11

Building Neural Networks in Python (NumPy + Keras)

Everything since Lecture 4 has been building toward this: the exact same network, first written by hand, then written in eight lines of Keras.

⏱ ~70 min 🧩 Builds on: Lectures 4–9 🎯 CO2
🧭 Why we're learning this now

Every piece of machinery so far — forward pass, loss, backprop, gradient descent, optimizers, metrics — has been derived by hand, on paper. This lecture is the payoff: watching all of it assemble into working code, and then seeing how a real framework automates the part we've been doing by hand (backpropagation) so we never have to hand-derive it again for a production model.

  • Recognize how Lectures 4–9's math (forward pass, activations, loss, backprop, gradient descent, optimizers) assembles into one trainable program.
  • Implement a complete NeuralNetwork class in NumPy with forward, backward, and train methods, generalized to a batch of examples.
  • Train that network on a small toy dataset and observe the loss decrease epoch over epoch.
  • Build the identical architecture in Keras/TensorFlow and map every tf.keras call back to the specific lecture that derived its math.
  • Explain, at a conceptual level, how reverse-mode automatic differentiation replaces hand-derived backpropagation in production frameworks.

1. Recap: From Math to Code

Every lecture since Module A has contributed one ingredient to a single recipe. Lecture 4 gave the forward pass \(Z=XW+b\). Lecture 5 gave activation functions like ReLU and sigmoid. Lecture 6 gave the loss function — binary cross-entropy. Lecture 7 gave the update rule \(w\leftarrow w-\eta\nabla_w L\). Lecture 8 showed exactly how to compute \(\nabla_w L\) via the chain rule. Lecture 9 showed smarter ways to turn that gradient into a step (Adam, RMSProp, etc.). Lecture 10 gave us a way to judge whether any of it worked.

This lecture assembles all of it into working code, twice: once entirely by hand in NumPy — so nothing is hidden — and once in Keras, where a few lines of configuration replace hundreds of lines of hand-written calculus. We reuse the exact architecture from Lecture 8: 2 inputs \(\to\) 2 hidden units (ReLU) \(\to\) 1 output (sigmoid), with binary cross-entropy loss.

The architecture we implement twice below — identical to Lecture 8's \(X \to Z_1 \to A \to Z_2 \to Q\) network.

2. Building It in NumPy: A NeuralNetwork Class

Lecture 8 hand-computed one forward+backward pass for a single example. Here we generalize that to a small batch of \(m=4\) examples — the two-input AND-gate points first introduced in Lecture 3 (\(X=[[0,0],[0,1],[1,0],[1,1]]\), target \(y=[0,0,0,1]\)) — using precisely the vectorized update equations from Lecture 8's Section 7:

$$\delta_2 = Q-y \quad\ \frac{\partial L}{\partial W_2}=\frac{1}{m}A^\top\delta_2 \quad\ \delta_1 = (\delta_2 W_2^\top)\odot\text{ReLU}'(Z_1) \quad\ \frac{\partial L}{\partial W_1}=\frac{1}{m}X^\top\delta_1$$

The class below implements exactly this: a forward method that caches every intermediate value, a backward method that applies the chain rule using those cached values, and a train method that loops gradient descent (Lecture 7) over many epochs.

lecture-11-numpy-nn.py (excerpt)
class NeuralNetwork:
    def __init__(self, seed=42):
        rng = np.random.default_rng(seed)
        self.W1 = rng.normal(0, 0.5, size=(2, 2))
        self.b1 = np.zeros((1, 2))
        self.W2 = rng.normal(0, 0.5, size=(2, 1))
        self.b2 = np.zeros((1, 1))

    def forward(self, X):
        self.X  = X
        self.Z1 = X @ self.W1 + self.b1
        self.A  = relu(self.Z1)
        self.Z2 = self.A @ self.W2 + self.b2
        self.Q  = sigmoid(self.Z2)
        return self.Q

    def backward(self, y, lr):
        m = y.shape[0]
        dZ2 = self.Q - y                        # sigmoid + BCE shortcut (Lecture 8)
        dW2 = self.A.T @ dZ2 / m
        db2 = np.sum(dZ2, axis=0, keepdims=True) / m
        dA  = dZ2 @ self.W2.T
        dZ1 = dA * relu_grad(self.Z1)
        dW1 = self.X.T @ dZ1 / m
        db1 = np.sum(dZ1, axis=0, keepdims=True) / m
        self.W1 -= lr*dW1; self.b1 -= lr*db1     # gradient descent (Lecture 7)
        self.W2 -= lr*dW2; self.b2 -= lr*db2

    def train(self, X, y, lr=0.5, epochs=1000):
        for epoch in range(epochs):
            Q = self.forward(X)
            self.backward(y, lr)
            if epoch % 100 == 0:
                loss = -np.mean(y*np.log(Q+1e-9) + (1-y)*np.log(1-Q+1e-9))
                print(f"epoch {epoch:4d}  loss={loss:.4f}")
What actually happens across those 1000 epochs

The code above can look like a black box if you just read it as code. Mechanically, here is exactly what happens, tied back to the lectures that derived each piece:

An epoch is one complete pass through the entire training set. Here the whole dataset is only \(m=4\) examples, so one epoch processes all 4 rows of \(X\) at once as a single batch (this is full-batch gradient descent, Lecture 7, Section 4 — not mini-batch, since the "batch" already is the whole dataset). Each iteration of the for epoch in range(epochs) loop does exactly two things, in order:

  1. forward(X) pushes all 4 examples through \(Z_1=XW_1+b_1,\ A=\text{ReLU}(Z_1),\ Z_2=AW_2+b_2,\ Q=\sigma(Z_2)\) (Lecture 4) in one shot — matrix multiplication naturally produces one prediction per row of \(X\), so \(Q\) comes out as 4 numbers, one per AND-gate input pair.
  2. backward(y, lr) does two things in sequence: first it computes gradients using exactly Lecture 8's chain-rule equations (\(\delta_2=Q-y\), propagate through \(W_2\) and ReLU's local gradient, etc.), averaged over the 4 examples by dividing by \(m\) — this is Lecture 8's Section 7 vectorized form, applied literally. Then it applies one gradient descent update \(w \leftarrow w-\eta\nabla_w L\) (Lecture 7) to every weight and bias using those gradients.

That two-step cycle — forward, then backward-and-update — repeats 1000 times, with each epoch starting from the slightly-improved weights the previous epoch left behind. Because every update nudges each weight a small step opposite its own gradient, and Lecture 7 showed that this locally decreases the loss for a small-enough learning rate, the loss should trend steadily downward across epochs — exactly what the printed trace below shows. Nothing conceptually new happens at epoch 900 that didn't happen at epoch 0; it is the same two-step cycle, purely repeated.

Training this for 1000 epochs at \(\eta=0.5\) on the 4-example AND-gate batch prints a loss that falls steadily and predictions that converge to essentially exact:

🔢 Actual training output (see the full downloadable script)

epoch 0 loss=0.6776 → epoch 100 loss=0.0775 → epoch 300 loss=0.0146 → epoch 600 loss=0.0061 → epoch 900 loss=0.0038
Final predictions on \([[0,0],[0,1],[1,0],[1,1]]\): \(\approx[0.000,\ 0.001,\ 0.001,\ 0.989]\) — matching the true AND-gate outputs \([0,0,0,1]\) almost exactly.

This is not an illustrative decay curve — it is the exact loss trace from running the NeuralNetwork class above with seed=42, printed every 100 epochs (independently re-run and verified: verify_lecture11.py, matching the "Actual training output" box above to 4 decimal places). Press play to watch it reveal epoch by epoch, the way it would print live during training.

2.1 Watching the Decision Boundary Sharpen, Not Just the Loss Number Fall

A falling loss number is one view of training; a more direct one is watching what the network actually believes about the input space change shape. Evaluate the same trained network's output \(Q\) across a \(5\times5\) grid spanning \(x_1,x_2\in[0,1]\) — chosen so the grid's four corners land exactly on the four real AND-gate training points — at five snapshots during the same training run used above.

Verified, not illustrative: every cell below is the network's real \(\sigma(Z_2)\) output at that grid point at that epoch, from the same seeded run (verify_lecture11.py). At epoch 0 the untrained network outputs a nearly uniform blur (≈0.31–0.47 everywhere — barely distinguishes any input); by epoch 999 it has sharpened into a spike that is ≈0 everywhere except the top-right corner \((x_1,x_2)=(1,1)\) (≈0.989) — visually, this is what "the loss went down" means for this network.

3. The Same Network in Keras

Now the identical architecture, trained the identical way, in a modern deep learning framework:

lecture-11-keras-nn.py (excerpt)
import tensorflow as tf

model = tf.keras.Sequential([
    tf.keras.layers.Dense(2, activation='relu', input_shape=(2,)),  # W1, b1, ReLU
    tf.keras.layers.Dense(1, activation='sigmoid'),                  # W2, b2, sigmoid
])

model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
history = model.fit(X, y, epochs=500, verbose=0)
print("final loss:", history.history['loss'][-1])

4. Mapping Keras Calls Back to the Math

Keras callWhat it actually doesLecture
Dense(2, activation='relu')\(A=\text{ReLU}(XW_1+b_1)\) — a linear layer, \(XW+b\), then an activation4, 5
Dense(1, activation='sigmoid')\(Q=\sigma(AW_2+b_2)\) — output layer squashed to a probability4, 5
loss='binary_crossentropy'\(L=-[y\log Q+(1-y)\log(1-Q)]\)6
optimizer='adam'Adaptive per-parameter step using first/second-moment estimates with bias correction9
metrics=['accuracy']Not part of training at all — just reports \(\frac{TP+TN}{TP+TN+FP+FN}\) on each epoch's batch, purely for the human watching training progress10
model.fit(X, y, epochs=500)Runs the training loop epochs times: forward pass, compute loss, backward pass via autodiff, gradient descent (or Adam) update — one full pass over X per epoch, same "epoch" meaning as the NumPy version in Section 27, 8

Every line of Keras configuration corresponds to a specific mathematical object we derived by hand earlier in the course. The framework has not introduced any new mathematics — it has automated the bookkeeping.

5. Hand-Derived Backprop vs. Framework Autodiff

⚠ What model.fit is really doing

Lecture 8's backward pass was hand-derived: we wrote out \(\delta_2=Q-y\), \(\delta_1=(\delta_2W_2^\top)\odot\text{ReLU}'(Z_1)\), etc., ourselves. Keras (and every modern framework — PyTorch, JAX) instead uses reverse-mode automatic differentiation: as the forward pass runs, the framework silently builds a computation graph recording every operation (matrix multiply, add, ReLU, sigmoid, log...). To get gradients, it walks that graph backward, applying the chain rule automatically at each node — mechanically the same algorithm as Lecture 8, just applied generically to arbitrary graphs instead of a hand-picked architecture. Understanding the hand-derived version is what lets you read an autodiff error message, debug a vanishing-gradient problem, or design a custom layer — the framework version is what you actually use to train anything beyond a toy example.

6. Common Pitfalls

⚠ Things to watch for
  • Forgetting to average over the batch. The from-scratch class divides gradients by \(m\); omitting this makes the effective learning rate scale (incorrectly) with batch size.
  • Mismatched input shapes in Keras. input_shape=(2,) must match the number of features in X's columns — a common first error when switching from hand-written NumPy shapes to Keras layers.
  • Not shuffling / not checking convergence. A NumPy loop that prints loss every 100 epochs (as above) is invaluable for catching a bug (e.g. a wrong sign in the gradient) — a loss that increases or plateaus immediately is a red flag.
  • Assuming Keras defaults are always right. optimizer='adam' with default hyperparameters (Lecture 9) is a strong baseline, but learning rate, batch size, and epochs still need tuning for real problems.

7. Summary

Key takeaways
  • A complete, trainable neural network is just the composition of ideas from Lectures 4–9: linear layers, activations, a loss, gradients via the chain rule, and an update rule.
  • The from-scratch NumPy NeuralNetwork class generalizes Lecture 8's single-example math to a batch, using the exact vectorized equations derived there.
  • Keras's Dense, activation, loss, and optimizer arguments are not new concepts — each one names a mathematical object already derived earlier in the course.
  • Reverse-mode automatic differentiation is conceptually identical to hand-derived backpropagation, generalized to work automatically on any computation graph.

8. Code: NumPy From Scratch & Keras Equivalent

Two complete, runnable scripts: a pure-NumPy implementation (no external ML framework) and its Keras equivalent, both training the same 2→2(ReLU)→1(sigmoid) network on the same AND-gate toy dataset.

lecture-11-numpy-nn.py
import numpy as np

def sigmoid(z):    return 1 / (1 + np.exp(-z))
def relu(z):        return np.maximum(0, z)
def relu_grad(z):   return (z > 0).astype(float)

class NeuralNetwork:
    """2 inputs -> 2 hidden (ReLU) -> 1 output (sigmoid), same architecture as Lecture 8."""
    def __init__(self, seed=42):
        rng = np.random.default_rng(seed)
        self.W1 = rng.normal(0, 0.5, size=(2, 2))
        self.b1 = np.zeros((1, 2))
        self.W2 = rng.normal(0, 0.5, size=(2, 1))
        self.b2 = np.zeros((1, 1))

    def forward(self, X):
        self.X  = X
        self.Z1 = X @ self.W1 + self.b1
        self.A  = relu(self.Z1)
        self.Z2 = self.A @ self.W2 + self.b2
        self.Q  = sigmoid(self.Z2)
        return self.Q

    def backward(self, y, lr):
        m = y.shape[0]
        dZ2 = self.Q - y                          # sigmoid + BCE shortcut (Lecture 8)
        dW2 = self.A.T @ dZ2 / m
        db2 = np.sum(dZ2, axis=0, keepdims=True) / m
        dA  = dZ2 @ self.W2.T
        dZ1 = dA * relu_grad(self.Z1)
        dW1 = self.X.T @ dZ1 / m
        db1 = np.sum(dZ1, axis=0, keepdims=True) / m
        self.W1 -= lr * dW1; self.b1 -= lr * db1  # gradient descent (Lecture 7)
        self.W2 -= lr * dW2; self.b2 -= lr * db2

    def train(self, X, y, lr=0.5, epochs=1000, verbose=True):
        for epoch in range(epochs):
            Q = self.forward(X)
            self.backward(y, lr)
            if verbose and epoch % 100 == 0:
                loss = -np.mean(y * np.log(Q + 1e-9) + (1 - y) * np.log(1 - Q + 1e-9))
                print(f"epoch {epoch:4d}  loss={loss:.4f}")
        return self


if __name__ == "__main__":
    # AND-gate toy dataset (Lecture 3's points)
    X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
    y = np.array([[0], [0], [0], [1]], dtype=float)

    nn = NeuralNetwork(seed=42)
    nn.train(X, y, lr=0.5, epochs=1000)

    print("\nFinal predictions:", nn.forward(X).ravel().round(4))
    print("True labels:      ", y.ravel())

⬇ Download lecture-11-numpy-nn.py

lecture-11-keras-nn.py
import numpy as np
import tensorflow as tf

# Same AND-gate toy dataset as the NumPy version
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([[0], [0], [0], [1]], dtype=float)

model = tf.keras.Sequential([
    tf.keras.layers.Dense(2, activation='relu', input_shape=(2,)),  # X W1 + b1, then ReLU  (Lectures 4-5)
    tf.keras.layers.Dense(1, activation='sigmoid'),                  # A W2 + b2, then sigmoid (Lectures 4-5)
])

model.compile(
    optimizer='adam',                 # Lecture 9
    loss='binary_crossentropy',       # Lecture 6
    metrics=['accuracy']              # Lecture 10
)

history = model.fit(X, y, epochs=500, verbose=0)  # internal loop = Lectures 7-8, via autodiff

print("Final loss:", round(history.history['loss'][-1], 4))
print("Predictions:", model.predict(X, verbose=0).ravel().round(4))
print("True labels:", y.ravel())

⬇ Download lecture-11-keras-nn.py   More resources for this lecture →