推荐系统:从协同过滤的起源

相关链接:

推荐系统常用的评价指标_推荐系统评价指标-CSDN博客

推荐系统-协同过滤在Spark中的实现 - vivo互联网技术 - 博客园 (cnblogs.com)

从上篇博文到现在又隔了一个月,由于工作内容的转变,转向了推荐/风控算法,尤其是CTR模型相关,因此准备系统的学习并实现一下一些常用的推荐和分控算法。

记得曾经看过一句话,推荐算法是没有Query的搜索系统,这句话非常粗略的介绍了推荐算法。正如搜索系统需要想法设法对用户的输入进行信息抽取,推荐算法的Query则是用户自身的历史行为信息和物品的信息。

推荐系统分类

推荐系统最早出现在90年代,1992年出现了第一个最早的协同过滤系统,由Xerox公司帕罗奥多研究中心研发。当时需要解决的问题是员工需要对大量的邮件进行筛选分类,因此衍生出了这个系统。随着不断的发展,这个协同过滤系统逐渐发展完善,并于1998年被亚马逊公司使用于自己的网站,为用户进行商品的个性化推荐。

大体上,协同过滤被分为三个子类:User based,Item based和Model based。从核心思想上来介绍协同过滤,可以用一言概括:通过分析用户或者事物之间的相似性来找到目标对象。

推荐系统的评估指标

推荐系统和CTR模型简单可以看作是一个多目标/单目标分类任务。推荐系统更多考虑到候选物品的排序,CTR则作为一个二分类任务,判断用户是否会点击目标物品。

作为分类任务时,就可以用一些常规的评估指标对模型进行评估。

  • AUC-ROC
  • Log Loss
  • Precsion/Recall/F1 Score
  • Accuracy

作为排序任务时,则常用以下指标:

  • NDCG:对推荐结果的相关性进行排名,考虑位置的重要性。
  • MRR(Mean Reciprocal Rank):推荐结果中第一个相关物品的倒数平均值。
  • MAP(Mean Average Precision):平均精度,对推荐结果的相关性进行综合评估。
  • Hit Rate:推荐系统的推荐物品命中率。

NDCG:评估推荐系统排序质量的指标

我们从NDCG的前身开始介绍。

首先需要了解的相关性得分:

$\text{Gain}= \text{rel}_i$

这个得分通常是人工标注或者通过用户行为得到。例如当用户点击某个推荐商品时,给商品赋予1分,未点击则为0分。如果购买则赋予2分,以此类推。也可以根据用户自己的打分来进行判断。最终会得到以下一个列表:

Item Rel(i)
Item A 1
Item B 3
Item C 2

CG

CG(Cumulative Gain)用于衡量推荐列表中的相关性之和。计算公式如下:

$CG@k = \sum_{i=1}^{k} \text{rel}_i$

其中:

  • $k \text{ 是推荐列表的长度。}$

  • $\text{rel}_i \text{ 是第 } i \text{ 个推荐物品的相关性得分(通常由实际数据或人工标注给出)。}$

CG只是单纯的累加相关性,不考虑他们之间的排序。我们以上面的列表举例:

如果我们有两个推荐列表:

  • List 1 = [A, B, C]
  • List 2 = [C, A, B]

他们的CG得分都是A, B, C的相关性得分相加。最终得分为$1+3+2=6$。由于不考虑排序的影响,他们的得分是一样的。

DCG

如果我们想要考虑排序的因素,使得高排名的物品权重更高,对排名靠后的物品扣分,这时候可以使用DCG作为指标。它的计算方式是在每个CG的结果上除以一个折损值,这样能让排名靠前的结果更加能影响到最终的结果。假设排序越靠后,价值越低,那么到第$i$个位置时,价值为$\frac{1}{log_{2}(i+1)}$,那么第$i$个结果的效益值是$rek_{i}*{\frac{1}{log_{2}(i+1)}}$。

$DCG_p = \sum_{i=1}^{p} \frac{2^{\text{rel}_i} - 1}{\log_2(i + 1)}$

