Code Examples and Implementations

Simple Neural Network Implementation

Below is a complete implementation of a simple neural network from scratch using NumPy. This implementation demonstrates the mathematical concepts we've discussed, including forward propagation, backpropagation, and gradient descent.

import numpy as np
import matplotlib.pyplot as plt

class SimpleNeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size, learning_rate=0.01):
        """
        Initialize a simple neural network with one hidden layer
        
        Parameters:
        - input_size: number of input features
        - hidden_size: number of neurons in the hidden layer
        - output_size: number of output neurons
        - learning_rate: learning rate for gradient descent
        """
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.learning_rate = learning_rate
        
        # Initialize weights and biases with random values
        # Xavier/Glorot initialization for better gradient flow
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(1 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(1 / hidden_size)
        self.b2 = np.zeros((1, output_size))
        
        # For tracking loss during training
        self.loss_history = []
    
    def sigmoid(self, x):
        """Sigmoid activation function: 1 / (1 + exp(-x))"""
        # Clip x to avoid overflow
        x = np.clip(x, -500, 500)
        return 1 / (1 + np.exp(-x))
    
    def sigmoid_derivative(self, x):
        """Derivative of sigmoid: sigmoid(x) * (1 - sigmoid(x))"""
        s = self.sigmoid(x)
        return s * (1 - s)
    
    def forward(self, X):
        """
        Forward propagation through the network
        
        Parameters:
        - X: input data of shape (batch_size, input_size)
        
        Returns:
        - Tuple containing:
          - y_pred: output predictions
          - cache: cached values for backpropagation
        """
        # First layer: input to hidden
        # z1 = X·W1 + b1 (matrix multiplication)
        z1 = np.dot(X, self.W1) + self.b1
        
        # Apply activation function
        a1 = self.sigmoid(z1)
        
        # Second layer: hidden to output
        # z2 = a1·W2 + b2 (matrix multiplication)
        z2 = np.dot(a1, self.W2) + self.b2
        
        # Apply sigmoid activation for output layer
        y_pred = self.sigmoid(z2)
        
        # Cache values for backpropagation
        cache = {
            'X': X,
            'z1': z1,
            'a1': a1,
            'z2': z2,
            'y_pred': y_pred
        }
        
        return y_pred, cache
    
    def compute_loss(self, y_pred, y_true):
        """
        Compute binary cross-entropy loss
        
        Parameters:
        - y_pred: predicted values
        - y_true: true values
        
        Returns:
        - loss: scalar loss value
        """
        # Binary cross-entropy loss
        # L = -1/m * Σ[y·log(ŷ) + (1-y)·log(1-ŷ)]
        m = y_true.shape[0]
        
        # Add small epsilon to avoid log(0)
        epsilon = 1e-15
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        
        loss = -1/m * np.sum(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
        return loss
    
    def backward(self, y_pred, y_true, cache):
        """
        Backward propagation to compute gradients
        
        Parameters:
        - y_pred: predicted values
        - y_true: true values
        - cache: cached values from forward propagation
        
        Returns:
        - gradients: dictionary containing gradients for W1, b1, W2, b2
        """
        m = y_true.shape[0]
        
        # Get cached values
        X = cache['X']
        z1 = cache['z1']
        a1 = cache['a1']
        
        # Output layer gradient
        # dL/dz2 = (y_pred - y_true) * sigmoid_derivative(z2)
        # For binary cross-entropy with sigmoid, this simplifies to (y_pred - y_true)
        dz2 = y_pred - y_true
        
        # Gradient for W2: dL/dW2 = a1^T · dz2
        dW2 = 1/m * np.dot(a1.T, dz2)
        
        # Gradient for b2: dL/db2 = sum(dz2)
        db2 = 1/m * np.sum(dz2, axis=0, keepdims=True)
        
        # Hidden layer gradient using chain rule
        # dL/da1 = dz2 · W2^T
        da1 = np.dot(dz2, self.W2.T)
        
        # dL/dz1 = dL/da1 * sigmoid_derivative(z1)
        dz1 = da1 * self.sigmoid_derivative(z1)
        
        # Gradient for W1: dL/dW1 = X^T · dz1
        dW1 = 1/m * np.dot(X.T, dz1)
        
        # Gradient for b1: dL/db1 = sum(dz1)
        db1 = 1/m * np.sum(dz1, axis=0, keepdims=True)
        
        # Return gradients
        gradients = {
            'dW1': dW1,
            'db1': db1,
            'dW2': dW2,
            'db2': db2
        }
        
        return gradients
    
    def update_parameters(self, gradients):
        """
        Update parameters using gradient descent
        
        Parameters:
        - gradients: dictionary containing gradients for W1, b1, W2, b2
        """
        # Update weights and biases
        # W = W - learning_rate * dW
        self.W1 -= self.learning_rate * gradients['dW1']
        self.b1 -= self.learning_rate * gradients['db1']
        self.W2 -= self.learning_rate * gradients['dW2']
        self.b2 -= self.learning_rate * gradients['db2']
    
    def train(self, X, y, epochs=1000, print_every=100):
        """
        Train the neural network
        
        Parameters:
        - X: input data
        - y: target values
        - epochs: number of training iterations
        - print_every: print loss every n epochs
        
        Returns:
        - loss_history: list of loss values during training
        """
        self.loss_history = []
        
        for i in range(epochs):
            # Forward propagation
            y_pred, cache = self.forward(X)
            
            # Compute loss
            loss = self.compute_loss(y_pred, y)
            self.loss_history.append(loss)
            
            # Backward propagation
            gradients = self.backward(y_pred, y, cache)
            
            # Update parameters
            self.update_parameters(gradients)
            
            # Print loss every n epochs
            if i % print_every == 0:
                print(f"Epoch {i}, Loss: {loss:.4f}")
        
        return self.loss_history
    
    def predict(self, X):
        """
        Make predictions
        
        Parameters:
        - X: input data
        
        Returns:
        - predictions: binary predictions (0 or 1)
        """
        # Forward propagation
        y_pred, _ = self.forward(X)
        
        # Convert probabilities to binary predictions
        predictions = (y_pred > 0.5).astype(int)
        
        return predictions

This implementation includes all the key components of a neural network:

Decision Boundary

Decision boundary learned by a neural network for the XOR problem

The image above shows the decision boundary learned by a neural network for the XOR problem, which is not linearly separable. This demonstrates the power of neural networks to learn complex patterns.

Forward Propagation Example

Here's a simplified example of forward propagation in a neural network:

# Forward propagation in a neural network
def forward(X, W1, b1, W2, b2):
    # First layer: input to hidden
    z1 = np.dot(X, W1) + b1  # Matrix multiplication
    a1 = sigmoid(z1)         # Apply activation function
    
    # Second layer: hidden to output
    z2 = np.dot(a1, W2) + b2 # Matrix multiplication
    y_pred = sigmoid(z2)     # Apply activation function
    
    return y_pred, (z1, a1, z2)

This code shows the forward propagation process in a neural network. We compute the weighted sum of inputs (z), then apply an activation function to get the activation (a). This process is repeated for each layer. The final output is our prediction.

Backpropagation Example

Here's a simplified example of backpropagation in a neural network:

# Backward propagation to compute gradients
def backward(X, y, y_pred, cache, W1, W2):
    z1, a1, z2 = cache
    m = X.shape[0]
    
    # Output layer gradient
    dz2 = y_pred - y                  # Derivative of loss w.r.t. z2
    dW2 = (1/m) * np.dot(a1.T, dz2)   # Gradient for W2
    db2 = (1/m) * np.sum(dz2, axis=0) # Gradient for b2
    
    # Hidden layer gradient (chain rule)
    da1 = np.dot(dz2, W2.T)           # dL/da1 = dL/dz2 · dz2/da1
    dz1 = da1 * sigmoid_derivative(z1)# dL/dz1 = dL/da1 · da1/dz1
    dW1 = (1/m) * np.dot(X.T, dz1)    # Gradient for W1
    db1 = (1/m) * np.sum(dz1, axis=0) # Gradient for b1
    
    return dW1, db1, dW2, db2

This code implements the backpropagation algorithm. We start by computing the gradient at the output layer, then use the chain rule to propagate this gradient backward through the network. Notice how we compute the gradient for each weight and bias.

Gradient Descent Update Example

Here's a simplified example of gradient descent update in a neural network:

# Update parameters using gradient descent
def update_parameters(W1, b1, W2, b2, dW1, db1, dW2, db2, learning_rate):
    W1 = W1 - learning_rate * dW1  # Update weights for layer 1
    b1 = b1 - learning_rate * db1  # Update biases for layer 1
    W2 = W2 - learning_rate * dW2  # Update weights for layer 2
    b2 = b2 - learning_rate * db2  # Update biases for layer 2
    
    return W1, b1, W2, b2

This code shows how we update the parameters using gradient descent. We subtract the gradient multiplied by the learning rate from each parameter. This moves the parameters in the direction of steepest descent, reducing the loss function.

Visualizing Gradient Descent

The following code generates visualizations of gradient descent optimization on a loss surface:

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from mpl_toolkits.mplot3d import Axes3D

# Define a loss function (a simple quadratic function)
def loss_function(w1, w2):
    return w1**2 + w2**2 + 2*w1*w2 + 2

# Define the gradient of the loss function
def gradient(w1, w2):
    dw1 = 2*w1 + 2*w2
    dw2 = 2*w2 + 2*w1
    return np.array([dw1, dw2])

# Implement gradient descent
def gradient_descent(start_w1, start_w2, learning_rate, num_iterations):
    w1, w2 = start_w1, start_w2
    path_w1, path_w2, path_z = [w1], [w2], [loss_function(w1, w2)]
    
    for i in range(num_iterations):
        grad = gradient(w1, w2)
        w1 = w1 - learning_rate * grad[0]
        w2 = w2 - learning_rate * grad[1]
        path_w1.append(w1)
        path_w2.append(w2)
        path_z.append(loss_function(w1, w2))
    
    return np.array(path_w1), np.array(path_w2), np.array(path_z)

# Create a 3D visualization of the loss function with gradient descent paths
fig = plt.figure(figsize=(14, 10))
ax = fig.add_subplot(111, projection='3d')

# Create a meshgrid for the parameter space
w1 = np.linspace(-4, 4, 100)
w2 = np.linspace(-4, 4, 100)
W1, W2 = np.meshgrid(w1, w2)
Z = loss_function(W1, W2)

# Plot the loss surface
surf = ax.plot_surface(W1, W2, Z, cmap='viridis', alpha=0.8, linewidth=0)

# Run gradient descent with different learning rates
start_w1, start_w2 = 3.5, 3.5
learning_rates = [0.01, 0.05, 0.1, 0.2]
num_iterations = 50

# Plot gradient descent paths with different learning rates
for lr in learning_rates:
    path_w1, path_w2, path_z = gradient_descent(start_w1, start_w2, lr, num_iterations)
    ax.plot(path_w1, path_w2, path_z, 'o-', linewidth=2, markersize=4, label=f'Learning Rate = {lr}')

# Add labels and title
ax.set_xlabel('Weight 1')
ax.set_ylabel('Weight 2')
ax.set_zlabel('Loss')
ax.set_title('Gradient Descent Optimization on Loss Surface', fontsize=16)

# Add a legend
ax.legend(loc='upper right')
Gradient Descent 3D

3D visualization of gradient descent optimization on a loss surface

Gradient Descent Contour

Contour plot showing gradient descent paths with different learning rates

Continue Exploring