xbe.at

Deep learning / RNN LSTM and sequence

Backpropagation Through Time in Recurrent Neural Networks

11 min read, 15 sections

Contents
  1. Introduction to Backpropagation Through Time (BPTT) in RNNs
  2. The Need for BPTT
  3. Forward Pass in BPTT
  4. Calculating the Loss
  5. Backward Pass: Computing Gradients
  6. Truncated BPTT
  7. Gradient Clipping
  8. Vanishing and Exploding Gradients
  9. Long Short-Term Memory (LSTM) Networks
  10. Real-life Example: Sentiment Analysis
  11. Real-life Example: Time Series Forecasting
  12. Training Process with BPTT
  13. Challenges and Improvements
  14. Challenges and Improvements
  15. Additional Resources

Introduction to Backpropagation Through Time (BPTT) in RNNs

Backpropagation Through Time (BPTT) is a crucial algorithm for training Recurrent Neural Networks (RNNs). It extends the standard backpropagation algorithm to handle sequences of inputs and outputs, allowing RNNs to learn from temporal data. BPTT unfolds the RNN across time steps, treating each time step as a layer in a deep neural network.

import numpy as np

def rnn_cell(x, h, W_hh, W_xh, W_hy):
    # RNN cell forward pass
    h_new = np.tanh(np.dot(W_hh, h) + np.dot(W_xh, x))
    y = np.dot(W_hy, h_new)
    return h_new, y

# Example usage
x = np.array([1, 0, 1])  # Input at time step t
h = np.array([0, 0, 0])  # Hidden state
W_hh = np.random.randn(3, 3)  # Hidden-to-hidden weights
W_xh = np.random.randn(3, 3)  # Input-to-hidden weights
W_hy = np.random.randn(1, 3)  # Hidden-to-output weights

h_new, y = rnn_cell(x, h, W_hh, W_xh, W_hy)
print("New hidden state:", h_new)
print("Output:", y)

The Need for BPTT

RNNs process sequences by maintaining an internal state, which allows them to capture temporal dependencies. However, training RNNs with standard backpropagation is challenging due to the recurrent connections. BPTT addresses this by unfolding the network through time, enabling gradient flow across multiple time steps.

import numpy as np

def unfold_rnn(x_sequence, h_initial, W_hh, W_xh, W_hy):
    # Unfold RNN for a sequence of inputs
    h = h_initial
    outputs = []
    hidden_states = [h]
    
    for x in x_sequence:
        h, y = rnn_cell(x, h, W_hh, W_xh, W_hy)
        outputs.append(y)
        hidden_states.append(h)
    
    return outputs, hidden_states

# Example usage
x_sequence = [np.array([1, 0, 1]), np.array([0, 1, 1]), np.array([1, 1, 0])]
h_initial = np.zeros(3)

outputs, hidden_states = unfold_rnn(x_sequence, h_initial, W_hh, W_xh, W_hy)
print("Outputs:", outputs)
print("Hidden states:", hidden_states)

Forward Pass in BPTT

The forward pass in BPTT involves propagating the input sequence through the unfolded network. At each time step, the RNN cell computes the new hidden state and output based on the current input and previous hidden state. This process continues for the entire sequence length.

import numpy as np

def forward_pass(x_sequence, h_initial, W_hh, W_xh, W_hy):
    h = h_initial
    outputs = []
    hidden_states = [h]
    
    for x in x_sequence:
        h_new = np.tanh(np.dot(W_hh, h) + np.dot(W_xh, x))
        y = np.dot(W_hy, h_new)
        outputs.append(y)
        hidden_states.append(h_new)
        h = h_new
    
    return outputs, hidden_states

# Example usage
x_sequence = [np.array([1, 0, 1]), np.array([0, 1, 1]), np.array([1, 1, 0])]
h_initial = np.zeros(3)

outputs, hidden_states = forward_pass(x_sequence, h_initial, W_hh, W_xh, W_hy)
print("Outputs:", outputs)
print("Final hidden state:", hidden_states[-1])

Calculating the Loss

After the forward pass, we calculate the loss by comparing the predicted outputs with the target values. The choice of loss function depends on the task, with Mean Squared Error (MSE) being common for regression and Cross-Entropy for classification tasks.

