☰
从边介数到模块度:GN算法原理与Python实现详解
2026/10/13 20:59:09 网站建设 项目流程

一句话弄清楚GN算法为什么值得学

社区发现这个话题,这几年在复杂网络分析里属于绕不开的基础操作。无论是社交平台上的用户分群、电商交易网络里的风险团伙识别,还是生物网络里的功能模块划分,本质上都是在做同一件事:给网络里的节点“靠相似度聚类”。在众多社区发现算法里,GN算法(Girvan-Newman算法的中文俗称,下文统一称GN)是很容易被新手低估的一个,因为它看起来思路简单,甚至有点“笨”:一条一条拆边。但它却是理解社区结构、理解模块度、理解网络层级划分的最佳入门算法之一。

这篇文章会用完整可运行的附源码Python实现,把GN算法掰开揉碎讲清楚,从边介数的计算原理、迭代拆分的执行过程,到模块度如何决定算法什么时候停,再到实际运行中容易踩的坑和复杂度优化。适合正在做图分析、社交网络研究,或者单纯想搞懂社区发现底层逻辑的开发者阅读。读完你可以直接复制代码跑一遍,也能理解为什么在真实大规模网络里,GN往往不是最优选择。

1. GN算法想解决的到底是什么问题

1.1 社区发现:复杂网络里的“聚类”

如果说“聚类”是无监督学习中的经典问题,那么社区发现就是图数据上的聚类。给定一个无向图,节点表示用户、商品、基因或者路由器,边表示关系,社区发现的目标是把节点划分成若干个组,让组内连接尽量稠密,组间连接尽量稀疏。

说起来容易,做起来却很微妙。因为现实中网络的结构千差万别,有的社区边界非常清晰,比如两个社团之间只靠一条边相连;有的社区则像一团乱麻,重叠、嵌套、模糊地带到处都是,划分起来怎么都不完美。社区发现算法要做的,就是在这种“模糊”中找到一个相对合理的划分。

GN算法处理这个问题的角度很特别:它不直接“找社区”,而是通过拆边让社区自己浮现出来。就跟拆积木一样,先找到最关键的连接点,抽掉它,自然就分成两堆了。这种从“破坏”入手的思路,在当时是非常有启发性的。

1.2 从“桥”到“边介数”:GN的突破口

想明白GN,先要理解一个直觉:一座跨越两个城市的大桥,哪怕平时车流量不大,一旦断了,两边的交通就彻底瘫痪。网络里也存在这种“关键连接”,它们位于不同社区的交界处,像一条跨海大桥,两端各自连接着一整片稠密的内部网络。

问题是,怎么用程序识别出这些关键边?总不能靠肉眼一个个看。GN算法的提出者给出了一个优雅的度量:边介数(edge betweenness)。一条边介数高,意味着它在很多节点对之间的最短路径上被反复“经过”,承担着重要的“中转”角色。这样的边一旦被切断,网络的连通性会被大幅削弱,社区边界也就暴露出来了。

GN算法的整体框架也因此变得清晰:反复计算所有边的边介数,找到最大值对应的边并移除,然后重新计算剩下的边介数,继续移除,直到把整个网络拆成一个一个孤立的节点。整个过程中间产生的每一轮连通分量,都是不同粒度下的社区划分,而最终选哪一轮,则交给模块度这个评估指标来定。

2. 边介数计算与算法流程详解

2.1 边介数的定义与计算原理

边介数的定义不复杂:在一张无向图中,如果统计所有节点对之间的最短路径,那么一条边出现在这些最短路径里的次数越多,它的边介数就越高。

这里有个容易混淆的点:边介数不是“经过这条边的流量”,而是“依赖这条边的最短路径数量”。两个概念有重叠,但本质不同。流量关注的是实际走多少,边介数关注的是假设大家都要走最短路径时,有多少条最短路被这条边命中。

为了让你更直观地理解,我举一个极端例子。A、B两个节点之间唯一路径经过边e,那么边e至少会被A到B这一对节点的最短路径命中一次。如果A和B各自又连接一大群节点,那么这一大群节点之间的最短路径只要跨边,就都得经过e。这样一来e的边介数就会变得非常高,因为它承担了整个网络“跨区通信”的咽喉作用。

