Welcome to the second lesson of our course on "The MLP Architecture: Activations & Initialization"! We're making excellent progress on our neural network journey. In the previous lesson, we successfully implemented a multi-layer perceptron (MLP) by stacking multiple dense layers, allowing information to flow from input to output through our network.
Today, we'll be exploring an essential component of modern neural networks: the rectified linear unit (ReLU) activation function. While we've been using the sigmoid activation function so far, ReLU has become the default activation function for most hidden layers in deep neural networks due to its computational efficiency and effectiveness in addressing the vanishing gradient problem.
By the end of this lesson, you'll understand what ReLU is, why it's so popular, and how to implement and incorporate it into your neural network architecture. We'll also modify our DenseLayer class to support different activation functions, making our neural network framework more flexible and powerful. Let's dive in!
Understanding Activation Functions and Their Importance
As we've seen in our previous work, activation functions introduce non-linearity into our neural networks. Without them, no matter how many layers we stack, our network would merely compute a linear transformation of the input data.
Let's quickly recall the sigmoid activation function we've been using:
double sigmoid(double x) { return 1.0 / (1.0 + std::exp(-x));}// For matrix operationsMatrix sigmoid(const Matrix& x) { Matrix result(x.rows, x.cols); for (int i = 0; i < x.rows; i++) { for (int j = 0; j < x.cols; j++) { result.data[i][j] = sigmoid(x.data[i][j]); } } return result;}
The sigmoid function maps any input to a value between 0 and 1, creating a smooth S-shaped curve:
While sigmoid works well for certain tasks, it has some significant limitations:
Vanishing Gradients: When inputs are very large or very small, the gradient of the sigmoid function becomes extremely small, slowing down learning. In deep networks, these tiny gradients get multiplied together across layers, causing them to shrink exponentially and making it nearly impossible for early layers to learn.
Exploding Gradients: Conversely, if gradients are too large, they can multiply across layers and grow exponentially, leading to unstable training where weights update by massive amounts. This can cause the network to diverge rather than converge to a solution.
Computational Expense: Computing exponentials is relatively expensive.
Not Zero-Centered: The output is always positive, which can cause zig-zagging dynamics during optimization.
These limitations become particularly problematic in deep networks with many layers. This is where alternative activation functions like ReLU come into play, offering solutions to many of these challenges.
The ReLU Activation Function
Understanding How ReLU Solves the Vanishing Gradient Problem
Implementing the ReLU Activation Function
Let's implement the ReLU activation function in C++. The implementation is remarkably simple:
double relu(double x) { return std::max(0.0, x);}// For matrix operationsMatrix relu(const Matrix& x) { Matrix result(x.rows, x.cols); for (int i = 0; i < x.rows; i++) { for (int j = 0; j < x.cols; j++) { result.data[i][j] = std::max(0.0, x.data[i][j]); } } return result;}
This implementation leverages C++'s std::max function, which returns the maximum between two values. In the matrix version, we iterate through each element of the input matrix and apply the element-wise maximum operation between each element and 0.
The beauty of this approach is that it will work efficiently whether we're applying ReLU to a single value or an entire matrix. When we apply ReLU to matrices of weighted sums in our neural network, all positive values will remain unchanged, while all negative values will be replaced with zeros.
Weight Initialization for ReLU Layers
When using different activation functions, we need to adjust how we initialize our weights. This is a crucial but often overlooked aspect of building neural networks. The initialization strategy that worked well for sigmoid (random values between 0 and 0.1) isn't optimal for ReLU.
Why Initialization Matters for ReLU
Modifying Our DenseLayer for Different Activations and Initializations
Now that we have both activation functions and initialization strategies, let's modify our DenseLayer class to support both:
#include <functional>#include <stdexcept>class DenseLayer {private: Matrix weights; Matrix biases; int n_inputs; int n_neurons; Matrix output; std::string activation_fn_name; std::function<Matrix(const Matrix&)> activation_fn;public: DenseLayer(int n_inputs, int n_neurons, const std::string& activation_fn_name = "sigmoid") : n_inputs(n_inputs), n_neurons(n_neurons), activation_fn_name(activation_fn_name) { // Initialize biases to zeros biases = Matrix(1, n_neurons); biases.zeros(); // Select the activation function and appropriate weight initialization if (activation_fn_name == "sigmoid") { activation_fn = sigmoid; // Use uniform initialization for sigmoid weights = uniform_initialization(n_inputs, n_neurons, 0.0, 0.1); } else if (activation_fn_name == "relu") { activation_fn = relu; // Use He initialization for ReLU weights = he_initialization(n_inputs, n_neurons); } else { throw std::invalid_argument("Unsupported activation function: " + activation_fn_name); } } Matrix forward(const Matrix& inputs) { // Compute weighted sum and apply activation function Matrix weighted_sum = inputs.dot(weights).add(biases); output = activation_fn(weighted_sum); return output; } // Getter methods for accessing layer properties int getInputs() const { return n_inputs; } int getNeurons() const { return n_neurons; } std::string getActivationName() const { return activation_fn_name; } Matrix getOutput() const { return output; }};
The key changes we've made are:
Added an activation_fn_name parameter to the constructor, defaulting to "sigmoid" for backward compatibility.
Stored the activation function name as an instance variable for informational purposes.
Used std::function to store a function pointer to the appropriate activation function.
Added logic to select both the appropriate activation function and the appropriate weight initialization strategy based on the provided name.
ReLU layers now automatically use He initialization, while sigmoid layers use uniform initialization.
Added error handling using std::invalid_argument for unsupported activation functions.
This approach offers several benefits:
It maintains backward compatibility with our existing code.
It automatically pairs activation functions with appropriate initialization strategies.
It makes our layer's behavior more transparent (we can easily see which activation is being used).
It sets us up to add more activation functions in the future.
The forward method remains conceptually the same, but now it will use whichever activation function was selected during initialization.
Building an MLP with Mixed Activations
With our enhanced DenseLayer class, we can now create an MLP that uses different activation functions for different layers. This is a common practice in deep learning, where ReLU is typically used for hidden layers and sigmoid (or softmax) for the output layer, depending on the task.
Let's create an MLP with ReLU for the first layer and sigmoid for the subsequent layers:
#include <iostream>#include <iomanip>int main() { // Create a sample input Matrix X_sample(1, 4); X_sample.data[0][0] = -1.0; X_sample.data[0][1] = 0.5; X_sample.data[0][2] = 2.0; X_sample.data[0][3] = -0.1; std::cout << "Input X (shape " << X_sample.rows << "x" << X_sample.cols << "):" << std::endl; X_sample.print(); // Create the MLP with different activation functions // Note: Each layer now uses the appropriate initialization automatically MLP mlp_relu; mlp_relu.addLayer(DenseLayer(4, 5, "relu")); // Uses He initialization mlp_relu.addLayer(DenseLayer(5, 3, "sigmoid")); // Uses uniform initialization mlp_relu.addLayer(DenseLayer(3, 1, "sigmoid")); // Uses uniform initialization // Display information about our network architecture std::cout << "\nMLP created with " << mlp_relu.getLayerCount() << " layers and mixed activations." << std::endl; for (int i = 0; i < mlp_relu.getLayerCount(); i++) { const DenseLayer& layer = mlp_relu.getLayer(i); std::cout << " Layer " << (i+1) << ": " << layer.getInputs() << " inputs, " << layer.getNeurons() << " neurons, Activation: " << layer.getActivationName() << std::endl; } return 0;}
This creates a three-layer MLP with:
A first layer using ReLU activation with 5 neurons (initialized with He initialization).
A second layer using sigmoid activation with 3 neurons (initialized with uniform initialization).
An output layer using sigmoid activation with 1 neuron (initialized with uniform initialization).
The output confirms our network structure:
Input X (shape 1x4):-1.00 0.50 2.00 -0.10MLP created with 3 layers and mixed activations. Layer 1: 4 inputs, 5 neurons, Activation: relu Layer 2: 5 inputs, 3 neurons, Activation: sigmoid Layer 3: 3 inputs, 1 neurons, Activation: sigmoid
Examining ReLU Behavior with Different Inputs
Let's see what happens when we forward propagate our input through this network with mixed activations:
// Forward propagate the input through the MLPMatrix output_relu = mlp_relu.forward(X_sample);std::cout << "\nOutput of the MLP (shape " << output_relu.rows << "x" << output_relu.cols << "):" << std::endl;output_relu.print();
This gives us:
Output of the MLP (shape 1x1):0.52108506
Our MLP with mixed activations is working! But to really understand how ReLU affects our network, let's try an input with mostly negative values:
// Create an input with mostly negative valuesMatrix X_negative_heavy(1, 4);X_negative_heavy.data[0][0] = -1.0;X_negative_heavy.data[0][1] = -0.5;X_negative_heavy.data[0][2] = -2.0;X_negative_heavy.data[0][3] = -0.1;std::cout << "\nInput with mostly negative values (shape " << X_negative_heavy.rows << "x" << X_negative_heavy.cols << "):" << std::endl;X_negative_heavy.print();// Forward propagate and examine the first layer's outputMatrix output_negative_heavy = mlp_relu.forward(X_negative_heavy);std::cout << "Output for negative heavy input (shape " << output_negative_heavy.rows << "x" << output_negative_heavy.cols << "):" << std::endl;output_negative_heavy.print();std::cout << "Output of first layer (ReLU) after forward pass: ";mlp_relu.getLayer(0).getOutput().print();
The result reveals a fascinating aspect of ReLU:
Input with mostly negative values (shape 1x4):-1.00 -0.50 -2.00 -0.10Output for negative heavy input (shape 1x1):0.5209758Output of first layer (ReLU) after forward pass: 0.00 0.00 0.00 0.00 0.00
Look at the output of the first layer! It's all zeros. This illustrates a key property of ReLU: it completely blocks negative inputs, resulting in a sparse activation pattern. In this case, all of our input values resulted in negative weighted sums in the first layer, so ReLU converted them all to zeros.
Despite this extreme first-layer output, our network still produced a reasonable final output because of the biases in subsequent layers. This example highlights the importance of proper weight initialization when using ReLU. With He initialization, we're much less likely to encounter situations where too many neurons "die" (always output 0), as the weights are scaled appropriately to maintain healthy activation levels.
Conclusion and Next Steps
Great work! You've now learned about the ReLU activation function, its advantages over sigmoid, and how to implement and use it in your neural network framework. You've also learned about He initialization and why proper weight initialization is crucial when using ReLU. You've seen how to build MLPs with mixed activation functions that automatically use appropriate initialization strategies, and observed the unique behavior of ReLU in practice.
Up next, you'll get hands-on experience with a practice section focused on ReLU, where you'll solidify your understanding by applying what you've learned. After that, we'll move on to discuss activation functions specifically designed for output layers, such as linear and softmax activations, and see how they are used for different types of prediction tasks. Your neural network toolkit is expanding, and you're well on your way to building more flexible and powerful models!
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
C++
double sigmoid(double x) { return 1.0 / (1.0 + std::exp(-x));}// For matrix operationsMatrix sigmoid(const Matrix& x) { Matrix result(x.rows, x.cols); for (int i = 0; i < x.rows; i++) { for (int j = 0; j < x.cols; j++) { result.data[i][j] = sigmoid(x.data[i][j]); } } return result;}
The rectified linear unit (ReLU) is perhaps the simplest non-linear activation function, yet it has revolutionized deep learning. Its mathematical definition is elegantly straightforward:
f(x)=max(0,x)
In plain English: ReLU outputs the input directly if it's positive and outputs 0 if the input is negative. This creates a simple "ramp" function that's linear for positive values and flat for negative values.
The advantages of ReLU over sigmoid are numerous and significant:
Computational Efficiency: ReLU involves only a simple max operation, making it much faster to compute than functions involving exponentials.
Reduced Vanishing Gradient Problem: For positive inputs, the gradient is always 1, allowing for much faster learning.
Sparsity: ReLU naturally creates sparse activations (many neurons output 0), which can be beneficial for representation learning.
Biological Plausibility: The firing pattern of ReLU resembles that of biological neurons more closely than sigmoid.
To truly appreciate why ReLU has become so popular, we need to understand its gradient behavior. The gradient (or derivative) of an activation function tells us how much the output changes in response to changes in the input. During training, these gradients are used to update the network's weights.
For ReLU, the gradient function is remarkably simple:
f′(x)={10if x>0if x≤0
This means:
When the input is positive, the gradient is exactly 1
When the input is negative or zero, the gradient is 0
Compare this to the sigmoid gradient, which can become extremely small (approaching zero) for large positive or negative inputs. When you multiply many small gradients together across multiple layers (as happens during backpropagation), they can shrink to nearly zero, making it virtually impossible for early layers in the network to learn. This is the vanishing gradient problem.
With ReLU, as long as the neuron's output is positive, the gradient remains at 1, providing a consistent and strong learning signal that can propagate back through many layers without vanishing. This property has been crucial in enabling the training of very deep neural networks.
It's worth noting that ReLU also has a limitation known as the "dying ReLU problem" — neurons can get stuck in a state where they always output 0, effectively becoming "dead" and unable to learn. This happens when a neuron's weighted sum is consistently negative.
C++
double relu(double x) { return std::max(0.0, x);}// For matrix operationsMatrix relu(const Matrix& x) { Matrix result(x.rows, x.cols); for (int i = 0; i < x.rows; i++) { for (int j = 0; j < x.cols; j++) { result.data[i][j] = std::max(0.0, x.data[i][j]); } } return result;}
With ReLU, roughly half of the neurons will output zero for any given input (those with negative weighted sums). If our weights are too small, we risk having most neurons "dead" from the start. If they're too large, we can get exploding activations. The solution is He initialization (named after Kaiming He), which is specifically designed for ReLU and its variants.
He initialization sets the initial weights by drawing from a distribution with variance:
Var(W)=nin2
where nin is the number of input connections to the layer. This ensures that the variance of the activations remains roughly constant across layers, which helps maintain healthy gradient flow during training.
Let's implement a helper function for He initialization:
#include <random>#include <cmath>Matrix he_initialization(int n_inputs, int n_neurons) { Matrix weights(n_inputs, n_neurons); // Calculate standard deviation for He initialization double std_dev = std::sqrt(2.0 / n_inputs); // Initialize random number generator std::random_device rd; std::mt19937 gen(rd()); std::normal_distribution<double> dist(0.0, std_dev); // Fill weights with random values from the normal distribution for (int i = 0; i < n_inputs; i++) { for (int j = 0; j < n_neurons; j++) { weights.data[i][j] = dist(gen); } } return weights;}
This function creates a weight matrix initialized using a normal (Gaussian) distribution with mean 0 and standard deviation 2/nin. The factor of 2 in the numerator is specifically chosen to work well with ReLU's behavior of zeroing out negative values.
For comparison, here's a simple uniform initialization function we've been using:
#include <functional>#include <stdexcept>class DenseLayer {private: Matrix weights; Matrix biases; int n_inputs; int n_neurons; Matrix output; std::string activation_fn_name; std::function<Matrix(const Matrix&)> activation_fn;public: DenseLayer(int n_inputs, int n_neurons, const std::string& activation_fn_name = "sigmoid") : n_inputs(n_inputs), n_neurons(n_neurons), activation_fn_name(activation_fn_name) { // Initialize biases to zeros biases = Matrix(1, n_neurons); biases.zeros(); // Select the activation function and appropriate weight initialization if (activation_fn_name == "sigmoid") { activation_fn = sigmoid; // Use uniform initialization for sigmoid weights = uniform_initialization(n_inputs, n_neurons, 0.0, 0.1); } else if (activation_fn_name == "relu") { activation_fn = relu; // Use He initialization for ReLU weights = he_initialization(n_inputs, n_neurons); } else { throw std::invalid_argument("Unsupported activation function: " + activation_fn_name); } } Matrix forward(const Matrix& inputs) { // Compute weighted sum and apply activation function Matrix weighted_sum = inputs.dot(weights).add(biases); output = activation_fn(weighted_sum); return output; } // Getter methods for accessing layer properties int getInputs() const { return n_inputs; } int getNeurons() const { return n_neurons; } std::string getActivationName() const { return activation_fn_name; } Matrix getOutput() const { return output; }};
C++
#include <iostream>#include <iomanip>int main() { // Create a sample input Matrix X_sample(1, 4); X_sample.data[0][0] = -1.0; X_sample.data[0][1] = 0.5; X_sample.data[0][2] = 2.0; X_sample.data[0][3] = -0.1; std::cout << "Input X (shape " << X_sample.rows << "x" << X_sample.cols << "):" << std::endl; X_sample.print(); // Create the MLP with different activation functions // Note: Each layer now uses the appropriate initialization automatically MLP mlp_relu; mlp_relu.addLayer(DenseLayer(4, 5, "relu")); // Uses He initialization mlp_relu.addLayer(DenseLayer(5, 3, "sigmoid")); // Uses uniform initialization mlp_relu.addLayer(DenseLayer(3, 1, "sigmoid")); // Uses uniform initialization // Display information about our network architecture std::cout << "\nMLP created with " << mlp_relu.getLayerCount() << " layers and mixed activations." << std::endl; for (int i = 0; i < mlp_relu.getLayerCount(); i++) { const DenseLayer& layer = mlp_relu.getLayer(i); std::cout << " Layer " << (i+1) << ": " << layer.getInputs() << " inputs, " << layer.getNeurons() << " neurons, Activation: " << layer.getActivationName() << std::endl; } return 0;}
Input X (shape 1x4):-1.00 0.50 2.00 -0.10MLP created with 3 layers and mixed activations. Layer 1: 4 inputs, 5 neurons, Activation: relu Layer 2: 5 inputs, 3 neurons, Activation: sigmoid Layer 3: 3 inputs, 1 neurons, Activation: sigmoid
C++
// Forward propagate the input through the MLPMatrix output_relu = mlp_relu.forward(X_sample);std::cout << "\nOutput of the MLP (shape " << output_relu.rows << "x" << output_relu.cols << "):" << std::endl;output_relu.print();
Output of the MLP (shape 1x1):0.52108506
C++
// Create an input with mostly negative valuesMatrix X_negative_heavy(1, 4);X_negative_heavy.data[0][0] = -1.0;X_negative_heavy.data[0][1] = -0.5;X_negative_heavy.data[0][2] = -2.0;X_negative_heavy.data[0][3] = -0.1;std::cout << "\nInput with mostly negative values (shape " << X_negative_heavy.rows << "x" << X_negative_heavy.cols << "):" << std::endl;X_negative_heavy.print();// Forward propagate and examine the first layer's outputMatrix output_negative_heavy = mlp_relu.forward(X_negative_heavy);std::cout << "Output for negative heavy input (shape " << output_negative_heavy.rows << "x" << output_negative_heavy.cols << "):" << std::endl;output_negative_heavy.print();std::cout << "Output of first layer (ReLU) after forward pass: ";mlp_relu.getLayer(0).getOutput().print();
Input with mostly negative values (shape 1x4):-1.00 -0.50 -2.00 -0.10Output for negative heavy input (shape 1x1):0.5209758Output of first layer (ReLU) after forward pass: 0.00 0.00 0.00 0.00 0.00
C++
#include <random>#include <cmath>Matrix he_initialization(int n_inputs, int n_neurons) { Matrix weights(n_inputs, n_neurons); // Calculate standard deviation for He initialization double std_dev = std::sqrt(2.0 / n_inputs); // Initialize random number generator std::random_device rd; std::mt19937 gen(rd()); std::normal_distribution<double> dist(0.0, std_dev); // Fill weights with random values from the normal distribution for (int i = 0; i < n_inputs; i++) { for (int j = 0; j < n_neurons; j++) { weights.data[i][j] = dist(gen); } } return weights;}