TAI BUI
Back to curriculum
BuildPython

CNNs and RNNs for Text

Convolutions learn n-grams. Recurrences remember. Both are superseded by attention. Both still matter on constrained hardware.

TIME: ~75 minutesPREREQUISITES: Phase 3 · 11 (PyTorch Intro), Phase 5 · 03 (Word Embeddings), Phase 4 · 02 (Convolutions from Scratch)

Learning Objectives

  • Explain what a width-k convolutional filter and global max-pooling learn in a TextCNN, and why max-pooling gives a fixed-size, position-invariant representation.
  • Write the RNN hidden-state recurrence, explain the vanishing-gradient problem in plain RNNs, and explain how LSTM gating, GRUs, and bidirectional RNNs address it.
  • Identify the three limitations of RNN/LSTM encoder-decoders (the sequential bottleneck, the fixed-size context vector, and the distant-dependency ceiling) that motivated attention.
  • Decide when a TextCNN, BiLSTM, or a frozen-transformer-encoder-plus-small-head architecture still beats fine-tuning a full transformer, for constraints like edge deployment, streaming inference, or small labeled datasets.

The Problem

TF-IDF and Word2Vec produced flat vectors that ignored word order. A classifier built on them could not tell dog bites man from man bites dog. Word order sometimes carries the signal.

Two families of architectures filled that gap before transformers arrived.

Convolutional nets for text (TextCNN). Apply 1D convolutions over sequences of word embeddings. A filter of width 3 is a learnable trigram detector: it spans three words and outputs a score. Stack different widths (2, 3, 4, 5) to detect multi-scale patterns. Max-pool to a fixed-size representation. Flat, parallel, fast.

Recurrent nets (RNN, LSTM, GRU). Process tokens one at a time, maintaining a hidden state that carries information forward. Sequential, memory-bearing, flexible input lengths. Dominated sequence modeling from 2014 to 2017, then attention happened.

This lesson builds both, then names the failure that motivated attention.

Warm-up0/4
1. If you read a long sentence one word at a time and try to keep every earlier word equally fresh in memory by the time you reach the end, what usually happens in practice?Warm-up
2. A text classifier represents each sentence only by which words it contains, ignoring order. Which pair of sentences can it never tell apart?Warm-up
3. In PyTorch, what does setting `requires_grad = False` on a model's parameters do during training?Warm-up
4. A 2D convolutional filter slides across an image. What is true of the filter's weights as it moves?Warm-up
Check: The Problem0/1
1. Why could a classifier built on TF-IDF or Word2Vec vectors not tell `dog bites man` from `man bites dog`?Explain

The Concept

TextCNN (Kim, 2014). Tokens get embedded. A width-k 1D convolution slides a filter over consecutive k-grams of embeddings, producing a feature map. Global max-pooling over that map picks the strongest activation. Concatenate max-pooled outputs from several filter widths. Feed to a classifier head.

Why it works. A filter is a learnable n-gram. Max-pooling is position-invariant, so "not good" fires the same feature at the start or middle of a review. Three filter widths with 100 filters each gives you 300 learned n-gram detectors. Training is parallel; no sequential dependency.

RNN. At each time step t, the hidden state h_t = f(W * x_t + U * h_{t-1} + b). Share W, U, b across time. The hidden state at time T is a summary of the entire prefix. For classification, pool across h_1 ... h_T (max, mean, or last).

Plain RNNs suffer vanishing gradients. The LSTM adds gates that decide what to forget, what to store, and what to output, stabilizing gradients through long sequences. The GRU simplifies LSTM to two gates and performs similarly with fewer parameters.

Bidirectional RNNs run one RNN forward and another backward, concatenating hidden states. Every token's representation sees both left and right context. Essential for tagging tasks.

Check: The Concept0/10
1. What does a width-3 filter in a TextCNN learn to detect?Define
2. What is a TextCNN?Define
3. What is global max-pooling over a TextCNN feature map?Define
4. What is the RNN hidden-state update formula given in the lesson?Define
5. What does an LSTM add to a plain RNN?Define
6. How does the lesson describe the GRU relative to the LSTM?Define
7. What is a bidirectional RNN?Define
8. Why does TextCNN use global max-pooling after the convolutional layer?Explain
9. Why does a TextCNN use filters of several widths, such as 2, 3, 4, and 5?Explain
10. What is the main advantage of a bidirectional RNN over a one-way RNN for sequence labeling?Explain

Build It

Step 1: TextCNN in PyTorch

python
import torch
import torch.nn as nn
import torch.nn.functional as F
 
 
class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, n_classes, filter_widths=(2, 3, 4), n_filters=64, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.convs = nn.ModuleList([
            nn.Conv1d(embed_dim, n_filters, kernel_size=k)
            for k in filter_widths
        ])
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)
 
    def forward(self, token_ids):
        x = self.embed(token_ids).transpose(1, 2)
        pooled = []
        for conv in self.convs:
            c = F.relu(conv(x))
            p = F.max_pool1d(c, c.size(2)).squeeze(2)
            pooled.append(p)
        h = torch.cat(pooled, dim=1)
        return self.fc(self.dropout(h))

