Backpropagation
Backpropagation, short for Backward Propagation of Errors, is the core algorithm used to train neural networks. It uses the Chain Rule to calculate how much each weight and bias contributed to the total error, allowing us to update them and improve the model's accuracy.

The Intuition: Why Backwards?
Think of the Forward Pass as "making a prediction" and the Backward Pass as "distributing the blame." When the network makes a mistake, we calculate the error at the output. To fix it, we need to know how each neuron in the previous layers influenced that error. By moving backward, we can efficiently "propagate" the error signal using the chain rule, calculating gradients layer by layer without redundant computations.
Steps of Backpropagation
After the forward pass through the network and the calculation of the loss using a cost function, backpropagation proceeds with the following steps:
-
Gradients Calculation: For each learnable parameter (weights and biases) in the network, we compute the gradient of the loss function with respect to that parameter. This is done using the Chain Rule of calculus. (See Gradient Descent for how we use these gradients to actually improve the model.)
-
Weight and Bias Updates: Once the gradients are computed, we update the weights and biases using an optimization algorithm, typically Gradient Descent. The parameters are adjusted in the direction that reduces the loss, scaled by a learning rate that controls the step size of the updates. The update rule for a weight is given by:
Where:
- is the learning rate
- is the gradient of the loss with respect to the weight
-
Iteration: Steps 1 and 2 are repeated for multiple epochs (iterations) until the network converges to a satisfactory level of accuracy.
Gradients
Situation: We consider a multi-class classification problem with a neural network that has multiple layers. The hidden layers use the Sigmoid activation function, and the output layer uses the Softmax activation function. The cost function used is Cross-Entropy Loss.
Output Layer
Our CE loss defined as where is the number of classes, and is the output of the Softmax function for class (neuron in the output layer).
The gradient of the loss w.r.t the is given by:
That gradient only depends on the and any other term in the summation where is treated as a constant.
The gradient of the w.r.t the input of the Softmax function is:
Here does depend on all because of the denominator from the Softmax function.
Simplifying this, we get:
Thus, for :
where:
- is the Kronecker delta, which is 1 if and 0 otherwise.
Then, using the chain rule, the gradient of the loss w.r.t the input of the Softmax function is:
And since for one-hot encoded labels, we have:
We have where is the weights of current layer in neuron , is the activations from the previous layer, and is the bias for neuron .
Thus, the gradients for the weights and biases in the output layer are:
Summary: Vectorized Gradients
To implement backpropagation efficiently for a batch of examples, we use the following vectorized equations. These avoid explicit loops over individual neurons or examples.
For any layer :
- Error Signal ():
- Weight Gradient ():
- Bias Gradient ():
- Activation Gradient ():
Python Implementation
import numpy as np
from typing import List, Callable
from activation_functions import Activation_Functions
from cost_functions import Cost_Functions
np.random.seed(42)
class NN:
def __init__(self, s: List[int]):
'''
s: List of layer sizes, where s[0] is the input layer size, s[1] is the first hidden layer size, ..., s[L] is the output layer size
'''
self.s = s
self.L = len(s) - 1
self.weights = [np.random.randn(s[l], s[l-1]) * 0.01 for l in range(1, self.L + 1)]
self.biases = [np.zeros((s[l], 1)) for l in range(1, self.L + 1)]
self.f = Activation_Functions.sigmoid
self.f_output = Activation_Functions.softmax
self.cost = Cost_Functions.ce
self.cost_grad = Cost_Functions.ce_grad
def forward_pass(self, X: np.ndarray) -> (np.ndarray, List[tuple]):
# ...
pass
def compute_cost(self, AL: np.ndarray, Y: np.ndarray) -> float:
# ...
pass
def backpropagation(self, AL: np.ndarray, Y: np.ndarray, cache: List[tuple]) -> dict:
'''
AL: Output of the network from forward pass
Y: True labels (one-hot encoded)
cache: List of tuples containing (A_prev, W, b, Z) for each layer from forward pass
Returns:
grads: Dictionary containing gradients for weights and biases e.g., {'dW1': ..., 'db1': ..., 'dW2': ..., 'db2': ..., ...}
'''
grads = {}
m = Y.shape[1] # number of examples
# ---- 1. Output Layer ----
Delta_L = AL - Y # (s[L], m)
A_prev, W_L, b_L, Z_L = cache[-1]
# grads
grads[f'dW{self.L}'] = (1/m) * np.dot(Delta_L, A_prev.T)
grads[f'db{self.L}'] = (1/m) * np.sum(Delta_L, axis=1, keepdims=True)
# ---- 2. Hidden Layers ----
Delta_next = Delta_L
dA_prev = np.dot(W_L.T, Delta_next)
for l in reversed(range(self.L - 1)):
A_prev, W_l, b_l, Z_l = cache[l]
Delta_l = dA_prev * Activation_Functions.sigmoid_derivative(Z_l)
# grads
grads[f'dW{l+1}'] = (1/m) * np.dot(Delta_l, A_prev.T)
grads[f'db{l+1}'] = (1/m) * np.sum(Delta_l, axis=1, keepdims=True)
dA_prev = np.dot(W_l.T, Delta_l)
return grads
def update_parameters(self, grads: dict, learning_rate: float = 0.001):
for l in range(self.L):
self.weights[l] -= learning_rate * grads[f'dW{l+1}']
self.biases[l] -= learning_rate * grads[f'db{l+1}']
- : Index of last layer (output layer).
- : Gradient of weights for layer with index .
- : Cached values for layer with index .
- : Weights for layer with index .