import numpy as np

def mse_loss(predictions, targets):
    return np.mean((predictions - targets) ** 2)

def cross_entropy_loss(predictions, targets):
    epsilon = 1e-15  # Small value to avoid log(0)
    return -np.sum(targets * np.log(predictions + epsilon))

# Example usage
predictions = np.array([0.2, 0.7, 0.1])
targets = np.array([0, 1, 0])

mse = mse_loss(predictions, targets)
ce = cross_entropy_loss(predictions, targets)

print("MSE Loss:", mse)
print("Cross-Entropy Loss:", ce)

Backward Pass: Computing Gradients

The backward pass in BPTT involves computing gradients of the loss with respect to the network parameters. We start from the last time step and propagate the error backwards through time, accumulating gradients for each parameter.

import numpy as np

def backward_pass(x_sequence, h_sequence, y_sequence, targets, W_hh, W_xh, W_hy):
    dW_hh, dW_xh, dW_hy = np.zeros_like(W_hh), np.zeros_like(W_xh), np.zeros_like(W_hy)
    dh_next = np.zeros_like(h_sequence[0])
    
    for t in reversed(range(len(x_sequence))):
        dy = y_sequence[t] - targets[t]
        dW_hy += np.outer(dy, h_sequence[t+1])
        dh = np.dot(W_hy.T, dy) + dh_next
        
        dtanh = (1 - h_sequence[t+1]**2) * dh
        dW_xh += np.outer(dtanh, x_sequence[t])
        dW_hh += np.outer(dtanh, h_sequence[t])
        dh_next = np.dot(W_hh.T, dtanh)
    
    return dW_hh, dW_xh, dW_hy

# Example usage (simplified)
x_sequence = [np.array([1, 0, 1]), np.array([0, 1, 1])]
h_sequence = [np.zeros(3), np.array([0.1, 0.2, 0.3]), np.array([0.2, 0.3, 0.4])]
y_sequence = [np.array([0.5]), np.array([0.7])]
targets = [np.array([1]), np.array([0])]

dW_hh, dW_xh, dW_hy = backward_pass(x_sequence, h_sequence, y_sequence, targets, W_hh, W_xh, W_hy)
print("Gradients for W_hh:", dW_hh)
print("Gradients for W_xh:", dW_xh)
print("Gradients for W_hy:", dW_hy)

Truncated BPTT

For very long sequences, full BPTT can be computationally expensive and may lead to vanishing/exploding gradients. Truncated BPTT addresses this by limiting the number of time steps for which gradients are propagated back. This approximation allows for efficient training on long sequences.

import numpy as np

def truncated_bptt(x_sequence, targets, h_initial, W_hh, W_xh, W_hy, truncate_steps):
    h = h_initial
    total_loss = 0
    
    for t in range(0, len(x_sequence), truncate_steps):
        x_batch = x_sequence[t:t+truncate_steps]
        targets_batch = targets[t:t+truncate_steps]
        
        # Forward pass
        outputs, hidden_states = forward_pass(x_batch, h, W_hh, W_xh, W_hy)
        
        # Compute loss
        loss = sum(mse_loss(output, target) for output, target in zip(outputs, targets_batch))
        total_loss += loss
        
        # Backward pass
        dW_hh, dW_xh, dW_hy = backward_pass(x_batch, hidden_states, outputs, targets_batch, W_hh, W_xh, W_hy)
        
        # Update weights (simplified, without learning rate)
        W_hh -= dW_hh
        W_xh -= dW_xh
        W_hy -= dW_hy
        
        # Update initial hidden state for next batch
        h = hidden_states[-1]
    
    return total_loss, W_hh, W_xh, W_hy

# Example usage
x_sequence = [np.random.randn(3) for _ in range(100)]
targets = [np.random.randn(1) for _ in range(100)]
h_initial = np.zeros(3)
truncate_steps = 10

total_loss, W_hh_updated, W_xh_updated, W_hy_updated = truncated_bptt(x_sequence, targets, h_initial, W_hh, W_xh, W_hy, truncate_steps)
print("Total loss:", total_loss)

Gradient Clipping

Gradient clipping is a technique used to prevent exploding gradients in RNNs. It involves limiting the magnitude of gradients during training, which helps stabilize the learning process and prevent large weight updates that can derail training.

