如果你最近正在为“旅游推荐系统”这种综合型项目挠头,或者说想把Python爬虫、协同过滤算法、数据可视化这一整条链路串起来做一个能演示、能交差的作品,那我这篇整理应该能帮上忙。我前不久刚把自己做的一套旅游推荐系统重新过了一遍,整套东西包括爬虫采集真实景点数据、基于协同过滤生成个性化推荐、再配上Web可视化看板,从数据到算法再到展示全部打通。规模不算大,但胜在链路完整,特别适合当课程设计、毕业设计,或者年终考核的项目原型。
这套系统最核心的价值在于:它不是那种只跑一个算法的demo,而是把“数据从哪来、算法怎么算、结果怎么展示”三个最容易被分开处理的问题统一收入一个工程里。很多朋友做类似课题时,往往是被某一块卡住:要么不会写爬虫,要么协同过滤只调库不理解原理,要么可视化做成了几张静态图。我希望通过这篇文章,把一个可复现的完整方案拆给你,包含技术选型逻辑、核心代码、我踩过的坑、优化思路,以及写万字报告时怎么组织内容才更容易过审。
1. 项目整体设计与思路拆解
1.1 为什么选择“爬虫+协同过滤+可视化”这个组合
先回答一个经常被问的问题:推荐系统那么多算法,为什么偏偏用协同过滤?最直接的原因有三个。第一,它足够经典,原理清晰,答辩时你能用大白话讲明白,不会像深度学习模型那样容易陷入“说不出为什么”的尴尬。第二,它对数据量要求不高,课程设计和本科毕设拿几千条用户评分数据就能跑出看得见的效果,换成基于内容的推荐还得额外构建画像特征,工作量大得多。第三,协同过滤的输出结果可解释性强,能直接告诉用户“因为你看过XX,所以推荐XX”,这在报告和演示中都是加分项。
至于为什么要加爬虫和可视化,我的想法很实际:很多教程会给你一份现成的MovieLens数据集,你跑个算法、输出RMSE就结束了。但旅游场景没有那么现成的公开数据,而且如果项目报告里只写“使用了公开数据集”,评委大概率会觉得工作量不饱和。自己写爬虫去采集真实的景点、用户评价数据,既能体现工程能力,又能让推荐算法在真实数据上跑,最终把结果可视化呈现出来,形成“数据采集—算法计算—结果展示”的完整闭环,这才是一个有说服力的项目。
1.2 系统功能模块划分
整个系统我拆成了四个核心模块,每个模块各司其职,之间通过数据格式规范衔接。
- 数据采集模块:负责从目标旅游网站抓取城市、景点名称、景点评分、评论者ID、评论文本、热度值等字段。
- 数据清洗与存储模块:对爬取结果做去重、缺失值处理、评论分词等操作,最终存入数据库(我用的MySQL,你也可以用SQLite)。
- 推荐引擎模块:构建用户—景点评分矩阵,使用协同过滤算法计算相似度,输出每个用户的TopN推荐列表。
- 可视化与前端展示模块:基于Flask搭建Web服务,通过ECharts渲染城市景点分布、评分热度、推荐结果等图表。
模块化设计最大的好处是:任何一块出问题都不需要推倒重来。比如爬虫字段变了,只要改数据清洗的映射;推荐效果不好,只调整算法层就能重新跑出结果。报告和答辩时,你也可以按模块分章节描述,结构清晰,老师问哪块都能直接定位。
1.3 技术选型与理由
| 技术组件 | 选型 | 理由 |
|---|---|---|
| 开发语言 | Python 3.9+ | 爬虫、算法、Web开发都能覆盖,生态完善 |
| 爬虫框架 | Requests + BeautifulSoup / Scrapy | 目标站点数据量不大,Requests足够灵活;Scrapy支持异步并发,适合高防护站点 |
| 数据处理 | Pandas + NumPy | 矩阵构建、清洗、统计都在行 |
| 推荐算法 | 自实现协同过滤(结合surprise库对比) | 手工实现便于理解原理,surprise库便于离线评估 |
| Web框架 | Flask | 轻量,几分钟搭好接口,适合小项目 |
| 可视化 | ECharts + 原生HTML/JS | 图表美观,异步加载方便,不需要额外license |
| 数据库 | MySQL(也可SQLite) | 结构化存储,写SQL方便,答辩时有底气 |
我当时没有用Django是因为这个项目后端逻辑不复杂,Django的ORM和Admin虽然强大,但会增加学习成本。Flask配合简单的路由和Jinja2模板,渲染几个页面足够了。如果你后续想扩展用户登录、评论增删改查,再把Flask蓝图和扩展插件加上也不迟。
2. 数据采集:爬虫设计与实现
2.1 目标数据与爬取策略
先明确要爬什么。旅游推荐系统至少要拿到三个维度的数据:景点信息(名字、城市、介绍、经度纬度)、评分信息(用户、评分值)、评论文本(用户表达偏好的重要来源)。我参考国内几个主流旅游平台的结构,定义的目标字段如下:
- 景点基础字段:景点ID、名称、所属城市、景点评分、热度(浏览量或收藏量)
- 用户评论字段:评论用户ID、评分(1-5分)、评论内容、评论时间
爬取策略分两层。第一层是列表页,遍历城市分类和各分页,拿到每个景点的详情页URL;第二层是详情页和评论接口,解析景点基本信息,再模拟Ajax请求获取评论列表。很多旅游网站的评论数据是通过异步接口加载的,直接解析HTML拿不到,建议用浏览器开发者工具(F12)的Network面板,筛选XHR请求,找到返回评论JSON的接口,然后直接请求那个接口,会省力很多。
具体流程可以这样写:
- 构造城市列表,每个城市对应一组景点列表页URL。
- 对列表页发起请求,提取景点的名称、评分、详情页链接。
- 访问详情页,补充景点热度、城市、简介信息。
- 找到评论接口,按页码循环请求,拿到用户ID和评分数据。
- 数据落地到临时DataFrame,每爬完一个城市就保存一次,防止中途进程挂掉丢失全部数据。
2.2 反爬应对与请求伪装
爬虫最容易踩的坑是什么?IP被封、验证码弹出、返回空数据。我在开发过程中遇到过详情页第一次能访问,第二次就被重定向到验证页的情况。我的处理经验是,一定要把爬虫伪装成正常浏览器,同时管理好请求频率。
几个必须做的细节:
- 请求头补全:User-Agent要模拟真实浏览器的版本和系统信息,Referer也要带上,很多网站校验这个。
- 随机延时:用
time.sleep(random.uniform(1, 3))让每次请求间隔随机化,别用固定秒数,固定间隔很容易被识别。 - 请求重试机制:遇到状态码异常时,捕获后等待几秒再重试,重试次数设为3次,超过就记录日志跳过。
- Cookie处理:部分接口需要登录后的Cookie,可以把浏览器里的Cookie复制到爬虫中,但注意不要在高并发下长时间使用同一个账号,容易触发风控。
至于代理池,我不建议初学者一上来就搞。代理池的维护成本高,免费代理多数不稳定,反而浪费时间。如果只是课程设计规模,控制频率、伪装头部和多换几个公用数据源,完全够用。
再强调一句合规问题:爬虫代码只用于个人学习和研究,抓取频率要低,不要影响对方网站的正常访问。如果网站有明确的robots协议或使用条款限制抓取,还是换一个公开的数据来源更稳妥。
2.3 数据清洗与存储
爬下来的数据通常很脏,不处理直接喂给算法会灾难。我遇到过的情况包括:评分字段出现字符串型数据(“4.5分”)、同一景点被多个页面重复抓取、评论内容为空、用户ID包含乱码等。清洗流程一般分四步:
- 字段类型统一:把评分、热度等列转换成浮点数或整数,非数值型强制置空。
- 去重:按“景点ID+用户ID”联合去重,同一个用户对同一个景点的多条评论只保留一条。
- 缺失值处理:景点名称缺失直接删除该行;用户ID缺失则用生成随机ID填充;评分缺失的行不能要,因为协同过滤依赖评分。
- 文本清洗:把评论文档中多余的换行、HTML标签、特殊符号去除,为后续可选的分词分析做准备。
清洗完成后,将数据存储到MySQL。我建了两张表:一张景点表(spot_id, spot_name, city, score, hot),一张评分表(user_id, spot_id, rating, comment_time)。后续构建评分矩阵只需要从评分表读取。存储时注意建立索引,user_id和spot_id都加普通索引,这样后面查询速度能快不少。
3. 协同过滤算法原理与落地
3.1 协同过滤的核心思想
协同过滤的直觉很朴素:如果你和另一个用户之前都喜欢相似的景点,那TA喜欢而你还没去过的景点,大概率也会合你口味。反过来,如果两个景点被很多共同用户喜欢,那么喜欢其中一个的用户,也可能喜欢另一个。前者叫基于用户的协同过滤(UserCF),后者叫基于物品的协同过滤(ItemCF)。
生活化类比:你和朋友一起吃火锅,你们俩都喜欢某地的麻辣锅底,那朋友推荐的同类火锅店,你去试踩雷的概率就比随便找一家店低得多。
在旅游场景里,ItemCF通常效果优于UserCF,因为景点数量相对固定,而用户数量大、兴趣变化快,用户相似度计算的时间和空间开销更大。另外,用户对景点的消费往往是一锤子买卖,去过一次的地方对推荐来说意义减弱,这时候物品间的相似关系更稳定。我在实现中两种都做了,最后线上用的是ItemCF的结果,用UserCF作为补充检验。
3.2 相似度计算与评分预测
相似度算法我首选余弦相似度和皮尔逊相关系数。余弦相似度的计算公式是:
similarity = 两向量的内积 / (向量A模长 * 向量B模长)在评分数据里,每个物品可以表示为一个用户评分的向量。向量中的维度是用户,值是对应用户给该物品的评分。这样计算物品之间的余弦相似度,得到的值越大说明两个景点的“评分画像”越接近。
皮尔逊相关系数比余弦相似度多做了一步去中心化,能消除用户评分尺度偏移(有的人习惯打4分,有的人总是打2分),公式上相当于对每个用户评分减去该用户的平均分后再算余弦相似度。实际效果上,皮尔逊相关系数在这个场景下更稳健,尤其当存在一些评分普遍偏高的用户时。
评分预测的公式采用加权平均:
预测用户u对物品p的评分 = 与p最相似的前K个物品的评分加权平均,权重为相似度这里有个细节要注意:不是所有相似物品都有评分数据,所以计算时只取用户u有评分的相似物品集合。如果这个集合为空,就回退到该景点的平均评分。这个简单的回退策略能避免大量冷启动缺失。
3.3 关键代码实现
我拿ItemCF的完整实现来举例。主要分三步:构建评分矩阵、计算物品相似度矩阵、生成推荐列表。
import pandas as pd import numpy as np # 从数据库读取评分表,得到 DataFrame: user_id, spot_id, rating df = pd.read_sql("SELECT user_id, spot_id, rating FROM ratings", engine) # 构建用户-景点评分矩阵 rating_matrix = df.pivot_table(index='user_id', columns='spot_id', values='rating') # 填充缺失值,注意这里用0填充只是为了计算余弦相似度时能参与运算 rating_matrix = rating_matrix.fillna(0) # 计算景点间的余弦相似度矩阵 from sklearn.metrics.pairwise import cosine_similarity item_sim_matrix = cosine_similarity(rating_matrix.T) # 转置后每一行是一个景点 item_sim_df = pd.DataFrame(item_sim_matrix, index=rating_matrix.columns, columns=rating_matrix.columns) def recommend_spot_by_itemcf(user_id, top_n=10, k=20): # 找到用户评分过的景点ID和评分 user_rated = rating_matrix.loc[user_id] user_rated = user_rated[user_rated > 0] if len(user_rated) == 0: return None # 初始化得分字典 score_dict = {} for spot_id, rating in user_rated.items(): # 与该景点相似的top-k个景点 sim_scores = item_sim_df[spot_id].sort_values(ascending=False)[1:k+1] for sim_spot_id, sim_score in sim_scores.items(): if sim_spot_id in user_rated.index: continue # 排除已评分的景点 score_dict[sim_spot_id] = score_dict.get(sim_spot_id, 0) + rating * sim_score # 按得分排序 ranked = sorted(score_dict.items(), key=lambda x: x[1], reverse=True) return [spot_id for spot_id, score in ranked[:top_n]]代码看着不长,但有几个小坑需要说明。
- 0填充会让余弦相似度计算把所有缺失值都当成“不喜欢”,这其实是错误的。更严谨的做法是只对非零评分计算距离,但那样代码复杂度高。在数据量不大、稀疏度可控的前提下,0填充对相似度排名的影响不会致命,可解释性也说得过去。如果追求更高质量,可以使用皮尔逊相关系数,在sklearn中可以用
scipy.stats.pearsonr自己实现。 k是相似邻居数量,我试过几个值,最终取了20。K值太小,相似信息不足;K值太大,噪声影响增大。K的选取可以通过离线实验对比RMSE来确定。- 排除已评分景点是必要的,否则推荐列表里全是用户去过的景点,毫无意义。
3.4 算法评估与调优
推荐系统不能只看“跑出来了没”,还得有评价指标。我在项目中做了离线评估,把评分数据按8:2切分为训练集和测试集,在训练集上计算相似度矩阵,用测试集的实际评分来验证预测评分。
主要看两个指标:RMSE(均方根误差)和MAE(平均绝对误差)。用surprise库直接计算很方便。
from surprise import Dataset, Reader, KNNBasic from surprise.model_selection import train_test_split reader = Reader(rating_scale=(1, 5)) data = Dataset.load_from_df(df[['user_id', 'spot_id', 'rating']], reader) trainset, testset = train_test_split(data, test_size=0.2) sim_options = {'name': 'pearson_baseline', 'user_based': False} algo = KNNBasic(k=20, sim_options=sim_options) algo.fit(trainset) predictions = algo.test(testset) # 计算RMSE MAE from surprise import accuracy accuracy.rmse(predictions) accuracy.mae(predictions)我最终得到的RMSE大概在1.0左右,MAE在0.8左右,意味着预测的平均误差不到1分,对于旅游推荐来说,这个精度已经足够产生有效排序了。调优经验分享:
- 如果数据稀疏,ItemCF的K值适当增大,比如30-50,因为可用的共同评分邻居少。
- 皮尔逊基线的效果通常优于普通余弦,但计算更慢。
- 如果指标不理想,别急着调参,先检查数据中是否有大量重复用户和错位字段,脏数据对推荐效果的影响远大于算法本身。
4. 可视化与系统实现
4.1 可视化展示什么
可视化不是为了炫技,而是为了把“采集了哪些数据、推荐效果如何、系统长什么样”直观呈现出来。我做了四个核心图:
- 全国热门城市景点数量柱状图:展示数据采集覆盖度,一眼看出哪些城市景点最多,侧面验证爬虫字段是否完整。
- 景点评分分布直方图:观察评分集中在哪个区间,判断是否存在用户打分偏好偏移(比如都打高分)。
- 用户活跃度Top10条形图:找出评分最多的用户,这些用户是协同过滤中贡献最大的“锚点”。
- 推荐结果展示:针对某个模拟用户,展示系统推荐的Top10景点列表和综合得分。
此外,我还做了一个城市景点热力地图,基于经纬度在ECharts地图上打点,展示热门景点的位置分布,视觉冲击力很强,答辩时能撑场面。
4.2 Flask框架整合
后端我用Flask提供接口,前端用HTML+ECharts渲染。核心思路是把所有数据封装成JSON接口,前端异步获取。
一个简化的Flask应用结构如下:
from flask import Flask, render_template, jsonify import pandas as pd from recommender import recommend_spot_by_itemcf app = Flask(__name__) # 加载清洗后的数据 spots = pd.read_csv('data/spots.csv') ratings = pd.read_csv('data/ratings.csv') @app.route('/') def index(): return render_template('index.html') @app.route('/api/city_distribution') def city_distribution(): data = spots['city'].value_counts().head(10).to_dict() return jsonify(data) @app.route('/api/recommend/<int:user_id>') def recommend(user_id): rec_list = recommend_spot_by_itemcf(user_id, top_n=10) result = [{'spot_id': sid, 'name': spots.loc[spots['spot_id']==sid, 'spot_name'].values[0]} for sid in rec_list] return jsonify(result) if __name__ == '__main__': app.run(debug=True)这里有一点很关键:recommend_spot_by_itemcf函数每次请求都会重新计算相似度矩阵吗?如果数据量大,这是灾难。我实际项目中会把相似度矩阵在服务启动时预先计算好,放到全局变量中,推荐函数只做查询和排序,这样接口响应能压到毫秒级。对于课程设计,这个优化意识很加分。
前端页面用Ajax拉取接口,比如使用fetch:
fetch('/api/city_distribution') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('cityChart')); chart.setOption({ xAxis: { data: Object.keys(data) }, yAxis: {}, series: [{ type: 'bar', data: Object.values(data) }] }); });ECharts的CDN建议下载到本地,避免答辩现场没网络时图表空白。我踩过这个坑。
4.3 用户体验与交互设计
我还在Web页面上加了模拟用户选择下拉框,让你可以选择不同的用户ID,点击查看各自不同的推荐结果。这个看似简单的功能,实际演示效果很好,因为评委可以亲自选一个用户看到推荐输出,比静态截图有说服力。
交互逻辑:下拉框加载所有用户ID,选择后触发推荐接口,返回的景点列表渲染成卡片,卡片上显示景点名称、景区评分、预测可能喜欢的原因(依据相似景点)。如果要做“推荐理由”,可以记录生成推荐时参与了加权的Top3相似景点,在返回JSON里带上。
5. 常见问题与排查技巧实录
5.1 爬虫数据采集中的典型问题
最让我头疼的是评论区异步接口的签名验证。很多网站会在接口参数里加sign或token,直接重放请求会被拒绝。我的解决思路有两个:一是尝试不带签名直接访问,有些站点校验不严;二是使用Selenium接管浏览器,让浏览器自己执行JavaScript生成签名,虽然慢,但稳定。在你实在搞不定动态签名时,Selenium是不错的保底方案,只是要注意控制并发,开多个浏览器窗口很容易被检测到。
还有一个高频问题:爬下来的评分有大量重复。原因是同一个景点可能同时出现在“城市必玩榜”和“周边景点精选”等不同列表里,爬虫会重复进详情页。解决办法就是在清洗阶段做联合去重,别在爬取阶段试图过滤,因为列表页结构不同,解析时容易遗漏。
5.2 推荐效果不理想的调整思路
如果你发现推荐结果总是集中在那几个热门景点,没有什么个性化,大概率是物品相似度矩阵中马太效应太重。解决办法是调整评分归一化:每个用户的评分都减去该用户自己的平均分,再计算相似度,这样能消除“爱打高分用户”对热门景点的无脑助推。另外可以设置一个高频惩罚项,对极端热门的景点降低相似度权重,让冷门但合格的景点有机会露头。
还有一种情况是数据太稀疏,很多用户只有一两条评分,导致相似度矩阵全是0。应对策略:只保留评分次数大于等于3的用户,低于这个阈值的用户直接剔除。这样会损失一部分数据,但保留下来的是有效计算集,推荐质量明显提升。
5.3 可视化图表加载不出来的解决办法
前端图表空白,90%是数据格式问题。ECharts的data字段要求数组格式,如果后端返回的是字典,要用Object.keys()和Object.values()转换。还有不少初学者把JSON接口写成了自动渲染HTML页面,前端fetch拿到的直接是<html>标签,必然报错。排查时先浏览器的开发者工具里看Network响应体,确认返回的是不是合法JSON。
另一个常见坑是ECharts初始化时DOM容器宽度为0,通常是因为图表容器放在CSS里没有设置显式高度,比如只写了height: 100%但父容器高度没有定义。解决办法是给容器设置固定高度,比如style="height: 400px"。
5.4 万字报告撰写与答辩经验
标题既然提了报告,我也把写报告的心得一并讲掉。很多人以为万字报告就是把代码贴上去凑字数,其实评委最想看的是“为什么这么做”和“效果怎么样”。
报告核心章节我建议这样组织:
- 绪论:研究背景、意义、国内外现状,重点写清楚“旅游推荐”解决什么问题。
- 系统相关技术:简要介绍Python、Flask、协同过滤、爬虫原理,这部分不要长篇大论,能让人看懂即可。
- 系统需求分析:功能性需求、非功能性需求、用例图。
- 系统设计:总体架构图、模块划分、数据库ER图、接口设计。
- 系统实现:每个模块的核心代码片段加运行截图,代码只贴关键逻辑,不要贴300行重复代码。
- 系统测试:功能性测试用例、算法评估结果(RMSE/MAE)、爬虫运行日志。
- 总结与展望:说做的好的,也坦然说不足(比如系统没有用深度学习、没有引入实时数据),再展望未来改进。
答辩时最常被问的三个问题:
为什么用协同过滤,不用深度学习?回答要点:数据量达不到深度学习要求,协同过滤在小规模数据上性价比高,且可解释性强。最好补充一句“后续如果数据积累充分,可以扩展基于图神经网络的推荐”。
相似度怎么计算的?把余弦相似度和皮尔逊系数的公式写白板上,用一组简单数据现场演示计算过程。
如果新景点没有评分,系统怎么处理?这就是冷启动问题。我当时的回答是:系统会从评分矩阵中剔除没有评分的景点,并在生产中利用爬虫抓取景点热度与内容特征做基于内容的补充推荐。这个回答能把缺陷转变为扩展方向。
最后留一点我的真实体会
整个项目做下来,我最想强调的不是算法本身,而是数据质量。协同过滤能不能出效果,很多时候不是取决于你选了ItemCF还是UserCF,而是你的评分矩阵里是不是塞满了重复、缺失、异常值。我前期比较急躁,直接拿爬下来的原始数据跑算法,结果推荐出来全是同一个城市的头部景点,看起来毫无个性。后来耐住性子做了数据清洗和用户最低评分次数过滤,推荐结果才真正有了可区分度。
另外一个小技巧:写文档时先画一张系统架构图,把四个模块之间的数据流标清楚,整份文档的结构就会自然浮出来,各章节写起来也会顺畅很多。这不只是应付报告,也是以后做任何项目都能复用到的经验。如果你正准备启动类似的课题,希望这篇文章能帮你少踩几个坑,做出一个真正完整、可演示、能答辩的项目。