《推荐系统实战》中介绍了基于图的推荐算法,将用户行为数据表示成图的形式。Standford的Haveliwala于2002年在他《Topic-sensitive pagerank》一文中提出了PersonalRank算法。原理上与PageRank是很相似的,区别在于PageRank 中的链接是有向的,而PersonalRank中人于物品之间的连接是无向的,或者说是双向的。
1.1 用户行为的二分图表示
假设用户行为数据是由一系列二元数组组成的,每个二元组(u, i)表示用户u对物品i产生过行为。这种数据集可以用一个二分图(Bipartite)表示,又叫二部图。
令G(V, E)表示用户物品二分图,其中 V = V u ∪ V i V=V_u \cup V_i V=Vu∪Vi由用户顶点集合 V u V_u Vu和物品顶点集合 V i V_i Vi组成。每个二元组(u, i)对应途中的一条边 e ( v u , v i ) e(v_u, v_i) e(vu,vi),其中 v u ∈ V U v_u \in V_U vu∈VU是用户u对应的顶点, v i ∈ V I v_i \in V_I vi∈VI是物品i对应的顶点。
下图是一个简单的用户物品二分图模型,其中圆形节点代表用户,方形节点代表物品,圆形节点和方形节点之间的边代表用户对物品的行为。比如图中用户节点A和物品节点a、b、d相连,说明用户A对物品a、b、d产生过行为。
1.2 图中顶点的相关性
将用户行为表示为二分图模型后,下面的任务就是在二分图上给用户进行个性化推荐。如果将个性化推荐算法放到二分图模型上,那么给用户u推荐物品的任务就可以转化为度量用户顶点 v u v_u vu和与 v u v_u vu没有边直接相连的物品节点在图上的相关性,相关性越高的物品在推荐列表中的权重就越高。
度量图中两个顶点之间相关性的方法很多,但一般来说图中顶点的相关性主要取决于下面3个因素:
举一个简单的例子,如图2-19所示,用户A和物品c、e没有边相连,但是用户A和物品c有1条长度为3的路径相连,用户A和物品e有2条长度为3的路径相连。那么,顶点A与e之间的相关性要高于顶点A与c,因而物品e在用户A的推荐列表中应该排在物品c之前,因为顶点A与e之间有两条路径——(A, b, C, e)和(A, d, D, e)。其中,(A, b, C, e)路径经过的顶点的出度为(3, 2, 2, 2),而(A, d, D, e)路径经过的顶点的出度为(3, 2, 3, 2)。因此,(A, d, D, e)经过了一个出度比较大的顶点D,所以(A, d, D, e)对顶点A与e之间相关性的贡献要小于(A, b, C, e)。
1.3 基于随机游走的PersonalRank算法
假设要给用户u进行个性化推荐,可以从用户u对应的节点 v u v_u vu开始在用户物品二分图上进行随机游走。游走到任何一个节点时,首先按照概率 α 决定是继续游走,还是停止这次游走并从 v u v_u vu节点开始重新游走。如果决定继续游走,那么就从当前节点指向的节点中按照均匀分布随机选择一个节点作为游走下次经过的节点。这样,经过很多次随机游走后,每个物品节点被访问到的概率会收敛到一个数。最终的推荐列表中物品的权重就是物品节点的访问概率。
上面的描述写成公式就是:
f ( n ) = { α ∑ v ′ ∈ ( v ) P R ( v ′ ) ∣ o u t ( v ′ ) ∣ if ( v ≠ v u ) ( 1 − α ) + α ∑ v ′ ∈ ( v ) P R ( v ′ ) ∣ o u t ( v ′ ) ∣ if ( v = v u ) f(n) = \begin{cases} \alpha \displaystyle\sum_{v'\in (v)}\frac{PR(v')}{|out(v')|} & \quad \text{if }(v \neq v_u)\\ (1- \alpha) + \alpha \displaystyle\sum_{v'\in (v)}\frac{PR(v')}{|out(v')|} & \quad \text{if }(v = v_u) \end{cases} f(n)=⎩⎪⎪⎪⎨⎪⎪⎪⎧αv′∈(v)∑∣out(v′)∣PR(v′)(1−α)+αv′∈(v)∑∣out(v′)∣PR(v′)if (v=vu)if (v=vu)
其中, α \alpha α表示随机游走的概率;
PR(v’)表示上一次迭代顶点v’的重要度(在PageRank中PR(i)是网页i的访问概率,也就是重要度),赋初值迭代收敛得到;
在PageRank中out(j)表示网页j指向的网页集合,也就是j的出度。这里out(v’)也表示节点v’指向的顶点集合。
1.4 缺点和改进
虽然PersonalRank算法可以通过随机游走进行比较好的理论解释,但该算法在时间复杂度上有明显的缺点。因为在为每个用户进行推荐时,都需要在整个用户物品二分图上进行迭代,直到整个图上的每个顶点的PR值收敛。这一过程的时间复杂度非常高,不仅无法在线提供实时推荐,甚至离线生成推荐结果也很耗时。
为了解决PersonalRank每次都需要在全图迭代并因此造成时间复杂度很高的问题,<<推荐系统实战>>给出两种解决方案。第一种很容易想到,就是减少迭代次数,在收敛之前就停止。这样会影响最终的精度,但一般来说影响不会特别大。另一种方法就是从矩阵论出发,重新设计算法。
令M为用户物品二分图的转移概率矩阵:
M ( v , v ′ ) = 1 ∣ o u t ( v ) ∣ M(v, v')=\dfrac{1}{|out(v)|} M(v,v′)=∣out(v)∣1
那么,迭代公式可以转化为:
r a n k = ( 1 − α ) r 0 + α M T r a n k rank = (1-\alpha)r_0 + \alpha M^T rank rank=(1−α)r0+αMTrank
用矩阵论的方法解出上面的方程,得到:
R a n k = ( 1 − α ) ( 1 − α M T ) − 1 r 0 Rank = (1-\alpha)(1-\alpha M^T)^{-1}r_0 Rank=(1−α)(1−αMT)−1r0
因此,只需要计算一次 ( 1 − α M T ) − 1 (1-\alpha M^T)^{-1} (1−αMT)−1,这里 1 − α M T 1-\alpha M^T 1−αMT是稀疏矩阵。可以通过稀疏矩阵快速求逆来得解(比如Generalized_minimal_residual_method)。
在scipy中提供了多种稀疏矩阵的存储方法:coo,lil,dia,dok,csr,csc等,各有各的优缺点,dok可以快速的按下标访问元素,csr和csc适合做矩阵的加法、乘法运算,lil省内存且按下标访问元素也很快。
参考实现:
https://github.com/lpty/recommendation
另外jamest给出了矩阵实现,代码如下:
#-*-coding:utf-8-*-
"""
author:jamest
date:20190310
PersonalRank function with Matrix
"""
import pandas as pd
import numpy as np
import time
import operator
from scipy.sparse import coo_matrix
from scipy.sparse.linalg import gmres
class PersonalRank:
def __init__(self,X,Y):
X,Y = ['user_'+str(x) for x in X],['item_'+str(y) for y in Y]
self.G = self.get_graph(X,Y)
def get_graph(self,X,Y):
"""
Args:
X: user id
Y: item id
Returns:
graph:dic['user_id1':{'item_id1':1}, ... ]
"""
item_user = dict()
for i in range(len(X)):
user = X[i]
item = Y[i]
if item not in item_user:
item_user[item] = {}
item_user[item][user]=1
user_item = dict()
for i in range(len(Y)):
user = X[i]
item = Y[i]
if user not in user_item:
user_item[user] = {}
user_item[user][item]=1
G = dict(item_user,**user_item)
return G
def graph_to_m(self):
"""
Returns:
a coo_matrix sparse mat M
a list,total user item points
a dict,map all the point to row index
"""
graph = self.G
vertex = list(graph.keys())
address_dict = {}
total_len = len(vertex)
for index in range(len(vertex)):
address_dict[vertex[index]] = index
row = []
col = []
data = []
for element_i in graph:
weight = round(1/len(graph[element_i]),3)
row_index= address_dict[element_i]
for element_j in graph[element_i]:
col_index = address_dict[element_j]
row.append(row_index)
col.append(col_index)
data.append(weight)
row = np.array(row)
col = np.array(col)
data = np.array(data)
m = coo_matrix((data,(row,col)),shape=(total_len,total_len))
return m,vertex,address_dict
def mat_all_point(self,m_mat,vertex,alpha):
"""
get E-alpha*m_mat.T
Args:
m_mat
vertex:total item and user points
alpha:the prob for random walking
Returns:
a sparse
"""
total_len = len(vertex)
row = []
col = []
data = []
for index in range(total_len):
row.append(index)
col.append(index)
data.append(1)
row = np.array(row)
col = np.array(col)
data = np.array(data)
eye_t = coo_matrix((data,(row,col)),shape=(total_len,total_len))
return eye_t.tocsr()-alpha*m_mat.tocsr().transpose()
def recommend_use_matrix(self, alpha, userID, K=10,use_matrix=True):
"""
Args:
alpha:the prob for random walking
userID:the user to recom
K:recom item num
Returns:
a dic,key:itemid ,value:pr score
"""
m, vertex, address_dict = self.graph_to_m()
userID = 'user_' + str(userID)
print('add',address_dict)
if userID not in address_dict:
return []
score_dict = {}
recom_dict = {}
mat_all = self.mat_all_point(m,vertex,alpha)
index = address_dict[userID]
initial_list = [[0] for row in range(len(vertex))]
initial_list[index] = [1]
r_zero = np.array(initial_list)
res = gmres(mat_all,r_zero,tol=1e-8)[0]
for index in range(len(res)):
point = vertex[index]
if len(point.strip().split('_'))<2:
continue
if point in self.G[userID]:
continue
score_dict[point] = round(res[index],3)
for zuhe in sorted(score_dict.items(),key=operator.itemgetter(1),reverse=True)[:K]:
point,score = zuhe[0],zuhe[1]
recom_dict[point] = score
return recom_dict
if __name__ == '__main__':
moviesPath = '../data/ml-1m/movies.dat'
ratingsPath = '../data/ml-1m/ratings.dat'
usersPath = '../data/ml-1m/users.dat'
# usersDF = pd.read_csv(usersPath,index_col=None,sep='::',
header=None,names=['user_id', 'gender', 'age', 'occupation', 'zip'])
# moviesDF = pd.read_csv(moviesPath,index_col=None,sep='::',
header=None,names=['movie_id', 'title', 'genres'])
ratingsDF = pd.read_csv(ratingsPath, index_col=None, sep='::', header=None,
names=['user_id', 'movie_id', 'rating', 'timestamp'])
X=ratingsDF['user_id'][:1000]
Y=ratingsDF['movie_id'][:1000]
rank = PersonalRank(X,Y).recommend_use_matrix(alpha=0.8,userID=1,K=30)
print('PersonalRank result',rank)
Stanford有一个快速计算Personal PageRank的Talk
其开源代码是scala写的。
部分摘录如下:
Personalized PageRank简介:
Given: 源 s, 目的 t,以及"teleport probability"(佩奇命名的科幻瞬间转移概率) α \alpha α
- 从源s开始随机游走;
- 每一步,以 α \alpha α的概率停止前进,否则continue。
那么给出从 s 到 t 的Personalized PageRank:
π s ( t ) = P [ W a l k f r o m s s t o p s a t t ] \bf{\pi}_s(t)= \mathbb{P}[\mathtt{Walk\, from}\,\textit{s}\, \mathtt{stops\, at}\,\textit{t}] πs(t)=P[Walkfromsstopsatt]
目标
给定 α \alpha α,出发节点 s,单个的目的节点 t ,threshold δ \delta δ
目标是估计: π s ( t ) \pi_s(t) πs(t)
当 π s ( t ) > δ \pi_s(t)>\delta πs(t)>δ时:
根据
以前的蒙特卡洛算法以 O ( 1 δ ) O(\frac{1}{\delta}) O(δ1)的复杂度从s开始随机游走,运行时间是 Θ ( 1 δ ) \Theta(\dfrac{1}{\delta}) Θ(δ1)
以前的本地更新算法从目的节点t 沿着边反向游走,并在本地更新Personal PageRank值,平均运行时间为 O ( d ˉ δ ) \large O(\dfrac{\bar{d}}{\delta}) O(δdˉ),其中 d ˉ = ∣ E ∣ ∣ V ∣ \bar{d}=\frac{|E|}{|V|} dˉ=∣V∣∣E∣(边的个数/顶点个数)
得到定理: