拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python旅游推荐系统实战:从协同过滤到混合推荐

按自己的喜好挑一个合适的景点在信息爆炸的今天反而成了最费劲的事。我花了两个周末用 Python 从零搭了一套旅游推荐系统跑通了从数据清洗、相似度计算到 Top-N 景点推荐的完整流程。这篇东西不是学院派的论文是我自己在实操过程中踩出来的经验记录。文中涉及的代码和思路适用于想做推荐系统练手项目、毕业设计或者想给自己小程序接入“猜你喜欢”功能的开发者也适合刚学完 Python 基础、想找一个完整项目练手的同学。1. 项目整体设计与推荐思路拆解1.1 需求分析推荐系统到底在解决什么问题旅游推荐这个场景本质上是帮用户在信息过载的信息流里做减法。携程、马蜂窝、小红书上关于一个城市的攻略可能有几百篇景点列表动辄几十上百个用户根本不可能一个个看过来。推荐系统的作用就是根据用户的历史行为、偏好特征从庞大的候选集里挑出最有可能吸引他的那一小撮景点。我之所以选 Python 来做这件事不是因为它“生态好”这种空话而是因为推荐系统开发周期里最核心的几个环节——数据处理、相似度计算、模型评估——Python 都有非常成熟的库可以直接用。pandas 处理表格数据numpy 跑矩阵运算scikit-learn 里内置了余弦相似度、TF-IDF 向量化等工具一个人两天就能出一个可用原型。换成其他语言光是把用户行为矩阵的存储和运算写好就要多花不少时间。这个项目解决的核心问题有三个。第一用户只有寥寥几次浏览或收藏记录怎么从中推断他的偏好第二景点数量多、标签维度杂怎么用统一的方式表达“这个景点适合什么人”第三推荐结果不能太单一否则用户刷两下就腻了。这三个问题分别对应推荐系统领域的经典分支协同过滤、基于内容的推荐、以及排序层的多样性优化。1.2 整体架构选型轻量级方案为什么够用在动手之前我先把系统的整体架构在脑子里过了一遍。完整的工业级推荐系统通常分四层数据层埋点、日志采集、召回层从海量物品中粗筛出几百个候选、排序层对候选做精细打分、重排层去重、打散、多样性控制。如果照搬这套架构需要引入 Spark、Flink 这类重工具明显超出练手项目的需要。我的处理思路是做一个“缩水版”的端到端流程但保留了工业级系统的核心逻辑离线计算用户相似度和物品相似度在线阶段给定一个用户 ID直接从相似度矩阵里召回候选景点再用规则和加权公式融合多种推荐结果。整个过程可以在一台普通笔记本上跑完不需要分布式环境也没有实时流计算。架构上我选了三个组件pandas 负责读取和清洗数据scikit-learn 负责计算相似度矩阵Flask 负责把推荐结果包装成接口。这里有个选型上的考量为什么不直接用深度学习模型因为旅游推荐这个场景用户行为数据天然稀疏一个用户可能一年就出行两三次深度模型需要的大量行为序列根本凑不齐。传统协同过滤在这种“冷启动严重、行为数据少”的场景里反而更稳、更可解释。2. 推荐算法核心原理解析与工具选型2.1 相似度计算余弦相似度和皮尔逊相关系数怎么选推荐系统的地基是“相似度”三个字。无论是找相似的用户还是找相似的景点最终都落到怎么量化两个向量有多“像”。最常用的有三种计算方式余弦相似度、皮尔逊相关系数、Jaccard 相似度。余弦相似度关注的是两个向量的方向是否一致不关心向量的长度。举个例子用户 A 给三个景点打了 5、4、1 分用户 B 打了 2.5、2、0.5 分余弦相似度会认为他们口味高度一致因为打分方向完全相同只是 B 整体打分偏保守。这个特性在评分数据里非常有用因为不同用户的打分尺度本来就不一样有人习惯给高分有人习惯给低分直接比较原始分数会被“尺度偏差”带偏。皮尔逊相关系数则是先把每个用户的打分减去他自己的平均分再算夹角。这种做法把“用户打分整体偏高”的影响消除得更彻底。在真实项目里我建议优先试皮尔逊系数因为它对评分偏移更鲁棒。至于 Jaccard 相似度只看两个集合的交集比例不看具体分数适合行为数据只有“点赞/没点赞”这种布尔值的情况用在旅游场景里会损失太多信息。最终我在代码里同时实现了余弦相似度和皮尔逊相关系数默认走皮尔逊。测试下来在真实评分数据上皮尔逊的推荐结果更贴合用户偏好尤其是在用户打分习惯差异明显的场景下。2.2 数据底座用户行为矩阵与景点画像的构建推荐系统里有个说法叫“数据决定上限模型只是逼近这个上限”。我在这个项目里没有用现成的公开数据集而是自己模拟了一套旅游行为数据因为公开数据集大多偏电商或电影和旅游场景的标签结构差异比较大。我构造了三个数据文件。第一个是用户表包含 user_id、年龄段、常驻城市、出行偏好亲子游、徒步、文化古迹、美食打卡等标签第二个是景点表包含 poi_id、景点名称、城市、门票价格、游玩时长、评分、以及一组景点标签比如“适合拍照”“历史古迹”“自然风光”第三个是行为表模拟用户对景点的收藏、打分和评论行为。这里最关键的环节是把原始的景点标签转换成向量。我做了一个简化处理把景点可能出现的所有标签收集起来去重后形成一个标签词典每个景点用一个和词典等长的 0/1 向量表示对应位置上有这个标签就记为 1。这种方法叫 one-hot 编码简单直接缺点是没有考虑标签的重要程度。后来我升级成了 TF-IDF 向量如果一个标签只在极少数景点出现比如“悬崖栈道”那它区分度就很高权重会被放大推荐出来的结果会更有个性。行为矩阵的构建是整个项目里最耗时的一步。用户真实的行为一定是稀疏的一个用户可能只收藏过 3 个景点但候选池里有 500 个景点这意味着矩阵里 99% 以上的位置都是空的。为了让后面的相似度计算跑得快我用 scipy 的稀疏矩阵来存储而不是普通的 pandas DataFrame。这一步对性能的影响非常大等数据量上来以后普通二维数组的内存占用会迅速爆炸。3. 实操过程与核心代码实现3.1 环境准备Python 与关键依赖的安装整个项目我是在 Python 3.10 环境下开发的。如果你用的是 Debian 系的操作系统系统自带的 Python 版本可能比较旧我建议不要动系统的 Python直接用 apt 安装 python3-venv 创建一个独立的虚拟环境避免污染系统环境。Windows 用户直接去 Python 官网下载安装包安装时记得勾选“Add Python to PATH”否则后面在命令行里运行 python 会提示 not found。IDE 方面VSCode 配 Python 插件就够用重点是把解释器路径指到虚拟环境里否则装再多的库也不会被识别。依赖库方面核心就四个pandas、numpy、scikit-learn、scipy。安装命令很简单pip install pandas numpy scikit-learn scipy如果你网络慢可以换成国内镜像源。我习惯在 pip 命令后面加-i https://pypi.tuna.tsinghua.edu.cn/simple速度会快很多。装完之后快速验证一下版本确保 scikit-learn 的版本不低于 1.0因为新版本对相似度计算的接口有调整python -c import sklearn; print(sklearn.__version__)3.2 核心算法一基于用户的协同过滤基于用户的协同过滤UserCF是整个推荐系统的第一个版本。它的核心思想就一句话找到和你口味最相似的一批人看看他们喜欢什么你没见过的把这些东西推荐给你。这个思路非常符合日常经验——你出去旅游前大概率会问身边兴趣爱好相近的朋友“上次你去 XX 玩得怎么样”实现步骤拆成三步。第一步构建用户-景点评分矩阵行是用户列是景点值是打分。第二步用皮尔逊相关系数计算用户之间的相似度矩阵。第三步对目标用户找出相似度最高的 K 个邻居把这些邻居打过分的景点收集起来按相似度加权算出预测分取 Top-N 输出。这里有一个需要注意的细节预测分不能简单用邻居评分的平均值。因为不同用户的打分习惯不同有的用户打分整体偏高有的偏低。正确做法是用邻居对该物品的评分减去邻居自身平均分再按相似度加权求和最后加上目标用户的平均分。这相当于把“邻居的口味偏差”和“自己的打分基线”分开处理预测会更准确。下面是我实际跑通的 UserCF 核心代码我加了详细的注释import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity from scipy import sparse # 构建用户-景点评分矩阵 def build_user_item_matrix(ratings_df): # 使用透视表把长表转成宽表 matrix ratings_df.pivot_table( indexuser_id, columnspoi_id, valuesrating ).fillna(0) return matrix # 计算皮尔逊相关系数手动实现便于理解 def pearson_sim(user1_ratings, user2_ratings): # 只取两个用户都评过分的物品 common_mask (user1_ratings 0) (user2_ratings 0) if common_mask.sum() 0: return 0.0 u1 user1_ratings[common_mask] u2 user2_ratings[common_mask] u1_mean u1.mean() u2_mean u2.mean() numerator ((u1 - u1_mean) * (u2 - u2_mean)).sum() denominator np.sqrt(((u1 - u1_mean) ** 2).sum() * ((u2 - u2_mean) ** 2).sum()) if denominator 0: return 0.0 return numerator / denominator # 基于用户的协同过滤推荐 def user_based_recommend(user_id, rating_matrix, user_sim_matrix, top_k5, top_n10): # 找到和目标用户最相似的 top_k 个用户 sim_scores user_sim_matrix[user_id].copy() sim_scores sim_scores.drop(user_id) # 排除自己 top_users sim_scores.sort_values(ascendingFalse).head(top_k) # 目标用户已经打过分或收藏过的景点要去掉 rated_items set(rating_matrix.loc[user_id][rating_matrix.loc[user_id] 0].index) # 加权汇总预测评分 item_scores {} for neighbor_id, sim_score in top_users.items(): if sim_score 0: continue neighbor_ratings rating_matrix.loc[neighbor_id] for item_id, rating in neighbor_ratings.items(): if rating 0 or item_id in rated_items: continue if item_id not in item_scores: item_scores[item_id] 0.0 item_scores[item_id] sim_score * rating # 排重、排序返回 Top-N ranked sorted(item_scores.items(), keylambda x: x[1], reverseTrue) return [item_id for item_id, score in ranked[:top_n]]这里我有个踩坑经验top_k 和 top_n 的取值直接决定了推荐结果的质量。我一开始把 top_k 设成 3结果推荐出来的东西非常窄因为邻居太少候选集太小。后来我把 top_k 调到 20top_n 取 10效果才稳定下来。建议你在自己的数据上做一个简单实验分别对比 top_k5、10、20 的推荐结果不要拍脑袋定参数。3.3 核心算法二基于内容的推荐与景点画像基于用户的协同过滤有一个天然的毛病如果用户只收藏了一两个景点他的“邻居”很难找得准推荐质量会断崖式下跌。这个时候就用上了第二个算法基于内容的推荐Content-Based。基于内容的推荐思路也很直观既然我们给景点打了标签那么完全可以直接计算用户收藏过的景点和候选景点之间的相似度。用户收藏了“西湖”喜欢它“自然风光”“适合拍照”的标签那系统就应该把同样带这两个标签的“瘦西湖”“千岛湖”这类景点捞出来。这个逻辑人不费劲就能理解可解释性极强这也是我推荐在旅游场景里引入基于内容推荐的原因——你可以直接告诉用户“因为你收藏了西湖所以推荐你去看千岛湖”。实现的时候我用了 sklearn 的 TfidfVectorizer 把所有景点的标签文本转成 TF-IDF 特征向量再两两计算余弦相似度得到“景点-景点”相似度矩阵。TF-IDF 的作用前面提到过它会给罕见但有区分度的标签更高的权重比如“极限运动”这个词在景点标签里出现得很少一旦某个景点有它就应该对相似度计算影响更大。基于内容的推荐打分逻辑如下from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 每个景点用空格分隔的标签文本表示 poi_tag_texts { poi_001: 自然风光 适合拍照 徒步, poi_002: 历史古迹 文化 博物馆, poi_003: 亲子游 主题乐园 室内, # ... } # 转成 TF-IDF 向量 vectorizer TfidfVectorizer() tfidf_matrix vectorizer.fit_transform(poi_tag_texts.values()) # 计算景点之间的余弦相似度 poi_sim_matrix cosine_similarity(tfidf_matrix, tfidf_matrix) # 基于内容的推荐累加用户收藏景点的相似度分数 def content_based_recommend(user_liked_pois, poi_sim_matrix, poi_ids, top_n10): # 初始化每个候选景点的得分 scores np.zeros(len(poi_ids)) for poi in user_liked_pois: idx poi_ids.index(poi) # 把和这个景点相似的景点分数累加进去 scores poi_sim_matrix[idx] # 用户已经收藏过的排掉 for poi in user_liked_pois: idx poi_ids.index(poi) scores[idx] 0 # 取分数最高的前 top_n 个 top_indices np.argsort(scores)[::-1][:top_n] return [poi_ids[i] for i in top_indices]基于内容推荐的优点是冷启动能力比协同过滤强只要有景点标签就能推荐不需要用户之间的行为关联。但它也有明显的短板推荐结果容易“画地为牢”只会推荐和你已收藏景点相似的很难帮你发现新类别的惊喜。为了解决这个问题我引入了“候选集扩展”的思路先用基于内容推荐拉出相似景点再用景点所属的城市做一轮过滤把同城的高分景点也塞进候选池让结果不至于局限在小圈子里。3.4 混合推荐加权融合与参数调优单一算法各有各的缺陷所以工业界几乎不会只用一种算法而是做混合推荐。我的第二版系统把 UserCF 和 Content-Based 的结果做了一次加权融合。融合公式非常简单final_score alpha * usercf_score (1 - alpha) * content_score这里 alpha 的取值非常关键。我做了两组实验alpha0.7 时推荐结果明显偏向“和你相似的人喜欢什么”结果比较大众化热门景点占比高alpha0.3 时推荐结果更贴你自己的收藏口味但多样性差一些。反复调试下来我最后把 alpha 定为 0.5综合效果最好。但这里有一个坑UserCF 的打分和 Content-Based 的打分不在同一个量纲上。UserCF 的分数来自相似度加权求和变化范围可能很大Content-Based 的分数来自余弦相似度累加范围是 [0, 1] 附近。直接加权会让量纲大的那个算法主导结果。解决方法很简单先对两组分数分别做 min-max 归一化再带入融合公式。归一化后的混合推荐代码def normalize(scores_dict): 把分数压缩到 0-1 区间 if not scores_dict: return scores_dict min_score min(scores_dict.values()) max_score max(scores_dict.values()) if max_score min_score: return {k: 1.0 for k in scores_dict} return {k: (v - min_score) / (max_score - min_score) for k, v in scores_dict.items()} def hybrid_recommend(user_id, usercf_scores, content_scores, alpha0.5, top_n10): usercf_scores normalize(usercf_scores) content_scores normalize(content_scores) final_scores {} all_items set(usercf_scores.keys()) | set(content_scores.keys()) for item in all_items: final_scores[item] alpha * usercf_scores.get(item, 0) \ (1 - alpha) * content_scores.get(item, 0) ranked sorted(final_scores.items(), keylambda x: x[1], reverseTrue) return [item for item, score in ranked[:top_n]]混合推荐的实际效果比我预想的要好。单独用 UserCF 跑出来的结果会有不少热门景点“霸榜”几乎每个用户拿到的都是同样的列表混合之后用户之间的推荐结果差异化明显变大冷门一些但标签匹配度高的景点开始出现。这就是我在 1.1 里说的“多样性”问题在系统层面得到了一部分解决。4. 常见问题与排查技巧实录4.1 冷启动问题新用户和新景点怎么处理我做测试的时候模拟了一批“只注册没点过任何景点”的游客。这种用户没有历史行为UserCF 直接哑火Content-Based 也因为用户收藏列表为空而输出为空。这就是推荐系统里最经典的冷启动问题。处理策略我总结下来有三种。第一种是“热门榜兜底”给新用户直接推全站收藏量最高的 Top-20 景点等用户产生了第一个行为再切换成个性化推荐。第二种是“偏好选择法”在用户注册时让他选几个感兴趣的场景标签比如“亲子游”“爬山徒步”“人文历史”把标签直接映射到带相同标签的景点上相当于用一次主动反馈完成了冷启动。第三种是“城市维度召回”如果用户的地理位置能拿到直接把常驻城市的周边热门景点推给他。我在代码里实现了前两种第三种只留了一个接口。从实测效果看第二种方法对后续推荐准确率的提升最明显因为用户主动选择的标签比被动行为更能反映真实意图。4.2 数据稀疏与计算性能矩阵太大跑不动怎么办当模拟用户数到了 5000、景点数到了 800 的时候我遇到了第一个性能瓶颈用 pandas 的 DataFrame 直接算用户相似度矩阵内存占用超过 2GB单次相似度计算要跑十几分钟。这个教训很直接——在数据量扩大之前就该把存储结构和计算逻辑设计成稀疏友好的。解决方案分两步。第一步用 scipy.sparse 的 CSR 格式存储用户行为矩阵只保存非零元素空间占用立刻降到原来的几十分之一。第二步计算用户相似度时不要一次性算完全部用户而是按“批量计算 结果缓存”的方式只算和目标用户有共同行为记录的用户对。实际操作中我甚至发现一个更快的办法先用 Jaccard 相似度粗筛出一批候选邻居只看是否重叠不看具体分数再对这批候选算精确的皮尔逊系数能省掉 80% 的无效计算。如果你后续要把这套系统接到 Web 服务里还有一个更工程化的建议把用户相似度矩阵和物品相似度矩阵提前算好存成文件在线推荐时只做查表不做实时计算。我是把相似度矩阵序列化成 numpy 的 .npy 文件保存配合 Redis 做缓存接口的响应时间能压到 50ms 以内。4.3 推荐结果多样性不足为什么用户总是看到同样的景点我最初跑出来的推荐结果有个比较尴尬的问题十个推荐位里有三四个是重复的热门景点。这是因为热门景点本身评分高、收藏多在 UserCF 和 Content-Based 的分数里都天然占优算法倾向于反复推荐它们。这个问题的排查过程让我意识到单纯调高 alpha 或者换算法都解决不了必须显式地在排序层做处理。我采用的做法是引入一个非常轻量的 MMR最大边际相关性重排逻辑。思路是每挑一个景点进入最终推荐列表时既考虑它本身的分数又要考虑它和已选景点的相似度。如果和已选景点太像就算分数高也得让位给其他类型的景点腾出位置。MMR 的公式长这样MMR argmax( lambda * 景点分 - (1 - lambda) * max(和已选景点的相似度) )lambda 控制“相关性”和“多样性”的权衡我实测下来取 0.7 比较合适。加了这个重排逻辑以后推荐列表里“自然风光历史文化主题乐园美食街区”这种组合开始出现整体观感丰富了很多。常见问题现象根因解决方式冷启动无推荐新用户接口返回为空无历史行为数据热门榜兜底 标签偏好选择相似度计算卡死CPU 跑满、内存溢出稠密矩阵存储无效计算过多稀疏矩阵改造 候选集粗筛推荐结果同质化Top-N 里热门景点扎堆缺少多样性约束MMR 重排lambda0.7分数量纲不一致混合推荐被某一算法主导两种算法分数范围不同min-max 归一化后再加权这里还需要注意一个容易忽略的细节采样数据时一定要控制用户行为分布的合理性。我一开始生成模拟数据时把所有用户的行为量设成一样多结果 UserCF 的效果看起来很好后来改成服从幂律分布少数用户行为多大量用户行为少效果明显下滑。这个现象和真实线上数据更接近——头部用户贡献了大部分行为。所以在做离线评估的时候务必要让测试数据和真实分布对齐否则你调出来的参数一上线就失灵。5. 从原型到上线的扩展建议如果你只是交作业或者练手做到混合推荐已经够了。但如果真想让这套系统serve给真实用户还有几件事躲不掉。第一是“多样性打散”不能只靠 MMR 一把梭。工业界会在重排层做更细的规则比如连续出现同一个子类目的景点不能超过两个、同一个城市的景点最多出现三个。这种规则看起来土但对用户体验的提升非常直接。第二是实时行为接入。我在这套系统里用的是离线计算的相似度矩阵用户新产生的收藏行为要等下一次离线任务才能生效。更好的做法是用消息队列实时接收行为日志增量更新相似度相关数据。第三是简单的 A/B 测试。上线前准备两组推荐策略一组用混合推荐一组用热门榜各分 50% 流量用点击率衡量哪个更优。这一步是让推荐系统从“看起来合理”走向“被验证有效”的分水岭。还有一个经常被忽略的点是推荐理由的展示。我在接口里额外返回了每个推荐景点命中的标签以及“因为您收藏了西湖所以为您推荐千岛湖”这类的可解释描述。用户的信任感很多时候就靠这一句话建立起来的。实现上与 Content-Based 的相似度矩阵天然契合几乎是白捡的加分项。说实话做完这套系统之后我对推荐算法的看法变得务实了很多。真正影响推荐效果的往往不是用什么模型而是你有没有把数据清洗干净、稀疏矩阵有没有做好、归一化有没有搞对、多样性约束有没有加上。先把这些基础工程做到位再谈模型升级这个顺序不能反。如果你也正在做类似的项目希望你少走我走过的弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门