基于Spark的电影推荐系统:从离线统计到协同过滤的完整实现
简介本资源为基于Spark的电影推荐系统毕业设计资料包面向计算机、大数据相关专业的学生及推荐算法初学者提供从理论到实现的完整参考。内容围绕Spark、MongoDB与Web技术栈展开涵盖基于人口统计学、基于内容及协同过滤三类推荐算法的原理与设计并给出系统分析、功能实现与测试的完整流程涉及用户登录注册、个性化推荐、电影搜索与评分等核心模块。资源包共1个docx文件约7.46MB内含论文正文与配套源码说明目录结构清晰便于按章节查阅。目前已有206人学习下载。读者可借助该资料理解推荐系统的整体架构与算法选型思路掌握离线与实时推荐的基本实现方式并参考功能测试与性能测试的写法为课程设计或毕业设计提供可落地的方案与排错参考。1. 从一份论文源码说起这套 Spark 电影推荐系统到底能跑出什么有个做毕设的读者给我看他拿到的资源包里面是一份完整论文加一套可运行源码题目叫「基于 Spark 的电影推荐系统设计与实现」。他原本以为就是个调库的玩具结果打开一看离线统计、实时推荐、详情页相似推荐三条链路全都在MongoDB 里躺着六张结果表Spark 集群还跑着伪分布式。这套东西的价值不在于算法多前沿而在于它把「推荐系统从数据到页面」的完整闭环摊开给你看——用户登录后能看到基于人口统计学的热门榜点进详情页有基于内容的相似影片评分行为又会触发协同过滤的离线重算。适合谁正在做大数据方向毕设、想找一个能讲清楚架构又能真跑起来的项目、或者想拿一套现成的 Spark MongoDB Web 三层结构做二次开发的人。下面我按「它是什么 → 怎么跑 → 怎么改」的顺序拆一遍。2. 技术栈与架构拆解Spark、MongoDB、Spring 三层怎么咬合2.1 为什么选 Spark 而不是 MapReduce这个项目最核心的选型理由是迭代计算。协同过滤要反复扫描用户-物品评分矩阵MapReduce 每轮都把中间结果落盘I/O 开销能把人拖死。Spark 把中间数据放内存RDD 用 Java 对象做数据格式省掉大量序列化反序列化。论文里写得很直白Spark 以单独进程运行每个应用、保留进程状态、多线程执行减少线程切换开销。落到代码层面你会在推荐模块里看到大量spark.sql()和Dataset操作而不是手写 MapReduce 的 mapper/reducer。常见做法是离线统计用 Spark SQL 直接读 MongoDB通过连接器把结果写回。实时部分用 Spark Streaming 消费评分流。这套组合在中小规模数据下足够稳也方便在单机伪分布式上演示。2.2 三层架构与数据流向系统分前端、后端、推荐三块。前端 AngularJS2 负责展示和交互后端 Spring Maven 管业务和依赖推荐模块独立跑 Spark 任务。数据流是这样的用户在前端评分 → 后端写入 MongoDB 的 ratings 表 → Spark 离线任务定时读取 ratings 和 movies → 算出热门榜、平均分、类别 Top10、用户推荐 → 写回 MongoDB 对应结果表 → 前端查询结果表渲染。这里有个关键设计推荐结果不实时算而是预计算好存表。用户请求推荐时后端只做查询响应快。实时推荐那条链路才走 Spark Streaming处理的是「用户刚评完分立刻调整推荐」的场景。2.3 六张结果表的分工MongoDB 里六张表各有用途理解它们就理解了整个推荐逻辑表名含义生成算法User用户基本信息注册写入AverageMovies电影平均评分Spark SQL 聚合GenresTopMovies各类别 Top10UDF 切分类别 排序RateMoreMovies历史热门电影按评分数量统计RateMoreRecentlyMovies近期热门电影近一月评分数量统计UserRecs用户个性化推荐协同过滤离线计算提示AverageMovies 和 GenresTopMovies 是详情页和首页榜单的数据源UserRecs 才是个性化推荐的核心输出。调试时先确认这六张表有没有数据再排查前端。2.4 推荐算法的混合策略论文明确说了单一算法不够用。系统把基于人口统计学的统计推荐和基于内容的推荐混在一起目的就是缓解冷启动和数据稀疏。新用户没有历史行为走人口统计学推荐热门榜、高分榜老用户有评分记录走协同过滤生成 UserRecs点进某部电影走基于内容的相似推荐。三种算法各管一段互不打架。3. 环境搭建与启动从伪分布式集群到六张结果表3.1 Spark 伪分布式集群搭建项目受经济条件限制用一台物理机模拟集群。具体是四个虚拟节点一个 Master、三个 Worker。主机名设为 hadoop102另外两台是 hadoop103、hadoop104。步骤不复杂但容易漏# 1. 安装 JDK 并配置环境变量 export JAVA_HOME/opt/module/jdk export PATH$JAVA_HOME/bin:$PATH # 2. 配置主机名映射 # /etc/hosts 里加 192.168.1.102 hadoop102 192.168.1.103 hadoop103 192.168.1.104 hadoop104 # 3. 配置三台机器免密登录 ssh-keygen -t rsa ssh-copy-id hadoop102 ssh-copy-id hadoop103 ssh-copy-id hadoop104 # 4. 修改 Spark 配置文件指定 Master 节点和端口 # spark-env.sh 里设置 export SPARK_MASTER_HOSThadoop102 export SPARK_MASTER_PORT7077 export JAVA_HOME/opt/module/jdk # 5. 启动集群 start-all.sh启动后访问hadoop102:8989看 Web UIWorker 栏显示三个节点 IP 和状态就说明集群起来了。这里有个血泪经验主机名千万别用默认的 localhost不然后面 Spark 连 MongoDB 时节点间通信会出玄学问题。3.2 MongoDB 与 Redis、Elasticsearch 的配置MongoDB 装在 Windows 上所有表都写进去。Redis 装在 Linux 上做缓存Elasticsearch 做搜索和存储。这三个服务的启动顺序建议是 MongoDB → Redis → Elasticsearch → Spark因为 Spark 任务启动时要连 MongoDB 读数据。# MongoDB 启动Windows mongod --dbpath D:\data\db # Redis 启动Linux redis-server /opt/module/redis/redis.conf # Elasticsearch 启动Linux注意不能用 root su esuser /opt/module/es/bin/elasticsearch -d注意Elasticsearch 对内存有要求虚拟机至少给 4G。如果启动报max virtual memory areas错误执行sysctl -w vm.max_map_count262144。3.3 推荐任务的执行顺序环境起来后推荐任务不能乱跑。正确顺序是先跑离线统计生成 AverageMovies、GenresTopMovies、RateMoreMovies、RateMoreRecentlyMovies再跑协同过滤生成 UserRecs最后启动 Web 服务。因为协同过滤依赖评分数据而评分数据可能被离线统计任务更新。# 提交离线统计任务 spark-submit \ --class com.movie.OfflineRecommender \ --master spark://hadoop102:7077 \ --executor-memory 2g \ movie-recommend.jar # 提交协同过滤任务 spark-submit \ --class com.movie.ALSRecommender \ --master spark://hadoop102:7077 \ movie-recommend.jar参数说明--executor-memory根据虚拟机内存调2g 是底线--master指向 Master 节点地址。如果任务卡在Job running不动先看 MongoDB 连接是否正常再看 ratings 表有没有数据。3.4 前端访问与登录验证后端 Spring 服务默认跑在 8088 端口浏览器访问localhost:8088跳登录页。注册一个账号登录后应该能看到首页推荐列表。如果页面空白按 F12 看 Network大概率是后端查 MongoDB 超时或者结果表为空。4. 核心推荐算法实现从统计推荐到协同过滤的代码链路4.1 历史热门与近期热门统计历史热门统计逻辑最简单按电影分组数评分条数降序排。近期热门多一个时间过滤只统计近一个月。// 历史热门电影统计 val rateMoreMovies spark.sql(SELECT mid, count(mid) as count FROM ratings GROUP BY mid) .withColumnRenamed(count, count) .orderBy(desc(count)) // 写入 MongoDB MongoSpark.save(rateMoreMovies.write .option(uri, mongoConfig.uri) .option(collection, RateMoreMovies) .mode(overwrite))逻辑说明groupBy(mid)按电影聚合count(mid)算每部电影的评分次数orderBy(desc(count))降序。MongoSpark.save是连接器提供的写入方法mode(overwrite)表示每次全量覆盖避免重复数据。近期热门需要 UDF 处理时间戳// 定义 UDF判断评分是否在近一月内 val getRecentMonth udf((timestamp: Long) { val oneMonthAgo System.currentTimeMillis() - 30L * 24 * 3600 * 1000 if (timestamp oneMonthAgo) 1 else 0 }) val rateMoreRecentlyMovies spark.sql(SELECT mid, timestamp FROM ratings) .withColumn(recent, getRecentMonth(col(timestamp))) .filter(col(recent) 1) .groupBy(mid) .count() .orderBy(desc(count))参数说明timestamp是评分时间戳单位毫秒。30L * 24 * 3600 * 1000是一个月的毫秒数。这个 UDF 返回 1 或 0filter 只留 1 的记录。注意时间戳字段类型要和数据库一致不一致会报ClassCastException。4.2 类别 Top10 的 UDF 切分与笛卡尔积这个算法稍微绕一点。电影类别字段是Action|Adventure|Sci-Fi这种格式要先切分再对每个类别算 Top10。// UDF按 | 切分类别 val splitGenres udf((genres: String) genres.split(\\|)) // 读取电影和平均分关联后展开类别 val moviesWithScore spark.sql(SELECT * FROM movies) .join(averageMovies, mid) val genresTopMovies moviesWithScore .withColumn(genre, explode(splitGenres(col(genres)))) .groupBy(genre) .apply(topN(10, avg))逻辑说明explode把数组炸成多行一部电影有几个类别就出几行。groupBy(genre)后对每个类别取平均分前 10。这里用apply(topN)是 Spark 的窗口函数封装等价于row_number() over (partition by genre order by avg desc) 10。提示类别字段如果为空或格式不对explode会丢数据。跑完任务后对比 movies 表总数和 GenresTopMovies 里的电影数差太多就是切分出了问题。4.3 协同过滤ALS 模型训练与 UserRecs 生成协同过滤用 Spark MLlib 的 ALS交替最小二乘。核心是把 ratings 表转成Rating(userId, movieId, rating)格式训练模型然后为每个用户生成推荐。// 读取评分数据 val ratings spark.sql(SELECT uid, mid, score FROM ratings) .map(row Rating(row.getInt(0), row.getInt(1), row.getDouble(2))) .rdd // 训练 ALS 模型 val als new ALS() .setMaxIter(10) // 最大迭代次数 .setRegParam(0.01) // 正则化参数防过拟合 .setRank(10) // 隐因子维度 .setUserCol(uid) .setItemCol(mid) .setRatingCol(score) val model als.fit(ratings) // 为每个用户生成 Top10 推荐 val userRecs model.recommendForAllUsers(10)参数说明rank是隐因子维度越大越能捕捉复杂偏好但计算越慢10 是常用起点regParam控制正则化强度0.01 到 0.1 之间调maxIter迭代次数10 次通常够用不收敛可以加到 20。recommendForAllUsers(10)返回每个用户的 10 部推荐电影和预测评分。生成的结果要转成 MongoDB 能存的格式val userRecsDF userRecs .select(col(uid), col(recommendations)) .withColumn(recs, explode(col(recommendations))) .select(col(uid), col(recs.mid).as(mid), col(recs.rating).as(score)) MongoSpark.save(userRecsDF.write .option(uri, mongoConfig.uri) .option(collection, UserRecs) .mode(overwrite))4.4 基于内容的详情页相似推荐详情页推荐不走协同过滤走内容相似度。逻辑是拿到当前电影的类别、主演、导演标签找标签重合度高的其他电影。// 计算电影之间的相似度基于类别标签的 Jaccard 相似度 val movieGenres spark.sql(SELECT mid, genres FROM movies) .withColumn(genreArr, splitGenres(col(genres))) val similarity movieGenres.as(a) .join(movieGenres.as(b), col(a.mid) ! col(b.mid)) .withColumn(common, size(array_intersect(col(a.genreArr), col(b.genreArr)))) .withColumn(total, size(array_union(col(a.genreArr), col(b.genreArr)))) .withColumn(sim, col(common) / col(total)) .filter(col(sim) 0.3) .select(col(a.mid).as(mid), col(b.mid).as(recMid), col(sim))逻辑说明array_intersect求两个类别数组的交集大小array_union求并集大小比值就是 Jaccard 相似度。sim 0.3是阈值低于这个值认为不相似。这个计算是笛卡尔积电影数量大时会很慢常见做法是只对热门电影算或者用 MinHash 近似。5. 避坑与排查跑这套系统最容易翻车的五个地方5.1 现象Spark 任务报MongoTimeoutException原因Spark 节点连不上 MongoDB。伪分布式下 Master 和 Worker 在不同虚拟节点MongoDB 装在 Windows 宿主机网络不通。解决确认 MongoDB 绑定了0.0.0.0而不是127.0.0.1防火墙放行 27017 端口Spark 提交任务时 URI 写宿主机 IP 而不是 localhost。5.2 现象ALS 训练报NaN或推荐结果为空原因评分数据太稀疏或者有用户只评了一部电影。ALS 对冷启动用户会返回 NaN。解决训练前过滤掉评分少于 5 条的用户和电影setColdStartStrategy(drop)丢弃无法预测的项。5.3 现象前端页面能打开但推荐列表空白原因UserRecs 表为空或者后端查询字段名和 MongoDB 里不一致。解决先进 MongoDB 命令行db.UserRecs.count()确认有数据再检查后端实体类字段名是否和表字段一一对应。常见错误是uid写成了userId。5.4 现象类别 Top10 结果里电影重复出现原因explode后一部电影有多个类别在每个类别里都排进了 Top10这是正常的。但如果同一类别里同一部电影出现两次说明 movies 表有重复 mid。解决对 movies 表按 mid 去重dropDuplicates(mid)。5.5 现象Spark Streaming 实时推荐延迟越来越高原因没有设置 checkpoint或者窗口时间设得太长。解决ssc.checkpoint(hdfs://hadoop102:9000/checkpoint)设置检查点窗口时间从 1 小时降到 10 分钟。实时推荐本身对这套系统是锦上添花跑不通可以先跳过不影响离线推荐的主流程。6. 二次开发与改造换数据源、调算法、加特征这套系统最值钱的地方是结构清晰改起来不费劲。我一般会从三个方向动手。第一个方向是换数据源。现在读的是 MongoDB 里的 movies 和 ratings如果想接豆瓣或 TMDB 的数据只需要改 Spark 读取那一段把spark.sql(SELECT * FROM movies)换成读 CSV 或调 API。注意字段映射要对齐尤其是类别字段的分隔符TMDB 用|豆瓣可能用/UDF 里的split参数跟着改。第二个方向是调算法参数。ALS 的rank、regParam、maxIter三个参数直接决定推荐质量。我习惯用交叉验证找最优组合val paramGrid new ParamGridBuilder() .addGrid(als.rank, Array(5, 10, 20)) .addGrid(als.regParam, Array(0.01, 0.1, 0.5)) .build() val evaluator new RegressionEvaluator() .setMetricName(rmse) .setLabelCol(score) .setPredictionCol(prediction) val cv new CrossValidator() .setEstimator(als) .setEvaluator(evaluator) .setEstimatorParamMaps(paramGrid) .setNumFolds(3) val cvModel cv.fit(ratings)这段代码会跑 27 种参数组合每种 3 折验证最后选 RMSE 最低的。耗时但值得调完 RMSE 通常能降 0.1 到 0.2。第三个方向是加特征。现在协同过滤只用了评分可以加入用户人口统计学特征年龄、性别和电影内容特征导演、演员做混合推荐。具体做法是在 ALS 基础上加内容相似度加权// 最终推荐分 0.7 * ALS预测分 0.3 * 内容相似度 val finalScore col(alsScore) * 0.7 col(contentSim) * 0.3权重根据业务调新用户内容权重大些老用户协同过滤权重大些。验证改造效果的方法留出 20% 评分做测试集算 RMSE 和 Precision10。RMSE 看预测准不准Precision10 看推荐列表里用户真正喜欢的比例。两个指标都涨了才算改对了。从那以后我每次改推荐算法都强制先跑一遍基线指标再跑改造后的对比着看。没有基线改了什么都是黑匣子。希望帮到你。本文还有配套的精品资源点击获取