☰
PyTorch实现IMDB电影评论情感分析:BiLSTM+Attention实战
2026/10/9 3:54:05 网站建设 项目流程

简介:面向Python毕业设计、课程实践与个人实训的电影评论情感分析系统完整项目,以深度学习模型为核心,完成从影评文本清洗、特征表示到情感极性判别的全流程,并配有前端交互界面与后台管理逻辑,适合需要一套可运行参考方案的初中级学习者。压缩包共290个文件,源码部分以Python脚本与编译产物为主,涵盖模型训练、预测接口与业务逻辑;前端由网页结构、样式表与交互脚本构成,可直接展示分析界面;数据与模型侧包含数据库脚本、数据集、训练权重文件,目录按前后端、脚本和说明文档划分,整体大小约122.97MB。已有136人学习下载,项目经过调试可直接运行,从环境部署、数据库初始化、模型加载到页面展示均有完整实现,还附有说明文件与测试数据,可作为毕业设计答辩、课题改造或二次开发的基础蓝本。

1. 电影评论情感分析,差评识别为什么比想象中更难

一条影评是好评还是差评,人扫一眼就知道,但让程序从零学会这件事,远没有看上去那么简单。用 Python 做基于深度学习的电影评论情感分析系统,本质上是让模型把一段任意长度的文本映射成正面或负面两类,顺带给出置信度。它最常见的两个落点:给电影/视频网站做评论自动打标,省掉人力初筛;给内容推荐系统提供一条用户反馈信号。适合动手的人主要是两类:入门 NLP 的 Python 开发者,想跑通一个完整的深度学习小项目;做课程设计或毕设的人,需要一套能演示、能改参数、能出指标的方案。下面从数据预处理一步步讲到推理封装,代码都是能直接改着跑的写法。

2. 把IMDB原始评论变成训练数据:清洗、分词与序列填充

2.1 数据集的选择与原始文本清洗

做电影评论情感分析,绕不开 IMDB 这 50000 条影评。它正负样本各 25000 条,标签均衡,文本长度从几十词到上千词都有,比很多干净到不真实的分类数据集更有说服力。常见的源码包里一般会带预处理脚本,但更普遍的做法是拿到原始 IMDB_Dataset.csv,两列:review 和 sentiment。先看一眼数据长什么样,再决定清洗规则。有些预处理脚本会顺手把清洗后的结果导出成 json 或 parquet,训练脚本每次启动就不用重新跑一遍清洗,这个习惯值得保留。

IMDB 的原始评论有个特点:抓取下来的时候带大量<br />换行标签,还有一堆标点、数字、HTML 实体。这些内容对情感判断基本没贡献,反而会把词表撑大。我的清洗规则是:换行标签替换成空格,非字母字符全部丢掉,统一小写。别小看这几行正则,词表能砍掉三分之一。

import pandas as pd import re df = pd.read_csv("IMDB_Dataset.csv") print(df.head()) def clean_text(text: str) -> str: # IMDB 原始评论里有很多 <br /> 标签,先换成空格,避免单词被硬拼在一起 text = re.sub(r"<br\s*/?>", " ", text) # 只保留英文字母和空白,数字、标点、特殊符号全部丢弃 text = re.sub(r"[^a-zA-Z\s]", "", text) # 统一小写,让 "Movie" 和 "movie" 落到同一个词项里 text = text.lower() return text df["clean_review"] = df["review"].apply(clean_text) df["label"] = (df["sentiment"] == "positive").astype(int)

这段代码跑完后,df 里多出 clean_review 和 label 两列。正则里的<br\s*/?>同时覆盖<br>和<br />两种写法;[^a-zA-Z\s]的 ^ 表示取反,即把字母和空白之外的字符都删掉。清洗阶段最好不要引入停用词表,IMDB 这类长文本里,not、but、never 这些“停用词”恰恰是情感翻转的关键,删了反而让模型丢失转折信息,这是很多入门项目翻车的第一个隐蔽点。如果拿到的是中文影评,清洗规则要换成保留中文字符和标点,分词从 split 换成 jieba,后面几步思路完全一样。

2.2 从 token 序列到定长张量:词表、编码与 mask

模型吃的是数字,不是字符串。这里分三步:给每个词编一个 id,把一条评论变成 id 序列,再统一成长度一致的张量。英文按空白切分就够了,Python 的 str.split 性能也好;中文需要 jieba.cut,因为中文词之间没有空格。

