学完 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, k=random.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", index=False, encoding="utf-8-sig") df_actions.to_csv("actions.csv", index=False, encoding="utf-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", encoding="utf-8-sig") actions = pd.read_csv("actions.csv", encoding="utf-8-sig") return spots, actions # 2. 构建景点特征向量 def build_spot_vectors(spots): # 把“古镇|海滨”这种文本转成 TF-IDF 向量 spots["tag_text"] = spots["tags"].str.replace("|", " ", regex=False) 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_k=10): 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, key=spot_interest.get, reverse=True)[:top_k] if not top_spot_ids: return None profile_vec = tag_matrix[top_spot_ids].mean(axis=0) return np.asarray(profile_vec).flatten(), set(top_spot_ids) # 4. 生成推荐结果 def recommend(user_id, spots, actions, tag_matrix, top_n=10): prof = build_user_profile(user_id, actions, spots, tag_matrix) if prof is None: # 冷启动:直接按热度推荐 return spots.sort_values("hot", ascending=False).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, on="spot_id", how="left") result = result.sort_values(["sim"], ascending=False).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 三个最常用的离线指标
推荐不是“能出结果”就算完,得知道结果好不好。我当时在本地做离线评估,主要看三个指标:准确率、召回率、覆盖率。
准确率(Precision@K)的意思是:推荐列表里有多少是用户真实喜欢的。召回率(Recall@K)的意思是:所有用户真实喜欢的景点里,被推荐出来的占比是多少。覆盖率(Coverage)则是:推荐系统有没有把所有景点都暴露给用户,还是永远只推那 10 个热门。
评估的前提是你要有“真实喜欢的定义”。我的定义是:用户行为类型是visited或rating,且评分不低于 4 分。按照这个口径,把用户行为切分成训练集和测试集——训练集用来构建画像,测试集用来判断推荐是否命中,是标准的做法。
下面是我写的简易评估函数,结构很直观:
def evaluate_recommender(actions, spots, tag_matrix, top_n=10): # 只评估有行为、且 >= 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 时统一用encoding="utf-8-sig",这能省掉你在 Windows 和 Mac 之间来回切换时一半的编码问题。第三,推荐结果一定要能画出来或者打印成表,而不是只看数字,视觉化之后你很容易发现“推荐的都是同一个标签下高度相似的景点”这种问题。
另外,如果你想把这个项目继续延伸,可以考虑加一个简单的 Flask 接口,把recommend函数暴露成 API,前端表格页直接展示推荐结果。这一层不需要多炫,但能让你第一次体验到“算法接进 Web 服务”的完整闭环。我自己的体会是,旅游推荐这类小型系统,真正难的不是算法本身,而是把数据、特征、推荐逻辑有条理地组织起来。先把这条链路跑通,再谈优化,你就已经超过大部分只看教程不动手的人了。