Graph embedding(2)----- DeepWalk、Node2vec、LINE


一、DeepWalk

(2014KDD)

1、思想

隨機游走+Word2vec

該算法使用隨機游走(Random Walk)的方式在圖中進行序列的采樣.

在獲得足夠數量的滿足一定長度的節點序列之后,就使用word2vec類似的方式,將每一個點看做單詞,將點的序列看做是句子,進行訓練.

Random Walk:一種可重復訪問已訪問節點的深度優先遍歷算法。給定當前訪問起始節點,從其鄰居中隨機采樣節點作為下一個訪問節點,重復此過程,直到訪問序列長度滿足預設條件。

Word2vec:接着利用skip-gram模型進行向量學習。

2、算法

:第一個部分是使用隨機游走獲得節點的序列;第二部分是使用skip-gram算法去訓練得到節點的embedding向量.

3、核心代碼

①構建同構網絡,從網絡中的每個節點開始分別進行Random Walk 采樣,得到局部相關聯的訓練數據;

②對采樣數據進行SkipGram訓練,將離散的網絡節點表示成向量化,最大化節點共現,使用Hierarchical Softmax來做超大規模分類的分類器

Random Walk

通過並行的方式加速路徑采樣,在采用多進程進行加速時,相比於開一個進程池讓每次外層循環啟動一個進程,我們采用固定為每個進程分配指定數量的num_walks的方式,這樣可以最大限度減少進程頻繁創建與銷毀的時間開銷。

  • deepwalk_walk方法對應上一節偽代碼中第6行,
  • _simulate_walks對應偽代碼中第3行開始的外層循環。
  • Parallel為多進程並行時的任務分配操作。
def deepwalk_walk(self, walk_length, start_node):

    walk = [start_node]

    while len(walk) < walk_length:
        cur = walk[-1]
        cur_nbrs = list(self.G.neighbors(cur))
        if len(cur_nbrs) > 0:
            walk.append(random.choice(cur_nbrs))
        else:
            break
    return walk

def _simulate_walks(self, nodes, num_walks, walk_length,):
    walks = []
    for _ in range(num_walks):
        random.shuffle(nodes)
        for v in nodes:           
            walks.append(self.deepwalk_walk(alk_length=walk_length, start_node=v))
    return walks

results = Parallel(n_jobs=workers, verbose=verbose, )(
    delayed(self._simulate_walks)(nodes, num, walk_length) for num in
    partition_num(num_walks, workers))

walks = list(itertools.chain(*results))

Word2vec

#采用gensim中的Word2vec
from gensim.models import Word2Vec
w2v_model = Word2Vec(walks,sg=1,hs=1)

4、完整代碼

(1)數據

wiki_edgelist:邊,用來構建圖

wiki_category:標簽,用來評估得到的節點embedding結果

(2)模型

隨機游走:

deepwalk_walk:產生當前節點的一個隨機序列,從當前節點開始,從鄰居節點中隨機抽取walk_length個鄰居產生序列。
_simulate_walks:產生圖所有節點的num_walks個隨機序列。
simulate_walks:並行執行_simulate_walks,並將結果合並。

 

from joblib import Parallel, delayed
import itertools
import random