在数学上,一条边e的介数可以写作:

[B(e)=\sum_{s \neq t}\frac{\sigma_{st}(e)}{\sigma_{st}}]

其中(\sigma_{st})是节点s到t的最短路径总数,(\sigma_{st}(e))是这些最短路径中经过边e的数量。对于无向图,通常不计s到自身,且s和t的先后顺序不重复计算。

2.2 用最短路径计数算一次边介数

如果你从定义出发直接算,时间复杂度会很糟糕,因为每一对节点都要做一次全源最短路径计算。好在有Brandes算法,可以把无向无权图上的边介数计算复杂度压到O(VE),也就是V个节点、E条边时,线性时间内完成一次全图计算。

Brandes算法的核心思想非常巧妙:它把“每条边对介数的贡献”拆解成从每个源节点出发的累积过程。首先对每个源节点做一次BFS,记录每个节点的最短路径条数和前驱节点集合,然后按照BFS顺序的逆序往回累积贡献值。这样不需要为每一对节点单独枚举,只需要对每个源节点跑一遍BFS。

这里我插入一个手算的简单例子。假设一条路径是A-B-C,我们要算A到C的最短路,路径唯一,那么经过边A-B的最短路径贡献是1,经过边B-C的贡献也是1。但如果A到C有两条等长路径,一条走A-B-C,另一条走A-D-C,那么每条边就只能分到0.5的贡献。这就是公式中除以(\sigma_{st})的原因:路径越多,单条边的“重要性”被稀释。

2.3 GN迭代过程:边的“连续切除”

GN算法的迭代过程可以用几个步骤概括:

  1. 计算当前网络中所有边的边介数;
  2. 找边介数最大的边并移除;
  3. 如果出现孤立的节点,直接删掉(或者不参与后续计算);
  4. 重复第1步,直到所有边都被移除;
  5. 记录每一轮移除边之后的连通分量,作为不同粒度下的社区划分结果;
  6. 用模块度对所有划分结果打分,取分数最高的那一轮作为最终社区划分。

这个流程有一个特点:它是一个“层级分裂”的过程,而不是一次性把所有节点分好。就好比先切断整个国家之间的主干道,国家分成省;再切省内的高速路,省分成市;再切城市之间的主干道……每一刀下去,都能得到一个不同尺度的社区划分。所以GN算法产生的不是单一结果,而是一棵社区层级树,具体选哪一层看实际需求。

3. 附源码:纯Python实现GN算法(可直接运行)

3.1 工程环境与图数据表示

在写代码之前,我先约定一下实现环境。以下代码全部用Python标准库实现,不需要安装任何第三方库,只需要Python 3.6以上即可运行。如果你装了networkx,可以拿它做结果对比,但核心算法不依赖它。

图数据统一用邻接表表示:一个字典,key是节点,value是一个列表,存放与该节点直接相连的所有节点。

G = { 'A': ['B', 'C', 'D'], 'B': ['A', 'C', 'D'], 'C': ['A', 'B', 'D', 'W'], 'D': ['A', 'B', 'C'], 'W': ['C', 'X', 'Y', 'Z'], 'X': ['W', 'Y', 'Z'], 'Y': ['W', 'X', 'Z'], 'Z': ['W', 'X', 'Y'], }

这是一个典型的“两个团一条桥”结构,左侧4个节点A、B、C、D构成一个完全图(团),右侧W、X、Y、Z也构成一个完全图,C和W之间用一条桥边连接。这个图我后面会反复用来说明算法行为。

3.2 边介数核心函数实现

接下来是最核心的部分:基于Brandes思想实现的边介数计算。我注释写得比较详细,方便你逐行理解。

