推荐系统(三)Graph Embedding之LINE

你离开我真会死。 提交于 2020-01-29 21:46:17

上一篇博客推荐系统(二)Graph Embedding之DeepWalk中讲到Graph Embedding的开山之作DeepWalk,该博客讲述了在图结构上进行RandomWalk获取训练样本,并通过Word2Vec模型来训练得到图中每个节点的Embedding向量。

但DeepWalk有两个比较大的问题:

  1. DeepWalk将问题抽象成无权图,这意味着高频次user behavior路径和低频次user behavior路径在模型看来是等价的。
  2. 所处环境相似但不直连的两个节点没有进行特殊的处理,这类节点的Embedding向量本应比较相似,而这类信息是没有办法通过深度优先遍历的方式来获取的,只能通过宽度优先遍历的方式来获取,使得这类节点的Embedding向量相去甚远。

上述两点缺陷正是本篇博客提到的LINE算法所关注的。本篇博客着重讲述LINE算法的模型构建和模型优化两个过程。在模型构建阶段,在图结构中随机提取指定条数的边作为训练样本集合,之后对于训练集合中的每条边,采用First/Second-order Proximity作为模型的两种构建手段,并利用相对熵作为损失函数。在模型优化阶段,采用负采样变更损失函数+alias边采样的方式进行加速运算。

关键字: First-order Proximity,Second-order Proximity,相对熵,Edge Sampling

如下是本篇博客的主要内容:

  • First/Second-order Proximity
  • Model Optimization
  • 代码实现
  • 总结

1. First/Second-order Proximity

LINE算法将user behavior 抽象为有权图,即每个边都带有权重,如下图所示。这样就能区分出高频次user behavior路径和低频次user behavior路径,相当于把原先DeepWalk中缺失的信息补充上。
在这里插入图片描述
而只将无权图变为有权图还远远不够,如何利用这部分信息才是关键。一般情况下如果图中两个节点有直接的边向量,且边的权值较大,则有理由相信这两个节点的Embedding向量的距离需要足够近,这就是论文中提到的First-order Proximity。而对于两个环境较为相似但不直连的节点,即他们共享很多相同的邻居,他们的Embedding向量也应该比较相似才对,就如下图中的节点5和节点6一样。对于这类节点的建模,即论文中提到的Second-order Proximity。如下会对这两种建模思想进行介绍。
在这里插入图片描述
设user behavior图结构为(V,E)(V,E),其节点用viv_i来表示,节点自身Embedding向量用ui\vec{u_i}来表示,而当viv_i被作为上下文节点时,则其Embedding向量用ui\vec{u_i}'表示,例如上图中2节点本身的Embedding向量为u2\vec{u_2},而如果其作为5或者6的相邻节点时,其上下文Embedding向量为u2\vec{u_2}'。对于图的边(vi,vj)(v_i,v_j),设边的权值为wi,jw_{i, j}

1.1 First-order Proximity

对于图中直连边(vi,vj)(v_i,v_j),模型预测概率为:
p1(vi,vj)=11+exp(uiTuj)p_1(v_i, v_j)=\frac{1}{1+exp(-\vec{u_i}^T\cdot\vec{u_j})}

可以看出这个预测概率其实就是sigmoid函数,而这条边出现的真实概率为
p^1(vi,vj)=wi,jW,W=(i,j)Ewi,j\hat{p}_1(v_i, v_j)=\frac{w_{i,j}}{W}, W=\sum_{(i,j)\in E}w_{i,j}

模型的目标是使预测概率和真实概率的分布尽量相近,即使得p1(vi,vj)p_1(v_i, v_j)p^1(vi,vj)\hat{p}_1(v_i, v_j)的KL散度尽量小,不熟悉KL散度的小伙伴可以参考这里,这个大神讲的真的非常的清楚。而这里省略了WW这个常数项,模型的目标如下所示:
O1=(i,j)Ewi,jlogp1(vi,vj)O_1=-\sum_{(i,j)\in E}w_{i,j}logp_1(v_i,v_j)

1.2 Second-order Proximity

对于直连的边(vi,vj)(v_i,v_j),直接给出模型预测的已知viv_i时的条件概率为:
p2(vjvi)=exp(ujTui)k=1Vexp(ukTui)p_2(v_j|v_i)=\frac{exp(-\vec{u_j}'^T\cdot\vec{u_i})}{\sum_{k=1}^{|V|}exp(-\vec{u_k}'^T\cdot\vec{u_i})}

可以看出这个预测概率也是类似sigmoid函数,这条边的真实条件概率变为如下公式,其中did_i表示节点viv_i的出度,
p^2(vjvi)=wi,jdi\hat{p}_2(v_j|v_i)=\frac{w_{i,j}}{d_i}

这时模型的目标和First-order Proximity类似,即使得p2(vjvi)p_2(v_j|v_i)尽量接近于p^2(vjvi)\hat{p}_2(v_j|v_i),经过简化,模型的目标为:
O2=(i,j)Ewi,jlogp2(vjvi)O_2=-\sum_{(i,j)\in E}w_{i,j}logp_2(v_j|v_i)

