☰
K2算法详解:贝叶斯网络结构学习中的贪心搜索与实战避坑
2026/10/14 6:47:18 网站建设 项目流程

简介:面向机器学习与贝叶斯网络初学者、研究人员及Matlab用户,这份代码包以K2算法为核心,实现了从批量观测数据中自动学习贝叶斯网络结构的功能,可服务于因果关系发现、变量依赖分析及相关课程设计。压缩包共7个文件、总大小约10KB,主体为4个Matlab脚本,分别承担控制流程、网络结构对象构建、子函数计算等功能;另有1个C语言源文件实现K2评分核心逻辑,并搭配示例数据mat文件与txt说明文档,便于直接运行和对照阅读。目前已有2168人学习/下载,资源体量小巧,适合快速掌握结构学习的基本流程。文件层次清晰,从数据结构设置、父节点评分到最终DAG输出均有涉及;读者可先通过样例数据运行K2主函数,再结合C语言版本深入理解打分与搜索机制。需要扩展算法时,可直接复用其中构建网络结构的辅助函数,节省大量编码时间。

1. K2算法是什么:给贝叶斯网络结构学习一个贪心起点

K2算法是贝叶斯网络结构学习中最常被拿出来做基线的方法,也是新手接触结构学习时绕不开的第一个完整流程。它解决的问题很具体:给你一张N列的数据表,每列是一个变量,你怎么自动判断变量之间该连哪些箭头、箭头朝哪个方向。K2不采样、不搞变分推断,靠一套评分函数加贪心搜索,在可控时间内给出一张可解释的贝叶斯网络结构。

这篇笔记会把K2评分公式、节点顺序、超参数设置、代码实现和实战踩坑一次讲透,适合正在跑结构学习实验、需要快速搭基线网的数据挖掘从业者照着复现。

2. 原理先立住:K2评分函数与贪心搜索为什么能配对

2.1 从贝叶斯后验到K2评分:可分解是它跑得快的前提

贝叶斯网络结构学习的最终目标是找到一个图结构G,使得后验概率P(G|D)最大。这里最麻烦的不是计算本身,而是结构空间的大小:n个变量的有向无环图数量随着n增长比指数还快,直接枚举所有候选结构完全不现实。因此所有评分搜索类算法都长着同一副骨架:评分函数负责给候选结构打分,搜索策略负责在结构空间里走出一条路。K2算法能成为被反复使用的基线,正是因为它先给出了一个既可分解、又好计算的评分函数。

K2评分本质上是贝叶斯Dirichlet评分家族在超参数α_ijk=1时的一个特例。它假设每个节点的参数先验服从狄利克雷分布,并且不同父节点配置之间相互独立。这句话写在论文里很学术,落到代码上其实就一句话:要给某个结构打分,只需要统计每个变量在它父节点配置条件下的取值计数。设变量Xi有ri个取值、父节点配置共有qi种,用N_ijk表示第j种父配置下Xi取第k个值的样本数、N_ij表示该配置下样本总数,K2评分的表达式是:

Score(G|D) = ∑_i ∑_j [ log( Γ(α_ij) / Γ(α_ij + N_ij) ) + ∑_k log( Γ(α_ijk + N_ijk) / Γ(α_ijk) ) ]

其中α_ij = ∑_k α_ijk,α_ijk=1就是最经典的K2设定,式子会自然简化。这个公式有两个容易忽略的点。第一,评分能对每个变量单独求和,这叫可分解性;正因为可分解,贪心加边时每次只需要重算受影响的那个节点,整个算法才能跑得动。第二,Γ函数在整数参数下就是阶乘的连续扩展,所以K2评分可以理解为带平滑的联合对数似然,平滑就来自每个计数格子里加的1个伪计数。

2.2 为什么不能直接最大化似然:全连边陷阱与伪计数的作用

很多第一次写结构学习代码的人会问:既然K2评分是带平滑的对数似然,那直接最大化原始似然不就行了吗?答案是不行,而且这是个一踩一个准的坑。似然函数天然偏好复杂结构:父节点越多,模型能解释的样本共现模式越丰富,训练集上的似然只会升不会降。如果直接拿最大似然当搜索目标,最终学出来的几乎一定是全连边网络,每个变量指向所有其他变量。这种结构在训练集上分数最高,一到预测就露馅,因为它把数据里的噪声当成真实依赖记住了。

K2评分顶上那1个伪计数,本质上是一个极轻的结构惩罚。每多加一个父节点,可用样本就被分割到更细的父配置格子里,每个格子里的伪计数占真实计数的比例升高,于是新边带来的评分增益逐轮递减。这个机制保证了贪心搜索一定会在某个时刻停下来,而不是陷入无限加边的循环。

