上一篇博客推荐系统(二)Graph Embedding之DeepWalk中讲到Graph Embedding的开山之作DeepWalk,该博客讲述了在图结构上进行RandomWalk获取训练样本,并通过Word2Vec模型来训练得到图中每个节点的Embedding向量。
但DeepWalk有两个比较大的问题:
上述两点缺陷正是本篇博客提到的LINE算法所关注的。本篇博客着重讲述LINE算法的模型构建和模型优化两个过程。在模型构建阶段,在图结构中随机提取指定条数的边作为训练样本集合,之后对于训练集合中的每条边,采用First/Second-order Proximity作为模型的两种构建手段,并利用相对熵作为损失函数。在模型优化阶段,采用负采样变更损失函数+alias边采样的方式进行加速运算。
关键字: First-order Proximity,Second-order Proximity,相对熵,Edge Sampling
如下是本篇博客的主要内容:
LINE算法将user behavior 抽象为有权图,即每个边都带有权重,如下图所示。这样就能区分出高频次user behavior路径和低频次user behavior路径,相当于把原先DeepWalk中缺失的信息补充上。
而只将无权图变为有权图还远远不够,如何利用这部分信息才是关键。一般情况下如果图中两个节点有直接的边向量,且边的权值较大,则有理由相信这两个节点的Embedding向量的距离需要足够近,这就是论文中提到的First-order Proximity。而对于两个环境较为相似但不直连的节点,即他们共享很多相同的邻居,他们的Embedding向量也应该比较相似才对,就如下图中的节点5和节点6一样。对于这类节点的建模,即论文中提到的Second-order Proximity。如下会对这两种建模思想进行介绍。
设user behavior图结构为 ( V , E ) (V,E) (V,E),其节点用 v i v_i vi来表示,节点自身Embedding向量用 u i ⃗ \vec{u_i} ui来表示,而当 v i v_i vi被作为上下文节点时,则其Embedding向量用 u i ⃗ ′ \vec{u_i}' ui′表示,例如上图中2节点本身的Embedding向量为 u 2 ⃗ \vec{u_2} u2,而如果其作为5或者6的相邻节点时,其上下文Embedding向量为 u 2 ⃗ ′ \vec{u_2}' u2′。对于图的边 ( v i , v j ) (v_i,v_j) (vi,vj),设边的权值为 w i , j w_{i, j} wi,j。
对于图中直连边 ( v i , v j ) (v_i,v_j) (vi,vj),模型预测概率为:
p 1 ( v i , v j ) = 1 1 + e x p ( − u i ⃗ T ⋅ u j ⃗ ) p_1(v_i, v_j)=\frac{1}{1+exp(-\vec{u_i}^T\cdot\vec{u_j})} p1(vi,vj)=1+exp(−uiT⋅uj)1
可以看出这个预测概率其实就是sigmoid
函数,而这条边出现的真实概率为
p ^ 1 ( v i , v j ) = w i , j W , W = ∑ ( i , j ) ∈ E w i , j \hat{p}_1(v_i, v_j)=\frac{w_{i,j}}{W}, W=\sum_{(i,j)\in E}w_{i,j} p^1(vi,vj)=Wwi,j,W=(i,j)∈E∑wi,j
模型的目标是使预测概率和真实概率的分布尽量相近,即使得 p 1 ( v i , v j ) p_1(v_i, v_j) p1(vi,vj)和 p ^ 1 ( v i , v j ) \hat{p}_1(v_i, v_j) p^1(vi,vj)的KL散度尽量小,不熟悉KL散度的小伙伴可以参考这里,这个大神讲的真的非常的清楚。而这里省略了 W W W这个常数项,模型的目标如下所示:
O 1 = − ∑ ( i , j ) ∈ E w i , j l o g p 1 ( v i , v j ) O_1=-\sum_{(i,j)\in E}w_{i,j}logp_1(v_i,v_j) O1=−(i,j)∈E∑wi,jlogp1(vi,vj)
对于直连的边 ( v i , v j ) (v_i,v_j) (vi,vj),直接给出模型预测的已知 v i v_i vi时的条件概率为:
p 2 ( v j ∣ v i ) = e x p ( − u j ⃗ ′ T ⋅ u i ⃗ ) ∑ k = 1 ∣ V ∣ e x p ( − u k ⃗ ′ T ⋅ u i ⃗ ) 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})} p2(vj∣vi)=∑k=1∣V∣exp(−uk′T⋅ui)exp(−uj′T⋅ui)
可以看出这个预测概率也是类似sigmoid
函数,这条边的真实条件概率变为如下公式,其中 d i d_i di表示节点 v i v_i vi的出度,
p ^ 2 ( v j ∣ v i ) = w i , j d i \hat{p}_2(v_j|v_i)=\frac{w_{i,j}}{d_i} p^2(vj∣vi)=diwi,j
这时模型的目标和First-order Proximity类似,即使得 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)尽量接近于 p ^ 2 ( v j ∣ v i ) \hat{p}_2(v_j|v_i) p^2(vj∣vi),经过简化,模型的目标为:
O 2 = − ∑ ( i , j ) ∈ E w i , j l o g p 2 ( v j ∣ v i ) O_2=-\sum_{(i,j)\in E}w_{i,j}logp_2(v_j|v_i) O2=−(i,j)∈E∑wi,jlogp2(vj∣vi)
可能很多人在这里都会有所疑惑,为什么Second-order Proximity能够使环境相似但不直连节点的Embedding向量距离相近,而且原始论文中也没有提到。这里我的理解是这样的:假设 v i v_i vi和 v m v_m vm是符合上述条件的两个节点,他们都连接着 v j v_j vj,则 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)和 p 2 ( v j ∣ v m ) p_2(v_j|v_m) p2(vj∣vm)两个式子只有 u i ⃗ \vec{u_i} ui和 u m ⃗ \vec{u_m} um是不同的,且 p ^ 2 ( v j ∣ v i ) \hat{p}_2(v_j|v_i) p^2(vj∣vi)和 p ^ 2 ( v j ∣ v m ) \hat{p}_2(v_j|v_m) p^2(vj∣vm)都为1,则这样模型学习出来的结果必然会让 u i ⃗ \vec{u_i} ui和 u m ⃗ \vec{u_m} um比较相近。
计算Second-order Proximity的损失函数时,可以看出模型有一个比较大的问题,就是每次求 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)时都要遍历图中的所有节点,这样是非常耗时的,论文中引入负采样的方式,即在计算 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)表示公式的分母的时候,并不需要遍历所有的节点,而是选取K
个负边进行计算,公式如下所示:
l o g σ ( u j ⃗ ′ T ⋅ u i ⃗ ) + ∑ i = 1 K E v n ∼ P n ( v ) [ l o g σ ( u n ⃗ ′ T ⋅ u i ⃗ ) ] 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})] logσ(uj′T⋅ui)+i=1∑KEvn∼Pn(v)[logσ(un′T⋅ui)]
2.1节中损失函数的计算效率问题已经解决,但是现在又有一个问题,即随机梯度下降过程中梯度不稳定的问题,因为 p 1 ( v i , v j ) p_1(v_i, v_j) p1(vi,vj)和 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)的计算公式中都有 w i , j w_{i,j} wi,j,梯度计算公式中都含有 w i , j w_{i,j} wi,j,拿 p 2 ( v j ∣ v i ) p_2(v_j|v_i) p2(vj∣vi)的梯度计算举例,有如下公式, w i , j w_{i,j} wi,j过大则会导致梯度爆炸, w i , j w_{i,j} wi,j过小则会导致梯度消失。
∂ O 2 ∂ u i ⃗ = w i , j ⋅ ∂ l o g p 2 ( v j ∣ v i ) ∂ u i ⃗ \frac{\partial O_2}{\partial \vec{u_i}}=w_{i,j} \cdot \frac{\partial logp_2(v_j|v_i)}{\partial \vec{u_i}} ∂ui∂O2=wi,j⋅∂ui∂logp2(vj∣vi)
针对上述情况,论文提出一种解决方案,使得有权图变为无权图,即使所有的 w i , j w_{i,j} wi,j都变为1,但是在采样的时候要根据原先每条边的权值大小调整采样概率,例如一个权重为5的边要比一个权重为1的边被采到的概率大,这时就需要选择一种合适的采样策略,使得采样后的数据和原先数据的分布尽量相似,这里就用到了大名鼎鼎的alias采样,不熟悉的小伙伴可以参考这里。
和DeepWalk类似,这里依然援引知乎浅梦大神的github代码,这里分享下我对其LINE代码实现的两点见解。
实现负采样的代码,思路比较新颖,在line.py
的函数batch_iter
中,对于一个batch的正样本集合,与之搭配negative_ratio
个batch的负样本集合来进行学习,整个过程只是通过mod
这一个变量进行控制的,思路真的很棒。
针对负采样,我这边有一点个人的见解,原先负采样的思路是针对一个节点 v i v_i vi,先随机采一个batch的与 v i v_i vi直连的节点,与 v i v_i vi拼接在一起构成正样本集合,之后随机采若干个batch的节点,与 v i v_i vi拼接在一起构成负样本集合。这个做法的问题在于如果这个负样本集合中有与 v i v_i vi直连的节点 v j v_j vj构成的边 ( v i , v j ) (v_i,v_j) (vi,vj),则会使模型的学习变得艰难,因为在采集正样本的时候已经采集过 ( v i , v j ) (v_i,v_j) (vi,vj)了,但是这里又将其作为负样本,这样会让模型变得困惑。在这里我自己新添加了几行代码来回避上述问题,如下所示,
# 若干代码...
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,可以看出,新添加的代码确实有效果,如果大家有异议的话,可以尽管提~
本篇博客介绍了LINE算法整体思路、加速训练的手段以及代码实现的一些细节,希望能够给大家带来帮助。但LINE算法依然有其自己的缺点,即算法过分关注邻接特征,即只去关注邻接节点或相似节点,没有像DeepWalk一样考虑一条路径上的特征,而后面我们要讲述的Node2Vec算法能够很好地兼顾这两个方面。