词表构建时我建议设一个 min_freq=2,也就是只出现一次的词全部退化成<unk>。IMDB 里大量人名、拼写错误只出现一次,硬塞进词表只会让 embedding 层学不到有效信息,还会拖慢训练。

from collections import Counter import torch from torch.utils.data import Dataset, DataLoader def build_vocab(texts, min_freq=2): counter = Counter() for text in texts: counter.update(text.split()) vocab = {"<pad>": 0, "<unk>": 1} # 0 留给 padding,1 留给未登录词 for word, freq in counter.most_common(): if freq >= min_freq: vocab[word] = len(vocab) # 按词频降序分配 id,从 2 开始 return vocab def encode(text: str, vocab: dict, max_len: int = 256) -> list: ids = [vocab.get(w, vocab["<unk>"]) for w in text.split()] if len(ids) > max_len: return ids[:max_len] # 超长直接截断,IMDB 里超过 256 词的评论很多 ids = ids + [vocab["<pad>"]] * (max_len - len(ids)) return ids

encode 的返回值是长度为 max_len 的 list。vocab.get(w, vocab["<unk>"])的第二个参数是默认值,词表里查不到这个词就返回<unk>的 id。截断放在 padding 之前,避免先 pad 再截断造成长度不一致。

放进模型之前,还要包一层 Dataset。这里我会顺手把 mask 一起算好:mask 记录哪些位置是真实 token、哪些是 pad。后续注意力机制和 loss 计算都要靠它屏蔽 padding 位置,否则模型会学出“pad 也算上下文”的坏习惯。

class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=256): self.data = [] for text, label in zip(texts, labels): ids = encode(text, vocab, max_len) self.data.append((ids, label)) def __len__(self): return len(self.data) def __getitem__(self, idx): ids, label = self.data[idx] x = torch.tensor(ids, dtype=torch.long) mask = (x != 0).bool() # pad id 是 0,所以 mask 直接比较即可 return x, torch.tensor(label, dtype=torch.long), mask

返回三元组 (x, label, mask)。DataLoader 用 batch_first 处理时,每个 batch 形状是 [batch_size, max_len],mask 同形状,label 是 [batch_size]。这个数据结构从预处理到训练全程一致,后面模型代码你不用回头改。

3. 模型选型与搭建:用PyTorch实现BiLSTM+Attention情感分类器

3.1 为什么在这个任务上选 BiLSTM 而不是纯 CNN

深度学习做文本分类,CNN 和 RNN 两派都能跑,但 IMDB 这种长文本上,CNN 需要堆很多层才能扩大感受野,而电影评论的褒贬常常由一句话甚至一个转折词决定,比如“前半段无聊,但结局救了整部电影”。CNN 的滑动窗口抓得住局部,抓不住这种跨句子的关系。

BiLSTM 的优势在于双向:正向读一遍、反向读一遍,每个位置的输出同时包含左侧和右侧的上下文。注意力机制再补一层筛选,让模型重点关注那些真正影响情感判断的词,而不是平均所有位置的向量。Transformer 理论上更强,但在 50000 条这个量级、没有预训练权重的情况下,自注意力训练更容易过拟合,收敛也慢。中小型项目从 BiLSTM 起步,是性价比最高的选择。

这也呼应了很多人问的“深度学习算法是不是越新越好”:在数据量不上万、没有预训练模型打底时,结构简单、正则好控的模型反而更容易出稳定结果。源码包里给的往往是经典结构,不是因为你不会写 Transformer,而是因为它在这个规模上最不容易翻车。

3.2 词嵌入、双向 LSTM 与注意力层的实现

模型结构按顺序拆是四块:Embedding 把 token id 映射成稠密向量;BiLSTM 提取顺序特征;attention 对 LSTM 输出做加权求和;最后接一个线性分类头。embedding_dim 我常用 100,hidden_dim 用 128,单层 LSTM。hidden_dim 翻到 256 提升有限,训练时间接近翻倍,IMDB 上不划算。

