ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

矩阵分解推荐系统实战:从协同过滤到SGD梯度下降原理与实现

矩阵分解推荐系统实战:从协同过滤到SGD梯度下降原理与实现

如果把推荐系统相关的经典论文排个"被引用最多、被误读也最多"的榜单,这篇《Matrix Factorization Techniques for Recommender Systems》绝对能进前三。我第一次读它的时候刚被各种协同过滤公式折磨完,看完第一反应是:就这么简单?等自己手推完梯度、复现完模型、在数据上跑了好几轮,才发现那些看起来平平无奇的偏置项和正则化里全是文章。这篇文章的目标很明确:把论文里的矩阵分解从"读过"变成"会用",适合正在学推荐算法、准备做推荐项目、或者想弄明白MF到底怎么落地的人。我会把论文的核心思想拆开讲,再补上那些论文里没细说、但实操时一定会踩的坑。

1. 一场算法竞赛把协同过滤逼到了墙角

1.1 近邻方法的两座大山:稀疏性与计算成本

在矩阵分解真正流行之前,推荐系统的主流做法是协同过滤里的近邻方法。逻辑很好理解:想给用户推荐电影,先找一批和他口味相似的用户,看这些用户看过什么、打过分,再把相似用户喜欢的东西推荐出去。另一种是物品近邻,预测用户对某个电影的打分时,找这个电影在历史上的"邻居"——用户已经打过分的、和它相似的电影,用这些分数的加权平均来估计。

这两种方法在当时都挺有效,但放到那场知名流媒体平台举办的推荐算法竞赛里,瓶颈很快暴露出来。最要命的是稀疏性:一个常规平台,用户的评分往往只覆盖全部物品的百分之一甚至千分之一,绝大多数用户和物品之间的评分是空的。近邻方法要在稀疏矩阵上算相似度,结果就是很多相似度靠少数几条重合评分撑起来,噪声非常大,算出来的邻居不一定是真邻居,预测分数自然也不稳。另一个问题是计算成本。用户和物品数量一旦到百万级,两两算相似度再存成邻居表,存储和更新都是大麻烦,线上要实时找出K个邻居更吃力。

那场比赛的参赛者逐渐意识到:靠显式地去"找相似"这条路,越往后走越难。矩阵分解恰恰绕开了"相似度"这个中间步骤,直接对评分矩阵本身做压缩,一下子成了整个竞赛的核心武器。

1.2 矩阵分解的"拆解"思路为什么能绕开这两座大山

矩阵分解的核心想法是:别再把评分矩阵当成一张需要逐格填写的表,而是把它拆成两个矮胖矩阵的乘积。假设有n个用户、m个物品,评分矩阵R的大小是n乘m,里面大量缺值。矩阵分解假设R可以近似写成P乘以Q的转置,P是n乘F的用户隐因子矩阵,Q是m乘F的物品隐因子矩阵,F远小于n和m。

这个近似的意思是说:虽然表面上评分有成千上万个组合,但真正决定用户会不会给某部电影高分的原因,可能只有几十个潜在维度。维度可能是"喜欢动作片还是文艺片""偏好特效还是偏好剧情""能不能接受沉闷叙事"这一类说不清楚、但藏在评分背后的因素。用户在每个维度上有一个偏好系数,物品在每个维度上有一个特征系数,两者点积就得到预测分。

拆解之后,稀疏性问题被天然绕开了一部分。用户没有评分过的物品,照样可以通过用户向量和物品向量计算预测分,不再依赖"必须有一群重合评分才能算相似度"。计算成本也降下来了,预测一个用户对某个物品的打分,只是两个长度为F的向量做点积,F一般只有几十维,比在大规模稀疏矩阵里找邻居快得多。而且得到的是隐因子,相当于自动从数据里学出了用户的兴趣画像,这一点后面会细讲。

2. 隐因子向量:把评分矩阵压缩成两份"性格档案"

2.1 从SVD说到矩阵分解,差在哪一步

很多资料会把矩阵分解和SVD放在一起讲,我最初也以为矩阵分解就是SVD的别名,其实它们在推荐场景下差别很大。传统SVD要求矩阵是稠密的,分解前必须把缺失值填掉。有人会填成平均值,有人会填成0,但无论怎么填,都等于在伪造信息:把本该是空白的评分当成"用户不喜欢"或者"用户没看过但会打平均分",整个分解结果会被这些假数据污染。而且稠密矩阵的SVD在用户物品都是百万级的时候,计算量基本不可接受。