class RandomWalker:
    def __init__(self, G):
        self.G = G

    def partition_num(self,num, workers):
        if num % workers == 0:
            return [num // workers] * workers
        else:
            return [num // workers] * workers + [num % workers]

##隨機游走,walk_length為游走長度,start_node為開始節點
def deepwalk_walk(self, walk_length, start_node): walk = [start_node] while len(walk) < walk_length: cur = walk[-1] cur_nbrs = list(self.G.neighbors(cur)) if len(cur_nbrs) > 0: walk.append(random.choice(cur_nbrs)) else: break return walk
def _simulate_walks(self, nodes, num_walks, walk_length, ): walks = [] for _ in range(num_walks): random.shuffle(nodes) for v in nodes: walks.append(self.deepwalk_walk(walk_length, start_node=v)) return walks
##num_walks為產生多少個隨機游走序列,walk_length為游走序列長度
def simulate_walks(self, num_walks, walk_length, workers=1, verbose=0): G = self.G nodes = list(G.nodes()) results = Parallel(n_jobs=workers, verbose=verbose, )( delayed(self._simulate_walks)(nodes, num, walk_length) for num in self.partition_num(num_walks, workers)) walks = list(itertools.chain(*results)) return walks

 

DeepWalk(RandomWalk + Word2vec):

參數:
  • graph:圖
  • w2v_model:word2vec模型,如skip-gram還是CBOW,滑動窗口大小等配置
  • _embeddings:{節點:embedding}
  • walker:構建隨機游走模型類。
  • sentences:調用隨機游走類的函數產生圖所有節點的n個隨機序列。
from ..d_walker import RandomWalker
from gensim.models import Word2Vec
import pandas as pd


class DeepWalk:
    def __init__(self, graph, walk_length, num_walks, workers=1):

        self.graph = graph
        self.w2v_model = None
        self._embeddings = {}

        self.walker = RandomWalker(graph)
        self.sentences = self.walker.simulate_walks(
            num_walks=num_walks, walk_length=walk_length, workers=workers, verbose=1)


    def train(self, embed_size=128, window_size=5, workers=3, iter=5, **kwargs):

        kwargs["sentences"] = self.sentences
        kwargs["min_count"] = kwargs.get("min_count", 0)
        kwargs["size"] = embed_size
        kwargs["sg"] = 1  # skip gram
        kwargs["hs"] = 1  # deepwalk use Hierarchical Softmax
        kwargs["workers"] = workers
        kwargs["window"] = window_size
        kwargs["iter"] = iter

        print("Learning embedding vectors...")
        model = Word2Vec(**kwargs)
        print("Learning embedding vectors done!")

        self.w2v_model = model
        return model

    def get_embeddings(self,):
        if self.w2v_model is None:
            print("model not train")
            return {}

        self._embeddings = {}
        for word in self.graph.nodes():
            self._embeddings[word] = self.w2v_model.wv[word]

        return self._embeddings

 

(3)執行模型

from ge import DeepWalk
import networkx as nx

if __name__ == "__main__":
    G = nx.read_edgelist('../data/wiki/Wiki_edgelist.txt',
                         create_using=nx.DiGraph(), nodetype=None, data=[('weight', int)])
    model = DeepWalk(G, walk_length=10, num_walks=80, workers=1)
    model.train(window_size=5, iter=3)
    embeddings = model.get_embeddings()

 

二、node2vec

1、思想

隨機游走改進的DeepWalk

相對於DeepWalk, node2vec的改進主要是對基於隨機游走的采樣策略的改進。在獲得了采樣方法之后,后面的學習策略就和DeepWalk一樣了,這里有一點要注意的是node2vec采用了Alias算法對節點進行了采樣,這是一個能將采樣時間復雜度降到 [公式] 的算法.

node2vec是結合了BFS和DFS的Deepwalk改進的隨機游走算法。

2、隨機游走策略

Deepwalk的隨機游走有一個假設是所有的節點出現的概率是服從均勻分布的,但實際的情況並非如此.

(1)node2vec優化目標:

(2)node2vec隨機游走:

node2vec采用的是一種有偏的隨機游走。

給定當前頂點 [公式] ,訪問下一個頂點 [公式] 的概率為

[公式] 是頂點 [公式] 和頂點 [公式] 之間的未歸一化轉移概率, [公式] 是歸一化常數。

node2vec引入兩個超參數 [公式] 和 [公式] 來控制隨機游走的策略,假設當前隨機游走經過邊 [公式] 到達頂點 [公式] 。一個節點轉移到另外一個節點的概率不再是隨機的,而是服從下面的公式:[公式] , 轉移策略為[公式][公式] 是頂點 [公式] 和 [公式] 之間的邊權。[公式] 為頂點 [公式] 和頂點 [公式] 之間的最短路徑距離。

     

 

 

下圖是對該轉移策略的一個解釋:

假設上一步游走的邊為 [公式] , 那么對於節點 [公式] 的不同鄰居 , node2vec 根據 [公式] 和 [公式] 定義了不同的鄰居的跳轉概率 .

[公式] 為Return parameter,因為 [公式] 控制着回到原節點的概率; (d=0)

[公式] 為In-out parameter,因為它控制着BFS和DFS的關系。如果 [公式] ,則更傾向於BFS,如果 [公式] ,則更傾向於DFS,如果 [公式] ,那么node2vec其實就退化為DeepWalk算法.

(3)Alias采樣

值得注意的是node2vecWalk中不再是隨機抽取鄰接點,而是按概率抽取,node2vec采用了Alias算法進行頂點采樣。(Alias采樣算法詳細介紹)

問題:給定一個離散型隨機變量的概率分布規律 [公式] ,希望設計一個方法能夠從該概率分布中進行采樣使得采樣結果盡可能服從概率分布 [公式]

3、算法

4、核心代碼

(1)node2vecWalk

    def node2vec_walk(self, walk_length, start_node):

        G = self.G
        alias_nodes = self.alias_nodes
        alias_edges = self.alias_edges

        walk = [start_node]

        while len(walk) < walk_length:
            cur = walk[-1]
            cur_nbrs = list(G.neighbors(cur))
            if len(cur_nbrs) > 0:
###由於采樣時需要考慮前面2步訪問過的頂點
#當訪問序列中只有1個頂點時,直接使用當前頂點和鄰居頂點之間的邊權作為采樣依據。
                if len(walk) == 1:
                    walk.append(
                        cur_nbrs[alias_sample(alias_nodes[cur][0], alias_nodes[cur][1])])

#當序列多余2個頂點時,使用文章提到的有偏采樣
                else:
                    prev = walk[-2]
                    edge = (prev, cur)
                    next_node = cur_nbrs[alias_sample(alias_edges[edge][0],
                                                      alias_edges[edge][1])]
                    walk.append(next_node)
            else:
                break

        return walk

(2)構造采樣表

 

 

alias算法的accept和alias獲取:(即上面代碼的alias_nodes和alias_edges)

alias_nodes:所有點的字典形式,{node:【標准化的鄰居邊權重列表作為概率分布而產生的accept和alias】}

alias_edges:所有邊的字典形式,{edge(t,v):【x為v的鄰居,所有x對應的標准化[公式]列表作為概率分布而產生的accept和alias】}

def get_alias_edge(self, t, v):
    G = self.G    
    p = self.p    
    q = self.q
    unnormalized_probs = []    
    for x in G.neighbors(v):        
        weight = G[v][x].get('weight', 1.0)# w_vx        
        if x == t:# d_tx == 0            
            unnormalized_probs.append(weight/p)        
        elif G.has_edge(x, t):# d_tx == 1            
            unnormalized_probs.append(weight)        
        else:# d_tx == 2            
            unnormalized_probs.append(weight/q)    
    norm_const = sum(unnormalized_probs)    
    normalized_probs = [float(u_prob)/norm_const for u_prob in unnormalized_probs]
    return create_alias_table(normalized_probs)

def preprocess_transition_probs(self):
    G = self.G
    alias_nodes = {}    
    for node in G.nodes():        
        unnormalized_probs = [G[node][nbr].get('weight', 1.0) for nbr in G.neighbors(node)]        
        norm_const = sum(unnormalized_probs)        
        normalized_probs = [float(u_prob)/norm_const for u_prob in unnormalized_probs]                 
        alias_nodes[node] = create_alias_table(normalized_probs)
    alias_edges = {}
    for edge in G.edges():        
        alias_edges[edge] = self.get_alias_edge(edge[0], edge[1])
    self.alias_nodes = alias_nodes    
    self.alias_edges = alias_edges
    return

(3)Alias算法的構造表和采樣

#構造表
def create_alias_table(area_ratio):
    """

    :param area_ratio: sum(area_ratio)=1
    :return: accept,alias
    """
    l = len(area_ratio)
    accept, alias = [0] * l, [0] * l
    small, large = [], []
    area_ratio_ = np.array(area_ratio) * l
    for i, prob in enumerate(area_ratio_):
        if prob < 1.0:
            small.append(i)
        else:
            large.append(i)

    while small and large:
        small_idx, large_idx = small.pop(), large.pop()
        accept[small_idx] = area_ratio_[small_idx]
        alias[small_idx] = large_idx
        area_ratio_[large_idx] = area_ratio_[large_idx] - \
            (1 - area_ratio_[small_idx])
        if area_ratio_[large_idx] < 1.0:
            small.append(large_idx)
        else:
            large.append(large_idx)

    while large:
        large_idx = large.pop()
        accept[large_idx] = 1
    while small:
        small_idx = small.pop()
        accept[small_idx] = 1

    return accept, alias

#采樣
def alias_sample(accept, alias):
    """

    :param accept:
    :param alias:
    :return: sample index
    """
    N = len(accept)
    i = int(np.random.random()*N)
    r = np.random.random()
    if r < accept[i]:
        return i
    else:
        return alias[i]
View Code

5、完整代碼

(1)數據+執行代碼

import networkx as nx
import Node2Vec


if __name__ == "__main__":
    G=nx.read_edgelist('../data/wiki/Wiki_edgelist.txt',
                         create_using = nx.DiGraph(), nodetype = None, data = [('weight', int)])
    model=Node2Vec(G, walk_length = 10, num_walks = 80,
                   p = 0.25, q = 4, workers = 1)
    model.train(window_size = 5, iter = 3)
    embeddings=model.get_embeddings()

(2)Node2vec類(隨機游走+Word2vec)

class Node2Vec:

    def __init__(self, graph, walk_length, num_walks, p=1.0, q=1.0, workers=1):

        self.graph = graph
        self._embeddings = {}

###采樣 self.walker
= RandomWalker(graph, p=p, q=q, )
###為了構造表
print("Preprocess transition probs...") self.walker.preprocess_transition_probs() self.sentences = self.walker.simulate_walks( num_walks=num_walks, walk_length=walk_length, workers=workers, verbose=1) def train(self, embed_size=128, window_size=5, workers=3, iter=5, **kwargs): kwargs["sentences"] = self.sentences kwargs["min_count"] = kwargs.get("min_count", 0) kwargs["size"] = embed_size kwargs["sg"] = 1 kwargs["hs"] = 0 # node2vec not use Hierarchical Softmax kwargs["workers"] = workers kwargs["window"] = window_size kwargs["iter"] = iter
###Word2vec
print("Learning embedding vectors...") model = Word2Vec(**kwargs) print("Learning embedding vectors done!") self.w2v_model = model return model def get_embeddings(self,): if self.w2v_model is None: print("model not train") return {} self._embeddings = {} for word in self.graph.nodes(): self._embeddings[word] = self.w2v_model.wv[word] return self._embeddings

(3)序列采樣策略

 
         
import itertools
import math
import random
from .alias import alias_sample, create_alias_table
from .utils import partition_num

class
RandomWalker: def __init__(self, G, p=1, q=1): """ :param G: :param p: Return parameter,controls the likelihood of immediately revisiting a node in the walk. :param q: In-out parameter,allows the search to differentiate between “inward” and “outward” nodes """ self.G = G self.p = p self.q = q ####這里是DeepWalk代碼,可忽略 def deepwalk_walk(self, walk_length, start_node): walk = [start_node] while len(walk) < walk_length: cur = walk[-1] cur_nbrs = list(self.G.neighbors(cur)) if len(cur_nbrs) > 0: walk.append(random.choice(cur_nbrs)) else: break return walk def node2vec_walk(self, walk_length, start_node): G = self.G alias_nodes = self.alias_nodes alias_edges = self.alias_edges walk = [start_node] while len(walk) < walk_length: cur = walk[-1] cur_nbrs = list(G.neighbors(cur)) if len(cur_nbrs) > 0: if len(walk) == 1: walk.append( cur_nbrs[alias_sample(alias_nodes[cur][0], alias_nodes[cur][1])]) else: prev = walk[-2] edge = (prev, cur) next_node = cur_nbrs[alias_sample(alias_edges[edge][0], alias_edges[edge][1])] walk.append(next_node) else: break return walk def simulate_walks(self, num_walks, walk_length, workers=1, verbose=0): G = self.G nodes = list(G.nodes()) results = Parallel(n_jobs=workers, verbose=verbose, )( delayed(self._simulate_walks)(nodes, num, walk_length) for num in partition_num(num_walks, workers)) walks = list(itertools.chain(*results)) return walks def _simulate_walks(self, nodes, num_walks, walk_length,): walks = [] for _ in range(num_walks): random.shuffle(nodes) for v in nodes: if self.p == 1 and self.q == 1: walks.append(self.deepwalk_walk( walk_length=walk_length, start_node=v)) else: walks.append(self.node2vec_walk( walk_length=walk_length, start_node=v)) return walks def get_alias_edge(self, t, v): """ compute unnormalized transition probability between nodes v and its neighbors give the previous visited node t. :param t: :param v: :return: """ G = self.G p = self.p q = self.q unnormalized_probs = [] for x in G.neighbors(v): weight = G[v][x].get('weight', 1.0) # w_vx if x == t: # d_tx == 0 unnormalized_probs.append(weight/p) elif G.has_edge(x, t): # d_tx == 1 unnormalized_probs.append(weight) else: # d_tx > 1 unnormalized_probs.append(weight/q) norm_const = sum(unnormalized_probs) normalized_probs = [ float(u_prob)/norm_const for u_prob in unnormalized_probs] return create_alias_table(normalized_probs) ##創建Alias算法的表 def preprocess_transition_probs(self): """ Preprocessing of transition probabilities for guiding the random walks. """ G = self.G alias_nodes = {} for node in G.nodes(): unnormalized_probs = [G[node][nbr].get('weight', 1.0) for nbr in G.neighbors(node)] norm_const = sum(unnormalized_probs) normalized_probs = [ float(u_prob)/norm_const for u_prob in unnormalized_probs] alias_nodes[node] = create_alias_table(normalized_probs) alias_edges = {} for edge in G.edges(): alias_edges[edge] = self.get_alias_edge(edge[0], edge[1]) self.alias_nodes = alias_nodes self.alias_edges = alias_edges return

alias.py:(alias_sample, create_alias_table)

import numpy as np


def create_alias_table(area_ratio):
    """

    :param area_ratio: sum(area_ratio)=1
    :return: accept,alias
    """
    l = len(area_ratio)
    accept, alias = [0] * l, [0] * l
    small, large = [], []
    area_ratio_ = np.array(area_ratio) * l
    for i, prob in enumerate(area_ratio_):
        if prob < 1.0:
            small.append(i)
        else:
            large.append(i)

    while small and large:
        small_idx, large_idx = small.pop(), large.pop()
        accept[small_idx] = area_ratio_[small_idx]
        alias[small_idx] = large_idx
        area_ratio_[large_idx] = area_ratio_[large_idx] - \
            (1 - area_ratio_[small_idx])
        if area_ratio_[large_idx] < 1.0:
            small.append(large_idx)
        else:
            large.append(large_idx)

    while large:
        large_idx = large.pop()
        accept[large_idx] = 1
    while small:
        small_idx = small.pop()
        accept[small_idx] = 1

    return accept, alias


def alias_sample(accept, alias):
    """

    :param accept:
    :param alias:
    :return: sample index
    """
    N = len(accept)
    i = int(np.random.random()*N)
    r = np.random.random()
    if r < accept[i]:
        return i
    else:
        return alias[i]
View Code

utils.py:(partition_num

def partition_num(num, workers):
    if num % workers == 0:
        return [num//workers]*workers
    else:
        return [num//workers]*workers + [num % workers]
View Code

 

三、LINE

LINE論文研究了大型信息網絡如何嵌入到低維向量空間的問題,應用於可視化,節點分類,和鏈路預測上。大多已存在的嵌入圖方法並不適用於現有的包含百萬個節點的信息網絡。

與DeepWalk使用DFS構造鄰域不同的是,LINE可以看作是一種使用BFS構造鄰域的算法。此外,LINE還可以應用在(有向、無向亦或是有權重)圖中(DeepWalk僅能用於無權圖),且對圖中頂點之間的相似度的定義不同。

應用效果:

在稀疏數據上 line的一階比二階要好,增加鄰居到鄰居的邊之后對效果有所提升。邊比較多的話,一階和二階結合比單獨使用一階和二階效果要更好。

 

1、思想

問題:

大規模信息網絡嵌入:給定一個大型網絡G=(V,E) 大規模信息網絡嵌入的目標是把每個節點 u \in V嵌入到低維向量空間 R^{d} 中。如:學習一個函數f_{G}:V\to R^{d},d\ll |V|.在R^{d} 空間內,節點間的一階相似度和二階相似度都被保留。

(1)一種新的相似度定義

 

 

  • first-order proximity(一階相似度)

1階相似度用於描述圖中成對頂點之間的局部相似度,形式化描述為若 [公式] , [公式] 之間存在直連邊,則邊權 [公式] 即為兩個頂點的相似度,若不存在直連邊,則1階相似度為0。 如上圖,6和7之間存在直連邊,且邊權較大,則認為兩者相似且1階相似度較高,而5和6之間不存在直連邊,則兩者間1階相似度為0。

  • second-order proximity

僅有1階相似度就夠了嗎?顯然不夠,如上圖,雖然5和6之間不存在直連邊,但是他們有很多相同的鄰居頂點(1,2,3,4),這其實也可以表明5和6是相似的,而2階相似度就是用來描述這種關系的。 形式化定義為,令 [公式] 表示頂點 [公式] 與所有其他頂點間的1階相似度,則 [公式] 與 [公式] 的2階相似度可以通過 [公式] 和 [公式] 的相似度表示。若[公式][公式]之間不存在相同的鄰居頂點,則2階相似度為0。

(2)優化目標

  • 1st-order

對於每一條無向邊 [公式] ,定義頂點 [公式] 和 [公式] 之間的聯合概率(兩者相連的可能性)為:

[公式] , [公式] 為頂點[公式]的低維向量表示。(可以看作一個內積模型,計算兩個item之間的匹配程度)

同時定義經驗分布 [公式] , [公式]

為了保留一階相似性,最小化優化目標: [公式]

[公式] 是兩個分布的距離,常用的衡量兩個概率分布差異的指標為KL散度,值越大差異越大,使用KL散度替換d(.,.)並忽略常數項后有

[公式]-----------(1)

1st order 相似度只能用於無向圖當中。

  • 2nd-order

這里對於每個頂點維護兩個embedding向量,一個是該頂點本身的表示向量,一個是該點作為其他頂點的上下文頂點時的表示向量。

對於有向邊 [公式] ,定義給定頂點 [公式] 條件下,產生上下文(鄰居)頂點 [公式] 的概率為 [公式] ,其中 [公式] 為上下文頂點的個數。

優化目標為 [公式] ,其中 [公式] 為控制節點重要性的因子,可以通過頂點的度數或者PageRank等方法估計得到。

經驗分布定義為: [公式] , [公式] 是邊 [公式] 的邊權, [公式] 是頂點 [公式] 的出度,對於帶權圖, [公式],其中N(i)是v_i節點的“出”鄰居(從i節點出發的鄰節點)。

為了方便,設置\lambda_i作為頂點i的出度,\lambda_i=d_i。還采用KL散度作為距離函數,使用KL距離代替d(.,.)。設置\lambda_i = d_{i}並忽略約束(常數項),有 

[公式]---------(2)

  • 一階相似度和二階相似度結合
為了在嵌入過程中保留一階相似度和二階相似度,在實踐中發現的一種簡單而有效的方法是訓練LINE模型,分別保留一階接近度和二階接近度,然后,為每個頂點連接由兩種方法訓練得到的嵌入。 更有原則的方法是結合兩個相似度來聯合訓練目標函數(1)和(2)。


(3)模型優化

  • Negative sampling(負采樣)

由於計算2階相似度--公式(2)時,softmax函數的分母計算需要遍歷所有頂點,這是非常低效的,論文采用了負采樣優化的技巧,為每條邊指定的目標函數變為:

[公式] ---------(3)

其中 \sigma(x)= 1/exp(-x)是sigmoid函數,第一項對觀察到的邊進行建模,第二項對從噪聲分布中繪制的負邊進行建模,[公式] 是負邊的個數。

論文使用 [公式] , [公式] 是頂點[公式] 的出度。

為了(1)式的目標函數。存在一個平凡解:u_{ik}=\infty.其中i=1,...,|V|且k=1...,d。為了避免平凡解,我們仍然可以使用負采樣方法,僅將\vec u{j}\prime^T變成\vec u_j^T
我們采用了異步隨機梯度算法(ASGD)來優化等式(3)。在每一步,ASGD算法取樣了一小部分的邊並更新了模型的參數,如果邊(i,j)被取樣,那么關於i節點的嵌入向量\vec u_i的梯度可以被計算:

\frac{\partial O_2}{\partial \vec u_i}=w_{ij}\cdot \frac{\partial logp_2(v_j|v_i)}{\partial \vec u_i}----(8)

 

  • Edge Sampling

注意到我們的目標函數在log之前還有一個權重系數 [公式] ,在使用梯度下降方法優化參數時, [公式] 會直接乘在梯度上。

如果圖中的邊權方差很大,則很難選擇一個合適的學習率。若使用較大的學習率那么對於較大的邊權可能會引起梯度爆炸,較小的學習率對於較小的邊權則會導致梯度過小。

對於上述問題,如果所有邊權相同,那么選擇一個合適的學習率會變得容易。這里采用了將帶權邊拆分為等權邊的一種方法,假如一個權重為 [公式] 的邊,則拆分后為 [公式] 個權重為1的邊。這樣可以解決學習率選擇的問題,但是由於邊數的增長,存儲的需求也會增加。

另一種方法則是從原始的帶權邊中進行采樣,每條邊被采樣的概率正比於原始圖中邊的權重,這樣既解決了學習率的問題,又沒有帶來過多的存儲開銷。

通過這種邊采樣處理,總體目標函數保持不變,問題歸結為如何根據權重對邊進行采樣。
W=(w_1,w_2,w_3,...,w_{|E|})表示邊的權重的順序。一種簡單的方法是可以直接計算權重的總和 w_{sum}=\sum_{i=1}^{|E|}w_i,然后在 [0,w_{sum}]中取一個隨機值來看隨機值落入的區間 [\sum_{j=0}^{i-1}w_j,\sum_{j=0}^iw_j]。這個方法得到樣本的時間復雜度時O(|E|).當邊的數量|E|較大時開銷較大。
我們根據邊的權重使用了從相同的離散分布中重復繪制樣本時時間復雜度僅為O(1)的alias table(別名表)方法來取樣。
從alias table取樣一條邊的時間O(1),優化一個負采樣需要O(d(K+1))的時間,其中K是負樣本的數量。因此,總體每一步驟都需要O(dK)時間。在實踐中,我們發現用於優化的步驟數量與邊的數量O(|E|)成比例。因此,LINE的總的時間復雜度是O(dK|E|),與邊|E|的數量呈線性關系的,且不依賴於頂點數量|V|。這種邊取樣方法在不影響效率的情況下提升了隨機梯度下降的有效性。

 

(4)其他問題

  • 低度數頂點

問題:如何精確嵌入具有較低度數的頂點?

對於一些頂點由於其鄰接點非常少會導致embedding向量的學習不充分,論文提到可以利用鄰居的鄰居構造樣本進行學習,這里也暴露出LINE方法僅考慮一階和二階相似性,對高階信息的利用不足。

由於這類頂點的鄰居數量很少,所以難以得到它所對應的精確表征,尤其是嚴重依賴上下文的二階相似度。一種推論是,通過增加其高階的鄰居(如鄰居的鄰居)來拓展這些頂點的鄰居。在本論文中,我們僅討論增加二級鄰居。即對每個頂點,增加其鄰居的鄰居。頂點i和其二級鄰居節點j之間的距離可以被計算為:

w_{ij}=\sum _{k\in N(i)}w_{ik}\frac{w_{kj}}{d_k}----(9)
實際上,我們可以僅為具有較低度數的頂點i增加一個有最大相似度w_{ij}的頂點子集{j}。

  • 新加入頂點

問題二:如何得到新頂點的表征?
對於一個新頂點i,如果已知它與已存在的頂點之間連接。我們可以根據已存在的頂點獲得經驗分布\hat p_1(\cdot ,v_i)和\hat p_2(\cdot|v_i)。為了獲取新頂點的嵌入,根據目標函數(3)式和(6)式。一個直接的方法通過更新新頂點的嵌入並保持已存在頂點的嵌入來最小化以下任意一個目標函數:

-\sum_{j\in N(i)}w_{ji}logp_1(v_j,v_i)或-\sum_{j\in N(i)}w_{ji}logp_2(v_j|v_i),---(10)
如果新頂點和已有節點之間有可觀察的連接,我們必須求助於其他信息,例如頂點的文本信息,我們將其作為未來的工作。

2、核心代碼

LINE使用梯度下降的方法進行優化,直接使用tensorflow進行實現,就可以不用人工寫參數更新的邏輯了。

這里的 實現中把1階和2階的方法融合到一起了,可以通過超參數order控制是分開優化還是聯合優化,論文推薦分開優化。

損失函數與模型

首先輸入就是兩個頂點的編號,然后分別拿到各自對應的embedding向量,最后輸出內積的結果。 真實label定義為1或者-1,通過模型輸出的內積和line_loss就可以優化使用了負采樣技巧的目標函數了。

def line_loss(y_true, y_pred):
    return -K.mean(K.log(K.sigmoid(y_true*y_pred)))

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')

    v_i_emb = first_emb(v_i)
    v_j_emb = first_emb(v_j)

    v_i_emb_second = second_emb(v_i)
    v_j_context_emb = context_emb(v_j)

    first = Lambda(lambda x: tf.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.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)

頂點負采樣和邊采樣

 下面的函數功能是創建頂點負采樣和邊采樣需要的采樣表。中規中矩,主要就是做一些預處理,然后創建alias算法需要的兩個表。

  • 頂點負采樣:

:node_degree【頂點】,頂點i 的出度權重和。

 :power = 0.75

norm_prob:所有頂點的 d0.75/ ∑di0.75(∑di即total_sum) ---------->(所有頂點出度權重和,進行歸一化)作為alias算法的頂點出度概率分布

  • 邊采樣:

norm_prob :所有邊權重,進行歸一化,作為alias算法的邊概率分布。

 

def _gen_sampling_table(self):

    # create sampling table for vertex
    power = 0.75
    numNodes = self.node_size
    node_degree = np.zeros(numNodes)  # out degree
    node2idx = self.node2idx

    for edge in self.graph.edges():
        node_degree[node2idx[edge[0]]
                    ] += self.graph[edge[0]][edge[1]].get('weight', 1.0)

    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)]

    self.node_accept, self.node_alias = create_alias_table(norm_prob)

    # create sampling table for edge
    numEdges = self.graph.number_of_edges()
    total_sum = sum([self.graph[edge[0]][edge[1]].get('weight', 1.0)
                        for edge in self.graph.edges()])
    norm_prob = [self.graph[edge[0]][edge[1]].get('weight', 1.0) *
                    numEdges / total_sum for edge in self.graph.edges()]

    self.edge_accept, self.edge_alias = create_alias_table(norm_prob)

 

3、應用代碼

用LINE在wiki數據集上進行節點分類任務和可視化任務。 wiki數據集包含 2,405 個網頁和17,981條網頁之間的鏈接關系,以及每個網頁的所屬類別。 由於1階相似度僅能應用於無向圖中,所以本例中僅使用2階相似度。

(1)加載數據和執行代碼

import LINE
import networkx as nx

if __name__ == "__main__":
    #加載圖數據
    G = nx.read_edgelist('../data/wiki/Wiki_edgelist.txt',
                         create_using=nx.DiGraph(), nodetype=None, data=[('weight', int)])

    #LINE模型訓練
    model = LINE(G, embedding_size=128, order='second')
    model.train(batch_size=1024, epochs=50, verbose=2)
    #獲取圖節點embedding
    embeddings = model.get_embeddings()

(2)LINE模型

import math
import random
import numpy as np

#tf2的相關模塊
import tensorflow as tf
from tensorflow.python.keras import backend as K
from tensorflow.python.keras.layers import Embedding, Input, Lambda
from tensorflow.python.keras.models import Model

##alias算法的構造表和采樣
from ..alias import create_alias_table, alias_sample


##輔助函數,將圖節點轉化成(0,1,2,……)對應的字典
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

##損失函數
def line_loss(y_true, y_pred):
    return -K.mean(K.log(K.sigmoid(y_true*y_pred)))

##創建模型
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')

    v_i_emb = first_emb(v_i)
    v_j_emb = first_emb(v_j)

    v_i_emb_second = second_emb(v_i)
    v_j_context_emb = context_emb(v_j)

    #Lambda函數,Lambda(function)(tensor)
    first = Lambda(lambda x: tf.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.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)

    return model, {'first': first_emb, 'second': second_emb}


##LINE模型類
class LINE:
    def __init__(self, graph, embedding_size=8, negative_ratio=5, order='second',):
        """

        :param graph:
        :param embedding_size:
        :param negative_ratio:
        :param order: 'first','second','all'
        """
        if order not in ['first', 'second', 'all']:
            raise ValueError('mode must be fisrt,second,or all')

        self.graph = graph
        self.idx2node, self.node2idx = preprocess_nxgraph(graph)
        self.use_alias = True

        self.rep_size = embedding_size
        self.order = order

        self._embeddings = {}
        self.negative_ratio = negative_ratio
        self.order = order

        self.node_size = graph.number_of_nodes()
        self.edge_size = graph.number_of_edges()
        self.samples_per_epoch = self.edge_size*(1+negative_ratio)


        # 采樣表,獲取邊和頂點的采樣accept和alias
        self._gen_sampling_table()
        # 建立模型,執行create_model和batch_iter
        self.reset_model()

    def reset_training_config(self, batch_size, times):
        self.batch_size = batch_size
        self.steps_per_epoch = (
            (self.samples_per_epoch - 1) // self.batch_size + 1)*times

    def reset_model(self, opt='adam'):

        self.model, self.embedding_dict = create_model(
            self.node_size, self.rep_size, self.order)
        self.model.compile(opt, line_loss)
        self.batch_it = self.batch_iter(self.node2idx)

    def _gen_sampling_table(self):

        # create sampling table for vertex
        power = 0.75
        numNodes = self.node_size
        node_degree = np.zeros(numNodes)  # out degree
        node2idx = self.node2idx

        for edge in self.graph.edges():
            node_degree[node2idx[edge[0]]
                        ] += self.graph[edge[0]][edge[1]].get('weight', 1.0)

        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)]

        self.node_accept, self.node_alias = create_alias_table(norm_prob)

        # create sampling table for edge
        numEdges = self.graph.number_of_edges()
        total_sum = sum([self.graph[edge[0]][edge[1]].get('weight', 1.0)
                         for edge in self.graph.edges()])
        norm_prob = [self.graph[edge[0]][edge[1]].get('weight', 1.0) *
                     numEdges / total_sum for edge in self.graph.edges()]

        self.edge_accept, self.edge_alias = create_alias_table(norm_prob)

    def batch_iter(self, node2idx):

        edges = [(node2idx[x[0]], node2idx[x[1]]) for x in self.graph.edges()]

        data_size = self.graph.number_of_edges()
        shuffle_indices = np.random.permutation(np.arange(data_size))
        # positive or negative mod
        mod = 0
        mod_size = 1 + self.negative_ratio
        h = []
        t = []
        sign = 0
        count = 0
        start_index = 0
        end_index = min(start_index + self.batch_size, data_size)
        while True:
            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)):
                    t.append(alias_sample(
                        self.node_accept, self.node_alias))

            if self.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 + self.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 + self.batch_size, data_size)

    def get_embeddings(self,):
        self._embeddings = {}
        if self.order == 'first':
            embeddings = self.embedding_dict['first'].get_weights()[0]
        elif self.order == 'second':
            embeddings = self.embedding_dict['second'].get_weights()[0]
        else:
            embeddings = np.hstack((self.embedding_dict['first'].get_weights()[
                                   0], self.embedding_dict['second'].get_weights()[0]))
        idx2node = self.idx2node
        for i, embedding in enumerate(embeddings):
            self._embeddings[idx2node[i]] = embedding

        return self._embeddings

    def train(self, batch_size=1024, epochs=1, initial_epoch=0, verbose=1, times=1):
        self.reset_training_config(batch_size, times)
        hist = self.model.fit_generator(self.batch_it, epochs=epochs, initial_epoch=initial_epoch, steps_per_epoch=self.steps_per_epoch,
                                        verbose=verbose)

        return hist

 參考:

Graph Representation Learning:圖的表示學習

DeepWalk:算法原理,實現和應用

node2vec:算法原理,實現和應用

LINE:算法原理,實現和應用

LINE學習筆記

 

 
       


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM