
简介一套基于深度学习的电影评论情感分析系统完整源码以Python 3.6.8为开发语言结合MySQL 5.7数据库与PyCharm工具采用word2vec向量模型对影评文本进行正负面情感判别面向毕业设计、课程设计及自然语言处理方向的开发者。资源共293个文件压缩包约126.37MB包含23个Python核心脚本、75个GIF操作演示、17个HTML页面及配套CSS/JS样式资源另含SQL建库脚本、PKL模型权重、CSV训练数据与部署说明文档覆盖数据预处理、词向量训练、模型构建、情感分类到Web展示的完整工程链路。已有68人浏览学习适合快速复现或二次开发。借助源码和LW文档能清晰理解word2vec应用、模型封装与前后端交互的实现细节同时通过readme和部署说明降低环境配置门槛为同类文本分析项目提供可直接参考的代码骨架与排错思路。1. 基于深度学习的电影评论情感分析不是模型难是工程闭环难拿到“电影评论情感分析 python 深度学习”这个组合多数人第一反应是“又要搭 LSTM 训 IMDB”。但真做过毕业设计或者带过这类项目就会知道卡人的从来不是模型结构而是数据怎么洗、词表怎么建、训完怎么给老师演示、论文里那个 LW论文文档能不能跟代码对得上。标题里这包“完整源码 LW”真正解决的问题是把一条从数据预处处理到 Web 展示的完整链路跑通而不是只给一个 accuracy 数字。这篇文章适合两类人一是正在选毕设题目、需要可控工作量的本科生二是想快速把深度学习情感分析落地成可演示系统的开发者。我会按自己的工程习惯把这套方案的每个环节拆开讲。2. 情感分析模型选型为什么用 BiLSTM 而不是盲目上 BERT2.1 毕设场景的“够用”标准准确率、算力、可解释性的三角权衡电影评论情感分析本质上是文本二分类正面/负面可选方案从词袋 朴素贝叶斯一路排到 Transformer。很多人一上来就盯着 BERT 微调理由是效果上限高。但结合毕设场景这个选型有三个现实问题第一本地没有 GPU 或只有一块入门级显卡时BERT 的 fine-tune 一轮要跑很久调参成本极高第二把“为什么选这个模型”写进论文时LSTM 的演化逻辑讲起来非常直观而 BERT 对毕业生来说容易变成黑匣子第三答辩老师更愿意追问你能解释的结构而不是你背下来的 API。所以我一般会建议把基线模型定为 BiLSTM甚至可以加一层 Attention 做可视化用这一点与“普通 LSTM”做消融对比这比直接拿 BERT 刷分更能体现工作量。这套方案并不是拒绝 Transformer。如果你的机器确实扛得住可以在同一份代码里把模型层替换成预训练模型再跑一组对比作为论文的“进阶实验”。但主线用 BiLSTM是保证在一台普通笔记本上也能在二十分钟内看到 loss 下降的最稳妥路线。2.2 模型结构Embedding、双向 LSTM 与分类头的 PyTorch 实现我用 PyTorch 写的模型代码核心结构就四层Embedding 层把词索引映射成稠密向量双向 LSTM 分别从正反两个方向读取序列把最后一层两个方向的隐藏状态拼接接一个 Dropout 和全连接层输出二分类 logits。下面是可直接落地的实现import torch import torch.nn as nn class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size128, num_layers2, num_classes2, dropout0.5): super().__init__() # padding_idx0 表示词表中索引 0 对应的向量始终为 0 # 这样 pad 位置不会参与梯度更新也能避免 padding 向量被学偏。 self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) # batch_firstTrue 让输入形状为 [batch, seq_len, embed_size] self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0) # 双向 LSTM 的最后一层有两个方向拼接后维度是 hidden_size * 2 self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len]元素是词索引 emb self.embedding(x) # [batch, seq_len, embed_size] out, (h_n, c_n) self.lstm(emb) # out 是每个时间步的输出 # h_n 形状为 [num_layers * 2, batch, hidden_size] # 取最后一层num_layers2 时是索引 -2 和 -1的前向、后向隐状态 last_hidden torch.cat((h_n[-2], h_n[-1]), dim1) # [batch, hidden*2] logits self.fc(self.dropout(last_hidden)) return logits这里有两个参数容易踩坑。第一dropout只在num_layers 1时才会生效PyTorch 官方实现里num_layers1时传 dropout 会被忽略所以代码里做了一个条件判断。第二双向 LSTM 的h_n维度顺序是“层数 × 2”很多人误用h_n[-1]取到的可能是后向的最后一层而非“最后一层后向”要取最后一层前向得用h_n[-2]。这两处想明白了模型基本不会在维度上报错。# ---- 简单验证维度的用法 ---- model BiLSTMClassifier(vocab_size1000, embed_size128, hidden_size128, num_layers2, num_classes2) demo_input torch.randint(0, 1000, (4, 32)) # batch4, seq_len32 output model(demo_input) print(output.shape) # torch.Size([4, 2])2.3 超参数的取值经验与调整顺序毕设项目里最容易“一跑就崩”的不是代码逻辑而是超参数配得离谱。下面这组参数是我在类似任务上验证过比较稳的起点你可以按这张表微调参数推荐值调整依据embed_size128语料 5 万条以内 128 够用升到 256 收益很小但显存翻倍hidden_size128与 embed_size 保持一致更稳调大容易过拟合num_layers2单层欠拟合三层以上在情感分析这种短文本任务上收益递减dropout0.5过拟合明显时优先升到 0.6不要动 L2 正则batch_size64显存不足时先降到 32不要改序列长度lr1e-3配合 Adam 使用不收敛再降到 5e-4max_len200超过 200 个词的评论占比很低截断影响极小调参顺序有个血泪经验先固定max_len和batch_size只调学习率等 loss 能稳定下降再动hidden_size和num_layers。很多人一上来就同时改三个参数loss 不降就怪模型结构结果模型换了三轮才发现是学习率太大加数据没洗乾淨。3. 数据预处理与训练闭环词表、序列化、损失监控3.1 数据集选择与清洗要求英文用 IMDB中文用豆瓣或自采电影评论情感分析最经典的公开数据集是 IMDB Large Movie Review Dataset包含 25000 条训练和 25000 条测试评论每条有明确的 polarity 标签适合作为毕业设计的主数据集。如果你要体现一点差异化可以再找一份中文影评数据做“跨语言验证”常见做法是用豆瓣短评 jieba 分词。但要注意中文任务的处理流程和英文有两点不同英文按空格分词即可中文必须分词英文词形变化需要还原plays - play中文没有这个步骤。分词和词形还原都很容易引入脏数据我把这一步单独拎出来讲。英文清洗我一般按这个顺序做去掉 HTML 标签和 URL、把大写转小写、按标点切分、去掉长度小于 2 的 token、用 spaCy 或 NLTK 做 lemmatization。不要用正则硬删b这类东西IMDB 原始数据里常有br标签残留。中文数据则是 jieba 分词后直接过滤停用词停用词表用常见的哈工大停用词表即可。这里有一个容易被忽略的细节测试集的清洗规则必须和训练集完全一致包括是否转小写、是否去停用词否则训练时看到的词分布和预测时不一致效果会断崖式下降。import re import jieba from nltk.stem import WordNetLemmatizer lemmatizer WordNetLemmatizer() stopwords set(open(stopwords.txt, encodingutf-8).read().split()) def clean_text_en(text): text re.sub(r[^], , text) # 去 HTML 标签 text re.sub(rhttp\S|www\.\S, , text) # 去 URL text re.sub(r[^a-zA-Z\s], , text) # 去标点和数字 tokens text.lower().split() tokens [lemmatizer.lemmatize(t) for t in tokens if len(t) 2] return tokens def clean_text_zh(text): text re.sub(r\s, , text) tokens [w for w in jieba.cut(text) if w.strip()] tokens [w for w in tokens if w not in stopwords] return tokens清洗这步看起来简单但直接影响词表质量和模型上限。之前我带过一个项目英文评论里br标签没清干净词表里出现了大量br这种无意义高频词导致unk比例偏低但语义特征被冲淡准确率卡在 81% 上不去。把清洗做彻底之后没动模型就涨到了 85%。3.2 词表构建与序列化索引映射的坑全部集中在这里词表构建的常见做法是统计词频取出现次数最多的前 N 个词通常 30000 到 50000保留 0 和 1 两个特殊位0 留给 padding1 留给 unknown。这里有一个关键决策——词表只能用训练集构建绝不能混入测试集。一旦测试集参与建词表就构成了数据泄漏测试集里原本应该被映射为 unknown 的词被保留成了词表词最终报告的正确率是虚高的答辩时一旦被追问很容易翻车。from collections import Counter def build_vocab(tokenized_texts, max_size30000, min_freq2): counter Counter() for tokens in tokenized_texts: counter.update(tokens) # 从索引 2 开始编号0 给 pad1 给 unk vocab {word: idx for idx, (word, freq) in enumerate(counter.most_common(max_size), start2) if freq min_freq} vocab[pad] 0 vocab[unk] 1 return vocab def encode(tokens, vocab, max_len200): ids [vocab.get(t, 1) for t in tokens[:max_len]] # 截断 if len(ids) max_len: ids [0] * (max_len - len(ids)) # 尾部补齐 return ids这段代码里有三个细节值得说明。第一min_freq2表示只出现一次的词直接记为unk可以显著压缩词表体积同时减少噪声词对 embedding 学习的干扰但代价是unk比例上升。对 25000 条评论的训练集min_freq2通常能保留 8 万到 10 万个词再取most_common(30000)截断。第二vocab.get(t, 1)里 1 是unk的索引训练时模型会把没见过的词统一当作未知词处理这比直接忽略要稳定得多。第三补齐发生在索引映射之后顺序不能反过来否则截断的位置会因补齐而错乱。构建完词表之后下一步是把原始文本转成 PyTorch 的 Dataset。常见做法是写一个自定义 Dataset 类在__getitem__里做动态编码。有一个性能优化点不要在数据预处理阶段把所有评论一次性转成长度为 200 的 Tensor 存进内存而是存原始 tokens在训练循环里按 batch 做 encode 再 pad。原因很简单每条评论实际长度差异很大统一 pad 到 200 会让短评论占用的内存膨胀 5 到 10 倍。from torch.utils.data import Dataset, DataLoader class ReviewDataset(Dataset): def __init__(self, texts, labels, vocab, max_len200): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): ids encode(self.texts[idx], self.vocab, self.max_len) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.long)在训练启动之前我习惯先从 DataLoader 里取一个 batch 打印形状确认[64, 200]的输入和[64]的标签能对上再做完整训练。这一步能省掉后面排维度报错的大把时间。3.3 训练循环与损失监控loss 曲线比什么都重要训练循环的骨架是标准的每个 epoch 里遍历训练 DataLoader前向计算、交叉熵损失、反向传播、梯度更新。但有两个地方我会写得更细。第一验证集的评估要单独放在torch.no_grad()下并且验证集不做数据增广、不做随机扰动保证评估结果稳定可对比。第二每个 batch 结束都把 loss 平均值记下来画成曲线而不是只打印 epoch 结束时的 loss。batch 级别的 loss 曲线能直接看出学习率是否过大、梯度是否爆炸。import torch import torch.nn as nn from torch.optim import Adam model BiLSTMClassifier(vocab_sizelen(vocab), embed_size128, hidden_size128, num_layers2, num_classes2) optimizer Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(5): model.train() total_loss 0.0 for batch_texts, batch_labels in train_loader: optimizer.zero_grad() logits model(batch_texts) # [batch, 2] loss criterion(logits, batch_labels) loss.backward() # 梯度裁剪防止 LSTM 反向传播时梯度范数过大导致训练震荡 nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() model.eval() correct 0 total 0 with torch.no_grad(): for batch_texts, batch_labels in val_loader: logits model(batch_texts) preds logits.argmax(dim1) correct (preds batch_labels).sum().item() total batch_labels.size(0) print(fepoch {epoch 1}, loss{total_loss / len(train_loader):.4f}, fval_acc{correct / total:.4f})训练循环里clip_grad_norm_经常被省略但在 LSTM 任务里我非常建议保留。原因是 LSTM 沿时间步展开时梯度范数天然偏大不裁剪时 loss 曲线会出现周期性的尖峰看起来像“玄学”一样的震荡。max_norm1.0是一个相对保守的取值如果训练正常可以把它调到 2.0 或 5.0给模型更大的更新步长。另一个值得注意的点是model.train()和model.eval()的切换Dropout 在 eval 模式下会被关闭忘记切换的话验证指标会比实际偏低不少这是我们常见的一个沉默翻车点。4. 把模型装进系统Flask 接口、模型导出与可演示闭环4.1 系统模块拆解为什么毕业设计必须有 Web 端一个只输出 accuracy 的训练脚本在毕业答辩里是撑不住场面的。老师更想看到的是一个能现场输入的交互系统——输入一句“This movie is fantastic!”界面返回“正面置信度 0.93”。这个演示价值远高于你口头解释 ROC 曲线。常见做法是做一个三层结构数据层预处理与词表、模型层加载训练好的权重、接口层Flask 提供 HTTP 接口。前端可以用一个简单的 HTML 页面加上一点 JavaScript不需要引入大型框架。如果你愿意多做一步可以把模型封装成 REST API再用一个独立的前端页面调接口这样论文里能多写一章“前后端分离设计”工作量展示得很实在。这套结构的另一个优势是模块之间解耦训练和预测共用同一份预处理代码。我见过很多半成品项目把预处理逻辑在训练脚本和预测脚本里各写一份结果清洗规则对不上模型加载后预测结果全部偏到某一类。如果从一开始就把tokenize、encode放进同一个工具模块这类问题就根本不会出现。4.2 Flask 端模型加载、预测接口与置信度返回Flask 端的关键代码并不复杂核心是把模型加载放到全局作用域、只加载一次避免每次请求都重新读权重。另一个关键点是模型的训练模式切换加载后要调用model.eval()这个不写的话 Dropout 仍然生效同样的输入每次预测结果都会不同现场演示时会被老师一眼看穿。from flask import Flask, request, jsonify, render_template import torch app Flask(__name__) # 全局加载一次避免每个请求都重复读文件 device torch.device(cuda if torch.cuda.is_available() else cpu) model BiLSTMClassifier(vocab_sizelen(vocab), embed_size128, hidden_size128, num_layers2, num_classes2) model.load_state_dict(torch.load(best_model.pt, map_locationdevice)) model.to(device) model.eval() app.route(/, methods[GET]) def index(): return render_template(index.html) app.route(/predict, methods[POST]) def predict(): text request.form.get(text, ) if not text.strip(): return jsonify({error: empty input}), 400 tokens clean_text_en(text) # 与训练时完全一致的清洗 ids encode(tokens, vocab, max_len200) seq torch.tensor([ids], dtypetorch.long).to(device) with torch.no_grad(): logits model(seq) prob torch.softmax(logits, dim1) # [1, 2] pos_prob float(prob[0][1].item()) # 索引 1 是正面 label positive if pos_prob 0.5 else negative return jsonify({sentiment: label, probability: round(pos_prob, 4)}) if __name__ __main__: app.run(host0.0.0.0, port5000)这段代码里有三个层面值得展开。第一map_locationdevice是为了跨设备加载模型。训练时用的是 GPU答辩机器上没有 NVIDIA 显卡如果不加这个参数torch.load会直接报错这是现场演示最常翻车的一环。第二encode和clean_text_en必须是从训练代码里 import 出来的同一个函数而不是手抄一遍。第三torch.no_grad()是必须的预测阶段不需要梯度图不写的话每次请求虽然结果一样但内存开销会随着请求次数线性增长。关于模型导出这里建议用state_dict加词表文件的方式而不是torch.save(model, ...)存整个模型对象。理由是state_dict只保存参数体积小且加载时对模型类定义版本的兼容性更好。词表单独存成 JSON 文件加载进内存后会生成一个与模型绑定的vocab全局变量。注意不要在加载后重新调用build_vocab否则词表顺序一变预测结果就会完全错位而且这种错误没有任何报错提示是最让人头痛的“黑匣子”问题。4.3 前端页面与现场演示的边界情况处理前端我一般只写一个最简单的 index.html一个文本框、一个按钮、一个结果显示区域。表单提交用fetch调/predict接口并渲染返回值。这里不要用浏览器自带的 form 提交方式因为页面会刷新演示节奏会被打断。至于样式美化可以直接套一个 Bootstrap 的 CDN 链接两三个组件就能组成一个像样的界面。这部分的最终效果是输入一句英文或中文影评点击按钮页面不刷新异步拿到情感标签和置信度。现场演示时最怕的是输入空文本、超长文本和全标点文本。我在接口里已经写了空文本的 400 返回但超长文本的处理还需要再提一句encode函数里做了tokens[:max_len]截断所以超过 200 词的输入不会报错只会丢失后半段信息。全标点文本经过清洗后得到空 token 列表全部映射为pad模型会输出一个概率接近 0.5 的结果。这种情况虽然不报错但会让老师觉得你对边界条件考虑不周。常见做法是在接口层加一个判断清洗后 token 数量为 0 时直接返回“文本过长或内容无效”的提示。5. 避坑与排查毕业设计里最常翻车的 4 个问题5.1 数据泄漏测试集提前进入了词表现象训练集准确率 92%测试集准确率也高达 91%但换一批新数据来预测时准确率骤降到 80%。原因构建词表时把训练集和测试集的全部文本合在一起统计了词频导致测试集里本身稀缺的词汇变成了词表中的高频词模型在训练时通过这些测试集专属词汇“偷看”了答案。这种泄漏不会报错只会让指标虚高答辩时如果老师现场换数据验证直接穿帮。解决严格规定词表只从训练集构建测试集文本在编码时遇到词表中没有的词一律映射为unk。代码里需要检查build_vocab的调用入口确保传入的是训练集而非合并全集。5.2 训练不收敛或 loss 曲线周期性尖峰现象loss 前几个 epoch 下降后开始震荡每隔几十个 batch 出现一个明显尖峰准确率一直上不去。原因最常见的是学习率偏大或者 LSTM 梯度爆炸。Adam 优化器虽然自带自适应学习率但lr1e-2在这种任务上大概率震荡另外没有梯度裁剪时LSTM 沿时间步展开的梯度范数很容易超过 1造成 loss 尖峰。解决把学习率降到1e-3或5e-4再观察两个 epoch同时加上nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。如果尖峰依然存在检查训练数据里有没有极端长的样本可以把max_len从 200 降到 128通常能明显改善。5.3 模型保存后预测结果全部偏到某一类现象训练时验证集准确率 87%加载保存的权重预测新文本结果几乎全部输出 positive 或几乎全部输出 negative。原因三个高频元凶——第一加载模型前重建了词表导致词索引与训练时不对齐第二训练时 Dropout 开启加载后忘记调用model.eval()第三预测时使用了logits直接取argmax但训练时用的是CrossEntropyLoss两者一致问题反而出在model.eval()上。解决把词表持久化成 JSON 文件预测时加载同一份词表模型加载后显式调用model.eval()预测代码里用torch.no_grad()包裹。如果问题依旧打印一条输入样本的编码结果人工核对索引与单词是否匹配这一步能快速定位问题。5.4 无 GPU 环境下训练慢到让人放弃现象在一台没有显卡的笔记本上embed_size128, hidden_size128, num_layers2加上 25000 条评论每个 epoch 要跑 15 到 20 分钟调三轮参就耗掉半天。原因CPU 训练 LSTM 确实慢但很多时候是数据加载和 pad 策略拖了后腿。解决优先缩小max_len到 128 或 100因为大多数影评的有效信息集中在前 100 个词内这一步对速度影响最大其次把batch_size从 64 降到 32再考虑用预训练词向量如 GloVe替代从零训练 embedding收敛速度会明显加快。如果这些做完还是慢可以先用 5000 条子集做代码验证确认模型能收敛再上全量数据这是最省时间的做法。至于换 GPU 云平台这类路径属于预算问题不属于技术排查范围。6. 把“会调模型”变成“能讲清楚”可视化与消融实验6.1 注意力权重可视化给答辩老师一个看得见的证据BiLSTM 的隐状态本身很难解释但如果模型加了 Attention就可以把每个时间步的注意力权重提取出来在页面上按词展示热力效果。这是目前成本最低也最有说服力的“模型可解释性”素材。实现上在 forward 里把注意力权重attn_weights返回预测时保留它并在前端渲染成彩色文字。注意力可视化可以让“模型在关注哪些词”一目了然比任何指标都直观。这也是为什么我在第 2 章特意提到加 Attention 层的原因——它不只是提升一点点准确率更是答辩的救命稻草。在写论文时除了可视化截图最好再配一张消融实验表。常见做法是固定数据处理流程不变只换模型结构弱基线用 TextCNN中等用 BiLSTM进阶用 BiLSTM Attention如果你有算力可以再加一组 BERT 微调做上限对比。结果表一般长这样模型准确率训练时间CPU参数量TextCNN83.2%约 12 分钟120 万BiLSTM85.6%约 18 分钟210 万BiLSTM Attention86.4%约 19 分钟212 万这张表能回答两个问题为什么不用更简单的模型为什么不用更复杂的模型。加上一句“Attention 在参数量几乎不增加的情况下提升了 0.8%”论文里的模型选型章节就立住了。这比堆砌 ROC 曲线和 PR 曲线更有说服力。6.2 答辩前最后过一遍的自检清单最后给你一份我每次带项目到交付前都会过的清单。第一训练、验证、测试三条数据路径上的清洗函数是否是同一个 import不允许复制粘贴两份第二预测接口是否在 CPU 环境下实际验证过不要在演示当天才发现torch.load因map_location缺失而崩掉第三模型在页面上的响应时间是否在 2 秒以内超过 3 秒老师会不耐烦这时优先减小max_len第四词表、模型权重、代码版本三者是否一一对应换个环境时最容易出现“代码是新的权重是旧的”这种无声错误第五论文里出现的每个指标是否都能在代码运行结果里找到来源严禁手填数字。这几条是我自己踩过不止一次的坑。第一次做类似项目时我把词表构建写在训练脚本里测试脚本里又“顺手”重建了一份结果所有预测结果都错得莫名其妙排查了整整一天才发现是词表不一致。后来我养成了一个习惯所有预处理函数集中在utils.py训练和预测都从这里 importvocab.json在训练结束时自动导出到磁盘预测脚本只 load 不 build。这个习惯帮我避开了很多隐藏问题也让我在答辩被追问时能快速讲清楚每一个文件的作用。希望这份从选型到落地的完整拆解能帮你少走弯路。这个方向本身不难难的是把每个环节都做扎实把每一条结论都讲到能自圆其说。做到这一步别说通过答辩你甚至可以把这个项目放进作品集里去面试 NLP 初级岗位。本文还有配套的精品资源点击获取