Gradient Descent & Backpropagation: Training Neural Networks from Scratch with CUDA/C++
This article is the fourth part of a deep dive into the world of neural networks, where we are building a Transformer architecture step-by-step, starting with the most basic components in CUDA. In this installment, we move from implementing the forward pass to the crucial stage of model training. We will explore the fundamental mechanisms of Gradient Descent and Backpropagation, implementing them at a low level using C++ and CUDA to train our neural network to recognize handwritten digits from the MNIST dataset. The goal is not just to use off-the-shelf libraries, but to thoroughly understand how these complex computational processes work "under the hood," which is critical for a deep comprehension of modern machine learning and artificial intelligence models.
Fundamentals of Neural Network Training: Gradient Descent
After successfully implementing the forward pass in the previous part, our model is capable of making predictions. However, at the initial stage, without training, these predictions will be random. For the model to start producing correct results, its internal parameters (weights and biases) must be adjusted to minimize the error between predictions and true labels. Algorithms like Gradient Descent and Backpropagation are used precisely for this purpose.
Imagine you are on a mountaintop in a dense fog, and your task is to descend to the lowest point of the valley. You cannot see the entire landscape, but you can feel the slope under your feet. The gradient is a vector that indicates the direction of the steepest ascent. Consequently, to descend, you need to move in the opposite direction of the gradient. In the context of neural networks, the "mountain" is the surface of the error function (or loss function), and the "valley" is its global minimum. The goal of gradient descent is to find this minimum by iteratively adjusting the network's weights in the direction opposite to the gradient of the loss function with respect to these weights.
Backpropagation is an algorithm that efficiently computes these gradients. It works by propagating the error from the network's output layer back to the input layer, distributing responsibility for the error among all weights. Mathematically, this is implemented through the Chain Rule of differentiation, which allows us to calculate how a change in each weight affects the model's overall error.
Loss Function: Cross-Entropy
To measure the "badness" of the model's predictions, a loss function is used. In classification tasks, such as recognizing handwritten MNIST digits, cross-entropy loss is a standard choice. It quantifies the divergence between the probability distribution predicted by the model and the true distribution (where only the correct class has a probability of 1, and others are 0). The greater this divergence, the higher the loss function value.
For a single example, cross-entropy is calculated using the formula:

Where:
-  — is the number of classes (for MNIST, this is 10).
-  — is the true value (1 for the correct class, 0 for others).
-  — is the model's prediction (probability obtained after the Softmax layer).
Since training usually occurs on batches of data, not individual examples, the total loss function for a batch is averaged over its size ( — batch size):

Below is the C++ code for calculating cross-entropy on the CPU, which simplifies debugging and understanding:
float cross_entropy_loss_cpu(
const Tensor& predictions, const std::vector<int>& labels
) {
int batch_size = predictions.shape()[0];
int num_classes = predictions.shape()[1];
float loss = 0.0f;
for (int i = 0; i < batch_size; i++) {
for (int j = 0; j < num_classes; j++) {
float prob = predictions.get({i, j});
float target = (labels[i] == j) ? 1.0f : 0.0f;
loss += -std::log(prob) * target;
}
}
return loss / batch_size;
}
Backpropagation and the Chain Rule
To minimize the loss function , we need to know how its value changes with a small change in each weight in the network. This is the gradient of the loss function with respect to the weights. For example, for weight  of the first layer, we need to find .
The architecture of our network, developed in previous articles, looks as follows:
- First Linear Layer: 
- Activation (ReLU): 
- Second Linear Layer: 
- Output (Softmax + Loss): 
To calculate the gradient , we apply the Chain Rule:

Each term in this chain represents a local gradient, which shows how the output of a given layer or function affects its input. Backpropagation begins by calculating the gradient of the loss function with respect to the outputs of the last layer, and then sequentially propagates this gradient backward through all layers of the network.
Detailed Derivation of Gradients for Softmax and Cross-Entropy
Let's start from the very end of the chain – the derivative of the loss function  with respect to the outputs of the second linear layer  after applying Softmax. This is one of the most important and frequently used gradients. Recall how Softmax looks for each element  in the vector :

Where  is the output of the second linear layer (logits), and  is the result of Softmax (probabilities). The gradient  can be decomposed using the Chain Rule:

First, let's find the derivative of the loss function  with respect to :

Now let's move to the derivative of Softmax with respect to . Here, the quotient rule for differentiation will be needed. The Softmax formula: 
If , the derivative is:

If , the derivative is:

Using the Kronecker delta symbol , these two cases can be combined:

Combining the derivatives of cross-entropy and Softmax, we get:

Further simplification, considering that the sum of true probabilities  is always equal to 1, leads to a surprisingly simple and elegant formula:

This means that the gradient of the loss function with respect to the logits (outputs before Softmax) is simply the difference between the predicted probabilities and the true labels. This result is a cornerstone for implementing backpropagation in classification models.
Implementing the Gradient in C++
Applying the derived formula, we can implement the gradient calculation for the last layer of our neural network. This gradient will then be used to update the weights, moving the model towards a smaller error. Here's how it looks in C++ code:
auto grad = Tensor::create_zeros({ batch_size, num_classes });
for (int i = 0; i < batch_size; ++i) {
for (int j = 0; j < num_classes; ++j) {
float prob = fc2.output->get({i, j});
float target = (targets[i] == j) ? 1.0f : 0.0f;
float gradient = (prob - target) / batch_size; // Division by batch_size for averaging
grad->set({i, j}, gradient);
}
}
This code calculates the error gradient for each element in the batch and then averages it over the batch size. The resulting grad tensor contains information on how much to change the logits  to reduce the error. This gradient will then be propagated through the preceding layers of the network, allowing the weights , , , and  to be adjusted using the corresponding local derivatives.
Key Takeaways:
- Gradient Descent is an iterative optimization method that adjusts model parameters in the direction opposite to the gradient of the loss function to find its minimum.
- Backpropagation is an algorithm that uses the chain rule to efficiently compute the gradients of the loss function with respect to all weights in a neural network.
- Cross-Entropy is a standard loss function for classification tasks, measuring the divergence between predicted and true probability distributions.
- The derivative of Softmax + Cross-Entropy with respect to logits simplifies to the difference between predicted probabilities and true labels, significantly simplifying implementation.
- Implementing from scratch in C++/CUDA allows for a deep understanding of the internal mechanisms of models like Transformer, without relying on high-level abstractions.
— Editorial Team
No comments yet.