ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spark的电影推荐系统:混合算法与三条链路落地

基于Spark的电影推荐系统:混合算法与三条链路落地

简介:面向计算机专业学生与大数据推荐系统初学者,这份基于Spark的电影推荐系统毕业设计资料围绕“论文+源码”展开,可用于课程设计、毕业设计选题参考及推荐算法入门实践,重点解决海量电影场景下的个性化推荐与系统实现问题。压缩包共1个docx文档,大小约7.46MB,内容以论文为主体,串联Spark、MongoDB与Web开发技术,并系统梳理基于人口统计学、基于内容及协同过滤三类推荐算法的原理与设计。文档覆盖需求分析、可行性分析、系统设计、功能实现与测试等完整章节,涉及用户登录注册、个性化推荐、电影搜索和用户评分等模块,便于读者理解推荐系统从算法选型到落地实现的整体链路。目前已有207人学习,适合需要搭建论文框架、对照功能模块或补充推荐算法实践思路的读者参考。

1. 从片荒到可交付:这套基于 Spark 的电影推荐系统要解决什么

一个很具体的场景:用户打开某个视频应用,首页推的还是三个月前看过的类型,翻了二十分钟没点开一部,最后关掉。个性化推荐做不好,用户流失是静悄悄的。这套基于 Spark 的电影推荐系统设计与实现,要解决的正是这件事——它是在页面里增加一个“猜你喜欢”模块,而是把离线统计、实时行为、详情页相似这三条链路都跑通,让不同位置的推荐有不同来源。这套系统面向电影爱好者:注册登录后按类型标签取推荐、按评分榜看高分片、点进详情页看相似影片;后台用 Spark 支撑大数据量下的统计与模型训练,用协同过滤和内容推荐算法互相补短板。如果你正在做推荐系统方向的毕设或课设,这套四层结构加三条链路的骨架可以直接照着搭,改改数据源就能变成自己的题目。

2. 总体架构与技术选型:四层结构与四类组件的职责边界

技术选型这件事,很多方案写得像产品手册,把 Spark 的优点抄一遍就算完。真正动手时,需要先回答一个问题:这个项目的数据量到底需不需要分布式框架。影视评分数据动辄百万级、千万级,用户画像要反复迭代计算,单机脚本每次全量重算要跑几十分钟,调一次参数等半小时,这种反馈速度基本没法做实验。Spark 把中间结果放在内存里,迭代计算的差距就拉开了;再加上 Spark SQL、Spark Streaming、MLlib 三件套都在同一个运行时里,离线统计、实时流处理、模型训练不用换技术栈,这是选它的核心理由,并非因为它是“最新一代框架”。

2.1 为什么是 Spark 而不是单机脚本

单机方案并非不能用。数据量在十万条评分以内时,pandas 加 surprise 库半天就能跑出一个能演示的版本。问题出在三个地方:第一,迭代计算。协同过滤的矩阵分解要反复迭代,Spark 把 RDD 缓存在内存里,下一轮直接读内存,而 MapReduce 那一套每轮都落磁盘,差距是数量级的。第二,数据转换的复杂度。真实数据里电影的类型字段常常是动作|科幻|冒险这种用竖线拼起来的一列,需要切分、展开、再聚合,Spark SQL 的explode加窗口函数几行就能写完,手写循环既慢又容易错。第三,组件复用。离线统计用 Spark SQL,实时推荐用 Spark Streaming,模型训练用 MLlib,三者共用同一套 RDD/DataFrame 抽象,序列化开销小,运维也只需要维护一套集群。

Scala 作为开发语言也是顺带的选择。它跑在 JVM 上,能直接调 Java 生态里的库,语法上又支持函数式写法,写map、filter、reduce这类算子比 Java 简洁不少。对毕设来说还有个隐性好处:Scala 版本和 Spark 版本强绑定,只要在build.sbt或pom.xml里把版本对齐,后面基本不会再遇到“编译过了运行报 NoSuchMethod”这种问题。

提示:Spark 3.x 之后 Scala 2.12 和 2.13 是两个不同的构建产物,依赖坐标里的后缀(_2.12/_2.13)必须和集群里的 Scala 版本一致,这一条对不上,后面所有报错都会指向奇怪的地方。

