[论文]https://dl.acm.org/doi/pdf/10.1145/2939672.2939754
[code]https://github.com/eliorc/node2vec
-
abstract
We define a flexible notion of a node’s network neighborhood and design a biased random walk procedure, which efficiently explores diverse neighborhoods. Our algorithm generalizes prior work which is based on rigid notions of network neighborhoods, and we argue that the added flexibility in exploring neighborhoods is the key to learning richer representations.
node2vec相较于之前严格定义的网络邻居提出了更灵活的节点网络邻居定义,设计了一个有偏的控制随机游走的方式,可以发现更丰富的表示。
-
introduction
Overall our paper makes the following contributions:
We propose node2vec, an efficient scalable algorithm for feature learning in networks that efficiently optimizes a novel network-aware, neighborhood preserving objective using SGD.
We show how node2vec is in accordance with established principles in network science, providing flexibility in discovering representations conforming to different equivalences.
We extend node2vec and other feature learning methods based on neighborhood preserving objectives, from nodes to pairs of nodes for edge-based prediction tasks.
We empirically evaluate node2vec for multi-label classificaion and link prediction on several real-world datasets.
-
FEATURE LEARNING FRAMEWORK
设f(u)是将顶点u映射为embedding向量的映射函数,对于图中每个顶点u,定义为通过采样策略S采样出的顶点u的近邻顶点集合。
node2vec优化的目标是给定每个顶点条件下,令其近邻顶点出现的概率最大。
为了将上述最优化问题可解,文章提出两个假设:
-
条件独立性假设
假设给定源顶点下,其近邻顶点出现的概率与近邻集合中其余顶点无关。
-
特征空间对称性假设
这里是说一个顶点作为源顶点和作为近邻顶点的时候共享同一套embedding向量。(对比LINE中的2阶相似度,一个顶点作为源点和近邻点的时候是拥有不同的embedding向量的)
在这个假设下,上述条件概率公式可表示为
根据以上两个假设条件,最终的目标函数表示为
由于归一化因子的计算代价高,所以采用负采样技术优化。
特征学习的方法基于skip-gram结构,但是这是适用于自然语言的方式,网络图也非线性,所以文章提出了随机过程,对于点u的领域采样可以得到不同的Ns(u)
-
node2vec
-
random walks
-
是顶点v和顶点x之间的未归一化概率,Z是归一化因子。
设是v、x之间的边权
-
search bias
针对一个已经从t->v的路线,在v考虑下一步走哪的时候,利用来控制下一步的走向,是走的离t更远,还是更近。
dtx denotes the shortest path distance between nodes t and x.
Return parameter, p. Parameter p controls the likelihood of immediately revisiting a node in the walk.
In-out parameter, q. Parameter q allows the search to differentiate between “inward” and “outward” nodes.
相较于deepwalk考虑的是dfs,node2vec考虑的是dfs和bfs结合的游走方式。
-
algorithm
采样完顶点序列后,剩下的步骤就和deepwalk一样了,用word2vec去学习顶点的embedding向量。
值得注意的是node2vecWalk中不再是随机抽取邻接点,而是按概率抽取,node2vec采用了Alias算法进行顶点采样。
-
learning edge features
node2vec算法提供了一种半监督的方法来学习网络中节点的丰富特征表示。但是,我们通常对涉及节点对而不是单个节点的预测任务感兴趣。例如,在链路预测中,我们预测网络中两个节点之间是否存在链路。由于我们的随机游走自然是基于基础网络中节点之间的连接结构,因此我们使用自举方法将各个节点的特征表示扩展到成对的节点。
几种节点对的边特征操作定义。
源码【code】
-
整体结构
-
node2vec.py
import os
from collections import defaultdict
import numpy as np
import networkx as nx
import gensim
from joblib import Parallel, delayed
from tqdm import tqdm
from .parallel import parallel_generate_walks
class Node2Vec:
FIRST_TRAVEL_KEY = 'first_travel_key'
PROBABILITIES_KEY = 'probabilities'
NEIGHBORS_KEY = 'neighbors'
WEIGHT_KEY = 'weight'
NUM_WALKS_KEY = 'num_walks'
WALK_LENGTH_KEY = 'walk_length'
P_KEY = 'p'
Q_KEY = 'q'
def __init__(self, graph: nx.Graph, dimensions: int = 128, walk_length: int = 80, num_walks: int = 10, p: float = 1,
q: float = 1, weight_key: str = 'weight', workers: int = 1, sampling_strategy: dict = None,
quiet: bool = False, temp_folder: str = None):
"""
Initiates the Node2Vec object, precomputes walking probabilities and generates the walks.
:param graph: Input graph
:param dimensions: Embedding dimensions (default: 128)
:param walk_length: Number of nodes in each walk (default: 80)
:param num_walks: Number of walks per node (default: 10)
:param p: Return hyper parameter (default: 1)
:param q: Inout parameter (default: 1)
:param weight_key: On weighted graphs, this is the key for the weight attribute (default: 'weight')
:param workers: Number of workers for parallel execution (default: 1)
:param sampling_strategy: Node specific sampling strategies, supports setting node specific 'q', 'p', 'num_walks' and 'walk_length'.
Use these keys exactly. If not set, will use the global ones which were passed on the object initialization
:param temp_folder: Path to folder with enough space to hold the memory map of self.d_graph (for big graphs); to be passed joblib.Parallel.temp_folder
"""
self.graph = graph
self.dimensions = dimensions
self.walk_length = walk_length
self.num_walks = num_walks
self.p = p
self.q = q
self.weight_key = weight_key
self.workers = workers
self.quiet = quiet
self.d_graph = defaultdict(dict)
# 采样策略,包括指定某个节点p、q、num_walks、walk_length
if sampling_strategy is None:
self.sampling_strategy = {}
else:
self.sampling_strategy = sampling_strategy
self.temp_folder, self.require = None, None
if temp_folder:
if not os.path.isdir(temp_folder):
raise NotADirectoryError("temp_folder does not exist or is not a directory. ({})".format(temp_folder))
self.temp_folder = temp_folder
self.require = "sharedmem"
self._precompute_probabilities()
self.walks = self._generate_walks()
def _precompute_probabilities(self):
"""
Precomputes transition probabilities for each node.
"""
d_graph = self.d_graph
# 统计图中节点,若quiet为Ture,则可以不输出统计的进度
nodes_generator = self.graph.nodes() if self.quiet \
else tqdm(self.graph.nodes(), desc='Computing transition probabilities')
for source in nodes_generator:
# Init probabilities dict for first travel
if self.PROBABILITIES_KEY not in d_graph[source]:
d_graph[source][self.PROBABILITIES_KEY] = dict()
# 探查当前节点的邻居
for current_node in self.graph.neighbors(source):
# Init probabilities dict
if self.PROBABILITIES_KEY not in d_graph[current_node]:
d_graph[current_node][self.PROBABILITIES_KEY] = dict()
unnormalized_weights = list()
d_neighbors = list()
# Calculate unnormalized weights
# 计算未归一化的权重
for destination in self.graph.neighbors(current_node):
p = self.sampling_strategy[current_node].get(self.P_KEY,
self.p) if current_node in self.sampling_strategy else self.p
q = self.sampling_strategy[current_node].get(self.Q_KEY,
self.q) if current_node in self.sampling_strategy else self.q
if destination == source: # Backwards probability
ss_weight = self.graph[current_node][destination].get(self.weight_key, 1) * 1 / p
elif destination in self.graph[source]: # If the neighbor is connected to the source
ss_weight = self.graph[current_node][destination].get(self.weight_key, 1)
else:
ss_weight = self.graph[current_node][destination].get(self.weight_key, 1) * 1 / q
# Assign the unnormalized sampling strategy weight, normalize during random walk
unnormalized_weights.append(ss_weight)
d_neighbors.append(destination)
# Normalize
unnormalized_weights = np.array(unnormalized_weights)
d_graph[current_node][self.PROBABILITIES_KEY][
source] = unnormalized_weights / unnormalized_weights.sum()
# Save neighbors
d_graph[current_node][self.NEIGHBORS_KEY] = d_neighbors
# Calculate first_travel weights for source
first_travel_weights = []
for destination in self.graph.neighbors(source):
first_travel_weights.append(self.graph[source][destination].get(self.weight_key, 1))
first_travel_weights = np.array(first_travel_weights)
d_graph[source][self.FIRST_TRAVEL_KEY] = first_travel_weights / first_travel_weights.sum()
def _generate_walks(self) -> list:
"""
Generates the random walks which will be used as the skip-gram input.
:return: List of walks. Each walk is a list of nodes.
"""
# 将数据拉平
flatten = lambda l: [item for sublist in l for item in sublist]
# Split num_walks for each worker
num_walks_lists = np.array_split(range(self.num_walks), self.workers)
# 并行执行
walk_results = Parallel(n_jobs=self.workers, temp_folder=self.temp_folder, require=self.require)(
delayed(parallel_generate_walks)(self.d_graph,
self.walk_length,
len(num_walks),
idx,
self.sampling_strategy,
self.NUM_WALKS_KEY,
self.WALK_LENGTH_KEY,
self.NEIGHBORS_KEY,
self.PROBABILITIES_KEY,
self.FIRST_TRAVEL_KEY,
self.quiet) for
idx, num_walks
in enumerate(num_walks_lists, 1))
# print(walk_results)
walks = flatten(walk_results)
return walks
def fit(self, **skip_gram_params) -> gensim.models.Word2Vec:
"""
Creates the embeddings using gensim's Word2Vec.
:param skip_gram_params: Parameteres for gensim.models.Word2Vec - do not supply 'size' it is taken from the Node2Vec 'dimensions' parameter
:type skip_gram_params: dict
:return: A gensim word2vec model
"""
if 'workers' not in skip_gram_params:
skip_gram_params['workers'] = self.workers
if 'size' not in skip_gram_params:
skip_gram_params['size'] = self.dimensions
return gensim.models.Word2Vec(self.walks, **skip_gram_params)
-
parallel.py(主要实现并行运行)
import random
import numpy as np
from tqdm import tqdm
def parallel_generate_walks(d_graph: dict, global_walk_length: int, num_walks: int, cpu_num: int,
sampling_strategy: dict = None, num_walks_key: str = None, walk_length_key: str = None,
neighbors_key: str = None, probabilities_key: str = None, first_travel_key: str = None,
quiet: bool = False) -> list:
"""
Generates the random walks which will be used as the skip-gram input.
:return: List of walks. Each walk is a list of nodes.
"""
walks = list()
# 输出当前是第几个cpu
if not quiet:
pbar = tqdm(total=num_walks, desc='Generating walks (CPU: {})'.format(cpu_num))
for n_walk in range(num_walks):
# Update progress bar
if not quiet:
pbar.update(1)
# Shuffle the nodes
shuffled_nodes = list(d_graph.keys())
random.shuffle(shuffled_nodes)
# Start a random walk from every node
for source in shuffled_nodes:
# Skip nodes with specific num_walks
if source in sampling_strategy and \
num_walks_key in sampling_strategy[source] and \
sampling_strategy[source][num_walks_key] <= n_walk:
continue
# Start walk
walk = [source]
# Calculate walk length
if source in sampling_strategy:
walk_length = sampling_strategy[source].get(walk_length_key, global_walk_length)
else:
walk_length = global_walk_length
# Perform walk
while len(walk) < walk_length:
walk_options = d_graph[walk[-1]].get(neighbors_key, None)
# Skip dead end nodes
if not walk_options:
break
if len(walk) == 1: # For the first step
probabilities = d_graph[walk[-1]][first_travel_key]
walk_to = np.random.choice(walk_options, size=1, p=probabilities)[0]
else:
probabilities = d_graph[walk[-1]][probabilities_key][walk[-2]]
walk_to = np.random.choice(walk_options, size=1, p=probabilities)[0]
walk.append(walk_to)
walk = list(map(str, walk)) # Convert all to strings
walks.append(walk)
if not quiet:
pbar.close()
return walks
-
edges.py(利用EdgeEmbedder实现了paper里面table1总结的几种操作,)
EdgeEmbedder is an abstract class which all the concrete edge embeddings class inherit from. The classes are AverageEmbedder, HadamardEmbedder, WeightedL1Embedder and WeightedL2Embedder which their practical definition could be found in the paper on table 1 Notice that edge embeddings are defined for any pair of nodes, connected or not and even node with itself.
import numpy as np
from abc import ABC, abstractmethod
from functools import reduce
from itertools import combinations_with_replacement
from gensim.models import KeyedVectors
from tqdm import tqdm
class EdgeEmbedder(ABC):
def __init__(self, keyed_vectors: KeyedVectors, quiet: bool = False):
"""
:param keyed_vectors: KeyedVectors containing nodes and embeddings to calculate edges for
"""
self.kv = keyed_vectors
self.quiet = quiet
@abstractmethod
def _embed(self, edge: tuple) -> np.ndarray:
"""
Abstract method for implementing the embedding method
:param edge: tuple of two nodes
:return: Edge embedding
"""
pass
def __getitem__(self, edge) -> np.ndarray:
if not isinstance(edge, tuple) or not len(edge) == 2:
raise ValueError('edge must be a tuple of two nodes')
if edge[0] not in self.kv.index2word:
raise KeyError('node {} does not exist in given KeyedVectors'.format(edge[0]))
if edge[1] not in self.kv.index2word:
raise KeyError('node {} does not exist in given KeyedVectors'.format(edge[1]))
return self._embed(edge)
def as_keyed_vectors(self) -> KeyedVectors:
"""
Generated a KeyedVectors instance with all the possible edge embeddings
:return: Edge embeddings
"""
edge_generator = combinations_with_replacement(self.kv.index2word, r=2)
if not self.quiet:
vocab_size = len(self.kv.vocab)
total_size = reduce(lambda x, y: x * y, range(1, vocab_size + 2)) / \
(2 * reduce(lambda x, y: x * y, range(1, vocab_size)))
edge_generator = tqdm(edge_generator, desc='Generating edge features', total=total_size)
# Generate features
tokens = []
features = []
for edge in edge_generator:
token = str(tuple(sorted(edge)))
embedding = self._embed(edge)
tokens.append(token)
features.append(embedding)
# Build KV instance
edge_kv = KeyedVectors(vector_size=self.kv.vector_size)
edge_kv.add(
entities=tokens,
weights=features)
return edge_kv
class AverageEmbedder(EdgeEmbedder):
"""
Average node features
"""
def _embed(self, edge: tuple):
return (self.kv[edge[0]] + self.kv[edge[1]]) / 2
class HadamardEmbedder(EdgeEmbedder):
"""
Hadamard product node features
"""
def _embed(self, edge: tuple):
return self.kv[edge[0]] * self.kv[edge[1]]
class WeightedL1Embedder(EdgeEmbedder):
"""
Weighted L1 node features
"""
def _embed(self, edge: tuple):
return np.abs(self.kv[edge[0]] - self.kv[edge[1]])
class WeightedL2Embedder(EdgeEmbedder):
"""
Weighted L2 node features
"""
def _embed(self, edge: tuple):
return (self.kv[edge[0]] - self.kv[edge[1]]) ** 2
操作
pip install node2vec