可能很多人在这里都会有所疑惑,为什么Second-order Proximity能够使环境相似但不直连节点的Embedding向量距离相近,而且原始论文中也没有提到。这里我的理解是这样的:假设viv_ivmv_m是符合上述条件的两个节点,他们都连接着vjv_j,则p2(vjvi)p_2(v_j|v_i)p2(vjvm)p_2(v_j|v_m)两个式子只有ui\vec{u_i}um\vec{u_m}是不同的,且p^2(vjvi)\hat{p}_2(v_j|v_i)p^2(vjvm)\hat{p}_2(v_j|v_m)都为1,则这样模型学习出来的结果必然会让ui\vec{u_i}um\vec{u_m}比较相近。

2. Model Optimization

2.1 负采样更改损失函数

计算Second-order Proximity的损失函数时,可以看出模型有一个比较大的问题,就是每次求p2(vjvi)p_2(v_j|v_i)时都要遍历图中的所有节点,这样是非常耗时的,论文中引入负采样的方式,即在计算p2(vjvi)p_2(v_j|v_i)表示公式的分母的时候,并不需要遍历所有的节点,而是选取K个负边进行计算,公式如下所示:
logσ(ujTui)+i=1KEvnPn(v)[logσ(unTui)]log\sigma(\vec{u_j}'^T\cdot\vec{u_i})+\sum_{i=1}^{K}E_{v_n \sim P_n(v)}[log\sigma(\vec{u_n}'^T\cdot\vec{u_i})]

2.2 alias采样

2.1节中损失函数的计算效率问题已经解决,但是现在又有一个问题,即随机梯度下降过程中梯度不稳定的问题,因为p1(vi,vj)p_1(v_i, v_j)p2(vjvi)p_2(v_j|v_i)的计算公式中都有wi,jw_{i,j},梯度计算公式中都含有wi,jw_{i,j},拿p2(vjvi)p_2(v_j|v_i)的梯度计算举例,有如下公式,wi,jw_{i,j}过大则会导致梯度爆炸,wi,jw_{i,j}过小则会导致梯度消失。
O2ui=wi,jlogp2(vjvi)ui\frac{\partial O_2}{\partial \vec{u_i}}=w_{i,j} \cdot \frac{\partial logp_2(v_j|v_i)}{\partial \vec{u_i}}

针对上述情况,论文提出一种解决方案,使得有权图变为无权图,即使所有的wi,jw_{i,j}都变为1,但是在采样的时候要根据原先每条边的权值大小调整采样概率,例如一个权重为5的边要比一个权重为1的边被采到的概率大,这时就需要选择一种合适的采样策略,使得采样后的数据和原先数据的分布尽量相似,这里就用到了大名鼎鼎的alias采样,不熟悉的小伙伴可以参考这里

3. 代码实现

和DeepWalk类似,这里依然援引知乎浅梦大神的github代码,这里分享下我对其LINE代码实现的两点见解。

  1. 实现负采样的代码,思路比较新颖,在line.py的函数batch_iter中,对于一个batch的正样本集合,与之搭配negative_ratio个batch的负样本集合来进行学习,整个过程只是通过mod这一个变量进行控制的,思路真的很棒。

  2. 针对负采样,我这边有一点个人的见解,原先负采样的思路是针对一个节点viv_i,先随机采一个batch的与viv_i直连的节点,与viv_i拼接在一起构成正样本集合,之后随机采若干个batch的节点,与viv_i拼接在一起构成负样本集合。这个做法的问题在于如果这个负样本集合中有与viv_i直连的节点vjv_j构成的边(vi,vj)(v_i,v_j),则会使模型的学习变得艰难,因为在采集正样本的时候已经采集过(vi,vj)(v_i,v_j)了,但是这里又将其作为负样本,这样会让模型变得困惑。在这里我自己新添加了几行代码来回避上述问题,如下所示,

    # 若干代码...
    if mod == 0:
        h = []
        t = []
        for i in range(start_index, end_index):
            if random.random() >= self.edge_accept[shuffle_indices[i]]:
                shuffle_indices[i] = self.edge_alias[shuffle_indices[i]]
            cur_h = edges[shuffle_indices[i]][0]
            cur_t = edges[shuffle_indices[i]][1]
            h.append(cur_h)
            t.append(cur_t)
        sign = np.ones(len(h))
    else:
        sign = np.ones(len(h))*-1
        t = []
        for i in range(len(h)):
            negative_sampled_index = alias_sample(self.node_accept, self.node_alias)
            # 新添加代码,回避采样困惑问题
            constructed_edge = (self.idx2node[h[i]], self.idx2node[negative_sampled_index])
            if constructed_edge in self.graph.edges:
                sign[i] = 1
            # -----------------------
    
            t.append(negative_sampled_index)
    # 若干代码...
    

    经过上述添加代码的改善后,经过50个epoch的训练后,loss由原先的0.0473变为0.0203,可以看出,新添加的代码确实有效果,如果大家有异议的话,可以尽管提~

4. 总结

本篇博客介绍了LINE算法整体思路、加速训练的手段以及代码实现的一些细节,希望能够给大家带来帮助。但LINE算法依然有其自己的缺点,即算法过分关注邻接特征,即只去关注邻接节点或相似节点,没有像DeepWalk一样考虑一条路径上的特征,而后面我们要讲述的Node2Vec算法能够很好地兼顾这两个方面。

参考

  1. 【Graph Embedding】LINE:算法原理,实现和应用
  2. LINE- Large-scale Information Network Embedding
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!