Introduction

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
Matrix Operations Helper Functions

Before implementing our activation functions and neural network layer, we need some helper functions for matrix operations in C++. These will replace the convenient matrix operations we would typically get from specialized libraries:

#include <vector>
#include <cmath>
#include <algorithm>
#include <iostream>
#include <random>
#include <stdexcept>

// Matrix class for 2D operations
class Matrix {
public:
    std::vector<std::vector<double>> data;
    size_t rows, cols;
    
    Matrix(size_t r, size_t c) : rows(r), cols(c) {
        data.resize(rows, std::vector<double>(cols, 0.0));
    }
    
    Matrix(const std::vector<std::vector<double>>& input_data) {
        data = input_data;
        rows = data.size();
        cols = rows > 0 ? data[0].size() : 0;
    }
    
    // Matrix multiplication
    Matrix dot(const Matrix& other) const {
        if (cols != other.rows) {
            throw std::invalid_argument("Matrix dimensions don't match for multiplication");
        }
        
        Matrix result(rows, other.cols);
        for (size_t i = 0; i < rows; ++i) {
            for (size_t j = 0; j < other.cols; ++j) {
                for (size_t k = 0; k < cols; ++k) {
                    result.data[i][j] += data[i][k] * other.data[k][j];
                }
            }
        }
        return result;
    }
    
    // Matrix transpose
    Matrix transpose() const {
        Matrix result(cols, rows);
        for (size_t i = 0; i < rows; ++i) {
            for (size_t j = 0; j < cols; ++j) {
                result.data[j][i] = data[i][j];
            }
        }
        return result;
    }
    
    // Element-wise operations
    Matrix operator*(const Matrix& other) const {
        Matrix result(rows, cols);
        for (size_t i = 0; i < rows; ++i) {
            for (size_t j = 0; j < cols; ++j) {
                result.data[i][j] = data[i][j] * other.data[i][j];
            }
        }
        return result;
    }
    
    Matrix operator+(const Matrix& other) const {
        Matrix result(rows, cols);
        for (size_t i = 0; i < rows; ++i) {
            for (size_t j = 0; j < cols; ++j) {
                result.data[i][j] = data[i][j] + other.data[i][j];
            }
        }
        return result;
    }
    
    // Sum along axis (0 for columns, 1 for rows)
    Matrix sum(int axis = 0) const {
        if (axis == 0) {
            Matrix result(1, cols);
            for (size_t j = 0; j < cols; ++j) {
                for (size_t i = 0; i < rows; ++i) {
                    result.data[0][j] += data[i][j];
                }
            }
            return result;
        } else {
            Matrix result(rows, 1);
            for (size_t i = 0; i < rows; ++i) {
                for (size_t j = 0; j < cols; ++j) {
                    result.data[i][0] += data[i][j];
                }
            }
            return result;
        }
    }
};

This Matrix class provides the essential operations we need for implementing our neural network layer, including matrix multiplication, transpose, element-wise operations, and summation along axes.

Activation Functions and Their Derivatives
The DenseLayer Class Structure

