2.1.4. 矩阵分解

UserCF和ItemCF虽然思路直观、易于理解,但它们都面临一个根本性的挑战:数据稀疏性。在真实的推荐场景中,用户-物品交互矩阵往往是极度稀疏的,大部分用户只与极少数物品发生过交互。这导致两个问题:一是很难找到足够的共同评分来计算可靠的相似度;二是即使找到了相似用户或物品,他们的交互覆盖面也可能很有限。

矩阵分解换了一种思路:不再显式计算相似度,而是通过学习用户和物品的隐向量表示,让向量空间中的距离自然地反映偏好关系。这种端到端的优化方式标志着协同过滤从统计方法向机器学习方法的转变。

2.1.4.1. 隐向量时代的开端

在推荐系统中,我们经常观察到这样的规律:喜欢《理智与情感》的用户往往也会给《公主日记》好评,而钟爱《致命武器》的观众通常也喜欢《独立日》。这反映了用户偏好的内在结构:一些隐含的“品味因子”在起作用。比如有些用户偏好面向女性的影片,有些则更喜欢面向男性的内容;有些人倾向于严肃深刻的作品如《阿马迪斯》,有些人则更享受轻松娱乐的片子如《阿呆与阿瓜》。

回顾前面介绍的UserCF和ItemCF这些基于邻域的协同过滤方法,它们的思路很直观:通过寻找相似的用户或物品来进行推荐,就像 图2.1.6 展示的那样。

../../_images/mf_usercf_illustration.svg

图2.1.6 基于用户行为统计的方法。张三喜欢左边的三部电影。为了对他进行预测,系统会找到也喜欢这些电影的相似用户,然后确定他们还喜欢哪些其他电影。在这种情况下,所有三位用户都喜欢《拯救大兵瑞恩》,因此这是首个推荐。接着,其中两位用户喜欢《沙丘》,所以它排在第二位,依此类推。

但这种方法有一个根本性的弱点:当评分数据非常稀疏时,很难找到足够的共同评分来计算可靠的相似度。矩阵分解的核心想法建立在以下两个关键假设上。

  • 第一个是低秩假设:虽然评分矩阵看起来很复杂,但实际上可能只受少数几个隐含因素影响,比如“面向男性vs面向女性”、“严肃vs逃避现实”等维度。

  • 第二个是隐向量假设:每个用户和每部电影都能用一个包含这些隐含因子的向量来表示,用户向量反映了其对各种因子的偏好程度,而电影向量则描述了该电影在各个因子上的特征强度。

这种做法的好处是显而易见的:即使两个用户没有看过相同的电影,只要他们在隐含因子上表现相似,我们就能为他们推荐相似的内容。这大大提高了模型处理稀疏数据的能力。

../../_images/mf_illustration.svg

图2.1.7 隐语义模型意图,该方法通过两个维度来刻画用户和电影:一个是面向男性与面向女性,另一个是严肃与逃避现实。

接下来我们看看如何把这个想法变成具体的算法。我们将介绍两种矩阵分解模型:简单直接的基础模型(FunkSVD)和考虑评分偏差的改进模型(BiasSVD)。

2.1.4.2. FunkSVD: 基础模型

FunkSVD 由 Simon Funk 在2006年提出 (Funk, 2006),是矩阵分解家族中最容易理解的一个。它的想法非常直接:把复杂的用户-物品评分矩阵分解成两个简单的矩阵——用户特征矩阵和物品特征矩阵。

假设我们有\(m\)个用户和\(n\)个物品,想要用\(K\)个隐含因子来描述它们。那么用户\(u\)可以用一个\(K\)维向量\(p_u\)来表示,物品\(i\)也可以用一个\(K\)维向量\(q_i\)来表示。预测用户\(u\)对物品\(i\)的评分就是这两个向量的内积:

(2.1.20)\[\hat{r}_{ui} = p_u^T q_i = \sum_{k=1}^{K} p_{u,k} \cdot q_{i,k}\]

这里\(p_{u,k}\)表示用户\(u\)在第\(k\)个隐含因子上的偏好程度,\(q_{i,k}\)表示物品\(i\)在第\(k\)个隐含因子上的特征强度。

现在问题变成了:如何找到这些隐含因子?我们采用一个很自然的思路——让预测评分尽可能接近真实评分。具体来说,我们要最小化所有已知评分的预测误差:

(2.1.21)\[\min_{P,Q} \frac{1}{2} \sum_{(u,i)\in \mathcal{K}} \left( r_{ui} - p_u^T q_i \right)^2\]

这里\(\mathcal{K}\)表示所有已知评分的用户-物品对,\(r_{ui}\)是用户\(u\)对物品\(i\)的真实评分。

要解决这个优化问题,我们使用梯度下降法。对于每个观测到的评分,我们先计算预测误差\(e_{ui} = r_{ui} - p_u^T q_i\),然后沿着误差减小的方向更新参数:

(2.1.22)\[p_{u,k} \leftarrow p_{u,k} + \eta \cdot e_{ui} \cdot q_{i,k}\]
(2.1.23)\[q_{i,k} \leftarrow q_{i,k} + \eta \cdot e_{ui} \cdot p_{u,k}\]

其中\(\eta\)是学习率,控制每次更新的步长。这个更新规则的直觉很简单:如果预测评分偏低了(\(e_{ui} > 0\)),我们就增大相关的参数值;如果预测偏高了,就减小参数值。

