简介:这是一套面向高校学生与Python开发初学者的旅游景点评论情感分析完整项目源码,采用Python+Django后端与Vue前端技术栈,可直接用于毕业设计、期末大作业或课程设计场景。项目含详细代码注释,新手也能读懂,部署简单,下载后即可运行使用。压缩包共102个文件,约47.72MB,其中32个py文件承载Django后端业务逻辑与情感分析核心算法,10个vue文件与6个js文件构成前端交互界面,另有json配置、html模板、png与jpg图片资源及md说明文档,目录结构清晰,便于按模块查阅与二次开发。目前已有190人学习下载。项目经过严格调试,功能完善、界面美观、操作便捷,配套文档说明可帮助读者快速理解情感分析流程、前后端数据交互方式与整体架构设计,是兼顾实用性与参考价值的高分项目范例。
1. 从零搭一套旅游景点评论情感分析系统:Python+Django+Vue 到底怎么落地
打开任何一个在线旅游平台,点进一个 5A 景区,评论区动辄几万条。运营想知道「最近一个月差评集中在哪」,靠人工翻页翻到崩溃。这就是旅游景点评论情感分析要解决的问题:把非结构化的中文评论,自动切成正面、负面、中性三类,再按景点、时间、关键词聚合,最后用 Web 界面呈现出来。整套系统用 Python 做算法、Django 做后端接口、Vue 做前端交互,是目前高校课程设计和中小团队落地最常见的技术组合。它适合三类人:想找一个完整项目练手的 Python 新手、需要交付课程设计的学生、以及要给景区做舆情看板的后端工程师。读完你能自己跑通数据采集、模型训练、接口开发、前端展示的完整链路,也能看清每一步的参数边界和翻车点。
2. 技术选型与数据链路:为什么是 Django 而不是 Flask
2.1 情感分析任务的技术栈拆解
旅游评论情感分析本质是一个中文短文本三分类任务。输入是「风景不错,就是门票太贵了」这样的句子,输出是负面。整条链路分四层:数据层负责采集和清洗评论,算法层负责分词、向量化和分类,服务层负责把模型包装成 HTTP 接口,展示层负责图表和列表渲染。
选 Django 而不是 Flask,核心理由是这类项目天然需要 ORM、Admin 后台、用户认证和迁移工具。评论数据要存库、要按景点和日期筛选、要能人工复核标注结果,Django 自带的这些能力直接省掉大量胶水代码。Flask 更轻,但你要自己拼 SQLAlchemy、自己写后台,项目一大就散。Vue 这边选 Vue 3 + Element Plus,是因为评论列表、分页、图表这类管理界面用组件库能快速搭出来,比手写 DOM 省事得多。
算法层我一般用 jieba 分词 + SnowNLP 做基线,再用 scikit-learn 的 TF-IDF + 朴素贝叶斯或 SVM 做可训练版本。如果追求更高准确率,可以上 HuggingFace 的中文预训练模型做微调,但那是另一个量级的算力投入。课程设计级别,TF-IDF + 线性模型在旅游评论这种领域词汇集中的场景,准确率做到 85% 左右是现实的。
提示:不要一上来就上 BERT。旅游评论的负面表达高度模板化(「坑」「宰客」「不值」),传统特征工程加线性模型性价比最高,训练一次几十秒,改起来也快。
2.2 从评论采集到入库的完整步骤
第一步是数据来源。常见做法是用 requests + BeautifulSoup 抓公开评论页,或者直接用平台开放接口。抓取时注意控制频率,加 time.sleep,否则容易被封 IP。抓下来的原始数据先落成 CSV,字段至少包含:景点名称、评论正文、评分、评论时间。
import requests import csv import time from bs4 import BeautifulSoup def fetch_comments(scenic_id, pages=10): """抓取指定景点的评论,返回列表""" all_comments = [] headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } for page in range(1, pages + 1): url = f"https://example.com/api/comments?scenic={scenic_id}&page={page}" resp = requests.get(url, headers=headers, timeout=10) if resp.status_code != 200: break data = resp.json() for item in data.get("list", []): all_comments.append({ "scenic": item["scenicName"], "content": item["content"].strip(), "score": item.get("score", 0), "date": item.get("createTime", "") }) time.sleep(1.5) # 控制频率,避免触发风控 return all_comments def save_to_csv(comments, path="raw_comments.csv"): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["scenic", "content", "score", "date"]) writer.writeheader() writer.writerows(comments) if __name__ == "__main__": data = fetch_comments("A001", pages=5) save_to_csv(data) print(f"共抓取 {len(data)} 条评论")这段代码的关键参数有三个:pages控制抓取页数,time.sleep(1.5)控制请求间隔,encoding="utf-8-sig"保证 Excel 打开 CSV 不乱码。timeout=10防止某个请求卡死拖垮整个循环。失败时先看resp.status_code,403 说明被拦,需要换请求头或降频;返回 JSON 结构变了就打印data看实际字段名。
第二步是清洗。评论里混着表情符号、URL、重复刷屏内容。清洗逻辑:去掉 HTML 标签和 URL,过滤长度小于 5 的评论,用 SimHash 或简单的去重集合去掉完全重复项。清洗完的数据存进 Django 的模型表,字段设计建议加一个sentiment字段默认空,留给后续标注或预测结果回填。
2.3 Django 模型设计与数据迁移
在 Django 里建 app,然后定义评论模型。字段类型要贴合实际:content用 TextField,score用 SmallIntegerField,sentiment用 CharField 加 choices。
# comments/models.py from django.db import models class Scenic(models.Model): name = models.CharField(max_length=100, unique=True) city = models.CharField(max_length=50, blank=True) def __str__(self): return self.name class Comment(models.Model): SENTIMENT_CHOICES = [ ("pos", "正面"), ("neg", "负面"), ("neu", "中性"), ] scenic = models.ForeignKey(Scenic, on_delete=models.CASCADE, related_name="comments") content = models.TextField() score = models.SmallIntegerField(default=0) sentiment = models.CharField(max_length=3, choices=SENTIMENT_CHOICES, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [models.Index(fields=["scenic", "sentiment"])]on_delete=models.CASCADE表示景点删除时评论一起删,related_name="comments"让你能用scenic.comments.all()反查。Meta.indexes给景点和情感建联合索引,因为看板最常查的就是「某景点的负面评论」,没索引数据量上万就明显卡。迁移命令是python manage.py makemigrations加python manage.py migrate,改字段后重复执行即可。注意auto_now_add=True只在创建时写入,后续更新不会变,需要更新时间就再加auto_now=True的字段。
3. 情感分析模型训练:从 jieba 分词到可复用的预测接口
3.1 中文分词与停用词处理的参数细节
中文和英文不同,词之间没有空格,必须先分词。jieba 是默认选择,jieba.lcut()返回列表,jieba.cut()返回生成器。旅游评论里有很多专有名词,比如「玻璃栈道」「索道」「摆渡车」,默认词典可能切错,可以用jieba.add_word()补充。
停用词表要针对旅游场景定制。通用停用词表(的、了、是)要加,但「不」「没」「太」这类否定和程度词绝对不能删,删了情感极性直接反转。我一般维护两份:一份通用停用词,一份旅游领域保留词,后者优先级更高。
import jieba import re STOPWORDS = set(["的", "了", "是", "在", "和", "就", "都", "而", "及", "与"]) KEEP_WORDS = set(["不", "没", "太", "很", "非常", "特别", "有点"]) def clean_text(text): text = re.sub(r"<[^>]+>", "", text) # 去 HTML 标签 text = re.sub(r"http\S+", "", text) # 去 URL text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", " ", text) # 只留中英文数字 return text.strip() def tokenize(text): text = clean_text(text) words = jieba.lcut(text) return [w for w in words if w not in STOPWORDS or w in KEEP_WORDS] # 补充领域词 for w in ["玻璃栈道", "摆渡车", "索道", "网红打卡"]: jieba.add_word(w) print(tokenize("风景不错,就是门票太贵了,摆渡车还要另外收费")) # 输出大致为 ['风景', '不错', '就是', '门票', '太', '贵', '摆渡车', '还要', '另外', '收费']clean_text里的正则[^\u4e00-\u9fa5a-zA-Z0-9]把标点和表情统一替换成空格,避免它们被当成词。tokenize的过滤条件是「不在停用词表,或者在保留词表」,这样「太」「不」能留下来。参数上,jieba.lcut的cut_all默认 False 走精确模式,别改成 True,全模式会产生大量无意义组合词,反而拉低分类效果。
3.2 TF-IDF 向量化与分类器训练
分词完要转成数值向量。TF-IDF 的核心是:一个词在当前评论里出现越多、在整个语料里出现越少,权重越高。TfidfVectorizer的max_features控制保留多少维,旅游评论语料几万条的话,设 5000 到 10000 比较合适,太大容易过拟合,太小丢信息。ngram_range=(1,2)让模型同时看单词和双词组合,能捕捉「不 值」这种搭配。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.svm import LinearSVC from sklearn.metrics import classification_report import joblib # X_raw 是清洗后的评论文本列表,y 是对应标签列表 X_tokens = [" ".join(tokenize(t)) for t in X_raw] vectorizer = TfidfVectorizer( max_features=8000, ngram_range=(1, 2), min_df=2, max_df=0.9 ) X_vec = vectorizer.fit_transform(X_tokens) X_train, X_test, y_train, y_test = train_test_split( X_vec, y, test_size=0.2, random_state=42, stratify=y ) clf = LinearSVC(C=1.0, class_weight="balanced") clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(classification_report(y_test, y_pred)) joblib.dump(vectorizer, "vectorizer.pkl") joblib.dump(clf, "sentiment_clf.pkl")min_df=2表示词至少出现在 2 条评论里才保留,过滤掉拼写错误和噪声。max_df=0.9表示出现在 90% 以上评论里的词丢掉,这类词没有区分度。class_weight="balanced"很重要,旅游评论里正面往往远多于负面,不加这个参数模型会偏向多数类,负面召回率惨不忍睹。stratify=y保证训练集和测试集类别比例一致。训练完用classification_report看每类的 precision、recall、f1,重点看负面类的 recall,它直接决定差评能不能被捞出来。
3.3 把模型包装成 Django 可调用的预测服务
模型训练是一次性的,线上要的是随时能调。做法是在 Django app 里写一个predict.py,启动时加载 pkl 文件,对外暴露一个函数。注意不要在每次请求里重新加载模型,那样每次都要几百毫秒,接口直接崩。
# comments/ml/predict.py import os import joblib import jieba from django.conf import settings _MODEL_DIR = os.path.join(settings.BASE_DIR, "comments", "ml", "artifacts") _vectorizer = None _clf = None def _load(): global _vectorizer, _clf if _vectorizer is None: _vectorizer = joblib.load(os.path.join(_MODEL_DIR, "vectorizer.pkl")) _clf = joblib.load(os.path.join(_MODEL_DIR, "sentiment_clf.pkl")) def predict_sentiment(text): _load() tokens = " ".join(tokenize(text)) vec = _vectorizer.transform([tokens]) label = _clf.predict(vec)[0] return label用全局变量做懒加载,第一次调用时读盘,之后常驻内存。transform而不是fit_transform,线上只能用训练时确定的词表,绝不能重新拟合。如果模型文件更新了,需要重启进程或加一个 reload 接口,否则内存里还是旧模型。批量预测时把多条评论拼成一个列表一次transform,比循环单条快一个数量级。
4. Django 接口与 Vue 前端联调:评论看板怎么跑起来
4.1 DRF 接口设计与分页参数
后端接口用 Django REST Framework。核心接口有三个:评论列表(支持按景点、情感筛选和分页)、情感统计(返回各情感数量)、批量预测(接收文本返回标签)。分页用 DRF 自带的 PageNumberPagination,page_size设 20,允许前端通过?page=翻页。
# comments/views.py from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.pagination import PageNumberPagination from rest_framework.decorators import api_view from rest_framework.response import Response from django.db.models import Count from .models import Comment from .serializers import CommentSerializer from .ml.predict import predict_sentiment class CommentPagination(PageNumberPagination): page_size = 20 page_size_query_param = "size" max_page_size = 100 class CommentViewSet(ReadOnlyModelViewSet): serializer_class = CommentSerializer pagination_class = CommentPagination def get_queryset(self): qs = Comment.objects.select_related("scenic").all() scenic_id = self.request.query_params.get("scenic") sentiment = self.request.query_params.get("sentiment") if scenic_id: qs = qs.filter(scenic_id=scenic_id) if sentiment: qs = qs.filter(sentiment=sentiment) return qs.order_by("-created_at") @api_view(["GET"]) def sentiment_stats(request): scenic_id = request.query_params.get("scenic") qs = Comment.objects.all() if scenic_id: qs = qs.filter(scenic_id=scenic_id) data = qs.values("sentiment").annotate(count=Count("id")) return Response({item["sentiment"]: item["count"] for item in data}) @api_view(["POST"]) def predict(request): text = request.data.get("text", "") if not text: return Response({"error": "text required"}, status=400) return Response({"sentiment": predict_sentiment(text)})select_related("scenic")是关键优化,评论列表要显示景点名,不加这个每渲染一条就查一次景点表,20 条就是 20 次额外查询,典型 N+1。page_size_query_param="size"允许前端自定义每页条数,但max_page_size=100兜底防止有人传 size=100000 把数据库拖死。sentiment_stats用values().annotate()在数据库层聚合,别在 Python 里循环计数。
4.2 Vue 端调用接口与图表渲染
前端用 Vue 3 的<script setup>写法,请求用 axios。评论列表和情感饼图是两个核心组件。饼图用 ECharts,数据来自sentiment_stats接口。
// src/api/comment.js import axios from "axios"; const api = axios.create({ baseURL: "http://127.0.0.1:8000/api", timeout: 10000, }); export function fetchComments(params) { return api.get("/comments/", { params }); } export function fetchStats(scenicId) { return api.get("/sentiment-stats/", { params: { scenic: scenicId } }); } export function predictText(text) { return api.post("/predict/", { text }); }// src/components/SentimentPie.vue <script setup> import { ref, onMounted } from "vue"; import * as echarts from "echarts"; import { fetchStats } from "@/api/comment"; const chartRef = ref(null); const props = defineProps({ scenicId: String }); onMounted(async () => { const { data } = await fetchStats(props.scenicId); const chart = echarts.init(chartRef.value); chart.setOption({ tooltip: { trigger: "item" }, series: [{ type: "pie", radius: ["40%", "70%"], data: [ { value: data.pos || 0, name: "正面" }, { value: data.neg || 0, name: "负面" }, { value: data.neu || 0, name: "中性" }, ], }], }); }); </script> <template> <div ref="chartRef" style="width: 100%; height: 320px;"></div> </template>axios 实例统一配baseURL和timeout,避免每个请求重复写。onMounted里初始化 ECharts,注意容器必须有明确高度,否则图表高度为 0 什么都不显示,这是新手最常见的翻车点。跨域问题在开发阶段用 Django 的django-cors-headers解决,生产环境让 Nginx 同源代理,别在前端硬编码 IP。
4.3 前后端联调的三个必查项
联调阶段问题集中在三处。第一,请求发出去了但 404,检查 Django 的urls.py有没有注册路由,DRF 的 router 注册路径和前端 baseURL 是否对得上。第二,返回 200 但数据是空数组,多半是筛选参数名不一致,前端传scenic后端读scenic_id这种。第三,图表不显示,打开浏览器控制台看有没有报错,再看 ECharts 容器的clientHeight是不是 0。
注意:开发阶段前端跑在 5173 端口,后端跑在 8000,跨域是必然的。
CORS_ALLOWED_ORIGINS要写完整协议和端口,写localhost:5173不带http://是不生效的。
5. 避坑与排查:这套系统最容易翻车的五个地方
5.1 模型准确率虚高但线上全是错
现象:离线测试集准确率 92%,上线后运营反馈「差评被标成正面」。原因通常是训练数据里正面样本占 80% 以上,模型学会了无脑猜正面,测试集分布又和训练集一样,所以指标好看。解决:先看classification_report里负面类的 recall,低于 0.7 就说明有问题。用class_weight="balanced",或者对负面样本做上采样。更彻底的做法是重新采样,让三类比例接近 1:1:1。
5.2 分词把否定词切没了导致极性反转
现象:「不推荐」被预测成正面。原因:停用词表里误删了「不」,或者分词把「不推荐」切成了「不」和「推荐」,而「不」被过滤掉只剩「推荐」。解决:把否定词和程度词加入保留词表,tokenize的过滤条件用「不在停用词 或 在保留词」。更稳的做法是用jieba的自定义词典把「不推荐」「不值得」整体加进去。
5.3 Django 接口数据量一大就超时
现象:评论过万后列表接口要 5 秒以上。原因:没建索引、没做分页、或者序列化时触发了 N+1 查询。解决:给scenic和sentiment建联合索引,列表接口强制分页,get_queryset里加select_related。用 Django Debug Toolbar 看每个请求执行了多少条 SQL,超过 5 条就要警惕。
5.4 Vue 打包后接口地址写死导致部署失败
现象:本地跑得好好的,npm run build部署到服务器后所有请求 404。原因:axios 的baseURL硬编码了127.0.0.1:8000,打包后浏览器访问的是用户自己的机器。解决:用环境变量区分,.env.development写本地地址,.env.production写/api,配合 Nginx 反向代理转发到 Django。
5.5 模型文件路径在部署环境找不到
现象:本地能预测,服务器上报FileNotFoundError。原因:settings.BASE_DIR拼接的相对路径在部署后目录结构变了,或者 pkl 文件没被打包进去。解决:模型文件路径用绝对路径配置,或者放进 Django 的STATIC_ROOT之外的固定目录,部署脚本里显式拷贝。启动时加一个文件存在性检查,不存在就打印明确日志,别让它静默失败。
6. 让这套系统真正可用:批量预测与效果验证的实操技巧
模型训完、接口通了,离「能用」还差一步:批量回填和效果验证。新抓的一批评论入库时sentiment是空的,你需要写一个管理命令批量预测并回填,而不是一条条调接口。
# comments/management/commands/fill_sentiment.py from django.core.management.base import BaseCommand from comments.models import Comment from comments.ml.predict import predict_sentiment class Command(BaseCommand): help = "批量回填评论情感标签" def add_arguments(self, parser): parser.add_argument("--batch", type=int, default=500) def handle(self, *args, **options): qs = Comment.objects.filter(sentiment="") total = qs.count() batch = options["batch"] for i in range(0, total, batch): chunk = list(qs[i:i + batch]) for c in chunk: c.sentiment = predict_sentiment(c.content) Comment.objects.bulk_update(chunk, ["sentiment"]) self.stdout.write(f"已处理 {min(i + batch, total)}/{total}")bulk_update一次更新一批,比逐条save()快几十倍。--batch参数控制每批大小,内存紧张就调小。跑之前先备份数据库,跑完抽查 50 条人工核对,算一个抽样准确率。
验证环节我一般做两件事。一是混淆矩阵,看负面被误判成正面多还是中性多,前者危害大,要优先优化。二是关键词云对比,把预测为负面的评论分词后取 Top 50 词,看是不是集中在「贵」「坑」「排队」这些真实痛点词上。如果负面词云里出现大量中性词,说明模型边界模糊,需要补充标注数据重训。
| 验证指标 | 合格线 | 不达标时的动作 |
|---|---|---|
| 负面类 recall | ≥ 0.75 | 加 class_weight 或上采样负面样本 |
| 整体 accuracy | ≥ 0.85 | 检查分词和停用词表 |
| 单条预测耗时 | ≤ 50ms | 确认模型是常驻内存而非每次加载 |
| 批量回填 1 万条 | ≤ 3 分钟 | 调大 batch 或改用多进程 |
最后说个血泪经验:别在项目初期追求模型多先进,先把数据链路和接口跑通,用最简单的 TF-IDF + SVM 出一个能看的版本,让运营先用起来。真实反馈会告诉你哪些评论被分错了,这些错例才是下一轮训练最值钱的标注数据。我见过太多人卡在调模型上,结果前端和部署一塌糊涂,项目根本没法演示。先把整条链路打通,再回头优化准确率,这个顺序别搞反。希望帮到你。
本文还有配套的精品资源,点击获取