import numpy as np

def clip_gradients(gradients, max_norm):
    total_norm = np.sqrt(sum(np.sum(grad ** 2) for grad in gradients))
    clip_coef = max_norm / (total_norm + 1e-6)
    if clip_coef < 1:
        return [grad * clip_coef for grad in gradients]
    return gradients

# Example usage
dW_hh = np.random.randn(3, 3) * 10  # Large gradients
dW_xh = np.random.randn(3, 3) * 10
dW_hy = np.random.randn(1, 3) * 10

gradients = [dW_hh, dW_xh, dW_hy]
max_norm = 5.0

clipped_gradients = clip_gradients(gradients, max_norm)

print("Original gradient norms:", [np.linalg.norm(grad) for grad in gradients])
print("Clipped gradient norms:", [np.linalg.norm(grad) for grad in clipped_gradients])

Vanishing and Exploding Gradients

RNNs often suffer from vanishing or exploding gradients during training, especially for long sequences. Vanishing gradients occur when the gradient becomes extremely small, making it difficult for the network to learn long-term dependencies. Exploding gradients, on the other hand, lead to unstable training and large weight updates.

import numpy as np
import matplotlib.pyplot as plt

def simulate_gradient_flow(sequence_length, w):
    gradients = [1.0]
    for _ in range(sequence_length - 1):
        gradients.append(gradients[-1] * w)
    return gradients

# Simulate gradient flow for different weight values
sequence_length = 100
w_values = [0.5, 1.0, 1.5]

plt.figure(figsize=(10, 6))
for w in w_values:
    gradients = simulate_gradient_flow(sequence_length, w)
    plt.plot(range(sequence_length), gradients, label=f'w = {w}')

plt.xlabel('Time Steps')
plt.ylabel('Gradient Magnitude')
plt.title('Gradient Flow in RNNs')
plt.legend()
plt.yscale('log')
plt.grid(True)
plt.show()

Long Short-Term Memory (LSTM) Networks

LSTMs are a type of RNN architecture designed to mitigate the vanishing gradient problem. They introduce a memory cell and gating mechanisms that allow the network to selectively remember or forget information over long sequences. BPTT can be applied to LSTMs with some modifications to account for the additional gates.

import numpy as np

def lstm_cell(x, h, c, W_f, W_i, W_o, W_c, U_f, U_i, U_o, U_c, b_f, b_i, b_o, b_c):
    # Forget gate
    f = sigmoid(np.dot(W_f, x) + np.dot(U_f, h) + b_f)
    
    # Input gate
    i = sigmoid(np.dot(W_i, x) + np.dot(U_i, h) + b_i)
    
    # Output gate
    o = sigmoid(np.dot(W_o, x) + np.dot(U_o, h) + b_o)
    
    # Cell state
    c_tilde = np.tanh(np.dot(W_c, x) + np.dot(U_c, h) + b_c)
    c_new = f * c + i * c_tilde
    
    # Hidden state
    h_new = o * np.tanh(c_new)
    
    return h_new, c_new

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

# Example usage (simplified)
x = np.random.randn(10)  # Input
h = np.zeros(5)  # Hidden state
c = np.zeros(5)  # Cell state
W_f, W_i, W_o, W_c = [np.random.randn(5, 10) for _ in range(4)]
U_f, U_i, U_o, U_c = [np.random.randn(5, 5) for _ in range(4)]
b_f, b_i, b_o, b_c = [np.zeros(5) for _ in range(4)]

h_new, c_new = lstm_cell(x, h, c, W_f, W_i, W_o, W_c, U_f, U_i, U_o, U_c, b_f, b_i, b_o, b_c)
print("New hidden state:", h_new)
print("New cell state:", c_new)

Real-life Example: Sentiment Analysis

Sentiment analysis is a common application of RNNs trained with BPTT. In this example, we'll create a simple RNN to classify movie reviews as positive or negative based on the sequence of words.

import numpy as np

# Simplified word embedding
word_to_index = {"good": 0, "bad": 1, "great": 2, "terrible": 3, "amazing": 4}
embedding_dim = 3
embedding_matrix = np.random.randn(len(word_to_index), embedding_dim)

