Guide To AI Logo
Unit 18

Recurrent Neural Networks and Sequential Learning

learning how recurrent memory processes ordered data, time series, and language

Recurrent Memory Unrolled Through Time
h_1x_1h_2x_2h_3x_3hidden statehidden stateprediction ŷThe same recurrent weights are reused at every step
Read diagram labels
  • Hidden states: h₁ → h₂ → h₃
  • Inputs: x₁, x₂, x₃
  • Prediction: ŷ
  • The same recurrent weights are reused at every step

Core Concepts Covered

  • Sequential tensors, recurrent weights, and hidden-state memory
  • Backpropagation Through Time and long-sequence gradient challenges
  • Time-series prediction, text classification, LSTMs, and GRUs
Local Setup Recommendation

To execute and experiment with the code cells below on your local machine, ensure you have set up your isolated virtual environments and scientific libraries by following the detailed protocols in Unit 03: Environment Setup or run them in Google Colab.

1. Sequences, Recurrence, and Hidden State

Recurrent Neural Networks (RNNs) are designed for ordered data such as text, audio, sensor readings, and time series. A feedforward network treats each input as independent, while an RNN reads a sequence one step at a time and carries a hidden state forward as a compact memory of what it has already seen.

At time step tt, a basic RNN combines the current input xtx_t with the previous hidden state ht1h_{t-1}: ht=tanh(Wxhxt+Whhht1+bh)h_t = \tanh(W_{xh}x_t + W_{hh}h_{t-1} + b_h) The same recurrent weights are reused at every time step. This parameter sharing lets one model process sequences of different lengths and learn patterns whose meaning depends on order.

With batch_first=True, PyTorch expects an input tensor shaped as: (batch size,sequence length,features)(\text{batch size}, \text{sequence length}, \text{features}) For example, (32,20,1)(32,20,1) represents 32 sequences, 20 time steps per sequence, and one feature at each step.

The code cells in this unit are static demonstrations and do not execute on this website. You can copy them into your own Google Colab notebook, or use the local environment configured in Unit 03, to run and modify them. Plotting examples require Matplotlib.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt

# Reproducible notebook results
torch.manual_seed(42)
np.random.seed(42)

print("PyTorch environment ready!")
Out [1]:
Example output (precomputed; this website does not execute code):
PyTorch environment ready!

2. Sine-Wave Data: Predicting the Next Point

To make recurrence concrete, begin with a predictable sine wave. The learning task is next-step prediction: use a window of past values as XX and predict the following value YY. For a one-step window, this is x0x1x_0 \rightarrow x_1, then x1x2x_1 \rightarrow x_2, and so on.

Setting seq_length = 1 is useful for inspecting tensor shapes and the mechanics of one recurrent update, but it gives the model only one observed value per example. It cannot demonstrate memory across a long history. After understanding the pipeline, try a longer window such as 2020 so the hidden state can summarize a meaningful portion of the wave.

The optional plot marks the input window and the next-value target. The same sliding-window transformation is used for demand forecasting, sensor prediction, and many other time-series problems.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
# Create a sine wave and convert it into past-to-future pairs.
x_axis = np.linspace(0, 100, 1000)
wave_data = np.sin(x_axis)

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

seq_length = 1
X_raw, Y_raw = create_sequences(wave_data, seq_length)

# Add the feature axis expected by nn.RNN.
X = torch.tensor(X_raw, dtype=torch.float32).unsqueeze(-1)
Y = torch.tensor(Y_raw, dtype=torch.float32).unsqueeze(-1)

print("X shape:", tuple(X.shape))
print("Y shape:", tuple(Y.shape))
print("First input:", np.round(X_raw[0], 3))
print("First target:", round(float(Y_raw[0]), 3))

# Optional Colab visualization of one sliding window.
start = 30
end = start + seq_length
plt.figure(figsize=(10, 4))
plt.plot(wave_data[:120], label="Sine wave")
plt.scatter(range(start, end), wave_data[start:end], label="Input window")
plt.scatter(end, wave_data[end], marker="*", s=160, label="Next-value target")
plt.xlabel("Time step")
plt.ylabel("Wave amplitude")
plt.legend()
plt.grid(True)
plt.show()
Out [1]:
Example output (precomputed; plot appears only when run in Colab):
X shape: (999, 1, 1)
Y shape: (999, 1)
First input: [0.]
First target: 0.1

