☰
基于Python的旅游推荐系统实战:从数据处理到算法融合
2026/9/30 3:29:51 网站建设 项目流程

“基于Python的旅游推荐系统”这个标题,乍一看很像一个典型的课程设计或者毕业设计题目,但真正动手做过的人会知道,把一个推荐系统从“能跑”做到“好用”,中间隔着数据、算法、工程化三条大沟。这篇文章我打算抛开教科书式的理论堆砌,完全从一个实际开发者的视角,把整个系统的设计思路、技术选型、核心代码、踩坑记录一次性讲清楚。无论你是准备做毕设、想给自己的博客加一个智能推荐模块,还是刚学完Python想找个实战项目练手,这篇文章的思路和代码你都可以直接拿去参考。

先说清楚这个东西到底是什么。所谓旅游推荐系统,本质是一个垂直领域的推荐引擎:用户输入自己的偏好(比如出发地、预算、游玩天数、兴趣标签),系统从一批旅游目的地或景点中,筛选出最匹配的结果。但光做到这一步还不够,真正有区分度的是“个性化”——同一个用户在不同时间、不同场景下想玩的东西不一样;不同的用户即使输入完全相同的条件,系统也应该给出差异化的排序。这背后依赖的,就是Python生态里那一套非常成熟的数据处理和机器学习工具链。

我自己的做法是:用pandas做数据处理,用scikit-learn和surprise库实现推荐算法,最后用Flask包一层Web接口,浏览器里就能直接点。整个项目不到2000行代码,但覆盖了从数据采集、清洗、特征工程到算法训练、评估、部署的完整流程。

1. 整体设计与思路拆解

1.1 为什么选Python写推荐系统

如果你用过Java或C++写过机器学习项目,再回头用Python,会有一种“从手工焊接电路板到使用自动贴片机”的落差感。Python在推荐系统这个领域几乎是垄断性的存在,原因就三个:生态、迭代速度、社区资源。

生态方面,数据处理有pandas和numpy,数值计算有scipy,机器学习有scikit-learn,专门做推荐系统的有surprise、lightfm、implicit这些库。关键不是“都有”,而是“都对接好了”。pandas的DataFrame可以直接喂给scikit-learn的模型,模型的输出又能无缝转成JSON返回给前端,整个链路非常顺滑。

迭代速度这一点做项目的人最懂。同样的协同过滤算法,在Java里你可能要写两百行代码处理矩阵和稀疏度,在Python里用surprise库三行搞定。项目节奏快的场景下,“今天提需求、明天出原型、后天看效果”这种节奏,只有Python扛得住。

社区资源则是隐性的护城河。推荐系统的坑非常多——冷启动、稀疏矩阵、流行度偏差——这些问题你几乎都能在Stack Overflow或GitHub的issue里找到现成的讨论和解决方案。动手前先搜一圈,能省出两三个通宵。

1.2 系统整体架构:模块划分与数据流向

整个推荐系统的架构我分成了四层:数据层、特征层、算法层、应用层。

数据层负责数据采集和存储。旅游领域的数据来源一般是三类:景点基础信息(名称、城市、门票价格、评分、经纬度)、用户行为数据(浏览记录、收藏、下单、评论)、用户画像数据(年龄、常住地、出行偏好)。我建议把这三类数据分开存储,不要一股脑塞进一张表里,因为它们的更新频率差别很大——景点信息可能一两个月才变一次,用户行为数据却是每天都在增长。

特征层是推荐系统里最容易被新手忽略的部分。原始数据不能直接喂给算法,需要先做特征工程:把“门票价格”归一化成“消费等级”,把“城市”转成one-hot编码,把“用户标签”做成分词后的词频向量。特征质量直接决定推荐效果的上限,算法只是尽可能逼近这个上限。

算法层是整个系统的大脑。我的实现里同时跑了两套算法:基于物品的协同过滤(ItemCF)负责“猜你喜欢”,基于内容的推荐(Content-Based)负责“根据你输入的条件筛出候选集”。最后用一个加权融合的策略把两套算法的结果合并,再做个去重和排序。

应用层就是一个轻量的Flask应用,提供RESTful接口。前端页面接收用户的参数请求,后端返回一个按推荐分降序排列的景点列表。