Now, let's look at the structure of our DenseLayer class. This class encapsulates both the forward pass (which we've seen in previous lessons) and the backward pass (which we're focusing on today).

class DenseLayer {
private:
    size_t n_inputs;
    size_t n_neurons;
    std::string activation_fn_name;
    std::string weight_init_strategy;
    double weight_init_scale;
    
    // Function pointers for activation functions
    Matrix (*activation_fn)(const Matrix&);
    Matrix (*activation_derivative_fn)(const Matrix&);
    
    // Variables to store values needed for backpropagation
    Matrix inputs;
    Matrix z;
    Matrix output;
    
public:
    Matrix weights;
    Matrix biases;
    Matrix d_weights;
    Matrix d_biases;
    
    DenseLayer(size_t n_inputs, size_t n_neurons, 
               const std::string& activation_fn_name = "sigmoid",
               const std::string& weight_init_strategy = "random_scaled", 
               double weight_init_scale = 0.01)
        : n_inputs(n_inputs), n_neurons(n_neurons), 
          activation_fn_name(activation_fn_name),
          weight_init_strategy(weight_init_strategy),
          weight_init_scale(weight_init_scale),
          inputs(1, 1), z(1, 1), output(1, 1),
          weights(n_inputs, n_neurons), biases(1, n_neurons),
          d_weights(n_inputs, n_neurons), d_biases(1, n_neurons) {
        
        // Initialize weights and biases
        if (weight_init_strategy == "random_scaled") {
            std::random_device rd;
            std::mt19937 gen(rd());
            std::normal_distribution<double> dist(0.0, weight_init_scale);
            
            for (size_t i = 0; i < n_inputs; ++i) {
                for (size_t j = 0; j < n_neurons; ++j) {
                    weights.data[i][j] = dist(gen);
                }
            }
        }
        
        // Biases are initialized to zero (already done by Matrix constructor)
        
        // Set activation function and its derivative
        if (activation_fn_name == "sigmoid") {
            activation_fn = sigmoid;
            activation_derivative_fn = sigmoid_derivative;
        } else if (activation_fn_name == "relu") {
            activation_fn = relu;
            activation_derivative_fn = relu_derivative;
        } else if (activation_fn_name == "linear") {
            activation_fn = linear;
            activation_derivative_fn = linear_derivative;
        } else {
            throw std::invalid_argument("Unsupported activation: " + activation_fn_name);
        }
    }
};

The key components to notice:

  1. Activation functions: We store function pointers to both the chosen activation_fn and its activation_derivative_fn.
  2. Caching variables:
    • inputs will store the inputs to the layer.
    • z will store the pre-activation outputs.
    • output will store the post-activation outputs.
    • d_weights and 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: Setting Up for Backpropagation

The forward pass not only computes the layer's output but also stores the necessary values for the backward pass. Let's examine the forward method implementation:

Matrix forward(const Matrix& input_data) {
    inputs = input_data;
    z = inputs.dot(weights) + biases;  // Pre-activation output
    output = activation_fn(z);
    return output;
}

This method:

  1. Stores the input values in inputs.
  2. Calculates and stores the pre-activation outputs z (the weighted sum plus bias).
  3. Applies the activation function and stores the results in output.
  4. 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.

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:

int main() {
    // Single layer: 2 inputs, 3 neurons, sigmoid activation
    DenseLayer layer(2, 3, "sigmoid");
    
    Matrix X_sample({{0.5, -0.2}});  // 1 sample, 2 features
    std::cout << "Input X: ";
    for (size_t j = 0; j < X_sample.cols; ++j) {
        std::cout << X_sample.data[0][j] << " ";
    }
    std::cout << std::endl;
    
    // Forward pass
    Matrix layer_output = layer.forward(X_sample);
    std::cout << "Layer output (after sigmoid): ";
    for (size_t j = 0; j < layer_output.cols; ++j) {
        std::cout << layer_output.data[0][j] << " ";
    }
    std::cout << std::endl;

    // 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)
    Matrix dummy_d_loss_wrt_layer_output({{0.1, -0.2, 0.05}});
    std::cout << "Dummy dL/d(layer_output): ";
    for (size_t j = 0; j < dummy_d_loss_wrt_layer_output.cols; ++j) {
        std::cout << dummy_d_loss_wrt_layer_output.data[0][j] << " ";
    }
    std::cout << std::endl;

    // Backward pass
    Matrix d_loss_wrt_input = layer.backward(dummy_d_loss_wrt_layer_output);

    std::cout << "\nCalculated Gradients:" << std::endl;
    std::cout << "  dL/d_weights (shape " << layer.d_weights.rows << "x" << layer.d_weights.cols << "):" << std::endl;
    for (size_t i = 0; i < layer.d_weights.rows; ++i) {
        std::cout << "    ";
        for (size_t j = 0; j < layer.d_weights.cols; ++j) {
            std::cout << layer.d_weights.data[i][j] << " ";
        }
        std::cout << std::endl;
    }
    
    std::cout << "  dL/d_biases (shape " << layer.d_biases.rows << "x" << layer.d_biases.cols << "):" << std::endl;
    std::cout << "    ";
    for (size_t j = 0; j < layer.d_biases.cols; ++j) {
        std::cout << layer.d_biases.data[0][j] << " ";
    }
    std::cout << std::endl;
    
    std::cout << "  dL/d_inputs (to pass to prev layer) (shape " << d_loss_wrt_input.rows << "x" << d_loss_wrt_input.cols << "):" << std::endl;
    std::cout << "    ";
    for (size_t j = 0; j < d_loss_wrt_input.cols; ++j) {
        std::cout << d_loss_wrt_input.data[0][j] << " ";
    }
    std::cout << std::endl;
    
    return 0;
}

This example:

  1. Creates a single DenseLayer with 2 inputs and 3 neurons.
  2. Performs a forward pass with a sample input.
  3. Simulates receiving gradients from the next layer using a dummy gradient.
  4. Performs a backward pass using this gradient.
  5. Prints the computed gradients for weights, biases, and inputs.
Output Discussion

When we run this code, we get output similar to the following:

Input X: 0.5 -0.2 
Layer output (after sigmoid): 0.500012 0.498415 0.501302 
Dummy dL/d(layer_output): 0.1 -0.2 0.05 

Calculated Gradients:
  dL/d_weights (shape 2x3):
    0.0125 -0.025 0.00625 
    -0.005 0.01 -0.0025 
  dL/d_biases (shape 1x3):
    0.025 -0.05 0.0125 
  dL/d_inputs (to pass to prev layer) (shape 1x2):
    0.000694 -0.000182 

Looking at this output:

  1. Our input is a single sample with two features: [0.5, -0.2].
  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).
  3. 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.
  4. 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.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal