简介:这是一个基于Python与深度学习的电影评论情感分析系统,采用前后端分离架构并接入MySQL数据库,附有完整说明文档与LW版本标识,定位于毕业设计、NLP入门和项目复现人群。zip包共293个文件,大小约128.01MB,其中包含Python源码、前端js/css/html页面、MySQL建表SQL、模型权重文件(npy/pkl/pb)、Markdown/Word说明文档,以及GIF演示、图表图片、字体图标等静态资源,目录结构完整清晰。当前已有55人学习/下载。系统利用TensorFlow/Keras构建LSTM和GRU循环神经网络,先通过词嵌入对影评进行向量化表示,再捕捉句子序列上下文完成正面/负面情感分类;后端以Python Web框架提供API接口,前端使用layui、bootstrap等成熟组件展示预测结果,整个流程可复现。说明文档对系统架构、模型原理、部署步骤、参数训练等方面均有交代,预览中也可看到现成UI样式,读者可据此快速搭建环境、理解从数据到模型的完整实现,并迁移到其他情感分析或文本分类项目。
1. 开工前先搞懂:这套电影评论情感分析系统在解决什么问题
日常开发里,前端、后端、模型是三种习惯完全不同的工作:前端在意交互粒度,后端在意数据一致性和接口字段,而模型训练最关心语料分布和指标。标题“python基于深度学习的电影评论情感分析系统(完整前后端+mysql+说明文档+LW)”之所以常被当成毕设或求职项目首选,不是因为它算法有多前沿,而是因为它的链路非常完整:用户在前端输入一段电影评论,后端调用 python 训练好的深度学习模型,把结果写进 MySQL,然后在页面上立刻回显“正面/负面/中性”。它适合三类人:想在一个系统里同时展示 NLP 建模能力、工程落地能力和数据库设计能力的开发者;正在做毕业设计,需要“模型+系统+文档”一次性交付的学生;以及想从纯算法转向全栈的工程师。需要注意的是,这套系统的难点不在单一环节,而在于把评论清洗、模型推理、接口封装、数据库事务和前端联调串成一个整体。
2. 模型选型与训练:用预训练词向量+LSTM把评论分析做成真的
2.1 为什么选 LSTM,而不是更快/更深的模型
电影评论里常见“剧情拖沓但我看完了”“并没有那么难看”这类句式。这类句子的核心词出现在句尾,靠词典匹配或统计词频很容易误判。LSTM 的优势在于它通过门的机制把前面出现的“剧情拖沓”与后面的转折“但”逐步传递到最后一个时刻的隐藏状态,再做分类,对这类中长文本更友好。
TextCNN 在短文本上速度更快,但卷积窗口通常只覆盖 3-5 个词,看见“没那么难看”时容易把“难看”当作负面信号。BERT 的精度确实高,但微调和推理都需要较大显存;把 BERT 塞进一个用 Flask 或 Django 写的单体系统里,不仅拉高部署成本,还会让评估接口的响应时间明显变长。对这套“完整前后端+mysql”的系统来说,常见做法是一个 2 层的 BiLSTM 加一个全连接分类头,几万条样本就能训到一个可用的水平。
提示:如果后续想把系统升级为多模态情感分析,可以保留当前 LSTM 的推理接口不变,只替换后端调用的模型对象,前端和数据库结构不需要改动。
2.2 评论数据预处理与词表构建
训练前先做清洗。电影评论来自爬虫或公开数据集,里面常见的噪声包括:HTML 标签、连续空格、网址、@ 用户名、表情符号、频率极低的人名地名。清洗函数按顺序处理,处理完分词,再按词频构建词表。
import re import jieba STOP_WORDS = set() with open("stopwords.txt", encoding="utf-8") as f: for line in f: if line.strip(): STOP_WORDS.add(line.strip()) def clean_review(raw: str) -> str: raw = re.sub(r"<[^>]+>", " ", raw) # 去掉HTML标签 raw = re.sub(r"http\S+", " ", raw) # 去掉网址 raw = re.sub(r"@\w+", " ", raw) # 去掉@用户名 raw = re.sub(r"[a-zA-Z0-9]", " ", raw) # 去掉英文和数字 raw = re.sub(r"\s+", " ", raw) # 压缩空白 return raw.strip() def tokenize(text: str) -> list: text = clean_review(text) words = jieba.lcut(text) return [w for w in words if w not in STOP_WORDS and len(w.strip()) > 1]第一次构建词表时固定一个MAX_VOCAB_SIZE,比如 30000,统计分词结果里出现频率最高的词。不在词表里的词统一映射为[UNK],句子开头加[CLS],长度超过MAX_SEQ_LEN的做截断,不够的在后面补[PAD]。两个占位符都在词表里占一个编号。
MAX_SEQ_LEN对电影评论很关键。短评平均在 30 到 60 个字,长评可能接近 1000 字。如果你直接截到 128,模型只看到了前半段;推荐按训练集长度的后 80% 分位点来设这个值,避免为了适配极个别超长评论浪费计算量。我一般会先对训练语料的 token 数量画一个分布图,再取分位数。
2.3 网络结构:BiLSTM 加全连接分类头
这套毕设系统里采用 PyTorch 实现,核心结构是词嵌入层、双向 LSTM、最大池化、全连接输出。
import torch import torch.nn as nn class SentimentLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=200, hidden_size=128, num_layers=2, num_classes=3, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0) self.classifier = nn.Sequential( nn.Dropout(dropout), nn.Linear(hidden_size * 2, 64), nn.ReLU(), nn.Linear(64, num_classes) ) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embed_dim] out, _ = self.lstm(emb) # [batch, seq_len, hidden*2] out = out.max(dim=1).values # 取每个句子在时间维上的最大值 return self.classifier(out)embed_dim=200是词向量的维度,hidden_size=128是 LSTM 每个方向的隐藏状态大小,双向拼接后进入分类器的特征维度是 256。max(dim=1)负责把每个词在所有时间步上的输出聚合,相比只取最后一步,表达式“并没那么难看”中的“难看”无论在句中的哪个位置都能被捕捉到。
训练时用CrossEntropyLoss,优化器用 Adam,配合 ReduceLROnPlateau,验证集损失连续两个 epoch 不再下降就降低学习率。
criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=2) for epoch in range(20): model.train() total_loss = 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() total_loss += loss.item() val_loss, val_acc = evaluate(model, val_loader) scheduler.step(val_loss) print(f"epoch={epoch} loss={total_loss / len(train_loader):.4f} " f"val_acc={val_acc:.4f} val_loss={val_loss:.4f}")batch_first=True让输入的维度对齐为[batch, seq_len],这也是数据加载器默认输出的顺序。训练阶段把batch_size拉高到 128 不会明显影响精度,但会直接提高 GPU 或 CPU 的利用率。当 epoch 超过 15 而验证集 acc 不再提升时,应该以模型文件的大小和响应时延为主要约束来做早停。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| embed_dim | 200 | 词向量维度,语料小就降到 100 |
| hidden_size | 128 | 单向 LSTM 隐层大小 |
| num_layers | 2 | 超过 2 层在小语料上收益很小 |
| dropout | 0.5 | 防止在几万条样本上过拟合 |
| batch_size | 128 | CPU 训练可降到 32 |
| lr | 1e-3 | 配合 ReduceLROnPlateau 使用 |
注意,nn.Embedding的padding_idx要和词表里[PAD]对应,这样补位位置不会在反向传播中产生梯度,能有效减少不同长度评论带来的噪声。
2.4 评估视角:准确率、F1、混淆意见
毕设答辩中只报一个准确率很容易被追问。分类样本不平衡在电影评论里非常常见:好评数量往往远大于差评,模型只要全部预测成“正面”就能得到很高的正确率,无法体现实际能力。所以评估要用classification_report看每个类别的 precision、recall、F1,再额外统计“负面被误判为中性”这类混淆情况。
from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_true, y_pred, target_names=["negative", "neutral", "positive"])) print(confusion_matrix(y_true, y_pred))当训练集只有 3 万条且正负样本比例接近 7:3 时,把中性样本去掉或者重新采样都会影响最终判别。真实系统中保留中性类更实用,因为用户提交的评论本来就不全是极端情绪。
3. MySQL 存储与后端接口:把预测流程接进数据库
3.1 表结构设计:用户、电影、评论、预测拆分
系统要求“完整前后端 + MySQL”,表结构设计是这部分最重要的体现。常见做法是拆成 4 张表:user记录用户,movie记录电影,comment记录原始评论,sentiment_result记录每次预测的输出和置信度。评论和分析结果分开存,可为后续人工复核留出空间。
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, director VARCHAR(100), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE comment ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, movie_id INT NOT NULL, content TEXT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0-正常 1-已删除', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_movie_id (movie_id), CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_movie FOREIGN KEY (movie_id) REFERENCES movie(id) ); CREATE TABLE sentiment_result ( id INT PRIMARY KEY AUTO_INCREMENT, comment_id INT NOT NULL, sentiment TINYINT NOT NULL COMMENT '0-负面 1-中性 2-正面', confidence FLOAT NOT NULL, model_version VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_comment (comment_id) );comment表保留用户原文,sentiment_result只存结果,这样即使后续换模型重跑测试,也只需要按comment_id重建结果表。业务上“展示历史记录”每次都从comment关联sentiment_result查询;分页时对comment表做主查询,再批量查结果,避免逐条回表。
提示:不要把模型预测的字符串直接存数据库。用
TINYINT存 0、1、2,前端展示时再映射成“负面、中性、正面”,后续做统计和建索引都会更快。
3.2 Flask 后端如何加载模型并提供预测接口
路径别写死。模型文件model.pth放在models/目录,通过配置类读取。接口层的常见结构是:Flask 应用启动时加载一次模型和词表;每次请求只做 tokenize、编码、前向推理;拿到结果后先落库,再返回 JSON。
from flask import Flask, request, jsonify import pymysql import torch from model import SentimentLSTM from utils import tokenize, build_vocab app = Flask(__name__) MODEL_PATH = "models/model.pth" word2idx = build_vocab("vocab.json") model = SentimentLSTM(vocab_size=len(word2idx)) model.load_state_dict(torch.load(MODEL_PATH, map_location="cpu")) model.eval() def get_db(): return pymysql.connect( host="127.0.0.1", port=3306, user="root", password="123456", database="movie_sentiment", cursorclass=pymysql.cursors.DictCursor, charset="utf8mb4" ) @app.route("/api/predict", methods=["POST"]) def predict(): data = request.get_json() content = data.get("content", "") if not content: return jsonify({"code": 400, "msg": "评论内容不能为空"}), 400 ids = [word2idx.get(w, word2idx["[UNK]"]) for w in tokenize(content)[: 128]] ids = [word2idx["[CLS]"]] + ids pad_len = 128 - len(ids) if pad_len > 0: ids = ids + [word2idx["[PAD]"]] * pad_len x = torch.tensor([ids], dtype=torch.long) with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=-1).squeeze().tolist() sentiment = int(torch.argmax(logits, dim=-1).item()) confidence = max(prob) return jsonify({"sentiment": sentiment, "confidence": confidence})上面这段实现了完整链路上最关键的一环。word2idx.get使用.get而不是直接下标访问,为的是让生词落到[UNK];pad_len用128 - len(ids)计算,是因为评论在进入接口前已经截到 128,不再依赖训练阶段那个更大的MAX_SEQ_LEN。confidence取 softmax 概率最大值,写库时也用它排序,便于后续挑出低置信度样本做人工复核。
3.3 接口落库与前端交互的衔接
预测接口里只有一个return并不完整,要让 MySQL 真正记录这次请求,就应该在返回前把comment和sentiment_result数据写入数据库。这里有一个隐藏陷阱:如果先插评论、后插结果,而结果插入失败,会出现“有评论无结果”的脏数据。常见做法是用一个事务把两个 insert 包起来,任何一步失败就回滚。
def save_comment_and_result(user_id, movie_id, content, sentiment, confidence): conn = get_db() try: with conn.cursor() as cursor: conn.begin() cursor.execute( "INSERT INTO comment (user_id, movie_id, content) VALUES (%s, %s, %s)", (user_id, movie_id, content) ) comment_id = cursor.lastrowid cursor.execute( "INSERT INTO sentiment_result (comment_id, sentiment, confidence, model_version) " "VALUES (%s, %s, %s, %s)", (comment_id, sentiment, confidence, "lstm_v1") ) conn.commit() return comment_id except Exception as exc: conn.rollback() raise exc finally: conn.close()保存评论与分析结果到两个表里,用conn.begin()开启事务,任一步异常都执行rollback(),避免出现流水线不一致。把模型版本写进sentiment_result,可以让“完整前后端 + mysql + 说明文档”交付物里的每一次预测都有迹可循,也是说明文档中架构图上最好画的一条链路。
4. 前端页面与前后端联调:Vue 下完成评论提交、结果回显
4.1 前端功能拆解:页面、组件和接口的对应关系
如果这是一套 Vue 项目,一般会有三个主要页面:首页展示电影列表和最新评论汇总;评论页提供文本输入与提交;个人中心展示当前用户的历史评论及情绪分布。后端接口和页面的行为要一一对应。
| 前端页面 | 主要接口 | 用途 |
|---|---|---|
| 电影列表页 | GET /api/movies | 展示电影列表 |
| 详情/评论页 | POST /api/predict | 提交评论文本并获得预测 |
| 个人中心 | GET /api/my/comments | 展示历史记录 |
| 个人中心 | GET /api/my/stats | 展示情绪统计折线图 |
前端不能直接把comment表拿过来用,而是让接口返回约定好的 JSON。常用字段是commentId、movieTitle、sentiment、confidence、createdAt。这类接口需要把 id 转换为可读的展示文本,转换逻辑放在后端完成,前端只负责渲染。
4.2 前后端联调时最容易出问题的跨域
前端跑在localhost:5173是 Vue 默认端口,后端默认是 Flask 的localhost:5000,端口不同,浏览器会直接阻止请求。解决方式有两种:后端加 CORS,前端配置代理。在 Flask 里安装flask-cors,然后CORS(app)即可。
很多初学者把它理解为“安全配置”,为了避免变动反复加规则。实际 CORS 是浏览器的同源策略,并不是加密。在本地联调阶段直接使用CORS(app)提高效率;部署到生产再通过 Nginx 反向代理让前后端使用同一个域名,一次性解决跨域问题。
4.3 Vue 里用 axios 提交评论并回显预测结果
前端用一个Vue单文件组件承载评论提交。提交时把movieId、content发给后端,收到返回后把sentiment映射成中文,confidence格式化掉多余的精度,再拼到历史列表的头部。
<script setup> import axios from 'axios' import { ref } from 'vue' const content = ref('') const result = ref(null) const labelMap = ['负面', '中性', '正面'] async function submitComment() { if (!content.value.trim()) return const { data } = await axios.post('/api/predict', { movieId: 7, content: content.value }) if (data.code === 0) { result.value = { label: labelMap[data.sentiment], confidence: (data.confidence * 100).toFixed(2) + '%' } } } </script> <template> <textarea v-model="content" placeholder="说说这部电影的看法"></textarea> <button @click="submitComment">提交检测</button> <p v-if="result">结果:{{ result.label }},置信度:{{ result.confidence }}</p> </template>labelMap是一个数组,下标正好对应后端返回的sentiment,这要求前后端在字段定义上保持一致。toFixed(2)把置信度保留两位小数,最适合直接展示。result只存展示用的数据而不是整包响应对象,避免后续把多余字段暴露在页面上。
联调时可以先不经过界面,直接用 curl 验证接口。curl 验证完之后,再回到页面点击按钮,发现问题基本上都集中在 JSON 字段名拼写和请求方式不匹配。例如后端要求 POST,前端写成了 GET,返回 405,就先检查axios.post的用法。
5. 标注效率与可视化:常见坑与实用改进
5.1 中性样本的取舍
许多开源的电影评论数据集只有“正面、负面”两维标注,但实际系统在上线后经常收到“剧情一般、不算差”这类中性评论,模型被迫在两个类里二选一,置信度往往很低。改进方式是把原始训练集中置信度处于 0.35 到 0.65 的样本找出来,人工重新标注出一条中类,并将其作为第三类加进训练。数据标注的过程可以用表格管理,每行记录评论原文、初判结果、人工复核结果和修正原因,避免两个人标注同一批数据时口径不一致。对于手动标注成本,控制在 2000 到 5000 条即可明显改善边界样本的表现。
5.2 用缓存表加速重复文本
预测接口第一次对某条评论分类,可能需要几百毫秒;但如果多人提交完全相同的短评,重复跑模型非常浪费。可以加一张predict_cache表,以 128 token 截断后的文本 hash 为 key,命中缓存直接返回。这张表建在 MySQL 里比使用 redis 更符合“完整前后端+mysql”的交付形态,部署时不需要额外引入服务。
CREATE TABLE predict_cache ( id INT PRIMARY KEY AUTO_INCREMENT, text_hash CHAR(32) NOT NULL UNIQUE, sentiment TINYINT NOT NULL, confidence FLOAT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );命中时把confidence原样返回;未命中时推理并写入缓存。每天日终再清理一次最近 3 天没有访问的记录。这种优化实现成本低,却能在答辩和演示中直观展示“响应时间从 287ms 降到 3ms”。UNIQUE约束会自动建立唯一索引,在数据库层防止同一文本被重复写入。
5.3 端到端的验证文件
说明文档里经常只写接口列表,忽略手动验收流程。真正可复现的交付物,应该包含表结构说明、数据流图、部署步骤、以及一个按顺序执行的测试清单。测试清单记录一条真实电影评论、预期输出、实际输出和修复情况,作为前后端联调验收的依据。
可以在项目根目录放一个smoke_test.sh,内容依次执行“启动前检查 MySQL 连接 → 确认 Flask 健康检查接口 → 读取一条测试评论 → 调用 /api/predict → 查询 sentiment_result 表确认落库”。每次提交代码前先跑这个脚本完成回归验证,再写说明文档里的测试章节,整个交付过程会顺畅很多。
本文还有配套的精品资源,点击获取