多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

旅游推荐数据分析可视化:Python从数据清洗到协同过滤看板实战

旅游推荐数据分析可视化:Python从数据清洗到协同过滤看板实战 简介面向计算机相关专业毕设学生与Python学习者的项目实战资源基于PythonDjangoMySQL实现旅游推荐数据分析与可视化融合协同过滤算法覆盖用户注册登录、景区浏览、评分收藏与个性化推荐以及管理员对景区信息、类型分类、用户资料等模块的管理可直接作为毕业设计或课程设计使用。压缩包共422个文件约9.36MB以174个JavaScript、59个CSS、40个JSON、27个Python源码、18个HTML页面为主同时包含数据库SQL文件、部署说明文档及静态资源目录结构清晰便于按功能模块快速定位。项目经过严格调试运行稳定配套部署说明和数据库可帮助读者快速搭建环境并二次开发。目前已有135人学习下载适合希望系统掌握Django项目架构、协同过滤推荐落地以及后台管理系统开发的中级学习者。1. 旅游推荐数据分析可视化从“脏数据”到“可汇报看板”的一次完整闭环“python项目实战之旅游推荐数据分析可视化”这个标题乍看像是个教程包其实它解决的是数据分析从业者最常遇到的一类问题给你一份旅游订单或者游记数据里面是各种口径不一的景点名、日期、价格、评分项目要求你既算出“这个用户下一站可能去哪”又要把地区热度、热门景点、出行趋势画成业务方看得懂的看板。整个过程横跨数据清洗、推荐算法、接口开发和可视化是一套不依赖企业数据平台也能完整跑通的数据分析项目。它适合正在找 python 练习项目的新手也适合需要给业务方交付可交互看板而不是只有 Excel 透视表的数据分析人员。2. 先拆业务再选算法旅游推荐可视化项目的四层结构与数据口径2.1 一份旅游数据长什么样先查字段再定口径拿到源码包第一件事不是马上看推荐算法而是打开数据文件。旅游推荐项目的数据通常是一张行为表每一行代表“某个用户在某个时间去了某个景点产生了多少消费给了什么评价”。常见字段大概有用户 id、景点名称、所在城市、到访日期、停留时长、消费金额、评分、评论标签。如果是从公开数据源或者爬虫抓下来的还会多一个来源 url 字段这个字段对推荐没有直接作用但可以用在检查数据是否重复上。我一般会用这一段代码把字段和缺失情况打印出来先建立数据直觉import pandas as pd # utf-8-sig 能兼容 Windows Excel 导出的带 BOM 中文 CSV df pd.read_csv(data/travel_records.csv, encodingutf-8-sig) print(df.info()) print(df.head(10))这段代码只有两个动作但很关键。info()会告诉你每一列有多少非空值如果user_id缺失率很高后面的推荐矩阵根本建不起来head()则让你看到景点名的写法有多乱比如“八达岭长城”可能被写成“八达岭长成”或者“八达岭长城景区”。这种不一致一般不会被报错却是推荐结果被腰斩的最常见原因。数据口径一开始没理顺后面所有算法和可视化都是在给错误数据化妆。2.2 推荐算法选型热门榜、基于内容与协同过滤怎么选很多教程会把推荐系统讲得很重但在这个项目里算法选型其实就三条路。热门榜最简单按用户访问次数排序适合做首页和冷启动兜底。它的问题是完全个性化所有用户看到的结果一样但作为“新用户没有行为数据时先丢给用户看什么”的保底方案它永远要用。基于内容推荐是把景点的城市、标签、消费等级做成特征向量然后算景点之间的相似度。它的好处是每个景点都有特征新景点也能被推荐出去不需要用户历史但缺点是特征通常粗糙城市一样不代表用户想去效果容易偏保守。协同过滤是这个项目最常见的核心算法。它不看景点的属性只看“和我去过同样地方的人还去了哪里”通过用户-景点评分矩阵计算相似度再对候选景点做加权排序。这种做法的个性化程度最高在有几千条行为数据时效果也最稳。我会怎么选数据量只有几百条时别硬上协同过滤用热门榜加内容相似度更稳数据量有几千条以上时协同过滤当主体热门榜当冷启动兜底。再强调一句热门榜不是用来凑数的去掉热门兜底新用户和新景点都拿不到任何推荐。下表是三个方案的直观对比方案需要的数据量个性化程度冷启动能力实现难度热门榜很少几十条就够无最强最低基于内容中等需要景点属性中较强低协同过滤较多用户行为充足高弱中2.3 可视化选型pyecharts 和 Flask ECharts 怎么取舍可视化的第一原则不是“哪个库好看”而是“图之后放在哪里”。如果只要最后的静态截图放进汇报文档matplotlib 就够了如果希望拿到的是能点击、能筛选、能接后端接口的数据看板那 matplotlib 通常不合适因为它做交互的成本很高。常见做法是两种。一种是 pyecharts它把 ECharts 的能力封装成了 python 接口适合只熟悉 pandas、不想碰前端的人缺点是在复杂交互场景里容易被库自身的抽象卡住。另一种是 Flask ECharts后端用 Flask 暴露一些 JSON 接口前端页面直接调接口渲染图表灵活性最高源码包里的部署说明也大多围绕这种方式展开。我看到“源码部署说明”这种标题会默认数据层、算法层、服务层、展示层这四层都已经拆好。数据层负责读文件和清洗算法层负责产出推荐列表服务层用 Flask 把推荐结果和统计结果包成 HTTP 接口展示层用 HTML 页面把图表画在浏览器里。后续的调参、部署、排错全是围绕这四层在转。3. 把源码跑起来环境、清洗、推荐、出图的最小可复现闭环3.1 环境准备先按 python 安装教程装一个 3.10 虚拟环境这类数据分析项目最怕依赖冲突。如果你还没装 Python直接按官方 python 安装教程准备一个 3.10 的环境最省事。3.10 是比较折中的版本pandas、numpy、scikit-learn、Flask 这几个核心依赖的兼容性都很稳。太老的 3.8 有些新语法用不了太新的 3.12 则可能碰到某些底层库还没有对应预编译包。我一般会在项目根目录建虚拟环境不用全局安装# 创建虚拟环境 python -m venv .venv # 激活环境Windows 用 .venv\Scripts\activate source .venv/bin/activate # 安装核心依赖 pip install pandas1.5,2.2 numpy1.23,2.0 scikit-learn1.1,1.5 flask如果你下到的源码包自带 requirements.txt不要跳过虚拟环境直接pip install -r requirements.txt容易把系统环境搞乱。虚拟环境不是给新手增加操作负担而是给你留后悔药。某一个依赖装坏了大不了删掉整个.venv目录重新建不用动系统的 Python。安装完后用pip list看一眼版本重点确认 scikit-learn 装上了因为后面建用户-物品矩阵和算相似度主要靠它。3.2 数据清洗把“故宫”和“故宫博物院”合并成同一景点数据清洗是旅游推荐项目里最枯燥但最值钱的部分。因为真实的数据永远不可能像教程那样规整同一个景点在不同的表里可以有完全不同的名字。我会先把日期解析、空值删除、景点别名合并放在一个清洗脚本里让后面所有算法和可视化都基于同一份干净数据。import pandas as pd df pd.read_csv(data/travel_records.csv, encodingutf-8-sig) # 1. 日期解析解析不了的记录直接丢弃 df[travel_date] pd.to_datetime(df[travel_date], errorscoerce) df df.dropna(subset[user_id, scenery_name, travel_date]) # 2. 去除景点名首尾空格并把常见别名合并 alias {故宫博物院: 故宫, 八达岭长城景区: 八达岭长城} df[scenery_name] df[scenery_name].str.strip().replace(alias) # 3. 评分缺失时用该用户自己的平均分填补 df[rating] df[rating].fillna( df.groupby(user_id)[rating].transform(mean) ) print(df.info()) print(df[scenery_name].value_counts().head(20))这里有两个参数容易踩坑。第一errorscoerce会让解析不了的日期变成NaT所以后面必须搭配dropna否则脏日期会留在表里。第二缺失评分不要直接fillna(0)。0 在余弦相似度里会真的参与计算把“没去过”和“很不喜欢”混为一谈是业务错误。用该用户历史评分的均值填充含义是这个用户对景区的一般期待值而不是“完全没得分”。清洗到这一步你还需要确认景点名合并后没有产生新的空值。我会用value_counts看前几个景点是否合理如果“故宫”和“故宫博物院”仍然同时出现说明别名映射没盖全需要继续补词典。3.3 协同过滤构建 item-item 相似度产出 Top-N 推荐这一段是这个项目真正的核心。我这里采用 item-based 协同过滤也就是计算景点和景点之间的相似度。当一个用户去过“故宫”并且打了高分系统就去找和“故宫”相似的景点推荐给这个用户。相比 user-baseditem-based 在物品数量小于用户数量时计算更快结果也更容易向业务方解释。from sklearn.metrics.pairwise import cosine_similarity import pandas as pd # 把清洗后的宽表变成用户-物品评分矩阵 matrix df.pivot_table( indexuser_id, columnsscenery_name, valuesrating, ).fillna(0) # 计算景点与景点间的余弦相似度 item_sim cosine_similarity(matrix.T.values) sim_df pd.DataFrame(item_sim, indexmatrix.columns, columnsmatrix.columns) def recommend(user_id, top_n5, min_sim0.2): # 没有行为数据的新用户走热门兜底 if user_id not in matrix.index: return popular_top(top_n) # 只取该用户明确打过分且大于 0 的景点 rated matrix.loc[user_id] seen rated[rated 0] scores {} for candidate in matrix.columns: if candidate in seen.index: continue score, total_w 0.0, 0.0 for item, actual_rating in seen.items(): w sim_df.loc[candidate, item] if w min_sim: score actual_rating * w total_w w if total_w 0: scores[candidate] score / total_w return pd.Series(scores).sort_values(ascendingFalse).head(top_n).index.tolist()这段代码的推荐逻辑是加权平均用户去过的景点评分越高候选景点与这些景点的相似度越高候选景点的推荐分就越高。最后除以total_w是为了避免“去过很多景点”的用户天然拿到更高分这里做的是归一化。min_sim0.2是经验值不是定死的。相似度低于这个阈值的候选景点会被认为“和用户去过的地方基本无关”强行推荐只会让结果莫名其妙。top_n5同样是业务参数如果可视化页面设计成一行五个卡片那 5 就是合理的如果首页要展示瀑布流可以考虑 10。协同过滤的代码要义不在炫技而是让参数能通过后续调试快速调整。3.4 Flask 接口 ECharts让推荐结果变成可交互看板算法算完只是第一步要看板能展示、让别人能点就要用 Flask 把数据包成接口。通常我会在app.py里定义两个接口一个推荐接口一个趋势统计接口。前端页面通过fetch从接口拿数据再用 ECharts 画图。from flask import Flask, jsonify, render_template, request import pandas as pd app Flask(__name__) # 假设前面清洗和推荐逻辑已经封装成 recommender 模块 from recommender import recommend, popular_top, df app.route(/) def index(): return render_template(index.html) app.route(/api/recommend) def api_recommend(): user_id request.args.get(user_id, default) top_n request.args.get(top_n, default5, typeint) result recommend(user_id, top_ntop_n) return jsonify({user_id: user_id, sceneries: result}) app.route(/api/trend) def api_trend(): trend df.groupby(df[travel_date].dt.to_period(M)).size().reset_index() trend.columns [month, count] trend[month] trend[month].astype(str) return jsonify(trend.to_dict(orientrecords)) if __name__ __main__: app.run(debugFalse, host0.0.0.0, port5000)前端页面用 ECharts 画一个横向条形图来展示推荐结果代码结构是这样!-- templates/index.html 核心片段 -- div idrecommend styleheight: 400px;/div script src{{ url_for(static, filenamejs/echarts.min.js) }}/script script async function loadRecommend(userId) { const res await fetch(/api/recommend?user_id userId top_n5); const data await res.json(); const chart echarts.init(document.getElementById(recommend)); chart.setOption({ grid: { left: 100 }, xAxis: { type: value }, yAxis: { type: category, inverse: true, data: data.sceneries.slice().reverse() }, series: [{ type: bar, data: data.sceneries.map(() 1) }] }); } /script这段前端代码里slice().reverse()是为了让 ECharts 的类目轴从下往上显示保证推荐的第一个景点出现在图表最顶端。条形图高度暂时全部设为 1只表达“推荐顺序”。真正交付时可以把推荐分数通过接口一起返回让条形长度表示推荐强度这个视觉表达更专业。到这里一套“读数据、清洗、算推荐、出图”的最小闭环已经跑通。你能在浏览器地址栏里访问http://127.0.0.1:5000看到一个能响应user_id参数的项目。但跑通和跑好之间还隔着参数调优这一层。4. 效果调优把推荐和看板的几个参数调到能交付的状态4.1 相似度阈值 min_sim多少才算“有关联”min_sim0.2不是通用答案。不同数据集的景点相似度分布差别很大有的数据里景区名称字段很规范高相似度对很多0.2 算保守有的数据里用户行为非常稀疏0.2 可能滤掉了几乎全部候选。我是这样确定阈值的先把所有非对角线的相似度拉出来看分位点再决定阈值设在哪。import numpy as np # 去掉每个景点和自身的相似度 sim_values sim_df.where(~np.eye(len(sim_df), dtypebool)).stack() print(sim_values.describe()) print(90% 分位点, sim_values.quantile(0.9))如果发现 90% 的相似度都低于 0.15说明数据本身太稀疏强行设 0.2 会让推荐列表经常凑不满top_n5。这时有两种选择把阈值降到 0.1或者换算法去用基于内容的景点标签相似度。反过来如果 50% 的相似度都在 0.4 以上说明用户行为高度集中阈值可以适度抬高到 0.3把噪声压下去。4.2 Top-N 的 N 与热门兜底窗口冷启动别写死top_n不要拍脑袋写 10。要看前端页面怎么展示一行五个卡片就写 5瀑布流长页面可以用 9 或 12。推荐列表过长反而稀释点击意愿业务上更看重“前几个准不准”。真正让很多项目翻车的是热门兜底没有时间窗口。只有新用户会走热门兜底如果热门榜直接按历史总量算老景点会永远压在新景点前面。一个简单做法是只取最近 30 天的数据算热门recent_hot ( df[df[travel_date] df[travel_date].max() - pd.Timedelta(days30)] .groupby(scenery_name)[user_id] .count() .sort_values(ascendingFalse) .head(10) )这样新用户看到的不是“历史上最多人去过的 10 个景点”而是“最近一个月的热门去处”时效性和数据看板的趋势图也保持一致。4.3 可视化大屏的交互参数热度分级、时间滑块、地图映射如果项目里要用可视化大屏展示地区热度ECharts 的地图热力图是最常见的形态。核心参数是visualMap它决定不同热度值对应什么颜色。很多新手会直接写max: 500结果数据里某个城市访问量是 3000其他城市全部被压成一个深色图面完全失真。我会先算数据的分位数再把分位数传给visualMap.max# 计算热度最大值的合理上限如 90% 分位点 import numpy as np heat_max np.quantile(city_stats[count].values, 0.9)然后在 ECharts 配置里用这个heat_max。这样做的好处是少数几个极端热点不会把整个地图的颜色层次吞掉。时间滑块则用dataZoom控制如果只有年月趋势折线图dataZoom的startValue和endValue可以设成数据里的最早和最晚月份避免用户打开页面只看到一小段截图。4.4 数据量小到矩阵稀疏降维、兜底与内容特征旅游推荐项目的数据量很可能只有几千条景区却有大几十个用户有上百个矩阵天然稀疏。这种情况下item-based 协同过滤经常出现“某用户没去过的所有景点相似度都接近 0”的尴尬局面。我通常会做三件事。第一把评分矩阵中访问量极低的景点过滤掉比如去掉只有 1 次访问的景点它们没有足够行为数据支撑相似度。第二给推荐逻辑加一个“如果 Top5 不足 3 个补齐热门榜”的兜底逻辑宁可推荐热门也不要让列表空着。第三给景点表加上城市和景区标签构建一个基于内容的相似度矩阵当协同过滤的相似度不足时用城市一致性作为兜底特征。提示这里不要一上来就引入 Spark、Redis 这类重组件。一个几千条的数据量级pandas 加虚拟内存完全能扛住重框架只会让部署说明复杂十倍收益却为零。5. 部署说明常踩的 5 个坑现象、原因、解决5.1 中文乱码一打开 CSV 全是大花脸现象pd.read_csv读进来之后景点名和城市全是乱码或者开头出现一个奇怪的\ufeff字符推荐结果里出现莫名其妙的前缀。原因CSV 文件是 Excel 在 Windows 环境导出的默认带 UTF-8 BOM 头而 pandas 默认按 UTF-8 解析有时也会把 BOM 直接解析到第一列列名里。解决读取时优先用utf-8-sig它专门处理带 BOM 的文件。如果数据源是 GBK 编码可以加一个回退try: df pd.read_csv(data/travel.csv, encodingutf-8-sig) except UnicodeDecodeError: df pd.read_csv(data/travel.csv, encodinggbk)如果你在部署说明里看到别人推荐encodingutf-8那大概率只在 Linux 上生成的 CSV 里有效Windows 数据场景一定要换成utf-8-sig。5.2 景点名不统一推荐结果被硬生生分成两拨现象推荐结果不算错但看起来像“故宫”和“故宫博物院”是两个景点用户去过“故宫”系统却还在推“故宫博物院”而且两个景点的评分被拆得很散。原因在 item-based 协同过滤里不同名字的景点会被当成独立列相似度矩阵里它们各自为政。别名没有合并景点矩阵列数虚高相似度也被稀释。解决在清洗阶段维护一份别名映射词典把“故宫博物院”“故宫博物馆”都归并到“故宫”。同时在清洗后检查df[scenery_name].nunique()如果景点名数量明显多于业务认知数量就要回头补词典。这个坑在部署后不会报错只会在结果上慢慢体现是最隐蔽的一种问题。5.3 端口冲突Flask 一启动就报 Address already in use现象在本地跑python app.py终端提示OSError: [Errno 98] Address already in use或者浏览器刷新后一直转圈。原因5000 端口被上一个没关干净的 Flask 进程占用或者本机有其他程序占用了同一端口。解决先在命令行查占用进程再杀掉。Linux 和 Mac 上命令是lsof -i:5000Windows 上用netstat -ano | findstr :5000找到进程 id 后kill。更省事的方法是直接换端口启动python app.py --port5050如果你改了app.run(port5050)前端调接口的地址也必须同步改否则页面会继续请求 5000 端口。另一个排查点是debugTrue它会启动 reloader经常让端口被两个进程同时占住部署前务必改成debugFalse。5.4 接口正常但页面白屏静态资源路径与字段映射错了现象浏览器里访问/api/recommend能正常返回 JSON但访问首页却是白屏或者 ECharts 图表一直不出来。原因前端页面的 ECharts 脚本没有加载出来或者接口返回的字段名和前端读取的字段名对不上。比如后端返回的是sceneries前端写成了sceneryECharts 拿到undefined图表自然渲染不出来。解决按 F12 打开浏览器开发者工具先看 Network 面板里echarts.min.js是不是 404再看 Console 面板里有没有TypeError。静态文件路径一定要使用 Flask 模板规则{{ url_for(static, filenamejs/echarts.min.js) }}不要手写相对路径/static/js/echarts.min.js否则在子路径部署时一定会出问题。字段命名的问题后端返回前先print(result)前端收到后再console.log(data)两头的字段对齐了再接图表。5.5 推荐越跑越慢相似度矩阵每次重启都在重算现象开发阶段几千条数据时还不明显到几百个景点之后就发现每次启动服务要等好几分钟点一次推荐也卡顿。原因相似度矩阵的计算在app.py顶层直接执行启动一次算一次或者没有缓存数据量变大后矩阵计算从秒级升到分钟级。解决把相似度矩阵保存成 pickle 文件启动时先读缓存。通常我放在cache/sim_df.pklfrom pathlib import Path CACHE_PATH Path(cache/sim_df.pkl) if CACHE_PATH.exists(): sim_df pd.read_pickle(CACHE_PATH) else: sim_df build_sim_matrix(df) CACHE_PATH.parent.mkdir(exist_okTrue) sim_df.to_pickle(CACHE_PATH)注意这个缓存只在数据源不变时有效。如果你重新清洗了数据务必手动删掉cache/sim_df.pkl否则推荐结果会基于旧矩阵。一个常见的操作习惯是把“删除缓存”写在部署说明的更新步骤里避免交付后业务方吐槽“改了数据没反应”。6. 进阶把部署说明变成能交付的数据看板并验证推荐效果6.1 用 waitress 代替 Flask 开发服务器Flask 自带的app.run是开发服务器处理并发能力有限不适合放在生产 Linux 环境里跑。我常在部署说明里推荐用 waitress它跨平台且不需要额外写一行代码pip install waitress waitress-serve --host0.0.0.0 --port8080 app:app这里的app:app含义是“模块名: Flask 实例名”也就是你在app.py里创建的app Flask(__name__)对象。启动后看板可以通过http://服务器IP:8080访问。如果源码包里的部署说明只写了python app.py那它更适合本地演示真正对外交付时还要补上 waitress 这一步才算完整。6.2 把相似度矩阵存成 npy/pickle让重启不重算这个习惯能显著改善交付体验。用户第一次打开页面时请求/api/recommend如果矩阵还要现算体验会很差。常见做法是在启动时预加载缓存上面第 5.5 节已经演示过读取逻辑这里再补一个关键点pickle 文件不要直接放在项目根目录放到cache/目录里并且在.gitignore里忽略它。否则别人拿到源码后你的本地缓存连同你的环境路径一起被打包发出去不同机器上反序列化容易出现路径相关的奇怪问题。6.3 验证推荐效果的三个自查指标能不能交付不能只看图表漂亮。我每次交付前会跑三个自查指标它们不需要线上系统也能算。指标计算方法达到多少算可用覆盖率被推荐过的景点数 / 全部景点数尽量不低于 30%命中率留出用户最近一条行为用之前数据推荐看是否命中Top5 命中率超过 20%稳定性同一用户连续两次调用结果是否一致结果应完全一致除非数据有更新覆盖率太低说明算法被热门景点困住了推荐列表总是那几个熟面孔。命中率太低说明推荐结果没有真正贴合用户喜好。稳定性问题通常来自代码里使用了未固定随机种子的sample()或train_test_split如果两次推荐结果不一致先查是否有隐式随机源。我在交付这类数据分析项目时最后的固定习惯是先跑覆盖率低于 30% 就回去调min_sim而不是急着调页面配色的细节。这样能避免很多次“看板很好看、算法实际没用”的翻车。旅游推荐这个方向数据和场景比模型更重要把景点归并、评分口径、冷启动兜底这三件事做扎实推荐结果和可视化看板自然都能见人。希望帮到你。本文还有配套的精品资源点击获取
返回列表