Introduction

Welcome back to our course, "Training Neural Networks: the Backpropagation Algorithm"! This is our fifth and final lesson in this course, and we've come a long way. So far, we've explored loss functions, gradient descent, and implemented backpropagation for both individual layers and complete networks.

In our previous lesson, we implemented the backward pass for a multi-layer perceptron, allowing us to calculate the gradients of all weights and biases with respect to the loss. However, calculating gradients is only half the story. These gradients tell us which direction to move, but we still need to actually move in that direction!

Today, we'll complete our neural network training journey by implementing stochastic gradient descent (SGD), which will use the gradients to update our network's parameters. We'll also create a complete training loop and apply our knowledge to train a model on a real-world regression task.

This represents the culmination of everything we've learned so far. After this lesson, you'll be ready to move on to our fourth and final course, "Building and Applying Your Neural Network Library"!

Stochastic Gradient Descent: The Workhorse of Neural Network Training

Stochastic gradient descent (SGD) is the foundational algorithm that powers most neural network training. In our earlier lesson, we explored basic gradient descent, also called batch gradient descent, where we minimized a simple quadratic function by computing the gradient over the entire dataset and updating the parameters accordingly. However, as datasets grow larger and models become more complex, this approach becomes computationally expensive and slow.

To address this, several variants of gradient descent have been developed:

  • Batch Gradient Descent: Computes gradients using the entire dataset for each update. While this provides the most accurate gradient direction, it is often too slow and memory-intensive for large datasets.
  • Stochastic Gradient Descent (SGD): Updates parameters using only a single randomly chosen data point at each step. This allows for very frequent updates and can help the model escape local minima, but introduces a lot of noise into the updates.
  • Mini-batch Gradient Descent: The most common or "default" approach in deep learning. Instead of using the whole dataset or a single data point, it updates parameters using small, randomly selected batches of data (e.g., 32 or 64 samples at a time). This strikes a balance between computational efficiency and the stability of gradient estimates.

The key idea behind these variants is how much data is used to estimate the gradient at each update step. Using smaller subsets (mini-batches) introduces randomness, or "stochasticity," into the training process. This randomness provides several benefits:

  1. Computational Efficiency: Processing small batches requires less memory and allows for faster updates.
  2. Faster Convergence: Weights are updated more frequently, so learning can progress more quickly.
  3. Ability To Escape Local Minima: The noise in gradient estimates can help the model avoid getting stuck in shallow local minima.

In modern neural network training, mini-batch SGD is the standard, as it leverages the strengths of both batch and stochastic approaches and is well-suited to parallel computation on hardware like GPUs.

The SGD Algorithm: Pseudocode and Key Steps

Let's break down the SGD algorithm as it is typically used in neural network training. The process involves iteratively updating the model's parameters in the direction that reduces the loss, using gradients computed from mini-batches of data.

Here's the pseudocode for mini-batch SGD:

Initialize network weights randomly
Set learning rate a
For each epoch:
    Shuffle training data
    Split data into mini-batches
    For each mini-batch:
        Forward pass: compute predictions
        Compute loss
        Backward pass: compute gradients
        Update weights: w = w - a * gradient

Let's discuss the key steps:

  1. Initialization: Start by randomly initializing the network's weights.
  2. Learning Rate: Set the learning rate a, which determines the size of each update step.
  3. Epoch Loop: For each epoch (a full pass through the dataset), shuffle the data to ensure randomness.
  4. Mini-batch Loop: Split the data into mini-batches and process each batch in turn.
    • Forward Pass: Compute the model's predictions for the current mini-batch.
    • Loss Calculation: Measure how far off the predictions are from the true values.
    • Backward Pass: Compute the gradients of the loss with respect to each parameter.
    • Parameter Update: Adjust the weights and biases in the direction that reduces the loss, scaled by the learning rate.

By following this cycle of forward and backward passes, combined with parameter updates, SGD enables neural networks to learn from data in a scalable and efficient way.

Implementing the SGD Optimizer

Now that we understand the concept behind SGD, let's implement it as a C++ class. Our SGD optimizer will be responsible for updating the weights and biases of each layer using their calculated gradients:

class SGD {
public:
    double learning_rate;
    SGD(double lr = 0.01) : learning_rate(lr) {}
    
