并行Louvain社区检测算法
因为在我最近的科研中需要用到分布式的社区检测(也称为图聚类(graph clustering))算法,专门去查找了相关文献对其进行了学习。下面我们就以这篇论文IPDPS2018的文章[1]为例介绍并行社区检测算法。
关于基本的单机/串行社区检测算法,大家可以参考我的另一篇博客(链接:)。总而言之,目前对于图的簇/社团划分,目前最广泛的测量方法是使用模块性(modularity)。然而模块性的优化是NP完全问题,目前最有名的一种启发式求解方法是基于贪心准则的Louvain方法。由于其速度和对社区检测结果的高质量,Louvain方法在串行/单机社区检测中一直是最常用的方法之一。
目前有许多方式可以对Louvain方法进行并行化。目前最快的基于共享内存多线程的Louvain方法是Grappolo软件包[2]。该软件能够在812s内在20个核上处理现实世界中大型的网络(soc-friendER; 18亿条边)。
在本篇论文中,作者将这个方法扩展到了分布式内存领域(和前面基于共享内存的领域不同)。而设计一个分布式的Louvain算法的挑战在于执行高效的邻居节点扫描(为了捕捉邻居节点所属社区状态的变化),因为在图的分布式表示的情况下,通信开销会变得非常重要。另一个关键的挑战是我们对哪个社区的状态进行查询和更新。串行算法的好处在于每轮迭代之间有同步操作,然而在分布式的环境下维护与传播最新的信息是被禁止的。此外,在分布式环境下一条边跨几个进程的处理也是一个棘手的问题。
1. 串行Louvain方法
我们这里简要描述一下串行的Louvain方法(详细描述见另一篇博客)。Louvain方法是一个多轮的迭代过程,每轮迭代分几个步骤。第一步,将每个节点视为一个社区,然后遍历每个节点——其中对于节点\(v\),计算将\(v\)移入各邻居可能导致的模块性增益\(\Delta Q\),如果最大的增益为正,那么\(v\)就会从其现在的社区移入该社区;第二步,进行图重建,即将所有划分好的社区缩为超节点。这个迭代过程一直持续直到不再获得模块性的增益(可以人工给定一个阈值\(\tau\))。
该算法的伪代码描述如下(伪代码中省略了图重建的过程):
2. 并行Louvain方法
我们开始叙述并行算法部分。我们使用\(p\)表示进程的数量,编号\(i\)表示区间\([0, p-1]\)内的任意进程编号。
首先,我们将输入的节点及它们的边集列表(edge list)(我们采用邻接表进行存储)分摊到各进程上,使每个进程接收到的边数量大致相同(这里没有用高级的图划分算法)。每个进程除了它本身拥有的节点集合之外,还存储了虽然属于其他进程、但与本进程中节点相关联的其他节点的拷贝(后面我们统一把这类节点称为影子节点)。论文采用稀疏行(CSR)的方式存储点和边集列表。
类似地,每个进程也获得一个任意集合的社区(大致使每个进程获得的社区数量相同),同时也保存了和本进程的社区之间有关联边(incident edges,即“跨边”)的社区(我们称为影子社区)。
下面的伪代码给出了并行的Louvain算法的大致框架,它包含了LouvainIteration
过程(也就是前面的模块性增益最大化过程)和BuildNextPhaseGraph
(对应前面的图的重构)这两个过程。注意,LouvainIteration
过程的步骤我们还没有展开,其中包括了进程之间通信的过程。
接下来我们详细的看LouvainIteration
过程和BuildNextPhaseGraph
是怎么实现的。
(a)Louvain Iteration
下面的伪代码展示了LouvainIteration
过程:
在这个过程中,每个进程都拥有节点和社区的集合,而进程之间的通信主要交换的信息也是节点和社区之间的信息。对每个局部的节点,都会存储一个社区ID。对每个局部的社区,关联度(\(a_c\))也被局部地存储。因为节点和进程编号的映射关系没轮迭代都在变化,每个进程需要存储其影子节点的列表以及这些影子节点对应的进程。此外,因为在每轮迭代开始时每个节点都在其自身的社区,故初始化的影子社区信息能够由节点信息推导出来。
下面我们解释一下LouvainIteration
过程的主要步骤:
-
2行(即过程开始),因为节点到进程的映射在每轮迭代(这里的迭代是指
LouvainIteration
过程外的迭代,别搞混了)会改变(由于图的缩点重构),我们会调用ExchangeGhostVertices
操作执行一次通信操作来自交换影子的坐标信息。 -
4-5行(相当于每轮迭代的开始),论文进行了每轮迭代一次的
send-receive
通信步骤来交换对应的影子节点信息(参照算法4),send
操作指将进程的局部节点信息发往以这些节点做为影子节点的其他进程;receive
操作指从其他进程获得影子节点的更新。 -
7-9行,使用最新的节点信息来计算所有局部节点的社区分配。
-
10-11行(相当于每轮迭代的末尾),我们需要将影子社区已更新的信息送往它们所属的进程,并且使各进程的社区接受相关更新的社区。
-
12-13行,先新的社区状态计算当前进程子图的模块性,然后采用
AllReduce
操作规约得到全局模块性。 -
如果网络的模块性增益(\(\Delta Q\))相对于前面的迭代低于需要的一个阈值下限,我们就终止迭代(否则继续迭代)。
其中,ExchangeGhost
函数的定义如下:
ExchangeGhost
函数中6-7行表示当\(u\)节点的所有者\(owner\)不是当前节点,就将\(u\)增加到列表 \(vmap[owner]\) 中(\(vmap\)做为一个局部缓冲区,后面在第10行将\(vmap\)发给进程\(j\),然后在第11行接受传到第\(j\)个进程的影子节点数据)。
(b)Graph reconstruction
下面我们来看图的重构过程。上一个社区划分过程中所划分的社团将在图的重构过程中被压缩为单个节点。在一个社区内部的边会形成自环,对于连接两个不同社区的“跨边”会形成连接两个节点的一条边(权重为所有"跨边"之和)。图的重建过程可以参考下图:
我们可以看到,模块性优化步骤最初始将节点\(\{0, 1, 3\}\)分配给社区\(0\),节点\(2\)分配给社区\(2\),节点\(4\)分配给社区\(4\)(节点\(2\)和\(4\)各自成为一个社区)。由于社区ID由节点ID产生,我们认为\(0-2\)号社区属于(存储在)进程\(0\),\(3-4\)号社区属于(存储在)进程\(1\)。注意,图例中[Array]
表示数组,表示字典映射。
字典中存储了节点编号和社区编号的映射关系;[Edges]
数组中存储了边的权重;
字典存储了影子节点编号与其所属社区的映射关系。
然后图的重建可分为7个步骤:
(1) 每个进程将其局部社区进行重编号。重编号使用一个关联原始社区ID和新社区ID的哈希表
来实现。
(2) 每个进程检查可能已分配给远程节点(但和局部节点已无任何联系)的局部社区的ID。
(3) 基于前\(N\)个进程中局部社区的数量,为第\(N+1\)个进程的局部社区进行全局编号(并行前缀和计算)。
(4) 将最新的局部社区ID和全局社区ID编号的映射关系(
) 以AllReduce的方式更新到各个进程上。
(5) 每个进程会检查其所拥有的节点并构建部分的新的边集列表。对进程所拥有的每个节点,进程会检查其邻居列表,其中和新的社区ID相关联的邻居会形成一个自环。
(6) 一旦新的“部分边集列表”生成,它们就会被重新分配到各进程,然后生成新的图划分并调整边的权重(每个进程会尽可能获得相同数量的节点)。
(7) 根据新的超节点的带权邻接列表
构建新的图。
参考文献
-
[1] Ghosh S, Halappanavar M, Tumeo A, et al. Distributed louvain algorithm for graph community detection[C]//2018 IEEE international parallel and distributed processing symposium (IPDPS). IEEE, 2018: 885-895.
-
[2] Lu H, Halappanavar M, Kalyanaraman A. Parallel heuristics for scalable community detection[J]. Parallel Computing, 2015, 47: 19-37.