还有一层原因是数值层面的。如果目标是最大化无平滑的似然,一旦某个父配置下样本数为0,连乘里面就会蹦出一个0,整个评分直接变成0或负无穷。K2评分的伪计数相当于给每个格子保留了最小概率质量,规避了这种数值灾难。这也是α_ijk=1这个默认值最实在的贡献,它不是理论洁癖,而是让计算稳定到能跑完一遍完整搜索。

2.3 贪心搜索的三步循环:加边、算分、比较增益

有了可分解的评分函数,K2的搜索策略朴素得接近粗暴。它把变量按给定顺序排列,从前往后处理每个节点,每次只决定“要不要给当前节点加一个父节点”,决定之后绝不回头。

完整循环可以拆成三步。第一步,把当前变量Xi的父节点集初始化为空集。第二步,在排在Xi前面的候选节点里,逐个尝试把某个节点c临时加进父节点集,用K2评分计算加边前后的差值ΔScore。第三步,找出使ΔScore最大的那个候选节点:如果最大ΔScore仍大于0,就把c永久写进父节点集,然后回到第二步继续寻找下一个候选;如果最大ΔScore小于等于0,或者父节点数已经达到max_parents上限,Xi的搜索就此结束,轮到下一个变量。

“排在前面”这四个字是整个K2算法的灵魂。节点顺序对搜索空间施加了方向性约束:只有靠后的节点能选择靠前的节点做父节点,反过来被直接禁止。跟传统爬山算法相比,这是两者最根本的差异。爬山法允许后来删除某条边来换取全局分数提升,K2不允许,所以K2没有删除边的后悔药,但它换来的是极低的复杂度。K2的时间开销大致是O(n² · max_parents · r^max_parents),变量数在100以内、每列不超过7档的数据,跑一次搜索通常只要几秒到几十秒。

2.4 节点顺序:K2最强的先验,也是最容易翻车的输入

节点顺序是K2最重要的外部输入,重要性超过α和max_parents。它表达的信息不是循环顺序,而是因果优先级的一种编码:某变量更可能是另一个变量的因,或者在业务过程里发生得更早。

实际项目里,节点顺序通常来自三条路。一是领域专家给的因果清单,先因后果依次排好;二是业务过程的时间先后,注册时间、首次下单、逾期、催收这种动作先后本身就是天然的顺序;三是从数据里粗估,先算相关矩阵或做一次层次聚类,再按业务语义归并出大致层级。如果对顺序毫无头绪,我的习惯是正序、反序各跑一遍,比较两轮结构的总评分。反序那轮通常会低一些,但如果两轮差距很小,说明数据里的因果信号本身弱,这时别急着下单边结论,先回去确认顺序来源。

顺序对结果的影响是决定性的,同样的数据换个顺序,学出来的箭头方向可能完全相反。这一点在第四章会展开,但这里先记住结论:K2不是一个完全自动的算法,它把“哪些变量不能互相影响”这个先验以顺序的形式硬编码进了搜索过程。

3. 把K2跑起来:数据准备、参数设置与代码实现

3.1 数据格式与离散化:等频、等宽、业务阈值三选一

K2评分要求输入数据是离散的,连续值必须先离散化。这一步对整个结果的影响比大多数人想象中大,却又经常被当成数据预处理随手处理掉。常用策略有三种,各有各的适用边界。

离散化策略做法适用场景需要注意的点
等宽按值域均匀切k段分布均匀、边界要整齐偏斜分布下长尾段样本过少
等频按分位数切,每段样本量接近大多数实际表格边界值可能没有业务含义
业务阈值按领域规则切有明确业务档位需要专家经验,档位不可控

我一般会先做等频离散化,把每列档位控制在3到7档。档位太少表达不了变量间的非线性依赖,档位太多则父配置组合数指数膨胀,直接拖慢K2搜索并稀释每一档的样本。离散化之后务必打印每列取值分布,如果某一档只有1%的样本,后续评分里这个格子的计数质量会非常差,相应的边很容易被错误判弱。

缺失值也不能留。K2评分没有内置缺失值处理,NaN进评分函数就是崩。低于5%的缺失比例时我直接删掉这些样本行;缺失集中在个别列且比例较高时,我习惯把缺失编码成一个单独的档位,让算法学“缺失”这个取值符号本身有没有预测含义。全局均值填充离散变量是最坏的选择,等于硬造一个原始数据里不存在的取值。

注意:离散化档位决定父配置组合数,档位多一档,组合量可能翻几倍。档位超过10的列在K2流程里应当先处理。

3.2 最小可用Python实现:从计数到贪心搜索一次到位

自己手写一遍K2,评分公式和搜索流程会在脑子里刻得更深。下面这段代码是我平时做实验用的起点,只依赖numpy,刻意写成可读版本,生产环境再换更完善的实现。

