Sequence-to-One: Sentiment Analysis with an LSTM
Learners will build an end-to-end sentiment classifier: tokenise reviews, embed words with nn.Embedding, run through LSTM, and classify the final hidden state.
Sequence-to-One: Sentiment Analysis with an LSTM is a free Machine Learning Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Machine Learning Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Sequence-to-One Classification Explained
In sequence-to-one tasks, the model reads an entire sequence and produces a single output label. Sentiment analysis is the classic example: given a full movie review ('I loved every minute of this film'), predict a single label — positive or negative.
This contrasts with sequence-to-sequence (translation, where every input token produces an output token) and one-to-sequence (image captioning). In sequence-to-one, we discard intermediate LSTM outputs and use only the final hidden state h_T, which theoretically encodes the meaning of the entire input sequence.
Dataset: IMDB Movie Reviews
The IMDB dataset contains 50,000 movie reviews (25,000 train, 25,000 test) labelled as positive or negative. Each review is a variable-length string of text. Before feeding it to an LSTM, we need three preprocessing steps: tokenisation (split text into words), vocabulary building (map words to integer indices), and padding (make all sequences the same length).
We also need to handle reviews that are very long — LSTM training slows quadratically with sequence length, so truncating to 256 or 512 tokens is common practice without significantly hurting accuracy.
from torchtext.datasets import IMDB
from torchtext.data.utils import get_tokenizer
from torchtext.vocab import build_vocab_from_iterator
tokenizer = get_tokenizer('basic_english')
def yield_tokens(data_iter):
for _, text in data_iter:
yield tokenizer(text)
train_iter = IMDB(split='train')
vocab = build_vocab_from_iterator(yield_tokens(train_iter),
specials=['<unk>', '<pad>'])
vocab.set_default_index(vocab['<unk>'])
print('Vocabulary size:', len(vocab))Tokenising and Padding Sequences
Padding is necessary because PyTorch DataLoaders require tensors of the same shape within a batch. We pad shorter sequences with a special <pad> token (usually index 0) and truncate longer ones to a maximum length.
The key insight is that the LSTM should ignore padding tokens. PyTorch's pack_padded_sequence and pad_packed_sequence utilities let you efficiently skip padding during the LSTM computation, which is important for correctness — otherwise the model will update its hidden state based on meaningless padding, distorting the final h_T.
import torch
from torch.nn.utils.rnn import pad_sequence
def text_pipeline(text):
return vocab(tokenizer(text))[:256] # Truncate to 256 tokens
def label_pipeline(label):
return 1 if label == 'pos' else 0
def collate_batch(batch):
labels, texts, lengths = [], [], []
for label, text in batch:
labels.append(label_pipeline(label))
processed = torch.tensor(text_pipeline(text), dtype=torch.long)
texts.append(processed)
lengths.append(len(processed))
texts = pad_sequence(texts, batch_first=True, padding_value=1) # 1 = <pad>
return torch.tensor(labels), texts, torch.tensor(lengths)Embedding Layer: Words to Dense Vectors
Integer word indices cannot be fed directly into an LSTM — we need to convert them to continuous vectors. An embedding layer nn.Embedding(vocab_size, embedding_dim) is a lookup table that maps each word index to a dense vector of floats.
Common embedding dimensions are 100, 200, or 300. The embeddings are learnable parameters that get updated during training alongside the LSTM weights. Alternatively, you can initialise them from pre-trained word vectors like GloVe or Word2Vec, which dramatically speeds up training on small datasets by providing pre-learned semantic relationships between words.
import torch.nn as nn
vocab_size = 25000
embedding_dim = 100
hidden_size = 128
output_dim = 1 # Binary classification: positive/negative
embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=1)
# Optional: load pre-trained GloVe vectors
# from torchtext.vocab import GloVe
# glove = GloVe(name='6B', dim=100)
# embedding.weight.data.copy_(glove.vectors)
print('Embedding weight shape:', embedding.weight.shape) # (25000, 100)Building the SentimentLSTM Model
The complete model architecture has three parts: an embedding layer, an LSTM, and a fully connected head. The embedding converts token indices to vectors, the LSTM processes the sequence, and the linear layer maps the final hidden state to a single logit.
We add dropout after the embedding and before the linear layer to regularise the model. For the output, we apply a sigmoid to get a probability between 0 and 1. Training uses Binary Cross-Entropy loss since this is binary classification.
import torch
import torch.nn as nn
class SentimentLSTM(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_size, num_layers, dropout):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=1)
self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers,
batch_first=True, dropout=dropout)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_size, 1)
def forward(self, text):
embedded = self.dropout(self.embedding(text)) # (B, T, embed_dim)
output, (h_n, c_n) = self.lstm(embedded)
last_hidden = self.dropout(h_n[-1]) # Final layer hidden state
return self.fc(last_hidden).squeeze(1) # (B,) logitsTraining Setup: Loss and Optimizer
For binary classification we use nn.BCEWithLogitsLoss, which combines sigmoid activation and binary cross-entropy loss in a single numerically stable operation. This is preferred over applying sigmoid manually followed by nn.BCELoss.
We use the Adam optimiser with a learning rate of 1e-3. Adam adapts learning rates per parameter, making it well-suited for sparse gradients that arise from embeddings (only the embeddings for words that appear in the batch receive gradient updates). Use gradient clipping with torch.nn.utils.clip_grad_norm_ to prevent exploding gradients, a common issue in RNN training.
import torch
import torch.nn as nn
import torch.optim as optim
model = SentimentLSTM(
vocab_size=25000, embed_dim=100,
hidden_size=128, num_layers=2, dropout=0.3
)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.BCEWithLogitsLoss()
# Gradient clipping prevents exploding gradients in RNNs
MAX_GRAD_NORM = 1.0
print('Model parameters:', sum(p.numel() for p in model.parameters()))The Training Loop for Sentiment LSTM
The training loop for sequence-to-one classification follows the standard PyTorch pattern: forward pass, compute loss, backpropagate, clip gradients, update parameters. We also compute accuracy by comparing predicted class (sigmoid output > 0.5) to ground truth labels.
One important consideration is moving both the model and batch tensors to the same device (CPU or GPU). Using model.to(device) and tensor.to(device) ensures consistent computation. On a GPU, training a two-layer LSTM on IMDB typically takes 2-5 minutes per epoch.
def train_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss, correct = 0.0, 0
for labels, texts, lengths in loader:
labels, texts = labels.float().to(device), texts.to(device)
optimizer.zero_grad()
predictions = model(texts) # Forward pass
loss = criterion(predictions, labels) # Compute loss
loss.backward() # Backprop
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # Clip
optimizer.step() # Update weights
total_loss += loss.item()
preds = (torch.sigmoid(predictions) > 0.5).float()
correct += (preds == labels).sum().item()
return total_loss / len(loader), correct / len(loader.dataset)Evaluation and Monitoring Progress
During evaluation we call model.eval() and wrap forward passes in torch.no_grad() to disable dropout and gradient tracking. This gives us an unbiased view of generalisation performance.
We track four numbers across epochs: train loss, validation loss, train accuracy, and validation accuracy. If train accuracy climbs but validation accuracy stagnates, the model is overfitting — increase dropout or reduce model size. If both are low, the model is underfitting — add LSTM layers or increase hidden size.
def evaluate(model, loader, criterion, device):
model.eval()
total_loss, correct = 0.0, 0
with torch.no_grad():
for labels, texts, lengths in loader:
labels, texts = labels.float().to(device), texts.to(device)
predictions = model(texts)
loss = criterion(predictions, labels)
total_loss += loss.item()
preds = (torch.sigmoid(predictions) > 0.5).float()
correct += (preds == labels).sum().item()
return total_loss / len(loader), correct / len(loader.dataset)
# Training run
for epoch in range(5):
tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, criterion, device)
vl_loss, vl_acc = evaluate(model, val_loader, criterion, device)
print(f'Epoch {epoch+1}: Train Acc={tr_acc:.3f}, Val Acc={vl_acc:.3f}')Expected Performance and Common Mistakes
A well-tuned LSTM sentiment classifier on IMDB typically achieves 85-90% test accuracy. Common mistakes that hurt performance include: not setting model.eval() during evaluation (dropout remains active), not zeroing gradients before backprop, and training for too few epochs before the embeddings converge.
Another pitfall is using the same vocabulary index for unknown words and padding. Separate <unk> (index 0) and <pad> (index 1) tokens and pass padding_idx=1 to the embedding layer so pad embeddings are always zero and their gradients are suppressed during training.
# Common mistakes checklist:
# 1. Missing model.eval() before evaluation
# 2. Missing optimizer.zero_grad() before backward
# 3. Applying sigmoid twice (using BCELoss instead of BCEWithLogitsLoss)
# 4. Not handling unknown words (<unk> token)
# 5. Padding and unknown sharing the same index
# Correct: separate special tokens
vocab = build_vocab_from_iterator(
yield_tokens(train_iter),
specials=['<unk>', '<pad>'], # 0 and 1
min_freq=2 # Discard rare words
)Making Predictions on New Text
To classify new reviews at inference time, we apply the same preprocessing pipeline used during training: tokenise, convert to indices, truncate, and add a batch dimension. The model returns a logit; applying sigmoid converts it to a probability.
A prediction score above 0.5 indicates positive sentiment. You can adjust this threshold: lowering it to 0.3 increases recall for positive reviews at the cost of precision. Always apply model.eval() and torch.no_grad() during inference to disable dropout and avoid unnecessary gradient computation.
def predict_sentiment(model, text, vocab, tokenizer, device, max_len=256):
model.eval()
tokens = vocab(tokenizer(text))[:max_len]
tensor = torch.tensor(tokens, dtype=torch.long).unsqueeze(0).to(device)
with torch.no_grad():
logit = model(tensor)
prob = torch.sigmoid(logit).item()
return 'positive' if prob > 0.5 else 'negative', prob
label, score = predict_sentiment(
model,
'This film was absolutely brilliant. Loved every scene!',
vocab, tokenizer, device
)
print(f'Sentiment: {label} ({score:.3f})')LSTM vs BERT for Sentiment Analysis
While LSTMs were state-of-the-art for NLP until around 2018, transformer-based models like BERT now dominate most NLP benchmarks. BERT achieves ~93-95% on IMDB, compared to LSTM's ~87-90%.
However, LSTMs remain relevant for several reasons: they are far more computationally efficient for very long sequences (transformers scale quadratically with sequence length), they work well on time-series data where the sequential nature is intrinsic, and they have a much smaller memory footprint. Understanding LSTMs also provides the conceptual foundation needed to understand how attention mechanisms improve upon them.
Quick Check
Test your understanding of LSTM-based sequence classification from this lesson.
Lesson Recap
In this lesson you learned: sequence-to-one architecture reads an entire input sequence and maps it to a single label using the final LSTM hidden state, text preprocessing pipeline requires tokenisation, vocabulary building, and padding before data enters the model, and embedding layers convert discrete word indices into dense learnable vectors that capture semantic meaning. Next up we explore transfer learning, where pre-trained model weights replace training from scratch.
Frequently asked questions
Is the “Sequence-to-One: Sentiment Analysis with an LSTM” lesson free?
Yes — the full text of “Sequence-to-One: Sentiment Analysis with an LSTM” is free to read here on the web, and the Machine Learning Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Machine Learning Academy course, upgrade to CoddyKit PRO.
What will I learn in “Sequence-to-One: Sentiment Analysis with an LSTM”?
Learners will build an end-to-end sentiment classifier: tokenise reviews, embed words with nn.Embedding, run through LSTM, and classify the final hidden state. You practise Machine Learning Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Machine Learning Academy?
No prior experience is required. Machine Learning Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Sequence-to-One: Sentiment Analysis with an LSTM” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Machine Learning Academy lesson?
Yes. Every Machine Learning Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Vanilla RNNs: Hidden State and Sequence Unrolling
- The Vanishing Gradient Problem in Deep Time Steps
- LSTM Cell: Input, Forget, and Output Gates
- Sequence-to-One: Sentiment Analysis with an LSTM