Graph Embedding(二)

Graph Embedding(二)_第1张图片

今天我们介绍Graph Embedding第二个算法——LINE(Large-scale Information Network Embedding)

一阶与二阶相似度

Deepwalk算法度量节点之间的相似性是根据是否有边相连决定的,LINE算法改变了这种度量方式,它引进了一阶相似度二阶相似度。

  • 一阶相似度First-order Proximity

对于无向图G(V,E)的每一条边edge(i,j),其相连的两个顶点vi和vj我们定义其一阶相似度:

Graph Embedding(二)_第2张图片

同时定义其经验分布:

Graph Embedding(二)_第3张图片

注意:wij不是上文中定义的一阶相似度pij

对于所有的V,引进KL散度衡量经验分布与一阶相似度的相似性:

注:KL散度=交叉熵-熵

我们希望这两个分布越接近越好,于是优化目标函数是:

Graph Embedding(二)_第4张图片

这里说明一下为什么一阶相似度只适用于无向图,因为对于pij来说,具备对称性,即交换顶点vi和vj位置,结果不变;但对于有向图来说wij不一定等于wji

  • 二阶相似度(Second-order Proximity)

二阶相似度适用于无向图和有向图两种情况,它的出发点是:每一个顶点扮演两个角色,一个是作为顶点的时候代表它自己,另一个是作为其他顶点的上下文(邻居),因此,把这两个角色用两个向量表示:

Graph Embedding(二)_第5张图片

因此对于每一条边edge(i,j),定义在顶点vi下的 “context” vj 的条件概率:

Graph Embedding(二)_第6张图片

|V|就是顶点的总数,上面公式是说对于顶点vi,各节点为其context的概率。进一步,如果p2(•|v1)和p2(•|v2)的概率分布是相似的(即v1和v2的context相似),那么这两个顶点就是相似的.

同样引进经验分布:

Graph Embedding(二)_第7张图片

我们希望二阶相似度与经验分布越接近越好,同理使用KL散度衡量,最后要优化的目标函数是:

Graph Embedding(二)_第8张图片

负采样优化目标

上一节,我们知道要优化的目标函数有两个:

Graph Embedding(二)_第9张图片

为了使得图模型同时考虑一阶和二阶相似性,先分别训练两个相似度的向量表示,然后组合(直接拼接)在一起使用。

对于二阶相似度,直接优是比较困难的,采取负采样方法,每一条边edge(i,j)优化目标函数转为:

Graph Embedding(二)_第10张图片

这里需要做个详细解释。

首先,对于一条有向边edge(i,j),顶点vi称为源节点,顶点vj称为目标节点,这是真实存在的一条边,我们称为正样本。负样本是指,vi作为源节点(固定),然后根据顶点出度对其他顶点进行采样,得到的顶点作为目标节点,这样子构成一条虚构的边,我们称为负样本:

Graph Embedding(二)_第11张图片

对于负样本,要使得其概率尽可能低:

Graph Embedding(二)_第12张图片

对于一阶相似度,也是采样同样的公式,只不过向量表示改一下即可:

参数求解

整个算法更新用到ASGD (Asynchronous Stochastic Gradient Algorithm) ,这里的学习率设置会有一点问题,因为其梯度是:

Graph Embedding(二)_第13张图片

权重的大小将会影响梯度的数值,对于边权重方差较大的图,学习率设置太小太大都不合适,因此通过AliasMethod算法进行边采样来解决这个问题。

它实质把梯度公式的wij置为1或0,在获取正样本的时候,按照边的权重进行Alias采样,权重大的边被采样到的概率大,得到的边edge(i,j)作为正样本。之后利用负采样得到K个目标节点,与源节点vi构成负样本。因此,上一节的目标函数参数的更新公式是:

Graph Embedding(二)_第14张图片