整个数据流动的方向是单向的:从数据层一路向上到应用层,每一层只依赖下一层的输出,不跨层调用。这也是我做一个项目时的基本习惯——前后端之间API先行,层与层之间接口明确,谁出问题了排查起来都很清楚。

2. 核心细节解析与实操要点

2.1 数据获取:公开数据集与爬虫方案的取舍

做推荐系统,首先要解决数据从哪来的问题。我推荐三条路,优先级从高到低排列。

第一条路是使用公开数据集。推荐系统领域的经典数据集都在旅游领域之外,比如MovieLens(电影评分)、Book-Crossing(图书)。直接用这些数据集做算法的验证没问题,但如果你想做一个“真正能演示”的旅游推荐系统,还是得有真实的景点数据。这时候可以用一些旅游平台开放的数据集,或者干脆从爬虫开始。

第二条路是自己写爬虫。Python做爬虫的套路基本固定:requests发请求,BeautifulSoup或Scrapy解析HTML,最后存成CSV或直接入库。我建议初学者自己写一个爬虫,量级控制在几千条数据就够了。这个过程能让你直观感受到“真实数据的脏”——字段缺失、格式混乱、重复数据,这些都是你在教科书上见不到的东西。

第三条路是人工整理。如果项目时间很紧,也可以人工从网站摘录一些数据。缺点是数据量少,推荐效果会打折扣。

爬虫这块提醒一句:不要爬那些有robots协议限制或需要登录才能访问的数据。一方面是合规问题,另一方面是需要登录的数据往往有反爬机制,投入产出比很低。我自己的做法是优先找那些数据开放的平台,爬几百页,再结合手动筛选,凑出两三千条干净数据完全够用。

2.2 数据清洗与特征工程:把脏数据变成可用数据的核心步骤

从爬虫拿到的原始数据,拿过来是不能直接建模的。我总结了一套标准处理流程,正好把“清洗”和“特征工程”两件事一起做。

第一步是去重。同一个景点在不同页面重复出现、同一条评论被爬了多遍,都属于需要处理的重复数据。pandas里一句df.drop_duplicates(subset=['name', 'city'])就完事了。

第二步是缺失值处理。景点数据里最常见的缺失是“门票价格”和“评分”。价格缺失的我一般用同城市的平均价格填充,评分缺失的用全局平均分填充。这里不建议直接删行——数据本身就不多,删一行就少一个推荐候选。

第三步是文本数据的标准化。景点名称里的空格、城市名里的“市”字后缀,这些都会影响后续的匹配和推荐。用str.strip()和str.replace()预处理一遍,再做统一格式转换,能省掉后面很多麻烦。

第四步是特征编码。旅游景点的特征分两类:数值型特征(评分、门票价格、热度)直接归一化到0到1之间;类别型特征(主题类型、适合人群)做成one-hot编码。这里有个小技巧:把“主题类型”拆细一点,比如自然风光、人文古迹、主题乐园、城市观光、美食购物,每个做成独立的标签列,后续做特征相似度计算时准确度高很多。

第伍步是构建用户画像。这部分我建议把用户的输入参数直接转换成和景点特征对齐的特征向量。比如用户选了“亲子游”,就映射到“适合亲子”这个标签上;用户选了“预算300元”,就归一化成消费等级的对应值。这样用户向量和景点向量就在同一个空间里,可以直接算相似度。

2.3 算法选型:协同过滤和基于内容推荐怎么选、怎么融合

推荐系统领域算法五花八门,但真正做旅游推荐,主流的还是两个流派:协同过滤(Collaborative Filtering)和基于内容的推荐(Content-Based)。我两个都实现了,最后融合使用,各有各的适用场景。

协同过滤的核心思想是“物以类聚,人以群分”,分两种:基于用户的(UserCF)和基于物品的(ItemCF)。UserCF就是找到和你偏好相似的用户,把他们喜欢的东西推荐给你;ItemCF则是找到和你历史喜欢的物品相似的物品,直接推荐。旅游场景我推荐用ItemCF——原因是旅游消费频次低,用户的历史行为数据很稀疏,基于用户相似度的计算容易失效,而物品之间的相似度相对稳定。

基于内容的推荐则完全不依赖用户行为,它的逻辑是“喜欢A的人,大概率也喜欢和A特征相似的B”。具体做法是把景点的特征向量拿出来,计算目标景点和候选景点之间的余弦相似度。这种方式最大优势是冷启动友好——一个没有任何行为记录的新用户,也能通过输入自己的偏好标签获得推荐。

两个算法各有短板:协同过滤有冷启动问题(新用户/新物品无行为数据),基于内容的推荐容易陷入“信息茧房”(推荐来推荐去都是同一种类型,多样性差)。所以我在最终系统里用了加权融合:候选景点同时经过两套算法打分,最终分 = 0.6 × Content-Based分 + 0.4 × ItemCF分。系数是调出来的,具体怎么调后面会说。

3. 实操过程与核心环节实现

3.1 数据预处理:完整代码与输出示例

我直接贴一段最核心的数据预处理代码,包含了读数据、清洗、特征构造的完整过程。

import pandas as pd import numpy as np from sklearn.preprocessing import MinMaxScaler # 读取原始景区数据 df = pd.read_csv('scenic_spots.csv', encoding='utf-8') # 去重:同一景点出现多次只保留第一条 df = df.drop_duplicates(subset=['name', 'city'], keep='first') # 缺失值处理 df['score'] = df['score'].fillna(df['score'].mean()) # 评分缺失填全局平均分 df['price'] = df['price'].fillna(df.groupby('city')['price'].transform('mean')) # 城市平均价格仍为空的行,再用全体平均价格兜底 df['price'] = df['price'].fillna(df['price'].mean()) # 文本标准化:去掉城市名中的“市” df['city'] = df['city'].str.replace('市', '', regex=False) # 主题标签:原始数据中是逗号分隔的字符串,拆开存成列表 df['tags'] = df['tags'].fillna('') df['tag_list'] = df['tags'].str.split(',') # 数值特征归一化 scaler = MinMaxScaler() df['score_norm'] = scaler.fit_transform(df[['score']]) df['price_norm'] = scaler.fit_transform(df[['price']]) df['hot_norm'] = scaler.fit_transform(df[['hot']]) print(df[['name', 'city', 'score', 'price', 'score_norm', 'price_norm']].head())

有一个细节要特别提醒:价格这个特征并不是越低越好。有人会把价格归一化后直接当作“性价比”特征,但旅游推荐里价格是双刃剑——预算充足的用户看到低分高价的景区会不满意,穷游用户看到高价的也不会点进去。所以我建议把价格归一化后取反再加到特征向量里,用的时候还要结合用户的预算参数动态处理。

3.2 基于内容推荐:余弦相似度计算旅游景点相似度

基于内容的推荐,核心就是计算特征向量的余弦相似度。

from sklearn.feature_extraction.text import CountVectorizer from sklearn.metrics.pairwise import cosine_similarity # 把tag列表重新组合成文本串 df['tag_str'] = df['tag_list'].apply(lambda x: ' '.join(x) if isinstance(x, list) else '') # 使用计数向量化把文本标签转为数值矩阵 vectorizer = CountVectorizer() tag_matrix = vectorizer.fit_transform(df['tag_str']) # 结合数值特征:稀疏矩阵转稠密,横向拼接 numerical_feats = df[['score_norm', 'price_norm', 'hot_norm']].values import scipy.sparse as sp combined_matrix = sp.hstack([tag_matrix, sp.csr_matrix(numerical_feats)]) # 计算所有景点两两之间的余弦相似度矩阵 similarity_matrix = cosine_similarity(combined_matrix, combined_matrix) # 示例:获取与“故宫博物院”最相似的5个景点 def get_top_similar(spot_name, top_n=5): idx = df[df['name'] == spot_name].index[0] sim_scores = list(enumerate(similarity_matrix[idx])) sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True) top_indices = [i[0] for i in sim_scores[1:top_n + 1]] return df.iloc[top_indices][['name', 'city', 'score']] print(get_top_similar('故宫博物院'))