3. Building the Wave RNN and Reading Its Memory

nn.RNN performs the repeated recurrence automatically. Its out tensor stores the hidden state from every time step, while hidden stores the final hidden state for every recurrent layer. A prediction head can transform the last layer's final state into the desired output.

For one example shaped (1,1,1)(1,1,1) and a hidden size of 3232, out has shape (1,1,32)(1,1,32): one batch, one time step, and 32 hidden features. hidden has shape (1,1,32)(1,1,32): one recurrent layer, one batch item, and 32 final hidden features. With a sequence length of 2020, out would retain 20 states while hidden would still retain only the final state per layer.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
class WaveRNN(nn.Module):
    def __init__(self, input_size=1, hidden_size=32, output_size=1):
        super().__init__()
        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        all_hidden_states, final_hidden = self.rnn(x)
        prediction = self.fc(final_hidden[-1])
        return prediction

model = WaveRNN()
one_sequence = X[0:1]  # (one example, one time step, one feature)

with torch.no_grad():
    all_hidden_states, final_hidden = model.rnn(one_sequence)

print(model)
print("Input shape:", tuple(one_sequence.shape))
print("All hidden states:", tuple(all_hidden_states.shape))
print("Final hidden state:", tuple(final_hidden.shape))
Out [1]:
Example output (precomputed):
WaveRNN(
  (rnn): RNN(1, 32, batch_first=True)
  (fc): Linear(in_features=32, out_features=1, bias=True)
)
Input shape: (1, 1, 1)
All hidden states: (1, 1, 32)
Final hidden state: (1, 1, 32)

4. Training with Backpropagation Through Time

RNN training follows the usual loop: predict, measure loss, backpropagate, and update the weights. Because the recurrent cell is conceptually unfolded across the sequence, the backward pass is called Backpropagation Through Time (BPTT). Error signals travel backward through the repeated time steps so shared weights learn how earlier inputs influenced the final prediction.

PyTorch Autograd performs BPTT automatically when loss.backward() is called. The sine-wave task uses Mean Squared Error because its target is continuous. The plots below are also static code: when copied into Colab, one shows whether loss decreases and the other compares the model's predictions with the true wave.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_history = []

for epoch in range(150):
    model.train()
    predictions = model(X)
    loss = criterion(predictions, Y)

    optimizer.zero_grad()
    loss.backward()  # Autograd performs BPTT.
    optimizer.step()

    loss_history.append(loss.item())
    if epoch % 25 == 0:
        print(f"Epoch {epoch:3d} | Loss: {loss.item():.5f}")

model.eval()
with torch.no_grad():
    wave_predictions = model(X).numpy()

# Plot training loss.
plt.figure(figsize=(8, 4))
plt.plot(loss_history)
plt.xlabel("Epoch")
plt.ylabel("MSE loss")
plt.grid(True)
plt.show()

# Plot the first 200 targets and predictions.
plt.figure(figsize=(10, 4))
plt.plot(Y.numpy()[:200], label="Actual wave", linewidth=3, alpha=0.5)
plt.plot(wave_predictions[:200], "r--", label="RNN prediction")
plt.xlabel("Time step")
plt.ylabel("Wave amplitude")
plt.legend()
plt.grid(True)
plt.show()
Out [1]:
Example output (illustrative; exact losses depend on the environment):
Epoch   0 | Loss: decreases from its initial value
Epoch  25 | Loss: lower than the initial value
...
The plots are generated only after this code is copied and run in Colab.

5. Long Sequences, Vanishing Gradients, LSTMs, and GRUs

A basic RNN can struggle with long dependencies. During BPTT, gradients repeatedly multiply through recurrent transformations. If their magnitudes are mostly below 11, they can shrink toward zero; if mostly above 11, they can grow explosively. Vanishing gradients make early events difficult to learn, while exploding gradients can destabilize training. Gradient clipping helps control explosions but does not restore information already lost through vanishing gradients.

Long Short-Term Memory (LSTM) networks introduce a cell state plus input, forget, and output gates that regulate what information is written, preserved, and exposed. Gated Recurrent Units (GRUs) use a simpler update/reset gating design. Both make longer dependencies easier to learn than a plain RNN, although recurrent models still process positions sequentially and are less parallel than Transformers.

6. From Words to Ordered Numerical Sequences

