简介:这是一份基于LSTM的中文电影评论情感倾向分析代码工程,专为自然语言处理初学者和毕业设计场景打造。项目以“输入评论文本→模型→正向/负向”为主线,数据已预先分词并打好标签,包含data_manager.py数据处理、mylstm.py中词向量映射、两层RNN循环层与全连接分类层的网络构建,以及train.py多轮迭代训练和predict_demo.py加载模型进行预测的完整实现,配套readme便于理解从语料预处理到情感分类输出的全流程。
资源包共11个文件,以6个Python源码为核心,配合3个txt文本数据、1个word2id.json词表和1个Markdown说明,压缩后仅3.43MB,体量轻且目录清晰,可快速定位训练、预测和网络定义模块。已有2315人学习使用,作为RNN/LSTM入门参照或毕业设计基础,既能帮助理解前向计算、误差反向传播与权重更新等训练细节,也能直接下载代码进行改造实验,整体上手门槛较低。
1. 这个项目到底在做什么:LSTM电影评论情感分析给初学者的价值
很多初学 RNN 的人第一次动手,代码库里选的往往不是股票预测就是时间序列,但我觉得最适合入门的其实是电影评论情感倾向分析。它的数据是公开的 IMDB 影评,标签只有正负两种,评价指标直接看准确率,模型结构用单层或双层 LSTM 就能讲清楚。你不需要理解复杂的业务背景,只需要回答一个问题:一条一二百词的英文评论,到底是夸还是骂。这个任务能同时覆盖文本预处理、词向量、循环神经网络和训练流程,四年毕业设计里一大半人会用到它。更关键的是,LSTM 在这里能让你亲眼看到普通 RNN 处理长文本时梯度消失的问题是怎么被门控机制救回来的,这是教科书里最容易看懵、但代码里最容易验证的知识点。
2. 从词向量到 LSTM:先把输入数据和模型结构盘清楚
2.1 IMDB 数据集的目录结构:train 与 test、pos 与 neg
做这个项目的第一步不是写模型,而是学会怎么看数据集。常见的 IMDB 电影评论数据集是 aclImdb,解压后目录结构是:
aclImdb/ ├── train/ │ ├── pos/ │ ├── neg/ │ └── unsup/ └── test/ ├── pos/ └── neg/每个 pos 或 neg 文件夹里是纯文本文件,一个文件就是一条评论。train 下正负各 12500 条,test 下正负也各 12500 条。unsup 是没有标签的评论,我们做监督学习不用管它。理解这个结构很重要,因为后续所有数据处理代码都要依赖这个固定路径,很多初学者把文件自己转成 CSV 之后反而把路径逻辑复杂化了,最后读数据时漏读一半。
我一般不会去改原始结构,而是写一个函数遍历目录,把文件名和内容读进来。你需要记住一个原则:pos目录里的文件全部对应标签 1,neg目录里的文件全部对应标签 0。这个映射关系在后面的 Dataset 类里要写清楚,否则训练时看着 loss 在降,实际学的是一个错乱的标签。
2.2 RNN 的瓶颈和 LSTM 的门控机制:为什么需要两个记忆
普通 RNN 在每一步把隐藏状态h_t传给下一步,公式抽象出来就是:
h_t = tanh(W · [h_{t-1}, x_t] + b)这种结构在序列短的时候没问题,但电影评论动不动就是一两百个词。关键信息出现在第 10 个词,要等到第 200 个词才做分类,信息在经过这么多步的矩阵乘法后,梯度要么膨胀要么消失。膨胀还能用梯度裁剪压一下,消失基本就是模型什么都学不到。这就是常说的长期依赖问题。LSTM 的解决办法是额外引入一条“细胞状态”C_t,它像一条传送带,靠三个门来控制哪些信息要忘掉、哪些要存进去、哪些要输出。
i_t = sigmoid(W_i · [h_{t-1}, x_t] + b_i) f_t = sigmoid(W_f · [h_{t-1}, x_t] + b_f) o_t = sigmoid(W_o · [h_{t-1}, x_t] + b_o) C_t = f_t * C_{t-1} + i_t * tanh(W_c · [h_{t-1}, x_t] + b_c) h_t = o_t * tanh(C_t)你不需要背公式,只需要理解一件事:f_t这个遗忘门是一个 0 到 1 之间的数,如果它接近 1,旧的细胞状态就原样保留;如果接近 0,就放弃。这样梯度可以通过C_t这条直线路径传得很远。这也是为什么 LSTM 在文本分类这种中等长度序列上,比普通 RNN 稳定得多。初学者第一次看这六个公式容易害怕,但当你在 PyTorch 里只是调用nn.LSTM时,这些公式已经被封装好了,你需要做的是设置对参数。
2.3 Embedding 层:把单词索引变成可训练的向量
模型不能直接吃文本,常规做法是把每个单词映射成一个整数索引,比如the -> 5、movie -> 18。但整数本身没有语义信息,需要再通过一个 Embedding 层把索引变成固定维度的稠密向量。PyTorch 里这样定义:
embedding = nn.Embedding(vocab_size=20000, embedding_dim=128, padding_idx=0) lstm = nn.LSTM(input_size=128, hidden_size=128, num_layers=2, batch_first=True, bidirectional=False) classifier = nn.Linear(128, 2)逻辑说明:vocab_size=20000表示我们只保留词频最高的 2 万个单词,其余词统一用<unk>代替;embedding_dim=128是每个单词映射成的向量维度,维度太小表达力弱,太大训练慢;padding_idx=0告诉模型索引 0 对应的向量永远是 0,并且不参与梯度更新,用来处理变长序列;batch_first=True表示输入的形状是(batch, seq_len, embedding_dim),这个设置初学者经常漏,漏了之后代码会在训练时报一堆维度错位;classifier把最后一个时间步的隐藏状态压缩成 2 个数,分别代表负向和正向的得分。
这里如果你用双向 LSTM,输入到nn.Linear的维度要变成hidden_size * 2,很多人在这翻车。下面第三节我们先按单向单塔结构跑通最小版本。
3. 跑通第一个 LSTM 模型:PyTorch 实现的最小可复现代码
3.1 数据预处理:读取文件夹并构建词表
先把 IMDB 原始文本读进来,做小写化和简单分词。这里不调用任何分词库,直接用空格 split 就够了,因为英文评论的词都是空格分隔的,标点符号虽然混在词里,但对二分类任务影响不大。完整代码如下:
import os import torch from torch.utils.data import Dataset def read_imdb(data_dir): texts, labels = [], [] for label_name, label in [('pos', 1), ('neg', 0)]: folder = os.path.join(data_dir, label_name) for fname in os.listdir(folder): if not fname.endswith('.txt'): continue with open(os.path.join(folder, fname), 'r', encoding='utf-8') as f: text = f.read().strip() texts.append(text) labels.append(label) return texts, labels train_texts, train_labels = read_imdb('aclImdb/train') test_texts, test_labels = read_imdb('aclImdb/test') def tokenize(text): return text.lower().split() def build_vocab(texts, max_words=20000): from collections import Counter counter = Counter() for text in texts: counter.update(tokenize(text)) vocab = {'<pad>': 0, '<unk>': 1} for word, count in counter.most_common(max_words - 2): vocab[word] = len(vocab) return vocab vocab = build_vocab(train_texts) print('词表大小:', len(vocab))逻辑说明:read_imdb返回两个列表,一个存文本,一个存标签。build_vocab先用计数器统计所有词频,按从高到低取前max_words - 2个词,因为0和1位已经留给了填充符和未知词。这里的关键参数是max_words,如果训练集很小,建议把max_words设成 10000 而不是 20000,否则大量低频词会变成unk,模型学不到有效信息;如果训练集很大,设成 50000 反而会增加 Embedding 层的参数量,但对准确率提升有限。
3.2 实现 Dataset 和数据加载器
有了词表之后,需要把每条评论转成整数序列,同时把所有序列填充成一样长。PyTorch 推荐用自定义Dataset加collate_fn的方式来做,因为可以在每个 batch 内动态填充,避免把所有数据都填到全数据集最长序列的长度。
class ReviewDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=256): self.data = [] for text, label in zip(texts, labels): ids = [vocab.get(w, vocab['<unk>']) for w in tokenize(text)] if len(ids) > max_len: ids = ids[:max_len] self.data.append((ids, int(label))) self.max_len = max_len def __len__(self): return len(self.data) def __getitem__(self, idx): ids, label = self.data[idx] return ids, label def collate_batch(batch): ids_list, labels = zip(*batch) max_len_this_batch = max(len(ids) for ids in ids_list) padded, masks = [], [] for ids in ids_list: pad_len = max_len_this_batch - len(ids) padded.append(ids + [0] * pad_len) return torch.tensor(padded, dtype=torch.long), torch.tensor(labels, dtype=torch.long) train_dataset = ReviewDataset(train_texts, train_labels, vocab) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=64, shuffle=True, collate_fn=collate_batch )逻辑说明:__getitem__返回的是长度不一的整数列表和标签,真正把数据拼成矩阵的是collate_batch。它先找出当前 batch 内最长的序列,然后给其他序列补0。注意这里没有用max_len统一填充,因为一个 batch 里长度往往在几十到两百左右,动态填充能大幅降低计算浪费。batch_size=64是一个比较平衡的取值,显存小的机器可以降到 32,但不要高于 128,否则训练后期显存容易爆。
3.3 定义 LSTM 模型并跑一个训练 epoch
数据准备好之后,定义模型。为了给初学者看完整流程,我把模型定义、损失函数和训练循环写在一个脚本里,方便你直接复制运行:
import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=128, hidden_size=128, num_layers=2, num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0) self.classifier = nn.Linear(hidden_size, num_classes) def forward(self, x): emb = self.embedding(x) # (batch, seq_len, embed_dim) out, (h_n, c_n) = self.lstm(emb) # out: (batch, seq_len, hidden) last_hidden = h_n[-1] # 取最后一层最后时间步的隐藏状态 return self.classifier(last_hidden) model = LSTMClassifier(len(vocab)) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() # 训练一个 epoch 重复几次就能看到效果 for batch_ids, batch_labels in train_loader: optimizer.zero_grad() logits = model(batch_ids) loss = criterion(logits, batch_labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() print('loss:', loss.item())逻辑说明:nn.LSTM默认返回两个输出,out是所有时间步的隐藏状态,h_n是最后一层最后一步的隐藏状态。文本分类通常只用h_n[-1],因为理论上它编码了整条序列的信息。dropout=0.5只在层数大于 1 时生效,如果num_layers=1又传了 dropout,PyTorch 会给出警告但不报错。nn.utils.clip_grad_norm_是梯度裁剪,max_norm=5.0 的意思是如果所有参数的梯度范数超过 5,就缩小到 5,这是防止梯度爆炸最便宜的保险。
这段代码直接跑,大概率能在测试集得到 80% 以上的准确率。注意我故意没有在循环里加model.train()和model.eval(),因为上面只跑一个 epoch 不需要。正式训练时记得加上,否则 Dropout 在验证时会继续生效,导致验证集结果忽高忽低。
4. 调参和优化:让验证集准确率从 80% 往 85% 走的几个位置
4.1 序列长度 max_len:不是越长越好
很多初学者觉得电影评论越长信息越多,于是把max_len设成 500。实际上大部分电影评论在 200 个词以内,超过 256 的部分往往是客套话和重复描述,对分类标签的贡献很差,还会让模型注意力被分散。IMDB 数据集的平均词数大约在 230 左右,但分布很散。我做过实验,max_len=150和max_len=300的准确率差距不到 1 个百分点,但训练时间差了近一倍。所以第一版建议直接设 256,跑通之后可以做一个对比实验:分别测试 100、200、300,用验证集准确率来决定。
这里有个反直觉的点:截断要比填充更安全。如果一个评论超过max_len,直接截掉前 256 个词通常比截掉后 256 个词效果略好,因为电影评论的表达习惯是开头给出总评价,后面是具体论据。如果你想要可复现性,可以在预处理时固定从后向前截断,但不要在文档里模棱两可。
4.2 词向量维度与隐藏层大小:模型容量的甜点
模型的参数量主要由三部分组成:embedding_dim * vocab_size、LSTM 层的四个门权重、classifier 的输入维度。对于 IMDB 这种 2 万词表的中等规模任务,embedding_dim=128和hidden_size=128已经足够。把 hidden_size 从 128 涨到 256,参数量会翻四倍,但准确率往往只涨零点几个点。更常见的问题反而是欠拟合:num_layers=1的单层 LSTM 在这个任务上表现明显弱于双层,因为双层能构建更高层次的特征抽象。
一个经验性的参数起步表如下:
| 参数 | 建议初始值 | 可尝试范围 | 调整方向 |
|---|---|---|---|
| embedding_dim | 128 | 64 ~ 300 | 小语料用小的维度 |
| hidden_size | 128 | 64 ~ 256 | 看 loss 和准确率是否同步上升 |
| num_layers | 2 | 1 ~ 3 | 超过 3 层对文本分类收益很小 |
| max_len | 256 | 100 ~ 400 | 看数据的长度分布 |
| batch_size | 64 | 16 ~ 128 | 显存不足时优先调小 |
4.3 双向 LSTM、Dropout 和梯度裁剪:三个性价比最高的改进
双向 LSTM 能在每个时间步同时看到前后的上下文。对情感分类任务,一个典型的句式是“这部电影虽然剧情烂,但特效很棒”,单向模型读到“烂”的时候还不知道后面有转折,双向模型可以从后面“补”一个反向特征过来。改法非常小,只需要把模型的LSTM层改成bidirectional=True,然后把全连接层的输入维度从hidden_size变成hidden_size * 2。
Dropout 需要放在两个位置:Embedding 之后和 LSTM 输出之后。PyTorch 的nn.LSTM自带一个dropout参数,但注意它只作用于层与层之间,不作用于最后输出。如果想在最后输出加 Dropout,要在 forward 里手动用nn.Dropout:
class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=128, hidden_size=128, num_layers=2, num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers=num_layers, bidirectional=True, batch_first=True, dropout=dropout) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.dropout(self.embedding(x)) out, (h_n, c_n) = self.lstm(emb) # 双向时 h_n 形状是 (num_layers * 2, batch, hidden) # 取最后一层的前后向状态并拼接 last_hidden = torch.cat([h_n[-2], h_n[-1]], dim=1) return self.classifier(self.dropout(last_hidden))逻辑说明:h_n在双向模式下每个时间步包含两个方向,h_n[-2]是最后一层的正向输出,h_n[-1]是反向输出,拼接后送入分类器。dropout=self.dropout会让 LSTM 每层的输出和最终线性层的输入都做随机置零,有效防止过拟合。梯度裁剪那一行nn.utils.clip_grad_norm_建议保留,尤其在双向 LSTM 下,梯度爆炸的概率比单向更高。
调参的顺序也有讲究。先固定max_len=256、embedding_dim=128、num_layers=2,只把bidirectional打开,看验证集准确率能涨多少。如果从 80% 涨到 83%,说明模型需要上下文信息,下一步可以调hidden_size。如果没涨甚至掉了,说明你的数据量小,双向模型的参数量增加带来的过拟合超过了收益。
5. 避坑指南:初学者跑这个项目最容易翻车的 5 个问题
5.1 报错 "index out of range in self"
现象:训练刚开始,程序在self.embedding(x)处报IndexError: index out of range in self。
原因:某个词的索引超过了定义 Embedding 时指定的vocab_size。常见情况是训练时用了max_words=20000,但保存和加载模型时重建的词表大小变成了 20001 或更少,或者测试集里出现了训练词表之外的新词,没有映射到<unk>。
解决:在预处理阶段,读取每个测试文本时强制使用vocab.get(w, vocab['<unk>']),永远不要用vocab[w]。另外,在模型初始化时打印一下len(vocab),确认和 Embedding 层传入的第一个参数一致。我习惯把词表单独保存为 json 文件,每次运行都从 json 加载,避免因词表重建顺序不同导致索引错位。
5.2 训练 loss 下降但预测结果全是同一类
现象:loss 从 0.7 下降到 0.3,准确率也在上升,但用训练好的模型预测新评论时,所有输出都指向“负面”,或者都是“正面”。
原因:最后一层全连接初始化太差,或者类别不平衡处理不当。IMDB 本身是平衡的,问题往往出在nn.Linear随机初始化后,某一类的偏置太大。另一个更隐蔽的原因是模型只用了最后一个时间步的隐藏状态,而这个隐藏状态正好落在训练时某类样本占优的区域内。
解决:先检查测试集标签分布,确认不是数据问题。然后在forward里把最后时间步的隐藏状态换成所有时间步的平均值out.mean(dim=1),图像、文本分类里平均池化通常比只取最后一步更稳定。如果真的遇到初始化问题,可以在__init__末尾手动重置线性层权重:
nn.init.kaiming_uniform_(self.classifier.weight, a=1) nn.init.zeros_(self.classifier.bias)5.3 DataLoader 加载到一半报错 "stack expects each tensor to be equal size"
现象:第一个 batch 正常训练,第二个 batch 崩了,报错信息提示stack时张量尺寸不一致。
原因:collate_batch写的填充逻辑只考虑了当前 batch 的最大长度,但某个 batch 的ids_list是空的。这通常是因为原始文本被清洗后变成了空串,比如某条评论全是 HTML 标签,tokenize之后一个词都没有。
解决:在ReviewDataset.__init__里过滤掉len(ids) == 0的样本。更稳妥的做法是在 reading 阶段就把所有文本转成ids存起来,而不是等到__getitem__时才现场分词。另外,collate_batch里需要加一个判断,如果max_len_this_batch == 0,就返回一个全零张量。但最简单的方式是保证没有空样本。
5.4 GPU 显存不够,训练到一半爆掉
现象:前几个 epoch 没事,突然报CUDA out of memory,程序中断。
原因:LSTM 反向传播时需要保存每个时间步的中间梯度,序列越长、batch 越大,显存消耗呈线性增长。尤其是bidirectional=True之后,显存占用直接翻倍。另一个常见原因是代码里没有torch.cuda.empty_cache(),多个 epoch 之间的旧计算图没有被释放。
解决:把batch_size从 64 降到 32,或者把max_len从 256 降到 128。同时确认训练循环里每个 batch 都会optimizer.zero_grad(),否则上一个 batch 的梯度图会挂在计算图上,一直累积到爆显存。如果你用的是固定max_len的填充方式,可以考虑改为 batch 内动态填充,也就是 3.2 节里的做法,这能省下不少显存。
5.5 训练 5 个 epoch 后验证集准确率一直在 82% 上下不动
现象:loss 还在下降,但验证集准确率不再升高,甚至轻微下降。
原因:这是典型的过拟合信号。LSTM 的参数量远大于简单线性模型,IMDB 这种 2.5 万条训练数据在两层 LSTM 下很容易记住训练集模式。
解决:把 Dropout 从 0.5 提到 0.7,或者把num_layers从 2 降到 1,观察准确率变化。如果依然不动,检查验证集是否和训练集来自同一分布——很多人直接从训练集里随机切 10% 当验证集,但 IMDB 提供的 test 本身就适合当验证集。建议把larval项目里的标准做法拿过来:用train训练,用test做验证,不要动测试集。
6. 毕业设计怎么往下延伸:用训练好的模型做推断与可视化
6.1 保存和加载模型参数的完整写法
训练完之后不要整个模型序列化,而是只保存state_dict,这样模型结构变了也能继续复用权重:
torch.save(model.state_dict(), 'lstm_imdb.pt') model = BiLSTMClassifier(len(vocab)) model.load_state_dict(torch.load('lstm_imdb.pt', map_location='cpu')) model.eval()注意load_state_dict之前必须重新构造一个一模一样的模型结构,否则会报键名不匹配。保存时最好同时把词表一起存成 pickle 或 json,这样换机器时不用重新训练。
6.2 写一个 predict 函数:把一条新评论转成情感倾向
推断时的预处理必须和训练时完全一致,包括小写化、词表映射、截断和填充。我通常这样写:
def predict(text, model, vocab, max_len=256): model.eval() ids = [vocab.get(w, vocab['<unk>']) for w in tokenize(text)] ids = ids[:max_len] ids += [0] * (max_len - len(ids)) x = torch.tensor([ids], dtype=torch.long) with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) neg_score, pos_score = prob[0].tolist() return '正面' if pos_score > neg_score else '负面', {'neg': neg_score, 'pos': pos_score} print(predict("A masterpiece of storytelling.", model, vocab))这里有一个细节:我用了固定max_len填充而不是动态填充,因为单条推断不需要考虑 batch 内对齐,统一长度更省事。with torch.no_grad()是必须的,否则推断时也会构建计算图,白白消耗显存。
6.3 可视化 LSTM 的隐藏状态:给论文加图的常见操作
除了分类准确率,毕业设计里最有展示价值的是把 LSTM 的隐藏状态降维可视化。你可以取训练集中每条评论h_n[-1],得到 256 维向量,然后用 TSNE 降维到二维,按标签着色画散点图。如果正负样本在二维空间里明显聚成两簇,这张图放在论文里非常直观。如果你想更进一步,可以用每个时间步的out做注意力热力图,回头看模型到底关注评论里的哪些词。这一步做出来,你的毕业设计就已经不是“调包侠”,而是真正理解了 RNN 内部在干什么。
我自己的血泪经验是:所有训练和推断的随机数种子要在脚本开头固定,torch.manual_seed(42)和np.random.seed(42)一行都不能省。否则你昨天跑出 85%,今天换台机器跑同样的代码变成 83%,导师问起来你连解释的余地都没有,那才叫真正的翻车。希望这份代码骨架和调参笔记,能帮你少踩几个我已经踩过的坑。
本文还有配套的精品资源,点击获取