Recurrent Neural Networks and LSTMs for Sequence Data

Think about how you read this sentence. You didn't process each word in isolation, you carried forward an understanding of everything that came before, using it to interpret each new word in context. When you hit the word "before" in that sentence, you already knew what "it" referred to. That's sequential reasoning, and it's something our brains do effortlessly. Teaching a neural network to do the same thing is one of the core challenges, and triumphs, of modern deep learning.
Most of the deep learning fundamentals we've covered so far assume your data has a fixed, well-defined structure: an image is a grid of pixels, a tabular row is a vector of features. But a huge class of real-world problems don't fit that mold. Text is a sequence of tokens where word order determines meaning. Time series data is a stream of observations where the past predicts the future. Audio is a signal evolving over time. Speech recognition, machine translation, stock forecasting, DNA sequence analysis, all of these live in the world of sequential data, and they all demand architectures that understand order and context.
Recurrent Neural Networks (RNNs) were designed specifically for this world. They process inputs one step at a time while maintaining a running summary, a hidden state, of everything they've seen. That hidden state is the network's memory, and it gets updated at every step. But as we'll see, vanilla RNNs have a critical weakness when it comes to long sequences: they forget. Enter the Long Short-Term Memory network (LSTM), an architectural innovation that solves this forgetting problem with elegant, learned gates that decide what to keep, what to update, and what to discard.
In this article, we'll build your intuition for how these architectures work, tackle their quirks, and show you how to implement them in PyTorch for real-world sequence problems. By the end, you'll understand not just how to use these tools, but why they're built the way they are, and that understanding is what separates engineers who can debug models from engineers who just run examples and hope for the best.
Table of Contents
- Why Sequences Need Special Networks
- Why Sequential Data Breaks Traditional Neural Networks
- Variable-Length Sequences
- Temporal Dependencies
- The Vanishing Gradient Problem
- Vanilla RNNs: The Starting Point
- Backpropagation Through Time (BPTT)
- LSTMs: Memory and Gates
- Why This Fixes Vanishing Gradients
- LSTM Gates Intuition
- Visual: The LSTM Cell with Tensor Annotations
- GRUs: A Simpler Alternative
- Bidirectional RNNs
- Packing and Padding Sequences
- Sequence-to-Sequence Architectures
- Many-to-One (Classification)
- One-to-Many (Generation)
- Many-to-Many (Sequence Labeling)
- Many-to-Many (Encoder-Decoder, Machine Translation)
- Time Series Forecasting: A Complete Example
- Common RNN Mistakes
- Common Pitfalls and Best Practices
- Summary
Why Sequences Need Special Networks
Before we dive into architecture, it's worth understanding precisely why standard feedforward networks fail at sequence tasks. The answer isn't just "they don't handle variable-length input", it goes deeper than that, into the fundamental nature of what we're trying to learn.
A feedforward network maps a fixed-size input vector to an output. It's a stateless function. Give it the same input twice, you get the same output twice. That's a feature, not a bug, it makes feedforward networks predictable and parallelizable. But when your data is sequential, that statelessness becomes a fatal limitation.
Consider the phrase "The bank refused the loan because it was too risky." The word "it" refers to "the loan," not "the bank", but nothing in the word "it" itself tells you that. You need to track context from earlier in the sentence. A feedforward network processing word by word has no mechanism to do that. Each word is processed as if it exists in isolation, with no memory of what came before.
Even if you try to work around this by feeding the entire sequence at once as a flattened vector, you run into the variable-length problem: sentences have different lengths, time series have different durations, and flattening breaks positional relationships. You'd also be giving up any ability to generalize across positions, a pattern learned at position 5 wouldn't transfer to position 15.
Sequences also encode information through their structure and rhythm. The difference between "I only eat sushi" and "I eat only sushi" is a single word displacement, but the meanings are subtly different. That kind of positional sensitivity is invisible to architectures that treat inputs as unordered bags of features. Sequential architectures are designed from the ground up to respect, and exploit, the temporal structure of your data.
Why Sequential Data Breaks Traditional Neural Networks
Let's start with the core problem. Imagine you're building a sentiment classifier for movie reviews. A standard feedforward network would:
- Take each word embedding as input
- Push it through hidden layers
- Output a probability
But here's the catch: it processes each word independently. It doesn't know where it is in the sentence. "Not good" and "good not" would look identical to a feedforward network. The order, the sequential structure, carries crucial information that we're throwing away.
Variable-Length Sequences
Another headache: real-world sequences have different lengths. Some reviews are 50 words; others are 500. You could pad everything to a fixed length, but then you're either wasting computation on padding or truncating meaningful content.
Sequential models handle variable-length inputs naturally. They process one time step at a time, so they don't care if your sequence is 50 steps or 500.
Temporal Dependencies
In financial time series, today's price depends on yesterday's, which depends on the day before. These temporal dependencies can stretch across time. Vanilla RNNs struggle to capture dependencies more than a handful of steps back, a problem called the vanishing gradient problem.
The Vanishing Gradient Problem
The vanishing gradient problem is the single biggest obstacle in training deep sequential models, and understanding it deeply will make you a much better practitioner. It's not a bug in your code, it's a mathematical consequence of how backpropagation interacts with repeated matrix multiplications.
When we train an RNN, we use a variant of backpropagation called Backpropagation Through Time (BPTT). To compute the gradient of the loss with respect to the weights at an early time step, we have to chain together gradients across all the intervening steps. Each step in that chain involves multiplying by the weight matrix $W_$. If that matrix has eigenvalues less than 1, which happens whenever the weights are initialized to small values, as they typically are, the gradient gets multiplied by a number less than 1 at every step. After 50 or 100 steps, the gradient has shrunk to essentially zero. The early parts of your sequence become invisible to the learning signal.
The practical consequence is brutal: your model learns to rely almost entirely on the last few time steps and ignores everything before that. For short sequences (5-10 steps), vanilla RNNs can work. For anything longer, they fail badly. Try to train a vanilla RNN to learn that a character introduced in chapter one is the murderer revealed in chapter ten, and it won't be able to, the gradient signal connecting chapter one to the loss in chapter ten will have vanished long before training converges.
The exploding gradient problem is the mirror image: if eigenvalues exceed 1, gradients grow exponentially and destabilize training. This is actually easier to fix, gradient clipping (capping gradient norms at a maximum value) handles it well. But vanishing gradients require a deeper architectural solution. You can't clip a gradient that's already zero. This is precisely the problem that LSTMs were invented to solve, and they do so with an elegance that's worth appreciating in detail.
Vanilla RNNs: The Starting Point
Let's build intuition with the simplest RNN. At each time step $t$, an RNN maintains a hidden state $h_t$ that captures information about the sequence so far.
The update rule is elegant:
$$h_t = \tanh(W_ h_ + W_ x_t + b_h)$$
$$y_t = W_ h_t + b_y$$
Here:
- $x_t$ is your input at time step $t$ (e.g., a word embedding)
- $h_$ is the hidden state from the previous step
- $W_$ governs how much of the past we retain
- $W_$ governs how the current input influences hidden state
- The hidden state is the memory
You can visualize this as a chain of operations, where each step feeds into the next:
x₀ → [RNN] → h₀ → y₀
↙ h₁ ↙
x₁ → [RNN] → h₁ → y₁
↙ h₂ ↙
x₂ → [RNN] → h₂ → y₂
Backpropagation Through Time (BPTT)
Training an RNN means computing gradients across the entire sequence. We unfold the network across time steps and backpropagate the error backward through the sequence.
Here's the problem: each step multiplies by $W_$. If $W_$ has small eigenvalues (< 1), gradients shrink exponentially as we backprop. By the time we reach early time steps, the gradient is essentially zero, the vanishing gradient problem. The network learns to rely only on recent inputs and forgets long-range dependencies.
Conversely, if $W_$ has large eigenvalues (> 1), gradients explode, the exploding gradient problem. This is "easier" to fix with gradient clipping, but vanishing gradients are the real killer.
LSTMs: Memory and Gates
LSTMs solve the vanishing gradient problem by introducing a cell state that can carry information unchanged across many time steps. Instead of replacing the hidden state completely, they use gates, neural network functions that decide what information flows.
An LSTM has four main components:
- Forget Gate: Decides what information to discard from the cell state
- Input Gate: Decides what new information to add
- Cell Update: Computes candidate values for the cell state
- Output Gate: Decides what parts of the cell state to expose as the hidden state
Let's work through the math. At time step $t$:
Forget Gate: $$f_t = \sigma(W_f \cdot [h_, x_t] + b_f)$$
The sigmoid outputs values between 0 and 1. A value of 1 means "keep it"; 0 means "forget it."
Input Gate: $$i_t = \sigma(W_i \cdot [h_, x_t] + b_i)$$
This gate decides which new information to allow through.
Cell Candidate: $$\tildet = \tanh(W_c \cdot [h, x_t] + b_c)$$
This is the "candidate" cell state, what we could add.
Cell State Update: $$C_t = f_t \odot C_ + i_t \odot \tilde_t$$
Here's the magic. The cell state $C_t$ is a weighted combination of the old state (scaled by the forget gate) and the new candidate (scaled by the input gate). The $\odot$ denotes element-wise multiplication.
Output Gate: $$o_t = \sigma(W_o \cdot [h_, x_t] + b_o)$$
Hidden State (Output): $$h_t = o_t \odot \tanh(C_t)$$
The hidden state is what we expose to the next time step and to downstream layers.
Why This Fixes Vanishing Gradients
The cell state $C_t$ is updated via addition, not multiplication by a weight matrix. When you backprop through an addition, the gradient flows unchanged, no shrinking or explosion. Each gate is a learned function, so the network learns when to forget, when to update, and when to output. This allows the cell state to carry information across hundreds of time steps without gradient degradation.
LSTM Gates Intuition
The gate mechanism is the heart of what makes LSTMs work, and it's worth spending time building a concrete mental model before touching any code. Think of the cell state as a conveyor belt running the length of your sequence. Information can be added to the belt, removed from the belt, or simply carried forward untouched, and all three of those operations are controlled by learned, input-dependent switches.
The forget gate is the "relevance filter." When you're processing a new sentence after a period at the end of the previous one, the forget gate should fire strongly and clear out subject-verb agreement information that was relevant for the old sentence but is no longer applicable. The network learns this automatically from data, it observes that period tokens correlate with the loss signal when old grammatical context persists, and it learns to flush it.
The input gate is the "novelty filter." Not every new token is equally worth storing in the long-term cell state. Stop words like "the" and "a" rarely carry critical long-range information. The input gate learns to open wide for content words and named entities that are likely to matter later, and mostly closed for tokens that are only locally relevant. Combined with the cell candidate (which computes what we'd want to add if we were going to add anything), the input gate creates a precise, selective update mechanism.
The output gate is the "exposure filter." The cell state might contain information at different levels of abstraction, grammatical state, semantic content, long-range dependencies. The output gate decides what subset of all that stored information is relevant to the current prediction. This decoupling of storage from exposure is subtle but powerful: the LSTM can store something without immediately acting on it, and act on something without erasing it from storage. Together, these three gates give the LSTM a kind of controlled, learnable working memory that vanilla RNNs simply don't have.
Visual: The LSTM Cell with Tensor Annotations
Here's what an LSTM cell looks like with PyTorch tensor shapes:
Input: [batch=32, seq_len=50, embedding=128]
┌─────────────────────────────────────────────┐
│ LSTM Cell (Single Time Step) │
├─────────────────────────────────────────────┤
│ │
│ h_prev: [32, 256] x_t: [32, 128] │
│ │
│ concat → [32, 384] │
│ ↙ ↙ ↙ ↙ │
│ Forget Input Update Output │
│ Gate Gate Gate Gate │
│ sig(·) sig(·) tanh(·) sig(·) │
│ ↓ ↓ ↓ ↓ │
│ [32,256] [32,256] [32,256] [32,256] │
│ ↓ ↓ ↓ ↓ │
│ f_t ⊙ C_prev + i_t ⊙ C_candidate │
│ ↓ │
│ C_t: [32, 256] (new cell state) │
│ ↓ │
│ o_t ⊙ tanh(C_t) │
│ ↓ │
│ h_t: [32, 256] (output hidden) │
│ │
└─────────────────────────────────────────────┘
Each gate is a simple neural network: Linear(h_dim + x_dim, hidden_dim) followed by a sigmoid or tanh activation.
GRUs: A Simpler Alternative
Gated Recurrent Units (GRUs) are a stripped-down LSTM variant. Instead of three gates and a cell state, they use:
- Reset Gate: Controls whether to ignore the previous hidden state
- Update Gate: Controls whether to update the hidden state
GRUs were introduced in 2014, a year after LSTMs became widely used, as an attempt to simplify the architecture without sacrificing the core capability. By merging the cell state and hidden state into a single vector, and combining the forget and input gates into a single update gate, GRUs reduce the parameter count by roughly 25% while keeping the essential gating behavior.
r_t = sigmoid(W_r @ [h_prev, x_t])
z_t = sigmoid(W_z @ [h_prev, x_t])
h_tilde = tanh(W_h @ [r_t * h_prev, x_t])
h_t = (1 - z_t) * h_tilde + z_t * h_prevThe update gate $z_t$ functions as a combined forget/input gate: when it's close to 1, the GRU mostly keeps the old hidden state; when it's close to 0, it mostly uses the new candidate. The reset gate $r_t$ controls how much of the previous hidden state influences the candidate computation, when it's near zero, the GRU effectively ignores its history and computes a fresh update from just the current input.
GRUs are faster (fewer parameters, fewer gates) and often perform just as well as LSTMs on many tasks. Use them when you want to train quickly; use LSTMs when you need maximum expressiveness.
Bidirectional RNNs
So far, we've processed sequences left-to-right. But what if your data has context from both directions?
In sentiment analysis, the word "good" before "not" matters differently than "good" after "not." A bidirectional RNN runs two passes: one forward (left→right) and one backward (right→left). You concatenate the forward and backward hidden states.
Bidirectionality is particularly valuable for tasks like named entity recognition, where the label for a word often depends heavily on words that come after it. In the sentence "I visited Paris last summer," knowing that "Paris" is followed by "last summer" (a time phrase rather than a location-related word) helps confirm that it's a location. A left-to-right model has to infer that from left context alone; a bidirectional model sees both sides simultaneously.
class BiLSTM(nn.Module):
def __init__(self, input_size, hidden_size):
super().__init__()
self.lstm = nn.LSTM(input_size, hidden_size, bidirectional=True, batch_first=True)
def forward(self, x):
# x: [batch, seq_len, input_size]
lstm_out, (h_n, c_n) = self.lstm(x)
# lstm_out: [batch, seq_len, hidden_size * 2]
return lstm_outThe hidden state is now hidden_size * 2 because you're concatenating forward and backward outputs. One important caveat: bidirectional models require the entire sequence to be available before processing, they can't be used for real-time, streaming inference where you process one token at a time as it arrives. For tasks like translation or NER where you have the full input, bidirectionality is almost always worth the extra computation.
Packing and Padding Sequences
Real-world sequences have variable lengths. You could pad everything to max length, but that wastes computation. PyTorch's pad_sequence and pack_padded_sequence let you handle variable lengths efficiently.
The core issue with naive padding is that when an RNN processes a padded-zero token at the end of a short sequence, it modifies the hidden state, potentially corrupting the useful representation built up over the real tokens. By packing sequences, you tell PyTorch to skip the padding tokens entirely, feeding each sequence exactly as many steps as it actually contains and jumping directly to the next sequence in the batch when a short one finishes. The result is cleaner representations and faster training.
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence
# Assume you have sequences of different lengths
sequences = [
torch.randn(10, 128), # seq 1: 10 time steps, 128-dim embedding
torch.randn(15, 128), # seq 2: 15 time steps
torch.randn(8, 128), # seq 3: 8 time steps
]
# Pad to max length
padded = pad_sequence(sequences, batch_first=True) # [3, 15, 128]
lengths = torch.tensor([10, 15, 8])
# Pack into a packed sequence object
packed = pack_padded_sequence(padded, lengths, batch_first=True, enforce_sorted=False)
# Pass through LSTM
lstm = nn.LSTM(128, 256, batch_first=True)
packed_output, (h_n, c_n) = lstm(packed)
# Unpack back to padded tensor
output, _ = pad_packed_sequence(packed_output, batch_first=True)
# output: [3, 15, 256]Packing is crucial because it prevents the RNN from "seeing" padding tokens, which would pollute your representations. After calling pad_packed_sequence, the output tensor is padded back to the maximum length in the batch, so you can apply loss functions and other operations normally, but the LSTM itself never saw those padding positions.
Sequence-to-Sequence Architectures
RNNs excel at four types of sequence problems:
Many-to-One (Classification)
Use case: Sentiment analysis, language identification, toxicity detection.
Input: sequence of embeddings. Output: single classification.
In the many-to-one pattern, the entire sequence is compressed into a single vector (the final hidden state) that summarizes everything the model has read. That summary vector is then passed to a classifier. The quality of that summary is everything, the LSTM needs to pack all the information relevant to the classification task into a fixed-size vector. For tasks where the decision depends on the entire context, this works well; for very long sequences, attention mechanisms (the precursor to transformers) are sometimes added on top to prevent information bottlenecks.
class SentimentClassifier(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, num_classes)
def forward(self, input_ids):
# input_ids: [batch, seq_len]
embedded = self.embedding(input_ids) # [batch, seq_len, embedding_dim]
_, (h_n, _) = self.lstm(embedded) # h_n: [1, batch, hidden_dim]
logits = self.fc(h_n.squeeze(0)) # [batch, num_classes]
return logitsTake the final hidden state and feed it to a classifier.
One-to-Many (Generation)
Use case: Image captioning, music generation.
Input: single vector. Output: sequence of predictions.
You typically start with a special token (e.g., <START>) and repeatedly generate tokens, feeding each output back as the next input.
The one-to-many pattern is conceptually the inverse of many-to-one: you start with a dense summary of some input (say, an image encoded by a CNN), initialize the LSTM's hidden state with it, and then decode a sequence one token at a time. This is the backbone of image captioning systems. The tricky part is the autoregressive loop, at inference time, each generated token becomes the next input, which means errors can compound over the sequence.
class CaptionGenerator(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, vocab_size)
def forward(self, initial_state, max_length, start_token):
batch_size = initial_state.shape[0]
outputs = []
h, c = initial_state, torch.zeros_like(initial_state)
current_input = self.embedding(start_token) # [batch, embedding_dim]
for _ in range(max_length):
current_input = current_input.unsqueeze(1) # [batch, 1, embedding_dim]
out, (h, c) = self.lstm(current_input, (h, c))
logits = self.fc(out[:, 0, :]) # [batch, vocab_size]
outputs.append(logits)
# For inference, sample next token; for training, use ground truth
predicted_idx = logits.argmax(dim=-1) # [batch]
current_input = self.embedding(predicted_idx)
return torch.stack(outputs, dim=1) # [batch, max_length, vocab_size]Many-to-Many (Sequence Labeling)
Use case: Named entity recognition, part-of-speech tagging, video action detection.
Input and output are both sequences of the same length.
Sequence labeling is one of the most common NLP tasks, and the many-to-many architecture handles it naturally. Rather than collapsing the sequence into a single summary vector, we keep all the intermediate hidden states and produce one output per input token. In named entity recognition, each word in the input gets tagged as a person, organization, location, or none-of-the-above. Bidirectionality is almost always used here because the correct tag for a word depends on both preceding and following context.
class NERTagger(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_tags):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True, bidirectional=True)
self.fc = nn.Linear(hidden_dim * 2, num_tags)
def forward(self, input_ids):
# input_ids: [batch, seq_len]
embedded = self.embedding(input_ids) # [batch, seq_len, embedding_dim]
lstm_out, _ = self.lstm(embedded) # [batch, seq_len, hidden_dim*2]
tag_scores = self.fc(lstm_out) # [batch, seq_len, num_tags]
return tag_scoresEvery time step gets a label.
Many-to-Many (Encoder-Decoder, Machine Translation)
Use case: Machine translation, abstractive summarization.
Input and output are different lengths. An encoder processes the source sequence; a decoder generates the target sequence.
The encoder-decoder architecture was a landmark development in NLP, it was the direct predecessor to the attention mechanism and transformers. The key insight is the information bottleneck: the entire source sequence is compressed into the encoder's final hidden state (h, c), which becomes the initial state of the decoder. The decoder then generates the target sequence autoregressively. This works well for short sequences but degrades on long ones, which is why attention was invented to bypass the bottleneck.
class Encoder(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
def forward(self, input_ids):
embedded = self.embedding(input_ids)
_, (h, c) = self.lstm(embedded)
return h, c
class Decoder(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, vocab_size)
def forward(self, target_ids, initial_state):
h, c = initial_state
embedded = self.embedding(target_ids)
lstm_out, (h, c) = self.lstm(embedded, (h, c))
logits = self.fc(lstm_out)
return logits
# At inference, feed decoder outputs back as inputs (autoregressive generation)Time Series Forecasting: A Complete Example
Let's tie it together with a practical example. We'll build an LSTM to forecast daily stock prices. This is a canonical time series regression task, we have a sequence of past observations and we want to predict the next one. The LSTM architecture maps naturally onto this problem because each price in the sequence is genuinely informative about the next one, and the model needs to track trends, momentum, and volatility over multiple time scales simultaneously.
The most important design choice in time series forecasting is your sequence length: how many past steps should the model see before predicting the next one? Too short, and you miss longer-term patterns. Too long, and you risk the model struggling to focus on the most recent, relevant context. Starting with 30 time steps for daily price data is a reasonable heuristic, it covers a full trading month of history. You can tune this as a hyperparameter.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
class TimeSeriesDataset(Dataset):
def __init__(self, data, sequence_length=30):
self.data = data
self.sequence_length = sequence_length
def __len__(self):
return len(self.data) - self.sequence_length
def __getitem__(self, idx):
x = self.data[idx:idx + self.sequence_length]
y = self.data[idx + self.sequence_length]
return torch.tensor(x, dtype=torch.float32), torch.tensor(y, dtype=torch.float32)
class StockForecaster(nn.Module):
def __init__(self, input_size=1, hidden_size=64, num_layers=2):
super().__init__()
self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True)
self.fc = nn.Linear(hidden_size, 1)
def forward(self, x):
# x: [batch, seq_len, input_size]
lstm_out, _ = self.lstm(x) # [batch, seq_len, hidden_size]
last_hidden = lstm_out[:, -1, :] # [batch, hidden_size]
prediction = self.fc(last_hidden) # [batch, 1]
return prediction
# Training loop
model = StockForecaster()
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.MSELoss()
import numpy as np
prices = np.random.randn(1000).cumsum() + 100 # simulated stock prices
train_data = TimeSeriesDataset(prices, sequence_length=30)
train_loader = DataLoader(train_data, batch_size=32, shuffle=True)
for epoch in range(100):
for x, y in train_loader:
optimizer.zero_grad()
pred = model(x.unsqueeze(-1)) # Add feature dimension
loss = criterion(pred, y.unsqueeze(-1))
loss.backward()
optimizer.step()
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch + 1}, Loss: {loss.item():.4f}")Key points:
- We create sequences of 30 time steps and predict the next price
- The LSTM learns temporal patterns
- We use the last hidden state (since we only need one prediction)
- MSE loss is standard for regression
One thing to keep in mind: raw price values span very different ranges across different stocks, which makes it hard to compare losses or reuse a model across assets. Always normalize your input sequences, subtract the mean and divide by the standard deviation of your training window, before feeding them to the LSTM. Denormalize when you report final predictions. This simple step can dramatically improve convergence speed and final performance.
Common RNN Mistakes
Even with a solid understanding of the architecture, RNN projects fail regularly due to a handful of consistent implementation and design mistakes. Here are the ones we see most often, along with the fixes.
The most common mistake is forgetting to detach hidden states between batches in a stateful training setup. When you're training on a very long time series by breaking it into chunks, you sometimes want to carry the hidden state from one chunk into the next (preserving continuity). If you do this naively without calling .detach() on the hidden state, PyTorch will try to backpropagate through the entire history of hidden states going back to the first chunk, blowing up your memory and your gradients. Always detach: h = h.detach() between batches in stateful setups.
Another common mistake is using the wrong output for classification. nn.LSTM returns both output (all hidden states, shape [batch, seq_len, hidden]) and a tuple (h_n, c_n) where h_n is the final hidden state (shape [num_layers, batch, hidden]). For many-to-one tasks, people sometimes accidentally use output[:, -1, :] (the last time step from the output tensor) thinking it's the same as h_n[-1], and for a unidirectional single-layer LSTM, it is. But for bidirectional or multi-layer LSTMs, they're different. Use h_n[-1] to get the last layer's hidden state, or concatenate h_n[-2] and h_n[-1] for bidirectional models.
A third mistake is ignoring teacher forcing ratio decay. During decoder training in seq2seq models, you use the ground truth output as the next decoder input (teacher forcing). This speeds up convergence but creates a train-test discrepancy: at inference, you feed your own predictions back, not ground truth. If you train with 100% teacher forcing and then switch to 0% at inference, performance drops sharply. The fix is to gradually reduce teacher forcing during training, start at 100%, decay toward 0% over epochs, so the model learns to handle its own imperfect predictions before deployment.
Common Pitfalls and Best Practices
-
Forget to pack variable-length sequences. Padding wastes computation and can introduce bias. Use
pack_padded_sequence. -
Initialize hidden states carelessly. If you're continuing a sequence (e.g., next batch in a time series), reuse previous hidden states. Otherwise, zero-initialize.
-
Gradient explosion. Use gradient clipping:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)-
Vanishing gradients. LSTMs help, but deep RNNs (many layers) still suffer. Use residual connections or layer normalization.
-
Overfitting on small datasets. RNNs have lots of parameters. Use dropout:
lstm = nn.LSTM(128, 256, num_layers=2, dropout=0.5, batch_first=True)-
Not normalizing input. RNNs are sensitive to input scale. Standardize your sequences to zero mean and unit variance.
-
Teacher forcing in inference. During training, feed ground-truth outputs back into the decoder. At inference, feed your own predictions. The discrepancy is called exposure bias.
Summary
Recurrent Neural Networks process sequences by maintaining state across time steps. Vanilla RNNs suffer from vanishing gradients, which LSTMs fix through gated cell states that allow information to flow unchanged across many time steps. GRUs are a simpler alternative that often works just as well.
Key architectures, many-to-one, one-to-many, many-to-many, handle different sequence problems. Bidirectional variants process sequences in both directions. Practical tools like pack_padded_sequence handle variable-length inputs efficiently.
The time series example showed how to build an LSTM for regression; the same structure applies to classification (many-to-one), generation (one-to-many), and sequence labeling (many-to-many). Remember: pack your sequences, clip your gradients, and normalize your inputs.
RNNs are part of the modern deep learning toolkit, especially for language and time series work. You've now got the intuition to implement them, debug them, and know when to reach for them. We've covered not just the mechanics but the reasoning: why sequences break feedforward networks, why vanilla RNNs forget, exactly how LSTM gates restore memory, and where every major architecture fits in the problem landscape. Take the time series example and adapt it, swap in your own data, experiment with GRUs versus LSTMs, try bidirectionality on a classification task. The best way to internalize these architectures is to break them deliberately and fix them deliberately. That's how intuition actually forms.
One final note: in modern NLP, transformers have largely superseded RNNs for language tasks, they parallelize better and scale further. But RNNs remain the right tool for streaming and online sequence tasks where you process one token at a time, for many time series applications where sequence lengths are moderate and the inductive bias of recurrence is genuinely useful, and as a conceptual foundation for understanding attention and transformers. Everything you've learned here carries forward. The gating idea in LSTMs is the conceptual ancestor of the attention mechanism; the encoder-decoder pattern is the direct predecessor of the transformer architecture. You haven't just learned a tool, you've learned the thinking that led to the current state of the art.