我们来看看原论文作者给出的核心代码,也是这么实现的:

    // 使用 AliasMethod 进行边的采样, 得到源节点 u, 正样本 v 
    curedge = SampleAnEdge(gsl_rng_uniform(gsl_r), gsl_rng_uniform(gsl_r));
    u = edge_source_id[curedge];
    v = edge_target_id[curedge];


    // dim 为 embedding 的大小, lu 表示 u 的 embedding 的起始位置, 因为矩阵使用数组来
    // 表示, embedding 矩阵大小为 [N, emb], 表示成一维数组的大小为 N*emb, 要找到 u 所
    // 代表的 emb, 需要用索引值 u 乘上 dim
    lu = u * dim;
    for (int c = 0; c != dim; c++) vec_error[c] = 0;


    // NEGATIVE SAMPLING
    for (int d = 0; d != num_negative + 1; d++)
    {
      if (d == 0)
      {
        target = v;
        label = 1;
      }
      else
      {
        target = neg_table[Rand(seed)]; // 从 Negative Table 采样负样本
        label = 0;
      }
      lv = target * dim;
      // 如果使用一阶邻近, 那么 Update 的输入均为 emb_vertex
      if (order == 1) Update(&emb_vertex[lu], &emb_vertex[lv], vec_error, label);
      // 如果使用二阶邻近, 那么 Update 的输入分别为 emb_vertex 以及 emb_context
      // emb_vertex 是节点作为自身所代表的 embedding,
      // emb_context 是节点作为其他节点的 context 所代表的 embedding
      if (order == 2) Update(&emb_vertex[lu], &emb_context[lv], vec_error, label);
    }
    // embedding 更新, 这个要根据对 loss 求导来得到, 具体见下面的讲解
    for (int c = 0; c != dim; c++) emb_vertex[c + lu] += vec_error[c];


SGD更新函数如下:

/* Update embeddings */
void Update(real *vec_u, real *vec_v, real *vec_error, int label)
{
  real x = 0, g;
  for (int c = 0; c != dim; c++) x += vec_u[c] * vec_v[c];
  g = (label - FastSigmoid(x)) * rho;
  for (int c = 0; c != dim; c++) vec_error[c] += g * vec_v[c];
  for (int c = 0; c != dim; c++) vec_v[c] += g * vec_u[c];
}


注:vec_u 为源节点的 embeddng, 而 vec_v 为目标节点的 embedding

算法实现

由此,我们得到LINE算法的流程图:

Graph Embedding(二)_第15张图片

下面我们用python实现LINE算法。


先介绍整体思路首先,编写两个类,一个获取正负样本,一个获取节点的Embedding:

class Get_Line_Sample(object)


class Get_Line_Embedding(object)

其中,第一个类包含四个函数:

def sampling_and_save()
def positive_sample_pro()
def negative_sample_pro()
def Alias()

第二个函数获取边的采样概率,利用Alias算法生成两个数组——Prab数组和Alias数组,除此之外,还生成边的编码字典(保证边的唯一性)

传送门:离散采样——Alias Method

第三个函数是顶点的采样概率,这里直接输出每个顶点出度3/4幂次方的归一化概率。

然后,利用第一个函数去生成采样样本。其中负样本的生成逻辑是先生成一个列表,大小是迭代次数与每次迭代负样本个数的乘积:iter*K。元素是一系列顶点,每个顶点的频数约等于其出度概率。

然后,每次采样时,先生成一个正样本,再从上面那个列表中每次采样一个位置的元素,直到采样K个。不过这里和原论文不一样的地方是,若采样的负样本顶点是正样本源节点的近邻节点集合,则不采样(保证其负样本不是真是图中存在的边)。

第二个类一共有三个函数:

def train()
def update()
def sigmoid()


第三个函数就是计算sigmoid激活函数的值,第一个函数是进行模型训练,即更新embedding,这个函数实现迭代次数以及计算ASGD算法的误差,当误差达到一定阈值时停止迭代,返回结果。每个参数的梯度更新计算过程在第二个函数实现,之所以计算过程要另起一个函数,因为LINE分为一阶相似和二阶相似,这个函数只需要接收是一阶还是二阶的embedding即可。

