首页 / 资讯中心 / 文章详情

从边介数到模块度:GN算法原理与Python实现详解

从边介数到模块度:GN算法原理与Python实现详解 ★ FEATURED ARTICLE
一句话弄清楚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步直到所有边都被移除记录每一轮移除边之后的连通分量作为不同粒度下的社区划分结果用模块度对所有划分结果打分取分数最高的那一轮作为最终社区划分。这个流程有一个特点它是一个“层级分裂”的过程而不是一次性把所有节点分好。就好比先切断整个国家之间的主干道国家分成省再切省内的高速路省分成市再切城市之间的主干道……每一刀下去都能得到一个不同尺度的社区划分。所以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×416对每一对之间的最短路径都只有一条就是必须走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这些新算法时你会发现所有的设计都是有迹可循的。把根扎稳后面长出来的知识树才不容易倒。
阅读完成 · 觉得有帮助?
咨询建站