隐语义模型(LFM)

隐语义模型

关于隐语义模型(latent factor model)的介绍,项亮博士在《推荐系统实践》中有过介绍,这本书的确是推荐系统方向非常非常棒的书籍. 我在用python实现书中隐语义模型时遇到了一些问题和坑,因此用这篇博客进行记录并总结,之后有新的或更深的理解会及时更新本文.

关于Latent Factor Model

隐语义模型本质上是通过对用户历史行为中的items进行聚类,从而完成推荐. 这样做的好处在于我们不需要对样本进行标记,在分类过程中也不需要关心类别的可解释性. LFM通过在用户和物品中构建一个“中间层”的方式来建立联系,使用算法自动得到物品和用户的分类权数. LFM通过以下模型来衡量这层关系:
\[R_{UI} = P_{U}*Q_{I} = \sum_{k=1}^K p_{uk}*q_{ki}\]
其中\(K\)为“中间层”中隐类的个数,我们笼统的把用户历史行为中的物品划分为\(K\)个类,每个类对于每个用户都学习一个权重参数,表示该类在用户历史行为中的权重,公式中\(P_{uk}\)用来衡量用户和\(K\)个类别的关系;\(Q_{ki}\)用来衡量每个物品和\(K\)个类之间的关系,LFM模型需要学习每个物品所占每个类的权重参数,我们将两者相乘就可以得到用户对每个物品的兴趣度.

对于模型,我们首先需要定义好损失函数:
\[C = \sum_{(u,i)\in k}^K (r_{ui} - \hat{r_{ui}})^2 = \sum_{(u,i)\in k}^K (r_{ui} - \sum_{k=1}^K p_{uk}*q_{ki})^2 + \lambda ||p_u||_2^2 + \lambda ||q_i||_2^2\]
为了优化目标函数,我们还需要对 \(p\)\(q\) 分别求出其一阶偏导数(partial derivertive) :
\[{\partial{C}\over p_{uk}} = 2(r_{ui} - \sum_{k=1}^K p_{uk}*q_{ki})(-q_{ki}) + 2\lambda p_{uk} = 2*error*(-q_{ki}) + 2\lambda p_{uk}\]
\[{\partial{C}\over q_{ki}} = 2(r_{ui} - \sum_{k=1}^K p_{uk}*q_{ki})(-p_{uk}) + 2\lambda p_{ki} = 2*error*(-p_{uk}) + 2\lambda q_{ki}\]
之后,我们需要在每次迭代时使用梯度下降法更新这两个权重参数 :
\[p_{uk} = p_{uk} - \alpha {\partial{C}\over p_{uk}}\]
\[q_{ki} = q_{ki} - \alpha {\partial{C}\over q_{ki}}\]

LFM实现

初始化模型

根据书中的代码,模型训练之前需要首先初始化模型,即初始化两个权重的值. 可以直接使用numpy.random.rand() 函数 :

1
2
3
4
5
6
def init_model(self):  
for user in self.user_item.keys():
self.P[user] = np.random.rand(self.K)

for item in self.item_pool:
self.Q[item] = np.random.rand(self.K)

采样

对于每一个用户,根据物品的流行度进行负采样,即根据物品的热门程度构建负样本,因为我们一般认为如果用户没有对热门物品有交互行为,可能意味着用户对其不感兴趣,而对于长尾物品,用户可能未曾有交互机会,因此不能够一定程度上认定对其不感兴趣,以下为采样函数 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def collect_samples(self, items):
# collect positive samples
labels = dict()
for movie in items:
labels[movie] = 1

# collect negative smaples
n_negative = 0

seen = set(items)
pos_num = len(seen)
item = np.random.choice(self.items, int(pos_num*self.ratio*3), self.pops)
item = [x for x in item if x not in seen][:int(pos_num*self.ratio)]
for i in item:
labels[i] = 0

return labels

训练

在训练中,根据上文中提到的公式进行迭代训练,优化损失函数 :

1
2
3
4
5
6
7
8
9
10
for epoch in range(self.epoches):
for user, items in self.user_item.items():
samples = self.collect_samples(items)
# samples : {movie_id : label}
for item, label in samples.items():
error = label - self.predict(user, item)
self.P[user] += self.alpha * (error * self.Q[item] -
self.lambda_r * self.P[user])
self.Q[item] += self.alpha * (error * self.P[user] -
self.lambda_r * self.Q[item])

推荐

对每一个不在用户历史交互列表中的物品,计算推荐分数,然后从高到低排序,选取前N个作为推荐结果 :

1
2
3
4
5
6
7
8
9
10
11
def recommend(self, user, N):
iteracted_item = self.user_item[user]
recommend_score = dict()
for item in self.item_pool:
if item not in iteracted_item:
# recommend_score : item_id : score
recommend_score[item] = np.dot(self.P[user], self.Q[item])

recs = sorted(recommend_score.items(), reverse=True, key=lambda x:x[1])[0:N]

return recs

问题总结

负样本采集

因为这里我们把用户对电影完成评分这一行为作为正样本,因此在构造训练集时需要采集相应的负样本,根据书中的介绍,在采集负样本时,应尽量选取和正样本数量相同且较热门的物品. 我在最初的尝试中每次训练时对用户按照物品流行度降序选取相同的物品,过程中发现 loss 可以下降到比较低的数值,但 recall 一直停留在 1.5 左右,且覆盖率较高(接近50%),我认为这种方法在推荐过程中过于随机化 :

recall precision coverage popularity
1.63 5.42 46.55 5.61

更加合理的方式是按照流行度随机采集负样本,这里我尝试了两种不同的方法.

  1. 将所有用户历史行为中的物品存储在 list 中,每次采样时随机抽取,由于流行度越高的物品出现的次数越多,因此这种抽取方式理论上实现了按照物品热度采用的思想. 但实际效果并不理想,原因在于用户交互过的物品有限,当采用次数没有达到一定量级时,不能保证尽可能的选出热门物品,可以看到此时 recall 和 precision 有明显提升:
recall precision coverage popularity
5.31 17.63 47.47 3.89
  1. 按照物品出现的概率采样是我实验中发现效果比较好的方式,统计每种物品的出现次数,用numpy.ramdom.choice()函数按概率取样,发现 recall 和 precision 有明显提升,但覆盖率有所下降:
recall precision coverage popularity
7.74 24.66 41.90 6.81

负样本的采集对结果具有决定性的影响,每次选取不同的负样本进行训练具有更好的效果.

正则化参数

正则化的最用和意义在我另一篇文章中有较详细的介绍: Regularization in Machine Learning. 当正则化参数较大时,对模型的惩罚加大,损失函数可能会一直居高不下,下降缓慢并在较大值处收敛,这是由于模型较简单所导致的,可以根据结果调整参数大小,一般当 K 值较小时倾向于选取较小的正则化参数.

References

0%