
摘要图像是空间数据文本是序列数据——处理文本需要不同的思维。这篇文章做一个完整的 NLP 实战项目用 LSTM 对电影评论进行情感分析正面/负面。我们从词嵌入、数据预处理讲到 LSTM 模型搭建、训练评估全部配有可运行代码。这是序列模型理论第 04 篇的代码实践版。一、项目概览任务定义输入这部电影太精彩了演员演技一流 输出正面 Positive 输入剧情无聊透顶浪费了我两个小时。 输出负面 Negative技术栈组件用途PyTorch深度学习框架TorchText文本数据加载与预处理LSTM核心序列建模词嵌入Embedding把词映射为向量IMDb 数据集5 万条电影评论正负各半NLP 处理流程 vs CV 处理流程图像分类 文本分类 像素矩阵 → 卷积层提取特征 → 全连接 → 输出 Token序列 → 词嵌入 → LSTM/RNN → 输出 空间局部性 序列时序性 固定尺寸输入 变长输入 平移不变性 顺序敏感性二、文本数据预处理文本数据不能直接输入神经网络——需要先转成数字。处理流水线原始文本 → 分词Tokenization → 构建词表Vocabulary → 序列化Numericalization → 填充Padding步骤 1IMDb 数据集加载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(fUsing: {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, encodingutf-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_size25000): 构建词表取最常见的 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_size25000) 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_len200): 把文本转换成等长序列 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_len200) print(f序列长度: {len(sample_seq)}) print(f前 10 个索引: {sample_seq[:10]}) print(f前 10 个词: {[idx2word[i] for i in sample_seq[:10]]})步骤 5封装为 Datasetclass IMDBDataset(Dataset): def __init__(self, texts, labels, word2idx, max_len200): 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], dtypetorch.long), \ torch.tensor(self.labels[idx], dtypetorch.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_sizebatch_size, shuffleTrue) test_loader DataLoader(test_dataset, batch_sizebatch_size, shuffleFalse) 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_embeddingsvocab_size, # 词表大小 25000 embedding_dim100, # 每个词用 100 维向量表示 padding_idx0 # 索引 0PAD对应的向量始终为 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(fEmbedding 参数量: {params:,}) # Embedding 参数量: 2,500,000 # → 占总模型参数的相当一部分相比卷积层四、LSTM 文本分类模型架构设计输入: [batch, seq_len200] —— 评论的索引序列 │ ▼ 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, dropout0.5): super().__init__() self.embedding nn.Embedding( vocab_size, embedding_dim, padding_idx0 ) self.lstm nn.LSTM( input_sizeembedding_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, # 输入形状: [batch, seq, features] dropoutdropout if num_layers 1 else 0, bidirectionalFalse, # 是否双向双向效果更好但参数翻倍 ) 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(dim1) # [batch, hidden_dim] # 方式 1 适合最终决策类任务如情感分类 # 方式 2 适合整体理解类任务如文本分类实例化模型model LSTMClassifier( vocab_size25000, embedding_dim100, hidden_dim128, num_layers2, dropout0.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(), lr0.001) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max10)训练函数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_norm5.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(fEpoch {epoch:2d} | fTrain Loss{train_loss:.3f} Acc{train_acc:.2f}% | fTest Loss{test_loss:.3f} Acc{test_acc:.2f}%) print(f\n✅ 最佳测试准确率: {best_acc:.2f}%)输出示例Epoch 1 | Train Loss0.536 Acc72.58% | Test Loss0.442 Acc79.65% Epoch 2 | Train Loss0.390 Acc82.18% | Test Loss0.377 Acc83.33% Epoch 3 | Train Loss0.312 Acc86.70% | Test Loss0.369 Acc84.07% Epoch 4 | Train Loss0.259 Acc89.29% | Test Loss0.342 Acc85.40% Epoch 5 | Train Loss0.212 Acc91.47% | Test Loss0.353 Acc85.87% Epoch 6 | Train Loss0.172 Acc93.33% | Test Loss0.389 Acc85.30% Epoch 7 | Train Loss0.141 Acc94.63% | Test Loss0.412 Acc85.43% Epoch 8 | Train Loss0.115 Acc95.80% | Test Loss0.440 Acc84.99% Epoch 9 | Train Loss0.094 Acc96.69% | Test Loss0.444 Acc85.17% Epoch 10 | Train Loss0.081 Acc97.30% | Test Loss0.502 Acc84.51% ✅ 最佳测试准确率: 85.87%结果分析训练准确率 97.30% | 测试准确率 85.87% ↑ ↑ 几乎记住了训练集 泛化到新数据 解读 - 第 5 个 epoch 之后测试准确率不再提升甚至略降 - 说明模型开始过拟合——可以用更多 Dropout 或更早停止 - 85% 的准确率在 2026 年的情感分析任务中是合理的基线六、推理用训练好的模型做预测def predict_sentiment(model, text, word2idx, max_len200, devicecpu): 预测单条评论的情感 model.eval() # 预处理 seq text_to_sequence(text, word2idx, max_len) tensor torch.tensor([seq], dtypetorch.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_dim100): 加载 GloVe 预训练词向量 embeddings np.random.randn(len(word2idx), embedding_dim) with open(glove_path, r, encodingutf-8) as f: for line in f: values line.split() word values[0] if word in word2idx: vector np.array(values[1:], dtypenp.float32) embeddings[word2idx[word]] vector return torch.tensor(embeddings, dtypetorch.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_sizeembedding_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, # ← 开启双向 dropoutdropout, ) # 双向 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_norm5.0) # 如果梯度的模超过 5就等比例缩小到 5序列长度的选择# 不同 max_len 的权衡 max_len100: 训练快但长评论信息丢失 max_len200: 大多数评论能覆盖推荐 max_len500: 信息更全但训练慢、占内存 # IMDb 评论的句子长度分布约 # 50% 的评论 150 词 # 80% 的评论 300 词 # 所以 max_len200 覆盖了大部分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(descendingTrue) # Pack 后输入 LSTM packed pack_padded_sequence(embedded, sorted_lengths.cpu(), batch_firstTrue) packed_output, (hidden, cell) self.lstm(packed) output, _ pad_packed_sequence(packed_output, batch_firstTrue)九、总结概念一句话词嵌入把词映射成有语义含义的连续向量LSTM有门控机制的 RNN能记住长距离依赖序列填充把不同长度的句子统一到相同长度梯度裁剪防止 RNN 训练中梯度爆炸的必需操作文本分类流程分词 → 词表 → Embedding → LSTM → 分类核心三句话文本数据必须先转成数字——分词 → 词表 → Embedding 是 NLP 的标准流水线LSTM 的门控机制让它能记住长距离依赖——比原始 RNN 有效得多梯度裁剪是 RNN 训练的必备操作——没有它训练随时可能发散这个实战项目的完整代码可以直接运行。用它作为起点可以轻松扩展到更复杂的 NLP 任务——文本分类、情感分析、垃圾邮件检测等。