2.2 四层架构与模块职责划分

整套系统按职责切成四层,这个划分决定了后面所有代码放在哪个包里,也决定了答辩时怎么讲清楚“你做了什么”。

层次承载内容技术栈对外形态
视图层登录注册、首页推荐、搜索、详情页、评分打标签AngularJS2浏览器页面
业务层用户会话、评分写入、推荐结果组装、权限校验Spring + MavenREST 接口
算法层离线统计、内容相似、协同过滤、实时打分Scala + Spark定时任务与流任务
存储层结果表、缓存、检索索引MongoDB / Redis / Elasticsearch数据读写

视图层只负责展示和收集行为,不参与任何打分逻辑;业务层是唯一能被前端调用的入口,它不做算法,只做参数校验和结果拼装;算法层是纯计算,输入是原始评分和电影元数据,输出是写进 MongoDB 的结果集合。这样切的好处是:换推荐算法不影响前端,换前端框架不影响算法。实际做课设时经常要临时改页面,这个边界能省掉大量返工。

算法层对外只暴露一个统一出口,业务层依赖接口而不是具体实现,这是我在这种项目里一定会加的一层抽象:

// 推荐服务统一出口:业务层只认这个 trait,换算法不动上层 trait Recommender { // 离线榜单:按类型取历史热门 / 近期热门 / 平均分榜 def offlineTopK(genre: String, k: Int): Seq[MovieRec] // 实时推荐:结合用户最近 K 条行为重新打分 def realtimeRecs(uid: Int, k: Int): Seq[MovieRec] // 详情页相似:以某部电影为种子找相似片 def similarMovies(mid: Int, k: Int): Seq[MovieRec] } // 每个方法返回统一的载体,前端不用关心结果来自哪个算法 case class MovieRec(mid: Int, score: Double, source: String)

source字段很关键,它记录这条推荐来自离线统计、内容相似还是协同过滤。线上排查“为什么给我推这部片”时,看这个字段就能定位到具体算法,否则就是一个黑匣子。k是返回条数,首页一般取 20,侧边栏取 6 到 8,详情页相似取 12,这个数值不要写死,从配置文件读,方便调。

2.3 存储组件的分工:MongoDB、Redis、Elasticsearch 各管一段

三个存储组件并非随意堆砌,各自承担不同职责。MongoDB 是主存储,存的是算法跑完的结果表,比如平均分榜、各类型 Top10、用户推荐列表。选它是因为推荐结果的结构是“一个用户对应一个电影列表”,用文档模型一次读出来就是一整块,不像关系库要 join 好几张表。Redis 承担实时链路的缓存,用户最近若干条评分、实时推荐的候选集都放这里,读写都在毫秒级,前端刷新首页不会卡。Elasticsearch 管两件事:电影标题的模糊检索,以及详情页相似影片的候选召回。

这套分工还有个实际好处:算法重跑只影响 MongoDB 里的结果集合,前端读的是集合不是实时计算,所以离线任务凌晨跑的时候,白天访问的用户完全无感。如果所有东西都塞进一个库,重跑一次统计就得停服,这在演示环节是致命的。

2.4 开发环境与伪分布式集群的搭建

开发工具用 IntelliJ IDEA,推荐模块用 Java 和 Scala 混合编写,前端 AngularJS2,数据库 MongoDB。考虑到只有一台物理机,集群采用伪分布式:在一台机器上开三个虚拟机节点,一个 Master 加三个 Worker 的配置里,Master 和其中一个 Worker 可以合并在同一台。主机名分别设成 hadoop102、hadoop103、hadoop104,装 JDK 并配好环境变量,配 SSH 免密,然后把节点信息写进 Spark 的配置文件。

# 三台节点分别生成密钥并互相分发,实现免密登录 ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa ssh-copy-id hadoop102 ssh-copy-id hadoop103 ssh-copy-id hadoop104 # Master 节点启动整个集群 $SPARK_HOME/sbin/start-all.sh # 浏览器访问 Master 的 8080 端口,Worker 列表里三个节点都是 ALIVE 才算成功

