Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Building a Multi-Layer Neural Network from Scratch in Python

Introduction

In our previous lessons, we built a complete theoretical picture of a neural network. We traced the flow of information forward to generate predictions (the forward pass) and then derived the backpropagation algorithm to calculate how the error changes with respect to every weight and bias in the network (the backward pass). We also explored the potential instabilities of this process, namely the vanishing and exploding gradient problems.

Today, we transition from theory to practice. This lesson is dedicated to implementing the core engine of a neural network from scratch. Our goal is to translate the mathematical equations for the forward and backward passes into efficient, vectorized Python code using NumPy. This is a crucial step for any deep learning practitioner, as it demystifies what high-level libraries like TensorFlow and PyTorch do under the hood. Your background in systems engineering will be valuable here, as we'll see how vectorized computations in NumPy provide a massive performance advantage over naïve, loopy implementations.

By the end of this lesson, you will have built the two fundamental functions—forward_prop and back_prop—that form the heart of a neural network's training process.

The Anatomy of Our Network

To make our implementation concrete, we will build a simple two-layer neural network designed to solve a classic problem: handwritten digit classification using the MNIST dataset.

A diagram illustrating a neural network with an input layer, a hidden layer, and an output layer. The connections between neurons represent the weights.

Our network will have the following structure:

  1. Input Layer (): This layer takes flattened 28x28 pixel images, resulting in 784 input neurons.
  2. Hidden Layer (): This layer will have 10 neurons, using the ReLU activation function.
  3. Output Layer (): This layer will have 10 neurons, one for each digit (0-9), using the Softmax activation function to produce a probability distribution.

The entire process of building, training, and testing this network is demonstrated in the video below. We will use this video as our primary guide, dissecting its Python implementation piece by piece.

Building a neural network FROM SCRATCH (no Tensorflow/Pytorch, just numpy & math)

The video "Building a neural network FROM SCRATCH" by Samson Zhang is an excellent, clear walkthrough of this entire process.

For now, watch the introduction from the problem and data setup to familiarize yourself with the network architecture and how the MNIST data is structured. Then, watch the conceptual overview of forward propagation and backpropagation to see the mathematical plan we are about to implement.

1. Parameter Initialization

Before we can compute anything, our network needs parameters: the weights () and biases () for each layer. These must be initialized with some starting values.

Let's consider the dimensions. Our first weight matrix, , connects the 784-dimensional input to the 10-neuron hidden layer. The second weight matrix, , connects the 10-neuron hidden layer to the 10-neuron output layer.

Question: What should the dimensions of the matrices and be? What about the bias vectors and ?

Answer * $\mathbf{W}^{(1)}$: To transform a 784-dim vector into a 10-dim vector, the weight matrix must be of shape `(10, 784)`. * $\mathbf{b}^{(1)}$: The bias is added to the 10-dim result, so it must be a vector of shape `(10, 1)`. * $\mathbf{W}^{(2)}$: To transform a 10-dim vector into another 10-dim vector, this matrix must be of shape `(10, 10)`. * $\mathbf{b}^{(2)}$: The final bias is also a vector of shape `(10, 1)`.

We initialize these parameters with small random numbers. This is critical to break symmetry; if all weights start at the same value, all neurons in a layer will learn the same thing.

Building a neural network FROM SCRATCH (no Tensorflow/Pytorch, just numpy & math)

Now let's look at the implementation of the init_params function.

Watch the segment from where the parameters are initialized. Notice how the dimensions we just discussed are used to create the NumPy arrays. The weights are initialized randomly and centered around zero.

2. The Forward Pass: From Input to Prediction

The forward pass computes the network's prediction by propagating the input signal through the layers. This involves a sequence of linear transformations followed by non-linear activations.

For a single layer , the computation is:

  1. Linear Step:
  2. Activation Step:

Here, is the activation from the previous layer (or the input data for the first layer).

Let's implement the two activation functions we need: ReLU and Softmax.

  • ReLU: . This can be done efficiently in NumPy using np.maximum(0, Z).
  • Softmax: . This normalizes the outputs into a probability distribution. This is implemented as np.exp(Z) / np.sum(np.exp(Z), axis=0). The axis=0 argument is crucial for vectorized computation, ensuring we sum over the neurons for each example independently.

With these helpers, we can build the full forward_prop function.