在同样上面的例子中:

Item Rel(i) Position (i) Contribution to DCG
Item A 1 1 1
Item B 3 2 $\frac{3}{\log_2(3)} \approx 1.893$
Item C 2 3 $\frac{2}{\log_2(4)}=1$

NDCG

最后就到了NDCG,他是DCG进行了归一化。不过因为两边的检索结果的列表长度可能不一样,所有没法针对两个不同的检索结果进行归一化,这里是除以IDCG。

IDCG是理想情况下的DCG最大的值,即推荐的最佳排序顺序,根据相关性从高到低排列。

最终的公式是:$NDCG_{p}=\frac{DCG}{IDCG_{p}}$

这样计算后,NDCG作为一个相对值,不同长度的检索就可以进行比较了。

基于用户的协同过滤

基于用户的协同过滤简称UserCF,核心是推荐与用户相似的用户喜欢的物品。简单来说,$n$个用户对$m$个物体的打分会成为一个$n*m$的User-Item矩阵。每个用户都是一个$m$维向量,这样就可以通过计算相似度来计算不同用户之间的相似度。最终能够得到一个下三角矩阵,其中包含了每个用户两两之间的相似度。使用较多的是余弦相似度。

在实际环境中,用户/物品评分矩阵是一个稀疏矩阵。

UserCF的核心是计算用户相似度,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

class UserBasedCF:
def __init__(self, ratings, k=2):
"""
初始化UserCF模型

入参:
ratings:二维评分矩阵
k:最近邻用户数
"""
self.ratings = ratings
self.k = k
self.user_similarity = self.calculate_user_similarity()

def calculate_user_similarity(self):
"""
计算用户相似度

返回:
用户相似度的二维数组
"""
return cosine_similarity(self.ratings)

def predict_rating(self, user_index, item_index):
"""
为特定用户计算特定物体的打分

入参:
user_index:用户的索引
item_index:物品的索引

返回:
预测得分
"""
# 首先根据用户相似度得到最相似的k个用户,并去除自身
similar_users = np.argsort(self.user_similarity[user_index])[-self.k:]
similar_users = similar_users[similar_users != user_index]

numerator = 0
denominator = 0

# 找到相似用户
for neighbor in similar_users:
if self.ratings[neighbor, item_index] > 0:
# 分子是相似用户对物品的评分*相似度
numerator += self.user_similarity[user_index, neighbor] * self.ratings[neighbor, item_index]
# 分母是用户相似度之和
denominator += abs(self.user_similarity[user_index, neighbor])

if denominator == 0:
return 0
else:
# 返回分子/分母
return numerator / denominator

def recommend(self, user_index, num_recommendations=5):
"""
为用户推荐具体物品

入参:
user_index:用户索引
num_recommendations:推荐物品个数

返回:
推荐物品的索引列表
"""
user_ratings = self.ratings[user_index]
print(f'用户{user_index}的评分:{user_ratings}')
item_indices = np.where(user_ratings == 0)[0]
print(f'用户{user_index}未评分的物品:{item_indices}')

predictions = np.array([self.predict_rating(user_index, item) for item in item_indices])
recommended_items = item_indices[np.argsort(predictions)[-num_recommendations:]]

return recommended_items

predictions = np.array([self.predict_rating(user_index, item) for item in item_indices])
recommended_items = item_indices[np.argsort(predictions)[-num_recommendations:]]

return recommended_items

现在,我们有一个用户-物品评分矩阵,每行都是一个用户对所有物品的打分:

1
2
3
4
5
6
7
8
# 示例数据:用户-物品评分矩阵
ratings_matrix = np.array([
[4, 0, 0, 5, 1],
[5, 5, 4, 0, 0],
[0, 0, 5, 4, 0],
[5, 4, 4, 0, 0],
[0, 0, 0, 5, 4]
])

我们想要得到用户0的推荐产品:

1
2
3
4
5
6
7
8
9
10
# 初始化并使用User-based CF模型
user_cf = UserBasedCF(ratings_matrix, k=2)

