一.引言
前面介绍了 DeepWalk,Node2vec,通过不同游走方法获取游走序列,然后通过 word2vec 进行 embedding 训练, 在 word2vec 训练中,所有向量共用了同一个 embedding 空间即同一个 Variable,Line 提出了一阶和二阶相似度的概念,从而丰富了 embedding 空间。
二.Line 算法
1.Line 简介
如上是一个信息网络的例子,在上例中 6 和 7 存在直连关系,可以判定为有较强联系,按照传统方法, 5 和 6 没有相连一定意义上联系不大,但是可以看到 5,6 有相似的邻居 1,2,3,4,所以根据 LookAlike 的思想, 5 和 6 有很大的相似性或者相关性,只不过没有体现在边的直连上。
A.一阶相似度
一阶相似度描述了网络中两个临近顶点之间的相关性,对于边 (u,v),该边上的权重为 表示其一阶相似度,如果没有边直连则一阶相似度为0。如上例所示,直连边只体现出了两个顶点之间的一部分信息量,如果只考虑一阶相似度,则无法捕捉到两个顶点拥有很多相似邻居的信息,所以需要通过二阶相似度补充丢失的信息量,有点类似于 LR 模型和 FM 模型 延伸到 DNN,DeepFM 一样。
B.二阶相似度
网络中一对顶点(u,v)之间的二阶相似度描述顶点的邻域的相似度。令 表示顶点 u 与其他顶点间的一阶相似度,则 u 与 v 的二阶相似度可以通过比较
与
的相似度,若 u,v 没有公共邻居顶点,则 u,v 的二阶相似度为0。
2.Line 算法
A.一阶相似度
一阶相似度描述网络间相似顶点的相似性,对于每个无向边 (i,j),定义 Vi 与 Vj 之间的联合概率:
为顶点的低维向量表示,在深度模型中可以理解为一个 Variable 中初始化的向量,同时定义经验分布
一阶相似度的优化目标即为:
衡量两个分布的近似度考虑K-L散度,通过对数的极大似然方法将优化目标转换为:
论文中提到一阶近似值只适用于无向图,不用于有向图,通过最小化上述函数得到的向量 embedding 可以标识 d-dimensional 空间中的每个顶点。
B.二阶相似度
二阶相似度同时适用于有向图和无向图,二阶近似假设共享多个相同邻域顶点的两个顶点相似,该情况下需要对向量引入两个向量表征,一个是顶点本身的 embedding,另一个是作为其他顶点上下文即邻域的表征,所以问前提到,对于一个顶点,有两个向量空间表征,也是其和 word2vec 不同的地方。对于任意边 (i,j),定义条件概率 vi 存在情况下 vj 存在的概率,这里 代表其本身的向量,
代表其作为上下文的表征, |V| 代表上下文顶点的数量:
同样的有经验分布,wij为带权边的权重,di为顶点vi的出度,非带权图即等权图出度为顶点邻点个数,带权图时为权重求和,其中 N(I) 为 vi 的外邻域集:
二阶优化目标为:
通过K-L散度计算分布距离,并设置 λi = di 并省略常数项得到:
通过学习自身向量 和上下文向量
最小化目标函数可以得到每个顶点的向量表征。
3.Line 技巧
A.负采样
计算 O2 函数时分母需要遍历所有 |V| 个顶点,非常低效,Line 通过引入负采样提高迭代效率,对于每条边 (i,j),修改后的目标函数为:
为 sigmoid 函数,前面函数优化正样本,后面的部分优化负样本,其中 K 为负样本个数。
B.ASGD (Mini-Batch)
在每个步骤中ASGD算法对一小批边进行采样,然后更新模型参数,mini-batch 可以提高训练效率。注意这里 与 edge 相乘,当
具有较大方差时,模型的更新会出现偏好,可能侧向于学习次数多且权重大的样本。
C.Alias Sample
通过 Alias Sample 将采样的时间复杂度优化至 O(1),关于 AliasSample 可以参考: AliasSample 图文详解https://blog.csdn.net/BIT_666/article/details/119865106https://blog.csdn.net/BIT_666/article/details/119865106
D.Discuss
关于低度顶点的精准嵌入:
基于二阶相似度的方法严重依赖"上下文"节点的数量,对于节点邻域非常少,难以学习或精确推断时,可以考虑扩充更高的顶点为该类样本作为邻居,例如 dx=2,dx=3 等等,dx代表两点之间距离,,其中顶点i与其二阶邻居j的权重值可以表示为:
E.优化目标:
对于一个新的顶点 i,如果他与现有顶点的连接是已知的,则可以获取经验分布 P1(·,vi) 和 P2(·,vi),为了获得新顶点的嵌入,根据上述目标函数方程,一种直接的方法是最小化下面任一目标函数:
三.Line 实践
1.生成带权图
这里生成1000条随机权重的边,通过 networkx 生成并读取,也可以调用 nx.draw(G) 查看随机生成的图。
# 1.生成带权图
file_name = ".../data/test_edges.txt"
G = createAndLoadGraph(file_name)
Tip: 辅助函数
A.随机生成 SampleNum 个样本并加载为 Graph
def createAndLoadGraph(file_name):
f = open(file_name, "w")
sample_num = 1000
for i in range(sample_num):
a = random.randint(0, 100)
b = random.randint(0, 100)
weight = random.random()
if a != b:
f.write("%s %s %s\n" % (a, b, weight))
f.close()
G = nx.read_edgelist(file_name, create_using=nx.DiGraph(), nodetype=None, data=[('weight', float)])
return G
2.构建AliasTable
这里 sample_per_epoch 为一次迭代的全部样本数,每条边作为一个正样本,负采样生成 K 个负样本,所以一个 epoch 的样本为 edge_size x (1 + K)
# 2.获取映射关系与AliasTable
# node-id的映射 node2idx = {} 通过node获取对应id
# 构建id-node的数组 idx2node = [] 通过id获取对应node
idx2node, node2idx = preprocess_nxgraph(G)
negative_ratio = 15 # 负采样比例
# 点与边的数量以及每个epoch的样本
node_size = G.number_of_nodes()
edge_size = G.number_of_edges()
# 1个正样本 negative个负样本
samples_per_epoch = edge_size * (1 + negative_ratio)
# 生成 Alias Table
node_accept, node_alias, edge_accept, edge_alias = gen_sampling_table(G, node2idx)
A.生成映射关系
通过 graph 获取 node-id,id-node 的映射关系
def preprocess_nxgraph(graph):
node2idx = {}
idx2node = []
node_size = 0
for node in graph.nodes():
node2idx[node] = node_size
idx2node.append(node)
node_size += 1
return idx2node, node2idx
B.生成 Node 和 Edge 采样表
参考之前讲到的 Node2vechttps://blog.csdn.net/BIT_666/article/details/119890792?spm=1001.2014.3001.5501https://blog.csdn.net/BIT_666/article/details/119890792?spm=1001.2014.3001.5501这里需要对 Node 和 Edge 各生成一个 AliasTable 来应对起点是 Node 还是 Edge 的情况,
def gen_sampling_table(graph, node2idx):
# create sampling table for vertex
power = 0.75
numNodes = node_size
node_degree = np.zeros(numNodes) # out degree
node2idx = node2idx
for edge in graph.edges():
# node2idx[edge[0]] 获取边的起始节点 并累加对应起始节点的 degree 度
node_degree[node2idx[edge[0]]] += graph[edge[0]][edge[1]].get('weight', 1.0)
# 对全部节点的 degree 做归一化
total_sum = sum([math.pow(node_degree[i], power)
for i in range(numNodes)])
norm_prob = [float(math.pow(node_degree[j], power)) /
total_sum for j in range(numNodes)]
# 构建 Node 的 Alias Table
_node_accept, _node_alias = create_alias_table(norm_prob)
# 构建 Edge 的 Alias Table
numEdges = graph.number_of_edges()
total_sum = sum([graph[edge[0]][edge[1]].get('weight', 1.0)
for edge in graph.edges()])
norm_prob = [graph[edge[0]][edge[1]].get('weight', 1.0) *
numEdges / total_sum for edge in graph.edges()]
_edge_accept, _edge_alias = create_alias_table(norm_prob)
return _node_accept, _node_alias, _edge_accept, _edge_alias
C.构建AliasTable
将转移概率归一化后送给 create_alias_table 生成 accept 和 alias ,具体的流程可参考:
def create_alias_table(transform_prob):
# 当前node的邻居的nbr的转移概率之和
norm_const = sum(transform_prob)
# 当前node的邻居的nbr的归一化转移概率
normalized_prob = [float(u_prob) / norm_const for u_prob in transform_prob]
length = len(transform_prob)
# 建表复杂度o(N)
accept, alias = [0] * length, [0] * length
# small,big 存放比1小和比1大的索引
small, big = [], []
# 归一化转移概率 * 转移概率数
transform_N = np.array(normalized_prob) * length
# 根据概率放入small large
for i, prob in enumerate(transform_N):
if prob < 1.0:
small.append(i)
else:
big.append(i)
while small and big:
small_idx, large_idx = small.pop(), big.pop()
accept[small_idx] = transform_N[small_idx]
alias[small_idx] = large_idx
transform_N[large_idx] = transform_N[large_idx] - (1 - transform_N[small_idx])
if np.float32(transform_N[large_idx]) < 1.:
small.append(large_idx)
else:
big.append(large_idx)
while big:
large_idx = big.pop()
accept[large_idx] = 1
while small:
small_idx = small.pop()
accept[small_idx] = 1
return accept, alias
3.初始化模型
# 3.构建模型
embedding_size = 8 # embedding size
order = "all" # 选择组合方式 first second all
embeddings = {} # 全部 embedding
model, embedding_dict = create_model(node_size, embedding_size, order)
opt = "adam"
model.compile(opt, line_loss)
A.构建模型
模型输入就是一个边对应的两个 Node,输出是二者 embedding 内积的结果,embedding 通过 Embedding 层 lookup 得到
First:
i,j 共享一个向量空间 first_emb,描述二者一阶相似度
Second:
这里初始化的意思是每个向量都有两个属性,自身向量 v_i 和环境向量 c_i,v_i 来自 second——emb,c_i 来自 context_emb 标识当前 Node 的上下文向量, 如果向量 i 的自身向量 v_i 和向量 j 的上下文向量 c_j 契合度很高,可以理解为 j 的邻居和 i 也有很多边连接,这就是二阶相似度提到判断无直连边但相似节点的判断方法,根据 order 的不同,模型可以支持多输出。不过这里 context_emb 我觉得用当前节点的邻居的 embedding 池化得到也是一种实现方案,这样可以更好地获取邻居的信息,有兴趣可以实现以下。
def create_model(numNodes, embedding_size, order='second'):
# 两个端点
v_i = Input(shape=(1,))
v_j = Input(shape=(1,))
first_emb = Embedding(numNodes, embedding_size, name='first_emb')
second_emb = Embedding(numNodes, embedding_size, name='second_emb')
context_emb = Embedding(numNodes, embedding_size, name='context_emb')
# i,j 都拿一阶 embedding
v_i_emb = first_emb(v_i)
v_j_emb = first_emb(v_j)
# i 拿二阶 embedding j 拿 context embedding
v_i_emb_second = second_emb(v_i)
v_j_context_emb = context_emb(v_j)
# 考虑两个点的一阶和二阶
first = Lambda(lambda x: tf.compat.v1.reduce_sum(
x[0] * x[1], axis=-1, keep_dims=False), name='first_order')([v_i_emb, v_j_emb])
second = Lambda(lambda x: tf.compat.v1.reduce_sum(
x[0] * x[1], axis=-1, keep_dims=False), name='second_order')([v_i_emb_second, v_j_context_emb])
# 考虑不同的参数带来的不同模型
if order == 'first':
output_list = [first]
elif order == 'second':
output_list = [second]
else:
output_list = [first, second]
model = Model(inputs=[v_i, v_j], outputs=output_list)
print("ModelFormat: %s" % order)
model.summary()
return model, {'first': first_emb, 'second': second_emb}
B.损失函数
最后提高可以优化如下两个目标函数:
P1(i,j) 为 sigmoid 的形式,前面加上 log ,即为 line_loss 样式:
P2(i,j) 优化后也具有 line_loss 的样式,这里 为 sigmoid 函数:
def line_loss(y_true, y_pred):
return -K.mean(K.log(K.sigmoid(y_true * y_pred)))
4.采样样本
构建样本迭代器供模型训练使用
# 4.构建样本
batch_it = batch_iter(G, node2idx, batch_size, order)
下面通过 mod 和 batch_size 控制生成每个 batch 的样本,h,t 代表一个 edge 的两个 Node,如果 mod 为0,则生成一个正样本,随后 mod += 1,随机 Alias 生成negative_ratio 个负样本,该逻辑直到一个 batch 的样本生成在重新置 mod 为0,重新开始。需要注意如果使用多输出模型,则对应的标记 sigh 也需要多份,这里正例为 +1,负例为 -1。
def batch_iter(graph, node2idx, batch_size, order: str):
# 获取全部边 (Node1, Node2)构成
edges = [(node2idx[x[0]], node2idx[x[1]]) for x in graph.edges()]
# 获取全部边的数量
data_size = graph.number_of_edges()
shuffle_indices = np.random.permutation(np.arange(data_size))
# positive or negative mod
mod = 0
mod_size = 1 + negative_ratio
h = []
# 一个 batch 取 batch_size 个数据
count = 0
start_index = 0
end_index = min(start_index + batch_size, data_size)
while True:
if mod == 0:
h = []
t = []
for i in range(start_index, end_index):
# < prob 保留 > prob 选取 alias
if random.random() >= edge_accept[shuffle_indices[i]]:
shuffle_indices[i] = edge_alias[shuffle_indices[i]]
# 边的起始点 h->t
cur_h = edges[shuffle_indices[i]][0]
cur_t = edges[shuffle_indices[i]][1]
h.append(cur_h)
t.append(cur_t)
# label ±1
sign = np.ones(len(h))
else:
sign = np.ones(len(h)) * -1
t = []
for i in range(len(h)):
t.append(alias_sample(node_accept, node_alias))
# all 情况下需要两个 label 同时训练
if order == 'all':
yield [np.array(h), np.array(t)], [sign, sign]
else:
yield [np.array(h), np.array(t)], [sign]
mod += 1
mod %= mod_size
# 结束一个循环
if mod == 0:
start_index = end_index
end_index = min(start_index + batch_size, data_size)
if start_index >= data_size:
count += 1
mod = 0
h = []
shuffle_indices = np.random.permutation(np.arange(data_size))
start_index = 0
end_index = min(start_index + batch_size, data_size)
5.训练模型
提供一些基本参数和迭代器即可开始训练
# 5.训练模型
epochs = 10
verbose = 1
batch_size = 1024
times = 5
steps_per_epoch = ((samples_per_epoch - 1) // batch_size + 1) * times
hist = model.fit_generator(batch_it, epochs=epochs,
steps_per_epoch=steps_per_epoch,
verbose=verbose)
6.向量可视化
使用 TSNE 将得到的 Embedding 压缩至两维
embs = get_embeddings()
embedding_list = [embs[k] for k in embs]
model = TSNE(n_components=2)
compress_embedding = model.fit_transform(embedding_list)
keys = list(embs.keys())
plt.scatter(compress_embedding[:, 0], compress_embedding[:, 1], s=10)
for x, y, key in zip(compress_embedding[:, 0], compress_embedding[:, 1], keys):
plt.text(x, y, key, ha='left', rotation=0, c='black', fontsize=8)
plt.title("T-SNE")
plt.colorbar()
plt.show()
First 和 Second 直接获取 Embedding,All 会用 hstack 将一阶二阶 Embedding 拼接在一起
def get_embeddings():
_embeddings = {}
if order == 'first':
embeddings = embedding_dict['first'].get_weights()[0]
elif order == 'second':
embeddings = embedding_dict['second'].get_weights()[0]
else:
embeddings = np.hstack((embedding_dict['first'].get_weights()[
0], embedding_dict['second'].get_weights()[0]))
for i, embedding in enumerate(embeddings):
_embeddings[idx2node[i]] = embedding
return _embeddings
7.查看二阶相似度
首先添加全部节点的邻居,然后遍历寻找 node_i,node_j 不在各自邻居且拥有超过 N 个邻居,即可得到对应节点,随后通过模型 get_embeddings 方法得到的 embedding 即可获取该类节点的内积。
def getCommonNode(graph):
node_neighbor_dict = {}
# 添加全部节点的邻居
for node in graph:
neighbors = graph.neighbors(node)
node_neighbor_dict[node] = set([node for node in neighbors])
# 暴力循环交集
common_node = {}
for node_i in graph:
for node_j in graph:
if node_i != node_j and node_j not in node_neighbor_dict[node_i] and node_i not in node_neighbor_dict[node_j]:
common_num = len(node_neighbor_dict[node_i] & node_neighbor_dict[node_j])
if common_num >= 5:
common_node[node_i + "_" + node_j] = common_num
return common_node
{'14_31': 6, '20_5': 5, '15_69': 5, '69_15': 5, '83_31': 5, '5_20': 5,'4_54': 5,
'89_21': 5, '54_4': 5, '21_89': 5, '31_14': 6, '31_83': 5, '31_92': 5, '92_31': 5}
上述节点的公共邻居数目 >= 5,查看对应 dot:
print(getCommonNode(G))
emb1 = embs['14']
emb2 = embs['31']
print(np.dot(emb1, emb2))
四.总结
LINE 理论+实践大概就写这么多,结合前面提到的几种方法,可以看到 GraphEmbedding 在采样,序列生成以及模型结构上的变化,后续有机会了解更多算法类似 EGES,SDNE 等等。关于 LINE 更多的推理与实践可以参考原论文。
更多推荐算法相关深度学习:深度学习导读专栏