import numpy as np from itertools import product from math import lgamma def k2_score_component(counts, alpha=1.0): """单个父配置下,子节点各取值计数的K2评分分量。 counts: shape (r,),r是当前节点取值档位数。""" total = counts.sum() # lgamma(alpha)=lgamma(1)=0,写成通式便于调alpha return (lgamma(alpha) - lgamma(alpha + total) + np.sum(lgamma(counts + alpha) - lgamma(alpha))) def k2_score_node(data, node, parents): """把给定父节点集下所有父配置的评分分量累加。""" r = int(data[:, node].max()) + 1 score = 0.0 # 无父节点时只有一种配置,这个分支让代码逻辑统一 if parents: parent_sets = [sorted(set(data[:, p])) for p in parents] else: parent_sets = [[]] for combo in product(*parent_sets): mask = np.ones(data.shape[0], dtype=bool) for p, v in zip(parents, combo): mask &= (data[:, p] == v) counts = np.bincount(data[:, node][mask], minlength=r) score += k2_score_component(counts) return score def k2_search(data, order, max_parents=3, alpha=1.0): """主循环:按order顺序逐节点贪心加父节点。 data必须是整数编码的二维数组,order是节点索引列表。""" n = data.shape[1] parents = {v: [] for v in range(n)} for pos, node in enumerate(order): candidates = order[:pos] # 只能从前面的节点里选父节点 while len(parents[node]) < max_parents: cur = k2_score_node(data, node, parents[node]) gains = [] for c in candidates: if c in parents[node]: continue trial = parents[node] + [c] gain = k2_score_node(data, node, trial) - cur gains.append((gain, c)) if not gains: break best_gain, best_c = max(gains) if best_gain <= 1e-12: break parents[node].append(best_c) print(f"node {node} + parent {best_c}, gain={best_gain:.4f}") return parents

代码分三层:k2_score_component处理单个父配置下的计数,k2_score_node把所有父配置累加成单个节点的评分,k2_search在顺序约束下做贪心加边。实现里故意用lgamma而不是先算阶乘再取对数,是因为阶乘很容易溢出,对数Γ天生稳定。bincount负责按取值给布尔掩码选出的子样本计数,比手工循环快一个量级。

参数alpha是伪计数,默认1.0就是经典K2设置,想对比平滑强度可以试0.5或2.0,不建议超过5,否则真实计数差异会被先验抹平。max_parents是每个节点的父节点上限,从3开始试,后续按评分拐点调。order就传节点索引列表,例如order=[0,1,2]时,节点1的候选父节点只有0,节点2的候选是0和1。if best_gain <= 1e-12这行是浮点保护,理论上增益大于0就接受,但浮点误差可能把一个零增益放大成极小正数,加上阈值后搜索行为更稳定。

3.3 现成图模型库的用法:与手写K2的三个差异

最小实现跑通之后,实际项目中我会换到更完整的图模型库,省去自己处理计数、枚举和边界条件的重复工作。下面用通用接口示意,具体导入方式以你环境的库为准。

# 以某个图模型库的API为例,具体包名以你的环境为准 from gm_lib import K2Scorer, GraphSearch scorer = K2Scorer(data, alpha=1.0) searcher = GraphSearch(data, scorer) model = searcher.estimate( max_indegree=3, fixed_edges=set(), black_list=set(), ) print(list(model.edges()))

这里有三点要特别提醒。第一,库里的estimate往往实现的是爬山法,而不是严格意义的顺序贪心搜索,它会尝试删边,结果和手写的K2会有细微差异。要严格复现原始K2的行为,用3.2的版本更可靠。第二,max_indegree对应K2的max_parents,但大小写拼法可能因库而异,抄参数前先确认你装的版本的签名。第三,black_list是表达先验的地方:把业务上不可能有直接因果关系的变量对写进去,搜索过程就不会尝试连接那两条边。这一步在标准教材里很少提,但每个实际项目都在用。

注意:库版搜索无顺序约束,输出结构可能带环,参数学习前先检查图是否为有向无环图。

3.4 结果解读:从邻接表到可视化检查

k2_search返回的parents是字典:键是节点,值是父节点列表。拆成边列表只需两行:

edges = set() for node, plist in parents.items(): for p in plist: edges.add((p, node)) print(edges)

拿到边列表后建议做三件事:检查每条边的方向是否满足顺序约束;检查有没有双向冲突或孤立节点过多;用图可视化工具画出来,按节点业务含义标注。手写版K2不会出环,因为顺序约束天然保证DAG,但库版爬山法就不一定,必须显式验证。后续要做参数学习或条件概率查询时,非DAG图结构会直接导致推导报错,所以“先验证无环”应该成为固定动作。