# 为用户0推荐物品
recommendations = user_cf.recommend(0, num_recommendations=2)
print(f"Recommendations for user 0: {recommendations}")

# 预测用户0对物品1的评分
predicted_rating = user_cf.predict_rating(0, 1)
print(f"Predicted rating for user 0 on item 1: {predicted_rating:.2f}")

首先实例化模型,需要计算用户相似度。这个相似度被保存在user_cf.user_similarity变量中:

1
2
3
4
5
6
7
user_cf.user_similarity 

array([[1. , 0.37986859, 0.48196269, 0.40875956, 0.6988459 ],
[0.37986859, 1. , 0.38447322, 0.99453584, 0. ],
[0.48196269, 0.38447322, 1. , 0.4137144 , 0.48780488],
[0.40875956, 0.99453584, 0.4137144 , 1. , 0. ],
[0.6988459 , 0. , 0.48780488, 0. , 1. ]])

输出是一个共轭矩阵,可以看到用户0和用户2/用户4的相似度最高。

现在我们想找到对用户0的2个推荐物品。首先需要找到用户评分的物品和未评分的物品:

1
2
3
4
user_ratings = self.ratings[user_index]
# 用户0的评分:[4 0 0 5 1]
item_indices = np.where(user_ratings == 0)[0]
# 用户0未评分的物品:[1 2]

现在就需要对用户0未评分的物品进行预测打分,来判断用户是否会喜欢。

要进行打分,就需要找到用户最相似的k个用户。这里k设置为2。根据前面计算得到相似用户是2和4。随后根据用户2,4对物品1,2的打分预测目标用户的打分。

我们需要遍历每个邻居,找到当前邻居对需要计算的物品的打分。如果分数不为0,则将分子设为用户相似度*用户对物品评分的加和,而分母设置为用户相似度的绝对值之和(用于归一化分子的加权和),最后得到的预测得分是相似用户的评分的加权得分。

分母设置为相似度的绝对值之和是为了防止出现负数之后使得分母为0。

示例

假设用户$u$的相似邻居 $Nu$有以下相似度和评分:

用户 $v$ 相似度 $s(u,v)$ 评分 $r_{vi}$
用户 1 0.9 4
用户 2 -0.8 3
用户 3 0.7 5

计算预测评分:

  1. 分子:$(0.9×4)+(−0.8×3)+(0.7×5)=3.6−2.4+3.5=4.7$
  2. 分母:$∣0.9∣+∣−0.8∣+∣0.7∣=0.9+0.8+0.7=2.4$
  3. 预测得分:$\frac{2.4}{4.7}≈1.96$

UserCF的不足

UserCF存在一些不足:

  1. 物体过多导致的稀疏矩阵问题,很多用户可能接触不到百分之一的物品。
  2. 用户增长比物品增长快的时候需要频繁维护相似度矩阵,对存储要求非常大,尤其是高用户的场景。

基于物品的协同过滤

相比UserCF,ItemCF被工业界应用的更多,例如此前亚马逊,Netflix和Youtube都使用过它。例如在电商平台某个商品下,会推荐类似商品,或者购买此商品的用户还看过这样的标签。背后用的就是ItemCF。

流程与UserCF类似。基本思想是根据用户的历史偏好数据计算物品之间的相似性,然后把与用户喜欢的物品相类似的物品推荐给用户。比如物品A和C非常相似,因为喜欢A的用户同时也喜欢C,而用户甲喜欢A,所以把C推荐给用户甲。

一言概况:喜欢某个物品的用户,也可能喜欢和该物品类似的物品。举一个例子:

Item 1 Item 2 Item 3 Item 4 Item 5
User 1 5 3 4 4
User 2 3 1 2 3 3
User 3 4 3 4 3 5
User 4 3 3 1 5 4

现在我们想预测User 1对Item 5的打分,就需要:

  1. 计算Item 5和其他Item的相似度
  2. 找到和Item 5最相近的k个物品
  3. 根据User 1和最相近的k个物品的打分计算Item 5的得分

