.. _matrix_factorization: 矩阵分解 ======== 在推荐系统中,我们经常观察到这样的规律:喜欢《理智与情感》的用户往往也会给《公主日记》好评,而钟爱《致命武器》的观众通常也喜欢《独立日》。这反映了用户偏好的内在结构——一些隐含的“品味因子”在起作用。比如有些用户偏好面向女性的影片,有些则更喜欢面向男性的内容;有些人倾向于严肃深刻的作品如《阿马迪斯》,有些人则更享受轻松娱乐的片子如《阿呆与阿瓜》。 在前面的章节中,我们了解了UserCF和ItemCF这些基于邻域的协同过滤方法。它们的思路很直观:通过寻找相似的用户或物品来进行推荐,就像 :numref:`mf_usercf_illustration` 展示的那样。 .. _mf_usercf_illustration: .. figure:: ../../img/mf_usercf_illustration.svg 基于用户行为统计的方法。张三喜欢左边的三部电影。为了对他进行预测,系统会找到也喜欢这些电影的相似用户,然后确定他们还喜欢哪些其他电影。在这种情况下,所有三位用户都喜欢《拯救大兵瑞恩》,因此这是首个推荐。接着,其中两位用户喜欢《沙丘》,所以它排在第二位,依此类推。 但这种方法有个致命弱点:当评分数据非常稀疏时,很难找到足够的相似用户或物品。想想看,在一个有百万用户和十万电影的系统中,大部分用户只看过其中几十部电影,传统方法很可能因为共同评分太少而失效。 这时候,矩阵分解登场了。它不再直接寻找相似性,而是换了个思路:假设用户的偏好和电影的特征都可以用几个关键因子来描述。比如,我们可以用“面向男性vs面向女性”和“严肃vs逃避现实”这两个维度来刻画电影,同时用用户对这两类特征的偏好程度来描述用户。这样,预测一个用户对某部电影的评分就变成了计算这两个向量的相似度。 .. _mf_illustration: .. figure:: ../../img/mf_illustration.svg 隐语义模型意图,该方法通过两个维度来刻画用户和电影:一个是面向男性与面向女性,另一个是严肃与逃避现实。 矩阵分解的核心想法建立在两个关键假设上: - 第一个是低秩假设:虽然评分矩阵看起来很复杂,但实际上可能只受少数几个隐含因素影响,比如“面向男性vs面向女性”、“严肃vs逃避现实”等维度。 - 第二个是隐向量假设:每个用户和每部电影都能用一个包含这些隐含因子的向量来表示,用户向量反映了其对各种因子的偏好程度,而电影向量则描述了该电影在各个因子上的特征强度。 这种做法的好处是显而易见的:即使两个用户没有看过相同的电影,只要他们在隐含因子上表现相似,我们就能为他们推荐相似的内容。这大大提高了模型处理稀疏数据的能力。 接下来我们看看如何把这个想法变成具体的算法。我们将介绍两种矩阵分解模型:简单直接的\ **基础模型**\ (FunkSVD)和考虑评分偏差的\ **改进模型**\ (BiasSVD)。 FunkSVD: 基础模型 ----------------- FunkSVD 由 Simon Funk 在2006年提出 :cite:`funk2006svd`\ ,是矩阵分解家族中最容易理解的一个。它的想法非常直接:把复杂的用户-物品评分矩阵分解成两个简单的矩阵——用户特征矩阵和物品特征矩阵。 假设我们有\ :math:`m`\ 个用户和\ :math:`n`\ 个物品,想要用\ :math:`K`\ 个隐含因子来描述它们。那么用户\ :math:`u`\ 可以用一个\ :math:`K`\ 维向量\ :math:`p_u`\ 来表示,物品\ :math:`i`\ 也可以用一个\ :math:`K`\ 维向量\ :math:`q_i`\ 来表示。预测用户\ :math:`u`\ 对物品\ :math:`i`\ 的评分就是这两个向量的内积: .. math:: \hat{r}_{ui} = p_u^T q_i = \sum_{k=1}^{K} p_{u,k} \cdot q_{i,k} 这里\ :math:`p_{u,k}`\ 表示用户\ :math:`u`\ 在第\ :math:`k`\ 个隐含因子上的偏好程度,\ :math:`q_{i,k}`\ 表示物品\ :math:`i`\ 在第\ :math:`k`\ 个隐含因子上的特征强度。 现在问题变成了:如何找到这些隐含因子?我们采用一个很自然的思路——让预测评分尽可能接近真实评分。具体来说,我们要最小化所有已知评分的预测误差: .. math:: \min_{P,Q} \frac{1}{2} \sum_{(u,i)\in \mathcal{K}} \left( r_{ui} - p_u^T q_i \right)^2 这里\ :math:`\mathcal{K}`\ 表示所有已知评分的用户-物品对,\ :math:`r_{ui}`\ 是用户\ :math:`u`\ 对物品\ :math:`i`\ 的真实评分。 要解决这个优化问题,我们使用梯度下降法。对于每个观测到的评分,我们先计算预测误差\ :math:`e_{ui} = r_{ui} - p_u^T q_i`\ ,然后沿着误差减小的方向更新参数: .. math:: p_{u,k} \leftarrow p_{u,k} + \eta \cdot e_{ui} \cdot q_{i,k} .. math:: q_{i,k} \leftarrow q_{i,k} + \eta \cdot e_{ui} \cdot p_{u,k} 其中\ :math:`\eta`\ 是学习率,控制每次更新的步长。这个更新规则的直觉很简单:如果预测评分偏低了(\ :math:`e_{ui} > 0`\ ),我们就增大相关的参数值;如果预测偏高了,就减小参数值。 不过在实际应用中,我们通常还会加入L2正则化来防止过拟合: .. math:: \min_{P,Q} \frac{1}{2} \sum_{(u,i)\in \mathcal{K}} \left( r_{ui} - p_u^T q_i \right)^2 + \lambda \left( \|p_u\|^2 + \|q_i\|^2 \right) 这样可以避免模型过度拟合训练数据,提高在新数据上的表现。 BiasSVD: 改进模型 ----------------- 基础模型虽然简洁,但在实际使用中我们发现了一个问题:不同用户的评分习惯差异很大。有些用户天生就是“好人”,很少给低分;有些用户则比较严格,平均分都不高。同样,有些电影因为制作精良或者明星云集,普遍得到较高评分;而有些冷门或质量一般的电影则评分偏低。 这些系统性的偏差如果不处理,会影响推荐的准确性。BiasSVD :cite:`koren2009matrix` 正是为了解决这个问题而提出的。它在基础模型的基础上引入了偏置项,让预测公式变成: .. math:: \hat{r}_{ui} = \mu + b_u + b_i + p_u^T q_i 这里新增了三个项:\ :math:`\mu`\ 是所有评分的全局平均值,反映了整个系统的评分水平;\ :math:`b_u`\ 是用户\ :math:`u`\ 的个人偏置,反映了该用户相对于平均水平是倾向于给高分还是低分;\ :math:`b_i`\ 是物品\ :math:`i`\ 的偏置,反映了该物品相对于平均水平是受欢迎还是不受欢迎。 相应地,优化目标也要调整: .. math:: \min_{P,Q,b_u,b_i} \frac{1}{2} \sum_{(u,i)\in \mathcal{K}} \left( r_{ui} - \mu - b_u - b_i - p_u^T q_i \right)^2 + \lambda \left( \|p_u\|^2 + \|q_i\|^2 + b_u^2 + b_i^2 \right) 在参数更新时,除了用户和物品的隐向量,我们还需要更新偏置项: .. math:: b_u \leftarrow b_u + \eta \left( e_{ui} - \lambda b_u \right) .. math:: b_i \leftarrow b_i + \eta \left( e_{ui} - \lambda b_i \right) 这种改进看似简单,但效果显著。通过分离出系统性偏差,模型能够更准确地捕捉用户和物品之间的真实交互模式,从而提供更精准的推荐。 代码实践 -------- FunkSVD 在 MovieLens 数据集上的应用 .. raw:: latex \diilbookstyleinputcell .. code:: python import os import sys import funrec from funrec.utils import build_metrics_table # 加载配置 config = funrec.load_config('funksvd') # 加载数据 train_data, test_data = funrec.load_data(config.data) # 准备特征 feature_columns, processed_data = funrec.prepare_features(config.features, train_data, test_data) # 训练模型 models = funrec.train_model(config.training, feature_columns, processed_data) # 评估模型 metrics = funrec.evaluate_model(models, processed_data, config.evaluation, feature_columns) print(build_metrics_table(metrics)) .. raw:: latex \diilbookstyleoutputcell .. parsed-literal:: :class: output +---------------+--------------+-----------+----------+----------------+---------------+ | hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 | +===============+==============+===========+==========+================+===============+ | 0.0033 | 0.0018 | 0.0016 | 0.0011 | 0.0003 | 0.0004 | +---------------+--------------+-----------+----------+----------------+---------------+ BiasSVD 在 MovieLens 数据集上的应用 .. raw:: latex \diilbookstyleinputcell .. code:: python config = funrec.load_config('biassvd') # 加载数据 train_data, test_data = funrec.load_data(config.data) # 准备特征 feature_columns, processed_data = funrec.prepare_features(config.features, train_data, test_data) # 训练模型 models = funrec.train_model(config.training, feature_columns, processed_data) # 评估模型 metrics = funrec.evaluate_model(models, processed_data, config.evaluation, feature_columns) print(build_metrics_table(metrics)) .. raw:: latex \diilbookstyleoutputcell .. parsed-literal:: :class: output +---------------+--------------+-----------+----------+----------------+---------------+ | hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 | +===============+==============+===========+==========+================+===============+ | 0.001 | 0.0007 | 0.0008 | 0.0007 | 0.0001 | 0.0001 | +---------------+--------------+-----------+----------+----------------+---------------+