矩阵分解推荐系统实战:从协同过滤到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之间但它的合适范围跟数据量关系很大。如果每个用户的平均评分数量很多模型有足够证据学出可靠的参数λ可以调小一些让模型更贴近数据如果数据很稀疏比如很多用户只有三五条评分λ就要稍微加大压制模型对这些小样本的过度拟合。可以用一个简单的类比来理解λ是在相信数据和不敢太相信数据之间做权衡。数据量大证据足多信一点数据少证据弱多保收一点。实际操作时我一般先固定F20把λ分别跑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, F20, epochs30, lr0.01, lam0.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(fepoch {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 评估时别只盯RMSEhit rate等指标也要看矩阵分解论文里使用的评估指标主要是RMSE因为它衡量的是预测分数和真实分数的平均偏差。RMSE直观、透明但有一个潜在问题推荐系统最终要交付的是排序结果而不是把每个分数都猜准。举例来说一个用户的实际评分是A电影4分B电影2分。模型预测A电影3分B电影1分。RMSE算下来两个都是偏差1但排序对了——推荐给用户时应该把A排在前面另一个模型把A预测成2分B预测成4分RMSE可能也差不多但排序完全反了。用户看到的结果是天差地别的。所以真实项目里除了看RMSE还要看recallk、命中率这类排序指标或者直接看推荐列表里用户实际点击了哪些、看了多久。矩阵分解优化的是均方误差不等于优化点击率这一点想通之后你就会明白为什么工程上经常要叠加排序模型或者换用专门的排序风格损失函数。我最后再分享一个实际操作中的体会读这篇论文最有价值的不是记住公式而是理解它把推荐看成参数估计的视角。你可以从最基础的SGD版本开始在数据上跑通观察验证集曲线和过拟合形态然后试着把隐式反馈加进去看看误差变化再往后把时间效应纳入偏置。每往前走一步你对公式里每一项存在的理由都会加深一层。先跑起来比反复琢磨所有理论细节有效得多。