美国著名的第三方调查机构尼尔森调查了影响用户相信某个推荐的因素,调查结果显示,9 成的用户相信朋友对他们的推荐,7 成的用户相信网上其他用户对广告商品的评论。从该调查可以看到,好友的推荐对于增加用户对推荐结果的信任度非常重要。
因此,在社交网络的背景下,推荐系统不单单需要关注用户与物品之间的关系,还要关注用户之间的关系。
在社交网站方面,国外以 Fackbook 和 Twitter 为代表,国内社交网站以新浪微博、QQ 空间等为代表。这些社交网站形成了两类社交网络结构。
【社交网络结构】:
需要注意的是,任何一个社会化网站都不是单纯的社交图谱或兴趣图谱。在 QQ 空间中大多数用户联系基于社交图谱,而在微博上大多数用户联系基于兴趣图谱。但在微博中,也会关注现实中的亲朋好友,在 QQ 中也会和部分好友有共同兴趣。
在社交网络中需要表示用户之间的联系,可以用图 G(V, E, W) 定义一个社交网络。其中 V 是顶点集合,每个顶点代表一个用户,E 是边集合,如果用户 V a V_a Va 和 V b V_b Vb 有社交网络关系,那么就有一条边 e ( V a , V b ) e(V_a, V_b) e(Va,Vb) 连接这两个用户, W ( V a , V b ) W(V_a, V_b) W(Va,Vb) 用来定义边的权重。
在之前的博客[推荐系统-基于用户的推荐在社交网络中的应用]中,(https://blog.csdn.net/weixin_43378396/article/details/91129814)我们使用了基于用户社交网络计算用户相似度的方法,但对于新浪微博、微信这样大规模的社交关系,离线计算好用户的相似度并存储下来供线上推荐系统使用,显然不合理。那能否用一个坐标表示来描述用户在社交网络中的位置呢?这样只需提前计算好用户坐标,线上计算用户之间的相似度时,只要计算坐标的距离或者余弦相似度即可。node2vec 可以帮助我们实现这个目标。
【node2vec 整体思路】:
下面我们将分别介绍这两个步骤的计算方法。
【基本流程】:给定一张图 G 和一个起始节点 S,标记起始节点位置为当前位置,随机选择当前位置节点的一个邻居,并将当前位置移动至被选择的邻居位置,重复以上步骤 n 次,最终会得到从初始节点到结束节点的一条长度为 n 的“点序列”,此条“点序列”即称为在图 G 上的一次 random walk。
【示例】:假设我们的起始节点为 A,随机游走步数为 4。
由上面的实例可以看出,random walk 算法主要分为两步:
斯坦福大学计算机教授 Jure Leskovec 给出了一种可以控制广度优先或者深度优先的方法。
以上图为例,我们假设第一步是从 t 随机游走到 v,接下来要确定下一步的邻接节点。参数 p 和 q 用以调整游走节点的倾向。
首先计算当前节点的邻居节点与上一节点 t 的距离 d,根据公式可得 α。
α = { 1 / p , d = 0 1 , d = 1 1 / q , d = 2 \alpha = \begin{cases} 1/p, \quad d = 0 \\ 1, \quad d = 1 \\ 1/q, \quad d = 2 \end{cases} α=⎩⎪⎨⎪⎧1/p,d=01,d=11/q,d=2
根据 α 的值确定下一节点的选择概率。
至此,我们就可以通过 random walk 生成点的序列样本。一般来说,我们会从每个点开始游走 5~10 次,步长则根据点的数量 N 游走 N \sqrt{N} N。
首先,引入所需的包以及数据。
>>> import numpy as np
>>> import pandas as pd
>>> focus = pd.read_csv('data/focus.csv')
>>> focus
userId focus
0 A B
1 B F
2 D B
3 D C
4 E B
5 F B
6 F E
>>> focus_dataset = focus.values
>>> focus_dataset
array([['A', 'B'],
['B', 'F'],
['D', 'B'],
['D', 'C'],
['E', 'B'],
['F', 'B'],
['F', 'E']], dtype=object)
接着,根据数据集建立邻接表。
class Node(object):
def __init__(self, val):
self.value = val
self.neighbors = {}
def __str__(self):
return self.value
def build_neighbors_table(dataset):
header_table = {}
for data in dataset:
user, focus = data[0], data[1]
if user not in header_table:
node_user = Node(user)
header_table[user] = Node(user)
if focus not in header_table:
node_focus = Node(focus)
header_table[focus] = node_focus
header_table[user].neighbors[focus] = header_table[focus]
return header_table
【说明】:build_neighbors_table() 函数接受数据集,并生成邻接表。
header_table = {}
for data in dataset:
user, focus = data[0], data[1]
if user not in header_table:
node_user = Node(user)
header_table[user] = Node(user)
if focus not in header_table:
node_focus = Node(focus)
header_table[focus] = node_focus
header_table[user].neighbors[focus] = header_table[focus]
然后,实现 random walk 算法。由于该算法涉及到的步骤较多,将其拆分为 random_choose() 以及 random_walk() 两部分。先来看 random_choose() 函数的实现。
def random_choose(neighbors, node_cur, node_last, p, q):
# 如果上个节点为 None,则随机选择一个节点
if node_last is None:
random = int(np.ceil(np.random.random() * len(node_cur.neighbors)))
ind = 1
for node_user in neighbors:
if ind == random:
return neighbors[node_user]
ind += 1
# 否则,计算通往各节点的权重,并根据权重选择下一节点
prob = {}
for node_user in neighbors:
node = neighbors[node_user]
if node == node_last:
prob[node] = 1 / p
elif node in node_last.neighbors or node_last in node.neighbors:
prob[node] = 1
else:
prob[node] = 1 / q
total = 0
for key in prob:
total += prob[key]
random = np.random.random() * total
total_prob = 0
for key in prob:
total_prob += prob[key]
if total_prob > random:
return key
【说明】:random_choose() 函数接受五个参数,当前节点的邻居节点 neighbors、当前节点 node_cur,上一个节点 node_last、调节搜索方式的 p 和 q。
if node_last is None:
random = int(np.ceil(np.random.random() * len(node_cur.neighbors)))
ind = 1
for node_user in neighbors:
if ind == random:
return neighbors[node_user]
ind += 1
prob = {}
for node_user in neighbors:
node = neighbors[node_user]
if node == node_last:
prob[node] = 1 / p
elif node in node_last.neighbors or node_last in node.neighbors:
prob[node] = 1
else:
prob[node] = 1 / q
total = 0
for key in prob:
total += prob[key]
random = np.random.random() * total
total_prob = 0
for key in prob:
total_prob += prob[key]
if total_prob > random:
return key
再来看 random_walk 函数的实现。
def random_walk(header_table, iter_count=1, step=5, back=0.5, forward=0.7):
path = []
for user in header_table:
for i in range(iter_count):
node_last = None
node_cur = header_table[user]
path_iter = [node_cur.value]
for j in range(step):
neighbors = node_cur.neighbors
# 若已“无路可走”则退出循环
if len(neighbors) == 0:
break
node_next = random_choose(neighbors, node_cur, node_last, back, forward)
path_iter.append(node_next.value)
node_last = node_cur
node_cur = node_next
path.append(path_iter)
return path
【说明】:random_walk() 函数接受五个参数,头指针字典 header_table,游走次数 iter_count,步长 step 以及回退参数 back(p)和前进参数 forward(q)。
path = []
for user in header_table:
for i in range(iter_count):
node_last = None
node_cur = header_table[user]
path_iter = [node_cur.value]
# ...
for j in range(step):
neighbors = node_cur.neighbors
# 若已“无路可走”则退出循环
if len(neighbors) == 0:
break
# ...
node_next = random_choose(neighbors, node_cur, node_last, back, forward)
path_iter.append(node_next.value)
node_last = node_cur
node_cur = node_next
path.append(path_iter)
return path
【代码测试】:
>>> path = random_walk(header_table, iter_count=2, step=3)
>>> path
[['A', 'B', 'F', 'E'],
['A', 'B', 'F', 'E'],
['B', 'F', 'E', 'B'],
['B', 'F', 'E', 'B'],
['F', 'E', 'B', 'F'],
['F', 'B', 'F', 'B'],
['D', 'C'],
['D', 'B', 'F', 'E'],
['C'],
['C'],
['E', 'B', 'F', 'B'],
['E', 'B', 'F', 'B']]
能够看到每个节点都作为起始点参与 random walk,且游走了两次,步长为 3。
在上一步中,我们已经获得了点的序列样本,那么下一步需要解决的问题是:如何根据点序列生成每个点的特征向量,即我们先前提到的“用户坐标”。
Word2Vec 可以解决这个问题,Word2Vec 是从大量文本语料中以无监督的方式学习语义知识的一种模型,它的核心目标是通过一个嵌入空间将每个词映射到一个空间向量上,并且使得语义上相似的单词在该空间内距离很近。关于 Wrod2Vec 模型可参考 test
实际上 random walk 算法获得的用户节点序列,每一个节点其实对应了 Word2Vec 中的单词,模型的输入是某个用户的 one-hot 编码,输出是该用户在节点序列前后的节点,例如输入是 F 的编码,输出是 A、B、C、D 的概率分布。最后得到的输出是每个节点(即用户)的 Word2Vec 向量。
有了数值化的向量,对于任意两个用户,我们就可以通过余弦距离或霍式距离来计算这两个用户的相似度。
【代码实现】:
from gensim.models import Word2Vec
path = random_walk(header_table, iter_count=2, step=3)
model = Word2Vec(path, min_count=2)
def choose_similarity_user(self, user, user_list, user_num):
similarity_user = []
for user_ in user_list:
if user != user_:
similarity_user.append((user_, self.model_.wv.similarity(user, user_)))
eturn sorted(similarity_user, key=lambda x:x[1], reverse=True)[:user_num]
我们可以直接把 random_walk() 生成的 path 列表直接掉 Word2Vec() 中,让其进行训练。
接下来我们只需要依次判断 user_list 中的用户与当前用户的相似度,从中挑选相似度最高的 user_num 用户。
【完整代码】:可从 GitHub 中获得 传送门