from collections import deque def edge_betweenness(G): """计算无向无权图的边介数。 参数 ---- G : dict 邻接表形式的无向图,例如 {'A': ['B', 'C'], ...} 返回 ---- cb : dict 键为 frozenset({u, v}),值为边介数值 """ cb = {frozenset((u, v)): 0.0 for u in G for v in G[u]} for source in G: # BFS 过程 dist = {source: 0} sigma = {source: 1} # 从source到每个节点的最短路径条数 pred = {node: [] for node in G} # 前驱列表 order = [] # BFS 访问顺序 queue = deque([source]) while queue: v = queue.popleft() order.append(v) for w in G[v]: if w not in dist: dist[w] = dist[v] + 1 queue.append(w) sigma[w] = 0 if dist[w] == dist[v] + 1: sigma[w] += sigma[v] pred[w].append(v) # 反向累积依赖值 delta = {node: 0.0 for node in G} for v in reversed(order): for p in pred[v]: if sigma[v] == 0: continue contribution = (delta[v] + 1.0) * sigma[p] / sigma[v] delta[p] += contribution cb[frozenset((p, v))] += contribution # 无向图中每条边被双向各算了一次,所以要除以2 for e in cb: cb[e] /= 2.0 return cb

这段代码的逻辑不复杂,但有两处容易写错。第一,在BFS中发现新节点w时,要先把sigma[w]置为0,再进入距离判断,否则首次访问时路径数会少算。第二,反向累积时,只遍历前驱关系里的边,不需要遍历所有边,这样才能保证线性复杂度。

3.3 社区分裂主流程与模块度评估

边介数算好之后,就进入GN主题循环。每轮都要做三件事:找到当前最大边介数对应的边、移除它、记录当前的连通分量。

def get_components(G): """返回当前图的所有连通分量(每个分量是节点列表)。""" visited = set() components = [] for node in G: if node in visited: continue stack = [node] visited.add(node) comp = [] while stack: cur = stack.pop() comp.append(cur) for nbr in G[cur]: if nbr not in visited: visited.add(nbr) stack.append(nbr) components.append(comp) return components def gn_communities(G): """执行GN算法,返回每一轮的社区划分结果。 返回一个列表,每个元素是某一轮移除边之后的连通分量列表。 """ # 深拷贝一份,避免修改原始图 current_graph = {node: list(neighbors) for node, neighbors in G.items()} history = [] while current_graph: # 记录当前划分 components = get_components(current_graph) history.append(components) if len(current_graph) <= 1 or len(get_components(current_graph)) == len(current_graph): break # 计算边介数 cb = edge_betweenness(current_graph) if not cb: break # 找边介数最大的边 max_edge = None max_val = -1.0 for edge, val in cb.items(): if val > max_val: max_val = val max_edge = edge # 移除边 u, v = tuple(max_edge) current_graph[u].remove(v) current_graph[v].remove(u) # 去除孤立节点 isolated = [node for node in current_graph if len(current_graph[node]) == 0] for node in isolated: del current_graph[node] return history def modularity(G, communities): """计算给定社区划分的模块度。 Q = sum( L_c / m - (D_c / (2m))^2 ) L_c 是社区c内部边数 D_c 是社区c内所有节点的度之和 m 是整个网络的边数 """ m = sum(len(neighbors) for neighbors in G.values()) // 2 q = 0.0 for community in communities: node_set = set(community) # 内部边数(注意无向图每条边会被数两次,最后要除以2) internal_edges = 0 for node in community: for nbr in G[node]: if nbr in node_set: internal_edges += 1 internal_edges //= 2 total_degree = sum(len(G[node]) for node in community) if m == 0: continue q += internal_edges / m - (total_degree / (2 * m)) ** 2 return q

模块度这个式子看着抽象,我举一个生活类比:你随机往网络里连边,连完之后发现某个组内部边数明显比随机情况下多得多,说明这个组不是巧合凑出来的,而是一个有意义的社区。模块度越高,这种“比随机情况多出来的结构性”越明显,社区质量越好。

3.4 一键运行完整演示

最后写一段演示代码,把整个流程串起来。运行之后,你能看到每一轮被移除的边,以及对应的模块度分数。

if __name__ == "__main__": G = { 'A': ['B', 'C', 'D'], 'B': ['A', 'C', 'D'], 'C': ['A', 'B', 'D', 'W'], 'D': ['A', 'B', 'C'], 'W': ['C', 'X', 'Y', 'Z'], 'X': ['W', 'Y', 'Z'], 'Y': ['W', 'X', 'Z'], 'Z': ['W', 'X', 'Y'], } history = gn_communities(G) best_q = -1.0 best_idx = -1 for i, communities in enumerate(history): q = modularity(G, communities) print(f"第{i}轮社区划分: {communities}") print(f"模块度 Q = {q:.4f}") print("-" * 40) if q > best_q: best_q = q best_idx = i print(f"最优划分出现在第{best_idx}轮,Q = {best_q:.4f}") print(f"对应社区: {history[best_idx]}")