论文里说的矩阵分解走的是完全不同的路:不把缺失值当作需要填补的数据,而是当作预测目标。训练的时候只看有真实评分的那些位置,用现在学到的P和Q去预测这些位置上的分数,跟真实分数比较,算误差,然后反向调整P和Q,让误差变小。缺失位置不参与计算,模型在"看不见"缺失值的情况下,通过已有评分模式泛化出对未知位置的预测。

这一点理解错了,后面全都会偏。我见过不少新手照着SVD的教程把整个矩阵填零硬分解,跑出来的效果好一点都算运气。矩阵分解真正的优雅之处就在于它只对观察到的评分建模,把推荐问题从矩阵补全变成参数估计问题。

2.2 向量点积的直观含义:口味与特性的契合度

隐因子向量的实际含义可能比公式看起来更有意思。假如我们把用户的向量想象成一份性格档案,里面记录了用户在每个潜在维度上的偏好强度;物品的向量则是物品在每个维度上的"表现力"。用户向量和物品向量做点积,得到的就是两者在这个隐空间里的契合度。

可以打个比方,相亲的时候两个人合不合适,通常看几个方面:兴趣爱好、生活节奏、价值观、沟通方式。你在这几个方面给自己打个分,对方也在这个几个方面给自己打个分,然后逐项相乘再求和。相合的分高,不合的分低,这就是点积匹配。矩阵分解学出来的隐因子没有明确名字,但经常能对应到一些可解释的模式,比如某个维度的物品权重集中在特效大片上,用户权重高的人就爱看这类片。

论文在基础预测公式里还加入了全局平均分、用户偏置和物品偏置三个项。为什么需要偏置?因为有些人天生打分苛刻,习惯性地只给2到3星;有些电影本身质量过硬,大片上线时普遍能拿到比较高的分数。如果一个用户普遍比平均分低1.2分,那他的b_u就接近-1.2;一部电影普遍比平均分高0.8分,它的b_i就接近0.8。预测时先把这些"常识性偏差"算清楚,剩下的部分才交给隐因子向量去解释。

3. 预测公式到梯度下降:手把手把SGD推导一遍

3.1 基本预测与误差平方损失

论文里最基础的预测公式长这样:

r̂_ui = μ + b_u + b_i + p_u^T q_i

  • μ是全部评分的全局均值
  • b_u是用户偏置,表示这个用户比平均用户苛刻还是宽容
  • b_i是物品偏置,表示这个物品比平均物品更受欢迎还是不受欢迎
  • p_u是用户u的隐因子向量
  • q_i是物品i的隐因子向量

有了预测公式,接下来要定义"预测得不好"的代价。论文用的还是经典的平方误差损失,目标函数可以写成:

min ∑ (r_ui - r̂_ui)^2 + λ (||p_u||^2 + ||q_i||^2 + b_u^2 + b_i^2)

前一项衡量预测误差,后一项是L2正则化,防止参数长得太大,也防止模型在稀疏数据上过度自信。这里要稍微停下来想清楚:为什么连b_u和b_i都要加正则?因为在评分稀疏的时候,有些用户只有一两条评分,模型很容易为了拟合这一两条评分把b_u调到极大值。加了正则项,参数一旦变大就会付出代价,模型会更愿意用"全局规律"去解释数据,而不是记住每一个特例。

3.2 L2正则、偏置项加入后的完整推导

这部分我当初硬是被网上各种符号绕晕过,所以这里不省略,一步步来。记误差项为e_ui = r_ui - r̂_ui,对参数求梯度。

先看p_u的梯度。注意到中等式里有p_u^T q_i,而e_ui本身也包含p_u,所以:

∂L/∂p_u = -e_ui * q_i + λ * p_u

同理,对q_i的梯度:

∂L/∂q_i = -e_ui * p_u + λ * q_i

偏置项的梯度更简单:

∂L/∂b_u = -e_ui + λ * b_u

∂L/∂b_i = -e_ui + λ * b_i

