
简介这份资源围绕 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_labeledTrue).count() unlabeled_count Comment.objects.filter(is_labeledFalse).count() sentiment_stats ( Comment.objects.filter(is_labeledTrue) .values(sentiment) .annotate(totalCount(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(totalCount(id)) 按情感标签分组计数返回的是一个类似 [{sentiment: 好评, total: 123}] 的列表。柱状图可以直接把这个列表喂给前端模板不用再做二次处理。这里要留意一个细节sentiment_stats 的查询加了 is_labeledTrue 过滤因为只有人工标注过的评论才算数。如果直接把所有未标注文本也统计进柱状图首页的好中差分布会被爬虫抓来的原始文本污染图表看起来就没什么参考价值。这在毕设答辩时容易被问到提前想清楚这个过滤条件回答会很有底气。2.2 文本列表界面列表展示、状态标记与删除的数据表设计文本列表界面展示评论的文本编号、文本内容和是否已标注状态支持直接删除。这个界面背后就是一张评论表我在项目源码里看到的核心字段和类型可以整理成一张表字段名类型说明idint 主键文本编号contentlongtext评论原始文本sourcevarchar(50)来源平台比如某旅游网站sentimentvarchar(10)人工标注结果好评/中评/差评is_labeledboolean是否已完成标注created_atdatetime爬取入库时间删除操作在 Django 视图里就是一条 ORM 调用但关键点在于删除之前要确认这条文本有没有参与模型训练。如果已标注的评论被删除训练集就被改了模型效果可能翻车。我建议在原项目基础上加一个逻辑只有当 is_labeledFalse 时允许直接删除已标注数据改走先取消标注再删除的流程。实现很简单def delete_comment(request, comment_id): comment Comment.objects.get(idcomment_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_dim128, hidden_size256, num_classes3): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.rnn nn.GRU(input_sizeembedding_dim, hidden_sizehidden_size, batch_firstTrue, bidirectionalTrue) 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, dim1).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_size10000): counter {} for text in texts: for token in text.split(): counter[token] counter.get(token, 0) 1 sorted_tokens sorted(counter.items(), keylambda x: x[1], reverseTrue) 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_len64): 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_idx0 对应 这个要在 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(fepoch {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_sizelen(vocab), num_classes3) model.load_state_dict(torch.load(models/best_rncc.pt, map_locationdevice)) 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_len64) 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, headersheaders, timeout10) 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_labeledFalse).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__induplicates).delete() print(fremoved {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 全加载不出来。原因DEBUGFalse 时 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 分词后做词表匹配或者用依存句法分析做更细的抽取但考虑到毕设时间和算力词表匹配是性价比最高的方案。验证时用几条含转折的评论来跑你会看到整句分类和方面级分类的差异——整句可能判成消极但风景这个方面其实是积极。这个对比本身就是很好的分析素材也能说明你理解了方面级情感分析的核心矛盾而不是只会调包。从那以后我每跑一套这样的毕设项目都会强制把数据分布检查、模型启动加载这两件事写在检查清单最前面因为它们分别决定了模型效果的上限和系统演示的流畅度。希望这篇拆解能帮你少走两步弯路把精力留在真正能出成果的地方。本文还有配套的精品资源点击获取