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.

An illustrative loss-vs-epoch curve of the general shape you should expect from this kind of training run (constructed as \(0.9e^{-\text{epoch}/150}+0.05\) for display purposes — your own run's exact numbers will differ, but the decaying shape is typical).

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 →