import torch import torch.nn as nn class BiLSTMAttention(nn.Module): def __init__(self, vocab_size, embed_dim=100, hidden_dim=128, num_layers=1, dropout=0.5, num_classes=2): super().__init__() # padding_idx=0 让 pad 位的 embedding 恒为 0,不参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # bidirectional=True 时输出维度翻倍,所以 attention 输入是 hidden_dim * 2 self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0.0) self.attention = nn.Linear(hidden_dim * 2, 1, bias=False) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x, mask=None): emb = self.embedding(x) # [B, L, embed_dim] out, _ = self.lstm(emb) # [B, L, 2*hidden_dim] scores = self.attention(out).squeeze(-1) # [B, L] if mask is not None: # pad 位置填 -1e9,softmax 后权重趋近于 0 scores = scores.masked_fill(~mask, -1e9) weights = torch.softmax(scores, dim=1) # [B, L] # weighted sum,得到整个句子的表征 context = (out * weights.unsqueeze(-1)).sum(dim=1) logits = self.fc(self.dropout(context)) return logits

几个参数值得展开。batch_first=True让输入输出维度统一成 [batch, seq, feature],不然每个 tensor 都要在 batch 维上转置,容易把人绕晕。masked_fill(~mask, -1e9)是 attention 里的关键保护:pad 位置的 attention score 压到极小值,softmax 之后权重约等于 0,这样 weighted sum 不会把无意义的 padding 向量混进句子里。dropout=0.5放在 LSTM 输出到分类头之间,这是全连接层最容易过拟合的位置。

训练一个 epoch 在 CPU 上大约几十秒,GPU 上几秒。如果显存紧张,把 batch_size 调小比把 max_len 调小更安全,因为截断长度直接影响模型对长评论的理解能力。

3.3 损失函数与评估指标:交叉熵之外还要看什么

分类任务默认用交叉熵。PyTorch 里nn.CrossEntropyLoss帮你在内部做了 log_softmax,所以模型最后一层输出 logits 就可以了,不要再手动接 softmax,否则梯度会不稳定,这是新手很常见的错误。标签要转成 long 型,float 型会直接报 RuntimeError。

准确率这个指标在 IMDB 上够用,因为正负样本几乎对半。换到真实场景,如果评论 90% 是好评,准确率会被多数类带偏,这时候要同时看 F1。我训练时会记录每个 epoch 的验证集准确率和 loss,控制在 8 个 epoch 以内就够,IMDB 上 3 到 4 个 epoch 后验证集就开始过拟合。

4. 训练与调参:把验证集准确率稳定在85%以上的实操记录

4.1 训练循环、梯度裁剪与学习率调度

训练代码看起来不长,坑全在细节里。我用 Adam 加 1e-3 的初始学习率,batch_size 64。梯度裁剪 clip=1.0 很关键,BiLSTM 反向传播的梯度很容易爆炸,尤其文本长度到 200 以上时,不裁剪的话 loss 会在某个 step 突然变成 nan。

import torch from torch.utils.data import DataLoader from torch.nn.utils import clip_grad_norm_ def train_one_epoch(model, loader, optimizer, criterion, device, clip=1.0): model.train() total_loss = 0.0 for x, y, mask in loader: x, y, mask = x.to(device), y.to(device), mask.to(device) optimizer.zero_grad() logits = model(x, mask) # 注意 mask 要传给 forward loss = criterion(logits, y) loss.backward() clip_grad_norm_(model.parameters(), clip) # 防止梯度爆炸 optimizer.step() total_loss += loss.item() return total_loss / len(loader) def evaluate(model, loader, criterion, device): model.eval() total, correct, total_loss = 0, 0, 0.0 with torch.no_grad(): for x, y, mask in loader: x, y, mask = x.to(device), y.to(device), mask.to(device) logits = model(x, mask) loss = criterion(logits, y) preds = logits.argmax(dim=1) total_loss += loss.item() total += y.size(0) correct += (preds == y).sum().item() return total_loss / len(loader), correct / total

model.eval()和torch.no_grad()缺一不可:前者关掉 dropout,后者关掉梯度计算。忘了切 model.eval(),推理时 dropout 还在随机丢神经元,验证集指标会忽高忽低。很多人看到验证集准确率波动大,第一反应是调学习率,其实只是没开 eval 模式。

完整训练流程我会套一个固定循环:每个 epoch 结束用验证集评估,保存当前最佳模型权重。验证集 loss 连续 2 个 epoch 不降就停,不需要跑满固定轮数。要复现的话,随机种子最好固定下来,PyTorch 的部分随机性来自 DataLoader 的 shuffle,设置torch.manual_seed(42)后结果基本可复现。