初次运行的时候我踩过一个坑:tag_matrix是稀疏矩阵,numerical_feats是稠密的numpy数组,直接相加会报维度不匹配。解决办法就是像上面这样,把numerical_feats用scipy.sparse.csr_matrix转成稀疏格式再hstack。这个小问题浪费了我半个小时,写出来提醒大家一下。

3.3 协同过滤实现:surprise库快速构建ItemCF模型

surprise库是专门做推荐系统算法的Python库,内置了多种经典算法,用起来非常方便。ItemCF对应的实现是KNNBasic加sim_options。

首先,行为数据需要整理成三元组格式:用户ID、景点ID、评分。如果只有浏览记录没有评分,可以把“浏览一次”映射成1.0分,“收藏”映射成2.0分,“评论”映射成3.0分。找一个近似的权重映射关系,效果也不错。

from surprise import Dataset, Reader, KNNBasic from surprise.model_selection import train_test_split from surprise import accuracy # rating_df: user_id, spot_id, rating 三列 reader = Reader(rating_scale=(1, 5)) data = Dataset.load_from_df(rating_df[['user_id', 'spot_id', 'rating']], reader) trainset, testset = train_test_split(data, test_size=0.2) # 物品协同过滤:item-based, 使用余弦相似度 sim_options = { 'name': 'cosine', 'user_based': False, # False代表基于物品 } algo = KNNBasic(k=20, min_k=3, sim_options=sim_options) algo.fit(trainset) predictions = algo.test(testset) rmse = accuracy.rmse(predictions) print(f'ItemCF RMSE: {rmse:.4f}')

关于k和min_k的选择,我个人的经验是:k取20到50之间比较合适,太小了相似度计算不够稳定,太大了会把不太相关的物品也纳入进来引入噪声;min_k是限制“至少有3个共同评分的用户才计算相似度”,能有效避免小样本造成的伪相似。

3.4 混合推荐融合与排序

两个算法各出一份推荐结果后,需要在应用层合并排序。代码逻辑如下。

def hybrid_recommend(user_id=None, user_tags=None, top_n=10): # 内容推荐:根据输入偏好标签,计算与所有景点的相似度 cb_scores = content_based_scores(user_tags) # 协同过滤推荐:根据用户历史行为预测评分 cf_scores = {} if user_id: all_spot_ids = df['spot_id'].tolist() for sid in all_spot_ids: pred = algo.predict(user_id, sid).est cf_scores[sid] = pred # 加权融合 final_scores = {} for sid in df['spot_id']: cb = cb_scores.get(sid, 0) cf = cf_scores.get(sid, 0) if cf_scores else 0 # 新用户无行为数据时cf默认为0,此时cb权重自动主导 final_scores[sid] = 0.6 * cb + 0.4 * cf # 按分数排序,取top_n ranked = sorted(final_scores.items(), key=lambda x: x[1], reverse=True) top_ids = [sid for sid, score in ranked[:top_n]] return df[df['spot_id'].isin(top_ids)][['name', 'city', 'score', 'price']]

这0.6和0.4的权重不是拍脑袋拍出来的。我在实验阶段做了一组对比:只跑CB、只跑CF、以不同比例融合,各找10个真实用户做了盲测反馈。结果0.6/0.4的组合在“推荐相关性”和“多样性”两个指标上最平衡,就定下了这个配置。

4. 系统落地:用Flask快速封装Web服务

4.1 Flask接口设计与前后端交互

推荐系统本身是一个服务,不能只活在Jupyter Notebook里。我用Flask封装了三个核心接口:景点搜索接口、个性化推荐接口、热门排行榜接口。

个性化推荐接口是核心,它的POST请求体大概是这样的:

{ "user_id": 123, "preferences": { "city": "北京", "days": 3, "budget": 800, "interests": ["人文古迹", "美食"] }, "top_n": 10 }

后端拿到这个JSON后,解析参数,构造用户特征向量,调用前面写的hybrid_recommend函数,返回一个JSON数组。每个元素包含景点名称、城市、评分、价格、推荐分和一句简单的推荐理由。

推荐理由这个细节是后来才加上的,但效果出奇地好。用户看到的不只是一个列表,而是“因为您选择了人文古迹主题,为您推荐故宫博物院”这种可解释的信息。推荐系统最忌讳“黑箱”——用户不知道为什么被推荐,就会产生不信任感。加上推荐理由后,用户点击率明显提升。