The transpose(1, 2) reshapes [batch, seq_len, embed_dim] to [batch, embed_dim, seq_len] because nn.Conv1d treats the middle axis as channels. The pooled output is fixed-size regardless of input length.

Step 2: LSTM classifier

python
class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, bidirectional=True, dropout=0.3):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=bidirectional)
        factor = 2 if bidirectional else 1
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim * factor, n_classes)
 
    def forward(self, token_ids):
        x = self.embed(token_ids)
        out, _ = self.lstm(x)
        pooled = out.max(dim=1).values
        return self.fc(self.dropout(pooled))

Max-pool over the sequence rather than the last hidden state. For classification, max-pooling usually wins because information at the end of a long sequence tends to dominate whatever that last state captures.

Step 3: the vanishing gradient demo (intuition)

A plain RNN without gating cannot learn long-range dependencies. Consider a toy task: predict whether token A appeared anywhere in a sequence. If A is at position 1 and the sequence is 100 tokens long, the gradient from the loss has to flow back through 99 multiplications of the recurrent weight. If the weight is less than 1, the gradient vanishes. If more than 1, it explodes.

python
def vanishing_gradient_sim(seq_len, recurrent_weight=0.9):
    import math
    return math.pow(recurrent_weight, seq_len)
 
 
# At weight=0.9 over 100 steps:
#   0.9 ^ 100 ≈ 2.7e-5
# The gradient from step 100 to step 1 is effectively zero.

LSTMs fix this with a cell state that runs through the network with only additive interactions (the forget gate scales it multiplicatively, but gradients still flow along the "highway"). GRUs do something similar with fewer parameters. Both give you stable training through 100+ step sequences.

Step 4: why this still was not enough

Three problems persisted even with LSTMs.

  1. Sequential bottleneck. Training an RNN on a sequence of length 1000 requires 1000 serial forward/backward steps. Cannot parallelize across time.
  2. Fixed-size context vector in encoder-decoder setups. The decoder sees only the final hidden state of the encoder, compressed over the entire input. Long inputs lose detail. Lesson 09 covers this directly.
  3. Distant-dependency accuracy ceiling. LSTMs outperform plain RNNs but still struggle to propagate specific information across 200+ steps.

Attention solved all three. Transformers dropped recurrence entirely. Lesson 10 is the pivot.

Check: Build It0/17
1. What is the vanishing gradient problem in plain RNNs?Define
2. Why can an RNN not be trained in parallel across the time steps of one sequence?Explain
3. How does an LSTM's cell state help gradients survive long sequences?Explain
4. Why does max-pooling over an LSTM's outputs often beat using just the last hidden state for classification?Explain
5. Why does TextCNN call `transpose(1, 2)` on the embeddings before the convolutions?Explain
6. Which limitation of LSTM/RNN encoder-decoders motivated attention?Explain
7. How do LSTMs compare with plain RNNs on very distant dependencies?Explain
8. Using the default arguments `filter_widths=(2, 3, 4)` and `n_filters=64`, how many input features does the final `nn.Linear` of `TextCNN` take?Apply
9. In `TextCNN.forward`, a batch of 8 sentences of any length passes one convolution with 64 filters, then `max_pool1d` and `squeeze(2)`. What shape is `p`?Apply
10. `LSTMClassifier(vocab_size, embed_dim, hidden_dim=128, n_classes)` is built with the default `bidirectional=True`. What is the `in_features` of its `fc` layer?Apply
11. What does `vanishing_gradient_sim(100, recurrent_weight=0.9)` return, approximately?Apply
12. What happens to the result of `vanishing_gradient_sim(100, recurrent_weight=1.1)` compared with the 0.9 case?Apply
13. A plain RNN learns to detect token `A` when it is among the last few tokens, but never when `A` is at position 1 of a 100-token sequence. What is the diagnosis?Analyze
14. A one-way RNN tagger mislabels a word whose correct tag depends on the word that follows it, while it handles tags that depend on earlier words. What should change?Analyze
15. An LSTM encoder-decoder translates short sentences well, but quality drops steadily as input length grows. Which limitation explains this most directly?Analyze
16. A developer builds `LSTMClassifier` with `bidirectional=True` but changes the last layer to `nn.Linear(hidden_dim, n_classes)`. What goes wrong?Analyze
17. A developer drops `transpose(1, 2)` from `TextCNN.forward` and feeds the `[batch, seq_len, embed_dim]` embeddings straight into `nn.Conv1d`. What is the likely failure?Analyze

Use It

PyTorch's nn.LSTM, nn.GRU, and nn.Conv1d are production-ready. Training code is standard.

Hugging Face ships pretrained embeddings you plug in as the input layer:

python
from transformers import AutoModel
 
