毕业设计做旅游推荐系统,选了Hadoop+Spark+Hive这套大数据全家桶,再加上爬虫、机器学习甚至深度学习——说句实在话,我在开题时也犯过嘀咕,这套组合拳下来工作量可不小。但做完之后回头看,这个选题确实值得:既有工程落地的完整链路,又有算法优化的升级空间,最重要的是面试时能讲的东西太多了。今天就把我整个项目从架构设计到踩坑填坑的全过程拆开揉碎讲清楚,给正在做同类选题或者想入行大数据开发的朋友一份可以直接参考的实战笔记。
1. 为什么旅游推荐系统适合拿来做大数据毕设:技术链路与选题价值分析
先聊选题。每年毕业设计选题里,旅游相关的管理系统、推荐系统少说也有几百个,但大多停留在Spring Boot+MyBatis的CRUD层面,做完除了证明你会写接口,什么都说明不了。真正有含金量的是把旅游数据和分布式计算结合起来,让题目本身带出大数据生态的完整技术栈。
1.1 旅游数据天然适合分布式处理
旅游数据有几个特点:数据源多、格式杂、体量大。携程、马蜂窝、去哪儿这些平台的公开接口能爬到景点信息、用户评论、评分、游记,甚至出行时间分布,爬下来之后既有结构化表数据,又有半结构化的JSON评论,还有纯文本的游记。这些数据加起来做个几十GB的训练集完全是可能的——拿单机MySQL跑,一个稍微复杂点的关联查询就能把数据库拖死。而这套数据放在HDFS上,用Hive做离线清洗,用Spark做特征聚合,正好把大数据组件全部串起来了。
我自己实际抓的数据集是:全国热门城市景点基础信息大约12万条,用户评论数据大约40万条,游记文本大约3万篇,加上手动标注的旅游偏好标签,总存储量在30GB左右。这个量级在单机上做处理已经能明显感觉到瓶颈,但放到3台虚拟机组成的集群上,跑一轮全量特征计算不超过十分钟,效果对比非常直观。
1.2 技术栈的组合逻辑:Hadoop负责存,Spark负责算,Hive负责查
很多同学纠结到底选Spark还是选Hadoop MapReduce,其实这两个是配合关系,不是替代关系。Hadoop的核心是HDFS,负责海量文件的可靠存储,而MapReduce只是一个计算模型,现在基本被Spark取代了。所以我的架构里Hadoop只承担存储层,计算层全部走Spark,然后通过Hive这个数据仓库工具对上层提供SQL查询能力。
这套组合的逻辑是:DataNode存原始文件和中间结果,NameNode管元数据,Spark从HDFS上拉数据做RDD或DataFrame级别的转换操作,结果再写回HDFS,Hive在之上建外表把结构化数据映射成表,供推荐服务、可视化大屏和后台管理系统做查询。整个链路的每一环都有明确分工,也对应了面试官最爱问的“你们数据流转是怎么设计的”。
1.3 推荐部分为什么值得走机器学习甚至深度学习
旅游推荐的难点在于兴趣建模:用户的偏好是隐性的,比如有人喜欢古镇,有人喜欢海滨,有人只看门票便宜的景点,这些标签并不直接写在用户表里。传统的协同过滤能解决一部分问题,但数据稀疏时效果很差,而且无法利用评论文本这种非结构化信息。
所以我的方案是分两路走:一路是Spark MLlib里的ALS协同过滤算法,吃用户对景点的评分矩阵,做离线召回;另一路用Word2Vec把游记和评论转为向量,算景点之间的语义相似度,拿来做基于内容的召回。至于深度学习,我用TensorFlow搭了个简单的DNN排序模型,把用户画像特征、景点特征、上下文特征拼成稠密向量输入,输出CTR预估值,用来做最终的打分排序。这三个层次的推荐逻辑在论文里是逐层递进的,在系统里也是可以拆开跑的,工作量和创新点都兼顾了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从爬虫到数据仓库:旅游数据全链路构建的完整落地步骤
数据是项目的血液,搞不好后面全是空中楼阁。我直接用Scrapy框架写了两个爬虫:一个爬景点基础信息和结构化评分,另一个爬评论和游记文本。数据目标写到HDFS之前,经历了临时存储、清洗、脱敏、格式化几个阶段。
2.1 Scrapy爬虫的架构设计与反爬应对
Scrapy的架构本身就是一个很好的面试素材:Engine(引擎)控制数据流,Scheduler(调度器)管理请求队列,Downloader(下载器)负责拉取页面,Spiders(爬虫)解析响应,Item Pipeline(管道)做后处理。我在爬虫里最核心的两个配置是中间件和管道。
code复制# settings.py
DOWNLOADER_MIDDLEWARES = {
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
'travel_spider.middlewares.RandomUserAgentMiddleware': 400,
'travel_spider.middlewares.ProxyMiddleware': 500,
}
ITEM_PIPELINES = {
'travel_spider.pipelines.DuplicatesPipeline': 200,
'travel_spider.pipelines.CleanDataPipeline': 300,
'travel_spider.pipelines.WriteToHDFSPipeline': 500,
}
在反爬这一块我只做了三件事:随机切换User-Agent、请求间隔限速、代理池轮换。因为要的是旅游公开数据,不是商业机密,没必要上特别重的技术。但有个坑必须提醒大家:Scrapy默认的去重是基于请求URL的,而很多网站的URL带时间戳参数,同一个景点在不同时间抓取会生成不同的URL,导致重复数据进库。我的解决办法是在Pipeline里对景点ID做业务级去重,而不是依赖框架内置的RFPDupeFilter。
code复制# DuplicatesPipeline.py
import redis
class DuplicatesPipeline:
def open_spider(self, spider):
self.redis_conn = redis.Redis(host='localhost', port=6379, db=1)
def process_item(self, item, spider):
key = f"scenic:{item.get('scenic_id')}"
if self.redis_conn.sadd(key, 'done') == 0:
raise DropItem(f"Duplicated scenic item: {item.get('scenic_id')}")
return item
2.2 Hive建表与数据标准化映射
爬虫拿到的原始数据是JSON行格式,直接扔进HDFS没问题,但要在上面跑SQL就得先建表。我这边的核心表一共有五张:
| 表名 | 字段 | 存储格式 | 分区 |
|---|---|---|---|
| ods_scenic_info | scenic_id, city_id, name, level, ticket_price, open_time, lat, lng | ORC | dt |
| ods_scenic_comment | comment_id, scenic_id, user_id, content, rating, comment_time | ORC | dt |
| ods_user_base | user_id, gender, age, city, register_time | Parquet | 无 |
| dwd_user_behavior | user_id, scenic_id, action, action_time | ORC | dt |
| dws_user_scenic_score | user_id, scenic_id, score, update_time | Parquet | 无 |
建表语言里出现频率最高的几个点:存储格式用ORC还是Parquet,分区字段用什么粒度,以及外部表和内部表的选择。我全部用的是外部表,因为数据原始文件是爬虫程序直接写到HDFS指定目录的,仓库和原始数据应该在逻辑上分离,删表不影响源文件,这一点在生产环境尤其重要。
code复制CREATE EXTERNAL TABLE dwd_user_behavior (
user_id STRING,
scenic_id STRING,
action STRING,
action_time TIMESTAMP
)
PARTITIONED BY (dt STRING)
STORED AS ORC
LOCATION '/data/warehouse/dwd/user_behavior';
2.3 从ODS到DWS:用Spark做清洗与特征聚合
Hive的优势是SQL表达能力,但做复杂ETL时Spark的更灵活。我这边消耗时间最多的环节就是从ODS原始表生成DWS聚合表的过程。用Spark读取Hive的ORC文件,通过DataFrame API做UDF处理、去重、关联、聚合,最后再写回HDFS。
聚合逻辑里比较关键的是评分矩阵的构建。用户对景点没有显式的打分,但评论里有一个评分字段,再加上收藏、分享、浏览时长这些行为日志,我设计了一个加权公式:
code复制score = 0.6 * comment_rating + 0.25 * log(1 + browse_duration / 60) + 0.15 * action_weight
这里的action_weight对应的是行为类型:收藏记为1.0,分享记为0.8,点击记为0.5。代码层面直接用Spark的withColumn加when函数就搞定了,不需要额外写UDF。
code复制from pyspark.sql import SparkSession
from pyspark.sql.functions import col, when, log, lit
spark = SparkSession.builder \
.appName("TravelFeatureETL") \
.enableHiveSupport() \
.getOrCreate()
behavior_df = spark.sql("SELECT user_id, scenic_id, action, browse_duration FROM dwd_user_behavior WHERE dt = '2024-06-01'")
behavior_df.withColumn("action_weight",
when(col("action") == "collect", 1.0)
.when(col("action") == "share", 0.8)
.when(col("action") == "click", 0.5)
.otherwise(0.0)) \
.withColumn("score",
lit(0.6) * col("comment_rating") +
lit(0.25) * log(lit(Math.E), col("browse_duration") / 60 + 1) +
lit(0.15) * col("action_weight")) \
.write.mode("overwrite").saveAsTable("dws_user_scenic_score")
这里有个细节要特别提醒:browse_duration单位是秒,但在很多前端埋点里它可能是毫秒,导致算出来的浏览时长特征数量级完全不对。最好是做一次min-max归一化再参与计算,不然个别异常值会把整个分数分布拉偏。
3. 推荐引擎的三层架构:协同过滤、内容相似度与深度学习排序
推荐模块是整篇论文的核心创新点,也是工作量最大的部分。我做的是“召回-排序”两阶段架构,召回阶段保证候选集的丰富度,排序阶段保证最终结果的精准性。
3.1 Spark MLlib ALS协同过滤:显式反馈还是隐式反馈
ALS在Spark MLlib里的接口非常友好,代码量不大,难点在参数调优。核心参数有三个:rank(隐因子个数)、regParam(正则化系数)、alpha(隐式反馈的置信度权重)。
先说我遇到的坑:我最初按最常规的显式反馈模型处理,把dws_user_scenic_score里user_id和scenic_id直接投入ALS,训练出来的RMSE在0.8左右,看起来还行,但推荐结果非常平庸——热门景点霸榜,个性化几乎为零。原因是绝大多数用户的交互只有一两条,评分矩阵稀疏度超过95%。
后来我改成隐式反馈模型:把score映射成一个0到1的置信度数值,有交互标记为1,无交互标记为0,同时把交互次数作为观察置信度的权重。拿训练集跑完后,推荐列表的多样性明显提升,长尾景点也有机会被推到头部。
code复制from pyspark.ml.recommendation import ALS
als = ALS(
userCol="user_id",
itemCol="scenic_id",
ratingCol="confidence",
rank=20,
maxIter=15,
regParam=0.1,
alpha=0.3,
implicitPrefs=True,
coldStartStrategy="drop"
)
model = als.fit(training_data_df)
user_recs = model.recommendForAllUsers(20)
关于参数选择我总结一个通用思路:rank从10开始往上试,每次加5;regParam控制过拟合,数据稀疏时尽量往0.1以上走;alpha的取值决定了用户未交互项的影响占比,一般0.2到0.5之间比较稳。具体最优值要靠小规模验证集去扫,不建议凭空拍脑袋。
3.2 Word2Vec生成景点语义向量,解决冷启动问题
协同过滤的天然短板是冷启动:新景点没有任何交互记录,永远无法被召回。所以我加了第二路召回:基于景点描述与评论的文本语义相似度召回。
流程并不复杂:把所有景点的评论文本拼成一篇“景点文档”,用Word2Vec训练词向量,再把文档里所有词的向量做加权平均作为该景点的向量。景点之间的余弦相似度高于某个阈值的,就互为相似景点。
这里有几个实现细节值得展开:
- 分词直接用了jieba的精确模式,并往自定义词典里加了“古北水镇”“长隆欢乐世界”这类专有名词,不然“欢乐世界”会被切成“欢乐/世界”,语义就跑偏了。
- 训练窗口
window设为5,向量维度size设为128,min_count设为5,太低的词频直接丢弃,减少噪音。 - 加权平均时用TF-IDF权重而不是全等权,
like、good这种高频废词权重会自动被压下去。
实测下来这一路召回的准确率不高,它最大的价值是多样性:能把潜在兴趣相关的冷门景点挖掘出来,给排序模型补充素材。
3.3 TensorFlow DNN排序模型:把召回结果排队
排序模型做的是二分类任务:用户是否会点击某个景点。正样本是有点击行为的样本,负样本是曝光而未点击的样本。特征工程这块我拼了四组特征:
- 用户特征:年龄分段、性别、历史浏览偏好(统计前10高频景点类目ID)
- 景点特征:景点等级、门票价格区间、所在城市热度、平均评分
- 用户-景点交叉特征:用户与该景点类目的历史交互次数、用户与该城市的历史交互次数
- 上下文特征:当前节假日标记、当前月份(判断淡旺季)、天气编码
模型结构非常简单,但效果提升非常明显。用TensorFlow的keras接口搭了个三层的全连接网络:
code复制import tensorflow as tf
from tensorflow.keras import layers, models
model = models.Sequential([
layers.Dense(128, activation='relu', input_shape=(X_train.shape[1],)),
layers.BatchNormalization(),
layers.Dropout(0.3),
layers.Dense(64, activation='relu'),
layers.Dropout(0.2),
layers.Dense(1, activation='sigmoid')
])
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['auc'])
model.fit(X_train, y_train, epochs=20, batch_size=512, validation_split=0.2)
上面这版简单模型跑出来的线下AUC在0.78左右,线上点击率大概提升了20%。如果要进一步提高,可以考虑引入FM(因子分解机)做二阶特征交叉,或者用Wide & Deep结构同时保留记忆能力和泛化能力。但这些属于锦上添花,毕设做到HR@20能达到0.6以上,已经足够拿出去展示了。
4. 旅游可视化与知识图谱:让数据变成能答辩的亮点
很多同学做完推荐算法就结束了,但作为毕业设计,还差两个很关键的内容:一个是可视化平台——这是答辩时最直观展示工作量和技术水平的部分;另一个是知识图谱——这是论文里能突出创新性的加分项。
4.1 ECharts+Flask搭建交互式旅游可视化大屏
旅游可视化系统我做了两个视角:管理端和用户端共用一套数据接口,前端展示全部用ECharts。地图展示用GeoJSON格式加载中国地图,散点图展示热门景点分布;关系图展示“城市-景点-用户”之间的连接关系;热力图用景点评分和访问量叠加展示。
我在这里的选择是后端只提供JSON数据API,前端异步拉取。后端用Flask承载接口层,内部查询Hive表时通过Spark Thrift Server来跑SQL。之所以不直接连HiveServer2,是因为Thrift Server可以复用Spark的上下文,交互式查询速度比Hive原生执行快几倍。
大屏上最有说服力的图表是“全国旅游热度热力图”:横轴是月份,纵轴是热门城市,颜色深浅代表客流量。这个图表直观反映了旅游的淡旺季规律,为推荐系统的时间衰减因子提供了数据支撑,答辩时讲起来非常加分。
4.2 Neo4j构建旅游知识图谱:从0到1的实体关系建模
知识图谱是我论文里的一个重要创新点。我没有选择用现成的通用知识图谱数据,而是基于爬下来的景点数据自己构建了实体和关系。
实体类型有四种:城市、景点、旅游偏好标签、用户。关系类型有:城市包含景点(CONTAINS)、景点属于标签(TAGGED_AS)、用户访问过景点(VISITED)、用户有偏好标签(PREFERS)。
数据导入有两种方案,我直接用了Neo4j的LOAD CSV命令从Hive导出的CSV文件批量导入。这里有一个经验问题:先建约束索引再导入,不然几万条节点和关系插入序列跑得极其痛苦。
code复制LOAD CSV WITH HEADERS FROM 'file:///scenic_nodes.csv' AS row
CREATE (s:Scenic {
scenic_id: row.scenic_id,
name: row.name,
city: row.city,
level: toInteger(row.level),
ticket: toFloat(row.ticket_price)
});
知识图谱这步做完最大的价值是:给前面那个DNN排序模型提供了更多可解释的特征路径。比如景点的类目标签不再是一个孤立的ID,而是可以通过图谱的邻居节点扩展出“同类目热门景点”“同城主打景点”等多跳特征,直接提升了推荐的可解释性。
4.3 管理系统部分:Spring Boot做业务闭环
旅游管理系统这块我用Spring Boot+MyBatis Plus做了一套简单的后台。功能涵盖景点CRUD、用户管理、评论审核、数据统计报表。目的是和前面的大数据链路形成互补,让系统有完整的业务闭环和管理能力。数据库用MySQL存在线业务数据,离线分析数据仍然走Hive。
这里的业务逻辑不复杂,真正麻烦的是画原型图和写接口文档,但这部分在整个毕业设计里是相对容易完成的任务,建议优先做完拿个保底分。
5. 环境搭建与踩坑实录:Hadoop生态最常见的地狱级问题
整个项目做下来,最花时间的不是写算法,而是环境搭建和调优。这里记录我踩过的最典型的几个坑,每一条都是血泪教训。
5.1 Hadoop安装与集群配置:伪分布式还是真集群
老实说,毕业设计如果机器配置一般,先跑伪分布式完全没有问题,核心流程和分布式区别不大。我自己的项目一开始就是伪分布式,把HDFS、YARN、Spark都配在单机上,后来为了演示分布式效果才又加了两台虚拟机组成3节点集群。
集群配置里最容易出问题的文件是core-site.xml和hdfs-site.xml。我自己见过最典型的报错是java.io.IOException: NameNode is not formatted。这是因为改了dfs.namenode.name.dir路径后没有重新格式化NameNode导致的。记住一条铁律:修改了NameNode元数据目录配置,必须先停服务、删掉临时数据和日志目录、hdfs namenode -format重新初始化,再重新启动。
另外localhost:9870访问不了Web UI有很大概率是防火墙没关,或者集群配了内网hostname而本机hosts没指向。建议集群搭建阶段直接关闭防火墙或者至少放开对应端口。
5.2 Spark与Hive集成时常见的三类坑
Spark on Hive的集成里,最容易出问题的几个配置全在hive-site.xml上。核心就是把Hive的元数据库连接信息、HDFS存储路径配到Spark的conf目录下,让Spark可以直接读写Hive表。
第一类坑是java.lang.RuntimeException: Unable to instantiate org.apache.hadoop.hive.ql.metadata.SessionHiveMetaStoreClient。这个报错基本是Hive的MetaStore服务没启动,或者hive.metastore.uris配的地址不对。本地直接跑hive --service metastore,确认9083端口起来,再重启Spark任务。
第二类坑是org.apache.spark.sql.AnalysisException: Table or view not found。我在Spark里建临时视图去join Hive表时经常遇到,核心原因是Spark的catalog中查不到Hive里的库表元数据。解决方法是创建SparkSession时一定要显式开启Hive支持,并在启动命令或配置文件里加入--jars mysql-connector-java.jar,让Spark能连接到Hive的元数据库。
第三类坑比较隐蔽:MetaException(message:Hive Runtime Error while processing row),这通常是Hive表里某些字段的格式异常导致的,比如字符串字段塞了非法字符。排查方法是用Hive原生命令先跑一条select语句确认表能查,再用Spark单独读那一列来定位脏数据。
5.3 Hive执行慢:MR引擎和Tez引擎的取舍
Hive默认的底层执行引擎是MapReduce,但抽象语法树解析到一个简单聚合查询,中MapReduce启动就要花十几秒,交互式体验非常差。我的做法是切到Tez引擎,启动耗时从20秒降到5秒左右,大查询性能还有明显提升。
code复制set hive.execution.engine=tez;
set hive.tez.container.size=2048;
set hive.exec.parallel=true;
另一个优化手段是开启分区裁剪和谓词下推。如果表按天做了dt分区,查询尽量带上dt='2024-06-01'这种条件,Hive在优化阶段就能直接跳过无关分区,减少IO和计算浪费。
5.4 Spark提交任务时的资源参数不能乱写
Spark任务的资源参数写在spark-submit里,很多人直接复制网上的模板,堆积--executor-memory 4g --num-executors 10,结果你的机器总共就8G内存,不OOM才怪。
我自己实践中建议先看节点配置再定参数。比如3台虚拟机,每台4G内存,那提交参数就写:
code复制spark-submit \
--master yarn \
--deploy-mode cluster \
--driver-memory 2g \
--executor-memory 3g \
--executor-cores 2 \
--num-executors 2 \
--conf spark.sql.shuffle.partitions=100 \
travel_recs.py
spark.sql.shuffle.partitions这个参数很多人会忽略。它的默认值是200,也就是说每次shuffle会产生200个输出分区。数据量不大时,这会让每个Task处理的文件极小,元数据开销反而拖慢任务。数据量在30GB以内时,我一般设置为50到100之间。
6. 答辩准备与项目扩展:论文里最容易被深挖的五个方向
最后这部分讲讲论文和答辩。写完代码只是完成了一半,另一个考核重点是讲清楚“为什么这么做”以及“如果不这样会怎样”。
6.1 论文里建议单独展开论述的技术点
- 数据倾斜的定位与调优思路:Group By或者Join时某个热门city出现极端数据倾斜,Spark的某个Stage卡住不动,你是怎么定位和解决的。我的做法是用
spark.sql.adaptive.skewJoin.enabled开启AOE优化,同时对热点Key加盐做两阶段聚合。 - ALS的隐式反馈模型为什么比显式反馈更适合旅游场景:显式评分极度稀疏,无法从无反馈中学习,而隐式反馈模型会把“未交互”作为一个弱负样本参与建模,更贴近真实场景。
- 如何评估推荐系统:离线用RMSE和Precision@K,线上用CTR和转化率。毕设不需要跑真正的线上实验,但要在答辩中明确区分离线指标和线上指标的差异,以及为什么离线指标高不代表线上表现好。
- 知识图谱如何反哺推荐:在我的系统里,知识图谱主要辅助特征工程和召回扩展,它不像协同过滤那样是一个独立模型,但能显著增强可解释性。
6.2 这个项目后续还能怎么扩展
如果时间充裕,有几个方向值得继续深入:
- 把实时推荐链路补起来:用Flume把用户点击日志采集到Kafka,再用Spark Streaming做准实时特征更新,最后把TopN结果写入Redis,前端接口直接读缓存。这样系统就能从“离线推荐”升级为“离线+实时混合推荐”。
- 引入更丰富的推荐结果解释:基于知识图谱输出推荐理由,比如“因为你去过杭州,而杭州与苏州在古镇类目上相似度高达0.87,所以为你推荐苏州”。
- 做深度学习模型的蒸馏与压缩:DNN排序模型离线训练好后可以转成TF Lite或ONNX,部署到服务端做在线推理,通过gRPC提供高性能预测服务,这就更进一步接近工业级的推荐系统架构了。
6.3 关于工作量分配的一点个人心得
做这种偏大数据方向的毕设,最大的误区是把时间全耗在调环境上。我认识不少同学,环境搭了两周,最后连推荐代码都没跑通。我的建议是先把核心流程在最小的数据集上走通——比如只抓一个城市、几千条评论,先把ETL、训练、召回、排序、展示的链路跑通,再回来扩展数据规模和优化参数。链路不通,数据再多也没有意义。环境调不通的问题,绝大多数是版本兼容性导致的,建议安装时直接锁定同一套版本组合,例如Hadoop 3.3.4配合Spark 3.3.2配合Hive 3.1.3,不要各自装最新版,装完才发现互相不兼容的滋味实在不好受。
