简介:面向本科毕业设计场景的Python Flask电影评论情感分析系统完整项目,基于深度学习word2vec模型实现评论情感值判断。系统采用B/S架构并整合MySQL存储,支持爬取电影评论与手动输入内容进行正面/负面情绪自动分类,适合计算机相关专业学生用于毕设参考、课设拓展或技术学习。资源包共290个文件,除Python后端逻辑、前端HTML/CSS/JavaScript交互、SQL数据库脚本外,还包含8个npy与4个pkl模型权重文件、词向量数据、75张GIF演示动图等,压缩包总大小约122.33MB。部署过程涵盖数据采集、文本预处理、word2vec词向量训练与情感分类模型调优等关键环节,配套说明文档和项目配置可帮助快速理解训练流程与部署步骤,便于按需求二次开发。目前已有992人学习下载,项目结构清晰、材料完整,适合作为毕业设计高分项目的参照模板。
1. 电影评论情感分析:word2vec 比词袋模型强在哪里
拿到这套基于 Python 的深度学习电影评论情感分析项目时,我第一反应不是打开 Flask 代码,而是先看模型部分用了什么词向量。压缩包里除了 bootstrap.min.css、layui.css 这些前端静态资源,真正值钱的是 word2vec 训练流程和 Python 端的情绪判断逻辑。传统词袋模型把“很好看”和“难看”拆成互不相干的特征,word2vec 却把语义相近的词映射到相邻向量空间,让模型有机会理解“剧情拖沓”和“节奏太慢”是同一类情绪。换句话说,这套 B/S 作品用 Flask 做后端、MySQL 存评论、word2vec 做特征、深度学习分类器做正向/负向识别,适合想用最短时间补齐工程链路的人拿去做成毕业设计。如果你已经会写 Python 脚本但没碰过词向量,这个项目能让你同时补齐 NLP 预处理、模型训练与 Web 接入三段经验。
2. 系统架构与数据流:Flask、MySQL 与 word2vec 模型如何协同
拆项目先拆数据流。用户在前端点“分析”,浏览器把评论通过 fetch 发给 Flask 路由,Flask 从请求体取文本,交给预处理模块做清洗和分词,再把分词结果变成向量,送入已经加载到内存的深度学习分类器,最后把情感标签、置信度写回 MySQL。整套链路里 Flask 只做编排,真正的计算发生在 word2vec 和分类器里,这样做的好处是换爬虫或换模型都不影响 Web 层。
选择 B/S 结构而不是桌面端,是因为答辩演示时只需要打开浏览器,不需要现场配 PyCharm 的 GUI 环境。Flask 本身足够轻,适合把注意力集中在算法模块上。MySQL 在这里不是存储评论那么简单,它还承担了样本积累的作用,爬虫抓来的历史评论和用户实时输入的评论都落在同一张表,后续重训模型时直接从表里导出即可。
2.1 Flask 工程结构与模型预加载
压缩包里的代码即使没重构,通常也是下面这种目录结构。把静态资源、模型文件和业务模块分开,答辩时也容易讲清楚边界。
movie_sentiment/ ├── app.py # Flask 入口 ├── config.py # 数据库与模型路径配置 ├── models/ │ ├── word2vec.bin # 训练好的词向量 │ └── sentiment.h5 # 深度学习分类器 ├── modules/ │ ├── preprocess.py # 清洗、分词、向量化 │ ├── predict.py # 预测封装 │ └── crawler.py # 评论采集 ├── static/ # 前端资源 │ ├── layui.css │ ├── bootstrap.min.css │ ├── font-awesome.css │ └── animate.css └── templates/ └── index.html这里重点说模型加载。word2vec.bin 和解码后的 h5 文件体积不小,如果放在请求函数里反复执行Word2Vec.load,每次点击都要等一两秒,页面像卡死。常见做法是让 Flask 在进程启动时把模型加载成全局对象,之后所有请求共用同一份内存。下面的 app.py 是一个可运行的骨架。
# app.py from flask import Flask, request, jsonify, render_template from modules.predict import load_model, analyze app = Flask(__name__) def create_app(): global model_ctx model_ctx = load_model("models/word2vec.bin", "models/sentiment.h5") return app create_app() @app.route("/") def index(): return render_template("index.html") @app.route("/api/analyze", methods=["POST"]) def api_analyze(): text = request.json.get("text", "") result = analyze(text, model_ctx) return jsonify(result) if __name__ == "__main__": app.run(debug=True)逻辑说明:create_app()在模块导入时执行,把 word2vec 和分类器读进model_ctx;/api/analyze接收 JSON 里的text字段,传给analyze后直接返回结构化结果。load_model内部一般会先判断文件是否存在,再把两个模型分别放进字典,避免懒加载造成的并发问题。model_ctx是全局变量,Flask 在多线程下只是读它,不会发生写入竞争;如果以后要做模型热更新,再把加载过程放到带锁的单例函数里。参数方面要注意app.run(debug=True)只适合本地调试,答辩现场的演示机可以开着,但如果是放到公网服务器演示,记得改成app.run(host="0.0.0.0", port=8000)并去掉 debug,否则一开调试器就等于把内部堆栈暴露给访问者。
2.2 MySQL 表结构与评论数据落库
Web 部分之外,另一块容易丢分的是数据表设计。爬虫抓来的评论和人工输入的评论最好放在同一张表里,用source字段区分,方便后面统计数据和核对正负样本。推荐结构如下。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int unsigned | 主键,自增 |
| movie_name | varchar(128) | 电影名称,默认空字符串 |
| comment | text | 原始评论内容 |
| sentiment | tinyint | 0 负面,1 正面 |
| score | float | 模型给出的置信度 |
| source | tinyint | 1 爬虫抓取,2 前端录入 |
| created_at | datetime | 入库时间 |
对应的建表语句直接用 utf8mb4,否则表情符号和生僻字容易变成问号。
CREATE TABLE comment_sentiment ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, movie_name VARCHAR(128) NOT NULL DEFAULT '', comment TEXT, sentiment TINYINT DEFAULT 0, score FLOAT DEFAULT 0.0, source TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_movie (movie_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里把comment设为 TEXT 而不是 VARCHAR,因为一条长影评可能超过几百个字符。score用 FLOAT 记录概率,不仅能看到模型把评论分到哪一类,还能看出模型“犹豫”的程度。utf8mb4是 Python 连接 MySQL 时常被忽略的一点,如果建表用了旧版 utf8,之后再跑ALTER TABLE也能补救,但你会比直接用对编码多折腾整整一轮。连接串里同样要写charset="utf8mb4",这部分我在第 5 章会再强调。
2.3 静态资源在页面里的分工
压缩包里的前端文件不是装饰品。bootstrap.min.css 负责栅格与按钮的基础样式,layui.css 用来渲染评论列表表格和分页条,font-awesome.css 提供正负向图标,animate.css 给预测结果加淡入效果。模板里通常这样引入它们。
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='layui.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='animate.css') }}">url_for是 Flask 自带的静态资源寻址函数,它会根据static目录自动生成浏览器可访问的 URL。监听表单提交的 JavaScript 会把评论 POST 给 Flask,拿到 JSON 后只做三件事:更新标签颜色、写入置信度、刷新表格。前端不承载任何算法逻辑,这样即使模型换了,页面代码也不需要改动。layui 的表单控件和 Bootstrap 的布局类名偶尔会有冲突,实际项目中建议先用 Bootstrap 定页面骨架,再用 layui 渲染局部表格,不要在同一块区域同时初始化两组组件。
3. 评论采集与预处理:python 爬虫、jieba 分词与停用词过滤
模型效果 70% 取决于语料干不干净。爬虫抓回来的评论包含超链接、@用户、评分等噪声,直接喂给 word2vec 会学到一堆低频垃圾词。这一章把从接口取数到生成训练语料的完整链路拆开,每一步都对应压缩包里modules/crawler.py和modules/preprocess.py的常见实现。
3.1 爬虫取值与请求头伪装
写爬虫的第一原则是把自己当成普通浏览器。这里用 requests 请求一个占位接口,真实项目中把 URL 换成对应电影页的评论接口即可。每条评论至少保留用户名、正文和评分三个字段,评分后面做弱标签用。
# modules/crawler.py import requests import time def crawl_comments(movie_id, page_limit=5): results = [] headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Accept": "application/json" } # 占位地址,实际使用时换成目标站点的评论接口 for page in range(1, page_limit + 1): url = f"https://api.example.com/movie/{movie_id}/comments?start={(page-1)*20}&limit=20" try: resp = requests.get(url, headers=headers, timeout=8) resp.raise_for_status() data = resp.json() for item in data.get("comments", []): results.append({ "user": item.get("user", ""), "content": item.get("content", ""), "rating": item.get("rating", 0) }) except requests.exceptions.RequestException as e: # 单页失败不中断整个任务,记录后继续下一页 print(f"page {page} failed: {e}") time.sleep(1) return results逻辑说明:page_limit控制抓取页数,start参数模拟翻页,timeout=8防止某个请求长时间挂起。raise_for_status()会在返回非 2xx 时抛出异常,结合try/except保证单页失败不中断任务。time.sleep(1)是给对方服务器的基础礼貌,也可以改成time.sleep(random.uniform(0.5, 1.5)),让访问间隔更像真人。resp.json()在接口返回 JSON 时最省事,如果接口返回的是 HTML,就用 BeautifulSoup 配合select(".comment-item")提取,本质没有区别。另一个容易踩的细节是编码,某些站点返回的 JSON 头里写 utf-8,但正文里混了 GBK 字符,稳妥的做法是在requests.get后检查resp.encoding,必要时手动指定为页面声明的编码。
提示:不要把爬虫 URL 硬编码在业务代码深处,至少在
config.py里留一个CRAWLER_API_BASE配置项,评审老师问起来你能说清楚换成真实站点要改哪里。
3.2 文本清洗、jieba 分词与停用词过滤
中文分词不是用str.split()就能解决的,“这部电影不错”按空格切只会得到一个整句,jieba 会把“这 / 部 / 电影 / 不错”拆开,再丢掉停用词后就剩下“电影”“不错”两个有效特征。清洗顺序推荐先去 URL,再去 @,最后统一空白字符,顺序错了容易把正文截断。
# modules/preprocess.py import re import jieba STOPWORDS = set(open("stopwords.txt", encoding="utf-8").read().split()) def clean_text(text): # 移除 URL、@用户和多余空白,保留中文、英文、数字 text = re.sub(r"https?://\S+", "", text) text = re.sub(r"@\w+", "", text) text = re.sub(r"\s+", " ", text) return text.strip() def tokenize(text): words = jieba.lcut(clean_text(text)) # 过滤停用词,去掉单字和纯数字 return [w for w in words if w not in STOPWORDS and len(w) > 1 and not w.isdigit()]clean_text里的正则作用分别是丢掉 URL、清掉 @用户名、把多个空白缩成一个。jieba.lcut直接返回 list,比旧接口jieba.cut更直观。过滤条件里的len(w) > 1会去掉“了”“的”“好”这类高频但语义弱的单字,w.isdigit()避免“1999”这种年份干扰向量。stopwords.txt 里集合了常见语气词和助词,比如“我们”“没有”,这个文件要求按行存放,一个词一行,读取时用split()拆分即可。如果语料里有人名或片名被切碎,可以在分词前调用jieba.add_word("流浪地球"),或者把领域词表写进user_dict.txt后执行jieba.load_userdict("user_dict.txt")。
下面的对照表可以放进毕业设计文档里,比空谈清洗效果更有说服力。
| 原始评论 | clean_text 后 | tokenize 后 |
|---|---|---|
| 剧情拖沓,@导演 看得我快睡了 https://t.cn/x | 剧情拖沓,看得我快睡了 | 剧情 / 拖沓 / 快睡 |
| 特效很好,但节奏太慢 | 特效很好,但节奏太慢 | 特效 / 节奏 / 太慢 |
| 1999 年的电影,现在看依然震撼 | 1999 年的电影,现在看依然震撼 | 电影 / 现在 / 依然 / 震撼 |
3.3 训练语料整理与标签对齐
爬虫拿到的是原始 JSON,预处理后的分词结果要攒成一个文件供 word2vec 训练。每一行是一篇评论的分词结果,词与词之间用空格连接。标签不需要写进这个文件,情感标签单独存成 CSV,方便后续和特征向量对齐。
# 将预处理结果写成训练语料 with open("train_corpus.txt", "w", encoding="utf-8") as f: for item in comments: tokens = tokenize(item["content"]) if len(tokens) >= 3: # 太短的句子没有特征价值 f.write(" ".join(tokens) + "\n")这里故意把长度小于 3 个词的评论丢掉,因为“好看”“不行”这种极短文本对 word2vec 的训练贡献很小,还会让词表里出现大量边缘词。标签层面,常见做法是用评分做弱监督:4 分及以上的评论标为 1(正面),2 分及以下标为 0(负面),3 分中间档剔除。这样在一部电影下能快速获得几百条带标签样本,比手工标注轻松得多。要注意的是弱标签有噪声,比如有人打 2 分但文字内容是肯定句,这种样本混进训练集后会让分类边界变模糊。提升质量的办法是抽 100 条做人工复核,把明显矛盾的标签修正后再训练。
3.4 语料分布检查与类别平衡
写完训练语料后不要急着训练,先跑一段统计脚本,观察词频分布和类别比例。这一步在答辩时非常加分,因为你已经在用数据分析的思路检查数据质量。
from collections import Counter word_counts = Counter() total_lines = 0 for line in open("train_corpus.txt", encoding="utf-8"): tokens = line.strip().split() word_counts.update(tokens) total_lines += 1 print("总句子数:", total_lines) print("总词数:", sum(word_counts.values())) print("词表大小:", len(word_counts)) print("最高频词:", word_counts.most_common(20))词频分布能告诉你语料是不是被某部电影的专有名词主导。如果“特效”“剧情”这类通用词排在前列还好,但前 10 个词里出现两部电影名,说明语料主题过窄,后续模型的泛化会很差。类别比例则可以直接从 CSV 里读取后画一个 bar 图,正面评论明显多于负面时,常见做法是做降采样,让两类样本数量接近,或者在训练 Keras 分类器时给少数类更高的权重。
4. word2vec 训练与深度学习分类器实现细节
word2vec 相当于给每个词发了一份“性格档案”,但一个句子由多个词组成,怎么把词的向量合并成句子的特征,是很多毕设卡壳的地方。这一章讲两部分:词向量训练参数怎么设,以及句子向量如何进深度学习分类器。
4.1 gensim 训练 word2vec 的关键参数
数据量不大时,直接用 gensim 的 Word2Vec 就行,没必要自己写反向传播。训练前把 train_corpus.txt 每一行变成字符串列表,再按下面的方式训练。
from gensim.models import Word2Vec sentences = [line.strip().split() for line in open("train_corpus.txt", encoding="utf-8")] model = Word2Vec( sentences, vector_size=128, # 词向量维度 window=5, # 上下文窗口大小 min_count=3, # 出现次数少于 3 的词扔掉 sg=1, # 1 表示 skip-gram,0 表示 CBOW workers=4, # 并行线程数 epochs=10 # 整个语料的训练轮数 ) model.save("models/word2vec.bin")vector_size是词向量的维度,常见取值 100 到 200,维度太小表达不了词义差异,太大在小语料上容易过拟合。window决定模型看多大范围的上下文,评论这种短文本通常 5 到 10 都行。min_count过低会把只出现一次的人名也纳入词表,浪费内存。sg=1的 skip-gram 对小语料更友好,CBOW 训练更快但语义精度略低。workers受 CPU 核心数限制,设成 4 在大多数笔记本上没有压力。下面这张参数表可以直接放进论文的参数分析章节。
| 参数 | 推荐值 | 调大后的影响 | 调小后的影响 |
|---|---|---|---|
| vector_size | 128 | 向量更精细,训练更慢 | 语义区分变弱 |
| window | 5 | 更关注全局上下文 | 更关注相邻词 |
| min_count | 3 | 词表更干净 | 丢词变多 |
| sg | 1 | 小语料语义更准 | CBOW 速度更快 |
| epochs | 10 | 拟合更充分 | 训练不充分 |
4.2 句子向量化策略
词向量是词级别的,模型输入需要句级别的固定长度特征。最简单有效的方式是把句子里所有词的向量取平均,虽然会丢失词序,但配合深度学习分类器,效果已经比 TF-IDF 好一个档次。
import numpy as np from gensim.models import Word2Vec wv = Word2Vec.load("models/word2vec.bin").wv def sentence_vector(tokens): vecs = [wv[word] for word in tokens if word in wv] if len(vecs) == 0: # 句子全是 OOV 词时返回零向量,模型输出会接近随机 return np.zeros(wv.vector_size) # 对所有词向量做均值,得到句向量 return np.mean(vecs, axis=0)代码先检查 token 是否在词表里,避免KeyError。零向量兜底很重要,因为爬虫数据里经常出现网络新词,如果某个短句全是 OOV,直接返回零向量能保持维度一致。均值向量是 128 维浮点数组,下一步直接作为分类器的输入特征。想要更强一点的表达,可以用 IDF 权重对词向量加权,或者把词向量放进神经网络的 Embedding 层里端到端训练,但对毕设来说均值向量的解释成本最低,reducer 和 reviewer 都能快速听懂。实际复现时如果发现两条完全不同的评论向量很像,也可以换成按词的出现次数加权,出现频率越高的词权重越低,这能削弱语气词的残留影响。
4.3 深度学习分类器与训练脚本
句子向量最后送到一个三层全连接网络里,ReLU 激活函数解决非线性问题,Dropout 防止过拟合,softmax 输出正负两个类别的概率。模型结构如下。
# train_model.py from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout, BatchNormalization from tensorflow.keras.optimizers import Adam clf = Sequential([ Dense(256, activation="relu", input_shape=(128,)), BatchNormalization(), Dropout(0.5), Dense(64, activation="relu"), Dense(2, activation="softmax") ]) clf.compile( loss="sparse_categorical_crossentropy", optimizer=Adam(learning_rate=1e-3), metrics=["accuracy"] ) # X_train 是 N*128 的向量矩阵,y_train 是 0/1 标签 clf.fit(X_train, y_train, validation_split=0.2, batch_size=64, epochs=10) clf.save("models/sentiment.h5")第一个 Dense 层把 128 维句子向量映射成 256 个神经元,BatchNormalization让每层的输入分布更规整,训练更容易收敛。Dropout(0.5)在训练时随机丢弃一半神经元,降低过拟合风险。输出层用 softmax,两个输出分别代表负面和正面的概率。sparse_categorical_crossentropy要求标签是整数 0/1,不能是 one-hot,如果提前做了 one-hot 就要改成categorical_crossentropy。Adam(learning_rate=1e-3)是比较常用的默认选择,验证集 loss 不降时可以降到 1e-4。训练时还要注意把数据分成训练集、验证集和测试集三份,最合理的划分是 6:2:2,其中测试集在训练过程中完全不碰,只用于最终评估。
4.4 模型评估与二次训练
训练结束后不要只看终端里最后一行 accuracy,用 sklearn 的classification_report把各类别的精确率和召回率打出来,能更快找到模型偏向哪一类。
from sklearn.metrics import classification_report y_pred = clf.predict(X_test).argmax(axis=1) print(classification_report(y_test, y_pred, target_names=["负面", "正面"]))精确率表示“模型说正面时有多准”,召回率表示“真的正面里被捞出来多少”。情感分析里如果负面评论只占 20%,模型全部猜正面也能拿到 80% 准确率,但负面样本一条都找不出来,这显然不能用于电影口碑分析。处理不平衡数据时,可以给 Keras 的fit传class_weight={0: weight_negative, 1: weight_positive},让少数类在 loss 里占更大比重。如果验证集 F1 始终上不去,优先检查清洗和分词结果,而不是急着改网络结构。很多情况下都是“演员”“演技”这类词被错误切分,导致词向量语境丢失。
5. Flask 接口封装、MySQL 落库与上线前验收技巧
Web 层拿到模型之后,剩下的工作是把预测函数包装成接口,并把结果写进 MySQL。最后这部分只讲最容易出问题的地方。
5.1 预测函数与 MySQL 写入
预测函数要在模块内部把tokenize、sentence_vector和分类器串起来,返回结果字典。Flask 路由收到字典后直接 jsonify,前端拿到的结构固定,后续加字段也不需要改页面。
# modules/predict.py import numpy as np def analyze(text, model_ctx): tokens = tokenize(text) vec = sentence_vector(tokens) prob = model_ctx["clf"].predict_proba(vec.reshape(1, -1))[0] sentiment = int(np.argmax(prob)) score = float(prob[sentiment]) return {"sentiment": sentiment, "score": score, "tokens": tokens}vec.reshape(1, -1)把一维向量变成二维矩阵,因为 Keras 的预测接口要的是批量维度。np.argmax取概率大的类别,prob[sentiment]把对应置信度拿出来。返回的tokens字段在调试时很有用,可以快速看出是清洗环节出错还是模型判断出错。写入 MySQL 时要注意连接编码,连接串里加charset="utf8mb4",否则表情符号会报Incorrect string value错误。
import pymysql conn = pymysql.connect(host="localhost", user="root", password="123456", database="movie_sentiment", charset="utf8mb4") with conn.cursor() as cursor: cursor.execute( "INSERT INTO comment_sentiment (movie_name, comment, sentiment, score, source) " "VALUES (%s, %s, %s, %s, %s)", (movie_name, comment, sentiment, score, source) ) conn.commit()%s占位符是参数化查询,不能直接拼 SQL,否则评论里有引号就会注入或报错。conn.commit()必须显式调用,只 execute 不 commit 的话数据不会真正落库。建议把连接操作封装成上下文管理器,用with确保异常时也能关闭游标和连接。
5.2 上线前的常见坑与验收指标
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 第一次请求非常慢 | 模型文件太大,启动时没加载 | 启动脚本里预加载模型,并打印加载耗时 |
| 中文全部变成问号 | 建表或连接不是 utf8mb4 | 统一使用 utf8mb4 字符集 |
| 预测结果几乎全是正类 | 训练语料正负样本不均衡 | 采样平衡,或者给少数类加 class_weight |
| 相同评论两次结果不一致 | word2vec 或模型文件被覆盖 | 预测过程不要写模型文件,只读加载 |
验收时常用 100 条人工标注样本,计算 precision、recall、F1,而不是只看准确率。在正负样本不均衡的数据集上,准确率可能到 80% 但正面样本全猜中,反面样本一条也捞不到。调试时可以分三类打印结果:真阴性里的“好评但预测成负面”,往往存在反讽或转折词;真阳性里的“明显差评却预测成正面”,多半是语料里没有对应句式的词。最后一个建议是把验证集的错误样本单独导出成 CSV,答辩现场直接翻 CSV 讲模型边界,比空说“准确率 90%”更有说服力。
本文还有配套的精品资源,点击获取