ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python实战:从零搭建旅游推荐系统的完整路线

Python实战:从零搭建旅游推荐系统的完整路线 学完 Python 基础语法之后我一度陷入很典型的迷茫代码能看懂教程跟得住但真让我自己搭一个项目不知道从哪下手。后来我逼着自己做了个完整的旅游推荐系统才真正把 pandas、向量化、相似度计算、离线评估这些东西串成了一条线。这套系统不依赖深度学习也不用上分布式它就是一个纯 Python 项目却能完整走完“数据准备 → 特征表示 → 推荐算法 → 效果评估”的全流程。如果你也想找一个能练手、能写进简历、又能实际跑起来的 Python 项目这篇拆解应该能把路线和方案讲透。1. 为什么我建议把“旅游推荐系统”当成第一个完整项目1.1 它把你学过的语法点统统领着跑一遍很多 Python 教程的套路是字典、列表、函数、类、文件读写、正则、爬虫每样都学一点但互不关联。做旅游推荐系统的时候这些知识点会自动往一块凑。你要处理景点数据就得用 pandas 做过滤、分组、拼接你要表示景点特征就要把标签文本转换成向量你要给用户算相似度就需要理解余弦相似度而不是背公式。整个过程里字典推导、列表推导、函数封装、异常处理这些都是基本功随便哪里都会用到。更重要的是这个项目会逼着你学会“面向数据思考”而不是“面向语法思考”。面对一个推荐任务第一反应不再是“这个功能用什么函数实现”而是“我手里的数据结构长什么样怎么把它变成机器能算的形式”。这一步跨过去你才算真正从学习者变成做项目的人。1.2 这个项目做到什么程度算“合格”我一直觉得练手项目的标准不是“界面多炫”而是“链路完整”。旅游推荐系统只要做到下面这几点就算合格有相对规范的本地数据不是直接在代码里手写一个列表硬算。有至少一种推荐策略并且能解释清楚这个策略为什么有效。能根据某个用户的行为历史输出一份新的推荐列表。能用一个指标说明推荐结果不算差而不是自己拍脑袋说“看起来挺准”。这几条都满足简历上写“基于 Python 的旅游推荐系统”就有底气了。它不需要你有后台开发经验也不需要你懂 Java 微服务那一套正好卡在 Python 学习者跳一跳够得着的位置。2. 数据准备没有官方数据集亲手造一份更能读懂逻辑2.1 三条数据路线为什么模拟数据最适合新手我当时摆在我面前的有三条路公开数据集、爬虫采集、自己模拟。最后我选了第三条。不是因为它简单而是因为可控性最好。公开数据集的问题是字段和目标不匹配。很多推荐系统数据集是电影评分或者商品购买记录字段确实是“用户 ID、物品 ID、评分、时间戳”但拿来做旅游推荐总感觉少了一环——旅游景点不是纯消费物品它有类型、有城市、有门票价格、有热度这些都是推荐里面非常重要的上下文。硬套公开数据集做出来的东西更像“通用推荐模板”不像“旅游推荐”。爬虫采集听上去很酷但容易被平台反爬限制而且数据清洗成本很高。你今天爬下来的景点信息明天可能字段就变了。新手把精力耗在反爬和解析上反而没时间研究推荐逻辑这就本末倒置了。模拟数据的优势在于你能按需求设计字段数量、数据规模、稀疏程度还能精确控制“哪些用户喜欢什么类型的景点”。这样一来你后面验证推荐算法的时候心里是有数的——系统推荐得对不对你可以对照自己造数据的逻辑去判断而不是模糊地“感觉挺准”。2.2 表结构设计景点、用户、行为各管一摊我设计的结构是三张表景点表、用户表、用户行为表。景点表是推荐系统的“物品侧”字段包括景点编号、景点名称、所属城市、类型标签、热度值、平均评分、门票价格。这里最核心的是“类型标签”字段它决定了后面基于内容的推荐能不能跑起来。标签可以是“海滨”“古镇”“山地”“博物馆”“亲子乐园”“美食街区”这类通俗分类。用户行为表是“交互侧”记录每个用户对景点做过什么。真实场景里用户的行为有浏览、收藏、评分、实际到访。不同行为的权重完全不同到访 评分 收藏 浏览。行为表的字段比较少——用户编号、景点编号、行为类型、评分值、时间戳。但它是整个推荐系统里最关键的输入。用户表在最小版本里可以很薄只存用户编号和偏好标签甚至不建都行。旅游推荐的冷启动阶段用户画像可以直接从“行为”里算出来所以用户表可以先不做。字段设计有个容易被忽略的点标签字段一定不要写成“古镇、亲子、美食”这种逗号分隔字符串因为后面做向量化时还要拆分麻烦。更稳的做法是直接存列表格式或者用竖线分隔例如“古镇|亲子|美食”。这样后续处理不易出错也更容易扩展。2.3 生成模拟数据的代码模板我当时用 random 和 pandas 写了一个数据生成脚本规模不大但足够跑通全流程。下面给你一个可直接复用的版本import random import pandas as pd random.seed(42) # 景点类型标签池 TAG_POOL [海滨, 古镇, 山地, 博物馆, 亲子乐园, 美食街区, 湿地公园, 徒步路线] # 生成 60 个景点 spot_rows [] for i in range(60): tags random.sample(TAG_POOL, krandom.randint(1, 3)) spot_rows.append({ spot_id: i, name: f示例景区{i:02d}, city: random.choice([城市A, 城市B, 城市C]), tags: |.join(tags), hot: round(random.uniform(60, 99), 2), avg_rating: round(random.uniform(3.6, 5.0), 2), ticket_price: random.choice([0, 30, 60, 90, 120]), }) df_spots pd.DataFrame(spot_rows) # 生成 200 个用户的行为记录 action_rows [] for uid in range(200): visited_count random.randint(5, 30) for _ in range(visited_count): behavior random.choice([view, fav, rating, visited]) score round(random.uniform(1, 5), 1) if behavior rating else None action_rows.append({ user_id: uid, spot_id: random.randint(0, 59), behavior: behavior, score: score, }) df_actions pd.DataFrame(action_rows) df_spots.to_csv(spots.csv, indexFalse, encodingutf-8-sig) df_actions.to_csv(actions.csv, indexFalse, encodingutf-8-sig)保存的时候我特意用utf-8-sig编码这个细节后面会再提。如果你在 Windows 上用 Excel 打开 CSV不带 BOM 的中文很容易乱码。生成完数据之后你应该先做一次“体检”看看行为数据里有没有明显异常比如某个用户的行为全是“view”或者某些景点完全没有任何行为记录。这个直觉以后处理真实数据也靠得住。行为数太少后面计算相似度就很容易出问题。3. 算法选型从“热门榜”到“个性化推荐”的三条路3.1 基线路线热门榜为什么永远不要丢推荐系统不是一个上来就跑协同过滤的东西。最朴素的做法是“热门榜”——按热度值排序把最火的景点推给用户。它没有个性化但对新用户、新场景特别有用用户没有历史行为你无法算偏好那就先把大众认可的东西摆出来体验不会太差。我建议所有做推荐练手的人都保留一个热门榜基线。它有两个价值。第一它是评估的参照物——你后面做的个性化推荐如果连热门榜都比不过那说明算法设计有问题得回到数据上找原因第二它是冷启动的兜底方案——系统遇到没有任何行为记录的用户时直接回退到热门榜避免返回空列表。实际操作里热门榜也可以做得复杂一点。比如不只是按hot字段排序而是按“热度 × 0.6 平均评分 × 0.4”这种加权分排序会更接近真实需求。不要小看这个基础模型很多小型推荐系统上线第一版用的就是它跑一阵子觉得不够再升级也不迟。3.2 基于内容推荐让“标签相似”替你找地方基于内容的推荐核心思路是“你喜欢看古镇那我就把你没去过的其他古镇找出来”。它不关心别的用户怎么看只关心景点自己的属性。具体做法分三步。第一步把景点标签文本变成向量。最简单是用TfidfVectorizer把“古镇|美食街区”这类文本转成向量也可以用 one-hot 编码。第二步根据用户历史行为构建用户画像。用户去过三个古镇、两个海滨景点那他的画像向量就是这些景点向量的平均值。第三步把画像向量和所有景点向量做余弦相似度取最大的几个作为推荐结果。这个方案对冷启动比较友好。新景点的标签是现成的不需要等用户行为累积它就有机会被推荐出去。缺点是容易“同质化”——系统会一直推和用户历史偏好很像的地方但真实旅游场景里用户有时候也想换换口味。这时候就需要协同过滤来补位。3.3 基于协同过滤让“相似的人”替你探路协同过滤分两种UserCF 和 ItemCF。UserCF 的思路是找到和我行为相似的一群用户看他们喜欢什么我没去过的地方推荐给我。ItemCF 的思路是找到和我已经去过的地方相似的其他景点推荐给我。旅游场景下ItemCF 一般体验更好因为“我喜欢古镇所以推给我其他古镇”在直觉上更顺而 UserCF 在新闻、短视频这类“兴趣变化快、用户量极大”的场合适用。如果你的数据集很小比如 200 个用户、60 个景点UserCF 算起来很快但容易因为行为稀疏导致相似用户找不到几个。协同过滤的最大问题是稀疏性。一个用户可能只看过 5 个景点矩阵里 95% 都是空值。这时候相似度算出来可能全是 0推荐质量直线下降。所以小型项目我更推荐以“基于内容推荐”为主线协同过滤作为补充。3.4 路线选择建议我当时给自己定的路线是“内容推荐为主热门榜兜底”。不是说协同过滤没用而是对于一个练手项目先把一条链路彻底跑通比同时塞进多种算法更重要。你先完成一个能用的推荐引擎再逐步加协同过滤、混合加权后面每一步都有明确的目标和对照。下表是我做选型时的参照方案冷启动能力可解释性稀疏数据表现实现成本热门榜极强极强完全不受影响最低基于内容较强依赖景点标签较强受标签质量影响低协同过滤ItemCF弱新景点无行为中等差稀疏时几乎失效中对于数据集比较干净的入门项目基于内容已经是性价比最高的选择了。4. 代码落地一个最小可用的旅游推荐引擎4.1 核心模块的设计顺序写代码前不要急着堆功能。我建议把整个引擎拆成四段加载数据、构建特征、算用户画像、生成推荐。这样每一段都能独立测试。你后面接 Web 接口或者换成真实数据只需要替换前面的加载模块推荐逻辑完全不用动。还有一个设计选择我特别想强调明确“用户画像”和“用户行为矩阵”的区别。入门教程里经常教你构建 user-item 矩阵但在旅游场景下用户行为往往很稀疏直接构造矩阵会造成大量空值相似度计算也不稳定。更实用的做法是把用户行为的景点向量做加权平均得到用户的固定长度画像向量再和所有景点向量一次性对比。这样相似度计算每次都是确定维度的向量运算不受用户行为多少的影响。4.2 推荐引擎代码含用户画像构建下面这段是我那个项目里最核心的代码。它完成了基于内容的推荐全过程import numpy as np import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 1. 加载数据 def load_data(): spots pd.read_csv(spots.csv, encodingutf-8-sig) actions pd.read_csv(actions.csv, encodingutf-8-sig) return spots, actions # 2. 构建景点特征向量 def build_spot_vectors(spots): # 把“古镇|海滨”这种文本转成 TF-IDF 向量 spots[tag_text] spots[tags].str.replace(|, , regexFalse) vectorizer TfidfVectorizer() tag_matrix vectorizer.fit_transform(spots[tag_text]) return tag_matrix, spots # 3. 计算某个用户的画像向量 def build_user_profile(user_id, actions, spots, tag_matrix, top_k10): weight_map {view: 1, fav: 2, rating: 3, visited: 4} user_actions actions[actions[user_id] user_id] if user_actions.empty: return None spot_interest {} for _, row in user_actions.iterrows(): sid row[spot_id] base weight_map.get(row[behavior], 1) if pd.notna(row.get(score)): base row[score] / 5 * 2 spot_interest[sid] max(spot_interest.get(sid, 0), base) # 只取兴趣分最高的 top_k 个景点做画像 top_spot_ids sorted(spot_interest, keyspot_interest.get, reverseTrue)[:top_k] if not top_spot_ids: return None profile_vec tag_matrix[top_spot_ids].mean(axis0) return np.asarray(profile_vec).flatten(), set(top_spot_ids) # 4. 生成推荐结果 def recommend(user_id, spots, actions, tag_matrix, top_n10): prof build_user_profile(user_id, actions, spots, tag_matrix) if prof is None: # 冷启动直接按热度推荐 return spots.sort_values(hot, ascendingFalse).head(top_n) profile_vec, seen_ids prof sims cosine_similarity([profile_vec], tag_matrix)[0] rank_df pd.DataFrame({spot_id: range(len(sims)), sim: sims}) # 去掉用户已经去过或评分过的景点 rank_df rank_df[~rank_df[spot_id].isin(seen_ids)] result rank_df.merge(spots, onspot_id, howleft) result result.sort_values([sim], ascendingFalse).head(top_n) return result[[spot_id, name, sim, hot, avg_rating]]这段代码里有几个细节我说一下。第一build_user_profile里我用了“行为权重 评分修正”的方式计算每个景点的兴趣分比单纯取平均值更接近真实场景。浏览一次和到访一次权重当然不同。第二seen_ids里包含了用户所有交互过的景点包括浏览过的——浏览也算一种弱信号不推荐用户重复看同一个景点是合理的。第三冷启动时直接返回热门榜这个兜底逻辑在代码里必须显式写出来。4.3 跑起来之后你应该看到什么测试的时候找几个典型用户来试。我当时是随便挑了行为记录比较多的用户看推荐结果是否符合直觉。比如用户 3 去过 3 个古镇类景点那推荐列表里应该出现“标签带古镇、但用户没去过”的景点。这个验证不需要很精确但你要能自己对结果说出个所以然来。如果你的推荐结果全是相似度 0 的景点问题多半出在标签文本上——比如标签列变成了空值或者TfidfVectorizer分词不符合预期。这时候不要急着调算法先用print(tag_matrix.shape)看看特征矩阵的维度是否符合预期特征数应该是标签池的大小而不应该等于 0。5. 离线和上线怎么判断推荐结果好不好5.1 三个最常用的离线指标推荐不是“能出结果”就算完得知道结果好不好。我当时在本地做离线评估主要看三个指标准确率、召回率、覆盖率。准确率PrecisionK的意思是推荐列表里有多少是用户真实喜欢的。召回率RecallK的意思是所有用户真实喜欢的景点里被推荐出来的占比是多少。覆盖率Coverage则是推荐系统有没有把所有景点都暴露给用户还是永远只推那 10 个热门。评估的前提是你要有“真实喜欢的定义”。我的定义是用户行为类型是visited或rating且评分不低于 4 分。按照这个口径把用户行为切分成训练集和测试集——训练集用来构建画像测试集用来判断推荐是否命中是标准的做法。下面是我写的简易评估函数结构很直观def evaluate_recommender(actions, spots, tag_matrix, top_n10): # 只评估有行为、且 4 次交互的用户 test_users actions[user_id].value_counts() test_users test_users[test_users 5].index hit, rec_total, real_total 0, 0, 0 for uid in test_users: user_actions actions[actions[user_id] uid] # 用当前数据直接作为训练集简化版真实场景需要先切分 recs recommend(uid, spots, actions, tag_matrix, top_n) rec_ids set(recs[spot_id]) liked_ids set(user_actions[user_actions[behavior].isin([visited, rating])][spot_id]) hit len(rec_ids liked_ids) rec_total len(rec_ids) real_total len(liked_ids) precision hit / rec_total if rec_total else 0 recall hit / real_total if real_total else 0 return precision, recall注意这个版本是简化逻辑直接拿全量行为做推荐再算命中有数据泄漏但在小型项目里用来观察趋势够用了。真正严谨一点应该按时间切分前 70% 的行为做训练后 30% 做测试。这个切分思路以后接真实数据时可以直接迁移。5.2 冷启动问题新用户和新景点冷启动有两个方向。一个是新用户系统里没有任何行为记录画像算不出来这时候热门榜兜底是最稳的。另一个是新景点它刚进系统没有任何人访问过但如果标签字段填好了基于内容的推荐天然就能覆盖到它因为推荐只和特征向量有关不需要该景点的评分记录。这也是我倾向于“基于内容为主、协同过滤为辅”的原因。练手时你可以在数据里人为构造几个“新景点”然后跑一次推荐看它有没有机会出现在推荐列表里。如果能被推出去说明这套框架初步具备冷启动能力如果永远被排在最后那就检查一下标签向量有没有正常参与相似度计算。5.3 调优思路从“全热门”到“个性化占比”推荐系统的调优不是一上来就堆模型。我当时做的一个简单但很有效的调整是给最终推荐列表混合“个性化结果”和“热门结果”。比如热门兜底时可以设定 70% 的推荐位给个性化相似度高的景点30% 留给热门榜。这样既保证用户大概率感兴趣又避免推荐结果奇怪到让人怀疑系统的水平。加权的实现方式非常朴素把相似度和热度分做一个加权融合比如score 0.7 * sim 0.3 * hot_normalized。不要小看这种加权真实商业系统早期版本就是这么干的。你要做的就是先把可控变量列出来——相似度权重、行为权重、推荐列表长度、画像取景点数然后逐个调一遍观察指标变化。调整的时候切记一次只改一个变量。我当时犯过同时改了三个参数结果准确率下降根本说不清是哪个改坏了。保持单一变量测试是离线调参最基本也最容易被人忽视的原则。6. 实操复盘我踩过的坑和给你的建议6.1 行为稀疏导致相似度全为 0第一次跑通代码时我满心期待看到一份带个性化差异的推荐列表结果三分之一用户返回了热门榜兜底。排查之后发现原因是用户行为太稀疏——有人总共只有 3 条行为记录且分散在 3 个不同类别的景点上画像向量平均之后只剩很弱的信号跟所有景点的余弦相似度都特别低。这个问题的根子不是算法而是数据。解决办法有两层。一层是数据层调大模拟数据里每个用户的最低行为数比如从 5 调到 15先让链路跑顺另一层是算法层build_user_profile里不要对所有行为景点做平均只取兴趣分最高的 top_k 个能有效过滤掉弱信号。记住当推荐结果不好时先怀疑数据再怀疑算法最后才怀疑模型。6.2 标签体系不做统一推荐全乱套我一开始设计标签时比较随意有的地方写“古镇”有的地方写“古镇游”还有的地方写“古镇、美食”混在一起。结果就是同一个类别的景点被 TF-IDF 拆成了多个独立的词相似度计算完全失真。后来我把所有标签统一成一套词表逗号改成竖线分隔并用程序做了一次全量替换推荐质量立刻改观。这里给你一个实操建议标签词表一定要集中维护。可以单独建一个tag_dict.py里面维护所有合法标签。生成数据、代码处理、人工检查都只用这一份词表避免手滑写错。真实项目中标签体系混乱是非常常见的数据质量事故绝不是小问题。6.3 别小看内存和候选集规模60 个景点的时候全量相似度计算根本不算事。但等我把模拟数据扩大到 6000 个景点突然发现cosine_similarity([profile_vec], tag_matrix)虽然还行但如果到处都这么搞交互接口的响应时间就撑不住了。更别提如果采用 user-item 矩阵方式6000 × 200 的稠密矩阵内存开销已经很可观。优化思路有两个一是给用户画像算相似度时先按热度粗筛出前 500 个候选景点只在这 500 个里算精确相似度运算量降低一个数量级二是用scipy.sparse的稀疏矩阵存储特征矩阵不要让 pandas DataFrame 在日常计算里承载密集矩阵运算。对于小型旅游推荐项目第一个思路更实用实现也简单。6.4 几个实用建议最后分享几个我实际做下来觉得最值得注意的经验每一条都是真金白银换来的。第一项目文件别只放代码要把数据生成脚本单独保留方便你随时改变数据规模测试不同情况。第二读 CSV 时统一用encodingutf-8-sig这能省掉你在 Windows 和 Mac 之间来回切换时一半的编码问题。第三推荐结果一定要能画出来或者打印成表而不是只看数字视觉化之后你很容易发现“推荐的都是同一个标签下高度相似的景点”这种问题。另外如果你想把这个项目继续延伸可以考虑加一个简单的 Flask 接口把recommend函数暴露成 API前端表格页直接展示推荐结果。这一层不需要多炫但能让你第一次体验到“算法接进 Web 服务”的完整闭环。我自己的体会是旅游推荐这类小型系统真正难的不是算法本身而是把数据、特征、推荐逻辑有条理地组织起来。先把这条链路跑通再谈优化你就已经超过大部分只看教程不动手的人了。
返回列表