Building a neural network FROM SCRATCH (no Tensorflow/Pytorch, just numpy & math)

This part of the video implements the entire forward pass.

Watch the section from on implementing forward_prop. Pay close attention to how the NumPy dot product @ (or np.dot) and simple addition + directly correspond to the linear step equation. The implementation elegantly chains these operations for both layers.

The forward_prop function takes the parameters and input data, and returns not only the final prediction but also the intermediate values . We need to cache these values because they are essential for the backward pass.

3. The Backward Pass: From Error to Gradients

This is the most mathematically intensive part, where we implement the backpropagation algorithm derived in the previous module. Our goal is to compute the gradients of the loss function with respect to every weight and bias: , , , and .

The process starts at the output and works backward.

This diagram shows how the chain rule is used to propagate the error backward. The gradient of the loss \(\mathcal{L}\) with respect to a parameter like \(w_2\) is computed by multiplying the "upstream" gradient \(\frac{\partial \mathcal{L}}{\partial z_2}\) by the "local" gradient \(\frac{\partial z_2}{\partial w_2}\).

Step 1: Output Layer Error
The error signal for the output layer, , is simply the difference between the predictions and the true labels:

Here, represents the true labels, which must be in a "one-hot" format. For example, the digit '4' becomes a 10-element vector [0, 0, 0, 0, 1, 0, 0, 0, 0, 0].

Step 2: Output Layer Gradients
Using , we compute the gradients for and . The equations we derived are:


where is the number of training examples. The bias gradient is an average of the error signal across all examples.

Step 3: Hidden Layer Error
Now we propagate the error backward to the hidden layer:

This is the core backpropagation step. The term is the derivative of our hidden layer's activation function, which is ReLU. The derivative of ReLU is particularly simple: it's 1 if the input is positive, and 0 otherwise.

Step 4: Hidden Layer Gradients
Finally, we compute the gradients for and using the newly computed :

Now, let's watch how these four steps are translated into vectorized NumPy code.

Building a neural network FROM SCRATCH (no Tensorflow/Pytorch, just numpy & math)

This is the most critical part of the video for our current learning outcome. It implements the entire back_prop function.

Watch carefully from the implementation of backpropagation. Notice the one_hot helper function. See how dZ2 = A2 - one_hot_Y directly implements Step 1. Observe the matrix multiplications used to calculate dW2 and dW1. Check that the dimensions align with the equations above (e.g., dZ2 dotted with A1.T). Pay attention to how np.sum with axis=1 is used to calculate the bias gradients db2 and db1. Finally, see the implementation of deriv_ReLU and how it's used to calculate dZ1.

The result of the back_prop function is a set of gradient matrices (dW1, db1, dW2, db2) that have the same dimensions as our parameter matrices. These gradients tell us the direction of steepest ascent on the loss surface.

Conclusion

In this lesson, you have translated the abstract mathematics of neural networks into concrete, functional Python code. We have successfully implemented the two most important functions in a neural network:

  • forward_prop: Takes inputs and current parameters to produce a prediction, caching intermediate values along the way.
  • back_prop: Uses the cached values and true labels to compute the gradient of the loss with respect to every single parameter in the network.

These two functions are the essential building blocks for training. You now have an "engine" that can process data and calculate the exact direction to adjust the parameters to improve performance. The vectorized nature of our implementation, using NumPy's optimized C and Fortran backends for matrix operations, is precisely the kind of low-level performance consideration that is crucial in real-world systems.

Key Takeaways:

  • A feedforward neural network's computation can be expressed as a series of matrix multiplications and element-wise non-linear functions.
  • The forward pass propagates a signal from input to output, while the backward pass propagates an error gradient from output to input.
  • Vectorization with libraries like NumPy is key to implementing these passes efficiently, avoiding slow Python loops and operating on entire batches of data at once.
  • The back_prop function relies on values cached during the forward_prop function, creating a tight coupling between the two.

Preview of the Next Lesson:

We have built the engine, but we haven't turned the key yet. In our next lesson, we will do just that. We will construct a training loop that repeatedly calls forward_prop and back_prop. We'll use the gradients returned by back_prop to update our network's weights and biases using an optimizer, a process known as gradient descent. This will complete the full training pipeline and allow us to watch our network learn to recognize digits.

Can't find a good explanation? Sign up and we'll make it for you

Sign up