不过在实际应用中,我们通常还会加入L2正则化来防止过拟合:

(2.1.24)\[\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)\]

这样可以避免模型过度拟合训练数据,提高在新数据上的表现。

2.1.4.3. FunkSVD代码实践

FunkSVD的核心在于学习用户和物品的隐向量表示。在实现中,我们使用Embedding层来表示这些隐向量,并通过内积计算预测评分:

# 用户的隐向量表示
user_factors = Embedding(
    user_vocab_size,
    embedding_dim,
    embeddings_initializer="normal",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)

# 物品的隐向量表示
item_factors = Embedding(
    item_vocab_size,
    embedding_dim,
    embeddings_initializer="normal",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)

# 预测:计算用户向量和物品向量的内积
prediction = Dot(axes=1)([user_factors, item_factors])

这里的embedding_dim对应公式中的隐含因子数量\(K\)user_factors对应\(p_u\)item_factors对应\(q_i\),而Dot操作计算的就是\(p_u^T q_i\)。通过训练,模型会自动学习最优的隐向量表示,使预测评分尽可能接近真实评分。

FunkSVD 在 MovieLens 数据集上的应用

from funrec import run_experiment

run_experiment('funksvd')
+---------------+--------------+-----------+----------+----------------+---------------+
|   hit_rate@10 |   hit_rate@5 |   ndcg@10 |   ndcg@5 |   precision@10 |   precision@5 |
+===============+==============+===========+==========+================+===============+
|        0.0015 |       0.0008 |    0.0006 |   0.0004 |         0.0001 |        0.0002 |
+---------------+--------------+-----------+----------+----------------+---------------+

2.1.4.4. BiasSVD: 改进模型

基础模型虽然简洁,但在实际使用中我们发现了一个问题:不同用户的评分习惯差异很大。有些用户天生就是“好人”,很少给低分;有些用户则比较严格,平均分都不高。同样,有些电影因为制作精良或者明星云集,普遍得到较高评分;而有些冷门或质量一般的电影则评分偏低。

这些系统性的偏差如果不处理,会影响推荐的准确性。BiasSVD (Koren et al., 2009) 正是为了解决这个问题而提出的。它在基础模型的基础上引入了偏置项,让预测公式变成:

(2.1.25)\[\hat{r}_{ui} = \mu + b_u + b_i + p_u^T q_i\]

这里新增了三个项:\(\mu\)是所有评分的全局平均值,反映了整个系统的评分水平;\(b_u\)是用户\(u\)的个人偏置,反映了该用户相对于平均水平是倾向于给高分还是低分;\(b_i\)是物品\(i\)的偏置,反映了该物品相对于平均水平是受欢迎还是不受欢迎。

相应地,优化目标也要调整:

(2.1.26)\[\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)\]

在参数更新时,除了用户和物品的隐向量,我们还需要更新偏置项:

(2.1.27)\[b_u \leftarrow b_u + \eta \left( e_{ui} - \lambda b_u \right)\]
(2.1.28)\[b_i \leftarrow b_i + \eta \left( e_{ui} - \lambda b_i \right)\]

这种改进看似简单,但效果显著。通过分离出系统性偏差,模型能够更准确地捕捉用户和物品之间的真实交互模式,从而提供更精准的推荐。

2.1.4.5. BiasSVD代码实践

BiasSVD在FunkSVD的基础上增加了偏置项。在实现中,我们为用户和物品分别添加偏置Embedding,并在预测时将所有项相加:

# 用户的隐向量表示 + 用户偏置
user_factors = Embedding(
    user_vocab_size, embedding_dim,
    embeddings_initializer="normal",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)

user_bias = Embedding(
    user_vocab_size, 1,
    embeddings_initializer="zeros",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="user_bias",
)(user_id_input)
user_bias = Flatten()(user_bias)

# 物品的隐向量表示 + 物品偏置
item_factors = Embedding(
    item_vocab_size, embedding_dim,
    embeddings_initializer="normal",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)

item_bias = Embedding(
    item_vocab_size, 1,
    embeddings_initializer="zeros",
    embeddings_regularizer=tf.keras.regularizers.l2(0.02),
    name="item_bias",
)(item_id_input)
item_bias = Flatten()(item_bias)

# 计算各个部分
interaction = Dot(axes=1)([user_factors, item_factors])  # 交互项
ones_input = tf.keras.layers.Lambda(lambda x: tf.ones_like(x))(interaction)
global_bias = Dense(1, use_bias=True, kernel_initializer="zeros",
                     name="global_bias")(ones_input)  # 全局偏置

# BiasSVD 预测:global_bias + user_bias + item_bias + interaction
prediction = Add()([global_bias, user_bias, item_bias, interaction])

偏置项初始化为"zeros",代表相对于平均水平的偏移,训练过程中自动学习不同用户的打分习惯和不同物品的受欢迎程度。

BiasSVD 在 MovieLens 数据集上的应用

run_experiment('biassvd')
+---------------+--------------+-----------+----------+----------------+---------------+
|   hit_rate@10 |   hit_rate@5 |   ndcg@10 |   ndcg@5 |   precision@10 |   precision@5 |
+===============+==============+===========+==========+================+===============+
|         0.001 |       0.0003 |    0.0003 |   0.0001 |         0.0001 |        0.0001 |
+---------------+--------------+-----------+----------+----------------+---------------+