Welcome back to "Training Neural Networks: The Backpropagation Algorithm"! You've made excellent progress so far, having learned about loss functions in our first lesson and gradient descent in our second. Today, we're diving into the heart of neural network training: backpropagation.
In our previous lesson, we explored how gradient descent updates weights by moving in the direction opposite to the gradient of the loss function. But we left an important question unanswered: How do we actually calculate these gradients in a neural network with multiple layers and thousands or even millions of parameters?
That's where backpropagation comes in. Backpropagation (short for "backward propagation of errors") is an efficient algorithm for computing these gradients. Today, we'll focus specifically on implementing the backward pass for a single dense layer, which will form the building block for training complete neural networks.
By the end of this lesson, you'll understand how to:
Calculate derivatives for different activation functions.
Store necessary values during the forward pass.
Implement the backward pass to calculate gradients.
Connect these gradients to the gradient descent algorithm we learned previously.
Let's embark on this crucial step in our neural network journey!
Understanding the Chain Rule for Backpropagation
Activation Functions and Their Derivatives
The DenseLayer Class Structure
The Forward Pass: Setting Up for Backpropagation
The Backward Pass: Calculating Gradients
Backpropagation in Action: A Practical Example
Let's now see how our backpropagation implementation works in practice with a simple example:
# Create a single layer: 2 inputs, 3 neurons, sigmoid activationlayer <- DenseLayer$new(n_inputs = 2, n_neurons = 3, activation_fn_name = 'sigmoid')X_sample <- matrix(c(0.5, -0.2), nrow = 1, ncol = 2) # 1 sample, 2 featurescat("Input X:\n")print(X_sample)# Forward passlayer_output <- layer$forward(X_sample)cat("Layer output (after sigmoid):\n")print(layer_output)# Assume a dummy gradient from a hypothetical next layer or loss function# This is d(Loss)/d(layer_output)# Shape must match layer_output: (n_samples, n_neurons_in_layer)dummy_d_loss_wrt_layer_output <- matrix(c(0.1, -0.2, 0.05), nrow = 1, ncol = 3)cat("Dummy dL/d(layer_output):\n")print(dummy_d_loss_wrt_layer_output)# Backward passd_loss_wrt_input <- layer$backward(dummy_d_loss_wrt_layer_output)cat("\nCalculated Gradients:\n")cat(" dL/d_weights (shape", dim(layer$d_weights), "):\n")print(layer$d_weights)cat(" dL/d_biases (shape", dim(layer$d_biases), "):\n")print(layer$d_biases)cat(" dL/d_inputs (to pass to prev layer) (shape", dim(d_loss_wrt_input), "):\n")print(d_loss_wrt_input)
This example:
Creates a single DenseLayer with 2 inputs and 3 neurons.
Performs a forward pass with a sample input.
Simulates receiving gradients from the next layer using a dummy gradient.
Performs a backward pass using this gradient.
Prints the computed gradients for weights, biases, and inputs.
Output Discussion
When we run this code, we get the following output:
Our input is a single sample with two features: [0.5, -0.2].
The forward pass produces outputs around 0.5 (since our weights are initialized close to zero, the sigmoid of values near zero is about 0.5).
We provide a dummy gradient [0.1, -0.2, 0.05] representing how the loss would change if each output neuron's value changed slightly.
The backward pass calculates:
Gradients for each weight (d_weights).
Gradients for each bias (d_biases).
Gradients to pass to the previous layer (d_loss_wrt_input).
This example demonstrates the full cycle of forward and backward passes for a single layer. In a complete neural network, we would perform this process for each layer, starting from the output and working backward (hence the name backpropagation).
Conclusion and Next Steps
Congratulations! You've now mastered one of the most fundamental algorithms in deep learning: backpropagation for a single dense layer. The chain rule has empowered us to efficiently calculate gradients through a network, while our careful implementation of activation functions and their derivatives has given us the building blocks for neural network learning. Our layer's forward pass not only computes outputs but also strategically caches values needed for the backward pass, which then efficiently computes the gradients that power the learning process.
In our upcoming practice exercises, you'll gain hands-on experience with backpropagation and see how these gradients drive the learning process in neural networks. After solidifying these concepts through practice, we'll expand this foundation to implement backpropagation for entire multi-layer networks and explore more advanced optimization techniques to enhance our models' performance.
Before diving into code, let's build some intuition about how backpropagation works. The core mathematical principle behind backpropagation is the chain rule from calculus.
The chain rule allows us to calculate the derivative of composite functions. In simple terms, if we have a function f(g(x)), the derivative with respect to x is:
dxdf=dgdf⋅dxdg
In neural networks, we have many nested functions. Consider a simple example with one dense layer:
We multiply inputs by weights: z=x⋅w+b.
We apply an activation function: a=σ(z).
We calculate a loss: L=loss(a,ytrue).
If we want to find how the loss changes with respect to the weights (dwdL), we apply the chain rule:
dwdL=dadL⋅dzda⋅dwdz
Backpropagation gets its name because we start at the output (the loss) and work backward through the network, calculating these gradients layer by layer. This is much more efficient than trying to directly compute the gradient of the loss with respect to each parameter.
For our implementation, we'll use matrix operations to handle batches of data efficiently, but the underlying principle remains the same.
Let's start implementing backpropagation by first defining our activation functions and their derivatives. These are crucial because the derivative of the activation function (dzda) is a key component in our chain rule calculations.
Let's analyze each activation function and its derivative:
Sigmoid:
The function is σ(x)=1+e−x1.
Its derivative is σ′(x)=σ(x)⋅(1−σ(x)).
Note that we pass the output of the sigmoid function to calculate its derivative, as this is computationally efficient.
ReLU (rectified linear unit):
The function is ReLU(x)=max(0,x).
Its derivative is 1 if x > 0 and 0 otherwise.
We use R's pmax function and ifelse to efficiently compute this.
Linear:
The function is simply f(x)=x.
Its derivative is always 1.
We create an array of ones with the same dimensions as the input.
These activation functions and their derivatives are essential building blocks for both the forward pass and backward pass in our neural network layer.
Now, let's look at the structure of our DenseLayer class using R6. This class encapsulates both the forward pass (which we've seen in previous lessons) and the backward pass (which we're focusing on today).
Activation Derivatives: We store not only the chosen activation_fn but also its activation_derivative_fn.
Caching Variables:
self$inputs will store the inputs to the layer.
self$z will store the pre-activation outputs.
self$output will store the post-activation outputs.
self$d_weights and self$d_biases will store the gradients of weights and biases.
This caching of intermediate values is crucial for backpropagation. We need to know these values during the backward pass to correctly compute the gradients.
The forward pass not only computes the layer's output but also stores the necessary values for the backward pass. Let's add the forward method to our DenseLayer class:
Calculates and stores the pre-activation outputs self$z (the weighted sum plus bias).
Applies the activation function and stores the results in self$output.
Returns the output for use in subsequent layers.
The key insight here is that we're caching all the intermediate values we'll need for the backward pass. This is essential for efficient computation of gradients during backpropagation.
As you may recall from our previous lessons, this is how information flows forward through the network. Now, let's see how errors flow backward during the backpropagation process.
Now we come to the heart of backpropagation: the backward method. This method calculates the gradients that will be used to update the weights during gradient descent.
# Add this method to the DenseLayer classDenseLayer$set("public", "backward", function(d_loss_wrt_layer_output) { # Gradient of loss w.r.t. pre-activation output (dL/dz) # dL/dz = dL/dy * dy/dz, where y = activation(z) d_activation_output <- d_loss_wrt_layer_output * self$activation_derivative_fn(self$output) # Gradient of loss w.r.t. weights (dL/dW) # dL/dW = dL/dz * dz/dW = d_activation_output * self$inputs.T self$d_weights <- t(self$inputs) %*% d_activation_output # Gradient of loss w.r.t. biases (dL/dB) # dL/dB = dL/dz * dz/dB = d_activation_output * 1 (summed over samples) self$d_biases <- matrix(colSums(d_activation_output), nrow = 1) # Gradient of loss w.r.t. inputs of this layer (dL/dX_current_layer) # This will be d_loss_wrt_layer_output for the *previous* layer # dL/dX = dL/dz * dz/dX = d_activation_output . self$weights.T d_loss_wrt_prev_layer_output <- d_activation_output %*% t(self$weights) return(d_loss_wrt_prev_layer_output)})
Let's break down what's happening:
Input: d_loss_wrt_layer_output is the gradient of the loss with respect to this layer's output. For the output layer, this comes directly from the loss function. For hidden layers, it's passed backward from the next layer.
Gradient w.r.t. pre-activation output: We first calculate dzdL by applying the chain rule: dzdL=dydL⋅dzdy, where y is the activation output and z is the pre-activation output.
Gradient w.r.t. weights: We calculate dWdL using the chain rule again: dWdL=dzdL⋅dWdz. Since z=X⋅W+b, we have dWdz=XT, leading to dWdL=XT⋅dzdL.
Gradient w.r.t. biases: Similarly, dbdL=dzdL⋅dbdz. Since dbdz=1, we sum dzdL over all samples.
Gradient w.r.t. inputs: Finally, we calculate dXdL to pass backward to the previous layer: dXdL=dzdL⋅dXdz. Since dXdz=WT, we get dXdL=dzdL⋅WT.
Each of these calculations is a direct application of the chain rule, and together they form the core of the backpropagation algorithm.