资讯详情

资讯详情

图编辑距离GED全解析:从定义、算法到工程实践

做图结构差异分析的人几乎都会遇到同一个问题两张图到底差在哪、差多大。简单地比节点数、边数过于粗糙直接对比节点embedding又看不出结构层面的变化。这个系列之前聊过度数分布、Weisfeiler-Lehman核等方法这次终于轮到图编辑距离Graph Edit DistanceGED了。如果说WL核是在问“两张图的局部结构长得多像”那GED问的就是“把一张图变成另一张图最少需要动多少次手术”。这个问题的答案不仅能告诉我们差异大小还能直接指出差异发生在哪些节点和边上非常适合做可解释的图差异分析。这篇文章会覆盖GED的定义、计算复杂度、精确算法与近似算法、实际编码思路以及我在真实项目中踩过的坑。无论你是做图相似性检索、分子图对比还是在搞代码差异分析只要涉及“图之间距离”这篇应该都能帮到你。1. GED到底在算什么编辑操作、成本与距离的本质1.1 形式化定义和那六种“手术”GED的思想其实特别朴素。给定两张图G1和G2我们允许对G1做一组编辑操作把它一步步改写成G2期间产生的总代价就是这次编辑路径的cost。GED定义是所有可能编辑路径中最小的那个代价。图的编辑操作一般分成六类节点插入、节点删除、节点替换改标签、边插入、边删除、边替换改边标签。当然也有不少实现把边的替换拆成删除加插入实际操作时看你的代价函数怎么设计。形式化地说假设G1有n个节点、m条边G2有n个节点、m条边每一个操作都有一个非负代价节点操作和边操作分别对应两种代价函数。GED就是要找一个从G1到G2的“对齐方案”让整体代价最小。这个概念和字符串编辑距离Levenshtein distance非常像只是从线性序列扩展到了图结构。字符串有天然顺序图没有所以复杂度完全不在一个量级。还有一点很容易被忽略GED里对“节点映射”的约束并不要求所有节点都参与映射G1里多余的节点可以删掉G2里没有对应点的节点可以插入这就是为什么GED天然支持节点数不同的图。实际计算时我们真正在搜索的是一个部分映射集合M它把G1的一部分节点映射到G2的一部分节点上——注意映射是单射的一个源节点只能对应一个目标节点反之亦然。映射确定后剩余的节点就是删除或插入边的编辑代价则由“映射边的存在性和标签差异”来算。1.2 和度分布、WL核、图同构放一起看为什么要单独搞一个GED而不是直接套用常见的图相似度方法因为这些方法回答的是完全不同的问题。度分布对比只看统计特征两个结构差异很大的图可以拥有完全相同的度序列。WL核做了多轮邻居标签聚合能捕捉局部邻域差异计算快但它输出的是一个核函数值你很难说清楚“到底改了哪个节点导致核值变小”。图同构判断只回答“是不是一模一样”完全不回答“差多少”。GED的优势是它产出一个有明确物理意义的数值能同时评估结构差异和标签差异而且这个数值可以拆解到具体操作和具体节点/边上。简单说度分布是“看着像不像”WL核是“整体相似度多少”GED是“改哪几刀能变成它”。在做异常检测或分子筛选时这种可解释性非常值钱。代价是计算复杂度极高所以后面必须聊算法。2. 什么场景非GED不可我的几个实战观察2.1 分子图对比电荷、官能团和子结构搜索我最早接触GED是在分子相似性分析里。化学分子天然是图原子是节点化学键是边原子类型、键类型都可以作为标签。做药物筛选时经常要回答“两个分子差一个官能团结构改动大不大”。用指纹相似度比如ECFP很快但会把空间结构信息丢失用GED则可以设置节点替换代价——碳原子换成氮原子代价高氢原子换成碳原子代价更高——这样计算出的距离能直接反映化学修饰的代价。分子场景有一个显著特点图规模通常很小几十个节点就是极限大部分分子只有几十个原子所以虽然GED是NP-hard可实际用精确算法硬算也能跑。这也是GED在化学信息学里一直没被淘汰的原因。反过来如果你拿到的是蛋白质相互作用网络这种几千上万个节点的图精确GED基本不可能只能用近似方案。2.2 程序依赖图和知识图谱的差异定位另一个我踩过坑的场景是程序分析。看两份代码差异时如果把代码解析成控制流图或程序依赖图GED就能告诉你“哪个基本块的依赖边被删了哪个条件判断被插进去了”。对比传统文本diffGED能忽略变量重命名、代码块的格式化移动只看结构层面变化这对重构影响评估非常有用。知识图谱那边也常见GED本体对齐和实例匹配经常要把两个图里的实体对应起来。实体对齐本身可以建模成节点匹配问题而GED算出的不只是匹配质量还包含插入删除成本适合处理“两边实体集合不完备”的情况。不过知识图谱规模往往不小实际工程里大家更常用基于嵌入的方法GED一般用于小规模核心子图的精对齐。2.3 给图嵌入方法当“标注器”这个用途可能很多人没想到。训练一个图神经网络来预测图相似度需要大量带标号的图对而“图对相似度”的标注哪里来如果只用图同构标签正负样本太极端。合理做法是用GED在中小规模图对上算出距离再归一化成相似度标签喂给回归模型做监督学习。换句话说GED虽然是慢方法但它可以作为“老师”去蒸馏出一个快速的学生模型。我在实际项目中就用GED给一批分子图对打了标签之后在线上用GNN推理把单次计算时间从秒级降到了毫秒级。3. 精确计算GED为什么难以及A*搜索怎么落地3.1 NP-hard这件事不能假装看不见GED的决策版本是NP-hard这几乎是所有入门教程都会提的一句话。一句话解释就是如果所有编辑代价都为1那么判断GED是否小于等于某个阈值等价于某个子图同构问题而子图同构本身就是NP-hard。更直白地说图的节点没有天然顺序要把两堆节点对应起来候选映射数量是指数级的。就算节点标签把很多候选排除了最坏情况下标签全相同时你就是在搜索一个排列空间。这导致一个现实问题写代码时不能把GED当作一个“库函数一调就出结果”的黑盒。图规模超过二三十个节点时精确算法可能跑几分钟到几天不等。所以做好精确算法优化的核心不是单点计算快而是大量剪枝让搜索空间在早期就迅速收缩。3.2 A*搜索的基本框架精确GED最经典的做法是A*搜索。状态定义为一个“部分节点映射”也就是已经确定匹配的若干源节点到目标节点的对应关系。初始状态是空映射目标状态是“无法再扩大映射”的完整方案。对每个状态我们把G1中下一个未映射节点u拿出来尝试把它映射到G2中某个未映射节点v或者选择把u删除。每次扩展都产生新的部分映射状态然后放入优先队列。优先队列的排序依据是f g h。g是当前状态已经付出的编辑代价比如已经确认的节点替换、匹配节点间的边差异、已删除节点的代价。h是剩余未处理部分的代价下界也就是“从当前状态到最终完成至少还要花多少钱”。h设计是否优秀直接决定搜索效率。最简单的h是“剩余节点数的最小插入/删除代价”太松几乎没有剪枝效果。稍微好一点的h会统计剩余节点的标签分布如果一个标签在源图剩余节点里有k个目标图剩余节点里只有j个那么多出来的k-j个节点至少得有对应删除代价边也类似。这种基于计数的下界实现简单而且很稳。一个容易被忽略的点A*搜索的可采纳性要求h不能高估真实代价否则会漏掉最优解。我自己曾经为了加速在h里加入了对边差异的粗略估计结果某几个图对得出非最小距离排查了很久才发现是启发式设计得过于激进。3.3 剪枝、上界和候选集决定生死的关键优化光靠A*理论框架跑不了大规模图真正实用的是工程化剪枝。我总结三个最有效的优化。第一个优化是上界剪枝。先用贪心算法或快速近似算法算出一个GED的上界UB搜索过程中任何状态的g值超过UB直接丢弃。上界质量越高剪枝越凶。实践中我会用“先做节点标签匹配再做边代价修正”的贪心跑一次通常能得到还不错的上界。第二个优化是候选节点筛选。扩展节点u时不用尝试G2里所有未映射节点。可以先按标签过滤标签不同的节点只有替换代价足够低时才值得试再看度数相似度度数差太大的节点基本不可能出现在最优映射里。候选越小搜索分支越少。这里要特别小心过度收缩候选集可能丢掉最优解。安全做法是保留所有替换代价小于“删除u 插入v”代价的候选因为如果代价更大那最优解里肯定会直接删除u和插入v。第三个优化是映射一致性约束。两个已经匹配的节点(u, v)如果在G1中u和u之间有一条边而在G2中v和v之间没有对应边那这一对匹配就对当前部分映射带来额外的边删除或插入成本。这些边成本在扩展新映射时要立即累加到g里不能拖延到最后算。越早累加搜索就能越早发现当前映射质量差。3.4 什么是DFbnb和整数规划路线除了A*精确算法里还有深度优先分支定界DFbnb。它和A不同不维护全局优先队列而是沿着一条路径猛挖利用上界剪枝快速逼近最优解。好处是内存占用小坏处是如果没有好的路径选择策略可能长期卡在次优区域。我个人经验在稀疏图上DFbnb往往比A快很多因为稀疏图的可选分支少深度优先的局部性更好。还有一类思路是把GED建模成整数线性规划ILP用约束变量表示“节点u是否映射到v”“边是否被编辑”然后交给商业求解器或开源求解器去解。图规模小的时候ILP的求解速度甚至超过A*且能利用成熟的优化器做割平面。但这个路线工程复杂度高我在图上试过一次后除非问题规模极小否则还是回到A*或DFbnb。4. 算不动怎么办近似算法和工程可扩展方案4.1 线性指派办法匈牙利算法与它的局限当图规模达到几百个节点时精确算法基本就别想了。实践中我第一个会用的是基于节点匹配的近似把GED求解松弛成一个“节点指派问题”。具体做法是构建一个代价矩阵行是G1所有节点列是G2所有节点额外增加“删除节点”和“插入节点”的虚拟列/行代价分别是节点删除和插入成本。两个真实节点之间的代价可以设为“节点标签替换代价 它们邻域结构的差异估计”然后对矩阵跑匈牙利算法即求解最小代价完美匹配。匈牙利算法能得到多项式时间内的最优指派但它的硬伤在于它只考虑了节点层面的匹配边编辑代价并没有被完整建模。两个匹配方案可能在节点层面代价相同但边的差异一个天一个地。解决方法也很暴力跑完匈牙利之后再根据最终匹配计算真实的边编辑代价作为这个近似GED的结果。这样得到的值一定大于等于真实GED但好处是能快速给出一个稳定的上界。如果要更接近真实值可以设计迭代策略第一轮匈牙利算出节点匹配然后根据匹配结果重新估计边代价调整节点间的匹配代价值再跑一轮匈牙利如此反复。经验上看两三轮迭代后结果就趋于稳定。这个方法不保证最优但在做大规模聚类和检索时稳定且快速的近似比“等一个永远算不完的精确值”更有价值。4.2 贪心、束搜索与元启发贪心法是最容易实现的近似按某种策略比如标签匹配优先、公共邻居最多优先逐个确定节点映射每一步都选当前边际代价最小的方案。它的计算复杂度可以是O(n^2)甚至更低处理上万节点的图都没压力。但贪心的问题也很明显早期一个错误决策会连锁影响后续所有匹配。我在小规模图上把贪心结果和精确值对比通常误差在20%~40%之间偶尔会出现离谱的偏差。束搜索是贪心的“加宽版”每一步保留分数最高的k个部分映射而不是只留一个。这个k就是束宽越大越接近精确结果但耗时也线性增长。束搜索非常适合做图相似度检索的粗筛阶段先用小束宽跑一轮把明显不相似的图丢掉再对保留的图对用更大束宽精算。实际工程里这比直接上精确算法舒服得多。元启发式路线包括模拟退火、遗传算法和禁忌搜索我也试过。它们适合图规模大且质量要求更高的场景。思路是维护一个完整映射通过交换映射节点、增删映射来改变解并用马尔可夫链或种群进化去优化总代价。写起来不复杂但调参数很玄学不同数据集上表现差异极大我不会把它当作默认选项只有在精确算法跑不动、匈牙利结果又明显不够好时才考虑。4.3 用图嵌入去“猜”GED学习型近似最近五六年用图神经网络预测图相似度变得很流行。思路是把G1和G2分别编码成向量拼起来或做差分再通过回归头输出一个相似度分数训练数据就用精确GED在小图上算出的距离。常见的编码器有GCN、GIN、注意力池化等。这种方法在线推理非常快而且可以端到端学习节点标签、结构、甚至语义之间的关系。我用这类模型踩过不少坑。最核心的问题是训练分布和测试分布必须一致。如果训练集里的图都是分子图节点标签是原子类型拿到社交网络图上去测结果基本不能看。另一个问题是GED距离尺度不稳定SGD回归很容易在少数困难样本上崩掉。所以我在实践中通常不直接回归GED数值而是回归“排序号”或训练一个Siamese网络去做对比学习只要求相对距离正确。这比绝对值拟合稳健得多。学习型近似还有一个额外好处模型可以学会“忽略”一些不重要的编辑操作比如为噪声标签单独设一类低代价变换。这在实际工业数据里很关键因为很多图数据的标签本身就有噪声。5. 实操过程从手算小例子到可运行的Python代码5.1 一个手工计算的小例子先把概念落地看一个最简单的例子。G1是三个节点的链A-B-C边有AB和BC。G2是三个节点的三角形边有AB、BC、CA。先约定所有节点替换代价为1节点删除/插入代价为1边插入/删除代价为1。节点标签全相同所以节点替换并不是最优选择。要把G1变成G2最直观的一个编辑路径是删除边CA不对G1里没有CA。换个方向说在G1里插入边CA然后删除边AB这样得到的图是BCCA还不是三角形缺AB。那就换一种方法G1插入边CA得到的是ABC三条边里的CA加上AB、BC其实已经是三角形了。等等G1原本有AB和BC插入CA后就有三条边AB、BC、CA这就是G2。所以只需要一次边插入GED1。从这个例子能看出来GED对“边的存在性差异”非常敏感而节点数相同时插入删除边的代价决定了距离。如果我也允许节点重排那其实G1和G2的拓扑完全相同三个节点两条相邻边 vs 三条边GED1也符合直觉。再看一个需要节点编辑的例子G1是孤立的单个节点XG2是AB两个节点的单边图。那么需要插入节点B并插入边AB代价2。如果G1的X标签是碳原子G2里B也是碳原子可以映射X-B然后插入A和边AB代价2也可以把X映射到A再插入B和边代价还是2。5.2 用networkx快速验证精确值实际动手时我常用的Python库是networkx它内置了图编辑距离接口。下面的代码演示了在小图上调用它并设置自定义代价函数。import networkx as nx G1 nx.Graph() G1.add_nodes_from([ (0, {label: C}), (1, {label: N}), (2, {label: C}) ]) G1.add_edges_from([(0, 1), (1, 2)]) G2 nx.Graph() G2.add_nodes_from([ (0, {label: C}), (1, {label: C}), (2, {label: N}) ]) G2.add_edges_from([(0, 1), (0, 2)]) def node_cost(u_data, v_data, params): if u_data is None: return 1.0 # 插入节点 if v_data is None: return 1.0 # 删除节点 if u_data[label] v_data[label]: return 0.0 return 1.0 # 节点替换 def edge_cost(e1_data, e2_data, params): if e1_data is None or e2_data is None: return 1.0 return 0.0 # 注意networkx默认把缺失的边也当作编辑对象 dist nx.graph_edit_distance( G1, G2, node_subst_costnode_cost, node_del_costlambda d, p: 1.0, node_ins_costlambda d, p: 1.0, edge_subst_costedge_cost, edge_del_costlambda d, p: 1.0, edge_ins_costlambda d, p: 1.0, ) print(dist)这段代码里要特别留意networkx对None的处理方式。node_subst_cost的u_data或v_data可能为None表示删除或插入操作边也同理。很多人第一次跑出来的距离和自己手算不一致就是因为没注意到边代价也分替换、删除、插入三种情况。我自己的习惯是写一个统一函数先判断是否存在再判断标签。networkx的实现是A*搜索所以图一大就会非常慢。我的经验是节点数超过20以后就要非常小心超过50基本不要指望能跑完。如果只是验证小图算法它是很好用的调试工具。5.3 手动实现一个A*骨架理解搜索过程依赖库只能帮你跑结果自己写一个简化版A*骨架反而能让你更快理解GED的搜索与剪枝。下面是一个核心思路的伪代码省略了候选生成和下界函数的细节。import heapq def a_star_ged(G1, G2, node_costs, edge_costs): start (0.0, 1, [], 0, frozenset(), frozenset()) # state: (f, seq, mapping, g, matched_u, matched_v) # mapping 是 (u, v) 元组列表g 是已确认代价 heap [] heapq.heappush(heap, start) best_upper greedy_upper_bound(G1, G2, node_costs, edge_costs) while heap: f, _, mapping, g, matched_u, matched_v heapq.heappop(heap) if f best_upper: continue if len(mapping) min(len(G1), len(G2)): # 剩余节点全部做删除/插入然后计算边代价 total finish_cost(mapping, G1, G2, node_costs, edge_costs) best_upper min(best_upper, total) continue u next_unmatched_node(G1, matched_u) # 方案1删除 u new_g g node_costs[delete](G1.nodes[u]) push_state(heap, mapping, new_g, matched_u | {u}, matched_v) # 方案2尝试匹配到 v for v in candidate_nodes(G2, u, matched_v, node_costs): h heuristic_lower_bound(G1, G2, matched_u | {u}, matched_v | {v}) new_g g calc_matching_cost(u, v, mapping, G1, G2, node_costs, edge_costs) if new_g h best_upper: push_state(heap, mapping [(u, v)], new_g, matched_u | {u}, matched_v | {v}) return best_upper我这里有意简化了细节实际工程里“优雅”的A*需要大量优化包括候选排序、动态上界更新、历史映射的边代价计算缓存等。对初学者我建议先把上面的逻辑跑通再逐步加入更好的启发式。下界函数是重中之重。最简单的可用下界是统计G1剩余未映射节点和G2剩余未映射节点的标签计数差乘以节点替换代价最小值再加上同样的边计数差。详见下方示意。def heuristic_lower_bound(G1, G2, matched_u, matched_v): remaining_u [n for n in G1.nodes if n not in matched_u] remaining_v [n for n in G2.nodes if n not in matched_v] label_u Counter(G1.nodes[n][label] for n in remaining_u) label_v Counter(G2.nodes[n][label] for n in remaining_v) lb 0 for lab, cnt in label_u.items(): lb max(0, cnt - label_v.get(lab, 0)) for lab, cnt in label_v.items(): lb max(0, cnt - label_u.get(lab, 0)) # 再加边计数差的估计 ... return lb必须再次强调这个启发式必须可采纳也就是它算出的值不能高于任何真实完成方案的最小可能代价。一旦违反A*可能返回错误的距离。工程上我宁可把下界写得松一点也不能冒漏解的风险。5.4 代价函数怎么设置一个经验清单很多朋友在算GED时卡得最久的问题不是算法而是代价参数。我整理出一份经验清单节点替换代价应当满足三角不等式cost(u-v) cost(u-w) cost(w-v)。如果不满足GED就不满足距离公理很多后续聚类算法会失真。当节点语义没有明确优劣时我习惯设置“同标签替换代价0不同标签替换代价2”而删除/插入代价为1。这样能保证“替换成不同标签”和“删除再插入”等价不会偏爱某一种操作。边代价通常要比节点代价低因为边只表示关系节点代表实体。但也不是绝对比如在分子图里化学键类型改变可能比原子替换更重要。两张图规模差异大时节点删除/插入代价过高会导致算法宁可做大量替换也不删节点结果匹配出一堆语义无关的虚假对应。这时可以考虑把删除/插入代价设成替换代价的一半。如果希望GED对噪声鲁棒可以对“罕见标签”设置低替换代价因为噪声标签往往是小概率出现的错标。6. 常见问题与排查技巧实录6.1 速查表先对号入座我把实操中最常遇到的情况整理成一个表。多数时候问题根源不是计算库的bug而是建模方式或参数设置的问题。现象可能原因建议处理跑很久没有结果图太大或启发式下界太松加候选剪枝降低图规模改用近似算法结果明显大于手算值节点/边编辑代价设置不一致或重复计费检查node_subst_cost里对None的判定确认边替换逻辑结果明显小于手算值启发式不可采纳A*漏掉了更优解检查h下界是否高估回退到简单计数下界两图的标签大多不同算出的距离总等于最大可能值替换代价设得过高搜索直接放弃替换降低替换代价并检查是否满足三角不等式断开的小分量导致匹配不合理孤立节点删除/插入代价设置不对称检查节点删除和插入成本是否一致匈牙利近似法结果波动大只跑了一轮节点指派没有迭代边代价用迭代指派或加入束搜索修整6.2 一个真实排查案例分子图替换操作被重复计费有一次我在处理一组分子图对代码跑出来的GED总是比化学专家手动标注的结构差异大1。仔细检查后发现问题出在边替换的重复计费上当我决定把节点u映射到v时计算边代价时已经处理了“G1中u与已匹配邻居的边是否在G2中存在”后来在finish阶段又把所有未处理边的差异全部算了一遍导致部分边的编辑成本被算了两次。这类问题在自研实现里非常容易出现因为边成本横跨“映射确定时”和“搜索结束时”两个阶段。解决方法是统一约定每确定一条映射立即结算所有与这条映射相关的边差异并且把这些边从后续边差异集合中移除。加一行“已结算边集合”的维护能让结果一步到位。很多库内部其实也做这个事但文档里写得不清楚自己实现时就要格外小心。6.3 关于“图太大”的最后一招如果图实在太大或者线上延迟要求太苛刻我的建议是走“先嵌入粗筛、再GED精排”的两阶段流程。先用图核或图神经网络把所有图编码成向量用余弦距离或欧氏距离快速召回TopK相似图再只对召回的几十个候选图对跑GED精确值。这套流程在真实业务里最实用能兼顾规模和精度。我之前在构建一个代码相似度检索引擎时两阶段方案把单次查询时间从分钟级压到了百毫秒级同时精确排序的效果比单纯用嵌入距离好很多。我个人在实际操作中的体会是GED这类方法最有价值的时刻不是“算出距离”那一瞬间而是为了算距离你被迫想清楚“什么编辑操作更有意义”的过程。很多团队上来就调库跑完一个数就完事完全没有意识到代价函数本身就是业务语义的浓缩。与其纠结算法快慢不如先花一个下午把节点、边、标签的编辑代价定义清楚。这个工作做好了即使最后只用了近似算法结果也比瞎调参数的精确算法有用得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →