简介:面向高校计算机类毕业生与希望积累真实项目经验的 Python 学习者,基于 Django+MySQL+协同过滤算法的旅游推荐数据分析可视化项目源码包,也适配课程设计、期末大作业等场景。项目实现了用户登录注册、个人信息管理、景区浏览、评分收藏与推荐,以及管理员对景区信息、分类和用户信息的管理等功能,前后端功能划分明确,操作流程完整,逻辑清晰。资源共 422 个文件,以 js、css、html 等前端页面样式文件为主,辅以 py 后端代码、json 配置、jpg 图片、csv 数据及 sql 数据库脚本,压缩包大小约 9.36MB,目录结构清晰,便于按模块阅读和二次开发。包内另含部署说明文档,覆盖环境配置与启动步骤,可帮助使用者在本地快速跑通项目。目前已有 81 人学习下载,适合需要完整线上项目参考、并希望据此快速搭建毕业设计的 Python/Django 初学者。
1. 旅游推荐数据分析可视化:毕业设计怎么把数据变成可交付的东西
很多人做旅游推荐系统,调一个协同过滤模型,输出Top10景点就结束了。但放到答辩现场,老师更想看到的是“你的数据从哪来、清洗后长什么样、为什么推荐这个而不是那个”。这个标题把三个关键词合在一起:基于Python的推荐算法、数据分析、可视化。它的价值不在算法多深,而在把一条完整的数据链路跑通——从景点数据清洗到推荐结果计算,再到页面上的地图和图表。适合正在做毕业设计或想补齐数据展示能力的人。接下来会按数据清洗、推荐算法、可视化部署的顺序,把每个环节可复现的命令和代码给出来。
2. 数据清洗与数据分析:旅游景点数据怎么变成推荐可用的特征
2.1 数据源选型与字段设计
数据是推荐系统的地基。做旅游推荐,常见的数据来源有两种:一是爬取公开的景点评论和评分,二是用模拟生成的测试数据。毕业设计不推荐一上来就爬虫,因为反爬、验证码会耗掉大量时间。我一般会给实训学生一个CSV结构,字段包括景点ID、名称、城市、类型(自然风光、历史古迹、主题乐园等)、评分、门票价格、游玩时间、评论关键词。这样的数据既能支撑基于内容的推荐,也方便做分组统计。
有趣的是,很多研究在做基于内容的推荐时只关注评分和标签,而忘了地理位置。旅游推荐和电影推荐最大的区别在于“距离成本”。所以在字段设计时加一个“所属区域”字段,可以复用城市级筛选。数据规模不用大,100个景点、5000条评论足以跑通全流程。
2.1.1 字段说明
| 字段名 | 类型 | 含义 | 推荐中的用途 |
|---|---|---|---|
| spot_id | int | 景点唯一ID | 主键 |
| spot_name | str | 景点名称 | 展示 |
| city | str | 所属城市 | 区域筛选 |
| category | str | 景点类型 | 特征 |
| rating | float | 平均评分 | 排序权重 |
| comment_tags | str | 评论关键词,逗号分隔 | 文本特征 |
| recommended_time | str | 建议游玩时长 | 过滤条件 |
2.2 用pandas完成数据清洗和聚合统计
拿着原始CSV不能直接进模型。常见问题包括评分列被读成字符串、评论关键词有空值、城市名有空格。用pandas清洗是标准做法。下面的代码可以当作模板:
import pandas as pd df = pd.read_csv("travel_data.csv", encoding="utf-8-sig") # 去除前后空格,统一字符串格式 df["city"] = df["city"].str.strip() df["category"] = df["category"].str.strip() # 评分转数值,解析不了的行置NaN df["rating"] = pd.to_numeric(df["rating"], errors="coerce") # 删除关键字段缺失的行 df = df.dropna(subset=["spot_name", "rating"]) # 评论关键词空值用“一般”补齐 df["comment_tags"] = df["comment_tags"].fillna("一般") # 按城市和类型统计数量,观察数据分布 city_stat = df.groupby("city").size().reset_index(name="景点数") category_stat = df.groupby("category")["rating"].mean().reset_index() print(city_stat.head())这段代码做了五件事:去空格、评分转数值、删缺失、补关键词、分组聚合。注意errors="coerce"是必须的,因为爬下来的评分可能混入“暂无”这类文本,直接astype(float)会报错。utf-8-sig是为了让Excel另存的CSV能正常读取中文字段。
分组统计的结果是后面可视化“城市景点数”“分类平均评分”两张图表的数据基础。如果你发现某个城市景点数异常少,比如只有1条,别急着删,先看是不是城市字段有别名,比如“北京市”和“北京”同时存在。这是数据分析里最常见的脏数据坑。
2.3 数据分析指标与可视化前的数据准备
清洗后做聚合,是为了回答几个具体问题:哪个城市景点最多、哪类景点平均评分最高、价格区间分布如何。这些图表标题会直接出现在可视化页面上,所以指标口径要提前定好。
推荐一个实用的做法:把清洗逻辑封装成函数,然后输出一份“干净数据集”的副本。后续所有图表和推荐模块都读取这份副本,而不是反复重跑原始数据。也就是把数据处理和业务展示解耦,不然每次改动一个过滤条件,所有图表都要跟着重新算。
def clean_data(path: str) -> pd.DataFrame: # 完整清洗流程,返回干净的DataFrame df = pd.read_csv(path, encoding="utf-8-sig") df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df = df.dropna(subset=["rating"]) return df clean_df = clean_data("travel_data.csv") clean_df.to_csv("clean_travel_data.csv", index=False, encoding="utf-8-sig")注意to_csv时index=False很重要。如果不设,读出来的数据会多出一列Unnamed索引,后续按spot_id做分组会失败,这也是一个常见疏漏。到这里,数据层面已经可以把“城市景点数量”“分类评分均值”输送给可视化模块,同时把comment_tags文本保留给推荐算法使用。
提示:在毕业设计说明书里,数据清洗部分建议附上清洗前后行数对比表。例如原始2007行,清洗后1985行,缺失22条。答辩时这个数字比“我用了pandas”有说服力得多。
3. 推荐算法落地:基于内容的旅游推荐怎么做才不“空泛”
3.1 为什么毕业设计选基于内容而不是协同过滤
协同过滤看着高级,但在100个景点的数据量下效果很差,并且冷启动阶段新景点没有历史评分就无法推荐。更现实的原因是毕业设计答辩时,老师一定会问“你的推荐依据是什么”,基于内容可以很清楚地说“因为用户选了历史古迹,所以推荐其他历史古迹中相似度最高的”。这种可解释性对毕业设计非常加分。
基于内容的推荐核心逻辑是“给用户看过/喜欢的对象算特征,找出库里最相似的”。旅游场景里,特征既可以是景点类型、城市,也可以是评论关键词的文本向量。用一个经典的文本相似度方法就能完成。
3.2 用TF-IDF构建景点特征向量
把每个景点的“类型 + 城市 + 评论关键词”拼成一个文本,然后做分词、向量化、计算余弦相似度。这样推荐结果在语义上更合理。比如用户看过“故宫”,评论关键词是“历史、宏伟”,那么“颐和园”“天坛”这种历史古迹且关键词相近的景点被排到前面。
代码示例:
import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 拼接文本特征 def build_full_text(row): return " ".join([row["category"], row["city"], str(row["comment_tags"])]) # 分词处理,使用空格连接 def tokenize(text): return " ".join(jieba.cut(text)) # 计算整个数据集的相似度矩阵 def build_similarity_matrix(df): df["content"] = df.apply(build_full_text, axis=1) df["content_tokenized"] = df["content"].apply(tokenize) vectorizer = TfidfVectorizer() tfidf_matrix = vectorizer.fit_transform(df["content_tokenized"]) return cosine_similarity(tfidf_matrix, tfidf_matrix) def recommend(spot_id, df, similarity_matrix, top_k=5): idx = df.index[df["spot_id"] == spot_id].tolist()[0] scores = list(enumerate(similarity_matrix[idx])) scores = sorted(scores, key=lambda x: x[1], reverse=True) scores = [s for s in scores if s[0] != idx][:top_k] result = df.iloc[[s[0] for s in scores]][["spot_name", "city", "category"]].copy() result["similarity"] = [round(s[1], 4) for s in scores] return result说明逻辑:build_full_text把结构化字段拼成一句话,jieba.cut负责中文分词。TfidfVectorizer在合并后的语料上计算每个词的TF-IDF权重,这样“故宫”出现次数多的景点会被认为更有代表性。cosine_similarity计算两两景点的相似度,矩阵中sim[i][j]就是第i个景点和第j个景点的相似度。
参数说明:top_k=5控制推荐数量,答辩时可以改成10看召回更多。TfidfVectorizer默认会过滤单字词,因为单个汉字对旅游文本没有区分度。可以显式设置token_pattern=r"(?u)\b\w+\b"配合分词后的空格文本,这样就不会因为默认的英文单词正则而丢掉中文。
3.3 调参和评估注意点
调参是让推荐结果“看起来好用”的关键。常见问题是新景点没有评论关键词,导致特征向量全零,相似度永远是0。解决办法是在拼接文本时,给缺失关键词一个通用兜底:row["comment_tags"] if row["comment_tags"] else "热门景点"。
评估上,没有标注数据时不要硬上准确率。可以做一个简单的人工验证:把相似度分数取阈值,比如0.3以下的结果直接不显示,这样可以过滤掉“看起来完全不搭”的推荐。在答辩时,这个阈值可以当作“模型可调节参数”讲,比单纯跑一个黑盒分数更有说服力。
表:推荐效果快速验证
| 验证点 | 检查方式 | 常见异常 |
|---|---|---|
| 是否重复推荐 | 推荐结果中spot_id唯一 | 相似矩阵对角元素没去除 |
| 是否推荐了不同城市 | 打印推荐列表的city字段 | 文本特征没包含city |
| 冷启动景点 | 给一个无评论关键词的景点调用 | 结果全为0相似度 |
4. 可视化大屏与交互界面:把分析结果变成可展示的页面
4.1 可视化工具选型:matplotlib、pyecharts和ECharts
分析了半天数据,最终要给老师看。matplotlib适合做论文插图,但不适合做网页交互。pyecharts封装了ECharts,能在Python里直接生成HTML片段,适合快速出图。如果你的项目要求“可视化大屏”,我建议直接使用ECharts的JavaScript库,因为大屏适配和轮播效果全在前端控制,后端只需要提供JSON数据。
选型逻辑:图表数量少、只需要导出静态图时用matplotlib;页面需要悬浮提示、点击联动时用ECharts。如果你用的是Flask模板,前两者都能嵌入,但ECharts的联动控制最方便。
4.2 Flask + ECharts 搭建最小的可视化页面
用Flask做后端,一个/api/stats接口提供统计数据,一个/api/recommend/<spot_id>接口提供推荐结果,页面用原生HTML+ECharts渲染。这是最小可运行的方案,不需要前端工程化。
from flask import Flask, jsonify, request, render_template import pandas as pd from recommender import build_similarity_matrix, recommend app = Flask(__name__) # 加载清洗后的数据及相似度矩阵 df = pd.read_csv("clean_travel_data.csv", encoding="utf-8-sig") matrix = build_similarity_matrix(df) @app.route("/") def index(): # 渲染可视化页面 return render_template("index.html") @app.route("/api/stats") def stats(): # 城市景点数量统计 city_count = df.groupby("city").size().reset_index(name="count") return jsonify({ "cities": city_count["city"].tolist(), "counts": city_count["count"].tolist() }) @app.route("/api/recommend/<int:spot_id>") def get_recommend(spot_id): rec_df = recommend(spot_id, df, matrix, top_k=5) return jsonify(rec_df.to_dict(orient="records")) if __name__ == "__main__": app.run(debug=True)这段代码把后端拆成了三个接口:首页渲染静态页面、统计接口返回柱状图数据、推荐接口按景点ID返回推荐列表。前端页面里用fetch("/api/stats")拿数据,然后用echarts.init生成图表。
注意fetch请求的是相对路径,不要在HTML里写http://localhost:5000/api/stats,否则部署到服务器上还得改代码。这是个很常见的低级错误。
4.2.1 前端页面片段
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>旅游推荐数据分析可视化</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="city_chart" style="width:600px;height:400px;"></div> <select id="spot_select"> <option value="1">故宫</option> <option value="2">颐和园</option> </select> <div id="recommend_box"></div> <script> fetch("/api/stats") .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById("city_chart")); chart.setOption({ xAxis: { data: data.cities }, yAxis: {}, series: [{ type: "bar", data: data.counts }] }); }); document.getElementById("spot_select").addEventListener("change", function() { const id = this.value; fetch("/api/recommend/" + id) .then(res => res.json()) .then(data => { const box = document.getElementById("recommend_box"); box.innerHTML = data.map(item => `<p>${item.spot_name}(${item.city})- ${item.similarity}</p>` ).join(""); }); }); </script> </body> </html>这个页面完成两个动作:页面加载时拉取统计数据画柱状图;下拉框改变时拉取对应景点的推荐结果。下拉框的选项最好由后端接口动态生成,不要像这里写死。否则新增景点后页面要改代码。
4.3 推荐结果联动与图表刷新
有了第一版页面,要把它变成“可视化大屏”还需要增加联动效果。一个常见的做法是:点击柱状图上的“北京市”,下方筛选出该城市的景点列表,再点某一个景点查看推荐结果。ECharts的点击事件正好可以做这件事。
chart.on("click", function(params) { const city = params.name; fetch("/api/spots?city=" + encodeURIComponent(city)) .then(res => res.json()) .then(data => { // 刷新景点下拉框 const select = document.getElementById("spot_select"); select.innerHTML = data.map(item => `<option value="${item.spot_id}">${item.spot_name}</option>` ).join(""); }); });这里的思路是“从统计到明细再到推荐”的下钻式交互,比单独一个推荐按钮更像一个数据产品。每次新增一个接口时,注意对参数做encodeURIComponent处理,避免中文城市名拼接乱码。
5. 部署说明里的坑与验证技巧
5.1 requirements.txt 与本地一键启动
毕业设计交付的包里必须包含依赖清单。在项目目录下执行pip freeze > requirements.txt会把当前环境所有包打进去,这不推荐,因为会把非项目依赖也带进去。更好的做法是手写一份关键依赖:
pandas==2.2.3 flask==3.0.3 scikit-learn==1.5.2 jieba==0.42.1 matplotlib==3.9.2版本号以你实测能跑通的为准。启动说明有三步就够:安装Python 3.10及以上,pip install -r requirements.txt,然后python app.py。我见过很多部署问题出在版本冲突,比如scikit-learn和Python版本不兼容,所以要求一律用虚拟环境:
python -m venv venv source venv/bin/activate # Windows下执行 venv\Scripts\activate pip install -r requirements.txt python app.pyWindows和macOS上激活虚拟环境的命令不同,部署说明里最好把两个平台都列出来,这是模板数据生成时最容易被忽略的一步。
5.2 数据不显示时先查哪几个地方
页面白屏或图表空白,先从浏览器开发者工具的Network面板看/api/stats的HTTP状态码。如果是200,检查返回的JSON字段是不是cities和counts,别把下划线拼错;如果是500,回终端看Flask的Traceback,绝大多数是KeyError或ValueError,说明前后端字段名不一致。这个排查动作能解决七成以上的显示问题。
第二类问题是端口被占用。app.run(debug=True)默认绑定5000端口,如果被其他服务占用,改成app.run(host="0.0.0.0", port=8899, debug=False)。注意debug=True会开启调试器,交给老师演示前建议关掉,避免页面报错时出现可交互的调试点。
第三类问题是中文乱码。后端返回的数据在浏览器里显示成乱码,通常是因为旧版本Flask的JSON配置没有关闭ASCII转义。Flask 2.x之后默认启用UTF-8,如果你还在用老代码,可以加上app.config["JSON_AS_ASCII"]=False,但在Flask 3.x里这个配置会被移除,所以最稳妥的方案是升级到Flask 3并把所有模板文件保存为UTF-8编码。
5.3 用点击日志验证推荐效果并准备答辩素材
毕业设计答辩最怕老师随机点一个景点,推荐列表不好看。与其临场紧张,不如给系统加一个轻量的点击日志。每次前端发起/api/recommend/<id>请求时,后端把景点ID、推荐结果、相似度最大值写入CSV:
import time, json def log_recommendation(spot_id, result): log_path = "recommend_log.csv" with open(log_path, "a", encoding="utf-8") as f: f.write(f"{time.time()},{spot_id},{len(result)},{result[0]['similarity'] if result else 0}\n")这个日志有三个用处:第一,证明推荐模块真的被调用过;第二,通过相似度最大值分布判断阈值设得是否合理;第三,如果老师问“系统怎么优化”,你可以说“收集用户点击行为后,用贝叶斯平滑算法补全新景点的评分”。日志模块不给老师看也可以,但它是你扩展“推荐系统上线后持续优化”这个答辩问题的实物支撑。
最后一步验证,打开recommend_log.csv,确认每次点击下拉框都新增了一行记录。如果某一行相似度最大值低于0.3,就回去检查这个景点的comment_tags是否为空、city是否参与了文本拼接。这样从CSV数据清洗到页面展示和推荐结果的完整链路,就可以在答辩前逐项核对完毕。
本文还有配套的精品资源,点击获取