梯度下降的更新规则就是沿着负梯度走一步,步长是学习率η。写出来是:

  • p_u ← p_u + η * (e_ui * q_i - λ * p_u)
  • q_i ← q_i + η * (e_ui * p_u - λ * q_i)
  • b_u ← b_u + η * (e_ui - λ * b_u)
  • b_i ← b_i + η * (e_ui - λ * b_i)

注意符号方向:求梯度时前面带的是负号,所以更新时要用e_ui而不是-e_ui。如果哪个资料里的更新式子和你推导的差了一个负号,先检查它的损失函数是否做的是最大化。

3.3 更新规则的工程细节:梯度更新顺序、学习率

理论推导完了,工程上还藏着两个小细节。

第一个是参数更新顺序。对同一条评分,计算完e_ui之后,p_u和q_i的更新实际上会相互影响。代码里如果先更新了p_u,再拿着已经变过的p_u去更新q_i,梯度就不再是原来那个位置上的梯度了。稳妥做法是用更新前的旧p_u去更新q_i,也就是先把p_u复制一份,更新完p_u之后,用旧值更新q_i。很多教程的伪代码不会提这件事,实际跑起来一般不会有天翻地覆的差别,但在早迭代时期,梯度比较陡,稍一疏忽就可能让损失曲线出现诡异的震荡。

第二个是学习率的选择。论文常见的设置是在0.005到0.02之间,太大容易来回震荡,太小则收敛慢。我习惯的做法是每个epoch结束之后在验证集上算一次误差,如果验证误差连续两三个epoch不降反升,就把学习率减半,同时把当前这轮参数保存成最终结果,相当于一个非常朴素的早停。

4. 容易被跳过的四个参数细节,直接影响模型好坏

4.1 F:隐因子数量不是越大越好

隐因子数量F是矩阵分解里最直观、也最容易调坏的一个参数。按直觉想,隐因子越多,模型表达能力越强,应该更好。但实际情况是:F太小时模型欠拟合,评分模式学不到位;F太大时模型开始记住每个用户和物品的评分历史,验证集误差反而上升。论文里报告实验时常见范围是10到50,个别场景会到100,但收益曲线往往是先快速上升再平缓甚至回跌。

我在自己数据上做了一组对照,评分范围1到5,同样的训练轮数和正则系数下,F从5提到20,验证集RMSE有明显下降;从20提到50,降幅很小;提到100,验证集误差不降反增,训练时间却涨了一倍以上。所以如果你只是要一个能用的推荐模型,从20维起步通常是性价比较高的选择。具体最优值还是要靠验证集判断,没有哪个数字能包打天下。

4.2 λ:正则化系数和用户可见数据量的关系

正则化系数λ是最容易让人纠结的参数。论文里常见的参考值在0.02到0.1之间,但它的合适范围跟数据量关系很大。如果每个用户的平均评分数量很多,模型有足够证据学出可靠的参数,λ可以调小一些,让模型更贴近数据;如果数据很稀疏,比如很多用户只有三五条评分,λ就要稍微加大,压制模型对这些小样本的过度拟合。

可以用一个简单的类比来理解:λ是在"相信数据"和"不敢太相信数据"之间做权衡。数据量大,证据足,多信一点;数据少,证据弱,多保收一点。实际操作时我一般先固定F=20,把λ分别跑0.02、0.05、0.1三档,看验证集误差选一个,再回头微调F。这样两个参数交替调,比同时乱试高效很多。

4.3 学习率、初始化与迭代次数的配合

很多初学者会把注意力全放在学习率上,忽略了初始化和迭代次数同样关键。初始化时如果随机数范围太大,比如用标准正态分布初始化隐向量,一开始预测值可能离真实评分非常远,早期梯度极大,模型很容易在几次迭代里冲出合理区域。论文和常见实现里通常会用均值为0、标准差0.1左右的小随机数来初始化P和Q,这样起点不至于太离谱。

迭代次数也不是越多越好。最直观的现象是训练集RMSE持续下降,看起来一切顺利,但验证集RMSE在某个拐点之后开始缓慢上升,这就是过拟合信号。我的做法是把训练和验证误差曲线画在同一张图上,验证误差开始连续两轮不降,就停止训练并回滚到最优点。这个做法和神经网络里的early stopping是一个道理。

4.4 隐式反馈:论文后半段其实讲了一个更实用的变体

