简介:基于LSTM的中文电影评论情感倾向分析项目,面向深度学习初学者、RNN模型研究者以及需要毕业设计参考的高校学生。该项目以评论语句为输入,经模型判断正向或负向,完整覆盖数据集加载与预处理、分词结果读取、词向量映射、两层RNN编码、全连接分类、批量训练、模型保存与预测等关键环节,能帮助读者在复现过程中理解词嵌入、LSTM状态传递、梯度计算与参数更新等核心概念。压缩包共11个文件,以Python源码为主(6个py),另含3个txt数据文件、1个md说明和1个json词表文件,整体仅3.43MB,轻量紧凑、目录清晰,按数据处理、模型构建、训练、预测四个模块展开。目前已有2315人学习使用;通过训练与预测脚本,可完整体验从词表构建、批处理训练到情感判定的流水线,并利用自备数据做小规模实验,对毕业设计或RNN入门项目具有直接的参考价值。
1. 电影评论情感分析入门:为什么第一个RNN项目选LSTM
拿到一批IMDB电影评论,要判断每句话是好评还是差评,这是NLP里最经典的文本二分类任务,也是初学者接触RNN模型最常见的切入点。LSTM在里面当主力:它能记住序列里靠前的信息,比普通RNN更耐得住长句子,代码量不大但课堂上学到的反向传播、词嵌入、梯度问题都能在跑通后对上号。用这个标题做毕业设计参考的,通常能拿到一条完整链路:影评文本清洗、词表构建、序列填充、LSTM模型训练、准确率评估,再跟CNN或双向LSTM做对比实验。本文就按这条链路,用PyTorch写一版能直接跑的情感分析代码,把参数、边界和踩坑交代清楚。适合想研究RNN又不知道从哪下手的人,也适合需要一个可扩展demo的毕业生。
2. 数据预处理与词表构建:把IMDB影评变成模型能读的整数序列
2.1 下载与读取IMDB数据集
IMDB影评集是情感分析最常用的公开数据集,里面每条评论带着pos或neg标签,分别对应正面和负面评价。训练集和测试集各两万条左右,数据量不大,CPU也能跑得动。常见做法是直接下载官方的tar.gz压缩包,解压后按目录结构读取,不依赖torchtext的版本接口,后面少踩很多坑。
import os import re import tarfile import urllib.request from collections import Counter import torch from torch.utils.data import Dataset, DataLoader import torch.nn as nn DATA_DIR = "./data" def download_imdb(data_dir=DATA_DIR): os.makedirs(data_dir, exist_ok=True) url = "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz" tar_path = os.path.join(data_dir, "aclImdb_v1.tar.gz") if not os.path.exists(tar_path): print("正在下载IMDB数据集,第一次需要一点时间...") urllib.request.urlretrieve(url, tar_path) extract_dir = os.path.join(data_dir, "aclImdb") if not os.path.exists(extract_dir): with tarfile.open(tar_path, "r:gz") as tar: tar.extractall(data_dir) return extract_dir这段代码先把压缩包下载到本地,解压后得到aclImdb目录,里面按train/pos、train/neg、test/pos、test/neg分成四个文件夹,每个文件是一条独立的评论。栅栏在于:如果压缩包已经存在,urllib不会断点续传,中途断网要删掉重下;解压判断用目录是否存在,目录不完整时也要手动删掉重来。第一次见这个数据集的同学,可以先打开一条文本看看原始格式,你会发现里面有HTML标签、大小写混排,这些东西必须清洗掉。
2.2 文本清洗、词表构建与<unk>兜底
原始评论不能直接喂给LSTM,模型只能吃数字。这里要做的有几步:去掉HTML标签和标点、统一小写、按空格分词,然后统计词频构建词表。词表一定要预留两个特殊位置:pad用0表示补长,unk用1表示词典外单词,否则测试时碰到没见过的词会直接索引越界。
def clean_text(text): # 去掉HTML标签,保留字母 text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"[^a-zA-Z]", " ", text) return text.lower().split() def build_vocab(texts, max_vocab=20000): counter = Counter() for text in texts: counter.update(clean_text(text)) # 预留2个位置给<pad>和<unk> vocab = [w for w, _ in counter.most_common(max_vocab - 2)] word2idx = {w: i + 2 for i, w in enumerate(vocab)} word2idx["<pad>"] = 0 word2idx["<unk>"] = 1 return word2idx这里max_vocab控制词表大小,取20000是让模型体积可控的常见值;如果你用的词表太大,Embedding层参数会暴涨,训练速度明显变慢。大多数英语评论文本的核心词汇量就在一两万级别,超过这个数的都是低频生僻词,跟着unk走了也不影响效果。要注意的是,词表必须完全基于训练集构建,不能把测试集数据算进来,否则就属于数据泄漏,论文答辩时被问到会很麻烦。
2.3 定长序列与Dataset封装
LSTM虽然能处理变长序列,但在批次训练时必须把同batch的句子拼成矩形tensor,所以要做截断和填充。评论短的几十词,长的上千词,这里设max_len=256,超过的截掉尾部,不足的用0补齐。注意截断策略有讲究:IMDB评论的重要情感词往往分布在句首和句尾,新手常犯的错误是直接截断尾部导致结论丢失,常见做法是保留开头部分,把尾部超长内容截断。
def encode(text, word2idx, max_len=256): ids = [word2idx.get(w, word2idx["<unk>"]) for w in clean_text(text)] if len(ids) > max_len: ids = ids[:max_len] else: ids += [word2idx["<pad>"]] * (max_len - len(ids)) return ids class IMDBDataset(Dataset): def __init__(self, data_dir, split="train", word2idx=None, max_len=256, max_vocab=20000): self.max_len = max_len texts, labels = [], [] for label, sub in [(1, "pos"), (0, "neg")]: folder = os.path.join(data_dir, split, sub) for fname in sorted(os.listdir(folder)): with open(os.path.join(folder, fname), "r", encoding="utf-8") as f: texts.append(f.read()) labels.append(float(label)) if word2idx is None: self.word2idx = build_vocab(texts, max_vocab) else: self.word2idx = word2idx self.texts = texts self.labels = labels def __len__(self): return len(self.texts) def __getitem__(self, idx): ids = encode(self.texts[idx], self.word2idx, self.max_len) return torch.tensor(ids, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.float)标签用float是因为后面用BCEWithLogitsLoss做二分类。word2idx如果传了训练集的进来,测试集就能保持同一个词表,这是工程里容易漏的细节。建议在main里这样组织:先建训练集得到词表,再用同一个词表建验证集和测试集,保证PAD、UNK、词频排序完全一致。DataLoader里设shuffle=True打乱训练顺序,batch取64或128都行,初学者从64起步比较稳。
3. 用PyTorch实现LSTM分类器:从embedding到输出层的完整结构
3.1 LSTM为什么能缓解RNN的梯度消失
普通RNN在长序列上有个根问题:误差往回传的时候,每一步都要乘一个相同的转移矩阵,连乘次数等于序列长度,梯度要么爆炸要么消失,导致网络记不住几十步之前的信息。LSTM在隐藏单元里加了门控机制,输入门、遗忘门、输出门各自负责决定“记住什么、丢掉什么、输出什么”,遗忘门可以让梯度在记忆细胞里近似恒等地流过,这一步设计让长距离依赖变得可行。跑情感分析这种对“前面铺垫后面反转”有要求的任务,LSTM比普通RNN靠谱得多。
3.2 模型代码与关键参数说明
下面的类就是完整的LSTM分类器,结构是Embedding层、单层LSTM、全连接输出层。输入是(batch, seq_len)的整数序列,经过Embedding变成(batch, seq_len, embedding_dim),LSTM按时间步逐个处理,最后取最后一层的隐藏状态过全连接层做二分类。
class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=100, hidden_dim=128, num_layers=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim, 1) def forward(self, x): emb = self.embedding(x) # (batch, seq_len, embedding_dim) out, (h_n, c_n) = self.lstm(emb) # h_n: (num_layers, batch, hidden_dim) last_hidden = h_n[-1] # 取最后一层隐藏状态 logits = self.fc(self.dropout(last_hidden)) return logits.squeeze(1) # (batch,)注意几点。第一,batch_first=True让输入形状变成(batch, seq_len),省去转置的烦恼,这是PyTorch里LSTM最常用的写法。第二,h_n的形状是(num_layers, batch, hidden_dim),切片h_n[-1]取的是最后一层的隐藏状态;如果你用双向LSTM,这里要改成拼接最后一层的前向和后向向量。第三,Embedding的padding_idx=0告诉模型第0号词向量不参与训练,它在反向传播时梯度永远是0,避免pad噪声影响。第四,embedding_dim和hidden_dim决定参数量,100/128是CPU能快速跑通的搭配,显存富余时可以往上调,但要注意下面的欠拟合和训练时长问题。
3.3 训练循环:损失函数、优化器与梯度裁剪
模型搭好之后,训练代码是这个项目真正的核心。二分类用BCEWithLogitsLoss,它内部把sigmoid和交叉熵合并在一起,数值上比“先sigmoid再算loss”更稳。优化器选Adam,学习率从1e-3开始。梯度裁剪是LSTM的重要保底手段:RNN系列模型在长序列上出现梯度爆炸时,loss会突然跳成NaN,clip_grad_norm_能直接限制梯度的最大范数。
import torch.nn.utils as nn_utils def train_epoch(model, loader, optimizer, criterion, device, clip=1.0): model.train() total_loss = 0.0 for batch_x, batch_y in loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() nn_utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss += loss.item() * batch_x.size(0) return total_loss / len(loader.dataset)每个batch的逻辑都在注释里:前向传播拿logits,算loss,反向传播得梯度,裁剪,更新参数。clip=1.0表示梯度范数超过1就按比例缩回去,这个数值不用抠太细,0.5到2之间都常见。训练时我一般会把模型放到GPU或CPU上,再用DataLoader的num_workers并行读数据。epoch数建议先跑5轮看趋势:如果验证loss在降就继续,如果验证loss翻了说明学习率偏大或dropout不够。
4. 评估与可视化:不只报准确率,要看坏例
4.1 训练过程中的损失与准确率曲线
验证方式的第一步是记录每个epoch的训练loss和验证准确率,把它们画成曲线。这是判断模型过拟合最直接的手段:训练loss继续下降、验证loss开始反弹,就是过拟合信号。不要只盯着训练集准确率,那在深度学习里是典型的“自我感动”。
@torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct = 0.0, 0 all_preds, all_labels = [], [] for batch_x, batch_y in loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) logits = model(batch_x) loss = criterion(logits, batch_y) total_loss += loss.item() * batch_x.size(0) preds = (torch.sigmoid(logits) > 0.5).long() correct += (preds == batch_y.long()).sum().item() all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch_y.long().cpu().numpy()) acc = correct / len(loader.dataset) return total_loss / len(loader.dataset), acc, all_preds, all_labelsevaluate里加了两件事:第一,把所有预测和真实标签收集起来,供后面的混淆矩阵和坏例分析用;第二,模型切到eval模式,dropout被关闭,这是评估和训练最大的区别。如果忘了model.eval(),dropout会随机丢掉节点,导致同一个样本每次预测结果不一样,验证准确率波动很大,这在初学阶段是个很容易忽略的翻车点。
4.2 混淆矩阵与预测样例
准确率只能说明整体对错,混淆矩阵能告诉你错误长什么样。用sklearn一行就能算出来:横轴是真实标签,纵轴是预测标签。负样本被判成正样本的FN、正样本被判成负样本的FP,它们的比例直接影响你后续怎么调阈值。接着从测试集里挑几个样本,打印原文、真实标签、预测概率和最终判定,这一步能让你直观看到模型在“读”什么。
from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt def plot_confusion(matrix, classes): plt.imshow(matrix, cmap="Blues") plt.colorbar() plt.xticks([0, 1], classes) plt.yticks([0, 1], classes) plt.xlabel("预测标签") plt.ylabel("真实标签") for i in range(2): for j in range(2): plt.text(j, i, matrix[i][j], ha="center", va="center") plt.show()打印样例时,我习惯同时输出sigmoid后的概率值而不是只看0/1阈值。概率靠近0.5的样本是模型自己都拿不准的,这类样本往往包含比较隐晦的反讽或双重否定,靠单一固定阈值判断本来就容易出错。把概率值输出到控制台,你就能看见模型的不确定性分布,后面如果想调阈值,也知道往哪个方向调。
4.3 从坏例里找到模型真正的短板
评估的最后一步,是把预测错的样本单独抽出来看。常见做法是打印前20个false positive和false negative,人类扫一遍就能发现规律。我做这个项目时印象最深的坏例有两种:一是带有讽刺语气的影评,字面全是夸赞实际在骂,这类很难靠LSTM解决;二是评论里出现“not bad”这种双重否定结构,模型容易只看尾部词。这些坏例不是模型的失败,而是你论文里“分析与讨论”那一节的最好素材,写到模型局限性时直接用,比空泛说“未来将引入预训练模型”有说服力得多。
5. 实践避坑:LSTM情感分析最常见的6个坑
5.1 坑:torchtext版本迁移导致老代码跑不通
现象:跟着网上的老教程用torchtext的Field、LabelField、BucketIterator,装完最新版torchtext后直接报AttributeError,或者提示dataset已经废弃。
原因:torchtext 0.9之后的API大改,老的Field接口被移除,网上大量早期教程是基于0.6写的,换版本后全部失效。
解决:绕开torchtext,直接用torch.utils.data.Dataset手写数据读取。这部分代码就是第2章展示的方式,代码量多不了多少,但完全不依赖torchtext的版本,装哪个版本都能跑。数据集加载这种底层逻辑,越少依赖越省心。
5.2 坑:训练集准确率很高,验证集却一直上不去
现象:训练到第3轮训练准确率到了95%,验证准确率卡在82%左右,继续训练验证loss还在涨。
原因:典型的过拟合。LSTM参数量不小,IMDB原始词表如果没用max_vocab限制,Embedding层会很大,模型把训练集里的专有名词记住了但没有泛化能力。
解决:三步一起做。第一,把max_vocab降下来,去掉生僻词;第二,把LSTM的dropout从0.3调到0.5,全连接前面也加Dropout层;第三,用早停法,保存验证准确率最高的模型权重而不是最后一轮,后面每个epoch结束都做一次验证。
5.3 坑:loss不降或直接变NaN
现象:训练到某个batch,loss突然变成nan,后面的loss全是nan,准确率也跟着崩掉。
原因:最常见的是梯度爆炸,LSTM在反向传播时梯度范数失控;其次是学习率过大,Adam的默认1e-3在部分初始化下也会震到爆炸。
解决:梯度裁剪是最有效的后悔药,clip_grad_norm_设为1.0几乎能让训练稳定下来。另一个做法是把学习率降到3e-4并配合warmup,前几个epoch用很小的学习率预热,再进入正常训练,这两招一起就很少再看见NaN了。
5.4 坑:预测阶段输入长度不一致导致报错
现象:训练时一切正常,单独预测一条新评论时直接报“mat1 and mat2 shapes cannot be multiplied”。
原因:训练时每个batch用max_len填充成了矩形,但你自己写预测函数时没有对单条文本做同样的encode逻辑,长度不固定导致Embedding输出形状对不上全连接层。
解决:预测函数里必须复用train时那套encode逻辑,把输入统一截断/填充到max_len;建议把encode、模型加载、预处理封装成一个predict函数,任何外部文本都先走同一套流程,不要复制粘贴代码里的预处理部分。
5.5 坑:类别不均衡导致模型倾向性预测
现象:结果全预测成某一类,比如90%以上的影评都被判断为正面,准确率看起来还行,但召回率极低。
原因:虽然IMDB是均衡数据,但自己裁数据时如果不注意,某个batch或某个局部数据集里正负比例失衡,模型会往多数类偏移。另外阈值0.5不是永恒真理,模型输出概率整体偏高时,0.5会误伤不少负样本。
解决:训练时用随机打乱确保每轮batch分布均匀;评估时不光看准确率,还要看precision、recall和F1,直接用classification_report能看得很清楚。如果概率分布整体偏移,在验证集上搜一个最优阈值,比如0.55或0.45,能救回一部分坏例。
5.6 坑:训练时间过长与显存不足
现象:batch_size设了128,跑第三个epoch时CUDA out of memory;或者CPU上跑一个epoch要二十多分钟。
原因:max_len和vocab_size分别控制了序列长度和词表宽度。IMDB很多评论近千词,256的上限已经截掉了大部分尾部内容;batch_size128在单卡上配hidden_dim=128不算大,但Embedding前向矩阵和LSTM的中间变量很占显存。
解决:显存不够优先降batch_size到32或64,再兼顾max_len降成200;CPU训练就把vocab_size降到10000,epoch跑3轮验证一下趋势是否正常。另外可以把模型输出了的复杂度做一个预算——embedding_dim/hidden_dim都取128时,模型大小大概在百万参数量级,这个量级跑IMDB完全够用,没必要堆大模型。
6. 从毕业设计到进阶:BiLSTM、注意力机制与对比实验
6.1 把单向LSTM改成双向
单向LSTM每个时间步只能看到过去的词,影评里转折往往在后面。改成双向后,每个位置同时看到前后上下文,信息量翻倍也只是把hidden_dim*2拼到全连接层。改法非常小:nn.LSTM加bidirectional=True,取h_n时把最后一层的前向和后向向量拼接:
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True) # forward里 h_forward = h_n[-2] # (batch, hidden_dim) h_backward = h_n[-1] # (batch, hidden_dim) last_hidden = torch.cat((h_forward, h_backward), dim=1) self.fc = nn.Linear(hidden_dim * 2, 1)这是毕业设计里性价比最高的升级,几行代码换来几个点的准确率提升,论文里还能画一个前向和反向的结构图,值得做。
6.2 让模型自己挑重点:一个简单的注意力层
LSTM最后只用一个隐藏向量代表全句信息,这等于逼着模型把整句话压缩成一个点,信息损失是必然的。注意力机制的做法是:每个时间步的输出不直接扔掉,而是按权重加权求和,权重由模型自己学习。代码量很少,但对影评这种“关键信息可能落在某个从句里”的任务很有效。
class Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.score = nn.Linear(hidden_dim, 1, bias=False) def forward(self, lstm_outputs): # lstm_outputs: (batch, seq_len, hidden_dim) attn = torch.softmax(self.score(lstm_outputs), dim=1) context = torch.sum(lstm_outputs * attn, dim=1) return context用的时候把LSTM的outputs(不是h_n)交给Attention,输出加权后的context向量再过全连接层。注意力还有一个额外好处:你可以把每个位置的权重可视化,画成热力图放在论文里,非常直观地回答“模型到底看了哪些词”。
6.3 三个值得做的对比实验
毕业设计答辩时,老师最常问的问题就是“你为什么不选CNN/传统RNN/BERT”。与其到时候挨个解释,不如提前跑三组对照:普通RNN、LSTM、BiLSTM+Attention,在同一份数据、同参数下记录准确率和训练时间。按我经验,普通RNN在长影评上准确率会低2~4个百分点,LSTM与BiLSTM差距在1~2个百分点,注意力对长句子的增益会更明显。这张对照表就是你论文实验章节的骨架,评审看见你有控制变量意识,比单纯堆一个高准确率更有说服力。
我自己当年做这个项目时,最大的教训是把训练准确率当成了模型能力的全部。后来学着把预测错的样本打印出来逐条看,才发现模型真正的问题不是参数,而是对讽刺和双重否定的理解不足。这也是为什么我到现在都保留着“先看坏例、再看准确率”的习惯:指标能告诉你模型够不够好,坏例能告诉你它为什么还不够好。模型不是黑匣子,只要肯花时间看输出,它就能告诉你下一步该改哪里。希望帮到你。
本文还有配套的精品资源,点击获取