启动之后要确认的细节:Worker 列表里显示的核数和内存要和你配置文件里写的一致,不一致说明spark-env.sh没生效;start-all.sh的输出里如果出现JAVA_HOME is not set,说明环境变量只在当前 shell 生效,没写进/etc/profile。这两点看着琐碎,但每年都有人卡在这里,把集群起不来当成 Spark 装错了。

3. 三类推荐算法的设计与实现:人口统计学、内容匹配与协同过滤怎么配合

只上一种算法的推荐系统,在答辩现场很容易被问住:新用户没有评分怎么办?新上架的电影没有评分怎么办?用户口味突然变了怎么办?这套方案的答案是三种算法分工,各管一段场景。基于人口统计学的统计推荐负责“所有人都会喜欢”的榜单,解决冷启动;基于内容的推荐负责“和你看过的这部像”的相似召回,不依赖他人评分;协同过滤负责“和你口味相似的人还看了什么”,挖掘隐含偏好。三者不是取平均,而是按场景分发。

3.1 基于人口统计学的离线统计:四个统计口径落地

统计推荐不做个性化,它算的是全站视角的榜单,但恰恰是新用户第一次进首页时唯一能看的内容。这套系统里定义了四个口径:历史热门影片、近期热门影片、电影平均评分、各类型评分前十。

历史热门按累计评分次数排序,反映长期口碑;近期热门只统计近三十天的评分行为,反映当下热度,两者分开存是因为新片需要曝光窗口,只按累计次数排,新片永远上不来。平均评分榜要过滤掉评分人数过少的电影,否则一部只有一个人打了五星的片子会排在榜首,这是典型的翻车点。各类型 Top10 需要先把动作|科幻这种拼接字段拆开,再按类型分组取前十。

// 近期热门:近 30 天被评分次数最多的电影,写入 RateMoreRecentlyMovies val recentHot = spark.sql( """ |SELECT mid, count(*) AS cnt |FROM ratings |WHERE ts >= unix_timestamp() - 30 * 86400 |GROUP BY mid |ORDER BY cnt DESC |LIMIT 100 """.stripMargin) recentHot.write .option("uri", mongoUri) .option("collection", "RateMoreRecentlyMovies") .mode("overwrite") // 每次全量重算,覆盖旧结果 .format("com.mongodb.spark.sql.DefaultSource") .save()

mode("overwrite")是有意为之:统计榜单每天重算一次,覆盖比增量更新简单,也不会出现新旧结果混在一起。代价是重算期间这张集合是空窗,所以任务要放在凌晨跑,或者先写临时集合再原子改名。ts用秒级时间戳而不是日期字符串,是为了避免时区问题——用字符串比较日期,跨时区部署时会少算或多算一天。

各类型 Top10 的写法值得单独说一句。原始材料里用的是按|切分后做笛卡尔积匹配,思路能跑通但代码量大。实际写起来用explode加窗口函数更直接:

// 平均分榜:过滤评分人数过少的电影,避免一部片一个人打五星就登顶 val avgMovies = spark.sql( """ |SELECT mid, avg(score) AS avgScore, count(*) AS cnt |FROM ratings |GROUP BY mid |HAVING count(*) >= 50 """.stripMargin) // 各类型 Top10:把 "动作|科幻" 拆成多行,再按类型分区排名 val genreTop = spark.sql("SELECT mid, genres, avgScore FROM movie_avg JOIN movies USING(mid)") .withColumn("genre", explode(split(col("genres"), "\\|"))) .withColumn("rk", row_number().over( Window.partitionBy("genre").orderBy(desc("avgScore")))) .filter(col("rk") <= 10) .drop("rk")

HAVING count(*) >= 50这个阈值是需要调的参数:数据量小的时候设 50 会让很多电影进不了榜,演示时看着榜单空荡荡;设成 5 又容易被刷分污染。我一般按总评分条数的千分之一估一个初值,再看榜单长度是否合理。row_number()保证每个类型严格十条,用rank()遇到并列会多出记录,前端分页会错位。

3.2 基于内容的推荐:标签向量化与相似度计算

内容推荐不依赖其他用户的评分,只回答一个问题:这部电影和用户看过的电影在哪些属性上像。系统里可用的标签有三类:影片类型、主演、导演。把这三类拼成一个标签串,切分后做词频统计,再算余弦相似度,是最省事也最稳的做法。

