简介:这份资源围绕 Python 旅游景点方面级别情感分析,提供了完整的毕业设计实现方案,包含 Django + Python + MySQL 搭建的语料库标注系统及基于 RNCC 模型的文本分类功能,适合计算机相关专业学生用于毕业设计参考、课程项目复现或情感分析方向入门。压缩包共含多个文件,以源码、数据库、演示视频及说明文档为主,整体约 81.99MB,涵盖系统首页统计、文本列表管理、文本分类模式等模块,能够直接展示从语料标注到模型判断的完整流程。资源已获得 193 人浏览学习,对于需要快速理解并运行一个可演示的情感分析系统的人来说,具备较高的参考价值。通过源码与演示视频,读者可以了解 Django 项目结构、MySQL 数据表设计、RNCC 模型在评价文本情感极性判别中的应用,并在此基础上扩展自己的毕业设计内容。
1. 输入一句话判断好评差评:这套 python 旅游情感分析系统解决了什么问题
如果你正在做毕业设计,大概率会遇到这种尴尬:花了两周把 Django 后台搭得漂漂亮亮,结果导师问"你的创新点在哪",你答不上来。而这套基于 python 的旅游景点方面级别情感分析项目,恰好把"能跑的 Web 系统"和"能出成果的算法模型"缝在了一起。它的核心体验就是:在系统里输入"景区一点都不好玩",点击开始分类,后端模型返回"消极",弹窗展示结果。这个看起来简单的交互,背后是爬虫采集评论文本、MySQL 存储语料、RNCC 模型训练和 Django 在线推理的完整链路。我把它拆了一遍,说实话,作为课程设计和毕业设计来讲,它比很多只做 CRUD 的管理系统扎实得多,而且源码、数据库、演示视频都齐,按步骤改就能复现。
2. 从首页统计到文本列表:Django 与 MySQL 的数据流转
这套系统的第一层不是算法,而是 Django 框架下的数据管理。系统首页展示了用户数量、累计评论数、已标注评论数、未标注评论数,还有一个柱状图按好评、中评、差评展示分布。这些数字看起来像"玄学",其实全是 MySQL 表里的聚合查询结果。先把数据流看明白,后面接模型才不懵。
2.1 首页统计数字不是写死的,是 ORM 聚合查出来的
首页四个统计数字,对应的是评论表在不同状态下的数量。已标注评论数和未标注评论数靠一个 is_labeled 字段区分,好评、中评、差评的柱状图则靠 sentiment 字段分组统计。Django 的 ORM 做这件事非常直接:
from django.db.models import Count from .models import Comment, User def get_index_stats(): total_users = User.objects.count() total_comments = Comment.objects.count() labeled_count = Comment.objects.filter(is_labeled=True).count() unlabeled_count = Comment.objects.filter(is_labeled=False).count() sentiment_stats = ( Comment.objects.filter(is_labeled=True) .values('sentiment') .annotate(total=Count('id')) .order_by() ) return { 'total_users': total_users, 'total_comments': total_comments, 'labeled_count': labeled_count, 'unlabeled_count': unlabeled_count, 'sentiment_stats': sentiment_stats, }这段代码的逻辑很清晰:count() 统计总数,filter().count() 统计指定条件数量,values('sentiment').annotate(total=Count('id')) 按情感标签分组计数,返回的是一个类似 [{'sentiment': '好评', 'total': 123}] 的列表。柱状图可以直接把这个列表喂给前端模板,不用再做二次处理。
这里要留意一个细节:sentiment_stats 的查询加了 is_labeled=True 过滤,因为只有人工标注过的评论才算数。如果直接把所有未标注文本也统计进柱状图,首页的好中差分布会被爬虫抓来的原始文本污染,图表看起来就没什么参考价值。这在毕设答辩时容易被问到,提前想清楚这个过滤条件,回答会很有底气。
2.2 文本列表界面:列表展示、状态标记与删除的数据表设计
文本列表界面展示评论的文本编号、文本内容和是否已标注状态,支持直接删除。这个界面背后就是一张评论表,我在项目源码里看到的核心字段和类型可以整理成一张表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int 主键 | 文本编号 |
| content | longtext | 评论原始文本 |
| source | varchar(50) | 来源平台,比如某旅游网站 |
| sentiment | varchar(10) | 人工标注结果:好评/中评/差评 |
| is_labeled | boolean | 是否已完成标注 |
| created_at | datetime | 爬取入库时间 |
删除操作在 Django 视图里就是一条 ORM 调用,但关键点在于:删除之前要确认这条文本有没有参与模型训练。如果已标注的评论被删除,训练集就被改了,模型效果可能"翻车"。
我建议在原项目基础上加一个逻辑:只有当 is_labeled=False 时允许直接删除,已标注数据改走"先取消标注,再删除"的流程。实现很简单:
def delete_comment(request, comment_id): comment = Comment.objects.get(id=comment_id) if comment.is_labeled: # 已标注数据先重置状态,防止训练样本被静默移除 comment.is_labeled = False comment.sentiment = None comment.save() messages.warning(request, '已标注文本已转为未标注状态,请确认后再删除') else: comment.delete() messages.success(request, '文本已删除') return redirect('text_list')这样改的好处是,删除操作变成可控的,不会因为手滑把好不容易标注好的语料从库里拔掉。原文虽然没有提这个细节,但这种边界处理在演示的时候非常加分——导师会认为你考虑了数据一致性,而不是只会写 delete()。
3. 分类模型不是黑匣子:RNCC 模型的训练与推理链路
文本分类功能是这套系统的灵魂。用户在页面输入"景区一点都不好玩",系统返回"消极"。这个过程的背后是一个叫 rncc 的模型,从源码里的实现来看,它是循环神经网络类的文本分类模型,用来做中文短文本的三分类或二分类任务。很多同学拿到源码后第一反应是"直接跑",但不懂模型结构的话,后面想换数据集、调参数就寸步难行。
3.1 RNCC 模型的结构与输入输出
这类模型的输入不是一串汉字,而是经过分词和词表映射后的索引序列。评论先按字或词切分,再映射成 id,然后 padding 成固定长度,最后才能喂给模型。模型的输出是一个概率分布,例如 [0.12, 0.03, 0.85],对应消极、中性、积极三个类别的置信度,取最大值的索引就是最终标签。
我在源码基础上重构了一个简化版,方便理解核心结构:
# -*- coding: utf-8 -*- import torch import torch.nn as nn class RNCCClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim=128, hidden_size=256, num_classes=3): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.rnn = nn.GRU(input_size=embedding_dim, hidden_size=hidden_size, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embedding_dim] out, _ = self.rnn(emb) # out: [batch, seq_len, hidden_size*2] # 取最后一个有效时间步,也可用全局平均池化 out = out[:, -1, :] logits = self.fc(out) # [batch, num_classes] return logits def predict(self, text_ids): self.eval() with torch.no_grad(): x = torch.tensor(text_ids).unsqueeze(0) logits = self.forward(x) pred = torch.argmax(logits, dim=1).item() return pred模型结构里三个参数值得记一下:embedding_dim 是词向量的维度,128 对中文评论这种短文本够用;hidden_size 是 GRU 隐层大小,256 是平衡了效果和训练速度的值;num_classes 是分类数,这套系统里是三分类(好评/中评/差评),如果你只想做积极消极二分类,改成 2 就行。
需要注意的是,双向 GRU 的输出维度是 hidden_size * 2,所以全连接层的输入维度必须对应成 hidden_size * 2,否则会报维度不匹配的错。这个细节我在第一次跑的时候就栽过,改模型结构时最容易漏的就是这处。
3.2 训练脚本:从语料到权重文件需要走完几步
拿到语料后,训练过程分成五步:加载数据、切分训练验证集、构建词表、训练、保存权重。词表的构建决定模型的输入空间大小,一般取出现频率最高的前 N 个词,N 在 5000 到 20000 之间比较合适。
from sklearn.model_selection import train_test_split from torch.utils.data import DataLoader, Dataset def build_vocab(texts, max_vocab_size=10000): counter = {} for text in texts: for token in text.split(): counter[token] = counter.get(token, 0) + 1 sorted_tokens = sorted(counter.items(), key=lambda x: x[1], reverse=True) vocab = {token: idx + 1 for idx, (token, _) in enumerate(sorted_tokens[:max_vocab_size])} vocab['<pad>'] = 0 # 0 作为 padding 占位符 return vocab def encode_text(text, vocab, max_len=64): ids = [vocab.get(t, 1) for t in text.split()] # 1 是 <unk>,词表外的词统一映射到这里 ids = ids[:max_len] + [0] * (max_len - len(ids)) if len(ids) < max_len else ids[:max_len] return ids这里有个容易踩的坑:padding_idx=0 对应 ,这个要在 Embedding 层上明确指定,不然填充的 0 也会参与梯度更新。另外,未登录词统一映射成 1,我习惯留一个 给词表外的词,不然新评论里出现没见过的词会直接 KeyError。
训练轮数不需要太多,中文短文本情感分类在这个规模的语料下,10 到 20 轮足够。我一般会在验证集上观察 loss,如果连续两轮不下降就提前停止,把最优权重单独存一份:
best_acc = 0.0 for epoch in range(epochs): model.train() for batch in train_loader: optimizer.zero_grad() logits = model(batch['input_ids']) loss = criterion(logits, batch['label']) loss.backward() optimizer.step() model.eval() acc = evaluate(model, val_loader) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_rncc.pt') print(f'epoch {epoch} saved, acc={acc:.4f}')这里用的是标准 early-stopping 思路,只保存验证集上准确率最高的那一份权重。不要每轮都覆盖保存,不然训练到最后可能把最好的模型给冲掉,这点我是在跑实验的时候吃过亏的。
3.3 把模型接到 Django 视图里:实时分类接口的写法
模型训练好之后,接进 Django 的思路是:项目启动时加载一次权重,之后每次分类请求直接走 forward。最忌讳的做法是每次请求都重新 torch.load(),模型加载本身的耗时比推理还长,页面会卡得让人以为系统死掉了。
import torch from django.shortcuts import render model = None device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') def load_model(): global model model = RNCCClassifier(vocab_size=len(vocab), num_classes=3) model.load_state_dict(torch.load('models/best_rncc.pt', map_location=device)) model.to(device) model.eval() def classify_view(request): if request.method == 'POST': text = request.POST.get('text', '') input_ids = encode_text(text, vocab, max_len=64) pred = model.predict(input_ids) label_map = {0: '消极', 1: '中性', 2: '积极'} return render(request, 'classify_result.html', {'result': label_map[pred], 'text': text}) return render(request, 'classify_input.html')全局变量 model 在模块加载时先置空,在 Django 的 ready 钩子或者 urls 模块导入时调用 load_model()。这样整个进程只维护一份模型实例,在线分类的延迟基本在几十毫秒内。如果你用的是 CPU 机器,而且评论长度比较长,可以把 max_len 从 64 减到 32,速度能快不少,准确率损失通常很小。
4. 语料库构建是重点工程:爬虫采集与三分类标注的完整实现
这套系统的题眼是"语料库构建"。很多毕业设计做情感分析,语料直接下载公开数据集,但这个项目的思路不一样——它自己爬取了旅游平台的真实用户评论文本,然后提供标注入口,由人工把每一条评论标成好评、中评、差评。标注后的数据既喂给模型训练,又作为成果展示,自圆其说。
4.1 评论采集脚本与入库的常见做法
采集部分在毕设场景下一般用 requests + BeautifulSoup,针对目标平台的结构化页面编写解析规则。采集到的字段就是前面表里的 content 和 source,入库之前要先做一次格式清洗,否则文本列表页面就会呈现一堆空行和乱码。
import requests from bs4 import BeautifulSoup def fetch_comments(url): headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} resp = requests.get(url, headers=headers, timeout=10) resp.encoding = 'utf-8' soup = BeautifulSoup(resp.text, 'html.parser') comments = [] for item in soup.select('.comment-item'): text = item.select_one('.content-text') if text: # 去掉首尾空白和常见噪声字符 content = text.get_text().strip().replace('\n', ' ').replace('\r', '') if len(content) >= 4: # 过滤过短的无效评论 comments.append(content) return comments注意一下我写的两个过滤规则:一是删掉换行符,把多行评论压缩成一行,方便入库和展示;二是长度小于 4 的评论直接丢弃,因为"真好""不错"这类过短文本对训练没有太大帮助,而且容易引入标注噪声。
爬虫这块最大的不确定因素是页面结构变化。写完爬虫当天能跑,不代表过两周还能跑,所以我会把每批次抓取的时间、来源、数量记录到一个 log 表里,便于检查数据是哪个批次进来的。这个做法原项目里没有,但属于毕设演示时"数据可追溯"的加分项。
4.2 标注状态机与三分类标签的管理
标注的核心是一个状态迁移:未标注 → 已标注(好评/中评/差评)。在 Django 里,我建议做一个简单的标注页面,每页展示一条评论,提供三个按钮和一个跳过按钮。跳过的评论保持未标注状态,不会污染训练集。
def annotate_view(request): comment = Comment.objects.filter(is_labeled=False).order_by('id').first() if request.method == 'POST': sentiment = request.POST.get('sentiment') # 好评 / 中评 / 差评 comment.sentiment = sentiment comment.is_labeled = True comment.save() return redirect('annotate') return render(request, 'annotate_page.html', {'comment': comment})标注速度决定了语料库规模。按我的经验,一个人一小时大概能标 150 到 250 条短文本,标注质量取决于对"中评"的界定是否一致。有的数据"风景不错但路太远"是好评还是中评,不同人判断可能不同。我给这套系统补了一个规则:只要文本里出现转折连词(如"但、但是、不过"),倾向标为中评或按后半句判断。这样标注一致性会明显提升,模型训练时也不会被互相矛盾的标签误导。
4.3 去重与脏数据清洗:不做这一步模型效果会明显变差
爬虫采下来的评论有大量重复内容,尤其是热门景区,同一句话可能被多个用户复述。直接训练的话,模型会对高频重复样本过拟合,验证集上的准确率会虚高,但换一批新评论就拉胯。去重我习惯用 content 字段的 MD5 哈希值:
import hashlib def deduplicate_comments(): seen = set() duplicates = [] for comment in Comment.objects.all(): digest = hashlib.md5(comment.content.encode('utf-8')).hexdigest() if digest in seen: duplicates.append(comment.id) else: seen.add(digest) Comment.objects.filter(id__in=duplicates).delete() print(f'removed {len(duplicates)} duplicate comments')清洗时还要处理 HTML 实体和 URL,比如评论里带着" http://... "这类链接,对情感判断没有帮助,反而会干扰分词。我一般会用一个简单的正则把它们替换成空字符串,规则写在脚本里作为预处理步骤,在入库前执行。这段脚本在毕设演示时可以专门展示一次,说明你的语料库不是简单的"爬下来就存进去",而是走了完整的 ETL 流程。
5. 避坑指南:部署运行时的 5 个高频报错与处理
这个项目我前后跑了三遍,每次都在不同环节被卡住。有的坑是 Django 项目共性的,有的坑是这类带机器学习模型的毕设特有的。我把高频问题按"现象 → 原因 → 解决"整理出来,你照着排错会省很多时间。
5.1 中文评论存进 MySQL 后变成问号
- 现象:文本列表页面显示的内容全是"????",或者写入数据库后再读出来就是乱码。
- 原因:MySQL 数据库或数据表的字符集不是 utf8mb4,中文和 emoji 字符无法正常存储。
- 解决:创建数据库时指定字符集,执行 CREATE DATABASE 语句时加上 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;已建好的表用 ALTER TABLE 修改字符集。Django 的 DATABASES 配置里也加上 OPTIONS:{"charset": "utf8mb4"},这样 ORM 写入时用的就是正确的编码。
5.2 模型预测结果全部集中在某一个类别
- 现象:无论输入什么评论,分类结果永远都是"好评"或永远都是"消极"。
- 原因:训练语料分布严重不平衡。比如爬虫抓到的评论 70% 是好评,模型学到的方法是"全猜好评"来降低损失,而不是真正理解语义。
- 解决:在损失函数上做处理,nn.CrossEntropyLoss 传入一个 weight 参数,给样本少的类别分配更高的权重。或者先重新采样,把低频类别的样本复制扩充,让三类数量大致持平。这个坑是模型类毕设最容易在答辩时暴露的硬伤,一定要提前检查。
5.3 点击开始分类后页面长时间无响应
- 现象:第一次输入文本点分类,浏览器转圈十几秒才出结果,后续再点又恢复正常。
- 原因:模型不是在项目启动时加载的,而是等第一次请求到达时才执行 torch.load,把加载耗时算进了请求链路。
- 解决:改成启动时加载,把 load_model() 放到 Django 的 AppConfig.ready() 方法里,或者放在 urls.py 模块顶部执行。这样第一个请求进来时模型已经在内存里,响应时间降到毫秒级。检查方法很简单:启动项目时看控制台有没有打印模型加载日志。
5.4 爬虫采集到的文本为空列表
- 现象:执行采集脚本,返回的结果是空列表,或者数据量远少于预期。
- 原因:目标页面结构变了,CSS 选择器匹配不到内容;也可能是请求被目标站点的基础反爬策略拦住了,返回的不是真实页面而是验证页。
- 解决:先用浏览器访问目标 URL,确认 .comment-item 这类 class 名称还存在;然后检查 resp.status_code 是否 200、页面里是否包含"验证""访问异常"这类关键字。结构变化就更新选择器,被拦截就适当加大请求间隔,并在代码里加一个随机延时。
5.5 部署到服务器后静态文件全部 404
- 现象:本地 runserver 一切正常,部署后页面完全裸奔,CSS 和 JS 全加载不出来。
- 原因:DEBUG=False 时 Django 默认不再托管静态文件,需要单独配置静态文件收集和对外服务的路径。
- 解决:在 settings.py 里确认 STATIC_ROOT 和 STATICFILES_DIRS 配置正确,然后执行 python manage.py collectstatic 把所有静态文件收集到指定目录。如果是用 nginx 部署,把 /static/ 的 location 直接指向收集后的目录就行。这个坑和算法无关,但最容易在最后演示环节掉链子。
6. 把整句情感拆到方面级:旅游景点场景下的进阶优化
这套系统的分类粒度是整条评论,但旅游场景下用户往往在一句话里表达多个方面的态度。比如"风景很美但门票太贵",整句情感很难说是好评还是差评——这就是为什么摘要里强调"方面级别情感分析"。你可以在现有模型基础上,把一个分类任务拆成两个方面分类任务,模型结构完全不用变,只改数据标注方式。
具体做法是在标注阶段增加一个 aspect 字段。比如一条评论涉及"风景""门票""交通""服务"四个方面,每个方面单独标注情感。训练时同一个模型分别训练四个分类器,或者用一个多任务网络共享 embedding 层。从毕设角度看,后者更有亮点,但前者更省事。
我的建议是先用现有单任务模型跑通全流程,然后单独做一个"方面抽取 + 方面情感判断"的验证脚本,证明这套系统是有扩展空间的。验证脚本不需要接进 Web,只要在后台跑通并输出结果即可,答辩演示效果非常好。
aspect_keywords = { '风景': ['风景', '景色', '山水', 'view'], '门票': ['门票', '票价', '贵', '性价比'], '交通': ['交通', '公交', '打车', '停车'], '服务': ['服务', '导游', '工作人员', '态度'], } def extract_aspects(text): return [aspect for aspect in aspect_keywords if any(k in text for k in aspect_keywords[aspect])]这个脚本的思路是候选词匹配,不做句法分析,拿来做演示已经足够。真实项目里可以用 jieba 分词后做词表匹配,或者用依存句法分析做更细的抽取,但考虑到毕设时间和算力,词表匹配是性价比最高的方案。
验证时用几条含转折的评论来跑,你会看到整句分类和方面级分类的差异——整句可能判成"消极",但"风景"这个方面其实是"积极"。这个对比本身就是很好的分析素材,也能说明你理解了方面级情感分析的核心矛盾,而不是只会调包。
从那以后我每跑一套这样的毕设项目,都会强制把数据分布检查、模型启动加载这两件事写在检查清单最前面,因为它们分别决定了模型效果的上限和系统演示的流畅度。希望这篇拆解能帮你少走两步弯路,把精力留在真正能出成果的地方。
本文还有配套的精品资源,点击获取