深度学习实战:从零搭建电影评论情感分析系统

📅 发布时间:2026/9/23 12:35:21
深度学习实战:从零搭建电影评论情感分析系统
简介这是一套基于深度学习的电影评论情感分析系统Python源码包适合希望完整实践自然语言处理与情感分析流程的中高级开发者、研究者和竞赛学生。系统围绕数据预处理、特征提取、深度学习模型训练、评估与部署展开涉及分词、去停用词、TF-IDF/Word2Vec向量化以及CNN、RNN、LSTM等模型结构可帮助读者快速搭建并复现一个可交互的电影评论情感判别工具。资源包共293个文件约127.96MB主体是.py源代码、.html/.css/.js前端展示模块、.pkl/.npy模型与词向量文件以及.csv/.sql数据文件和.md/.txt说明文档代码结构和目录层次清晰便于按模块阅读理解。目前已有84人在平台学习或下载适合用来作为毕业设计、课程项目或技术预研的参考模板。1. 为什么说电影评论情感分析是深度学习入门最合适的练手项目毕业论文和课程设计的季节最多人问我的就是这类标题的项目包python基于深度学习的电影评论情感分析系统。名字看着长拆开其实就四件事——拿到一批带标签的评论数据做清洗和分词用深度学习模型学习文本里的情绪信号最后对一条新评论输出正面还是负面。整个过程是 NLP 文本分类的标准流水线也是把 python 基础语法、深度学习环境配置、数据处理实战一次串起来最有效的练习项目。这篇笔记不打算介绍某个现成压缩包里的文件结构而是带你从头搭一版能跑通、能答辩、能讲清每个参数作用的系统。不管你是想读懂下载来的项目包还是打算从零写一套交作业按照「数据 → 预处理 → 向量化 → 模型 → 训练评估」的顺序走都不容易跑偏。选电影评论这个场景还有个额外好处IMDb 和豆瓣都有大量公开标注数据标签质量比随意抓来的短文本高得多做出来的系统效果可解释性也强。2. 数据和预处理一条评论变成干净文本的全过程2.1 数据从哪来IMDb 自带标签和中文评论兜底做情感分析第一步不是选模型而是选数据。英文场景直接用 IMDb 的 Large Movie Review Dataset 最省事5 万条带标签评论训练集和测试集各占一半评论平均长度在 200 词左右题材覆盖广基本不需要额外标注。它的标签机制很巧妙每条评论同时带 1 到 10 分的评分评分小于等于 4 的归为负面大于等于 7 的归为正面5 分和 6 分的模糊地带直接丢掉这样二分类的边界非常干净。中文场景则需要自己想办法。像美团这类平台对商家评论做差评预警后台跑的其实也是同一套流程但他们的标注数据不对外开放。课程设计里常见的做法是用豆瓣电影短评作为数据源但豆瓣只显示总体评分单条评论没有分数需要自己打标。一个折中的方案是抓取短评后按评论长度、星标等级做半自动标注或者直接使用开源的酒店评论、电商评论数据集后者在格式上跟电影评论几乎没有差别。如果一定要爬我一般会写一个最小化的爬虫兜底重点不是爬得全而是结构清晰、频率可控import requests import time from bs4 import BeautifulSoup def fetch_reviews(url, max_pages5): headers {User-Agent: Mozilla/5.0 (study project)} for page in range(max_pages): resp requests.get(url, headersheaders, timeout10) soup BeautifulSoup(resp.text, html.parser) for item in soup.select(.review-item): text item.get_text(stripTrue) if len(text) 10: yield text time.sleep(2) # 控制请求频率避免给对方服务器造成压力 for review in fetch_reviews(https://example.com/movie/reviews): print(review)这段代码里select(.review-item)是页面里评论条目的 CSS 选择器实际使用时必须根据目标网站的 HTML 结构调整。time.sleep(2)是关键没有这个延迟快速请求很容易被对方反爬策略拦截严重时还会影响自己的出口 IP。实际做项目时我还是建议优先使用公开标注数据集爬虫只作为补充来源负责给模型增加一些新鲜样本不要让它成为数据主渠道。2.2 清洗与去重HTML、URL、表情和重复评论从任何渠道拿到的原始文本都不能直接丢给模型。电影评论里最常见的噪音是 HTML 标签、URL、数字、特殊符号和多余空白另外豆瓣短评里大量重复刷屏的内容也要去掉。清洗这一步做得好不好直接影响后续分词和词表构建的质量。我常用的清洗函数长这样import re import unicodedata def clean_text(text): # 统一 Unicode 编码把全角符号转成半角 text unicodedata.normalize(NFKC, text) # 去掉 URL text re.sub(rhttps?://\S|www\.\S, , text) # 去掉 HTML 标签 text re.sub(r[^], , text) # 去掉表情和特殊符号中文和英文、数字都保留 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9\s], , text) # 连续空白压缩成一个空格 text re.sub(r\s, , text).strip() # 纯英文文本统一小写中文不要小写化 if not re.search(r[\u4e00-\u9fa5], text): text text.lower() return text这里有个细节值得注意NFKC归一化会把全角逗号、括号转成半角免得同一个标点被拆成两种特征中文文本不能直接做lower()因为中文没有大小写概念做了也无所谓但做了也不会错。保留数字还是去掉数字要看任务电影评论里「这部电影我给 10 分」的「10」是有情感含义的所以我只去掉纯数字的词而不是在清洗阶段就把数字全部抹掉。去重则简单一些维护一个 hash 集合重复评论直接跳过即可。2.3 标签怎么定二分类还是三分类很多初学者一上来就做三分类正面、负面、中性。这个想法本身没错但落地时最容易翻车。原因很简单中性评论的边界极其模糊「这部电影还行」到底是中性还是弱正面标注者自己都容易产生分歧。而二分类只需要判断「值不值得推荐」主观性弱很多模型学起来也更稳定。从数据利用角度讲二分类能直接利用 IMDb 的评分机制自动打标签三分类则意味着必须人工标注大量样本课程设计的时间成本完全不是一个量级。我的建议是系统的核心功能做成二分类如果展示页面需要三类结果可以设置一个置信度阈值比如正面概率在 0.45 到 0.55 之间就显示「中性」这样既保留了二分类的训练稳定性又实现了三分类的展示效果一举两得。3. 文本向量化与数据集构建让模型读懂评论3.1 分词是第一步英文按空格中文交给 jieba深度学习模型吃的是数字不是文字。把清洗后的文本变成数字序列第一步是分词。英文分词简单按空格切分就够了因为英文单词之间天然有空格分隔中文没有这个边界必须借助分词工具。目前使用最广泛的仍是 jieba虽然它在电影名、人名这类专有名词上偶尔会切错但对情感分析任务来说错误率完全可以接受。我的分词代码如下import jieba import re STOP_WORDS set() with open(stopwords.txt, encodingutf-8) as f: STOP_WORDS {line.strip() for line in f if line.strip()} def tokenize(text): if re.search(r[\u4e00-\u9fa5], text): words jieba.cut(text) else: words text.split() return [w for w in words if w and w not in STOP_WORDS and not w.isdigit()]stopwords.txt是常见的停用词表网上有开源版本可以直接下载里面是「的、了、和、是」这类没有情感含义的虚词。加停用词的目的有两个一是减少词表体积二是避免模型把注意力浪费在无意义的词上。需要注意的是停用词表不是越大越好像「不是」「但是」这类转折词如果被误删会让负面评论失去关键信号。所以我一般只删除纯虚词保留否定词和程度副词。3.2 词表、序列化与 paddingmax_len 到底设多大分词之后要把每个词映射成一个整数 ID。做法是先统计全部训练集的词频构建一个词表然后把每个句子替换成 ID 序列。这里隐藏着两个经典问题低频词怎么处理句子长短不一怎么办。from collections import Counter import torch from torch.utils.data import Dataset def build_vocab(tokenized_texts, min_freq2): counter Counter(w for s in tokenized_texts for w in s) vocab {PAD: 0, UNK: 1} vocab.update({w: idx for idx, (w, c) in enumerate(counter.items(), 2) if c min_freq}) return vocab class ReviewDataset(Dataset): def __init__(self, texts, labels, vocab, max_len100): self.data [self._encode(s, vocab, max_len) for s in texts] self.labels labels self.vocab vocab def _encode(self, tokens, vocab, max_len): ids [vocab.get(w, vocab[UNK]) for w in tokens[:max_len]] return ids [0] * (max_len - len(ids)) def __len__(self): return len(self.data) def __getitem__(self, idx): return torch.tensor(self.data[idx]), torch.tensor(self.labels[idx])min_freq2表示只出现一次的词一律映射成UNK这个词表里没有的意义不大的词统一用未知标记代替。max_len的选择需要看数据分布电影评论大多在 50 到 150 词之间我一般先统计一下训练集的长度分布取 90% 分位作为max_len而不是拍脑袋定一个数。序列超过max_len就截断不足就补零补零对应PAD这个 ID。建好的词表是整个项目最值得保存的中间产物后续预测时必须加载同一个词表否则 ID 对应关系全乱。3.3 Embedding随机初始化还是加载预训练词向量ID 序列不能直接进卷积或循环网络需要先经过 Embedding 层转换成稠密向量。Embedding 层本质是一个可学习的查找表把每个词 ID 映射成一个固定维度的向量。最常见的做法是随机初始化这个查找表然后跟着模型一起训练对中小规模数据集来说效果已经不错代码也最简单embedding nn.Embedding(len(vocab), embedding_dim100, padding_idx0)padding_idx0告诉 PyTorch 这个位置的向量固定为全零且在训练中不更新避免无意义的补零位干扰模型。embedding_dim一般取 100 或 300维度越高能容纳的语义信息越多但参数量也线性上升。如果数据量不足可以考虑加载预训练词向量比如英文的 GloVe 或中文的腾讯词向量把对应词的向量初始化到查找表里再用训练数据做微调。这样做的收益在小数据集上很明显代价是加载和预处理多一些代码且 OOV词表外词依然回退到随机初始化。4. 模型选型与搭建TextCNN、BiLSTM 到 BERT 的取舍4.1 TextCNN为什么它是短文本情感分析的默认起点文本卷积网络TextCNN是我在情感分析实战项目里默认先跑的模型。它的核心思路是用一维卷积核在词向量序列上滑动每种卷积核尺寸对应一个 n-gram比如卷积核大小为 3 就捕捉连续的三个词的局部特征大小为 5 就捕捉五个词。电影评论里「太差了」「精彩绝伦」这类情感短语通常集中在几个词内TextCNN 的 n-gram 特征刚好能命中。import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim100, num_filters128, kernel_sizes(2, 3, 4), num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embedding_dim, num_filters, k) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): # x: [batch_size, seq_len] emb self.embedding(x) # [B, L, D] emb emb.transpose(1, 2) # [B, D, L]Conv1d 期望通道维在第 1 维 pooled [] for conv in self.convs: conv_out conv(emb) # [B, num_filters, L - k 1] pooled.append(F.relu(conv_out).max(dim2).values) out torch.cat(pooled, dim1) # [B, num_filters * len(kernel_sizes)] return self.fc(self.dropout(out))kernel_sizes(2, 3, 4)对应二元组、三元组、四元组三种 n-gram 特征num_filters128表示每种尺寸的卷积核有 128 个相当于用 128 个不同视角去抓取同一个 n-gram 模式。max(dim2)做全局最大池化取出每个特征图里最强烈的信号这也是 TextCNN 的一个特点只要句子某个位置出现了强烈的情感词不管它在句子开头还是结尾都能被捕捉到。dropout0.5是防止过拟合的关键训练时随机丢弃一半神经元的输出测试时自动关闭。TextCNN 的参数量主要由 Embedding 层决定卷积部分很小训练速度极快CPU 上几分钟就能跑完一个 epoch。它的问题也很明确完全靠局部 n-gram 判断情感忽略了词与词之间的距离和顺序关系比如「不是……而是……」这种长距离转折它就很难学好这就是后面 BiLSTM 存在的意义。4.2 BiLSTM 补什么顺序信息与注意力机制双向 LSTM 通过正向和反向两个方向的循环单元让每个时间步的输出同时包含当前词之前和之后的上下文信息。对情感分析来说这意味着模型能学会「虽然……但是……」这种转折结构知道「虽然」后面跟着的内容权重更低「但是」后面的才是真正的态度。这是 TextCNN 学不到的长距离依赖能力。class BiLSTM(nn.Module): def __init__(self, vocab_size, embedding_dim100, hidden_size128, num_layers2, num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb self.embedding(x) out, _ self.lstm(emb) # out: [B, L, hidden_size * 2] out out[:, -1, :] # 取最后一个时间步正反向已经对齐 return self.fc(out)注意nn.Linear(hidden_size * 2, num_classes)的* 2是因为双向拼接后维度翻倍。代码里取最后一个时间步的输出其实有些粗糙更精细的做法是在所有时间步的输出上做注意力加权让模型自己学会把注意力放在「烂」「赞」这类决定性词汇上。BiLSTM 的代价是训练速度明显慢于 TextCNN而且两个方向的循环无法并行在长文本上更容易出现梯度消失需要配合梯度裁剪使用。4.3 BERT 什么时候上算力、数据量与性能的平衡BERT 系列模型在绝大多数 NLP 任务上都能碾压前面两种结构但代价是参数规模大两三个数量级。以bert-base-uncased为例约 1.1 亿参数显存占用在 6GB 以上微调一个 epoch 的时间是 TextCNN 的几十倍。对电影评论情感分析这种短文本分类任务TextCNN 和 BiLSTM 通常已经能把准确率做到 88% 到 92%而微调 BERT 也许能把上限推到 95% 左右但投入产出比要看数据集规模和你的算力条件。我给出的实际建议是课程设计和毕业设计阶段先用 TextCNN 把完整流程跑通再对比一个 BiLSTM 作为改进点只有当你的数据量超过 10 万条且导师明确要求探索最新方法时才考虑微调 BERT。真要用 BERT代码其实不复杂关键在于环境配置和显存管理from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(bert-base-uncased) bert AutoModel.from_pretrained(bert-base-uncased) # 取 [CLS] 位置的输出向量接一个分类层学习率设为 2e-5 ~ 5e-5BERT 微调与传统模型最大的区别是学习率要小两个数量级因为预训练参数已经很好了学习率过大会把学到的知识冲掉。另外输入需要按 BERT 的要求做特殊处理句首加[CLS]句尾加[SEP]相同的句子要加 attention mask。这三种模型的取舍可以看这张表模型参数量级训练速度效果基线适合场景TextCNN百万级极快CPU 可跑0.87-0.91 准确率短文本、对延迟敏感、算力有限BiLSTM百万级较慢建议 GPU0.88-0.92需要捕捉转折、长依赖的文本BERT亿级GPU 上仍需数小时0.93-0.96数据量大、算力充足、追求极限5. 训练评估与避坑让 loss 正常下降让准确率不骗人5.1 训练循环与关键参数优化器、学习率、梯度裁剪搭建好模型后训练循环是整个项目最需要耐心调整的部分。我第一次写这套代码时把学习率设成 0.01结果 loss 在 0.7 附近疯狂震荡怎么都下不去后来才明白 Adam 优化器的默认学习率 1e-3 已经够用手调反而容易调崩。下面是经过验证的最小训练循环import torch from torch.optim import Adam def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, total_correct, total 0.0, 0, 0 for batch_x, batch_y in dataloader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() * batch_x.size(0) total_correct (logits.argmax(dim1) batch_y).sum().item() total batch_x.size(0) return total_loss / total, total_correct / totalclip_grad_norm_(max_norm1.0)是做梯度裁剪把梯度的全局范数限制在 1.0 以内防止梯度爆炸导致 loss 突然变成 NaN。这段代码里最容易被忽略的是model.train()和后面的model.eval()切换训练时要开启 dropout验证和预测时必须关闭否则预测结果会有随机性。关键参数的设置经验是这样的TextCNN 用 Adam学习率 1e-3batch_size 32 或 64 都可以BiLSTM 建议学习率降到 5e-4同时打开梯度裁剪BERT 微调学习率 2e-5。训练轮次不要拍脑袋定 50 个 epoch我一般保存验证集上 F1 最高的模型设置 Early Stopping连续 3 个 epoch 验证集指标没有提升就停止训练这个 patience3 的经验值能避免模型过拟合。还有一件事很重要固定随机种子包括 Python、NumPy、PyTorch 的种子不然每次跑出来的结果都不同调参时根本分不清是改参数的效果还是随机性带来的波动。5.2 评估指标准确率、F1 与混淆矩阵的配合情感分析项目里最常见的误区是只看准确率但这在类别不平衡时会被严重误导。比如 90% 的样本是正面评论模型全预测正面准确率也有 90%实际上一个正面词都没学到。电影评论数据集的标签相对均衡但训练过程中仍然要养成用混淆矩阵评估的习惯from sklearn.metrics import confusion_matrix, classification_report def evaluate(model, dataloader, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for batch_x, batch_y in dataloader: logits model(batch_x.to(device)) y_pred logits.argmax(dim1).cpu().tolist() y_true batch_y.tolist() print(classification_report(y_true, y_pred, target_names[negative, positive])) print(confusion_matrix(y_true, y_pred))classification_report会同时输出精确率、召回率和 F1 值这三项比单一准确率信息量大得多。精确率低说明模型经常把负面的误判成正面的召回率低说明它漏掉了不少负面评论。对情感分析系统来说我通常更关注负面评论的召回率因为实际使用中漏掉一条差评比误伤一条好评代价更大。混淆矩阵则能直观看出模型在哪个方向上犯的错误更多。5.3 高频踩坑记录四种翻车现场与解决办法整理几个我在实战里真正遇到过的坑每个都是血泪经验换来的坑一训练时 loss 正常下降预测时报词表 KeyError。现象是训练过程一切正常但单独预测一条新评论时程序报错原因是训练和预测用了两套不同的build_vocab逻辑或者根本没有保存词表。解决办法很简单训练结束后把vocab用json.dump保存到本地文件预测脚本加载模型的同时加载同一个词表文件保证 ID 映射完全一致。坑二中文评论预测结果几乎全是负面。现象是英文数据集上模型效果很好换成中文数据后准确率骤降。原因是分词没做直接把中文字符串按空格切分成了一整个长串模型根本没有学到词汇级别的特征。解决办法是接入 jieba 分词同时确认停用词表是中文版本而不是英文版本。另一个可能原因是数据集里的短评太短大量样本清洗后只剩一两个词这种数据需要检查清洗规则是否误删了内容。坑三loss 震荡不下降或者验证集 loss 先降后升。现象是训练集 loss 一直在 0.69 附近徘徊二分类随机猜测的 loss 值验证集 loss 在第三个 epoch 后掉头向上。前者通常是学习率过大或优化器选择不当把学习率从 1e-3 降到 5e-4 试试后者是过拟合信号需要增强 dropout、增大训练数据或提前停止而不是继续闷头训练更多轮次。坑四训练到一半显存溢出batch_size 调小后 loss 反而变大。现象是 GPU 显存不足报 CUDA out of memory把 batch_size 从 64 减到 16 后训练不稳定。原因是 batch_size 过小时每个 batch 的梯度噪声变大BN 层如果用了的统计量也不准确。解决办法是保持 batch_size 不低于 32显存不够就减少max_len或降低 embedding_dim而不是一味缩小 batch_size。6. 把系统变成一个能用的服务FastAPI 封装、模型保存与多模态延伸模型训练完成只是项目的一半答辩时老师一定会问「你的系统怎么给别人用」。最简单的做法是用 FastAPI 封装成一个 HTTP 接口前端页面上传评论文字后端返回情感判断结果。关键是把前面写的清洗、分词、编码逻辑原封不动地复制到预测服务里一个函数都不能少from fastapi import FastAPI from pydantic import BaseModel app FastAPI() model torch.load(best_model.pt, map_locationcpu) model.eval() class Review(BaseModel): text: str app.post(/predict) def predict(review: Review): ids torch.tensor([preprocess(review.text)]) # 复用训练时同一套流水线 with torch.no_grad(): logits model(ids) prob torch.softmax(logits, dim1)[0].tolist() return {negative: round(prob[0], 4), positive: round(prob[1], 4)}实际部署时记得用map_locationcpu让模型在无 GPU 的机器上也能跑预测接口走批量处理而不是单条循环响应速度会快很多。接口之外我强烈建议把三个文件一起保存模型权重、词表 JSON、预处理配置包括max_len、清洗规则版本这三个文件放到同一个目录缺一个系统都跑不起来。进阶方向可以往多模态走一步把电影海报图像和评论文本结合起来判断情感视觉特征和文本特征做特征拼接这已经是当前从文本情感分析到多模态情感分析的主流研究趋势。不过对入门项目来说把现有系统做到接口稳定、参数可解释比盲目追新模型更能体现你的工程能力。我做文本项目的习惯是第一件事固定随机种子并制定模型保存命名规则第二件事把词表和配置单独存一份这样即使模型训练翻车也有后悔药可吃。希望帮到你。本文还有配套的精品资源点击获取