def preprocess_text(text):
    return [word_to_index[word] for word in text.lower().split() if word in word_to_index]

def sentiment_rnn(text, W_hh, W_xh, W_hy, h_initial):
    h = h_initial
    for word_index in preprocess_text(text):
        x = embedding_matrix[word_index]
        h = np.tanh(np.dot(W_hh, h) + np.dot(W_xh, x))
    
    y = 1 / (1 + np.exp(-np.dot(W_hy, h)))  # Sigmoid activation
    return y

# Example usage
W_hh = np.random.randn(5, 5)
W_xh = np.random.randn(5, embedding_dim)
W_hy = np.random.randn(1, 5)
h_initial = np.zeros(5)

review = "This movie was great and amazing"
sentiment = sentiment_rnn(review, W_hh, W_xh, W_hy, h_initial)
print(f"Sentiment score: {sentiment[0]:.4f}")

Real-life Example: Time Series Forecasting

RNNs are widely used for time series forecasting, such as predicting future values based on historical data. Here's a simple example of using an RNN for temperature prediction.

import numpy as np

def create_sequences(data, seq_length):
    sequences = []
    for i in range(len(data) - seq_length):
        sequences.append(data[i:i+seq_length+1])
    return np.array(sequences)

def rnn_forecast(X, y, W_hh, W_xh, W_hy, h_initial):
    h = h_initial
    for x in X:
        h = np.tanh(np.dot(W_hh, h) + np.dot(W_xh, x))
    
    y_pred = np.dot(W_hy, h)
    return y_pred

# Generate synthetic temperature data
np.random.seed(42)
temperatures = 20 + 10 * np.sin(np.arange(100) * 0.1) + np.random.randn(100) * 2

# Prepare sequences
seq_length = 7
sequences = create_sequences(temperatures, seq_length)
X, y = sequences[:, :-1], sequences[:, -1]

# Initialize weights
hidden_size = 10
W_hh = np.random.randn(hidden_size, hidden_size) * 0.01
W_xh = np.random.randn(hidden_size, 1) * 0.01
W_hy = np.random.randn(1, hidden_size) * 0.01
h_initial = np.zeros(hidden_size)

# Make predictions
predictions = []
for seq in X:
    pred = rnn_forecast(seq, y, W_hh, W_xh, W_hy, h_initial)
    predictions.append(pred[0])

print("First 5 actual temperatures:", y[:5])
print("First 5 predicted temperatures:", predictions[:5])

Training Process with BPTT

The training process for RNNs using BPTT involves iterating through the dataset, performing forward and backward passes, and updating the weights. Here's a simplified training loop:

import numpy as np

def train_rnn(X, y, hidden_size, learning_rate, epochs):
    input_size = X.shape[2]
    output_size = y.shape[1]
    
    # Initialize weights
    W_hh = np.random.randn(hidden_size, hidden_size) * 0.01
    W_xh = np.random.randn(hidden_size, input_size) * 0.01
    W_hy = np.random.randn(output_size, hidden_size) * 0.01
    
    for epoch in range(epochs):
        total_loss = 0
        for i in range(len(X)):
            h = np.zeros((hidden_size, 1))
            
            # Forward pass
            for t in range(len(X[i])):
                x = X[i][t].reshape(-1, 1)
                h = np.tanh(np.dot(W_hh, h) + np.dot(W_xh, x))
            
            y_pred = np.dot(W_hy, h)
            loss = np.mean((y_pred - y[i]) ** 2)
            total_loss += loss
            
            # Backward pass (simplified)
            dW_hy = np.dot(2 * (y_pred - y[i]), h.T)
            dh = np.dot(W_hy.T, 2 * (y_pred - y[i]))
            
            for t in reversed(range(len(X[i]))):
                x = X[i][t].reshape(-1, 1)
                dW_hh = np.dot(dh * (1 - h**2), h.T)
                dW_xh = np.dot(dh * (1 - h**2), x.T)
                dh = np.dot(W_hh.T, dh * (1 - h**2))
            
            # Update weights
            W_hh -= learning_rate * dW_hh
            W_xh -= learning_rate * dW_xh
            W_hy -= learning_rate * dW_hy
        
        print(f"Epoch {epoch + 1}, Loss: {total_loss / len(X)}")
    
    return W_hh, W_xh, W_hy