Text demonstrates why order matters: “not good” and “not bad” contain similar words but convey different meanings. An RNN reads tokens in order, allowing earlier words to change the hidden state used to interpret later ones.

The static example below uses NLTK's Movie Reviews corpus. A vocabulary maps words to integer IDs, unknown words use <UNK>, and shorter reviews are padded with <PAD> until every batch has a consistent length. When copied to Colab, it requires NLTK and downloads the movie_reviews dataset.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
# Colab note: this downloads the NLTK Movie Reviews dataset when run.
import nltk
from nltk.corpus import movie_reviews
from collections import Counter
import random

nltk.download("movie_reviews", quiet=True)

documents = []
for label in movie_reviews.categories():
    for file_id in movie_reviews.fileids(label):
        words = [w.lower() for w in movie_reviews.words(file_id) if w.isalpha()]
        documents.append((words, 1 if label == "pos" else 0))

random.seed(42)
random.shuffle(documents)

word_counts = Counter(word for words, _ in documents for word in words)
vocab = {"<PAD>": 0, "<UNK>": 1}
for word, _ in word_counts.most_common(9_998):
    vocab[word] = len(vocab)

max_len = 100

def encode_review(words):
    ids = [vocab.get(word, vocab["<UNK>"]) for word in words[:max_len]]
    return ids + [vocab["<PAD>"]] * (max_len - len(ids))

X_text = torch.tensor([encode_review(words) for words, _ in documents])
Y_text = torch.tensor([label for _, label in documents], dtype=torch.float32).unsqueeze(1)

split = int(0.8 * len(X_text))
X_train, X_test = X_text[:split], X_text[split:]
Y_train, Y_test = Y_text[:split], Y_text[split:]

print("Reviews:", len(documents))
print("Encoded input shape:", tuple(X_text.shape))
Out [1]:
Example output (precomputed; dataset downloads only when run in Colab):
Reviews: 2000
Encoded input shape: (2000, 100)

7. Static NLP Example: Sentiment Classification

The sentiment model has three parts: an embedding layer converts word IDs into learned vectors, an RNN processes those vectors in order, and a linear layer converts the final hidden state into one classification logit. BCEWithLogitsLoss combines a numerically stable sigmoid operation with binary cross-entropy during training.

After training, applying sigmoid converts each logit into a probability. This is a deliberately small educational model, so learners should expect errors on sarcasm, rare words, long dependencies, and sentences that differ from the training corpus.

Jupyter Code Notebook Cell
Python 3 (ipykernel)
In [1]:
class SentimentRNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim=64, hidden_size=64):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
        self.rnn = nn.RNN(embedding_dim, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, 1)

    def forward(self, token_ids):
        embedded = self.embedding(token_ids)
        _, final_hidden = self.rnn(embedded)
        return self.fc(final_hidden[-1])

sentiment_model = SentimentRNN(len(vocab))
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(sentiment_model.parameters(), lr=0.001)

for epoch in range(5):
    sentiment_model.train()
    permutation = torch.randperm(X_train.size(0))

    for start in range(0, X_train.size(0), 64):
        indices = permutation[start:start + 64]
        logits = sentiment_model(X_train[indices])
        loss = criterion(logits, Y_train[indices])

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

def predict_sentiment(sentence):
    words = [word.lower() for word in sentence.split() if word.isalpha()]
    token_ids = torch.tensor([encode_review(words)])

    sentiment_model.eval()
    with torch.no_grad():
        probability = torch.sigmoid(sentiment_model(token_ids)).item()

    label = "positive" if probability >= 0.5 else "negative"
    return label, probability

print(predict_sentiment("the movie was great and good"))
print(predict_sentiment("the movie was long and boring"))
Out [1]:
Example output only; predictions appear after training in Colab:
('positive', model_probability)
('negative', model_probability)

Interactive Practice Quiz

Test your understanding with instant feedback

QUESTION 01

With batch_first=True, what does an RNN input shape of (32,20,1)(32,20,1) mean?

QUESTION 02

What does an RNN's final hidden state represent?

QUESTION 03

Why is RNN backpropagation called Backpropagation Through Time (BPTT)?

QUESTION 04

What is the main limitation of using seq_length = 1 for sine-wave prediction?

QUESTION 05

Why do LSTMs and GRUs often learn long-range dependencies better than plain RNNs?