ARTICLE DETAIL

资讯详情

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

基于Python的旅游景点推荐系统:爬虫+协同过滤+Hadoop+Flask实战

基于Python的旅游景点推荐系统:爬虫+协同过滤+Hadoop+Flask实战 每到毕业设计季推荐系统都是计算机专业的常客各种电商推荐、电影推荐题目我见了不下几十个。相比之下基于Python的热门旅游景点推荐系统这个题目更容易做出辨识度——它把爬虫、协同过滤推荐算法、Hadoop、Flask可视化展示串成一条完整链路Hadoop负责数据落地与离线统计协同过滤负责核心推荐逻辑Flask负责和前端页面打交道答辩时能讲的东西非常立体。这篇文章把我做这套系统的完整思路、模块拆分、核心代码和踩过的坑都整理出来。不管你是想照着跑通再改需求还是想真正理解背后的原理都可以参考。1. 这套系统的整体架构到底怎么搭1.1 为什么是Hadoop 爬虫 协同过滤 Flask这个组合先说技术选型的逻辑。这套题目的核心诉求是一条龙既要有数据来源又要有算法深度还要能可视化展示顺带体现大数据思维。四个组件各管一段职责非常清楚。爬虫负责数据采集。旅游景点数据没有一个现成的标准数据集需要通过爬虫从公开网页拿。这也是毕设里最直观的工程能力展示。Hadoop负责数据存储和离线计算。景点数据、用户行为数据积累到一定量级之后单机文件或MySQL就不好玩了HDFS天然适合海量数据落地。MapReduce离线统计可以产出城市景点数量评分分布这类前端要用的聚合结果。协同过滤推荐算法负责核心推荐。它原理清晰、代码可复现、结果可解释不会像深度学习那样变成一个说不清楚的黑盒。答辩老师问起来你可以从相似度计算讲到冷启动处理层次感很强。Flask负责把算法结果变成能看的网页。轻量、简单、几分钟能写完接口和ECharts配合做可视化非常顺手。这套方案最大的优势不是单个组件多牛而是形成了闭环数据采集 → 数据清洗 → HDFS存储 → 离线统计 → 算法推荐 → Web展示每一步都有产出物。很多毕设项目的问题在于点状堆砌每个技术都用了一点但串不起来。你只要把数据流讲明白答辩就已经赢了一半。1.2 项目模块划分与目录结构建议我在动手之前先把目录结构定死强烈建议你也这样做。项目一大了之后最容易出现的问题就是爬虫代码、算法代码、Web代码全都混在一个文件夹里最后自己都找不到文件。travel_recommend/ ├── spider/ # 爬虫模块抓取、解析、清洗 │ ├── fetch_spot.py │ └── clean_data.py ├── data/ # 本地中间数据 │ ├── raw_data.csv │ └── final_data.csv ├── hdfs_loader/ # 数据上传脚本 │ └── upload_hdfs.py ├── mr_stats/ # MapReduce离线统计 │ ├── city_count_mapper.py │ └── city_count_reducer.py ├── recommend/ # 协同过滤推荐算法 │ ├── cf.py # 相似度计算与推荐 │ └── mix_recommend.py # 混合推荐策略 ├── web/ # Flask应用 │ ├── app.py │ ├── templates/ │ └── static/ └── docs/ # 设计文档、答辩材料每个模块独立成包模块之间通过数据文件或接口通信这样后期改起来很轻松。比如你不想用爬虫数据直接换一个CSV文件就能跑不想用Flask把推荐结果导出成JSON也能演示给老师看。2. 爬虫采集旅游数据是怎么一步步落地的2.1 技术选型与合规前提爬虫框架主要就是两个选择requests BeautifulSoup或者 Scrapy。我的建议是景点数据量在几千量级用requests BeautifulSoup就够了轻量直接出了问题好排查。Scrapy的性能优势要上十万级页面才明显而且它的信号机制、中间件配置对新手不友好毕设阶段没必要为了框架而框架。这里必须强调一个前提爬虫一定要合规。只抓取公开可访问的页面信息遵守目标网站的robots协议设置合理的请求间隔我习惯每个请求之间sleep 1到2秒不做高频并发请求抓下来的数据只用于学习和研究不商用、不二次传播。这不是套话是实操底线。去年有几个学生拿着爬虫代码改个URL就去爬别人的付费接口最后被对方投诉到学校毕业设计直接延期代价非常大。数据源方面公开的旅游点评网站、OTA平台的景点详情页都可以作为目标。字段我建议至少包含这些景点名称、所属城市、省份、评分、评论数、门票参考价、热度排名、景点标签、简介、经纬度。经纬度很重要后面做地图可视化的时候能省很多事。2.2 核心爬虫代码与入库逻辑爬虫代码的核心结构很简单请求页面 → 解析HTML → 提取字段 → 写文件。我写了一个基础框架你拿过去改选择器就能用。import requests import time import csv from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } def fetch_spot(url): try: resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) # 下面这些选择器要根据实际页面结构调整 name soup.select_one(.title).text.strip() city soup.select_one(.city).text.strip() score soup.select_one(.score).text.strip() rating_cnt soup.select_one(.comment-count).text.strip() return [name, city, score, rating_cnt, url] except Exception as e: print(f抓取失败: {url}, 错误: {e}) return None def main(): urls load_url_list() # 从配置文件读取待抓取URL with open(../data/raw_data.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([name, city, score, comment_cnt, source_url]) for url in urls: row fetch_spot(url) if row: writer.writerow(row) time.sleep(1.5) # 控制请求频率别给目标站造成压力 if __name__ __main__: main()这里有几个细节容易踩坑。第一encoding要记得设置成utf-8很多中文站如果让requests自动猜编码出来的全是乱码。第二写CSV时我用utf-8-sig而不是utf-8因为utf-8不带BOM的情况下Excel打开会乱码而且后续读者在pandas里读文件时列名会带一个看不见的\ufeff排查起来非常浪费时间。第三接受异常处理爬虫挂了不要整个程序退出单条失败就记日志跳过保证整批任务能跑完。数据抓下来之后要做清洗。清洗不是可选项是必须项。我实际清洗时发现的问题包括同一景点在不同页面里名字有空格差异部分景点评分缺失评论数有些是1.2万这种中文单位需要转换标签字段是列表结构要序列化成字符串。清洗代码大致如下import pandas as pd df pd.read_csv(../data/raw_data.csv, encodingutf-8-sig) df[name] df[name].str.strip() df[city] df[city].str.strip() # 去重 df df.drop_duplicates(subset[name, city], keepfirst) # 评论数转换1.2万 - 12000 def parse_count(s): if isinstance(s, str) and 万 in s: return int(float(s.replace(万, )) * 10000) return int(s) if str(s).isdigit() else 0 df[comment_cnt] df[comment_cnt].apply(parse_count) # 缺失评分用城市均值填充 df[score] df[score].replace(暂无, pd.NA) df[score] pd.to_numeric(df[score], errorscoerce) df[score] df[score].fillna(df.groupby(city)[score].transform(median)) df.to_csv(../data/final_data.csv, indexFalse, encodingutf-8-sig)特别提醒一句评分缺失用城市中位数填充比直接用全体均值合理。比如北京整体评分偏高用全国均值会把北京低分景点拉得虚高。这种小细节在答辩时主动讲出来老师会觉得你真的处理过真实数据。3. 推荐算法协同过滤到底怎么实现选哪个变体3.1 UserCF和ItemCF先选哪个协同过滤分两类UserCF基于用户的协同过滤和ItemCF基于物品的协同过滤。原理一句话就能说清UserCF找到和你口味相似的一批用户把那些用户喜欢但你没去过的景点推荐给你。ItemCF找到你之前喜欢的景点再找出和这些景点相似的景点推荐给你。选择哪个要看场景特征。我做了个对比表对比维度UserCFItemCF核心逻辑找相似用户推荐他们喜欢的找相似物品推荐你喜欢的物品的同类适用场景用户数量少、用户兴趣变化快物品数量少、物品生命周期长冷启动短板新用户没有行为数据难算相似性新景点没有交互记录难算相似性可解释性一般和你有相似偏好的人也去了XX较强因为你喜欢西湖所以推荐灵隐寺推荐多样性容易跨领域推荐容易陷入相似即同质旅游景点这个场景用户的出行频率远低于网购频率一个用户一年可能就写几次点评用户之间的公共行为非常稀疏。这时候UserCF算出来的相似度噪声很大。相反景点数量相对可控而且景点的属性稳定标签、城市、类型都明确ItemCF算相似度更可靠。所以我最终选择ItemCF作为主算法再加了一个UserCF做混合。3.2 评分数据怎么构建协同过滤需要用户对物品的评分矩阵。现实中没有现成的评分只有用户行为。我的做法是把隐式行为折算成显式评分搜索或浏览景点1分收藏/加入行程3分下单购票/写点评5分如果你手里的数据是从OTA网站爬来的公开点评信息没有用户行为可以用用户对景点的评分数、点评内容情感倾向来构造。比如某用户点评了某景点且给分4.5那就是对应用户-景点评分。另一种做法是用模拟数据先构造100个模拟用户、500个模拟行为把算法流程跑通再在真实景点数据上做推荐。毕设阶段完全可以用模拟用户行为来验证算法只要你在论文里说明数据来源即可。构建评分矩阵时要注意稀疏性问题。假设有1000个景点、500个用户评分矩阵通常只有不到10%的位置有值。稀疏矩阵直接用DataFrame运算性能很差我一般用scipy.sparse或者字典嵌套结构。相似度计算用余弦相似度两个物品或两个用户的评分向量做点积除以它们模长的乘积。数值上就是sim(a, b) (a向量 · b向量) / (|a向量| * |b向量|)代码实现我放在了recommend/cf.py里逻辑分三步先构建物品-用户倒排表再遍历计算物品之间相似度最后根据相似物品和用户历史打分生成推荐结果。import math from collections import defaultdict def build_item_user(user_items): user_items: {user_id: {item_id: rating}} 返回 item_user: {item_id: {user_id: rating}} item_user defaultdict(dict) for user, items in user_items.items(): for item, rating in items.items(): item_user[item][user] rating return item_user def cosine_sim(item_user, item_a, item_b): common set(item_user[item_a].keys()) set(item_user[item_b].keys()) if not common: return 0.0 va [item_user[item_a][u] for u in common] vb [item_user[item_b][u] for u in common] dot sum(x * y for x, y in zip(va, vb)) norm_a math.sqrt(sum(x * x for x in item_user[item_a].values())) norm_b math.sqrt(sum(x * x for x in item_user[item_b].values())) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def recommend_by_item(user_items, item_user, user_id, top_n10): # 用户已经消费过的物品 seen set(user_items.get(user_id, {}).keys()) scores defaultdict(float) for item in seen: for other, sim in item_sim_matrix[item].items(): if other in seen: continue scores[other] sim * user_items[user_id][item] # 按得分排序 ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [item for item, score in ranked[:top_n]]这里有一个很实际的坑如果直接用两两遍历所有物品算相似度复杂度是O(N^2)景点数量到5000以上就很慢了。优化方式是只算有共同用户的物品对也就是先做一个倒排索引每个用户消费过的物品两两配对最后累加共现次数。这个细节可以放在论文里说明你对算法做过工程优化。另一个坑是热门景点容易霸榜。西湖、故宫这种景点和谁都有共现关系相似度矩阵里它的邻居特别多推荐的永远是热门景点用户看到的个性化推荐和排行榜没区别。解决的办法是加一个热门惩罚两个物品的相似度除以它们各自流行度的对数让热门物品和冷门物品的共同出现不那么容易被放大。公式上就是sim(i, j) sim(i, j) / (log(1 pop(i)) * log(1 pop(j)))3.3 冷启动与推荐兜底策略总有用户是全新的一点行为数据都没有也总有景点刚上线没有任何人评价过。协同过滤对这两种情况都无能为力所以必须设计兜底策略。我的做法是混合推荐最终结果 0.6 * ItemCF结果 0.3 * UserCF结果 0.1 * 热门Top榜。新用户没有行为数据就完全走热门Top榜和城市热榜保证页面有内容展示新景点没有交互数据就用基于内容的方法兜底——根据景点的标签文本算TF-IDF余弦相似度找到和新景点最像的已有景点推荐逻辑变为因为你看了XX类型所以推荐同类新景点。混合权重不是拍脑袋定的我是跑了几组离线评测调出来的随机拿20%的评分数据做测试集用召回率和覆盖率两个指标对比最终0.6/0.3/0.1的组合效果最好。你如果有时间也可以把这部分写成一个小实验放在论文里答辩时很有说服力。4. Hadoop集群环境搭建与数据接入4.1 环境准备伪分布式还是集群很多同学一看到Hadoop就慌其实毕设阶段完全不用搭集群。我强烈建议用伪分布式模式——一台Linux机器上同时跑NameNode、DataNode、SecondaryNameNode配置简单、资源占用低但HDFS的完整机制文件上传、副本策略、块存储都能体验到。三台机器搭集群的意义在于多节点容器对毕设来说性价比较低而且笔记本跑三台虚拟机的内存容易爆。我在Ubuntu 22.04上的搭建流程大致是先装JDK8再下载Hadoop 3.3.x解压配置core-site.xml指定fs.defaultFS为hdfs://localhost:9000配置hdfs-site.xml把副本数dfs.replication设为1设置SSH免密登录然后hdfs namenode -format格式化最后start-dfs.sh启动。如果你觉得一步步装太麻烦也可以直接用现成的Docker镜像一条命令起容器Hadoop环境就能用。但我个人建议至少手动装一遍因为答辩时老师特别爱问HDFS的NameNode和DataNode各自的作用格式化做了什么操作这类问题自己走过一遍流程才答得上来。顺便说一句如果之后想扩展到高可用集群NameNode的自动故障转移需要引入ZooKeeper但你做毕设用伪分布式完全不需要不用为了用ZooKeeper而去用环境越简单越好。4.2 数据怎么放到HDFS上数据清洗成final_data.csv之后统一上传到HDFS。上传方式有两种命令行和Python API我建议都掌握。# 先建目录 hdfs dfs -mkdir -p /user/hadoop/travel # 上传数据 hdfs dfs -put data/final_data.csv /user/hadoop/travel/final_data.csv # 查看确认 hdfs dfs -ls /user/hadoop/travelPython里的方式是用hdfs库连接HDFS的NameNode HTTP端口。注意Hadoop 3.x的HTTP端口是9870网上很多老教程写的50070是Hadoop 2.x的照着老教程配会连不上。from hdfs import InsecureClient client InsecureClient(http://localhost:9870, userhadoop) client.upload(/user/hadoop/travel/final_data.csv, data/final_data.csv, overwriteTrue)上传之前有一个和数据量相关的考量HDFS对小文件非常不友好几万个小文件会大量占用NameNode内存。如果你爬虫是按天增量抓取的每天生成一个小CSV那么建议先合并成一个完整文件再上传或者定期做小文件合并操作。毕设里从头到尾只需要维护一个干净的数据集文件能少踩很多坑。4.3 MapReduce在这里面到底扮演什么角色这里说点实话如果你的数据只有几千行MapReduce计算的最优解可能是用pandas三分钟算完。但毕设需要体现大数据处理流程MapReduce离线批处理是这套架构里最好讲的一环。我的做法是用MapReduce做两类统计一是按城市统计景点数量二是按城市统计平均评分。这两个结果直接输出成文件Flask后端读取后展示在地图和柱状图上。Mapper和Reducer的核心逻辑其实不难就是一个分而治之的思想。Map阶段把一行记录解析成城市-景点名和城市-评分的键值对Shuffle阶段把相同城市的键聚到一起Reduce阶段做求和与计数。如果你用Python写Streaming模式的MapReduce代码会更简洁# mapper.py import sys for line in sys.stdin: fields line.strip().split(,) if len(fields) 3 and fields[0] ! name: city fields[1] print(f{city}\t1\t{fields[3]}) # reducer.py import sys current_city None count 0 score_sum 0.0 for line in sys.stdin: city, cnt, score line.strip().split(\t) if current_city and city ! current_city: avg score_sum / count print(f{current_city}\t{count}\t{avg:.2f}) count 0 score_sum 0.0 current_city city count int(cnt) score_sum float(score) if current_city: print(f{current_city}\t{count}\t{score_sum / count:.2f})跑MapReduce时用hadoop jar path/to/hadoop-streaming.jar -mapper mapper.py -reducer reducer.py -input /user/hadoop/travel/final_data.csv -output /user/hadoop/travel/city_stats输出的part-00000文件就是聚合结果。把这几行放到论文的系统实现章节产生的效果比写几百行逻辑代码直观得多。5. Flask后端设计可视化呈现5.1 后端接口如何设计Flask在整套系统里就是个轻量API服务层不建议在Flask里写太重的业务逻辑。推荐、统计这些事情都在独立模块里算好Flask负责接收前端请求、调用模块、返回JSON。接口设计我建议统一前缀/api/便于和页面路由区分。我列一份常用的接口清单接口方法参数返回说明/GET无首页HTML/api/hotGETlimit热门景点Top榜/api/recommendGETuser_id, top_n个性化推荐结果/api/stats/cityGET无城市景点数量分布/api/stats/scoreGET无城市平均评分/api/searchGETkeyword景点搜索app.py的骨架代码大概是这样的from flask import Flask, jsonify, request, render_template import sys sys.path.append(../recommend) from mix_recommend import recommend_for_user, get_hot_spots app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/hot) def hot(): limit request.args.get(limit, default10, typeint) spots get_hot_spots(limit) return jsonify({code: 0, data: spots}) app.route(/api/recommend) def recommend(): user_id request.args.get(user_id, default1, typeint) top_n request.args.get(top_n, default10, typeint) recs recommend_for_user(user_id, top_n) return jsonify({code: 0, data: recs}) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)注意几个小细节。参数一定要做类型校验request.args.get带typeint否则前端传个非数字字符串过来接口直接500。返回格式统一成{code: 0, data: ...}的结构前端解析时只需要判断code方便扩展错误码。debugFalse是最基本的意识debug模式开着等于把代码执行细节暴露给所有访问者很危险。5.2 可视化图表怎么做才好看可视化我用的是EChartsCDN引入即可不需要复杂的前端工程。四个图建议必做中国地图展示各城市景点数量热力分布、柱状图展示热门景点Top10、饼图展示城市景点占比、词云展示景点标签高频词。这几个图做完页面的完成度立刻就不一样了。关键点是地图需要GeoJSON数据ECharts 5.0之后不再内置中国地图需要单独引入china.json。前端通过fetch(/api/stats/city)拿到各城市景点数再setOption渲染。柱状图和饼图同理拿到接口数据之后做映射。一个完整的柱状图配置大概是fetch(/api/stats/city).then(res res.json()).then(data { const cities data.data.map(item item.city); const counts data.data.map(item item.count); myChart.setOption({ xAxis: { type: category, data: cities }, yAxis: { type: value }, series: [{ type: bar, data: counts, label: { show: true } }], tooltip: { trigger: axis } }); });页面布局上我建议上半部分放地图和热门景点榜下半部分放推荐结果和词云。推荐结果展示时给每个景点配上字段里的评分和评论数再写一句推荐理由——因为你喜欢西湖推荐你去灵隐寺——这种可解释性展示是这套系统最大的加分项老师一看就知道你的推荐不是黑盒。5.3 部署运行注意事项本地开发阶段直接python app.py跑起来就行。如果要部署到服务器上用gunicorn比Flask自带的开发服务器稳得多pip install gunicorn gunicorn -w 4 -b 0.0.0.0:8080 app:app-w 4表示启动4个工作进程注意如果你的推荐算法加载了比较大的数据多进程会导致内存翻倍这时候-w 2就够了别盲目增加进程数。另外前端静态文件要放到web/static目录下ECharts的CDN也可以改成下载到本地引用避免答辩现场没有外网导致图表白屏——这个问题我亲眼见过不止一次现场演示时图表加载不出来场面非常尴尬。6. 完整跑通这套系统的问题排查实录6.1 Hadoop环境常见问题伪分布式Hadoop的坑集中在启动阶段。我踩过且周围人反复踩的有这么几个第一datanode起不来。多半是namenode重新格式化之后datanode的dfs.data.dir里还留着旧的VERSION文件导致clusterID不匹配。解决办法是把Hadoop的tmp目录删干净再重新格式化。你如果遇到日志里有Incompatible clusterIDs字样就是这个问题。第二内存不够。默认Hadoop会给每个进程分配很多内存笔记本8G内存跑三四个JVM进程会卡到怀疑人生。可以在hadoop-env.sh里把HADOOP_HEAPSIZE调小到512或256MapReduce的任务内存也相应调小跑起来会流畅很多。第三HDFS端口连不上。检查是不是用了Hadoop 3.xHTTP端口是9870不是50070另外确认防火墙和云服务器安全组是否放行了对应端口。6.2 算法结果异常问题推荐结果全是冷门景点或者相似度全是0这两个问题基本都出在数据稀疏性上。相似度全为0说明两个景点的评分向量没有共同用户向量内积为0这时候可以考虑用皮尔逊相关系数替代余弦相似度或者扩大行为数据的构造来源。我之前用爬虫爬到的点评数据用户维度特别稀疏最后是加了一些模拟行为才让评分矩阵的稠密度达到能出结果的水平。推荐结果全是热门景点则多半是热门惩罚没有生效或者混合权重里热门榜占的比例太大。检查一下相似度计算之后是否对每个物品的邻居列表做了按分数归一化如果没归一化高分邻居会一直主导推荐结果。中文乱码问题前面提到过根源就是文件编码不统一。爬虫写入CSV用utf-8-sigHDFS读取路径不要转码Flask返回JSON时设置app.config[JSON_AS_ASCII] False每一个环节都显式指定UTF-8编码乱码就不会发生。6.3 避坑速查表问题原因解决方案CSV列名带\ufeff编码用了utf-8而非utf-8-sig写入时指定encodingutf-8-sigHDFS端口连不上Hadoop 3.x端口是9870检查文档与版本确认端口DataNode起不来格式化后clusterID不一致清空tmp目录重新format推荐相似度全为0评分矩阵太稀疏补充模拟行为改用皮尔逊相关系数推荐全是热门景点热门惩罚未生效调整相似度公式加入log惩罚项图表现场加载不出引用了CDN资源把ECharts下载到本地static目录Flask返回中文乱码JSON编码问题设置JSON_AS_ASCII False爬虫运行几分钟被限制请求频率过高增加sleep间隔降低并发6.4 最后说点个人体会整套做下来我最大的感触是数据质量决定算法上限。一开始我图省事直接用爬下来的原始数据跑推荐结果因为字段没清洗干净很多景点名带空格同一个景点被当成两个物品相似度计算全乱了。后来老老实实做数据清洗、去重、类型转换推荐结果才变得像样。所以如果你时间有限优先把数据清洗做扎实算法后续再优化都来得及。另一条体会是答辩时不要堆名词。你用了Hadoop、爬虫、协同过滤、Flask、ECharts但如果讲不清数据从哪来、存在哪、怎么算、怎么展示这条链路老师一句你这些技术是独立演示的吗就能把你问住。把每个环节的输入输出准备清楚比背一百个面试题有用得多。希望这篇整理能帮你少走点弯路做完记得把推荐效果截图留下来那是你整个项目最直观的成果证明。
返回列表