Skip to content
Deep Learning with PyTorch
Natural Language Processing 12 min read

Sentiment Analysis End to End

Build a sentiment classifier three ways — a TF-IDF baseline you can run in the browser, an Embedding + LSTM model in PyTorch, and a modern transformer pipeline.

Download notebook Open Google ColabIn Colab: File → Upload notebook → pick the downloaded file.

Sentiment analysis — deciding whether a piece of text is positive or negative — is the classic first "real" NLP project: the task is easy to state, the labels are cheap to get, and every technique from the last lesson gets put to work. In this lesson you'll build the same classifier three ways: a classical baseline that runs right here in your browser, a deep Embedding + LSTM model in PyTorch, and finally the modern transformer shortcut.

The task: is this review positive or negative?

We'll treat sentiment as binary classification on short reviews: each input is a piece of text, the target is 1 (positive) or 0 (negative). The canonical benchmark is IMDB: 50,000 movie reviews split evenly into train and test, labeled by star rating (1–4 stars = negative, 7–10 = positive; the ambiguous middle is dropped).

That labeling detail is worth pausing on — it's typical dataset thinking for sentiment. Labels usually come from something users already provide (star ratings, thumbs up/down), which is what makes the task cheap. But it also means the classes reflect how people rate: app-store reviews, for instance, pile up at 1 star and 5 stars, so real sentiment datasets are often imbalanced, and accuracy alone can be misleading. Keep that in mind — we return to it in the evaluation section.

Preprocessing: from raw text to model food

Raw reviews are messy — capitalization, punctuation, wildly different lengths. A standard cleanup pipeline:

  1. Lowercase everything, so "Great" and "great" are one token.
  2. Tokenize — split text into words (or subwords). A simple text.lower().split() gets you surprisingly far in English.
  3. Stopwords — carefully. Removing ultra-common words ("the", "a", "of") shrinks the vocabulary and can reduce noise for topic tasks. But standard stopword lists include "not", "no", and "never" — and deleting those turns "not good" into "good". For sentiment, either keep stopwords or prune the list by hand.
  4. Pad or truncate to a fixed length. Neural networks train on batches, and a batch is a rectangular tensor — so every sequence in it must have the same length. Short reviews get filled with a special <pad> token; long ones get cut at, say, 200 tokens.

Stopword lists are task-dependent

The words that are "meaningless" for document topic detection can be the most meaningful ones for sentiment. Negations flip polarity, and intensifiers like "very" and "so" amplify it. Never apply a stopword list without reading it first.

Always start with the baseline: TF-IDF + logistic regression

Before touching a neural network, build the ten-line classical pipeline: TF-IDF turns each review into a vector of word weights (frequent in this document, rare across the corpus = high weight), and logistic regression learns which words push a review positive or negative. This baseline is fast, interpretable, and embarrassingly hard to beat on small datasets.

Here it is end to end on sixteen mini-reviews — training, predicting new sentences, and showing which n-grams the model learned to trust:

Python — runs in your browser

Both new sentences come out on the right side — but notice the probabilities hover near 0.5. Sixteen examples is nowhere near enough for confident predictions; on the full IMDB training set this exact pipeline reaches roughly 88–90% accuracy. That's the number any deep model has to beat.

Evaluation beyond accuracy

A single accuracy number hides where a model fails. The confusion matrix breaks predictions into four cells — true/false positives and negatives — and from it come precision (of everything predicted positive, how much really was?) and recall (of everything really positive, how much did we catch?).

To make this concrete, let's generate a corpus with a built-in trap: mixed-sentiment reviews like "the acting was great but the plot was dull", where the verdict after "but" wins. A bag-of-words model sees the same words in both orders, so it cannot tell "great but ... dull" from "dull but ... great":

Python — runs in your browser

Around 75% accuracy overall — but the mixed reviews are barely above coin-flip territory, and the confusion matrix shows the misses landing on both sides. The lesson: bag-of-words throws away word order, and some sentiment lives in the order. That is exactly the itch a recurrent model scratches.

The deep version: Embedding → LSTM → linear head

The deep pipeline reads a review as a sequence: token IDs go through an nn.Embedding lookup, an LSTM reads the vectors left to right while carrying a memory, and a linear head turns the final memory into one logit. PyTorch and the IMDB download don't run in the browser, so run these cells in the downloadable notebook on Colab with a GPU.

First, data and a vocabulary. We keep words seen at least twice, reserve ID 0 for padding and ID 1 for unknown words, and clamp every review to 200 tokens:

from collections import Counter
from datasets import load_dataset
 
imdb = load_dataset("imdb")
train_texts, train_labels = imdb["train"]["text"], imdb["train"]["label"]
test_texts, test_labels = imdb["test"]["text"], imdb["test"]["label"]
 