encoder = AutoModel.from_pretrained("bert-base-uncased")
for param in encoder.parameters():
    param.requires_grad = False
 
 
class BertCNN(nn.Module):
    def __init__(self, n_classes, filter_widths=(2, 3, 4), n_filters=64):
        super().__init__()
        self.encoder = encoder
        self.convs = nn.ModuleList([nn.Conv1d(768, n_filters, kernel_size=k) for k in filter_widths])
        self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)
 
    def forward(self, input_ids, attention_mask):
        with torch.no_grad():
            out = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
        x = out.transpose(1, 2)
        pooled = [F.max_pool1d(F.relu(conv(x)), kernel_size=conv(x).size(2)).squeeze(2) for conv in self.convs]
        return self.fc(torch.cat(pooled, dim=1))

Four situations where TextCNN or an RNN still beats reaching for a transformer:

  • Edge / on-device inference. TextCNN with GloVe embeddings is 10-100x smaller than a transformer. If your deploy target is a phone, this is the stack.
  • Streaming / online classification. RNN processes one token at a time; transformers need the full sequence. For real-time incoming text, LSTMs still win.
  • Tiny models for baselines. Fast iteration on a new task. Train a TextCNN in 5 minutes on a CPU.
  • Sequence labeling with limited data. BiLSTM-CRF (lesson 06) is still a production-grade NER architecture for 1k-10k labeled sentences.

Everything else goes to a transformer.

Check: Use It0/5
1. When does a TextCNN or BiLSTM still beat a transformer in 2026?Explain
2. In the lesson's `BertCNN` example, which parameters change during training?Apply
3. You must ship a short-text classifier inside a phone app that runs fully on-device with a small footprint. Which design fits best?Design
4. You must classify live text that arrives one token at a time and update the prediction as each token comes in. Which approach fits?Design
5. You have 5,000 labeled sentences for a new named-entity tagging task and want a production-grade model. Which design fits?Design

Ship It

Save as outputs/prompt-text-encoder-picker.md:

markdown
---
name: text-encoder-picker
description: Pick a text encoder architecture for a given constraint set.
phase: 5
lesson: 08
---
 
Given constraints (task, data volume, latency budget, deploy target, compute budget), output:
 
1. Encoder architecture: TextCNN, BiLSTM, BiLSTM-CRF, transformer fine-tune, or "use a pretrained transformer as a frozen encoder + small head".
2. Embedding input: random init, GloVe / fastText frozen, or contextualized transformer embeddings.
3. Training recipe in 5 lines: optimizer, learning rate, batch size, epochs, regularization.
4. One monitoring signal. For RNN/CNN models: attention mechanism absence means they miss long-range deps; check per-length accuracy. For transformers: fine-tuning collapse if LR too high; check train loss.
 
Refuse to recommend fine-tuning a transformer when data is under ~500 labeled examples without showing that a TextCNN / BiLSTM baseline has plateaued. Flag edge deployment as needing architecture-before-everything.
Check: Ship It0/1
1. A team asks the text-encoder-picker for a transformer fine-tune on 300 labeled examples and has no baseline. What does the spec tell it to do?Apply
Author's Note

I reach for a TextCNN more often than you'd expect from someone teaching a phase that ends in attention. Every team I've watched skip straight to fine-tuning a transformer on a few hundred labeled examples hit the same wall: the real bottleneck was label noise, not architecture, and a five-minute TextCNN baseline would have shown that before anyone burned a GPU-day chasing the wrong fix.

Treat this lesson as a diagnostic tool, not just history. If your BiLSTM baseline plateaus at the same accuracy as your transformer fine-tune, the ceiling isn't the architecture. It's the data.

Exercises

  1. Easy. Train a TextCNN on a 3-class toy dataset (you invent the data). Verify that filter widths (2, 3, 4) outperform a single width (3) on average F1.
  2. Medium. Implement max-pool, mean-pool, and last-state pooling for the LSTM classifier. Compare on a small dataset; document which pooling wins and hypothesize why.
  3. Hard. Build a BiLSTM-CRF NER tagger (combine lesson 06 and this one). Train on CoNLL-2003. Compare to the CRF-alone baseline from lesson 06 and to a BERT fine-tune. Report training time, memory, and F1.

Key Terms

TermWhat people sayWhat it actually means
TextCNNCNN for textStack of 1D convolutions over word embeddings with global max-pool. Kim (2014).
RNNRecurrent netHidden state updated at each time step: h_t = f(W x_t + U h_{t-1}).
LSTMGated RNNAdds input / forget / output gates + a cell state. Trains stably through long sequences.
GRUSimpler LSTMTwo gates instead of three. Similar accuracy, fewer parameters.
BidirectionalBoth directionsForward + backward RNN concatenated. Every token sees both sides of its context.
Vanishing gradientTraining signal diesRepeated multiplication by <1 weights in plain RNNs makes early-step gradients effectively zero.

Further Reading

Code

Ships With