    void update(DenseLayer& layer) {
        // Check if gradients exist before attempting to use them
        if (layer.d_weights.empty() || layer.d_biases.empty()) {
            std::cout << "Warning: Gradients not found for a layer. Skipping update." << std::endl;
            return;
        }
        
        // Update weights: w = w - learning_rate * gradient
        for (size_t i = 0; i < layer.weights.size(); ++i) {
            for (size_t j = 0; j < layer.weights[i].size(); ++j) {
                layer.weights[i][j] -= learning_rate * layer.d_weights[i][j];
            }
        }
        
        // Update biases: b = b - learning_rate * gradient
        for (size_t i = 0; i < layer.biases.size(); ++i) {
            layer.biases[i] -= learning_rate * layer.d_biases[i];
        }
    }
};

This implementation is straightforward but powerful. Let's break it down:

  1. We initialize the optimizer with a specified learning rate (defaulting to 0.01 if none is provided) using C++'s member initialization list.
  2. The update method takes a layer reference as input and applies the SGD update rule to its weights and biases.
  3. We check if the gradients exist (by checking if the gradient vectors are empty) before attempting to use them, providing a warning if they're missing.
  4. The core update rule is simply: parameter -= learning_rate * gradient, implemented using nested loops for the 2D weight matrix and a single loop for the 1D bias vector.

The beauty of this design is its simplicity and flexibility. Each layer calculates and stores its own gradients during the backward pass, and the optimizer simply uses these stored gradients to update the parameters. This separation of concerns creates a clean architecture that can be easily extended to more sophisticated optimization algorithms in the future.

Sample Dataset for Regression

Before we implement our training loop, let's create a sample dataset for our regression task. Since we're working in C++, we'll generate a synthetic dataset that mimics the characteristics of a real-world regression problem. This will allow us to focus on the neural network training process without getting bogged down in data loading complexities.

We'll create a dataset with multiple features and a continuous target variable, similar to what you might encounter in medical or scientific applications:

// Function to generate sample regression data
std::pair<std::vector<std::vector<double>>, std::vector<double>> generate_sample_data(int n_samples = 400, int n_features = 10) {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::normal_distribution<double> feature_dist(0.0, 1.0);
    std::normal_distribution<double> noise_dist(0.0, 10.0);
    
    std::vector<std::vector<double>> X(n_samples, std::vector<double>(n_features));
    std::vector<double> y(n_samples);
    
    // Generate random coefficients for our synthetic relationship
    std::vector<double> true_coeffs(n_features);
    for (int i = 0; i < n_features; ++i) {
        true_coeffs[i] = feature_dist(gen) * 50.0; // Scale coefficients
    }
    
    // Generate data points
    for (int i = 0; i < n_samples; ++i) {
        double target = 100.0; // Base value
        
        // Generate features and compute target
        for (int j = 0; j < n_features; ++j) {
            X[i][j] = feature_dist(gen);
            target += true_coeffs[j] * X[i][j];
        }
        
        // Add some noise to make it realistic
        y[i] = target + noise_dist(gen);
    }
    
    return std::make_pair(X, y);
}

This synthetic dataset generator creates:

  1. Feature Matrix: A matrix of normally distributed random features (similar to standardized real-world data).
  2. Target Variable: A continuous target computed as a linear combination of features plus noise.
  3. Realistic Complexity: Multiple features with different importance weights and added noise to simulate real-world conditions.

Our task will be to predict the target values, making this a regression problem. This dataset is manageable in size, making it ideal for our educational purposes while still being complex enough to demonstrate the power of our neural network implementation.

Here's how we'll use this data in our training code:

// Generate training data
auto [X_train, y_train] = generate_sample_data(400, 10);
int n_samples = X_train.size();
int n_features = X_train[0].size();

std::cout << "Training data shape: X_train: (" << n_samples << ", " << n_features 
          << "), y_train: (" << y_train.size() << ")" << std::endl;

This gives us our feature matrix X_train with shape (400, 10) and our target vector y_train with shape (400). We're now ready to build our training loop and apply SGD to learn from this data.

Creating a Training Loop

With our SGD optimizer and dataset ready, let's implement a basic training loop to tie everything together. The training loop is the heart of neural network learning, orchestrating the forward pass, loss calculation, backward pass, and parameter updates.

