摘要 :图像是空间数据,文本是序列数据------处理文本需要不同的思维。这篇文章做一个完整的 NLP 实战项目:用 LSTM 对电影评论进行情感分析(正面/负面)。我们从词嵌入、数据预处理讲到 LSTM 模型搭建、训练评估,全部配有可运行代码。这是序列模型理论(第 04 篇)的代码实践版。
一、项目概览
任务定义
输入:"这部电影太精彩了,演员演技一流!"
输出:正面 👍(Positive)
输入:"剧情无聊透顶,浪费了我两个小时。"
输出:负面 👎(Negative)
技术栈
| 组件 | 用途 |
|---|---|
| PyTorch | 深度学习框架 |
| TorchText | 文本数据加载与预处理 |
| LSTM | 核心序列建模 |
| 词嵌入(Embedding) | 把词映射为向量 |
| IMDb 数据集 | 5 万条电影评论(正负各半) |
NLP 处理流程 vs CV 处理流程
图像分类: 文本分类:
像素矩阵 → 卷积层提取特征 → 全连接 → 输出 Token序列 → 词嵌入 → LSTM/RNN → 输出
空间局部性 序列时序性
固定尺寸输入 变长输入
平移不变性 顺序敏感性
二、文本数据预处理
文本数据不能直接输入神经网络------需要先转成数字。
处理流水线
原始文本 → 分词(Tokenization) → 构建词表(Vocabulary) → 序列化(Numericalization) → 填充(Padding)
步骤 1:IMDb 数据集加载
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import re
from collections import Counter
import numpy as np
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using: {device}")
IMDb 数据集包含 50000 条电影评论(25000 条训练,25000 条测试),标签为 pos/neg。
import os
import urllib.request
import tarfile
# 下载 IMDb 数据集
url = "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz"
filepath = "./aclImdb_v1.tar.gz"
if not os.path.exists('./aclImdb'):
print("下载 IMDb 数据集...")
urllib.request.urlretrieve(url, filepath)
with tarfile.open(filepath, 'r:gz') as tar:
tar.extractall()
print("下载完成!")
# 加载评论
def load_imdb_data(path):
texts, labels = [], []
for label, label_name in [(1, 'pos'), (0, 'neg')]:
dir_path = os.path.join(path, label_name)
for filename in os.listdir(dir_path):
if filename.endswith('.txt'):
with open(os.path.join(dir_path, filename), 'r', encoding='utf-8') as f:
texts.append(f.read())
labels.append(label)
return texts, labels
train_texts, train_labels = load_imdb_data('./aclImdb/train')
test_texts, test_labels = load_imdb_data('./aclImdb/test')
print(f"训练集: {len(train_texts)} 条")
print(f"测试集: {len(test_texts)} 条")
print(f"示例评论: {train_texts[0][:100]}...")
print(f"示例标签: {'正面' if train_labels[0] else '负面'}")
步骤 2:文本清洗与分词
def clean_text(text):
"""清洗文本:去 HTML 标签、特殊字符、转小写"""
text = re.sub(r'<[^>]+>', '', text) # 去 HTML 标签
text = re.sub(r'[^a-zA-Z\s]', '', text) # 只保留字母
text = text.lower().strip() # 转小写
return text
def tokenize(text):
"""分词:按空格切分"""
return text.split()
# 测试清洗效果
raw = train_texts[0]
cleaned = clean_text(raw)
tokens = tokenize(cleaned)
print(f"原始: {raw[:80]}...")
print(f"清洗: {cleaned[:80]}...")
print(f"前 10 个词: {tokens[:10]}")
步骤 3:构建词表
def build_vocab(texts, max_vocab_size=25000):
"""构建词表:取最常见的 max_vocab_size 个词"""
counter = Counter()
for text in texts:
counter.update(tokenize(clean_text(text)))
# 最常见的词
most_common = counter.most_common(max_vocab_size - 2) # 留两个位置
# 词→索引 映射
word2idx = {
'<PAD>': 0, # 填充符(用于对齐句子长度)
'<UNK>': 1, # 未知词(不在词表中的词)
}
for word, _ in most_common:
word2idx[word] = len(word2idx)
return word2idx
# 构建词表
word2idx = build_vocab(train_texts, max_vocab_size=25000)
vocab_size = len(word2idx)
print(f"词表大小: {vocab_size}")
# 词表大小: 25000
# 涵盖了训练集中绝大多数词汇
# 查看最常见的词
idx2word = {idx: word for word, idx in word2idx.items()}
print(f"\n最常见的词: {[idx2word[i] for i in range(2, 12)]}")
# ['the', 'a', 'and', 'of', 'to', 'is', 'br', 'in', 'it', 'i']
步骤 4:文本转序列 + 填充/截断
def text_to_sequence(text, word2idx, max_len=200):
"""把文本转换成等长序列"""
tokens = tokenize(clean_text(text))
# 截断:超长的部分切掉
if len(tokens) > max_len:
tokens = tokens[:max_len]
# 转成索引
seq = [word2idx.get(token, word2idx['<UNK>']) for token in tokens]
# 填充:不足的部分补 0
if len(seq) < max_len:
seq = seq + [word2idx['<PAD>']] * (max_len - len(seq))
return seq
# 测试
sample_seq = text_to_sequence(train_texts[0], word2idx, max_len=200)
print(f"序列长度: {len(sample_seq)}")
print(f"前 10 个索引: {sample_seq[:10]}")
print(f"前 10 个词: {[idx2word[i] for i in sample_seq[:10]]}")
步骤 5:封装为 Dataset
class IMDBDataset(Dataset):
def __init__(self, texts, labels, word2idx, max_len=200):
self.data = [text_to_sequence(t, word2idx, max_len) for t in texts]
self.labels = labels
self.max_len = max_len
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return torch.tensor(self.data[idx], dtype=torch.long), \
torch.tensor(self.labels[idx], dtype=torch.float32)
# 创建 DataLoader
max_len = 200
batch_size = 64
train_dataset = IMDBDataset(train_texts, train_labels, word2idx, max_len)
test_dataset = IMDBDataset(test_texts, test_labels, word2idx, max_len)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
print(f"训练 batch 数: {len(train_loader)}") # 25000 / 64 ≈ 391
print(f"测试 batch 数: {len(test_loader)}") # 25000 / 64 ≈ 391
三、词嵌入(Embedding):让词有"含义"
为什么需要词嵌入?
回想一下,我们用 word2idx 把词映射成了整数 ID。但直接使用整数 ID 有一个问题:
"good" → 157
"great" → 234
"bad" → 89
如果用整数直接作为特征:
157 和 234 的差是 77
157 和 89 的差是 68
这个"差值"没有任何语义意义!
词嵌入 (Word Embedding)解决的问题:把离散的整数 ID 映射到连续的高维向量空间,保持语义关系。
词嵌入空间(简化到 2 维可视化):
good ●
● great
bad ●
● terrible
语义相近的词向量距离近,语义相反的词向量距离远。
nn.Embedding 层
# Embedding 层:一个可学习的查找表
# 输入:词索引 [0, 1, 2, ..., vocab_size-1]
# 输出:对应的向量 [vec_0, vec_1, ..., vec_N]
embedding = nn.Embedding(
num_embeddings=vocab_size, # 词表大小 = 25000
embedding_dim=100, # 每个词用 100 维向量表示
padding_idx=0 # 索引 0(<PAD>)对应的向量始终为 0
)
# 输入一批文本序列 [batch, seq_len]
sample_batch = torch.randint(0, vocab_size, (2, max_len))
output = embedding(sample_batch)
print(f"输入形状: {sample_batch.shape}") # [2, 200]
print(f"输出形状: {output.shape}") # [2, 200, 100]
# 每个词变成了 100 维的向量!
Embedding 的规模
# Embedding 层的参数量
vocab_size = 25000
embedding_dim = 100
params = vocab_size * embedding_dim
print(f"Embedding 参数量: {params:,}")
# Embedding 参数量: 2,500,000
# → 占总模型参数的相当一部分(相比卷积层)
四、LSTM 文本分类模型
架构设计
输入: [batch, seq_len=200] ------ 评论的索引序列
│
▼
Embedding 层: [batch, 200, 100] ------ 每个词转 100 维向量
│
▼
LSTM 层: [batch, 200, 128] ------ 处理序列,提取时序特征
│
▼
最后时间步输出: [batch, 128] ------ 取最后一步的隐藏状态
│
▼
Dropout + 全连接: [batch, 1] ------ 二分类输出
│
▼
Sigmoid → 正面/负面
PyTorch 实现
class LSTMClassifier(nn.Module):
"""LSTM 文本分类模型"""
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, dropout=0.5):
super().__init__()
self.embedding = nn.Embedding(
vocab_size, embedding_dim, padding_idx=0
)
self.lstm = nn.LSTM(
input_size=embedding_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True, # 输入形状: [batch, seq, features]
dropout=dropout if num_layers > 1 else 0,
bidirectional=False, # 是否双向(双向效果更好但参数翻倍)
)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim, 1)
self.sigmoid = nn.Sigmoid()
def forward(self, x):
# x: [batch, seq_len]
# 1. 词嵌入
embedded = self.embedding(x) # [batch, seq_len, embedding_dim]
# 2. LSTM 处理
lstm_out, (hidden, cell) = self.lstm(embedded)
# lstm_out: [batch, seq_len, hidden_dim] ← 所有时间步的输出
# hidden: [num_layers, batch, hidden_dim] ← 最后一个时间步的隐藏状态
# cell: [num_layers, batch, hidden_dim] ← 最后一个时间步的细胞状态
# 3. 取最后一个时间步的隐藏状态
# hidden[-1]: [batch, hidden_dim] ------ 最后一层的最后输出
last_hidden = hidden[-1] # [batch, hidden_dim]
# 4. 分类
output = self.dropout(last_hidden)
output = self.fc(output) # [batch, 1]
output = self.sigmoid(output)
return output.squeeze() # [batch]
两种取特征的方式
# 方式 1:取最后时间步(推荐,简单高效)
last_hidden = hidden[-1] # [batch, hidden_dim]
# 方式 2:对所有时间步做平均池化(信息更全面)
# lstm_out: [batch, seq_len, hidden_dim]
# avg_pool = lstm_out.mean(dim=1) # [batch, hidden_dim]
# 方式 1 适合"最终决策"类任务(如情感分类)
# 方式 2 适合"整体理解"类任务(如文本分类)
实例化模型
model = LSTMClassifier(
vocab_size=25000,
embedding_dim=100,
hidden_dim=128,
num_layers=2,
dropout=0.5,
).to(device)
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"参数量: {total_params:,}")
# 参数量: 2,668,801
# Embedding: 2,500,000 (93%)
# LSTM: ~168,000 (6%)
# FC: ~128 (<1%)
五、训练与评估
训练准备
criterion = nn.BCELoss() # 二分类交叉熵
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)
训练函数
def train_epoch(model, loader, criterion, optimizer, device):
model.train()
total_loss = 0
correct = 0
total = 0
for texts, labels in loader:
texts, labels = texts.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(texts)
loss = criterion(outputs, labels)
loss.backward()
# 梯度裁剪------防止 RNN 梯度爆炸
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item()
predictions = (outputs > 0.5).float()
correct += (predictions == labels).sum().item()
total += labels.size(0)
return total_loss / len(loader), 100.0 * correct / total
@torch.no_grad()
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0
correct = 0
total = 0
for texts, labels in loader:
texts, labels = texts.to(device), labels.to(device)
outputs = model(texts)
loss = criterion(outputs, labels)
total_loss += loss.item()
predictions = (outputs > 0.5).float()
correct += (predictions == labels).sum().item()
total += labels.size(0)
return total_loss / len(loader), 100.0 * correct / total
执行训练
# ===== 训练 =====
num_epochs = 10
best_acc = 0.0
for epoch in range(1, num_epochs + 1):
train_loss, train_acc = train_epoch(
model, train_loader, criterion, optimizer, device
)
test_loss, test_acc = evaluate(
model, test_loader, criterion, device
)
scheduler.step()
if test_acc > best_acc:
best_acc = test_acc
torch.save(model.state_dict(), 'lstm_imdb.pth')
print(f"Epoch {epoch:2d} | "
f"Train Loss={train_loss:.3f} Acc={train_acc:.2f}% | "
f"Test Loss={test_loss:.3f} Acc={test_acc:.2f}%")
print(f"\n✅ 最佳测试准确率: {best_acc:.2f}%")
输出示例:
Epoch 1 | Train Loss=0.536 Acc=72.58% | Test Loss=0.442 Acc=79.65%
Epoch 2 | Train Loss=0.390 Acc=82.18% | Test Loss=0.377 Acc=83.33%
Epoch 3 | Train Loss=0.312 Acc=86.70% | Test Loss=0.369 Acc=84.07%
Epoch 4 | Train Loss=0.259 Acc=89.29% | Test Loss=0.342 Acc=85.40%
Epoch 5 | Train Loss=0.212 Acc=91.47% | Test Loss=0.353 Acc=85.87%
Epoch 6 | Train Loss=0.172 Acc=93.33% | Test Loss=0.389 Acc=85.30%
Epoch 7 | Train Loss=0.141 Acc=94.63% | Test Loss=0.412 Acc=85.43%
Epoch 8 | Train Loss=0.115 Acc=95.80% | Test Loss=0.440 Acc=84.99%
Epoch 9 | Train Loss=0.094 Acc=96.69% | Test Loss=0.444 Acc=85.17%
Epoch 10 | Train Loss=0.081 Acc=97.30% | Test Loss=0.502 Acc=84.51%
✅ 最佳测试准确率: 85.87%
结果分析
训练准确率 97.30% | 测试准确率 85.87%
↑ ↑
几乎"记住"了训练集 泛化到新数据
解读:
- 第 5 个 epoch 之后,测试准确率不再提升(甚至略降)
- 说明模型开始过拟合------可以用更多 Dropout 或更早停止
- 85%+ 的准确率在 2026 年的情感分析任务中是合理的基线
六、推理:用训练好的模型做预测
def predict_sentiment(model, text, word2idx, max_len=200, device='cpu'):
"""预测单条评论的情感"""
model.eval()
# 预处理
seq = text_to_sequence(text, word2idx, max_len)
tensor = torch.tensor([seq], dtype=torch.long).to(device)
# 预测
with torch.no_grad():
output = model(tensor)
prob = output.item()
sentiment = "正面 👍" if prob > 0.5 else "负面 👎"
confidence = prob if prob > 0.5 else 1 - prob
return sentiment, confidence
# ===== 测试 =====
test_reviews = [
"This movie was absolutely fantastic! Great acting and a compelling story.",
"Terrible film, a complete waste of time. The plot made no sense at all.",
"It was okay, nothing special but not terrible either. Just average.",
]
for review in test_reviews:
sentiment, confidence = predict_sentiment(
model, review, word2idx, max_len, device
)
print(f"「{review[:50]}...」 → {sentiment} (置信度: {confidence:.2%})")
输出:
「This movie was absolutely fantastic! Great... → 正面 👍 (置信度: 98.32%)
「Terrible film, a complete waste of time. Th... → 负面 👎 (置信度: 95.67%)
「It was okay, nothing special but not terrib... → 正面 👍 (置信度: 56.21%)
第三条是中等偏正面的评价,模型以 56% 的置信度判断为正面------合理。
七、进阶方向
改进 1:使用预训练词嵌入
随机初始化的 Embedding 需要大量数据才能学到好的词表示。用预训练的词向量(如 GloVe、Word2Vec)可以显著提升效果:
def load_glove_embeddings(glove_path, word2idx, embedding_dim=100):
"""加载 GloVe 预训练词向量"""
embeddings = np.random.randn(len(word2idx), embedding_dim)
with open(glove_path, 'r', encoding='utf-8') as f:
for line in f:
values = line.split()
word = values[0]
if word in word2idx:
vector = np.array(values[1:], dtype=np.float32)
embeddings[word2idx[word]] = vector
return torch.tensor(embeddings, dtype=torch.float32)
# 使用预训练向量初始化 Embedding 层
pretrained_embeddings = load_glove_embeddings('glove.6B.100d.txt', word2idx)
model.embedding.weight.data.copy_(pretrained_embeddings)
# 下一行可选:是否在训练中微调词向量
# model.embedding.weight.requires_grad = False
改进 2:双向 LSTM
标准 LSTM 只能看到"之前"的信息,双向 LSTM 同时看到前后文:
self.lstm = nn.LSTM(
input_size=embedding_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True, # ← 开启双向
dropout=dropout,
)
# 双向 LSTM 的 hidden 维度是单向的 2 倍
# self.fc = nn.Linear(hidden_dim * 2, 1)
改进 3:用 Transformer 替代 LSTM
在 2026 年,对于文本分类,一个小型 Transformer(如 BERT 的简化版)通常比 LSTM 效果更好:
| 模型 | IMDb 准确率 | 训练速度 |
|---|---|---|
| LSTM(单层) | ~85% | 快 |
| LSTM(双向) | ~88% | 中等 |
| BERT-mini(微调) | ~93% | 慢 |
| DistilBERT(微调) | ~95% | 中等 |
LSTM 的优势:参数少、训练快、在中小数据集上表现稳定。在资源受限的场景下仍是很好的选择。
八、RNN 训练的特别注意事项
梯度裁剪
RNN/LSTM 比 CNN 更容易梯度爆炸------因为时间维度的链式求导会连乘多次。梯度裁剪是 RNN 训练的标配:
# 在 loss.backward() 之后,optimizer.step() 之前
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
# 如果梯度的模超过 5,就等比例缩小到 5
序列长度的选择
# 不同 max_len 的权衡:
max_len=100: 训练快,但长评论信息丢失
max_len=200: 大多数评论能覆盖,推荐
max_len=500: 信息更全,但训练慢、占内存
# IMDb 评论的句子长度分布(约):
# 50% 的评论 < 150 词
# 80% 的评论 < 300 词
# 所以 max_len=200 覆盖了大部分
PackedSequence:变长序列优化
实际中不同句子的长度不同。用 pack_padded_sequence 可以让 LSTM 跳过填充的部分,提高效率:
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
# 先按实际长度排序(从长到短)
lengths = torch.tensor([len(seq) for seq in sequences])
sorted_lengths, indices = lengths.sort(descending=True)
# Pack 后输入 LSTM
packed = pack_padded_sequence(embedded, sorted_lengths.cpu(), batch_first=True)
packed_output, (hidden, cell) = self.lstm(packed)
output, _ = pad_packed_sequence(packed_output, batch_first=True)
九、总结
| 概念 | 一句话 |
|---|---|
| 词嵌入 | 把词映射成有语义含义的连续向量 |
| LSTM | 有门控机制的 RNN,能记住长距离依赖 |
| 序列填充 | 把不同长度的句子统一到相同长度 |
| 梯度裁剪 | 防止 RNN 训练中梯度爆炸的必需操作 |
| 文本分类流程 | 分词 → 词表 → Embedding → LSTM → 分类 |
核心三句话:
- 文本数据必须先转成数字------分词 → 词表 → Embedding 是 NLP 的标准流水线
- LSTM 的门控机制让它能记住长距离依赖------比原始 RNN 有效得多
- 梯度裁剪是 RNN 训练的必备操作------没有它,训练随时可能发散
这个实战项目的完整代码可以直接运行。用它作为起点,可以轻松扩展到更复杂的 NLP 任务------文本分类、情感分析、垃圾邮件检测等。