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.
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
NeuralNetworkclass in NumPy withforward,backward, andtrainmethods, 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.kerascall 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.
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.
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}")
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:
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.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:
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.
3. The Same Network in Keras
Now the identical architecture, trained the identical way, in a modern deep learning framework:
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 call | What it actually does | Lecture |
|---|---|---|
Dense(2, activation='relu') | \(A=\text{ReLU}(XW_1+b_1)\) — a linear layer, \(XW+b\), then an activation | 4, 5 |
Dense(1, activation='sigmoid') | \(Q=\sigma(AW_2+b_2)\) — output layer squashed to a probability | 4, 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 correction | 9 |
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 progress | 10 |
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 2 | 7, 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
model.fit is really doingLecture 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
- 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 inX'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
- 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
NeuralNetworkclass generalizes Lecture 8's single-example math to a batch, using the exact vectorized equations derived there. - Keras's
Dense,activation,loss, andoptimizerarguments 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.
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
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 →