这份资源的完整脚本和模拟数据生成器就在手头的包里,配合这篇笔记跑一遍,你能直观看到每一步输出的实际形态。

4. 避坑指南:K2算法实战中的5个经典现场

4.1 现象一:箭头方向全反,网络图和业务认知互相打架

现象:学出来的网络里,“逾期”指向“收入”,或者“点击”指向“曝光”,一眼看过去全是果指向因。 原因:节点顺序排反了,或者代码里直接把变量名字母序当成了order传进去。K2不允许后面的节点成为前面节点的父节点,顺序定死之后箭头方向没有修正机会,错一次就全错。 解决:先按业务因果清单排顺序,先发生的因在前、后发生的果在后。拿不准时正序反序各跑一遍,比较总评分,但最终采信以业务逻辑为准。评分能帮你筛掉明显错误,但补不回你没给进去的先验。

4.2 现象二:max_parents设太小,网络稀疏成一根链条

现象:每个节点最多只有一个父节点,整张图连成一条链,大部分变量之间没有任何边。 原因:参数被设成1,或者默认值抄低了。K2的停止条件同时受评分增益和父节点上限约束,上限太小会让算法在增益还很明显的阶段被迫截停。 解决:把max_parents分别设成1、2、3、5各跑一轮,记录每轮总评分和边数。当评分增量降到总评分1%以下时,取那个拐点对应的档位。变量数少于10且依赖关系复杂时,我一般往上提到5。

4.3 现象三:评分算出NaN,或者父配置组合数量爆炸

现象:代码跑到一半k2_score_component返回nan,或者搜索慢到无法接受。 原因:变量档位太多,导致父配置组合数指数增长,小样本下大量格子计数为0,部分实现里对数Γ函数对0计数不稳定;同时组合枚举也耗尽时间。 解决:把每列档位压到3到7档,优先等频离散化。跑之前先打印每列取值个数,超过10档就想办法切细一点。如果分析需求必须保留细腻度,拆变量比加档位对K2更友好,把一个多值变量拆成多个二值变量是常见做法。

4.4 现象四:样本量太小,学出来的结构稀疏得不像话

现象:几百条数据的实验里,网络几乎没有边,和业务直觉差很远。 原因:K2评分的伪计数在少量样本下占比例过高,增加父节点带来的似然提升经常盖不过先验的稀释,评分增益反复出现负值,搜索提前停止。 解决:把α调到5或10能缓解,但这等于更信先验、更不信数据;更稳的办法是减少候选父节点数量、降低档位、精简变量数。终极手段还是补数据,几百行的表不可能支撑复杂的因果结构,结构学习和深度学习一样没有免费午餐。

4.5 现象五:学出的局部箭头与已知强依赖矛盾,评分却很高

现象:某个变量对在业务上明确不该有直接箭头,模型却给了一条强边。 原因:K2只看单变量局部评分,不做全局业务一致性检查。统计共现强的变量会被强迫解释成因果箭头,哪怕它们只是经由隐藏变量产生了间接相关。 解决:把业务上不可能直连的变量对写进black_list,让搜索永远不会尝试那条边。学完之后再人工检查每个节点的父节点集合,挑出与领域知识冲突的边。这一步比调任何超参数都重要,可惜标准算法流程里没有它。

5. 把K2用到自己的数据上:评分对比与调参习惯

最后一章,分享一个我现在固定下来的使用流程:拿到新数据,先用K2跑一版基线,再做三步验证。

第一步,确认评分函数选择。K2对应α=1的BD评分,但我从不在它上面一条道走到黑。同一张学出的结构,我会分别用K2、BDeu、BIC三种评分重算总分对比。

评分函数超参数稀疏偏好适用场景
K2α_ijk=1弱快速基线、顺序已知
BDeu等效样本大小中有明确领域先验
BIC无强大样本、强调简洁

第二步,看排名稳定性。同一个结构在三种评分里排名差异很大,比如K2第一、BIC第五,说明它的优势主要来自伪计数假设而非真实依赖,这种结构我不会直接采信。

第三步,也是最少人做的一步:留出集验证。K2学的是结构不是参数,但结构最终要落到参数上才能用。把学出结构固定下来,在训练集做参数学习,在留出集算平均条件对数似然,与全连边网络和空网络对比。合格的K2结果应明显优于空网络,且不低于全连边网络太多。

从那以后,我每次用K2都会强制走一遍「三种评分对比 + 留出集验证」的流程,而不只看K2分数自己。这套流程大概多花半小时,却能让结论从“看着合理”变成“经得起推敲”。希望这份拆解能帮到你,尤其是当你正要和节点顺序作斗争的时候;顺序想清楚,K2会还你一张讲得通的图。

本文还有配套的精品资源,点击获取

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

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

立即咨询