运行结果你应该能看到第一轮就把桥边C-W切掉了,得到两个各含4个节点的社区,模块度大约0.497。之后再继续切,模块度会下降,最优结果就是这两个簇。

4. 实战拆解:用双社团网络验证算法行为

4.1 构造“两个团一条桥”的数据

为了验证GN算法真的能找出社区,我特意构造了一个极度“干净”的例子:两个K4完全图,用一条边连接。在这种图上,社区划分的正确答案非常明显,就是两个团,因为没有比这更自然的划分方式了。

这种图唯一的边界就是那条桥边C-W。理论上,任何正常工作的社区发现算法都应该首先把这条桥边识别出来并切开。GN算法能不能做到,就看边介数计算是否准确。

4.2 逐轮观察划分与模块度变化

实际运行代码后,第一轮计算结果里,C-W这条边的介数会显著高于其他任何一条边。原因不难理解:左侧4个节点中任意一个到右侧4个节点中任意一个的最短路径,只要是跨社区通信,通通会经过C-W。而左侧内部节点之间的最短路径,以及右侧内部节点之间的最短路径,都有很多条可以绕行,单条边的介数自然被稀释了。

移除C-W之后,网络分裂成两个不相连的组件。这时你再算模块度,会发现Q值达到约0.497,这是整个过程中最高的。理性地说,0.5左右已经算是非常好的社区结构了,说明划分显著优于随机连接。

继续迭代,算法会继续切掉左侧团内部的一条边,导致原来的4节点团进一步分裂成两个更小的社区。这时模块度不升反降,说明你正在把“本来应该是一体的社区”强行拆开。模块度在这里就起到了刹车作用:它告诉我们,拆到这里就该停了,再拆下去是过度切分。

4.3 为什么桥边最先被切除:边介数验证

我想再手动算下C-W边介数的量级,帮助你理解程序输出的含义。

把图拆成两部分看:左侧4个节点、右侧4个节点。跨社区的节点对有4×4=16对,每一对之间的最短路径都只有一条,就是必须走C-W。所以仅凭跨社区节点对,C-W这桥边至少贡献16的介数。

再看同侧节点对:左侧内部任意两个节点之间的最短路径有两条,一条经过C、一条绕另一条对角边,所以经过C-W的只有一部分。右侧同理。还有从左侧某个节点出发,经过C-W到右侧,再回到右侧某个节点的路径,这些也都算C-W的介数。综合算下来,C-W的边介数大概是其他内部边的三四倍。这也是GN算法“切桥”直觉的数学体现:桥边承担了不成比例的最短路径负担,介数值自然脱颖而出。

5. 复杂度、局限性与优化

5.1 时间复杂度推导:为什么GN在几百个节点后很慢

GN算法最遭人诟病的地方就是复杂度。我们来做个简单的推导。

一次全图边介数计算,需要对每个节点跑一遍BFS,每次BFS遍历整张图,所以复杂度是O(VE)。GN算法每移除一条边就要重新计算一次全图边介数,最坏情况下要移除E条边才能拆完,因此整体复杂度是O(E²V)。

对于稀疏图,E和V近似同阶,复杂度退化到O(V³),也就是几百个节点时还能忍受,上千个节点就开始明显变慢。我用一个约500个节点的社交关系网跑过,每轮计算要等几秒,拆到最后等了几十分钟。这个算法的定位从来就不是“大规模生产工具”,而是教学和理解性工具。

5.2 工程改造与替代算法对比

针对GN算法的性能瓶颈,常见的优化思路有三个:

第一,在大型网络上避免全局重算,只在被移除边附近的局部区域重算边介数。具体做法并不简单,但能显著减少重复计算量。

第二,用并行化加速边介数计算。因为每个源节点的BFS计算彼此独立,你可以用多进程把V个源节点分给不同核心同时算,最后再汇总。这是性价比很高的一种提速方式,代码也容易改造。