论文前半段的经典矩阵分解只处理显式评分,比如用户打的1到5星。但实际场景里,更丰富的用户行为往往是隐式的,比如有没有看过某部电影、停留了多久、有没有点开页面。这些行为没有明确的分数,但包含了"用户对这个东西有没有兴趣"的信号。

论文相关章节里有个重要想法:把隐式反馈作为辅助信号融合进用户向量,而不是只用显式评分。典型做法是在预测公式里增加一个聚合项,把用户u所有产生过隐式行为的物品向量做加权求和,再拼到用户向量上。可以这样理解:用户的真实兴趣 = 他明确打过分的东西体现出来的偏好 + 他看过、点过的所有东西体现出来的隐性偏好。后者在没有评分的时候尤其有用。

这个变体后来在实际工程里用得非常多,因为大多数产品里,评分数据非常稀缺,而点击、浏览、观看这类行为是海量的。论文单独把隐式反馈拎出来讲,说明作者很早就意识到:光靠打分矩阵,模型的天花板很快会到,必须把行为数据也用起来。

5. 复现一个最小可用的推荐模型:代码与实测记录

5.1 数据准备:评分矩阵和训练/验证切分

读论文只推导公式是不够的,动手复现一遍才算真正理解。我用的数据来自一个公开的影评数据集,格式就三列:用户ID、物品ID、评分。评分范围1到5,总计大概几十万条记录,足够观察训练和验证误差的走势了。

数据切分不能乱来。我采用了随机按条目切分:所有评分记录随机打乱,80%作为训练集,20%作为验证集。这里比用户维度的切分更简单直接,但如果要严格评估冷启动效果,就需要按用户或按时间切分,让验证集里的用户或物品不出现在训练集里。一般实验可以先随机切分,快速验证模型代码正确性,之后再按生产场景的需求做更严格的切分。

另外一个容易忽略的点是:把评分原样传入模型,还是先做归一化?如果用原始1到5分,加上偏置项和隐向量,输出的预测值通常也能落在合理范围,不需要额外归一化。但训练过程中要盯住数值范围,如果预测值动不动超过10或者变成负的大数,先怀疑学习率太大或初始化范围太宽。

5.2 核心训练代码(Python实现)

这里给出一个带偏置项和L2正则的SGD矩阵分解最小实现。代码刻意写得精简,方便对照公式。

import numpy as np from math import sqrt def train_sgd(ratings, n_users, n_items, F=20, epochs=30, lr=0.01, lam=0.05): # ratings: list of (user_id, item_id, rating) np.random.seed(0) P = np.random.normal(0, 0.1, (n_users, F)) Q = np.random.normal(0, 0.1, (n_items, F)) b_u = np.zeros(n_users) b_i = np.zeros(n_items) mu = np.mean([r for _, _, r in ratings]) for epoch in range(epochs): np.random.shuffle(ratings) total_train_loss = 0 for u, i, r in ratings: pred = mu + b_u[u] + b_i[i] + P[u].dot(Q[i]) err = r - pred b_u[u] += lr * (err - lam * b_u[u]) b_i[i] += lr * (err - lam * b_i[i]) pu_old = P[u].copy() P[u] += lr * (err * Q[i] - lam * P[u]) Q[i] += lr * (err * pu_old - lam * Q[i]) total_train_loss += err ** 2 train_rmse = sqrt(total_train_loss / len(ratings)) # 在这里可以计算验证集RMSE,并记录最优参数 print(f"epoch {epoch + 1}, train RMSE = {train_rmse:.4f}") return P, Q, b_u, b_i, mu

对照第3节的公式,这段代码基本是一一对应的。注意我特意写了pu_old = P[u].copy(),然后用pu_old去更新Q[i],原因在前面说过:防止用已经被当前样本更新过的P[u]去计算Q[i]的梯度。

5.3 我在训练中遇到的三个实际问题

这一段写得绝对真实,因为都是我自己踩过的坑。

第一个坑就是忘记复制P[u]。当时偷懒直接写P[u] += ...,下一行更新Q[i]时用的P[u]已经是新值。跑出来的训练误差曲线在早期特别不流畅,偶尔还会先降后升再降,像抽风一样。后来加了一行copy,曲线立刻变得非常平滑。

