基于标签的推荐系统
用户用标签来描述自己对物品的看法,因此,标签成为了联系用户和物品的纽带。因此,标签数据是反应用户兴趣的重要数据源,而如何利用用户的标签数据来提高用户个性化推荐结果的质量,是推荐系统研究的重要问题。
在如何利用标签数据的问题上,豆瓣无疑是这方面的代表。豆瓣将标签系统融入到他们的整个产品线中。下面以豆瓣读书为例进行介绍。首先,在每本书的页面上,都提供了一个叫做“豆瓣成员常用标签”的应用,它给出了这本书上用户最常打的标签。同时,在用户希望给书做评价时,豆瓣也会让用户给图书打标签。最后,在最终的个性化推荐结果里,豆瓣利用标签将用户的推荐结果做了聚类,显示了不同标签下用户的推荐结果,从而增加了推荐的多样性和可解释性。
一个用户标签行为的数据集一般由一个三元组的集合表示,其中记录(u, i, b) 表示用户u给物品i打上了标签b。当然,用户的真实的标签行为数据远远比三元组表示的要复杂,比如用户标签的时间、用户的属性数据、物品的属性数据等。但是,在本章中,为了集中讨论标签数据,我们只考虑上面定义的三元组形式的数据,即用户的每一次标签行为都用一个三元组(用户,物品,标签)来表示。
在下面的各节中,我们将利用Delicious的数据集,讨论如何利用用户标签数据进行个性化推荐的各种算法。
我们将Delicious的数据集按照9:1随机分成训练集R和测试集T。这里分割的键值是用户和物品,不包括标签,也就是说,用户对物品的多个标签记录要么都被分进训练集,要么都被分进测试集。
在分完数据集后,我们将通过学习训练集中的用户标签数据,来预测测试集上用户会给什么物品打标签。对于用户u,令R(u)是给用户u的长度为N的推荐列表,里面包含着我们认为用户会打标签的物品。而另T(u)是测试集中用户u实际上打过标签的物品集合。然后,我们利用准确率/召回率(Precision/Recall)来评测个性化推荐算法的准确率:
如果用Python实现上面的两个指标,我们可以通过如下的代码:
def PrecisionRecall(test_data, recommendations, N):
hit = 0
n_recall = 0
n_precision = 0
for user,ru in recommendations.items():
tu = test_data[user]
for item in sorted(ru.items(), key=itemgetter(1), reverse=True)[0:N]:
if item in tu:
hit += 1
n_precision += 1
n_recall += len(tu)
recall = hit / (n_recall * 1.0)
precision = hit / (n_precision * 1.0)
return list(recall, precision)
同时,为了全面评测个性化推荐的性能,我们同时评测了推荐结果的覆盖度(Coverage)、多样性(Diversity)和新颖度。
覆盖度我们通过如下的公式和代码计算:
def Coverage(train_data, test_data, recommendations, N):
total_items = set()
recommend_items = set()
for user, items in train_data.items():
for item in items:
total_items.add(item)
for user, ru in recommendations.items():
for item, weight in sorted(ru.items(), key=itemgetter(1), reverse=True)[0:N]:
recommend_items.add(item)
return (len(recommend_items) * 1.0) / (len(total_items) * 1.0);
关于多样性,我们在第1章中讨论过,多样性的定义取决于相似度的定义。在本章中,我们用物品的标签向量的余弦相似度来度量物品之间的相似度。对于每个物品i,item_tags[i]存储了物品i的标签向量,其中item_tags[i][b]是物品i上标签b被打的次数,那么物品i和j的余弦相似度可以通过如下的程序计算:
def CosineSim(item_tags, i, j):
ret = 0
for b,wib in item_tags[i].items():
if b in item_tags[j]:
ret += wib * item_tags[j][b]
ni = 0
nj = 0
for b, w in item_tags[i].items():
ni += w * w for b, w in item_tags[j].items():
nj += w * w if ret == 0:
return 0
return ret / math.sqrt(ni * nj)
在得到物品之间的相似度度量后,我们通过如下的公式来计算一个推荐列表的多样性:
如果用程序实现,代码如下:
def Diversity(item_tags, recommend_items):
ret = 0
n = 0
for i in recommend_items.keys():
for j in recommend_items.keys():
if i == j:
continue
ret += CosineSim(item_tags, i, j)
n += 1
return ret / (n * 1.0)
而推荐系统的多样性定义为所有用户的推荐列表的多样性的平均值。
至于推荐结果的新颖性,这里我们简单地用推荐结果的平均热门程度(AveragePopularity)来度量。对于物品i,定义它的热门度item_pop(i)为给这个物品打过标签的用户数。而对推荐系统,我们定义它的新颖度如下:
如果用程序实现,代码如下:
def AveragePopularity(item_pop, recommend_results):
ret = 0
n = 0
for u, recommend_items in recommend_results.items():
for item in recommend_items.keys():
ret += math.log(1 + item_pop [item])
n += 1
return ret / (n * 1.0)
当拿到了用户标签行为数据时,大家都可以想到一个最简单的算法来给用户推荐个性化的物品。这个算法的描述如下所示。
如果用公式描述上面的算法,那么用户u对物品i的兴趣可以用如下的公式度量:
这里,B(u)是用户u打过的标签集合,B(i)是物品i被打过的标签集合,
是用户u打过标签b的次数,
是物品i被打过标签b的次数。本章用SimpleTagBased来标记这个算法。
在Python中,我们用 records 来存储标签数据的三元组,其中
records[i] = [user, item, tag]
用 user_tags 来存储
,其中user_tags[u][b] =
。
用 tag_items来存储
,其中tag_items[b][i] =
。
如下的程序可以从records统计出user_tags和tag_items:
def InitStat(records):
user_tags = dict()
tag_items = dict()
for user, item, tag in records.items():
if user not in user_tags:
user_tags[user] = dict()
if tag not in user_tags[user]:
user_tags[user][tag] = 1
else:
user_tags[user][tag] += 1
if tag not in tag_items:
tag_items[tag] = dict()
if item not in tag_items[tag]:
tag_items[tag][item] = 1
else:
tag_items[tag][item] += 1
统计出user_tags和tag_items之后,可以通过如下程序对用户进行个性化推荐:
def Recommend(user):
recommend_items = dict()
for tag, wut in user_tags[user].items():
for item, wti in tag_items[tag].items():
if item not in recommend_items:
recommend_items[item] = wut * wti else:
recommend_items[item] += wut * wti return recommend_items
我们在Delicious数据集上对上面的算法进行评测,结果如表2所示。
表2 简单的基于标签的推荐算法在Delicious数据集上的评测结果
我们再来回顾一下上面提出的简单算法,该算法通过如下公式预测用户u对物品i的兴趣:
仔细研究上面的公式,可以发现上面的公式有很多缺点。下面我们将逐条分析该算法的缺点,并提出改进意见。
如果我们从概率论的角度出发,认为用户u喜欢物品i的概率取决于u曾经打过的标签,那么我们会得到如下的概率公式:
这个公式和SimpleTagBased算法的公式相比,对参数做了归一化,而且他的解释也是从概率的角度出发,更加明确,本章用NormTagBased来代表这个算法。表3给出了SimpleTagBased算法和NormTagBased算法在各种指标上的实验结果的比较。
表3 SimpleTagBased算法和NormTagBased算法的比较
如表3所示,经过归一化之后的NormTagBased算法无论在召回率/准确率,还是在覆盖度、多样性和热门程度等指标上,均优于SimpleTagBased算法。因此,NormTagBased算法是对SimpleTagBased的算法的一个有效的改进。
在前面的算法中,用户兴趣和物品的联系是通过B(u)∩B(i)中的标签建立的。但是,如果这个用户是新用户,或者物品是新物品,那么这个集合(B(u)∩B(i))中的标签数量会很少。为了提高推荐的准确率,我们可能要对标签集合做扩展,比如用户曾经用过“推荐系统”这个标签,我们可以将这个标签的相似标签也加入到用户标签集合中,比如“个性化”,“协同过滤”等标签。
为了说明数据稀疏性对性能的影响,我们将用户按照打过的标签数分成两组。第一组用户打过10次以下的标签,而第二组用户打过超过10次标签,我们分别统计这两组用户的推荐结果的准确率和召回率,结果如表4所示。
表4 不同活跃度的用户的召回率/准确率对比
[具体实验结果待正式发表时公布]
进行标签扩展有很多方法,其中著名的有话题模型(Topic Model)。不过这里我们遵循简单的原则,只介绍一种基于邻域的方法。
标签扩展的本质是对每个标签找到和它相似的标签,也就是计算标签之间的相似度。最简单的相似度可以是同义词。如果我们有一个同义词词典,就可以根据这个词典来进行标签扩展。如果没有这个词典,我们还是可以从数据中统计出标签的相似度。
如果认为同一个物品上的不同标签具有某种相似度的话,那么如果两个标签同时出现在很多物品的标签集合中,就可以认为这两个标签具有较大的相似度。对于标签b,令N(b)为有标签b的物品的集合,n_(b,i)为给物品i打上标签b的用户数,可以通过如下的余弦相似度公式计算标签b和标签b'的相似度:
[具体实验结果待正式发表时公布]
不是所有的标签都能反应用户的兴趣。比如,在一个视频网站中,用户可能对一个视频赋予了一个表示情绪的标签,比如“不好笑”(no funny)。但我们不能因此认为用户对“不好笑”有兴趣,并且给用户推荐其他具有“不好笑”这个标签的视频。相反,如果用户对视频打过“成龙”这个标签,我们可以据此认为用户对成龙的电影感兴趣,从而给用户推荐成龙其他的电影。同时,标签系统里经常出现词形不同、词义相同的标签,比如recommender system和recommendation engine就是两个同义词。
标签清理的另一个重要意义在于用标签作为推荐解释。如果我们要把标签呈现给用户,作为给用户推荐某一个物品的解释时,对标签的质量要求就很高。首先,这些标签不能包含没有意义的停止词或者表示情绪的词,其次这些推荐解释里不能包含很多相同意义的词语。
本章我们使用的标签清理的方法有以下几种。
[具体实验结果待正式发表时公布]
为了控制标签的质量,很多网站也采用了让用户反馈的思想,即让用户来告诉系统某个标签是否合适。MovieLens在他们的实验系统中就采用了这种方法,关于这方面的研究可以参考GroupLens的Shilad同学的博士论文 。此外,电影推荐网站Jinni也采用了这种方式(如图9所示)。当然,Jinni不属于UGC的标签系统,它给电影的标签是专家赋予的,因此它让用户对标签反馈其实是想融合专家和广大用户的知识。
图9 Jinni允许用户对编辑给的标签进行反馈
前面讨论的简单算法很容易懂,也容易实现,但缺点是不够系统化和理论化。因此,在这一节中,我们主要讨论如何利用图模型来做基于标签数据的个性化推荐。
首先,我们需要将用户的标签行为表示到一个图上。我们知道,图是由顶点、边和边上的权重组成的。而在用户标签数据集上,有三种不同的元素:用户、物品和标签。因此,我们需要定义三种不同的顶点:用户顶点、物品顶点和标签顶点。然后,如果我们得到一个表示用户u给物品i打了标签b的用户标签行为(u,i,b),那么,最自然的想法就是在图中增加三条边,首先在用户u对应的顶点v(u)和物品i对应的顶点v(i)之间需要增加一条边(如果这两个顶点已经有边相连,那么就应该将边的权重加1),同理,在v(u)和v(b)之间需要增加一条边, v(i)和v(b)之间也需要边相连接。
图10是一个简单的用户-物品-标签图的例子。
图10 一个简单的用户-物品-标签图的例子
通过用户标签行为构造出图之后,为用户u推荐物品的问题就转化为计算图上所有物品节点相对于用户节点v(u)的相关度排名的问题。图上的排名算法很多,其中最著名的就是PageRank算法。
PageRank算法最初是用来对互联网上的网页进行排名的算法。网页通过超级链接形成了图。PageRank假设用户从所有网页里随机挑出一个网页,然后开始通过超级链接进行网上冲浪。到达每个网页后,用户首先会以d的概率继续冲浪,而在冲浪时,用户会以同等的概率在当前网页的所有超级链接中随机挑选一个进入下一个网页。那么,在这种模拟下,最终每个网页都会有一个被用户访问到的稳定概率,而这个概率就是网页的排名。
PageRank算法通过如下的迭代关系式来计算网页的权重:
其中PR(i)是网页i的排名,d是用户每次继续冲浪的概率,N是所有网页的总数。in(i)是指向网页i的所有网页的集合,out(j)是网页j链向的网页的集合。
下面我们举一个简单的例子来说明PageRank算法,我们用图11所示的例子来演示一下PageRank的迭代过程。
图11 说明PageRank算法的图例
(1)一开始,每个顶点的排名都是一样的,PR(A) = PR(B) = PR(C) = PR(D) = PR(E) = 1 / 5,令d = 0.85。
(2)根据前面的迭代关系式有
PR(A) = (1 – 0.85) / 5 + 0.85 * (PR(B) / 2) = 0.115PR(B) = (1 – 0.85) / 5 + 0.85 * (PR(C) / 1) = 0.2PR(C) = (1 – 0.85) / 5 + 0.85 * (PR(A) / 2 + PR(D) / 2 + PR(E) / 2) = 0.285PR(D) = (1 – 0.85) / 5 + 0.85 * (PR(E) / 2) = 0.115PR(E) = (1 – 0.85) / 5 + 0.85 * (PR(A) / 2 + PR(B) / 2 + PR(D) / 2) = 0.285
(3)继续按照前面的迭代关系式,有
PR(A) = (1 – 0.85) / 5 + 0.85 * (PR(B) / 2) = 0.115PR(B) = (1 – 0.85) / 5 + 0.85 * (PR(C) / 1) = 0.27225PR(C) = (1 – 0.85) / 5 + 0.85 * (PR(A) / 2 + PR(D) / 2 + PR(E) / 2) = 0.248875PR(D) = (1 – 0.85) / 5 + 0.85 * (PR(E) / 2) = 0.151125PR(E) = (1 – 0.85) / 5 + 0.85 * (PR(A) / 2 + PR(B) / 2 + PR(D) / 2) = 0.21275
我们可以按照上面的步骤一步步迭代下去,最终得到所有顶点的PageRank排名。
但是,从上面的描述可以看到,PageRank只是计算了所有顶点的全局排名,并不能用来计算一个顶点相对于另一个顶点的相关度排名。因此,很多研究人员对PageRank做出了修改,其中一个著名的修改就是TopicRank算法。
PageRank算法认为,用户每次都是从所有顶点中以相同的概率随机挑选一个顶点,然后开始随机游走,而且在每次随机游走经过每个顶点时,都会有1 - d的概率停止游走。那么,如果我们要计算所有点相对于某一个顶点的相关度排名,我们可以假设用户每次都从某一个顶点v出发,然后在每次随机游走经过每个顶点时都以1-d的概率停止游走,从v重新开始。 那么,最终每个顶点被访问的概率就是这些顶点和v的相关度排名。
PageRank可以用来给图中的顶点进行全局的排名,但它无法用来给每个用户个性化的对所有物品排序。为了解决个性化排名的问题,斯坦福大学的Haveliwala提出了TopicRank的算法 ,这个算法可以用来做个性化排序,因此本文将其称为PersonalRank。PersonalRank的迭代公式如下:
可以看到,PersonalRank和PageRank的区别在于用ri代替了1/N,也就是说,从不同的点重新开始的概率不同了。那么,假设如果我们要计算所有顶点和顶点k的相关度排名,我们可以定义
如下:
然后利用上面的迭代公式,就可以计算出所有顶点相对于k的相关度排名。我们将这里的
称为顶点i的启动概率。
回到给用户推荐物品这个问题上来,在我们构造出用户-物品-标签的图之后,如果我们要给用户u做推荐,我们可以令顶点v(u)的启动概率为1,而其他顶点的启动概率为0。然后用上面的迭代公式来计算所有物品对应的顶点相对于v(u)的排名。
下面两段Python代码给出了如何从用户行为记录集合tagging_records中构建图,以及如何在图上给用户进行推荐。
def BuildGraph(tagging_records):
graph = dict()
for user, item, tag in tagging_records:
addToMat(graph, ‘u:’+user, ‘i:’+item, 1)
addToMat(graph, ‘i:’+item, ‘u:’+user, 1)
addToMat(graph, ‘u:’+user, ‘t:’+tag, 1)
addToMat(graph, ‘t:’+tag, ‘u:’+user, 1)
addToMat(graph, ‘t:’+tag, ‘i:’+item, 1)
addToMat(graph, ‘i:’+item, ‘t:’+tag, 1)def Recommend(user, d, K):
rank = dict()
rank[‘u:’+user] = 1
for step in range(0:K):
for i, ri in rank.items():
for j, wij in graph[i]:
tmp_rank[j] += d * ri * wij
tmp_rank[‘u:’ + user] = (1 – d)
sum_weight = sum(tmp_rank.values())
rank = dict()
for i, ri in tmp_rank.items():
rank[i] = ri / sum_weight
tmp_rank = dict()
return rank
这里,d是前面提到的继续随机游走的概率,K是迭代的次数。在上面从某一个用户节点开始随机游走时,迭代K步最多可以走到离该用户节点距离为K之内的所有顶点,而其他顶点的权重为0。
在传统的PersonalRank中,我们需要迭代很多次,直到所有顶点的权重都稳定了为止。但是,如果我们为每个用户做推荐,都需要在全图上进行迭代,直到全图的所有顶点的权重都收敛,这样的时间复杂度太大了。因此,我们在实际的应用中一般只迭代比较少的次数。
在介绍了图模型后,我们可以用图模型来重新看待前面提到的简单的算法。在那个算法中,用户对物品的兴趣通过如下的公式计算:
这个公式认为用户对物品的兴趣通过标签来传递,因此这个公式可以通过一个比前面简单的图来建模(记为SimpleTagGraph)。给定用户标签行为记录(u,i,b),SimpleTagGraph会增加两条有向边,一条由用户节点v(u)指向标签节点v(b),另一条由标签节点v(b)指向物品节点v(i)。
图12就是一个简单的SimpleGraph的例子。在构建了SimpleGraph后,利用前面的PersonalRank算法,令K = 1,就是我们前面提出的简单推荐算法。
图12 SimpleGraph的例子
[相关实验:发表时公布
A. 迭代次数K对精度的影响
B. 边权重的定义对精度的影响
]
基于标签的推荐的最大好处是可以利用标签来做推荐解释,这方面的代表性应用是豆瓣的个性化推荐系统。图13展示了豆瓣读书的个性化推荐界面。
图13 豆瓣读书的个性化推荐应用“豆瓣猜”的界面
如图13所示,豆瓣读书推荐结果包括两个部分。上面是一个标签云,表示用户的兴趣分布,标签的尺寸越大,表示用户对这个标签相关的图书越感兴趣。比如图13显示了我在豆瓣的阅读兴趣,从上方的标签云可以看到,豆瓣认为我对“编程”、“机器学习”、“软件开发”感兴趣,这是因为我看了很多IT技术方面的图书,豆瓣认为我对“东野圭吾”感兴趣,是因为我看了好几本他的侦探小说,同时因为我对人文学科比较感兴趣,所以豆瓣认为我对“传记”、“文化”比较感兴趣。单击每一个标签云中的标签,都可以在标签云下方得到和这个标签相关的图书推荐,比如图13就是机器学习相关的图书推荐。
豆瓣这样组织推荐结果页面有很多好处。首先这样提高了推荐结果的多样性。我们知道,一个用户的兴趣在长时间内是很广泛的,但在某一天又比较具体。因此,我们如果想在某一天击中用户当天的兴趣,是非常困难的。而豆瓣通过标签云,展示了用户的所有兴趣,然后让用户自己根据他今天的兴趣选择相关的标签,得到推荐结果,从而极大地提高了推荐结果的多样性,使得推荐结果更容易满足用户多样的兴趣。
同时,标签云也提供了推荐解释的作用。用户通过这个界面可以知道豆瓣给自己推荐的每一本书都是基于它认为自己对某个标签感兴趣。而对于每个标签,用户总能通过回忆自己之前的行为来知道自己是否真的对这个标签感兴趣。
我们知道,要让用户直接觉得推荐结果是有道理的,是很困难的。而豆瓣将推荐结果的可解释性拆分成了两个部分,首先让用户觉得标签云是有道理的,然后让用户觉得从某个标签推荐出某本书也是有道理的。因为生成让用户觉得有道理的标签云比生成让用户觉得有道理的推荐图书更加简单,标签和书的关系就更容易让用户觉得有道理,从而让用户最终觉得推荐出来的书也是很有道理的。