第三,如果网络规模真的很大,建议直接换成其他算法。Louvain算法通过在局部反复贪心合并社区来逼近模块度最大化,速度远快于GN;标签传播算法则是让节点根据邻居的标签反复更新自己的社区归属,虽然稳定性和结果确定性差一些,但性能极好。

算法复杂度是否确定适用规模最擅长场景
GN算法O(E²V)确定百级节点理解社区层次结构、小规模精确分析
LouvainO(V log²V) 左右基本确定百万级节点大规模网络快速社区发现
标签传播O(E) 级别随机千万级节点大规模场景下快速粗分

所以我的建议是:在需要真正理解社区结构、或者做算法实验对比的场景里,GN是很好的基线算法;在工程生产环境里,优先考虑Louvain和Leiden这类分层贪心算法。

6. 踩坑清单与GN算法使用心得

6.1 常见的五个错误与排查方法

再好的算法,代码实现上也有容易翻车的地方。我把实际调试过程中踩过的坑整理了一遍,每一条都是真实教训。

第一,忘了处理孤立节点。移除桥边后,可能会出现度变为0的节点,如果不删除,后续边介数计算就会出问题,因为它没有任何最短路径经过,但依然在BFS遍历里占用资源,甚至会在某些实现里造成死循环。解决方法是每轮移除边后主动检测并清理孤立节点。

第二,图的副本问题。如果不小心在原始图上直接执行边移除操作,多次运行算法时数据就被污染了。记得用深拷贝或者在一开始就重新构建邻接表。

第三,自环和重边。GN算法基于最短路径计数,如果图里有自环,BFS距离计算会出现歧义;如果有重边,边介数计算要考虑多条平行边。绝大多数社区发现算法的公认假设是无向简单图,你需要先对输入图清洗一遍。

第四,模块度会“过拟合”。模块度O(N)级别的分辨率限制导致小社区容易被合并进大社区。如果你的图中社区大小悬殊,模块度指导的“最优”划分可能不符合直觉。这时候不要迷信模块度最高分,要结合业务需求手动挑选层级。

第五,边介数全为0。这种情况出现在完全孤立节点组成的图里,或者没有最短路径关系的图里。代码里如果没有判空保护,max(cb.values())会直接报错。我习惯在找最大边之前加一个判断,如果最大介数值小于等于0,直接终止迭代。

6.2 什么样的场景才适合用GN算法

总结下来,GN算法在以下场景里其实非常合适。

小规模网络需要精细社区层级的时候。比如几十个节点的协作网络、小组关系网络,这种规模下Louvain跑得太粗,GN反而能展示出完整的层次分裂过程。你不但想知道“分成几组”,还想知道“组是怎么一层一层裂开的”,这时候GN的输出结构非常合适。

算法教学和对比实验。GN作为经典基线,和后来的各种新算法做对比,比出来的差异才更有说服力。

还有,当网络里存在明确的“桥”结构时,GN几乎是唯一一个在概念上能直接解释清楚桥边作用的算法。它能帮你在新人面前把“边介数”这个概念讲到血肉饱满。

6.3 我个人实际操作中的建议

如果你刚接触GN算法,我建议你按这个顺序上手:先别急着下载别人封装好的库,先把本文的源代码自己敲一遍,逐行debug;然后换成几个不同结构的图,比如环形图、星形图、带层次结构的图,跑几轮,观察边介数的变化;最后再读一遍Brandes原论文,把反向累积那部分理解透。

敲代码这件事,自己动手过一遍比看十遍教程有效得多。我当年就是拿一个小图,跟着程序单步调试,打印每一轮的边介数值和模块度,才算彻底弄明白GN算法“为什么先切这条边,而不是那条边”。

还有一个小技巧:在处理非连通图时,不要直接对整个图运行GN。先把连通分量拆开,对每个大分量单独执行GN,最后再把划分结果合并回去。这样不仅速度更快,社区语义也更清晰,你不会因为一个跨分量的大桥边而干扰到另一个分量内部的结构判断。

GN算法在今天依然值得学,不是因为它性能最强,而是因为它能把“社区发现”的核心直觉用最朴素的方式表达出来。理解了一个算法怎么从“桥边”这个观察走到“边介数”这个数学定义,再去看Louvain、Leiden这些新算法时,你会发现所有的设计都是有迹可循的。把根扎稳,后面长出来的知识树才不容易倒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询