推荐系统矩阵分解这块,Spark ALS 是我在实际项目中用得最多的一个算法。很多人第一次接触它,是被公式吓住的;真正跑通之后,又会发现调参、结果解析、性能优化这些坑一个比一个深。这篇博文就把我从原理到实践,踩过的坑和验证过的经验完整梳理一遍,希望能帮你少走弯路。
1. 为什么矩阵分解能撑起推荐系统的半壁江山
先聊点直观的。推荐系统本质上是在回答一个问题:用户对哪些物品可能感兴趣?这个问题落到数据层面,就是一张巨大的打分矩阵——行是用户,列是物品,格子是用户对物品的交互(评分、点击、购买次数等)。但现实里这张矩阵极其稀疏,可能 99% 的格子都是空的,因为绝大多数用户只会和极少数物品发生交互。
矩阵分解的思路很简单:把这个巨大的稀疏矩阵,拆成两个小矩阵相乘。一个小矩阵代表“用户的隐含特征”,另一个代表“物品的隐含特征”。两个小矩阵都不直接对应任何可解释的标签,比如“年轻”“科技感”“性价比”,它们只是数字上最能还原原矩阵的潜向量。但正是这些不可解释的潜向量,让系统具备了捕捉复杂偏好的能力。
我自己的体会是,矩阵分解最大的价值在于它的“泛化性”。相比协同过滤里那种“和你有相似行为的用户还喜欢什么”的邻居式推荐(即 user-based CF),矩阵分解不依赖局部相似度,而是从全局拟合用户和物品的交互模式。这就像一个阅人无数的老手,不是靠“你朋友喜欢什么”来猜你喜欢,而是靠你过往行为和整体人群的统计规律来做推断。它更稳,覆盖面也更广。
还有一个特别适合生产环境的特性:矩阵分解的产物(用户因子矩阵 U 和物品因子矩阵 V)都是稠密的小矩阵。这意味着在线推荐阶段,系统不再需要去大海捞针一样扫描历史行为,只需把用户向量和所有物品向量做点积,排序一下就能拿到候选集。延迟可控,扩展性极强。
但矩阵分解也并非银弹。它本质上依赖用户和物品之间有足够的交互信号,冷启动问题它解决不了;用户行为非常稀疏的时候,它的表现会明显下降;而且隐因子不可解释,这导致你很难向业务方解释“为什么推荐了这个”。这些我在后面章节会结合 Spark ALS 的实际表现详细展开。
2. ALS 的核心原理拆解:交替最小二乘是怎么工作的
明确了矩阵分解的宏观目标,接下来就得落地到具体算法。Spark 里使用的 ALS(Alternating Least Squares,交替最小二乘)是矩阵分解问题的一种高效求解方法。要理解 ALS,先得理解我们要优化的目标函数长什么样。
2.1 目标函数与正则化:为什么说它是“加权”的最小二乘
ALS 要解决的最优化问题是:
[ \min_{U,V} \sum_{(i,j)\in observed} (r_{ij} - u_i^T v_j)^2 + \lambda (|u_i|^2 + |v_j|^2) ]
其中 ( r_{ij} ) 是用户 i 对物品 j 的真实交互值,( u_i ) 是用户 i 的隐因子向量,( v_j ) 是物品 j 的隐因子向量,λ 是正则化系数。通俗讲,就是让预测值 ( u_i^T v_j ) 逼近真实值 ( r_{ij} ),同时限制因子向量的模长不要过大,防止过拟合。
Spark ALS 的一个关键设计在于,它支持explicit feedback(显式反馈)和implicit feedback(隐式反馈)两种模式。前者处理评分数据,后者处理点击、购买、播放时长这类隐式数据。多说一句隐式反馈,因为真实业务里绝大多数有价值的行为都是隐式的。Spark 在 implicit 模式下,把原始交互次数先做了置信度加权:
[ c_{ij} = 1 + \alpha \cdot r_{ij} ]
其中 α 是置信度缩放系数(代码中对应alpha参数,默认 1.0)。交互次数越高,置信度越大,但这个增长是缓和的,避免头部用户或爆品完全主导模型。同时,所有未观测到的交互会被当成 0,而不是直接忽略。这是个很聪明的设计——用户没看过某件商品,未必是不喜欢,但至少在置信度上应该比“看过但没产生交互”更低一些。
2.2 交替优化的数学直觉:先固定一个,求解另一个
如果直接同时对 U 和 V 求偏导,这就是一个非凸问题,很难保证收敛到全局最优。ALS 的思路是“分而治之”:先固定 V 不动,把 U 看成未知数,那每个用户向量的求解就变成了一个标准的、有闭式解的最小二乘问题;求解完 U,再固定 U 求解 V,如此循环迭代。
为什么能做到这一步?因为每一轮固定其中一个矩阵后,损失函数对另一个矩阵就变成了凸函数。凸函数意味着局部最优就是全局最优,可以直接用正规方程求解。每一轮迭代都是精确解而不是梯度下降式地“挪一步看看”,所以 ALS 的收敛节奏通常比 SGD 更干脆。
从计算角度看,Spark 里每个用户的因子向量更新只依赖该用户关联的物品因子向量和交互记录,物品更新同理。这带来一个巨大的优势:天然可并行化。按用户划分的数据分片,可以各自独立更新用户因子,互不干扰;下一轮再按物品分片更新物品因子。这就是 Spark ALS 能在分布式环境下高效运行的根基。我在实践中对比过,在千万级用户、亿级交互的数据集上,ALS 能在几小时内完成多轮迭代,这在单机 sklearn 类的实现上是不可想象的。
3. Spark 环境准备与数据要求:很多坑都埋在这里
理论部分清楚了,接下来是动手阶段。说实话,用 Spark ALS 跑通一个 Demo 并不难,难的是你准备的数据和环境能否支撑模型真正上线。我在这里把最容易踩的坑提前说明白。
3.1 版本选型与运行时环境
我的经验是,Spark 的版本升级经常会带来 API 变动,ALS 的接口相对稳定,但 DataFrame 相关的行为细节会有差异。目前主流推荐使用 Spark 3.x 系列,性能相比 2.x 有明显提升,特别是 Spark 3.2+ 之后的版本,对动态资源分配和 AQE(自适应查询执行)的支持更完善,这对大规模 ALS 作业很有帮助。
跑 ALS 的时候,一个特别容易被忽略的问题是 JVM 内存和 GC 的配置。ALS 在迭代过程中会产生大量的临时矩阵对象,垃圾回收的压力非常大。我见过不少集群上作业失败,不是算法不对,而是 Executor 内存溢出。建议在提交作业时显式指定 Executor 内存,并启用 G1GC 垃圾回收器:
spark-submit \ --class com.example.als.ALSTrain \ --master yarn \ --deploy-mode cluster \ --executor-memory 16g \ --driver-memory 8g \ --conf spark.executor.extraJavaOptions="-XX:+UseG1GC -XX:MaxGCPauseMillis=200" \ --conf spark.default.parallelism=500 \ als-demo.jarspark.default.parallelism这个参数经常被忽视,它直接影响每一步操作的分区数量。如果分区数远小于 Executor 核心数,计算资源就闲置了;如果分区数过多,调度开销又会增大。经验值:建议设置为核心数的 3-5 倍。
3.2 数据格式的“隐性要求”
ALS 的输入数据是一个 DataFrame,最基本的格式是三列:
| 列名 | 类型 | 含义 |
|---|---|---|
| userId | Int | 用户 ID(必须是数值型,且从 0 开始建议连续编码) |
| itemId | Int | 物品 ID(必须是数值型,建议从 0 开始连续编码) |
| rating | Float/Double | 交互值(显式为评分,隐式为次数/时长等) |
关于 ID 编码,这是个非常重要的细节。ALS 内部会把用户 ID 和物品 ID 映射为索引,虽然 API 层会自动做转换,但如果你的原始 ID 是字符串(比如 UUID),建议在数据预处理阶段就转成连续的整数。好处有两个:一是能减少 shuffle 时的序列化开销;二是训练生成的因子矩阵行号和 ID 的对应关系更清晰,后续做在线召回时不容易对错位。
数据规模评估也是必须做的前置工作。ALS 的训练复杂度大约随交互数线性增长(每轮迭代),随特征维度(rank)线性增长。如果你的交互数据有 5 亿条,rank 设 20,那么单轮迭代的中间结果就要占掉不少内存。建议在跑全量数据之前,先取 1% 的样本做小规模验证,确认代码逻辑和参数范围合理后再放全量。这一步能帮你省掉很多无意义的失败尝试和排查时间。
4. Spark ALS 的核心 API 使用与完整训练代码框架
官方提供的ALS类在org.apache.spark.ml.recommendation包下。我贴一份我项目里常用的训练代码骨架,读者可以在自己环境上直接改改就能跑。注意,我用的是 ML 包(DataFrame 接口),不是老的 MLlib RDD 接口,两者的结果和性能有差异,新项目直接上 ML 版就好。
import org.apache.spark.sql.SparkSession import org.apache.spark.ml.recommendation.ALS import org.apache.spark.ml.evaluation.RegressionEvaluator val spark = SparkSession.builder() .appName("ALSRecDemo") .getOrCreate() // 读取数据,假设格式为:userId,itemId,rating val ratings = spark.read .option("header", "true") .option("inferSchema", "true") .csv("hdfs:///path/to/ratings.csv") // 划分训练集和验证集 val Array(train, test) = ratings.randomSplit(Array(0.8, 0.2), seed = 42L) // 构建 ALS 模型 val als = new ALS() .setMaxIter(10) // 迭代轮数,不是越大越好,后面细说 .setRegParam(0.01) // 正则化系数 λ .setRank(20) // 隐因子维度 .setUserCol("userId") .setItemCol("itemId") .setRatingCol("rating") .setImplicitPrefs(true) // 如果是隐式反馈数据,这里要置为 true .setColdStartStrategy("drop") // 处理测试集里新用户/新物品的策略 // 训练模型(本质上是交替迭代求解 U 和 V) val model = als.fit(train) // 预测测试集评分 val predictions = model.transform(test) // 评估 RMSE(显式反馈常用)或使用自定义排序指标(隐式反馈常用) val evaluator = new RegressionEvaluator() .setMetricName("rmse") .setLabelCol("rating") .setPredictionCol("prediction") val rmse = evaluator.evaluate(predictions) println(s"Root-mean-square error = $rmse")这里面有两个参数值得单独强调。
第一是setImplicitPrefs(true)。如果数据是隐式反馈,这一项必须设置为true。一旦设置为 true,ALS 内部的损失函数和置信度机制都会切换成隐式模式:原始交互值 0/1 会被映射为置信度权重,未交互的条目参与训练(作为 0),而不是简简单单地“忽略缺失值”。不少初学者在这个地方栽过跟头——拿隐式数据训练,却忘了开这个开关,导致模型效果奇差无比。
第二是setColdStartStrategy("drop")。这参数处理的是测试集或线上数据中出现了训练时没见过的用户或物品的情况。如果不处理,预测时会返回 NaN。上生产时我会把策略设为"drop"先保证线上不出 NaN;对于这些真正的冷启动用户,就交给其他策略(比如热门兜底)去接住,而不是强求 ALS 给出预测。
5. 训练完怎么用:从因子矩阵到推荐列表的完整链路
模型训练完成之后,真正的挑战才开始。怎么把 U 矩阵和 V 矩阵变成产品里用户看到的推荐结果?这中间有不少容易出错的操作细节。
5.1 拿到用户因子和物品因子
Spark ML 的 ALS 模型训练产物是ALSModel类的实例,它内部就有userFactors和itemFactors两个 DataFrame。你可以直接把它们缓存下来,供后续推荐逻辑使用。
val userFactors = model.userFactors .withColumnRenamed("features", "userFeatures") val itemFactors = model.itemFactors .withColumnRenamed("features", "itemFeatures")值得留意的是,这两个 DataFrame 的id列就是 ALS 内部重新编码过的索引,不是你原始 CSV 里的用户 ID。所以如果你需要把预测结果映射回原始业务 ID,一定要保留一份从原始 ID 到索引 ID 的映射表。
推荐候选集的生成通常分两种方式:
- 用户定向推荐:给定一个用户 ID,取它的因子向量 ( u_i ),然后与物品因子矩阵中的每个向量 ( v_j ) 做点积,按得分从高到低取前 N 个。
- 物品相似推荐:给定一个物品 ID,利用物品因子向量间的余弦相似度或点积,找出最相似的物品集合。这本质上就是一种 item2item 的召回。
5.2 批量召回:全量打分其实很贵
如果你天真地想用 DataFrame 把“所有用户 × 所有物品”的全部得分都算出来,在千万用户、千万物品级别的规模下,这就是个天文数字般的计算量——百万亿级的点积,任何分布式框架都扛不住。
实际工程里更常见的做法是,先粗略召回一个候选集,再在候选集上精排。比如每个用户先通过规则(热门、同品类、协同召回)圈定几千个物品,再用 ALS 因子在这个范围里做点积;或者使用近似近邻搜索工具(如 Faiss )直接为每个用户向量在物品向量库里查 Top-N 近邻。后者的性能和效果平衡得更好,也是很多大厂落地向量召回的标准姿势。
// 示例:取前100个活跃用户,为它们生成候选打分(示意,实际要用高效方法) val sampleUsers = userFactors.limit(100).cache() val sampleItems = itemFactors.limit(1000).cache() val scored = sampleUsers.crossJoin(sampleItems).map { row => // 计算点积... }.sortByScore.take(10)千万别把这种 crossJoin 直接用在生产环境——它会引发爆炸性的 shuffle 和计算量,分分钟把集群拖垮。上面代码只是演示计算逻辑,不代表生产姿势。
6. 模型评估不能只看 RMSE:几套指标配合使用才靠谱
评估是模型训练中最容易被低估的环节。很多教程只教你看 RMSE,但在真实推荐系统里,RMSE 低并不代表推荐效果好。
6.1 显式反馈场景用 RMSE/MAE
如果是评分预测任务(比如预测用户会给电影打多少分),RMSE 和 MAE 是合理的评估维度。但要注意,评分预测的误差并不能完全转化为推荐列表的质量。一个模型 RMSE 低了 0.05,推荐出来的 Top-10 列表可能并没有变得更好,因为用户真正在意的不是“预测分准不准”,而是“排在前面的东西我喜不喜欢”。
6.2 隐式反馈场景优先看排序指标
对于点击、购买这类隐式数据,我更习惯用Precision@K、Recall@K、NDCG@K这类排序指标。思路是:为每个测试用户遮挡他的一部分历史交互,用训练好的模型对全量物品打分排序,看被遮挡的那些物品能不能排到 Top-K 里去。
如果你想要一个更省力的评估方式,也可以用 Spark 自带的RankingMetrics。它是基于每个用户实际交互物品集合和模型预测物品列表的排序质量进行计算的。代码示例:
import org.apache.spark.mllib.evaluation.RankingMetrics // 对每个用户生成 groundTruth 集合和 predicted 排序列表 val predictionAndLabels = scoredList.map { case (userId, predictedItems, actualItems) => (predictedItems.asInstanceOf[Seq[Any]], actualItems.asInstanceOf[Seq[Any]]) } val metrics = new RankingMetrics(predictionAndLabels) println(s"Precision@10 = ${metrics.precisionAt(10)}") println(s"NDCG@10 = ${metrics.ndcgAt(10)}")6.3 离线指标和线上指标之间的温差
这是我想专门提醒的:离线指标提升,不等于线上点击率提升。原因在于离线评估的“公平性”假设(数据分布稳定、用户行为被完整观察)在线上是松弛的;且离线结果再好,也抵不过推荐位、文案、时间上下文这些因素的干扰。所以我的原则是:离线指标用来筛方向和调参,最终决策还是要看线上 AB 实验。
7. 调参与迭代优化:rank、regParam、alpha、maxIter 之间的博弈
这一节是整篇文章的“避坑集锦”,也是我和团队从无数次实验里摸出来的经验总结。
7.1 关键参数的影响和推荐范围
| 参数 | 作用 | 推荐范围 | 我的经验 |
|---|---|---|---|
| rank | 隐因子维度,决定模型表达容量 | 10~200 | 起点设 20,逐步加大;维度太高容易过拟合,训练时间和内存也大涨 |
| regParam | 正则化系数 λ | 0.001~0.1 | 先用 0.01 起步;数据越稀疏,正则化应该越大 |
| alpha | 隐式反馈置信度缩放系数 | 10~40 | 我常用 20;太小则置信度区分度不够,太大则头部交互样本压制其他样本 |
| maxIter | 最大迭代轮数 | 5~20 | 10 是个不错的起点,20 以后收益衰减明显 |
| implicitPrefs | 是否隐式反馈 | false/true | 由数据决定,别搞错 |
7.2 参数敏感性:rank 和 regParam 的耦合效应
rank 和 regParam 不是孤立的。rank 越大,模型表达能力越强,越容易过拟合,这时需要更大的 regParam 来压。如果你发现训练集上 RMSE 很低但验证集上很差,先别急着加 regParam,试试减小 rank 往往更有效。我在某次项目中,将 rank 从 100 降到 40,配合调节 regParam,验证集上的 NDCG 反而提升了近 5 个百分点,训练时间也缩短了好几个小时。
数据稀疏程度也会强烈影响参数选择。如果平均每个用户的交互记录只有十几条,低 rank(10~30)的表现通常优于高 rank;如果数据相对稠密(每个用户有上百条交互),rank 可以适当上调到 50~100。这背后逻辑是:数据里包含的信号量本来就有限,硬要拟合更高维度的隐空间,只会学到噪声。
7.3 隐式反馈的数据缩放与 alpha 设置的联动
对于隐式反馈数据,直接拿原始交互次数当 rating 带进去并不合适。比如:用户 A 点击了 100 次商品的详情页,用户 B 只点击了 2 次,这 50 倍的差异真的代表 A 的喜欢程度是 B 的 50 倍吗?显然不是。
我通常的做法是:对原始次数做一个 log 变换,即 ( rating = \log(1 + count) ),将次数差异压缩到一个更合理的尺度。在此基础上再配合 alpha 参数。实测下来,这种预处理对稳定模型效果、减少尾部极端值影响的效果非常明显。
7.4 早停法与迭代过程中的注意点
maxIter 设到 10 不一定意味着算法会跑满 10 轮。我在训练日志里经常看到,某些数据集 5~6 轮时 loss 就几乎不再下降了。这时并不是 bug,而是模型已经收敛。你可以通过 Spark UI 观察每个 iteration 的 loss 变化曲线,如果连续两三轮没有明显下降,就可以手动终止作业,不用耗满 maxIter。
不过要特别留意,loss 降得快也只是“拟合了当前数据”的快,不代表泛化好。我习惯在每轮迭代后用验证集算一次 NDCG,画出迭代轮数与验证指标的变化曲线。如果发现验证指标先升后降,那说明从那个点开始模型就在过拟合了,此时应把 maxIter 设定在那个拐点附近。这套做法比单纯看 loss 曲线可靠得多。
8. 稳定性问题与生产环境避坑指南
这一节内容从真实生产故障中来,每一类我都亲自排查过,建议你仔细看完,能省不少半夜爬起来排障的时间。
8.1 偶发的“NaN”预测值从哪里来
ALS 训练时偶尔会产出 NaN 预测值。常见原因有:
- 测试集或线上新用户/新物品在训练集中完全没见过,
coldStartStrategy设成了"drop"但某个环节没能正确过滤掉 NaN(通常是 transform 阶段之后)。解决办法:在 transform 后自行加一条filter($"prediction".isNotNull),别过度信任框架内部过滤。 - 输入数据中存在无穷值之类的不合法数值(rating 字段可能出现 Infinity),这会让 ALS 内部的最小二乘求解的矩阵病态。解决办法:数据清洗阶段统一用
isnan和isinf过滤一遍。 - 正则化过小且迭代过深,导致因子向量范数爆炸,中间结果溢出。解决办法:调大 regParam,或对评分列做标准化。
8.2 数据倾斜:top 用户和爆品如何拖垮整个作业
ALS 迭代过程中,核心操作是按用户分组更新用户因子、按物品分组更新物品因子。如果数据严重倾斜(少数用户交互几十万次,少数爆品被几百万用户交互),那么这些“大 key”所在的分区就会变成数据倾斜的重灾区,导致某些 Executor 忙死、其他 Executor 闲着,整个作业卡在最后几个任务上。
我自己常用的缓解手段有几个:一是对极端活跃用户做降采样,保留其大部分信号但限制单用户交互数上限;二是通过repartition调整分区策略,让热点用户尽量散开;三是用spark.sql.shuffle.partitions配合 AQE 的coalesce特性自动平衡。如果这些手段仍解决不了,考虑对热点用户单独拆出来训练一个模型分支,这样反而更灵活。
8.3 模型更新频率:离线全量训练 + 近线增量更新
ALS 本身是全量离线训练算法,它的计算代价决定了不可能对每次新交互都实时重训练。工程上我的经验是:核心模型每天离线训练一次,中间用近线(Nearline)手段做增量更新(比如把新交互先放进实时召回池,让召回结果按一定比例混入新数据),再配合一个基于规则的在线探索层。这样既保持模型的稳定性,又能快速反应新热内容。
如果业务对实时性要求极高,可以调研增量 ALS 的方案,但复杂度会明显上升。大多数推荐场景下,日级全量更新已经能提供相当不错的时效性,优先把离线链路的稳定性做好,比盲目追求实时训练更划算。
9. 一个完整小案例:从原始日志到 Top-N 推荐结果
把前面所有内容串起来,我在这里给一个从原始日志到最终推荐结果的完整闭环示例,方便你直接对照着自己的项目迁移。
假设原始数据是用户行为日志,包含字段:user_id、item_id、action(枚举:view、cart、buy)、ts。我按下述步骤处理:
- 数据清洗与加权:将行为映射为隐式反馈分值(view 计 1、cart 计 3、buy 计 5),然后做 log 压缩,
rating = log(1 + weighted_count)。 - 编码转换:把原始用户 ID 和物品 ID 映射为 0 开始的连续整数,并把映射表保存成 Parquet 文件。
- 训练验证集划分:按用户维度划分(同一个用户的所有交互只出现在训练集或只出现在验证集),避免数据穿越。
- 训练 ALS:设定
rank=40、regParam=0.02、alpha=20、maxIter=10,implicitPrefs=true。 - 评估与调参:计算 NDCG@20,反复调整参数至验证集指标稳定。
- 生成候选集:对每个用户,用近似近邻工具在物品因子库中查 Top-100 相似物品。
- 业务规则后处理:过滤已购买的、下架的、不合规的物品,按业务运营位补入热门兜底。
- 写入在线存储:把每个用户的 Top-N 列表写入 Redis 缓存,供推荐服务读取下发。
整个流程在中等规模数据上(几千万交互),一次全量训练的时间普遍在半小时到两小时之间。如果是初次搭建,建议先从这个规模起步,验证好每个环节的正确性再逐步扩大。
回头看我做过的这些推荐项目,ALS 的定位更像是一个“稳定的基本盘”。它不像深度学习模型那样需要大量调参和特征工程,但能持续提供不错的召回基础。调参、评估、上线这一整套流程里,最重要的其实是建立自己的评估闭环,不断验证离线假设在线上是否成立。只要把这条链路跑顺了,ALS 的维护成本低到你几乎可以忘记它在后台运行。如果之后你们的数据规模成长到亿级交互以上,再考虑引入更深层的模型做排名,把 ALS 作为底层的召回模块继续保留,通常是性价比很高的组合。