4.2 性能优化:缓存与并发处理

旅游推荐系统虽然不是高并发场景,但也不能让用户等太久。我做了三个优化。

第一个是“预计算缓存”。景点相似度矩阵在数据不变化时是固定的,不需要每次请求都重新计算。启动服务时算一次,存内存里,后续查询直接查表。相似度矩阵是n×n的,3000个景点就是900万个浮点数,大约70MB内存,完全在可接受范围内。

第二个是“热门推荐兜底”。用户行为数据不足时(比如新用户、冷门兴趣标签),直接把数据库里按热度排序的前20个景点作为推荐结果返回。这个方法听起来朴素,但实际效果很好,因为它兼顾了普通用户的基本需求。

第三个是“Gunicorn多进程部署”。单个Flask服务是单进程的,并发能力有限。用Gunicorn起4个worker进程,配合Nginx做反向代理,QPS能提到几十,个人项目完全够用了。

5. 常见问题与排查技巧实录

5.1 冷启动问题:新用户和新景点怎么推荐

冷启动是推荐系统里最经典的问题,冷启动场景在旅游推荐里尤其常见——因为旅游消费频次低,普通人一年也就规划两三次出行,系统积累不到足够的行为数据。

针对新用户,我的方案是“偏好引导+热门兜底”。用户第一次使用时,先让他填一个简单的问卷:出发地、预算、天数、兴趣标签。这些信息直接构造成用户特征向量,交给基于内容的推荐算法。所以即使这个用户一个景点都没浏览过,系统也能输出合理的推荐结果。如果问卷信息也不完整,就直接用热门榜兜底,保证接口一定有数据返回。

针对新景点,问题展开来说就是:一个刚上线的景区没有任何用户行为数据,协同过滤部分算不出它的得分。对此我的处理是给所有新景点一个固定的“探索分数”加成——比如所有新景点在CB结果的排序中统一加上0.2的额外得分。这样新景点有概率被推荐出去,拿到最初的曝光和用户反馈,逐步进入正常的协同过滤计算流程。

5.2 数据稀疏问题:当评分矩阵空荡荡

数据稀疏是协同过滤的死穴。旅游推荐的行为数据天然稀疏——可能200条浏览记录里只有30条有评分,矩阵稀疏度超过95%。

解决稀疏问题的思路有三个方向。方向一:把评分数据从“显式评分”扩展到“隐式反馈”——用户搜索过的目的地、点击过的详情页、鼠标停留超过10秒的页面,都算作一次正反馈。这一步能带来几十倍的数据增幅。

方向二:降维。用矩阵分解类算法(比如surprise库里的SVD)代替KNN。SVD特别擅长处理极高稀疏度的矩阵,能把用户和物品映射到同一个低维隐因子空间,即使相互作用点很少也能学到有效向量表示。

方向三:收缩到群体。按城市、年龄段、出行方式将用户聚成几个群组,填充“群组平均评分”作为默认值。这种做法牺牲了一点个性化精度,但换来了推荐的稳定性和覆盖率。

5.3 性能瓶颈与Python常见坑点

Python在处理大规模计算时性能上限比较低,但旅游推荐系统的数据量级还远远达不到触顶。真正影响性能的往往不是Python本身,而是写代码的方式。

第一个坑是“循环里逐行处理DataFrame”。刚开始写爬虫清洗时,我用for循环遍历每一行做字符串处理,3000行数据跑了好几秒。后来改成apply方法配合向量化字符串操作,同样的数据毫秒级跑完。pandas的核心优势就在向量化计算,千万别背道而驰。

第二个坑是“相似度矩阵一次性算完”。前面提到3000个景点算出来的矩阵是70MB,如果数据量涨到1万个景点,矩阵就是800MB,内存直接吃满。这时候要么改用MiniBatchKMeans先做物品聚类,缩小相似度计算范围,要么用faiss做近邻检索,把时间复杂度从O(n²)降到近似O(log n)。