示例代码和之前类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

class ItemBasedCF:
def __init__(self, ratings, k=5):
self.ratings = ratings
self.k = k
self.item_similarity = self.calculate_item_similarity()

def calculate_item_similarity(self):
return cosine_similarity(self.ratings.T)

def predict_rating(self, user_index, item_index):
similar_items = np.argsort(self.item_similarity[item_index])[-(self.k+1):]
similar_items = similar_items[similar_items != item_index]

numerator = 0
denominator = 0

for neighbor in similar_items:
if self.ratings[user_index, neighbor] > 0:
numerator += self.item_similarity[item_index, neighbor] * self.ratings[user_index, neighbor]
denominator += abs(self.item_similarity[item_index, neighbor])

if denominator == 0:
return 0
else:
return numerator / denominator

def recommend(self, user_index, num_recommendations=5):
user_ratings = self.ratings[user_index]
item_indices = np.where(user_ratings == 0)[0]

predictions = np.array([self.predict_rating(user_index, item) for item in item_indices])
recommended_items = item_indices[np.argsort(predictions)[-num_recommendations:]]

return recommended_items

同样的,先计算物品相似度:

1
2
3
4
5
6
7
item_cf.item_similarity

array([[1. , 0.86506475, 0.65215465, 0.3030303 , 0.11941629],
[0.86506475, 1. , 0.74468592, 0. , 0. ],
[0.65215465, 0.74468592, 1. , 0.32607733, 0. ],
[0.3030303 , 0. , 0.32607733, 1. , 0.74635179],
[0.11941629, 0. , 0. , 0.74635179, 1. ]])

可以看到Item 5和Item 4最相似。随后计算得分的时候,分母是最相似的物品的用户打分*相似度,分母同样是相似度的绝对值加和。

工业离线计算的技巧

在实际工业中,如果要计算完整的物品相似度,计算量过大,因此需要用一些trick。通过将余弦相似度公式展开后发现,只有当用户A对需要对比的两个物品都打分时,相似度才不为0。因此在计算时需要对这些稀疏数据进行过滤。

协同过滤的问题

协同过滤的经典问题就是稀疏性问题,由于超过百分之80的数据都可能是Nan,数据质量并不好。

另一个问题是无法捕捉到物品本身的信息,仅仅使用了用户与物品的交互信息,而放弃了用户特征,物品特征,为了解决这个问题,工业界逐渐转向了使用逻辑回归之流的算法。

此外,热门的物品会和大量其他的物品出现相似性,这不是因为它们本身相似,只是因为它经常被购买。为了解决这个问题,出现了MF算法,使用隐向量表示物品。

第三个问题就是冷启动问题,对于没有历史信息的用户,找不到相似的用户进行推荐。

为了解决这几个问题,需要对一些热门物品进行惩罚,例如对热门商品或者活跃用户进行惩罚。

什么时候用UserCF/ItemCF?

摘抄一下:

  • UserCF 由于是基于用户相似度进行推荐, 所以具备更强的社交特性, 这样的特点非常适于用户少, 物品多, 时效性较强的场合, 比如新闻推荐场景, 因为新闻本身兴趣点分散, 相比用户对不同新闻的兴趣偏好, 新闻的及时性,热点性往往更加重要, 所以正好适用于发现热点,跟踪热点的趋势。 另外还具有推荐新信息的能力, 更有可能发现惊喜, 因为看的是人与人的相似性, 推出来的结果可能更有惊喜,可以发现用户潜在但自己尚未察觉的兴趣爱好。

    对于用户较少, 要求时效性较强的场合, 就可以考虑UserCF。

  • ItemCF 这个更适用于兴趣变化较为稳定的应用, 更接近于个性化的推荐, 适合物品少,用户多,用户兴趣固定持久, 物品更新速度不是太快的场合, 比如推荐艺术品, 音乐, 电影。

2024/7/28 于苏州