下面是详细的代码。首先导入必要的模块:

import random
import numpy as np
import pandas as pd
import networkx as nx

第一个类:

class Get_Line_Sample(object):

    def __init__(self, edge_code, sample_list):

        self.edge_code = edge_code
        self.sample_list = sample_list

    @classmethod
    def sampling_and_save(cls, G, n_iter, K):

        '''

       :param G: 图模型,networkx形式
       :param iter: 迭代次数,即采集正样本的个数
       :param K: 每次迭代采样负样本的个数
       '''

        sample_list = [] # 接收采样样本

        edge_code, Prab_table, Alias_table = cls.positive_sample_pro(G)
        node_sample_pro = cls.negative_sample_pro(G)

        Neg_table = []
        for neg in node_sample_pro.keys():
            num_node = int(node_sample_pro[neg] * K * n_iter)
            li = [neg for i in range(num_node)]
            Neg_table.extend(li)

        for pos in range(n_iter):

            a = random.randint(1, len(edge_code)) # 产生第一个随机数,1~N之间,N是边的个数
            b = random.random() # 产生第二个随机,0~1之间
            if b < Prab_table[a - 1]:
                edge = a
            else:
                edge = Alias_table[a - 1]

            source_node = edge_code[edge][0] # 正样本源节点
            neigbor_nodes = list(G.neighbors(source_node)) # 源节点的所有邻居节点

            neg_list = []
            while len(neg_list) != K:
                neg = random.choice(Neg_table) # 获取一个负样本
                if neg in neigbor_nodes:
                    continue
                else:
                    neg_list.append(neg)

            neg_list.append(edge) # 把正样本存储到最后
            sample_list.append(neg_list)

        return cls(edge_code, sample_list)

    @staticmethod
    def positive_sample_pro(G):

        '''
        正样本采样概率表
        :return: 每条边的编码以及对应的Alias表
        '''
        edge_weight = {(u, v): d['weight'] for (u, v, d) in G.edges(data=True)}
        edge_z = sum(edge_weight.values()) # 边权重归一化因子
        edge_weight = {key: value / edge_z for key, value in edge_weight.items()} # 权重归一化
        edge_code = {n + 1: key for n, key in enumerate(edge_weight.keys())} # 对边进行编码化
        Prob_li = list(edge_weight.values())
        Prab_table, Alias_table = Get_Line_Sample.Alias(Prob_li) # 得到Alias算法结果表

        return edge_code, Prab_table, Alias_table

    @staticmethod
    def negative_sample_pro(G):

        '''
        负样本采样概率表
        :return:每个节点被采样到的概率
        '''
        s = G.number_of_nodes() - 1
        node_out_degree = {n: int(d * s) ** (3 / 4) for n, d in nx.out_degree_centrality(G).items()} # 计算节点出度的3/4幂次方
        z = sum(node_out_degree.values())
        node_sample_pro = {n: i / z for n, i in node_out_degree.items()} # 每个顶点作为负采样的概率

        return node_sample_pro

    @staticmethod
    def Alias(Prob_li):

        '''

        :param Prob_li: 事件对应的概率列表,索引代表事件
        :return: 返回Prab和Alias列表
        '''
        M = len(Prob_li)
        S = [M * i for i in Prob_li]
        Prab_table = [1 for i in range(M)]
        Alias_table = [0 for i in range(M)]
        while True:
            for i, s1 in enumerate(S):
                if s1 < 1:
                    Prab_table[i] = s1
                    s0 = 1 - s1
                    for j, s2 in enumerate(S):
                        if s2 != 1 and s2 > s0 and i != j:
                            Alias_table[i] = j + 1
                            S[i] = 1
                            S[j] = s2 - s0
                            break
                    break
            e = 1 - min(S)
            if e < 0.000001:
                break

        return Prab_table, Alias_table

第二个类:

class Get_Line_Embedding(object):

    def __init__(self, G, edge_code, sample_list, dim):

        '''

        :param G: 图模型
        :param edge_code: 接收来自Get_Line_Sample类的边的编码
        :param sample_list: 接收来自Get_Line_Sample采样的样本
        :param dim: embedding的维度
        '''

        self.dim = dim
        self.edge_code = edge_code
        self.sample_list = sample_list
        self.emb_vertex = {node: np.random.uniform(0, 1, (dim, 1)) for node in G.nodes()} # 作为节点自身的表达
        self.emb_context = {node: np.random.uniform(0, 1, (dim, 1)) for node in G.nodes()} # 作为其他节点上下文的表达

    def train(self, iter_n=1000, order=2):

        '''

        :param iter_n: ASGD最大迭代次数
        :param order: 一阶相似还是二阶,默认是二阶
        :return: 更新后的embedding
        '''

        if order == 1:
            emb_vertex, emb_context = self.emb_vertex, self.emb_vertex

        else:
            emb_vertex, emb_context = self.emb_vertex, self.emb_context

        alpha = 0.001  # 学习率
        e0 = 0
        for epoch in range(iter_n):

            per_vertex = {key: value.T.tolist()[0] for key, value in self.emb_vertex.items()}
            per_context = {key: value.T.tolist()[0] for key, value in self.emb_context.items()}

            self.update(emb_vertex, emb_context, alpha)

            update_vertex = {key: value.T.tolist()[0] for key, value in self.emb_vertex.items()}
            update_context = {key: value.T.tolist()[0] for key, value in self.emb_context.items()}

            e_ver = sum([sum(i) for i in update_vertex.values()]) - sum([sum(i) for i in per_vertex.values()])
            e_con = sum([sum(i) for i in update_context.values()]) - sum([sum(i) for i in per_context.values()])
            e = abs(e_ver) + abs(e_con)
            if e - e0 > 0 and epoch >= 1:
                alpha = 0.1 * alpha

            if abs(e) <= 0.000001:
                print('ASGD early stop at epoch {},error {}'.format(epoch + 1, e))
                break
            else:
                print('epoch {},error {}'.format(epoch + 1, e))
                e0 = e

        di_vertex = {key: value.T.tolist()[0] for key, value in self.emb_vertex.items()}
        di_context = {key: value.T.tolist()[0] for key, value in self.emb_context.items()}
        update_vertex = pd.DataFrame(di_vertex).T.sort_index()
        update_context = pd.DataFrame(di_context).T.sort_index()

        return update_vertex, update_context

    def update(self, emb_vertex, emb_context, alpha):

        '''
        如果是一阶,传入的都是emb_vertex参数,即emb_context=emb_vertex,
        :param emb_vertex: 节点作为自身的embedding
        :param emb_context: 节点作为上下文的embedding
        :param alpha: ASGD算法的学习率
        :return:
        '''
        for sample in self.sample_list:

            ui, uj = self.edge_code[sample[-1]] # 获取正样本源节点和目标节点
            v = sample[:-1] # 获取K个负样本

            g1 = 1 - self.sigmoid(np.dot(emb_context[uj].T, emb_vertex[ui]))
            error_v = np.zeros((self.dim, 1))
            for n in v:
                g0 = 0 - self.sigmoid(np.dot(emb_context[n].T, emb_vertex[ui]))
                error_v += g0 * emb_context[n]
                emb_context[n] += alpha * g0 * emb_vertex[ui] # 更新负样本

            emb_vertex[ui] += alpha * (g1 * emb_context[uj] + error_v) # 更新源节点
            emb_context[uj] += alpha * g1 * emb_vertex[ui] # 更新目标节点

    def sigmoid(self, x):

        return 1 / (1 + np.exp(-x))

参考资料:

https://arxiv.org/pdf/1503.03578.pdf

https://github.com/tangjianpku/LINE

https://blog.csdn.net/Eric_1993/article/details/107573102

你可能感兴趣的:(算法,python,机器学习,深度学习,神经网络)