Building Neural Networks From Scratch

I wanted to understand the calculations behind the TensorFlow functions I’d been using, so I built a small neural network with NumPy. Working through the forward and backward passes gave me a way to connect the equations to running code. Michael Nielsen’s book and 3Blue1Brown’s neural network videos were useful references along the way.
The setup
In this network, each layer takes the previous layer’s outputs, forms weighted sums, and applies an activation function. Before the activation, a single neuron computes:
\[z = \sum_{i=1}^{n} w_i x_i + b\]
I used three layers with sigmoid activations and binary cross entropy loss, keeping the model small enough to work through each calculation.
import numpy as np |
Initialization
The initial weights affect how signals and gradients travel through the network. If sigmoid inputs become too large in magnitude, the activations saturate and their gradients become small. Xavier initialization helps keep the scale of the activations more consistent across layers:
def _initialize_parameters(self) -> dict[str, np.ndarray]: |
Forward pass
The forward pass applies each layer in order. After computing the weighted sum, the sigmoid maps it to a value between 0 and 1:
\[\sigma(z) = \frac{1}{1 + e^{-z}}\]
def forward_propagation(self, X: np.ndarray): |
I kept the intermediate Z and A values from
each layer so the backward pass could reuse them.
Loss
Binary cross entropy measures how well the predicted probabilities match the labels. A confident wrong prediction contributes more to the loss than an uncertain one:
\[E = -\frac{1}{m}\sum_{i=1}^m \left[ y_i \log(a_i) + (1 - y_i) \log(1 - a_i) \right]\]
Tracking this loss during training gives us a way to check whether the updates are improving the predictions.
Backprop
Backpropagation computes the derivative of the loss with respect to each weight. The chain rule lets us break that calculation into the derivatives of the operations along the way:
\[\frac{\partial E}{\partial w_{i}} = \frac{\partial E}{\partial y} \cdot \frac{\partial y}{\partial z} \cdot \frac{\partial z}{\partial w_{i}}\]
We then subtract a fraction of the gradient from each weight, with the learning rate \(\alpha\) setting the step size:
\[w_{i} := w_{i} - \alpha \frac{\partial E}{\partial w_{i}}\]
Each training iteration runs the forward pass, computes the loss, and updates the parameters using the backward pass:
def train(self, X, Y, learning_rate: float = 0.1, epochs: int = 1000): |
With sigmoid outputs and binary cross entropy, the output layer’s derivative simplifies to:
\[\frac{\partial L}{\partial z^{[L]}} = A^{[L]} - Y\]
For a hidden layer, we pass the gradient back through the next layer’s weights and multiply by the activation derivative:
\[\frac{\partial L}{\partial z^{[l]}} = W^{[l+1]T} \frac{\partial L}{\partial z^{[l+1]}} \odot \sigma'(z^{[l]})\]
The implementation walks the layers in reverse:
def _backward_propagation(self, X, Y, cache, learning_rate): |
The sigmoid derivative is \(\sigma'(z) = \sigma(z)(1 - \sigma(z))\), so we can calculate it from the activations saved during the forward pass.
Back to TensorFlow
Writing out both passes made it easier to reason about the TensorFlow models I was already using. When a network failed to learn, I had a clearer idea of how initialization, gradient scale, and the learning rate could affect it.
The implementation took more work than calling a library, but that was useful for this exercise. I could follow a prediction all the way from the input to the loss and then back through the weight updates.