第三个坑是“Embedding向量全加载到内存”。如果以后你想上深度学习模型,用户和景点的Embedding向量会占不少内存。建议用numpy.memmap做内存映射加载,或者直接存放在Redis里做缓存,别一股脑全读进进程内存。

6. 评估与体验优化:推荐系统不只是“算出来”

6.1 离线指标与在线体验:我如何验证推荐效果

推荐系统做出来之后,怎么证明它推荐得“准”?学术论文喜欢用RMSE、MAE这些离线指标,但实际项目中这些指标只能说明“模型拟合用户历史行为的程度”,并不能说明“用户对推荐结果满意”。

我做了两套评估:离线测试用RMSE衡量预测精度。我之前用ItemCF跑出来的RMSE在0.91左右——在1到5分的评分尺度下,这个误差意味着“平均预测偏差在0.9分左右”,基础精度可用,但还有提升空间。

在线层面我用了一套“人工评测+点击率观察”的方法。让3个同事连续用了一周,每天记录搜索结果的前10条,让他们主观打分,1到5分,推荐相关且符合偏好的打5分,完全不相关的打1分。最后综合平均分到4.2分以上,说明系统达到了“可用”状态。同时观察了隐式反馈——用户是否点击了推荐结果、是否继续查看详情。

有句话我特别认可:推荐系统快不了的,就得人工多测;而人工多测的前提,是系统能把“为什么推荐这个”的理由也暴露出来。

6.2 推荐结果的多样性:不让用户陷进信息茧房

纯基于内容的推荐有一个严重问题:推荐结果会越来越“同质化”。用户喜欢人文古迹,系统就推荐故宫、颐和园、天坛,翻几页全是同一类。这其实是推荐系统领域常说的“多样性陷阱”——准确性很高,但用户很快就会厌倦。

我的解决办法是在最终排序里加入了“MMR(最大边际相关度)”策略。简单来说,在排序过程中不只看“景点与用户的相关度”,还要考虑“当前候选景点和已推荐景点的相似度”——如果新候选和已经推荐过的前几位太像,就跳过它,换一个稍微不同但相关度也不算低的。这个策略能显著提高推荐列表的多样性,让用户在“好这口”和“换换口味”之间得到平衡。

具体实现里,相关性分和差异性分的权重我设为0.7比0.3,你可以根据自己的场景调。

6.3 个性化解释:让用户明白“为什么推荐这个”

前面提过推荐理由的功能,这里展开说明一下思路。

每个景点最终都会被匹配到若干个标签,比如“人文古迹”“适合情侣”“交通便利”。当用户画像里出现了这些标签时,系统会在返回结果里附上对应的解释句。

explanation = [] if '人文古迹' in user_interests and '人文古迹' in spot_tags: explanation.append('基于您对人文古迹的兴趣') if spot_price <= budget * 0.5: explanation.append('价格在您的预算范围内') if spot_city == user_city: explanation.append('临近出发城市,出行方便')

这种硬编码的解释策略虽然不够“智能”,但在实际体验中效果非常好。用户看到推荐理由时,会更容易接受这个推荐结果,也更愿意点击。其实推荐系统本来就不只是“算法问题”,很大程度是“用户信任问题”。

最后分享一点做项目的心得

这个旅游推荐系统我从零到一完整做过两遍,第一遍是照搬教材,第二遍才真正懂了些东西。我个人的体会是:推荐系统的算法部分其实只占三分之一的工程量,剩下三分之二是数据处理、效果评估和体验优化。很多新手在算法上死磕,却忽视了数据质量、推荐理由、多样性这些“看不见的细节”才是决定系统成败的关键。

如果你准备自己动手做,我的建议是:先别追求复杂的深度模型,把基于内容的推荐和协同过滤吃透、融合好,已经能解决绝大多数场景的需求。然后一定要给自己搭一个离线评估流程,哪怕很粗糙——有了评估你才能迭代出更好的效果。最后再包装成Web服务,整个系统的完整度就有了质的提升。

如果后续你还想让这个项目继续生长,可以考虑接入更大规模的真实数据、引入用户实时行为日志、甚至用轻量级的向量数据库加速相似检索。但这些东西,都是建立在这个基础版本已经“稳定好用”的前提下。先把地基打牢,上层建筑才有意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询