4.2 过拟合的典型信号与三个正则手段

验证集准确率上去之后,最典型的过拟合信号是:训练 loss 还在降,验证 loss 已经开始回升;或者训练准确率逼近 98%,验证卡在 86% 不动。IMDB 的文本量大,过拟合没有小数据集那么夸张,但 attention 层的权重很容易集中在少数几个词上,导致泛化变差。

我的三个正则手段,按优先级排:第一是 dropout,模型代码里已经加在 LSTM 输出和分类头之前,不要取消;第二是梯度裁剪,它不只是防 nan,本质上也是限制参数更新的步长;第三是早停,验证集指标不再改善时立刻保存最后一次最佳权重。

学习率也不要一路跑到黑。常见做法是前两个 epoch 用 1e-3,之后降到 3e-4,或者用ReduceLROnPlateau,验证 loss 不降就自动减半。我比较偏好手动分阶段降,因为 ReduceLROnPlateau 在训练后期经常触发得太频繁,反而让最优权重出现在验证指标提升之前。

4.3 一组实测好用的默认参数

下表是我在 IMDB 上验证过的一组参数,照着可以用,想调也建议从单一变量开始动,不要一次改两个以上,不然出了问题你不知道是哪个参数导致的。

参数取值说明
embedding_dim100维度再大收益递减,训练明显变慢
hidden_dim128双向后实际特征维度是 256
num_layers1第二层 LSTM 对这类任务提升很小
dropout0.5过拟合明显时可提到 0.6
batch_size64显存不够先降这个
max_len256覆盖 IMDB 约 90% 的评论长度
lr1e-3 到 3e-4第 3 个 epoch 后降低
clip1.0梯度裁剪阈值
epochs8通常第 4、5 个 epoch 后触发早停

验证集准确率 85% 在这个任务上是正常水平。IMDB 用双向 LSTM 加 attention 能做到 88% 左右,再往上就要靠预训练模型或更复杂的结构。如果你的结果只有 82%,先看是不是数据预处理丢了太多信息;真正影响大的是 max_len 设太小,长评论被截断后情感反转的部分被切掉了。

5. 避坑记录:跑这个情感分析系统最容易翻车的5个默认设置

5.1 解压 zip 后路径带中文,数据集直接读不进来

现象:pandas 读 IMDB_Dataset.csv 报 FileNotFoundError,或者路径明明对,Python 却说找不到文件。

原因:很多源码包是在 Windows 下打包的,解压后路径里带中文目录名。pandas 底层的 C 解析器对非 ASCII 路径支持不太好,读文件时直接失败。

解决:把整个工程移到纯英文路径下,比如 D:\movie_sentiment\,再不行就用pd.read_csv(r"D:\movie_sentiment\IMDB_Dataset.csv", engine="python"),指定 python 引擎能绕开部分编码问题。顺带建议:所有涉及文件读写的脚本里,路径不要写死,用Path拼接相对路径,换机器不用改代码。

5.2 torchtext 版本升级后接口全变,旧代码直接报错

现象:按老教程装 torchtext,跑torchtext.datasets.IMDB报错,或者Field不存在了。

原因:torchtext 0.9 到 0.12 之间接口大改,torchtext.data.Field被移除,换成了新版 DataLoader API,和 PyTorch 自己的 DataLoader 还不一样。源码包里如果写死了老版本,换新环境就跑不起来。

解决:最快的方法不是升级适配新 API,而是锁版本:pip install torchtext==0.9.0。如果锁版本后仍和当前 PyTorch 版本冲突,就直接按上面第 2 章的方式自己写 Dataset,绕开 torchtext 不依赖它。自己实现数据管道其实更稳,至少不会被框架升级绑架。

5.3 padding 污染了 attention,模型自己学会了“读空白”

现象:训练 loss 掉得很快,验证集也不错,但可视化 attention 权重时发现大量权重分配在 pad 位置上。

原因:forward 里没有对 attention score 做 mask。pad 位置虽然 embedding 是 0,但 BiLSTM 递归计算后输出不是 0,attention 会把一部分分数发给这些“假 token”。