// 把类型、主演、导演拼成一个标签串,按 | 切分成词 val tagDF = movies.select( col("mid"), concat_ws("|", col("genres"), col("actors"), col("director")).as("tagStr")) val tokenizer = new Tokenizer().setInputCol("tagStr").setOutputCol("words") val hashingTF = new HashingTF().setNumFeatures(4096) .setInputCol("words").setOutputCol("rawFeatures") val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features") // 每部电影变成 4096 维稀疏向量,后续两两算余弦 val movieVec = idf.fit(hashingTF.transform(tokenizer.transform(tagDF))).transform(...)

setNumFeatures(4096)是哈希空间大小,取值太小会发生哈希冲突,不同的演员被映射到同一维,相似度算出来就失真;取值太大内存占用上升但精度提升有限,4096 到 8192 之间是比较舒服的区间。IDF 的作用是压低高频词的权重——如果某位演员参演了平台上三分之一的电影,他对“相似”的贡献就应当很小,这正是 IDF 干的事。

工业场景里,详情页推荐对“像不像”的要求比首页更高,用户点进一部电影,期望的是同类型、同主演、同导演的片子。这时候纯余弦还不够,通常会在结果上加一层规则过滤:同类型优先、同导演次之、同主演再次,最后按相似度排序。这个加权规则不需要写进模型,在结果组装时排一下序就行,改起来快,答辩时也好解释。

3.3 协同过滤的落地:ALS 训练与冷启动兜底

协同过滤是这套系统的关键词之一,也是答辩最容易被追问的部分。它的假设很朴素:找到和目标用户评分行为相似的一批人,把他们喜欢而目标用户没看过的电影推过来。实现上有两类,一类是找邻居,一类是训模型。数据量大之后找邻居的计算成本会变得不可接受,所以实际工程里多用矩阵分解,MLlib 里的 ALS 就是干这个的。

val als = new ALS() .setRank(50) // 隐因子维度:用户和电影各映射到 50 维空间 .setMaxIter(10) // 迭代轮数,10 轮基本收敛,再大收益很小 .setRegParam(0.01) // 正则项,防止对稀疏评分过拟合 .setUserCol("uid") .setItemCol("mid") .setRatingCol("score") .setColdStartStrategy("drop") // 预测阶段遇到训练集没见过的用户/电影直接丢弃 .setNonnegative(true) // 评分非负,隐因子加非负约束更贴合业务 val model = als.fit(trainSet) // 每个用户取 20 条,写进 UserRecs val userRecs = model.recommendForAllUsers(20)

几个参数的实际含义要说清楚:rank太低模型表达不了复杂口味,太高在小数据量上直接过拟合,50 是中等规模数据的常用起点;regParam调大能压住过拟合,但调太大推荐会趋于平庸,所有人拿到差不多的结果;setColdStartStrategy("drop")如果不加,预测阶段遇到新用户会返回 NaN,排序时 NaN 会被当成极大值排到最前面,首页就会出现一堆莫名其妙的片子,这是最常见的翻车现场之一。

ALS 解决不了冷启动。新用户没有评分,模型给不出有意义的向量。所以整套系统必须有一条兜底路径:评分条数少于阈值(我一般设 5)的用户,不走协同过滤,直接回落到近期热门榜单,同时在前端加一层类型偏好的问卷式选择——用户勾选喜欢的类型标签,系统就按类型 Top10 出推荐。这一步既是产品设计也是算法设计,缺了它,新注册用户的首页就是空的。

3.4 混合策略:什么场景用哪个算法

场景主算法数据来源兜底方案
新用户首页人口统计学近期热门 + 类型偏好全站平均分榜
老用户首页协同过滤ALS 离线结果评分不足 5 条转热门
实时推荐位内容相似 + 实时行为Redis 近期评分 + 电影标签用户离线推荐列表
详情页相似内容相似类型/主演/导演标签同类型高分会话
搜索无结果模糊检索Elasticsearch 标题索引热门关键词推荐