第二个坑是只用训练误差判断模型好坏。第一版代码每轮结束后只打印训练RMSE,眼睁睁看着误差从1.3一路降到了0.6,心里还挺高兴。后来加上验证集RMSE才发现,验证曲线在0.82左右就触底了,之后一直在缓慢攀升。从那一刻起我再也不迷信训练误差,每轮结束必看验证集。

第三个坑和评价尺度有关。我一开始把评分归一化到0到1再训练,验证集也在0到1的尺度上计算RMSE,大概在0.32附近。后来一个同事问真实评分尺度下误差是多少,我把预测结果还原回1到5之后一算,RMSE变成了0.81。如果你看到网上经验贴说"RMSE 0.8算正常",一定先确认他用的评分尺度是不是1到5。这个0.8不是魔法数字,而是评分分布本身的属性。理论上下限取决于这个评分数据集里用户的一致性。

6. 从论文走向工程:冷启动、时间效应与评估指标

6.1 冷启动不是论文的主角,却是工程上绕不开的短板

矩阵分解有个很明显的先天短板:新物品没有人打分,它的物品向量Q完全无从学起;新用户的向量P也一样,如果不做任何处理,模型给新用户和新物品的预测全靠偏置项和全局均值,等于一个全局平均分。这在论文里没花太多笔墨,但在真实系统里却是最常见的产品问题。

工程上的处理思路大多不是在MF内部硬解,而是给它加外援。新物品可以用内容特征构造一个初始向量,比如从标题、分类、描述里抽文本向量,作为物品向量的起点;新用户可以把注册时的偏好标签转成初始向量,或者先把他丢到热门榜上,用他看过的东西再去更新向量。换句话说,矩阵分解擅长做"已有行为的人的老推荐",冷启动部分要靠旁路逻辑兜底。

我个人的经验是:上线推荐系统时别指望一个模型解决所有阶段的用户。冷启动用户走规则和热门逻辑,等到行为足够多之后再切换成模型推荐,这种混合策略在工程里最稳。

6.2 论文里的时间效应处理思路:偏置项随时间的浮动

很多做推荐的人会忽略"时间"这个维度,但用户的兴趣和物品的热度几乎永远在变化。一个人去年爱看悬疑,今年可能开始追职场剧;一部电影刚上线时热度极高,三个月后评分环境又完全不同。如果模型把所有这些趋势全部压成一个静态物品偏置,预测误差会慢慢变大。

论文里提到的一个重要思路是让偏置项随时间浮动:比如把时间切成若干个区间,每个区间单独学习b_i;或者把b_u(t)建模成一个关于时间的函数,让用户偏置可以随着时间平缓变化。更彻底的做法是让用户隐向量本身也随时间变化,原理上就是用时间加权或者分段式建模替代固定向量。论文里讲的是"为了提升预测精度,需要考虑时间效应",实际应用里,对新闻、短视频这类时效性极强的场景,时间处理带来的提升往往比把隐因子数量翻倍还明显。

6.3 评估时别只盯RMSE,hit rate等指标也要看

矩阵分解论文里使用的评估指标主要是RMSE,因为它衡量的是预测分数和真实分数的平均偏差。RMSE直观、透明,但有一个潜在问题:推荐系统最终要交付的是排序结果,而不是把每个分数都猜准。

举例来说,一个用户的实际评分是:A电影4分,B电影2分。模型预测A电影3分,B电影1分。RMSE算下来两个都是偏差1,但排序对了——推荐给用户时应该把A排在前面;另一个模型把A预测成2分,B预测成4分,RMSE可能也差不多,但排序完全反了。用户看到的结果是天差地别的。所以真实项目里,除了看RMSE,还要看recall@k、命中率这类排序指标,或者直接看推荐列表里用户实际点击了哪些、看了多久。矩阵分解优化的是均方误差,不等于优化点击率,这一点想通之后,你就会明白为什么工程上经常要叠加排序模型或者换用专门的排序风格损失函数。

我最后再分享一个实际操作中的体会:读这篇论文,最有价值的不是记住公式,而是理解它"把推荐看成参数估计"的视角。你可以从最基础的SGD版本开始,在数据上跑通,观察验证集曲线和过拟合形态;然后试着把隐式反馈加进去,看看误差变化;再往后,把时间效应纳入偏置。每往前走一步,你对公式里每一项存在的理由都会加深一层。先跑起来,比反复琢磨所有理论细节有效得多。

RELATED READING

延伸阅读

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