Deep learning / Backpropagation and optimization
Optimizers and Gradient Descent Challenges in Deep Learning
Contents
- Introduction to Optimizers in Deep Learning
- The Gradient Descent Algorithm
- Learning Rate Selection
- Stochastic Gradient Descent (SGD)
- Momentum Optimizer
- Adam Optimizer
- Handling Local Minima
- Vanishing Gradients
- Exploding Gradients and Gradient Clipping
- Learning Rate Schedules
- Adaptive Learning Rate Methods
- Real-life Example: Image Classification
- Real-life Example: Natural Language Processing
- Additional Resources
Introduction to Optimizers in Deep Learning
Optimizers are algorithms used to adjust the parameters of neural networks during training. They aim to minimize the loss function and improve the model's performance. Let's start with a simple example of how an optimizer works:
import numpy as np
# Simple gradient descent optimizer
def gradient_descent(params, grads, learning_rate):
return params - learning_rate * grads
# Initialize parameters
params = np.array([1.0, 2.0, 3.0])
grads = np.array([0.1, 0.2, 0.3])
learning_rate = 0.01
# Update parameters
new_params = gradient_descent(params, grads, learning_rate)
print("Updated parameters:", new_params)
The Gradient Descent Algorithm
Gradient descent is the foundation of many optimization algorithms in deep learning. It iteratively adjusts parameters in the direction of steepest descent of the loss function.
import numpy as np
import matplotlib.pyplot as plt
def loss_function(x):
return x**2 + 2*x + 1
def gradient(x):
return 2*x + 2
x = np.linspace(-5, 5, 100)
y = loss_function(x)
plt.plot(x, y)
plt.title("Loss Function")
plt.xlabel("x")
plt.ylabel("Loss")
plt.show()
# Gradient descent
x_current = 3.0
learning_rate = 0.1
iterations = 10
for i in range(iterations):
grad = gradient(x_current)
x_current = x_current - learning_rate * grad
print(f"Iteration {i+1}: x = {x_current:.4f}, Loss = {loss_function(x_current):.4f}")
plt.plot(x, y)
plt.scatter([x_current], [loss_function(x_current)], color='red', s=100)
plt.title("Final Position after Gradient Descent")
plt.show()
Learning Rate Selection
Choosing the right learning rate is crucial for effective training. Let's explore the impact of different learning rates:
import numpy as np
import matplotlib.pyplot as plt
def loss_function(x):
return x**2 + 2*x + 1
def gradient(x):
return 2*x + 2
x = np.linspace(-5, 5, 100)
y = loss_function(x)
learning_rates = [0.01, 0.1, 0.5]
colors = ['r', 'g', 'b']
iterations = 20
plt.figure(figsize=(12, 6))
plt.plot(x, y, 'k-', label='Loss Function')
for lr, color in zip(learning_rates, colors):
x_current = 4.0
x_path = [x_current]
for _ in range(iterations):
x_current = x_current - lr * gradient(x_current)
x_path.append(x_current)
plt.plot(x_path, [loss_function(xi) for xi in x_path], f'{color}o-', label=f'LR = {lr}')
plt.title("Impact of Learning Rate on Convergence")
plt.xlabel("x")
plt.ylabel("Loss")
plt.legend()
plt.show()
Stochastic Gradient Descent (SGD)
SGD is a variant of gradient descent that uses a subset of data (mini-batch) in each iteration, making it more efficient for large datasets.
import numpy as np
class SGD:
def __init__(self, learning_rate=0.01):
self.learning_rate = learning_rate
def update(self, params, grads):
return params - self.learning_rate * grads
# Example usage
np.random.seed(42)
X = np.random.randn(1000, 5)
y = 2 * X[:, 0] + 3 * X[:, 1] - X[:, 2] + 0.5 * X[:, 3] + np.random.randn(1000) * 0.1
weights = np.zeros(5)
sgd = SGD(learning_rate=0.01)
batch_size = 32
epochs = 100
for epoch in range(epochs):
for i in range(0, len(X), batch_size):
X_batch = X[i:i+batch_size]
y_batch = y[i:i+batch_size]
predictions = np.dot(X_batch, weights)
error = predictions - y_batch
gradients = np.dot(X_batch.T, error) / batch_size
weights = sgd.update(weights, gradients)
if epoch % 10 == 0:
mse = np.mean((np.dot(X, weights) - y) ** 2)
print(f"Epoch {epoch}, MSE: {mse:.4f}")
print("Final weights:", weights)
Momentum Optimizer
Momentum is an extension of SGD that helps accelerate convergence and reduce oscillations in the optimization process.
import numpy as np
class MomentumOptimizer:
def __init__(self, learning_rate=0.01, momentum=0.9):
self.learning_rate = learning_rate
self.momentum = momentum
self.velocity = None
def update(self, params, grads):
if self.velocity is None:
self.velocity = np.zeros_like(params)
self.velocity = self.momentum * self.velocity - self.learning_rate * grads
return params + self.velocity
# Example usage
np.random.seed(42)
X = np.random.randn(1000, 5)
y = 2 * X[:, 0] + 3 * X[:, 1] - X[:, 2] + 0.5 * X[:, 3] + np.random.randn(1000) * 0.1
weights = np.zeros(5)
optimizer = MomentumOptimizer(learning_rate=0.01, momentum=0.9)
batch_size = 32
epochs = 100
for epoch in range(epochs):
for i in range(0, len(X), batch_size):
X_batch = X[i:i+batch_size]
y_batch = y[i:i+batch_size]
predictions = np.dot(X_batch, weights)
error = predictions - y_batch
gradients = np.dot(X_batch.T, error) / batch_size
weights = optimizer.update(weights, gradients)
if epoch % 10 == 0:
mse = np.mean((np.dot(X, weights) - y) ** 2)
print(f"Epoch {epoch}, MSE: {mse:.4f}")
print("Final weights:", weights)
Adam Optimizer
Adam (Adaptive Moment Estimation) is a popular optimizer that combines ideas from momentum and RMSprop, adapting the learning rate for each parameter.
import numpy as np
class AdamOptimizer:
def __init__(self, learning_rate=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
self.learning_rate = learning_rate
self.beta1 = beta1
self.beta2 = beta2
self.epsilon = epsilon
self.m = None
self.v = None
self.t = 0
def update(self, params, grads):
if self.m is None:
self.m = np.zeros_like(params)
self.v = np.zeros_like(params)
self.t += 1
self.m = self.beta1 * self.m + (1 - self.beta1) * grads
self.v = self.beta2 * self.v + (1 - self.beta2) * (grads ** 2)
m_hat = self.m / (1 - self.beta1 ** self.t)
v_hat = self.v / (1 - self.beta2 ** self.t)
return params - self.learning_rate * m_hat / (np.sqrt(v_hat) + self.epsilon)
# Example usage
np.random.seed(42)
X = np.random.randn(1000, 5)
y = 2 * X[:, 0] + 3 * X[:, 1] - X[:, 2] + 0.5 * X[:, 3] + np.random.randn(1000) * 0.1
weights = np.zeros(5)
optimizer = AdamOptimizer()
batch_size = 32
epochs = 100
for epoch in range(epochs):
for i in range(0, len(X), batch_size):
X_batch = X[i:i+batch_size]
y_batch = y[i:i+batch_size]
predictions = np.dot(X_batch, weights)
error = predictions - y_batch
gradients = np.dot(X_batch.T, error) / batch_size
weights = optimizer.update(weights, gradients)
if epoch % 10 == 0:
mse = np.mean((np.dot(X, weights) - y) ** 2)
print(f"Epoch {epoch}, MSE: {mse:.4f}")
print("Final weights:", weights)
Handling Local Minima
Local minima can trap gradient descent algorithms. Let's visualize this problem and a potential solution using momentum:
import numpy as np
import matplotlib.pyplot as plt
def loss_function(x):
return np.sin(5 * x) * np.exp(-x**2)
def gradient(x):
return 5 * np.cos(5 * x) * np.exp(-x**2) - 2 * x * np.sin(5 * x) * np.exp(-x**2)
x = np.linspace(-2, 2, 1000)
y = loss_function(x)
plt.figure(figsize=(12, 6))
plt.plot(x, y, label='Loss Function')
plt.title("Loss Function with Multiple Local Minima")
plt.xlabel("x")
plt.ylabel("Loss")
# Standard Gradient Descent
x_gd = 1.5
learning_rate = 0.1
iterations = 50
x_path_gd = [x_gd]
for _ in range(iterations):
x_gd = x_gd - learning_rate * gradient(x_gd)
x_path_gd.append(x_gd)
plt.plot(x_path_gd, [loss_function(xi) for xi in x_path_gd], 'ro-', label='Gradient Descent')
# Momentum
x_momentum = 1.5
velocity = 0
momentum = 0.9
x_path_momentum = [x_momentum]
for _ in range(iterations):
velocity = momentum * velocity - learning_rate * gradient(x_momentum)
x_momentum = x_momentum + velocity
x_path_momentum.append(x_momentum)
plt.plot(x_path_momentum, [loss_function(xi) for xi in x_path_momentum], 'bo-', label='Momentum')
plt.legend()
plt.show()
Vanishing Gradients
Vanishing gradients can occur in deep networks, especially with certain activation functions. Let's visualize this problem:
import numpy as np
import matplotlib.pyplot as plt
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
return sigmoid(x) * (1 - sigmoid(x))
def tanh(x):
return np.tanh(x)
def tanh_derivative(x):
return 1 - np.tanh(x)**2
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return np.where(x > 0, 1, 0)
x = np.linspace(-5, 5, 1000)
plt.figure(figsize=(15, 5))
plt.subplot(1, 3, 1)
plt.plot(x, sigmoid(x), label='Sigmoid')
plt.plot(x, sigmoid_derivative(x), label='Sigmoid Derivative')
plt.title('Sigmoid and its Derivative')
plt.legend()
plt.subplot(1, 3, 2)
plt.plot(x, tanh(x), label='Tanh')
plt.plot(x, tanh_derivative(x), label='Tanh Derivative')
plt.title('Tanh and its Derivative')
plt.legend()
plt.subplot(1, 3, 3)
plt.plot(x, relu(x), label='ReLU')
plt.plot(x, relu_derivative(x), label='ReLU Derivative')
plt.title('ReLU and its Derivative')
plt.legend()
plt.tight_layout()
plt.show()
Exploding Gradients and Gradient Clipping
Exploding gradients occur when the gradient becomes too large, causing unstable updates. Gradient clipping is a technique to address this issue:
import numpy as np
import matplotlib.pyplot as plt
def clip_gradients(gradients, max_norm):
total_norm = np.linalg.norm(gradients)
if total_norm > max_norm:
clip_coef = max_norm / (total_norm + 1e-6)
return gradients * clip_coef
return gradients
# Simulating large gradients
np.random.seed(42)
gradients = np.random.randn(1000) * 10
max_norm = 5.0
clipped_gradients = clip_gradients(gradients, max_norm)
print(f"Original gradients norm: {np.linalg.norm(gradients):.4f}")
print(f"Clipped gradients norm: {np.linalg.norm(clipped_gradients):.4f}")
# Visualize the effect of clipping
plt.figure(figsize=(12, 6))
plt.scatter(gradients, clipped_gradients, alpha=0.5)
plt.plot([-max_norm, max_norm], [-max_norm, max_norm], 'r--')
plt.xlim(-15, 15)
plt.ylim(-15, 15)
plt.title('Original vs Clipped Gradients')
plt.xlabel('Original Gradients')
plt.ylabel('Clipped Gradients')
plt.show()
Learning Rate Schedules
Adjusting the learning rate during training can improve convergence and help escape local minima. Let's implement and visualize some common learning rate schedules:
import numpy as np
import matplotlib.pyplot as plt
def step_decay(initial_lr, drop_factor, epochs_drop):
def schedule(epoch):
return initial_lr * (drop_factor ** (epoch // epochs_drop))
return schedule
def exponential_decay(initial_lr, decay_rate):
def schedule(epoch):
return initial_lr * np.exp(-decay_rate * epoch)
return schedule
def cosine_annealing(initial_lr, T_max):
def schedule(epoch):
return initial_lr * (1 + np.cos(np.pi * epoch / T_max)) / 2
return schedule
epochs = 100
initial_lr = 0.1
step_schedule = step_decay(initial_lr, 0.5, 20)
exp_schedule = exponential_decay(initial_lr, 0.05)
cosine_schedule = cosine_annealing(initial_lr, epochs)
plt.figure(figsize=(12, 6))
plt.plot(range(epochs), [step_schedule(e) for e in range(epochs)], label='Step Decay')
plt.plot(range(epochs), [exp_schedule(e) for e in range(epochs)], label='Exponential Decay')
plt.plot(range(epochs), [cosine_schedule(e) for e in range(epochs)], label='Cosine Annealing')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.title('Learning Rate Schedules')
plt.legend()
plt.show()
Adaptive Learning Rate Methods
Adaptive learning rate methods automatically adjust the learning rate for each parameter. Let's implement a simple version of the AdaGrad optimizer:
import numpy as np
class AdaGrad:
def __init__(self, learning_rate=0.01, epsilon=1e-8):
self.learning_rate = learning_rate
self.epsilon = epsilon
self.cache = None
def update(self, params, grads):
if self.cache is None:
self.cache = np.zeros_like(params)
self.cache += grads**2
return params - self.learning_rate * grads / (np.sqrt(self.cache) + self.epsilon)
# Example usage
np.random.seed(42)
X = np.random.randn(1000, 5)
y = 2 * X[:, 0] + 3 * X[:, 1] - X[:, 2] + 0.5 * X[:, 3] + np.random.randn(1000) * 0.1
weights = np.zeros(5)
optimizer = AdaGrad(learning_rate=0.1)
batch_size = 32
epochs = 100
for epoch in range(epochs):
for i in range(0, len(X), batch_size):
X_batch = X[i:i+batch_size]
y_batch = y[i:i+batch_size]
predictions = np.dot(X_batch, weights)
error = predictions - y_batch
gradients = np.dot(X_batch.T, error) / batch_size
weights = optimizer.update(weights, gradients)
if epoch % 10 == 0:
mse = np.mean((np.dot(X, weights) - y) ** 2)
print(f"Epoch {epoch}, MSE: {mse:.4f}")
print("Final weights:", weights)
Real-life Example: Image Classification
Let's consider a simple image classification task using a convolutional neural network (CNN) and the Adam optimizer:
import numpy as np
from tensorflow import keras
from tensorflow.keras import layers
# Load and preprocess the CIFAR-10 dataset
(x_train, y_train), (x_test, y_test) = keras.datasets.cifar10.load_data()
x_train = x_train.astype("float32") / 255
x_test = x_test.astype("float32") / 255
# Define the CNN model
model = keras.Sequential([
layers.Conv2D(32, (3, 3), activation="relu", input_shape=(32, 32, 3)),
layers.MaxPooling2D((2, 2)),
layers.Conv2D(64, (3, 3), activation="relu"),
layers.MaxPooling2D((2, 2)),
layers.Conv2D(64, (3, 3), activation="relu"),
layers.Flatten(),
layers.Dense(64, activation="relu"),
layers.Dense(10, activation="softmax")
])
# Compile the model with Adam optimizer
model.compile(optimizer=keras.optimizers.Adam(),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
# Train the model
history = model.fit(x_train, y_train, epochs=10, validation_split=0.2)
# Evaluate the model
test_loss, test_acc = model.evaluate(x_test, y_test, verbose=2)
print(f"Test accuracy: {test_acc:.4f}")
Real-life Example: Natural Language Processing
Let's consider a simple sentiment analysis task using a recurrent neural network (RNN) and the RMSprop optimizer:
import numpy as np
from tensorflow import keras
from tensorflow.keras import layers
# Load and preprocess the IMDB dataset
max_features = 10000
maxlen = 200
(x_train, y_train), (x_test, y_test) = keras.datasets.imdb.load_data(num_words=max_features)
x_train = keras.preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = keras.preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)
# Define the RNN model
model = keras.Sequential([
layers.Embedding(max_features, 128),
layers.LSTM(64, dropout=0.2, recurrent_dropout=0.2),
layers.Dense(1, activation="sigmoid")
])
# Compile the model with RMSprop optimizer
model.compile(optimizer=keras.optimizers.RMSprop(),
loss="binary_crossentropy",
metrics=["accuracy"])
# Train the model
history = model.fit(x_train, y_train, epochs=10, batch_size=32, validation_split=0.2)
# Evaluate the model
test_loss, test_acc = model.evaluate(x_test, y_test, verbose=2)
print(f"Test accuracy: {test_acc:.4f}")
Additional Resources
For those interested in diving deeper into optimization techniques in deep learning, here are some valuable resources:
- "An overview of gradient descent optimization algorithms" by Sebastian Ruder ArXiv URL: https://arxiv.org/abs/1609.04747
- "Adam: A Method for Stochastic Optimization" by Diederik P. Kingma and Jimmy Ba ArXiv URL: https://arxiv.org/abs/1412.6980
- "Adaptive Subgradient Methods for Online Learning and Stochastic Optimization" by John Duchi, Elad Hazan, and Yoram Singer URL: https://jmlr.org/papers/v12/duchi11a.html