解决:就是第 3 章代码里的那行scores = scores.masked_fill(~mask, -1e9)。mask 要和你输入的 padding 位置严格对应,注意 DataLoader 里 pad 的 id 必须是 0,这样x != 0才能正确构造 mask。如果你把<pad>的 id 设成别的数字,mask 就会全错,attention 等于没设。

5.4 训练 loss 在降,验证集准确率纹丝不动

现象:loss 从 0.7 降到 0.3,训练准确率 90% 以上,验证集准确率停在 50% 左右,跟瞎猜差不多。

原因:三个常见来源。一是标签翻转了,sentiment 列映射成 0/1 时方向写反,验证集指标自然不对;二是词表构建时把 min_freq 设得太大,比如 10,导致大量评论变成<unk>序列,模型学不到语义;三是 mask 没传进 forward,验证时 pad 位置参与计算,训练和验证的数据分布不一致。

解决:先用一个小样本验证数据管道——打印前几条 encode 后的 id 和原始文本,肉眼确认没有变成一列<unk>;再检查 label 统计,train/val 的正负样本比例都应该接近 1:1。数据管道没问题再动模型,这是排查这类问题最快的顺序。

5.5 新评论预测全是同一类,模型像“黑匣子”一样拒绝输出变化

现象:训练指标一切正常,但把自己写的新评论喂进去,任何输入都输出 0.99 的高分,永远是同一个类别。

原因:最常见是类别不平衡,训练数据里某一个类别占比过高,模型学会了直接输出多数类。IMDB 本身是平衡的,但如果你把自建数据集混进来,清洗后标签分布没检查,就会这样。另一个可能原因:推理时的预处理和训练时不一致,比如训练时过滤了标点,推理时没过滤,同一个词表里查不到词,全都变成<unk>。

解决:每次训练前打印df["label"].value_counts();推理时复用训练时的 clean_text 和 encode,不要另写一套。模型训练结束后,我习惯先跑 20 条人工标注的测试评论,确认正向和负向都能输出合理分数,再上线。遇到输出概率极端接近 1 的情况,也可以检查是不是开着 dropout 做推理——记得model.eval()。

6. 从训练好的模型到可交付的系统:推理封装与效果验证

训练只是第一步。要交付一个能用的情感分析系统,还得把模型封装成接收字符串、返回预测结果的函数。网上很多教程讲完训练就结束,实际上卡在落地这步的人不少:模型权重、词表、清洗规则都是分离的,没人告诉你它们怎么串起来。下面这个 predict 函数,输入一句英文影评,输出正面和负面的概率,串起了全部环节。

def predict(model, text, vocab, device, max_len=256): model.eval() text = clean_text(text) # 和训练时同一套清洗,绝不能换规则 ids = encode(text, vocab, max_len) x = torch.tensor([ids], dtype=torch.long, device=device) mask = x != 0 # 单条样本也要构造 mask with torch.no_grad(): logits = model(x, mask) prob = torch.softmax(logits, dim=1)[0] neg, pos = prob.tolist() return {"negative": round(neg, 4), "positive": round(pos, 4)}

风险点全在“和训练保持一致”这七个字上。清洗、编码、词表、mask,任何一步不一致,结果都会偏差,而且你很难从输出里看出来。我自己的血泪经验是:有一次训练时加了小写转换,推理时觉得“用户输入的评论本来就是小写”,省掉了这一步,结果专有名词全变成<unk>,负面评论的准确率直接掉 10 个百分点。所以推理函数最好直接复用训练脚本里的函数,不要重写。

效果验证我习惯分两层。第一层是量化:预留的测试集上跑一遍准确率和 F1,记录成基线,以后换模型、调参都拿这个基线比较。第二层是定性:准备 10 条边界案例,比如反讽、“not bad”这种双重否定、带表情符号的评论,人工看输出是否符合直觉。模型对反讽通常表现很差,这个要提前知道,不要等到上线被业务方问住。置信度低于 0.6 的样本可以标记为“待人工审核”,这也是把模型接进真实业务流程时常用的兜底手段。

往后想扩展,路径很清晰:把词表换成预训练词向量(GloVe 或 fastText)能提升 1 到 2 个点;换成 BERT 微调又是另一套玩法;处理中文影评时,分词换成 jieba 加停用词过滤,清洗正则按中文字符调整即可。建议第一次做时先别加预训练,先把 BiLSTM 这套跑通、看得到所有中间结果,后续所有改进才有对比基准。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询