Here's the structure of a typical training loop using mini-batch SGD:

// Create and configure the MLP
MLP mlp;
mlp.add_layer(DenseLayer(n_features, 10, "relu", 0.1));
mlp.add_layer(DenseLayer(10, 1, "linear", 0.1));

// Initialize optimizer and training parameters
SGD optimizer(0.002); // Small learning rate
int epochs = 100;
int batch_size = 32;
int num_batches = (n_samples + batch_size - 1) / batch_size; // Ceiling division

std::cout << "Training MLP for regression for " << epochs 
          << " epochs with LR=" << optimizer.learning_rate << std::endl;

// Random number generator for shuffling
std::random_device rd;
std::mt19937 gen(rd());

for (int epoch = 0; epoch < epochs; ++epoch) {
    // Create indices for shuffling
    std::vector<int> indices(n_samples);
    std::iota(indices.begin(), indices.end(), 0);
    std::shuffle(indices.begin(), indices.end(), gen);
    
    double total_loss = 0.0;
    
    // Process data in mini-batches
    for (int batch_start = 0; batch_start < n_samples; batch_start += batch_size) {
        int current_batch_size = std::min(batch_size, n_samples - batch_start);
        
        // Create mini-batch
        std::vector<std::vector<double>> X_batch(current_batch_size, std::vector<double>(n_features));
        std::vector<std::vector<double>> y_batch(current_batch_size, std::vector<double>(1));
        
        for (int i = 0; i < current_batch_size; ++i) {
            int idx = indices[batch_start + i];
            X_batch[i] = X_train[idx];
            y_batch[i][0] = y_train[idx];
        }

Let's analyze this initial part of our training loop:

  1. We first create our MLP with two layers: a hidden layer with 10 neurons and ReLU activation, and an output layer with 1 neuron (for regression) and linear activation.
  2. We initialize our SGD optimizer with a learning rate of 0.002, which is relatively small to ensure stable training.
  3. We set up hyperparameters: 100 epochs and a batch size of 32.
  4. For each epoch, we create a vector of indices and shuffle them using C++'s std::shuffle to prevent the model from learning patterns based on data order.
  5. We then process the data in mini-batches, creating subsets of the training data for each update.

The shuffling step is crucial in stochastic gradient descent. It ensures that each epoch presents the data in a different order, which helps prevent the model from getting stuck in local minima and improves generalization. Note how we use C++'s standard library functions like std::iota to generate sequential indices and std::shuffle for randomization.

Completing the Training Loop

Now let's complete our training loop by implementing the forward pass, backward pass, and parameter updates for each mini-batch:

        // Within the mini-batch loop:
        // 1. Forward pass
        std::vector<std::vector<double>> y_pred = mlp.forward(X_batch);
        
        // 2. Calculate loss
        double loss = mse_loss(y_batch, y_pred);
        total_loss += loss;
        
        // 3. Calculate derivative of loss
        std::vector<std::vector<double>> d_loss_wrt_pred = mse_loss_derivative(y_batch, y_pred);
        
        // 4. Backward pass
        mlp.backward(d_loss_wrt_pred);
        
        // 5. Update weights
        for (auto& layer : mlp.layers) {
            optimizer.update(layer);
        }
    }
    
    // Average loss over all batches for reporting
    double avg_loss = total_loss / num_batches;
    if ((epoch + 1) % (epochs / 10 > 0 ? epochs / 10 : 1) == 0 || epoch == 0) {
        std::cout << "Epoch " << std::setw(4) << (epoch + 1) << "/" << epochs 
                  << ", Loss: " << std::fixed << std::setprecision(2) << avg_loss << std::endl;
    }
}

For each mini-batch, we:

  1. Forward Pass: Use our MLP to generate predictions.
  2. Loss Calculation: Compute the MSE loss between predictions and targets.
  3. Loss Derivative: Calculate how the loss changes with respect to our predictions.
  4. Backward Pass: Propagate this gradient backward through the network.
  5. Parameter Updates: Use our SGD optimizer to update each layer's weights and biases.

After processing all mini-batches, we calculate the average loss for the epoch and periodically print it to track training progress. We use C++'s std::setw, std::fixed, and std::setprecision for formatted output that's easy to read.

