1. Abstract
在过往很多的Graph embedding都是通过节点的相似度组织语料,如node2vec,deepwalk都是基于根据邻居节点的相似度来组织语料,然后使用word2vec来进行训练.这种无监督的训练方式将相似的节点进行聚合,然而他们更加关注"相邻性",而忽视了"结构相似性".虽然node2vec也考虑到结构性(structural),但实际上它仍然使用一种相邻节点的游走策略组织语料,它使用p, q两个参数来平衡着两个属性.
本文介绍的Struc2Vec主要考虑节点的结构性并且使用多层将不同跳数的节点组织起来,使用alias算法进行采样和转移概率的方式组织训练所需要的语料,最后通过word2vec将其训练.当然代码和算法逻辑相对于node2vec和deepwalk更加复杂,抱着学习的心态我们一起来学习一下struc2Vec的算法核心和代码逻辑吧
2. Main Work
struc2vec利用节点的度来衡量两个节点之间的节点相似性,再通过节点的跳数对节点进行分层.进而构建了多层的结构相似度拓扑图.
这其实与node2veec以及deepwalk是很不同的,前者是关注于拓扑图的结构相似度,后者则是关注于节点的相邻性.因此虽然两种方法都是进行无监督学习,但是聚类的目的是不同,不能绝对地说两者优劣.那么接下来我们先说一下struc2vec是如何构建的.
2.1 weight compute
论文当中给出以下的公式来计算两个节点直接的距离和权值
上面参数有一下解释
- f_{k}(u, v): u, v两个节点的在第k跳的结构相似度
- R_k(u): u节点的第k跳的相邻节点集合
- s(D): 节点集合D的节点度的有序序列,如节点集合D每个节点的度为[2, 34, 5, 1, 2],那么s(D)为[1, 2, 2, 5, 34]
- g(S1, S2): 计算两个节点度的有序序列的距离,考虑到S1, S2两个序列的长度不一定一样,因此使用DTW进行对齐,有趣去的朋友可以看一下这个链接(Dynamic Time Warping)动态时间规整,这里就不详细介绍了
对于g(S1, S2),我们会计算序列当中对齐的每一个值来运算其序列的距离.
S1和S2对齐后,可以得到一对值a属于S1, b属于S2,以便后续求和得到两个集合的实际距离.回顾之前这一堆繁杂的公式,其本质上是:计算u, v两个节点的结构距离就是找出每一跳的邻居节点集合
R_k
(第k跳的邻居节点结合),根据两个节点的邻居节点集合R_k
计算出集合当中节点的度,接着排序好每个度值再对齐,接着计算出u, v两个节点在第k跳的距离相似度.有了两个节点的距离就可以求解两个节点之间的权值了:
相对于求距离的计算公式,这个更加容易理解.到这里存在一个问题:假如要计算每对节点的权值可是十分耗时,而且k跳也是增加计算量.因此如何将该算法简化成为论文的另外一个亮点.因此struc2vec作者提出了优化点:
- 缩短order degree sequences的长度(前文提到的节点度的有序序列)
- 限制计算节点的对数(reduce the number of pairwise)
- 限制k的值,也就是layers的数量
在实际当中,order degree sequences会出现大量重复的值因为节点的度分布是长尾分布,因此使用(degree, the number of node)作为pair来缩短序列长度.node的数量其实是节点度数为degree的节点数量.然后使用另外一种距离函数来计算pairwise的距离
我们用
(a0, a1), (a1, b1)
作为两个节点的pair值,函数当中打括号部分跟前面的距离函数一样的,但是多出max(a1, b1)
这是因为相对于之前order degree sequences存在大量重复的值,我们通过统计dergee的节点个数压缩了其长度那么计算的时候就要乘以其最大的重复数量.接着就是限制了计算的节点对数,假如所有的数量都进入计算,那么计算个数是n*(n-1)/2
.因此论文作者设置了限制计算节点的个数为: log(n)
.同时也限制了k的个数,也是后面的待会会谈到的layers数量.下面是计算结构距离的函数
_compute_structural_distance
:
def _compute_structural_distance(self, max_num_layers, workers=1, verbose=0):
# return:
# dtw_dict = {
# (v1, v2): {
# layer1: dist1,
# layer2: dist2,
# ...
# },
# (v3, v4): {
# layer1: dist1,
# layer2: dist2,
# ...
# }, ...
# }
if os.path.exists(self.temp_path + 'structural_dist.pkl'):
structural_dist = pd.read_pickle(self.temp_path+'structural_dist.pkl')
else:
if self.opt1_reduce_len:
dist_func = cost_max
else:
dist_func = cost
if os.path.exists(self.temp_path + 'degreelist.pkl'):
degree_list = pd.read_pickle(self.temp_path + 'degreelist.pkl')
else:
# 构建每个节点,max_layers跳(keys: 第i跳,value: [(degree, #node)])
degree_list = self._compute_ordered_degreelist(max_num_layers)
pd.to_pickle(degree_list, self.temp_path + 'degreelist.pkl')
if self.opt2_reduce_sim_calc:
# 创建节点相邻的degrees dict,以degree为key,其value有vertices,before,after
degrees = self._create_vectors()
degree_list_selected = {}
vertices = {}
n_nodes = len(self.graph.nodes())
for v in self.idx:
# 获取相邻的节点(度数越相近越相邻),主要考虑结构相似度
node = self.idx2node[v]
nbs = get_vertices(v, len(self.graph[node]), degrees, n_nodes)
vertices[v] = nbs
# 递归获取一定量的相邻节点
# 获取v节点下,不同层的dergee信息(按照degree进行排序的节点个数)
degree_list_selected[v] = degree_list[v]
for n in nbs:
degree_list_selected[n] = degree_list[n]
else:
vertices = {}
for v in degree_list:
vertices[v] = [vd for vd in degree_list.keys() if vd > v]
results = Parallel(n_jobs=workers, verbose=verbose,)(delayed(compute_dtw_dist)(part_list, degree_list, dist_func) for part_list in partition_dict(vertices, workers))
dtw_dist = dict((ChainMap(*results)))
structural_dist = convert_dtw_struc_dist(dtw_dist)
pd.to_pickle(structural_dist, self.temp_path + 'structural_dist.pkl')
return structural_dist
构建不同度的值的节点列表_create_vectors
def _create_vectors(self):
# 获取每个度值的节点,并按照degree进行排序,记录下该degree下的节点有哪些,前后两个度值的节点有哪些
# degrees = {
# degree_value1: {
# 'vertices': [v1, v2, ...],
# 'before': [v1, v2, ...],
# 'after': [v1, v2, ...]}
# },
# degree_value2: {
# 'vertices': [v1, v2, ...],
# 'before': [v1, v2, ...],
# 'after': [v1, v2, ...]}
# },
# ....
# }
degrees = {} # key: graph中的度数,value: 度数的节点
degrees_sorted = set() # 存放graph当中的度数
for v in self.idx:
degree = len(self.graph[self.idx2node[v]])
degrees_sorted.add(degree)
if (degree not in degrees):
degrees[degree] = {}
degrees[degree]['vertices'] = []
degrees[degree]['vertices'].append(v)
degrees_sorted = np.array(list(degrees_sorted), dtype='int')
degrees_sorted = np.sort(degrees_sorted)
l = len(degrees_sorted)
for index, degree in enumerate(degrees_sorted):
if (index > 0):
# 记录比该度数小的前一个度数值
degrees[degree]['before'] = degrees_sorted[index - 1]
if (index < (l - 1)):
# 记录比该度数大的后一个度数值
degrees[degree]['after'] = degrees_sorted[index + 1]
return degrees
下面是_compute_ordered_degreelist
是构建节点的度和对应的节点个数,_get_order_degreelist_node
是构建节点的有序度序列,它本质上层次遍历.
def _compute_ordered_degreelist(self, max_num_layers):
# 构建多层关于度排序的节点数量,用于计算节点之间的距离
degree_list = {}
vertices = self.idx
for v in vertices:
# 根据不同的node建立不同的树
# degree_list =
# v : {
# level_1: {
# (degree_1, number of node),
# (degree_2, number of node)
# },
# level_2: {....}}
degree_list[v] = self._get_order_degreelist_node(v, max_num_layers)
return degree_list
def _get_order_degreelist_node(self, root, max_num_layers=None):
# 顶点对距离计算
# 用一个循环去计算每个顶点对应的有序度序列。
if max_num_layers is None:
max_num_layers = float('inf')
ordered_degree_sequence_dict = {}
visited = [False] * len(self.graph.nodes())
queue = deque()
level = 0
queue.append(root)
visited[root] = True
# 进行层次遍历
while (len(queue) > 0 and level <= max_num_layers):
count = len(queue) # 当前layer节点个数
if self.opt1_reduce_len:
degree_list = {}
else:
degree_list = []
while (count > 0):
top = queue.popleft()
# 获取邻居个数(度)
node = self.idx2node[top]
degree = len(self.graph[node])
if self.opt1_reduce_len:
# 统计当前层下的同样度的节点个数
degree_list[degree] = degree_list.get(degree, 0) + 1
else:
degree_list.append(degree)
# 将结论邻居放入队列当中
for i in self.graph[node]:
if not visited[int(i)]:
visited[int(i)] = True
queue.append(int(i))
count -= 1
if self.opt1_reduce_len:
# 按照degree进行排序
order_degree_list = [(degree, freq) for degree, freq in degree_list.items()]
order_degree_list.sort(key=lambda x: x[0])
else:
order_degree_list = sorted(degree_list)
# level作为key保存order degree序列
ordered_degree_sequence_dict[level] = order_degree_list
level += 1
return ordered_degree_sequence_dict
使用fastdtw计算compute_dtw_dist
节点之间结构性的距离
def compute_dtw_dist(part_list, degree_list, dist_func):
# part_list: {v: nbs}
# degree_list =
# v : {
# level_1: {
# degree_1, number of node,
# degree_2, number of node
# },
# level_2: {....}}
# return:
# dtw_dict = {
# (v1, v2): {
# layer1: dist1,
# layer2: dist2,
# ...
# },
# (v3, v4): {
# layer1: dist1,
# layer2: dist2,
# ...
# }, ...
# }
dtw_dict = {}
# 获取v1的degree_list和v1邻居节点degree_list
for v1, nbs in part_list:
lists_v1 = degree_list[v1] # orderd degree list of v1
for v2 in nbs:
lists_v2 = degree_list[v2] # orderd degree list of v2
max_layer = min(len(lists_v1), len(lists_v2))
dtw_dict[v1, v2] = {}
for layer in range(0, max_layer):
# v1节点与邻居节点的距离(按照layer计算)
dist, path = fastdtw(lists_v1[layer], lists_v2[layer], radius=1, dist=dist_func)
dtw_dict[v1, v2][layer] = dist
return dtw_dict
上面一大段又长又丑的代码肯定是很难看的,后面我会给github的地址让大家可以看看完整的代码.
2.2 multi layers construct
有了权值,那么就需要构建一个用于游走的多层图.刚刚我们的计算方式是用于后续计算层内的节点相似度,那么对于层外呢?层与层之间的节点的转移呢,我们之所以考虑构建多层图就是为了构建一个用于不同跳的结构相似度.所以在这一小节我们需要考虑层与层之间的转移权值.
下面解释一下公式当中每个符号的含义:
- gamma函数: 统计在第k层的u节点与其他节点之间的权值大于平均权值的节点个数
- w(u_k, u_{k + 1}): 是从k层u节点跳到去k+1层的u节点的权值,使用log函数是为了避免gamma函数值过大
好了到这里多层的layers结构就构建出来了,根据不同层构建gamma函数的映射
def prepare_biased_walk(self,):
# 论文当中3.2节构建graph上下文
# 构建每一层内部的平均权值和每层每个节点的gamma函数映射,在后续随机游走的时候计算层与层之间的转移概率
sum_weights = {} # sum layer edge weights
sum_edges = {} # number of edges
average_weight = {} # each layer average weight of edges
gamma = {}
layer = 0
while (os.path.exists(self.temp_path + 'norm_weights_distance-layer-' + str(layer) + '.pkl')):
# 读取每层每个节点与邻居节点的权值
probs = pd.read_pickle(self.temp_path + 'norm_weights_distance-layer-' + str(layer) + '.pkl')
for v, list_weights in probs.items():
sum_weights.setdefault(layer, 0)
sum_edges.setdefault(layer, 0)
sum_weights[layer] += sum(list_weights)
sum_edges[layer] += len(list_weights)
average_weight[layer] = sum_weights[layer] / sum_edges[layer]
# 构建gamma函数: 计算当前层的节点v,在当前层的相邻节点的权值大于全局平均权值的节点个数
gamma.setdefault(layer, {})
for v, list_weights in probs.items():
num_neighbours = 0
# 筛选权值,统计大于该层的平均权值的权值节点个数(符合论文当中的优化)
for w in list_weights:
if (w > average_weight[layer]):
num_neighbours += 1
gamma[layer][v] = num_neighbours
layer += 1
pd.to_pickle(average_weight, self.temp_path + 'average_weight')
pd.to_pickle(gamma, self.temp_path + 'gamma.pkl')
2.3 compute transition probability
这是对于同一层的不同节点的转移概率:
这是对于同一节点转移到不同层转移概率:
设置一个stay_prob
来判断游走是否留在同一层,若保留同一层我们使用alias sample算法进行权值采样游走到其他节点,起始alias sample算法是为了加速采样速度,假如有n个节点,一个均匀采样算法的时间复杂度为O(n),但是alias sample的时间复杂度为O(1).我这里就不详细解释alias sample,有兴趣的朋友可以进入这个link看看时间复杂度O(1)的离散采样算法—— Alias method/别名采样方法.随机游走的代码如下:
def _exec_random_walk(self, graphs, layers_accept, layers_alias, v, walk_length, gamma, stay_prob=0.3):
initialLayer = 0
layer = initialLayer
path = []
path.append(self.idx2node[v])
while len(path) < walk_length:
r = random.random()
if (r < stay_prob): # 下一步在同一层当中游走
v = chooseNeighbor(v, graphs, layers_alias, layers_accept, layer)
path.append(self.idx2node[v])
else: # 下一步前往上一层或者下一层
r = random.random()
try:
# x: 转移到H+1层的权值大小
x = math.log(gamma[layer][v] + math.e)
# p_moveup: 转移到H+1层的概率
p_moveup = (x / (x + 1))
except:
print(layer, v)
raise ValueError()
if (r > p_moveup and layer > initialLayer):
layer = layer - 1
elif (r <= p_moveup and (layer + 1) in graphs and v in graphs[layer + 1]):
layer = layer + 1
return path
这时候语料就组织好了.那么剩下的是训练了,训练的方法就是word2vec.算法介绍大概完成了.
3 Experiment and conclusion
这里我实验了论文当中的europe-airports和usa-airports两份数据,但实验起来发现并没有论文上面说得accuracy这么好,可能是论文没有公布它使用的分类器是什么.我只是简单使用LogisticRegression进行分类.
dataset name | micro | macro | acc |
---|---|---|---|
usa-airports | 52.10% | 51.86% | 52.10% |
brazil-airports | 71.43% | 73.57% | 71.43% |
文章目的是使用拓扑图当中的结构相似性,进行无监督学习.但假如task并不是偏向于structure,那么做这种无监督的学习是无用的.我这里使用另外一个数据集合wiki去证明这件事.
dataset name | micro | macro | acc |
---|---|---|---|
wiki | 18.26% | 9.89% | 18.26% |
显然效果差得不行,也证明了wiki的label数据并不是根据节点的结构性来进行划分.所以请各位使用每个模型的时候,请认真分析模型是否真的适合当前的场景,而不是看到某个task不行就说它毫无用地.模型研究其实也是一种特征工程和分析.
最后放出github地址:https://github.com/Salon-sai/learning-tensorflow/tree/master/lesson11