这张表是我认为方案里最该讲清楚的部分。答辩时被问“你为什么用三种算法”,答案不应该是“显得全面”,而是这五个位置各自的最优解不同:新用户没有行为,只能用群体统计;老用户有足够行为,协同过滤的效果上限最高;详情页需要强解释性,用户点了动作片却推出喜剧片会被认为系统坏了,内容相似最稳。

4. 三条推荐链路的落地:离线统计、实时打分与详情页相似的具体实现

算法讲清楚之后,落到工程上还有一段距离。同样是协同过滤,写在论文里是三行公式,写在项目里要考虑:结果存哪、多久更新一次、前端怎么读、读不到怎么办。这一章按三条链路拆开讲,每条链路都给出数据结构、执行方式和失败时的表现。

4.1 数据模型:MongoDB 里的六张结果表

系统把算法产物都落成集合,前端只读这些集合,不触发实时计算。这样的设计让“算法跑得慢”和“页面打开慢”两件事彻底解耦。

集合名内容更新频率关键字段
User用户账号与偏好标签注册时写入uid, username, genres
AverageMovies电影平均分每日mid, avgScore, cnt
GenresTopMovies各类型 Top10每日genre, recs[]
RateMoreMovies历史热门每日mid, cnt
RateMoreRecentlyMovies近 30 天热门每日mid, cnt
UserRecs用户个性化推荐每日uid, recs[]

UserRecs的结构值得单独看一眼,它决定业务层读数据的方式:

{ "uid": 1001, "recs": [ { "mid": 3201, "score": 4.82 }, { "mid": 1187, "score": 4.51 }, { "mid": 902, "score": 4.30 } ], "source": "als", "updateTime": "2024-05-11 02:00:00" }

把整个推荐列表内嵌成一个数组,一次查询就能拿到全部结果,比“一条推荐一行记录”少了几十次网络往返。代价是更新时要整条覆盖,不能只改一条推荐。对每日全量重算的场景来说这不是问题,反而更简单。source和updateTime两个字段是我一定会加的,前者用于排查,后者用于前端判断数据是否过期——如果离线任务失败,页面可以降级显示“热门榜”而不是空白。

4.2 离线链路:调度、执行与落库

离线链路负责所有统计榜单和 ALS 训练,通常是每天凌晨跑一次。执行顺序有依赖:先算平均分,再算类型 Top10,因为后者依赖前者的结果;ALS 训练要等评分数据同步完成之后才能开始,否则训的是旧数据。这个依赖关系要写进调度脚本,不能靠人工记。

#!/usr/bin/env bash # 离线任务按依赖顺序串行执行,任一步失败即中断,不发版 set -e spark-submit --class com.rec.offline.AvgMovies \ --master spark://hadoop102:7077 \ --executor-memory 2g \ offline.jar || exit 1 spark-submit --class com.rec.offline.GenreTop10 offline.jar || exit 1 spark-submit --class com.rec.offline.RecentHot offline.jar || exit 1 spark-submit --class com.rec.offline.TrainALS offline.jar || exit 1 echo "offline pipeline done at $(date)"

set -e是必须的,不加的话前一步失败后面照跑,最后 ALS 拿空数据训出一个全零模型,页面上推荐位全是同一部片。--executor-memory 2g在伪分布式环境里要按虚拟机实际内存调,给大了 Worker 起不来,给小了 ALS 训练直接 OOM。这类参数没有标准答案,只能按机器实际情况调,我一般先给一半物理内存,跑通再压。

4.3 实时链路:Spark Streaming 与 Redis 的配合

实时推荐要解决的问题是:用户刚刚给一部科幻片打了五星,下一次刷新首页时,推荐里应该出现更多科幻片。这条链路不能等每日重算,得走流处理。

实现上分三步。第一步,评分事件进入消息队列,用 Kafka 承接,主题按用户 ID 分区,保证同一用户的事件有序。第二步,Spark Streaming 消费事件,从 Redis 取出该用户最近若干条评分,和本批事件合并,用离线训练好的 ALS 模型对新候选集打分。第三步,过滤掉用户已经看过的电影,把结果写回 Redis,前端实时接口直接读。

// 消费评分事件流,按微批处理 val stream = KafkaUtils.createDirectStream[String, String]( ssc, PreferConsistent, Subscribe[String, String](Array("rating-topic"), kafkaParams)) stream.map(_.value()).map(parseRating).foreachRDD { rdd => rdd.foreachPartition { iter => val jedis = JedisPoolUtil.getResource // 每个分区复用一个连接 iter.foreach { r => // 1. 把本次评分写进用户的近期行为列表,只保留最近 20 条 jedis.lpush(s"recent:${r.uid}", s"${r.mid}:${r.score}") jedis.ltrim(s"recent:${r.uid}", 0, 19) // 2. 用模型对该用户重新打分,结果写入实时推荐位 val recs = scoreForUser(r.uid, model) jedis.set(s"realtime:${r.uid}", recs.mkString(",")) } jedis.close() } }

foreachPartition而不是foreach,是为了每个分区只建一次 Redis 连接,否则每条评分都建连,连接数会直接打满。ltrim保留最近 20 条是关键设计:历史行为太长会稀释最近兴趣,太短又不足以判断口味,20 条大约覆盖用户最近两三次观影。scoreForUser内部要过滤已看列表,这一步漏了,实时推荐位会反复推刚看过的电影,是用户最容易察觉的 bug。

流任务还有个容易被忽略的点:模型是离线训练好的,流任务启动时加载一次,全天使用。这意味着新片的向量要等第二天重训才进模型,实时链路对全新电影无能为力。所以实时推荐位的结果要和内容相似的候选合并,内容相似基于标签,新片只要打了标签就能被召回,两条路互补。

4.4 详情页与搜索:Elasticsearch 的角色

详情页推荐和搜索是两个不同的查询,但都用 Elasticsearch。搜索走的是标题和类型字段的模糊匹配,用户输入片名的一部分就能命中;详情页走的是more_like_this查询,以当前电影的类型、主演、导演作为种子文档,召回相似电影。

{ "query": { "more_like_this": { "fields": ["genres", "actors", "director"], "like": [{ "_index": "movies", "_id": "3201" }], "min_term_freq": 1, "max_query_terms": 25 } }, "size": 12 }

min_term_freq: 1表示标签至少出现一次就参与打分,max_query_terms: 25限制参与相似度计算的词条数量,防止主演列表特别长的电影把查询拖慢。size取 12 是因为详情页侧栏一屏放得下 12 个卡片,多了会滚动,少了显得内容单薄。

这条链路和离线链路的关系是:Elasticsearch 里存的是电影元数据,实时同步;MongoDB 存的是统计和推荐结果,每日更新。两者不重复,前端在详情页同时读两边——相似影片来自 ES,该片的平均分和类型榜排名来自 MongoDB。

5. 避坑与排查:环境、数据、算法三层最容易翻车的五个点

这些坑不是理论上可能出问题,是我在这个类型的项目里反复见过的。每一条都按“现象—原因—解决”写,照着排查基本能定位到具体位置。

5.1 环境与数据链路

现象一:本地 IDE 跑得好好的,提交到集群就报 ClassNotFound,指向 MongoDB 连接器。

原因:spark-submit提交时只带了自己的 jar,MongoDB 连接器的依赖没有打进 fat jar,或者打进去了但 scope 写成了provided。本地跑的时候依赖在 classpath 里,看不出问题。

解决:用 Maven Shade 或 sbt-assembly 打成包含全部依赖的 fat jar,确认连接器版本和 Spark 版本匹配,提交时加--packages org.mongodb.spark:mongo-spark-connector_2.12:3.0.1让集群自动拉取。两个方案选一个,不要混用——混用时类加载顺序不确定,反而更难查。

现象二:近期热门榜单每天都是空的,但历史热门正常。

原因:ratings表里的时间字段是字符串格式,代码里按数值和时间戳比较,Spark 隐式转换失败后全部过滤掉了。这类问题不会报错,只是结果为空。

解决:在数据同步阶段就把时间统一成秒级 Long,写一个parseTs函数处理两种格式,解析失败的行单独落到错误表里统计条数。每次跑完任务后看一眼错误表的行数,如果突然从 0 变成几万,说明上游数据格式变了。

现象三:伪分布式集群启动后,Worker 列表里只有两个节点,第三个显示 UNKNOWN。

原因:三个虚拟机的系统时间不同步,或者 SSH 免密只在 Master 上配了、Worker 之间没配。Spark 的 Worker 注册依赖时间戳,偏差超过阈值就注册失败。