The training loop is the orchestrator that brings together all the components we've built throughout this course: the layer structure, forward propagation, loss functions, backpropagation, and now parameter updates through SGD. This elegant cycle of computation and learning is what gives neural networks their power.

Evaluating Training Results

After training is complete, we want to evaluate our model's performance and examine some sample predictions. Let's add this final piece to our implementation:

std::cout << "\n--- Training Over ---" << std::endl;

// Generate final predictions on training data
std::vector<std::vector<double>> final_preds = mlp.forward(X_train);

// Convert y_train to the expected format for loss calculation
std::vector<std::vector<double>> y_train_2d(n_samples, std::vector<double>(1));
for (int i = 0; i < n_samples; ++i) {
    y_train_2d[i][0] = y_train[i];
}

double final_loss = mse_loss(y_train_2d, final_preds);
std::cout << "Final MSE Loss on training data: " << std::fixed << std::setprecision(2) 
          << final_loss << std::endl;

std::cout << "\nSample Predictions:" << std::endl;
int num_samples_to_show = std::min(5, n_samples);
for (int i = 0; i < num_samples_to_show; ++i) {
    std::cout << "  True: " << std::setw(8) << std::fixed << std::setprecision(2) 
              << y_train[i] << ", Predicted: " << std::setw(8) << std::fixed 
              << std::setprecision(2) << final_preds[i][0] << std::endl;
}

After running this code, we might see output like:

Training MLP for regression for 100 epochs with LR=0.002
Training data shape: X_train: (400, 10), y_train: (400)
Epoch    1/100, Loss: 18234.56
Epoch   10/100, Loss: 3456.78
Epoch   20/100, Loss: 2987.45
Epoch   30/100, Loss: 2654.32
Epoch   40/100, Loss: 2456.89
Epoch   50/100, Loss: 2298.76
Epoch   60/100, Loss: 2187.43
Epoch   70/100, Loss: 2098.65
Epoch   80/100, Loss: 2034.21
Epoch   90/100, Loss: 1987.54
Epoch  100/100, Loss: 1954.32

--- Training Over ---
Final MSE Loss on training data: 1923.45

Sample Predictions:
  True:   156.78, Predicted:   148.92
  True:    89.34, Predicted:    95.67
  True:   203.45, Predicted:   198.23
  True:   134.56, Predicted:   142.18
  True:   178.90, Predicted:   171.45

The training results show that:

  1. Loss Reduction: The loss drops significantly in the early epochs (from around 18,000 to around 3,500), demonstrating that our model is learning. The loss continues to decrease more gradually in later epochs.
  2. Final Performance: The final MSE on the training data is approximately 1,923, which gives us a sense of the model's fit. For our synthetic regression dataset, this represents reasonable learning progress.
  3. Predictions Vs. Targets: Looking at the sample predictions, we can see that our model makes reasonable predictions that are generally close to the true values. The differences we observe are expected, given the noise we added to our synthetic dataset.

This analysis provides valuable insights into our model's performance and confirms that our implementation of SGD and the training loop is working correctly. The C++ implementation successfully demonstrates the complete neural network training pipeline.

Conclusion

Congratulations! You've completed the fifth and final lesson in our course on "Training Neural Networks: the Backpropagation Algorithm". Throughout this lesson, we've implemented the crucial final component of neural network training: the stochastic gradient descent optimizer that transforms calculated gradients into parameter updates. We created a complete training loop that orchestrates forward propagation, loss calculation, backpropagation, and parameter updates, applying it to a synthetic regression task. You now understand the full lifecycle of neural network training, from initializing parameters to making predictions, calculating loss, computing gradients, and updating parameters in a continuous cycle of improvement.

You're now ready to move on to our fourth and final course in this path, "Building and Applying Your Neural Network Library". There, you'll build upon the foundations established in this course to create a more comprehensive neural network library and apply it to solve real-world problems. Remember that while our implementation prioritized clarity and educational value over computational efficiency, the fundamental principles remain the same in production libraries like TensorFlow and PyTorch, which leverage advanced hardware for optimization. Keep experimenting, keep learning, and most importantly, enjoy applying your new skills to solve interesting problems!

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