def tokenize(text):
    return text.lower().split()
 
counter = Counter(tok for text in train_texts for tok in tokenize(text))
vocab = {"<pad>": 0, "<unk>": 1}
for word, count in counter.most_common():
    if count >= 2:
        vocab[word] = len(vocab)
 
MAX_LEN = 200
def encode(text):
    ids = [vocab.get(tok, 1) for tok in tokenize(text)][:MAX_LEN]
    return ids + [0] * (MAX_LEN - len(ids))   # pad to fixed length

The model — follow the tensor shapes in the comments, they are the whole story:

import torch
from torch import nn
 
class SentimentLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim=100, hidden_dim=128):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True)
        self.head = nn.Linear(hidden_dim, 1)
 
    def forward(self, x):              # x:   (batch, seq)       token IDs
        emb = self.embedding(x)        # emb: (batch, seq, embed) vectors
        out, (h, c) = self.lstm(emb)   # h:   (1, batch, hidden)  final state
        return self.head(h[-1])        # ->   (batch, 1)          one logit

A batch of 64 reviews enters as a (64, 200) integer tensor. The embedding layer replaces each ID with its 100-dimensional vector: (64, 200, 100). The LSTM digests the 200 steps and hands back its final hidden state h, one 128-dimensional summary per review — we take h[-1], shape (64, 128), and the head maps it to (64, 1) logits. BCEWithLogitsLoss applies the sigmoid internally:

from torch.utils.data import TensorDataset, DataLoader
 
device = "cuda" if torch.cuda.is_available() else "cpu"
 
X_train = torch.tensor([encode(t) for t in train_texts])
y_train = torch.tensor(train_labels, dtype=torch.float32)
loader = DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=True)
 
model = SentimentLSTM(len(vocab)).to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
 
for epoch in range(5):
    model.train()
    total = 0.0
    for xb, yb in loader:
        xb, yb = xb.to(device), yb.to(device)
        loss = criterion(model(xb).squeeze(1), yb)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total += loss.item() * len(xb)
    print(f"epoch {epoch + 1}: train loss {total / len(loader.dataset):.4f}")

Warm-starting with pretrained embeddings

Trained from scratch, the embedding layer has to rediscover that "superb" and "wonderful" are related — from 25,000 reviews. The previous lesson's GloVe vectors already know this from billions of words, so copy them in as the starting point:

import gensim.downloader as api
 
glove = api.load("glove-wiki-gigaword-100")   # matches embed_dim=100
 
hits = 0
with torch.no_grad():
    for word, idx in vocab.items():
        if word in glove:
            model.embedding.weight[idx] = torch.tensor(glove[word])
            hits += 1
print(f"initialized {hits}/{len(vocab)} embedding rows from GloVe")

The rows stay trainable, so fine-tuning nudges them toward sentiment-specific meanings. Warm-starting typically buys a couple of accuracy points and much faster convergence — enough to push this LSTM a little past the TF-IDF baseline on IMDB.

What you'd do today: fine-tune a transformer

Honest guidance: in 2026 nobody starts a new sentiment project with an LSTM. Pretrained transformers (BERT and descendants) read the whole sequence with attention, come already trained on enormous corpora, and fine-tune to 93–95% on IMDB in minutes. With Hugging Face, inference is three lines:

from transformers import pipeline
 
clf = pipeline("sentiment-analysis",
               model="distilbert-base-uncased-finetuned-sst-2-english")
print(clf(["An absolute masterpiece.",
           "Two hours of my life I will never get back."]))

So why build the LSTM at all? Because the transformer pipeline is this same pipeline — tokenize, embed, encode the sequence, classify from a summary vector — with attention swapped in for recurrence. Having built it once by hand, nothing inside the black box will surprise you.

Check your understanding

5 questions · free
  1. Q1.Why is blindly removing stopwords risky for sentiment analysis?

  2. Q2.A batch of token IDs has shape (64, 200). What is the shape after nn.Embedding with embed_dim=100?

  3. Q3.In the mixed-review demo, why did the unigram TF-IDF model fail on 'the acting was great but the plot was dull'?

  4. Q4.Why pad every review in a batch to the same length?

  5. Q5.What is the main benefit of initializing nn.Embedding with pretrained GloVe vectors?

Exercise: Fix the mixed-review failure

Return to the evaluation PyRunner above and try to rescue the mixed-sentiment reviews without a neural network. Run three experiments: (1) switch the vectorizer to bigrams with ngram_range=(1, 2); (2) keep unigrams but triple the mixed examples by changing range(20) to range(60); (3) do both. For each, note the mixed-review score. Which change matters, and why do you need both?

Next up: a new module and a new goal — instead of predicting labels, we'll train networks that generate data, starting with autoencoders.