解决:三台机器都装 NTP 并指向同一个时间源,用date逐台确认;免密用ssh-copy-id三台互发一遍,不要只在 Master 上做。这类问题的排查顺序是先看时间再看网络,最后看防火墙,反过来查会很浪费时间。

5.2 算法与结果

现象四:首页推荐位出现大量重复电影,翻两页就到底了。

原因:ALS 的recommendForAllUsers返回的列表里可能包含用户已经评分过的电影,没有做过滤;另外各类型 Top10 合并时没有去重,同一部片因为属于多个类型被重复插入。

解决:在结果落库前统一做一次已看过滤——拿用户的评分历史集合和推荐列表求差集;多类型合并用distinct或按 mid 去重后再排序。过滤这步放在写库之前而不是前端,前端做去重会导致每页条数不一致。

现象五:协同过滤的推荐结果分数是 NaN,排序后排在第一位。

原因:setColdStartStrategy没设成drop,预测阶段遇到训练集里没出现过的用户或电影,ALS 返回 NaN。Scala 的排序里 NaN 会被当成最大值。

解决:加上setColdStartStrategy("drop"),同时在写库前过滤score.isNaN的记录。更稳妥的做法是在打分前判断该用户的评分数,少于 5 条直接走热门兜底,根本不进 ALS 预测。这个判断要在业务层做,不能指望算法层兜住所有情况。

6. 验收与答辩:一份能当场跑通的检查清单和一个讲清楚混合策略的技巧

验收这件事,很多人理解成“功能跑通了就行”。真正坐到答辩席上或者交付给对方时,被问的往往不是“能不能推荐”,而是“推荐得对不对、稳不稳、出问题怎么发现”。我在这个项目里总结出的做法是先列一张检查清单,逐项过一遍,过的过程本身就是演示脚本。

验收项检查方法通过标准
集群可用访问 Master 的 Web UIWorker 全部 ALIVE,核数与配置一致
离线任务手动触发全量调度四个任务依次成功,MongoDB 六张表均有数据
冷启动新注册账号登录首页有推荐,来源显示为热门榜
个性化老账号打三个五星后刷新推荐列表出现同类型影片,非全站热门
实时链路打一个五星后等 10 秒刷新实时位出现该类型影片,且不含刚评分的片
详情页相似点进任意电影12 条相似影片,类型重合度肉眼可判
搜索输入片名片段能命中,无结果时有兜底推荐

这张表的价值在于每一项都有明确的通过标准,不是“看起来正常”。比如“类型重合度肉眼可判”这条,演示时随手点开一部科幻片,如果相似推荐里出现三部以上喜剧,直接判定内容相似链路有问题,不用等用户投诉。

答辩环节有个技巧值得单独说:不要一上来讲三种算法,先把“为什么需要三种”讲清楚。我的讲法是拿一个具体用户走一遍——新注册时系统不知道他是谁,只能给热门榜;他打了五个评分之后,协同过滤开始起作用;他点进一部电影,内容相似接管。三段话讲完,评委自然理解混合策略的必要性,后面再展开 ALS 的参数、IDF 的权重,就都落在你已经铺好的框架里。反过来先讲公式,讲到一半被打断问“新用户怎么办”,节奏就乱了。

还有一点,把失败路径讲出来比只讲成功路径更加分。比如主动说明“离线任务失败时页面降级到热门榜”“ALS 对新片无效所以详情页走 ES 召回”,这些是真实工程里必须考虑的事,讲出来说明你想过边界。我见过太多方案只画一条顺畅的箭头,一问“数据没同步完怎么办”就答不上来。

从那以后我每次做这类项目,验收清单都会在写代码之前先列出来,而不是等做完再补。清单反过来约束设计——你列了“实时位不含刚评分的片”,写代码时就会记得加过滤;列了“离线失败可降级”,架构上就会留一个兜底字段。这套基于 Spark 的电影推荐系统,从需求、选型、算法到三条链路的实现和验收,整套结构都在上面了,源码和论文里的图表可以对着这份结构去看,改数据源、换前端、加一路召回,都能在这个骨架上接着做。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进