# Example usage
X = np.random.randn(100, 5, 3)  # 100 sequences, 5 time steps, 3 features
y = np.random.randn(100, 1)  # 100 target values
hidden_size = 10
learning_rate = 0.01
epochs = 10

W_hh, W_xh, W_hy = train_rnn(X, y, hidden_size, learning_rate, epochs)

Challenges and Improvements

While BPTT is powerful, it faces challenges such as vanishing/exploding gradients and difficulty in capturing long-term dependencies. Several improvements have been proposed:

  1. Gradient clipping: Limits gradient magnitudes to prevent exploding gradients.
  2. LSTM and GRU: Architectures designed to mitigate vanishing gradients.
  3. Attention mechanisms: Allow the model to focus on relevant parts of the input sequence.
  4. Layer normalization: Normalizes activations to improve training stability.

Challenges and Improvements

import numpy as np

def clip_gradients(gradients, max_norm):
    total_norm = np.sqrt(sum(np.sum(grad ** 2) for grad in gradients))
    clip_coef = max_norm / (total_norm + 1e-6)
    return [grad * min(1, clip_coef) for grad in gradients]

def layer_norm(x, gamma, beta, eps=1e-8):
    mean = np.mean(x, axis=1, keepdims=True)
    var = np.var(x, axis=1, keepdims=True)
    x_norm = (x - mean) / np.sqrt(var + eps)
    return gamma * x_norm + beta

# Example usage
gradients = [np.random.randn(5, 5) * 10 for _ in range(3)]
max_norm = 5.0
clipped_gradients = clip_gradients(gradients, max_norm)

x = np.random.randn(10, 5)
gamma = np.ones((10, 1))
beta = np.zeros((10, 1))
normalized_x = layer_norm(x, gamma, beta)

print("Original gradient norm:", [np.linalg.norm(grad) for grad in gradients])
print("Clipped gradient norm:", [np.linalg.norm(grad) for grad in clipped_gradients])
print("Layer normalized output shape:", normalized_x.shape)

Additional Resources

For further exploration of Backpropagation Through Time and RNNs, consider the following resources:

  1. "Learning representations by back-propagating errors" by Rumelhart et al. (1986) ArXiv: https://arxiv.org/abs/1811.05956 (retrospective)
  2. "Long Short-Term Memory" by Hochreiter & Schmidhuber (1997) ArXiv: https://arxiv.org/abs/1909.09586 (retrospective)
  3. "Neural Machine Translation by Jointly Learning to Align and Translate" by Bahdanau et al. (2014) ArXiv: https://arxiv.org/abs/1409.0473
  4. "Attention Is All You Need" by Vaswani et al. (2017) ArXiv: https://arxiv.org/abs/1706.03762

These papers provide foundational concepts and recent advancements in RNN architectures and training techniques.

More in RNN LSTM and sequence

  1. Challenges of Recurrent Neural Networks11 min

    Recurrent Neural Networks (RNNs) are a class of neural networks designed to process sequential data. They are particularly effective for tasks involving time...

  2. Comparing ANN and RNN Architectures and Forward Propagation in RNNs13 min

    Artificial Neural Networks (ANNs) and Recurrent Neural Networks (RNNs) are two fundamental architectures in deep learning. ANNs are designed for static data...

  3. Deep Recurrent Neural Networks Mastering Sequential Data12 min

    Deep Recurrent Neural Networks (RNNs) are advanced neural architectures designed to process sequential data by maintaining an internal state or 'memory'....

  4. Deep Recurrent Neural Networks with Keras and Python12 min

    Deep Recurrent Neural Networks (RNNs) are a class of neural networks designed to process sequential data. They have loops that allow information to persist...

  5. Explaining LSTM Networks in Deep Learning with Python12 min

    Long Short-Term Memory (LSTM) networks are a type of recurrent neural network (RNN) designed to address the vanishing gradient problem in traditional RNNs....

  6. Exploring 5 Key Assumptions in Linear Regression12 min

    Recurrent Neural Networks (RNNs) are powerful models for processing sequential data, but they face significant challenges. This slideshow explores these...