☰
贝叶斯网络结构学习实战:K2算法原理与pgmpy实现
2026/10/14 6:43:05 网站建设 项目流程

简介:针对贝叶斯网络结构学习任务,这份资源围绕K2算法,提供从数据中自动学习网络结构的Matlab实现,适用于概率图模型初学者、研究生及相关算法开发者,可作为课程作业与算法研究的入门参考。包内共7个文件,4个.m源码分别承担主控流程、子函数和图对象构造等任务;1个.c文件为MEX接口,经编译后可提升核心计算速度;1个.mat样例数据可直接加载,用于验证算法效果;1个.txt为许可说明。整体压缩包仅10KB,紧凑清晰,便于按行阅读和二次改造,文件划分简洁,便于按功能定位代码。目前已有2168人学习过该资源。借助源码、MEX接口和样例数据,读者可深入理解K2算法如何利用评分函数并通过贪心搜索逐步生成贝叶斯网络结构,同时也能借鉴Matlab与C混合编程的实际用法,为课程设计、科研实验或算法改进提供直接参考。

1. 贝叶斯网络结构学习是什么,K2算法为什么还有人在用

一位做风险分析的同行曾跟我抱怨:业务方指着二十多个指标问他哪些会相互影响,他只能靠领域经验手动画依赖图,画到一半自己都信不过。这其实就是贝叶斯网络结构学习要解决的事——从观测数据里自动推出变量之间的依赖关系图,而K2算法是这条路上历史最久、也最容易落地的入口之一。它的名字来自上世纪90年代初一篇经典文献,核心思路是:给变量一个大致先后顺序,用评分函数衡量「某个节点加上某几个父节点后,模型和数据的匹配程度」,再一步步贪心地补边。下面按原理、代码、参数、避坑的顺序一步步拆,适合刚接触贝叶斯网络、手里有离散或可离散化数据、想快速得到一张可解释依赖图的从业者。

2. 从评分到搜索:K2算法的工作机制与选型理由

2.1 K2解决的问题:结构学习该走哪条路

贝叶斯网络由两部分组成:结构(DAG,即有向无环图)和参数(每个节点在给定父节点取值下的条件概率表)。结构学习就是只从观测数据里推出这张图长什么样,参数学习是在结构确定之后去估计每个节点的条件概率。K2算法管的是前半段,而且只针对离散数据——它的统计基础是多项式分布和Dirichlet先验,数据里出现小数或者NaN,第一步就会卡住。

结构学习业内分两条路线。一条是基于约束的,典型代表是PC算法:反复做条件独立性检验,保留那些“在控制了某些变量之后仍然相关”的边,最后再定向。这条路的问题在于高阶独立性检验非常吃样本量,样本不够时检验结果互相矛盾,算法只能靠某种启发式去调和,稳定性比较让人头疼。另一条就是K2所在的基于评分的路线:定义一个评分函数,衡量“这张图+数据”有多匹配,然后在图空间里搜索高分结构。评分函数给出方向,搜索策略决定效率,两件事分开,逻辑上比约束法好理解得多。

K2在这条路线里的定位很特别。它是最早把评分和搜索组合成完整方案的算法之一,至今仍是很多结构学习论文的baseline。它的关键设计——输入节点顺序、限制每个节点的父节点数、每步只贪心选增益最大的父节点——让它在几十个变量的规模下依然跑得动,结果可复现,还方便把领域经验塞进去。作为入门第一个结构学习算法,K2的性价比很高。

2.2 K2评分:为什么局部打分能代表全局好坏

K2的评分函数不是随便定义的经验值,它来自贝叶斯后验。假设所有可能的结构先验相等,那么结构G的后验概率正比于边际似然P(D|G),也就是对参数求积分之后的数据似然。离散数据配合Dirichlet先验时,这个积分有解析解。K2原始论文用的先验是每个参数都取1的均匀先验,跟后来常用的BDeu评分把等价样本量设为1完全等价——这一点在实现上非常讨巧,因为主流库里的BDeu评分可以直接当K2评分用。

把它取对数之后,单个节点的局部评分长这样:

log g(i, π_i) = Σ_j [ log Γ(r_i) - log Γ(N_ij + r_i) + Σ_k log Γ(N_ijk + 1) ]

其中符号的含义是:

符号含义
r_i节点X_i的状态数,也就是这个变量取几个离散值
N_ijkX_i取第k个状态、父节点组合为第j种组合时,样本里出现这种组合的个数
N_ij父节点组合为第j种时所有样本数,等于Σ_k N_ijk
π_i节点X_i的父节点集合

总评分就是所有节点局部评分之和。这个性质决定了整条路线的走向:既然总评分能按节点拆开,那么搜图的时候就可以一个节点一个节点地处理,不需要每次候选动作都重新计算整张图的分。K2算法的每一步都是在当前节点的候选父集合里找一个能让这个局部评分涨得最多的父节点——评分可分解,贪心搜索才站得住脚。

另一个值得注意的点是:评分函数天然包含对复杂度的惩罚。父节点越多、状态组合越细,数据被切得越碎,Γ(N_ij + r_i)这项就会把分数拉低。所以K2不会无限加父节点,即便不设上限,加过头了评分也会掉头向下。这个内在惩罚机制让K2对过拟合有一定自愈能力。

2.3 贪心搜索:K2怎样把指数级搜索变成线性近似

DAG的数量随节点数超指数增长,5个节点的DAG已经有几万种,10个节点以上完全不可能穷举。K2没有去搜全空间,而是用两个约束把空间砍到可控:第一,用户必须提供一个节点顺序,算法只允许从“顺序靠前”的节点指向“顺序靠后”的节点;第二,每个节点的父节点数上限为u,超过固定值就停。这两个约束听起来粗暴,但正是它们让K2在几十个节点下依然有实用价值。

搜索过程非常朴素。按给定顺序遍历每个节点,对当前节点X,把所有排在它前面的节点作为候选父集合,初始父集合为空。每一轮,逐个尝试把某个候选父节点加入父集合,用局部评分算出增益,保留增益最大的那个;如果最大增益为正就真的加进去,否则停止,继续处理下一个节点。每节点最多u轮,每个候选父至多试一次,整体计算量大致在O(n²u)量级,和穷举不可同日而语。

用三个节点A、B、C做例子,如果顺序是A在前、B居中、C最后,那么合法的边只有A→B、A→C、B→C三种可能。算法永远不会生成C→A这样的边,不管数据里这两个变量的相关性有多强。这就是K2把先验变成硬约束的方式——顺序一旦给定,搜索空间就被圈死了,代价是顺序错了,所有结果都会跟着错。这个特性既是K2的高效来源,也是它最大的坑,后面避坑章会专门展开。

3. 用pgmpy跑通K2结构学习:离散化、代码与参数调优

3.1 数据准备:离散化与缺失值处理

K2的评分模型要求数据里每列都是离散整数,取值最好是从0开始的连续整数。如果拿到的是连续变量,必须先在外部做离散化。我一般用pgmpy自带的KBinsDiscretizer做等频分箱,分箱数先给3到5,不要一上来就设10。等频分箱对长尾分布比较稳,等距分箱碰上偏态分布很容易把多数样本塞进一个箱子里。

缺失值也必须提前处理,K2Score不会容忍NaN。常见做法是缺失率高的列直接删,缺失率低的行先删行或按众数补,数据集行数很紧张时再用插补:

import pandas as pd from pgmpy.preprocessing import KBinsDiscretizer data = pd.read_csv("your_data.csv") data = data.dropna().reset_index(drop=True) discretizer = KBinsDiscretizer(data, n_bins=5, strategy="quantile") discrete_data = discretizer.transform(data).astype(int) for col in discrete_data.columns: print(col, sorted(discrete_data[col].unique()))

这段代码先把带缺失的行丢掉,再用等频分箱把连续列变成5档整数。astype(int)很关键,不同版本的pgmpy分箱结果类型并不一致,有的会返回浮点,K2Score会直接报错。最后的循环是检查每个变量的实际取值,确认它们是连续的整数集合,比如0、1、2,而不是0、2、4这种缺值的状态集。如果发现取值不连续,说明数据里混入了异常编码,先用factorize或者LabelEncoder重编码再往下走。

3.2 K2结构学习的核心代码:手写循环与库搜索两种姿势

pgmpy的estimators模块里提供了K2Score,也就是等价样本量为1的BDeu评分,但并没有一个叫K2的现成搜索器。想跑严格意义上的K2,常见做法是自己写贪心循环,逻辑非常短。下面的函数就是标准实现:

from pgmpy.estimators import K2Score def k2_search(discrete_data, node_order, max_parents=3): scorer = K2Score(discrete_data) learned = {} for i, node in enumerate(node_order): candidates = [v for v in node_order[:i] if v != node] parents = [] while len(parents) < max_parents and candidates: current = scorer.local_score(node, parents) best_gain = float("-inf") best_parent = None for cand in candidates: if cand in parents: continue new_score = scorer.local_score(node, parents + [cand]) gain = new_score - current if gain > best_gain: best_gain, best_parent = gain, cand if best_gain <= 1e-6: break parents.append(best_parent) candidates.remove(best_parent) learned[node] = parents return learned

核心逻辑只有两层循环:外层按节点顺序逐个处理,内层在当前节点的候选父集里找最大增益。local_score(node, parents)返回的是对数评分,越大越好,所以gain > 0就说明这个父节点值得收编。1e-6的阈值是防止浮点噪声导致的多加一条无意义边,实际调参时可以放宽到1e-4,少一些边、结果更干净。候选只取node_order里排在前面的节点,这就把K2的顺序先验落到了代码里。

另一条路是用HillClimbSearch加上黑名单来模拟K2搜索空间。HillClimbSearch是全局贪心搜索,允许加边、删边、反边,比原版K2更激进,通常结果也更好,但已经不是严格意义上的K2:

from pgmpy.estimators import HillClimbSearch, K2Score node_order = list(discrete_data.columns) black_list = [ (later, earlier) for i, earlier in enumerate(node_order) for later in node_order[i + 1:] ] hc = HillClimbSearch(discrete_data) dag = hc.estimate( scoring_method=K2Score(discrete_data), black_list=black_list, max_indegree=max_parents, epsilon=1e-4, ) print(dag.edges())

黑名单把所有“后序节点指向先序节点”的边全部禁掉,搜索空间就和K2一致了。区别在于HillClimbSearch每走一步都会重估整张图,还可能删掉之前加错的边,而原版K2只加边不删边。对比文献复现用前面的手写版本,实际建模图省事、想冲更高评分,用HillClimbSearch更划算。我个人在项目里通常先跑手写K2拿一个稳妥的基线,再用HillClimbSearch做增强,两边结果一对比就能看出数据里有没有足够信号支撑更复杂的结构。

提示:node_order如果不是手动指定的,直接用list(discrete_data.columns)会按列名排序,这个顺序完全随机,实战里必须用领域知识或者排序算法单独确定。

3.3 参数说明:max_parents、节点顺序、epsilon 怎么给

K2需要设置的参数不多,但每一个都直接决定结果质量。按重要程度排,顺序是:节点顺序、max_parents、epsilon、分箱数。参数含义和调整思路放在一起看:

参数作用常见设置调整思路
node_order节点先后顺序,K2的硬先验领域时间顺序/因果层次排错全盘皆错,务必做敏感性检查
max_parents每个节点最多父节点数3~5起步小样本从3开始,别硬加
epsilon评分增益阈值1e-4~1e-6越小搜得越狠,越大图越稀疏
n_bins连续变量分箱数3~5箱数少丢信息,多则参数爆炸

节点顺序是唯一没有后悔药的参数。如果没有领域知识,常见做法是先算变量两两之间的互信息,用最大生成树建一个粗略的拓扑顺序,再把这个顺序喂给K2。这样不一定对,但比瞎排强很多,而且能跑通整个流程。顺序到底靠不靠谱,有个很实用的诊断方法:把顺序随机打乱10次,每次跑一遍K2,看学出来的边集波动有多大。如果10次结果SHD波动剧烈,说明数据对顺序先验高度敏感,任何单次K2输出都不能当结论,必须回头去找更可靠顺序,或者换PC这类不依赖顺序的算法交叉验证。

max_parents的默认值我会控制在3到5。不是越多越好,父节点组合数按乘积增长,两个三值父节点就有9种组合,再来一个就27种,样本稍微小一点,每个组合下的计数稀疏得像筛子,评分全是噪声。epsilon在1e-4到1e-6之间调,调大一点图更稀疏,调小一点更容易塞进弱相关边。实际使用中,epsilon=1e-4在几千样本上效果通常已经足够干净。

4. 模拟数据验证:用已知结构反推K2的可靠性

4.1 构造已知贝叶斯网络并生成样本

结构学习是个没有标准答案的任务,拿到真实数据你永远不知道学出来的图对不对。所以在正式跑业务数据之前,先做一个合成数据验证是必须的步骤:手工搭一个已知结构的贝叶斯网络,从里面生成数据,再把数据喂给K2,看它能不能把原结构找回来。这个过程能在十几分钟内告诉你当前的数据质量、顺序和参数设定到底行不行。

下面搭一个五个节点的疾病诊断模型:X1和X2共同影响X3,X3和X4共同影响X5。用pgmpy的TabularCPD逐个定义条件概率表:

from pgmpy.models import BayesianNetwork from pgmpy.factors.discrete import TabularCPD model = BayesianNetwork([("X1", "X3"), ("X2", "X3"), ("X3", "X5"), ("X4", "X5")]) cpd_x1 = TabularCPD("X1", 2, [[0.6], [0.4]]) cpd_x2 = TabularCPD("X2", 2, [[0.5], [0.5]]) cpd_x3 = TabularCPD( "X3", 2, evidence=["X1", "X2"], values=[[0.9, 0.7, 0.6, 0.1], [0.1, 0.3, 0.4, 0.9]] ) cpd_x4 = TabularCPD("X4", 2, [[0.8], [0.2]]) cpd_x5 = TabularCPD( "X5", 2, evidence=["X3", "X4"], values=[[0.9, 0.8, 0.5, 0.2], [0.1, 0.2, 0.5, 0.8]] ) model.add_cpds(cpd_x1, cpd_x2, cpd_x3, cpd_x4, cpd_x5) assert model.check_model() sim_data = model.simulate(n_samples=5000, random_state=42) discrete_data = sim_data.astype(int)

这里有个新手常踩的坑:TabularCPD的values列顺序是按evidence列表从后往前遍历的。以X3为例,四列依次对应X1=0,X2=0;X1=0,X2=1;X1=1,X2=0;X1=1,X2=1。写概率表之前先把这个顺序想清楚,不然check_model()会报概率不归一化,或者更隐蔽地直接构造出一个语义完全错掉的分布。simulate负责从网络里采样,5000个样本对五节点二值网络来说足够学出稳定结构。

4.2 学习恢复并计算结构距离

沿用第3章手写的k2_search,直接把合成数据丢进去学。节点顺序这里用真实生成顺序,也就是X1、X2、X3、X4、X5——先有原因后有结果,完全符合K2的顺序假设:

node_order = ["X1", "X2", "X3", "X4", "X5"] learned = k2_search(discrete_data, node_order, max_parents=3) learned_edges = [(p, c) for c, ps in learned.items() for p in ps] true_edges = list(model.edges()) print("learned edges:", learned_edges) print("true edges:", true_edges) def compute_shd(edges_learned, edges_true): true_und = {frozenset(e) for e in edges_true} learn_und = {frozenset(e) for e in edges_learned} shd = len(true_und.symmetric_difference(learn_und)) common = true_und & learn_und for pair in common: true_dir = [e for e in edges_true if frozenset(e) == pair][0] learn_dir = [e for e in edges_learned if frozenset(e) == pair][0] if true_dir != learn_dir: shd -= 1 return shd print("SHD:", compute_shd(learned_edges, true_edges))

SHD,结构汉明距离,就是把学到的图变成真实图需要的最少操作次数:加一条边、删一条边、反一条边各算1。反向边在这种实现里需要做一次删除加一次新增,但我在公共边检测里把它扣了1次,所以最终反向边按1次计。正确的顺序加上5000样本,这个五节点例子通常能学到SHD=0的结果,偶尔有一两条边因为采样噪声丢失,SHD落在1左右也正常。

如果把这个顺序故意打乱,比如把X5放到最前面,SHD会明显上升,而且错的边大多是那些“后被K2当作先验原因”的边。这一步对比能直观感受到顺序对结果的支配作用,比任何原理说明都更入味。

4.3 结果解读:SHD、评分和方向一致性的判断线

拿到SHD后怎么判断能不能收工?我一般用三个维度交叉看。第一个是SHD本身,五节点小网络SHD=0到1属于完美恢复,2到4说明数据噪声大或顺序有局部错位,超过4就得回去查数据质量,而不是怀疑K2——它只是在给定的错误约束下忠实执行搜索。第二个是总评分对比,用K2Score(discrete_data).total_score(model)分别算真实图和学到的图,两张图的分数应该非常接近,学到图偶尔略高也不意外,因为K2本就是在最大化这个分数。

第三个维度是方向一致性,这个最容易被忽略。K2学出的有向边不能直接理解成因果方向,尤其在链式结构里,X→Y和X←Y可能属于同一个马尔可夫等价类,评分完全相同,K2只是按黑名单约束选了一个方向。真正能从数据里识别方向的地方是V结构,也就是某个节点有多个父节点且这些父节点之间没有边相连。解读结果时,优先看V结构处的位置和方向,链式部分只当作依赖关系用,不要写进结论当成因果。模拟验证的意义就在于:你先知道真图,再看K2的哪些边是可靠的,哪些只是等价类里的一个代表,这个经验可以直接迁移到真实数据上。

5. K2落地避坑:节点顺序、离散化与等价类的五个翻车现场

5.1 节点顺序排错,整个DAG方向跟着乱

现象:跑出来的DAG确实没有环,边也少,但方向看起来就是不对劲。比如业务上明明是A导致B,学出来却是B导致A,而且评分还挺高。更迷惑的是,如果把顺序彻底反过来再跑一遍,得到的图评分几乎一样,方向却整体反转。

原因:K2把节点顺序当成硬约束,给它一个错的空间,它就只能在错的空间里找最优解。A和B之间不管是A→B还是B→A,只要两者没有形成V结构,评分函数根本分不出哪个方向更对,黑名单里没禁掉的那个方向就留下了。顺序排反,等于先入为主把因果方向写错了,数据再强也救不回来。

解决:没有后悔药,只能重新跑。实战里有顺序直接排,没有顺序先做互信息排序或者用PC算法先学无向骨架再定序。更务实的方式是做顺序敏感性分析:随机生成10组顺序,跑10次K2,如果学出来的边结构大范围漂移,就说明结果完全被顺序绑架,任何单次输出都不可信,必须回到数据探索阶段把顺序定稳再往下走。

5.2 连续变量直接喂评分函数,K2Score直接罢工

现象:DataFrame里明明有值,代码报错说数值不是整数,或者数据带小数时评分结果全都是NaN,图学出来是空的。

原因:K2评分是建立在多项式计数上的,底层逻辑是数样本落入哪个状态组合,小数或者浮点类型根本没法计数。这是很多第一次用pgmpy的人会撞上的错误,日志信息往往还比较隐晦,只提示score计算失败,不告诉你问题出在分箱。

解决:分箱是必经步骤。先用KBinsDiscretizer做等频分箱,箱数控制在3到5。分箱数也不是越大越好,5个箱子和2个父节点已经有25种组合,样本量稍微不够就全是稀疏计数,学出来的边带着大量虚高评分,看起来结构丰富,换个数据集立刻崩塌。分箱之后顺手astype(int),杜绝类型问题。

5.3 缺失值没处理,程序直接报错或静默丢行

现象:数据里有NaN,K2Score直接抛异常;换成某些旧版本库,会静默把包含缺失的行整行丢弃,样本量悄悄缩水,学出的结构越来越稀。

原因:K2的边际似然计算要求每条样本在所有变量上都有完整观测,缺失值在数学上直接没法计数。静默丢行比报错更危险,因为没有人会发现样本量变了,几千行的数据丢掉几百行,结构学习结果可能完全漂移。

解决:在进K2之前就统一处理。缺失率超过20%的变量先删列,个别缺失行按删除或众数插补处理,处理完要看一眼每列的行数、取值集合。这条看似是数据预处理的基本功,却是K2实践里翻车频率最高的一步,因为报错时机往往不在读取时,而在评分那一行。业界所谓「趁早踩坑」就是这个意思,数据不进评分函数,模型不会替你兜底。

5.4 评分等价类:学出来的方向不等于因果方向

现象:同一个数据集,用不同的初始化顺序跑K2,得到的图评分相差无几,但某些边的方向完全相反,看起来像是算法不稳定。

原因:这是评分函数的数学性质,不是bug。结构学习只能识别到马尔可夫等价类这个粒度,同一等价类里的所有DAG共用一个评分值。X→Y和X←Y在没有任何其他相邻结构时,属于同一个等价类,K2选哪个纯粹看黑名单先验。

解决:解读时区分两类结构。V结构(如X→Z←Y且X与Y无直接边)是可以从数据里识别方向的,多路条件依赖会打破评分等价;链式和分叉结构的方向则完全是先验说了算。交付结论时,把K2学出的图称作「条件依赖图」而不是「因果图」,只在V结构处谈方向倾向。如果业务上必须因果方向,那就只能引入干预数据或者更严格的领域约束,算法本身已经到极限了。

5.5 max_parents设得太大,评分表直接爆炸

现象:max_parents从3提到6之后,运行时间暴涨几十倍,内存也开始吃紧,最后学出来的图反而多了好几条明显不靠谱的弱边。

原因:K2评分要对每一种父节点组合做计数,组合数是各父节点状态数的乘积。两个三值父节点是9种组合,四个三值父节点直接到81种,叠加5000个样本,每个组合平均只有60条数据,评分对这60条数据的随机波动极其敏感,弱相关边就趁虚而入。

解决:max_parents从3开始,最多不要超过5,这是绝大多数实际业务数据集的安全边界。如果确实怀疑某个节点受很多因素影响,先用3跑通全流程、看一眼核心边,再单独放开这个节点的上限做对比,而不是全局盲调。小样本场景还有一个土办法:先跑500行的子集做pipeline验证,确定参数组合和代码没有性能问题,再用全量数据正式跑,能省下大量试错的等待时间。

6. 进阶:让K2结果更稳的BDeu参数与模型平均技巧

前文用的K2Score本质上是等价样本量设为1的BDeu评分。这个等价样本量是可以调的:把它调大,相当于给参数先验加了更多虚拟样本,评分对数据里的偶然波动更钝感,学出来的结构更稀疏、更保守。数据量小、噪声大的场景,我一般把equivalent_sample_size调到5到10,能明显减少那些只在某一个数据集里成立、换个样本就消失的假边:

from pgmpy.estimators import BDeuScore scorer = BDeuScore(discrete_data, equivalent_sample_size=10)

另一个比调参更能救场的习惯是模型平均。单次K2学出来的边不一定稳,尤其是样本量几千这种常见规模,随机采样波动就能让个别边从有到无。我一般从原始数据里做有放回抽样,跑50到100次K2,统计每条边出现的频率,只保留出现率超过0.8的边作为最终结构。这样做会把那些评分略高但脆弱的边自动过滤掉,剩下的基本都是数据里的稳定信号。这个技巧在真实业务数据上比任何提参技巧都管用,代价只是多跑几十次搜索,对K2这种量级的算法来说完全能接受。

from sklearn.utils import resample edge_count = {} n_bootstrap = 50 for seed in range(n_bootstrap): boot = resample(discrete_data, replace=True, n_samples=len(discrete_data), random_state=seed) dag = k2_search(boot, node_order, max_parents=3) for child, parents in dag.items(): for parent in parents: edge_count[(parent, child)] = edge_count.get((parent, child), 0) + 1 stable_edges = [e for e, c in edge_count.items() if c / n_bootstrap >= 0.8] print(stable_edges)

和PC算法怎么分工也顺带说一句:K2适合你已经掌握变量先后顺序的场景,比如按时间发生的流水线数据、按因果层级组织的指标体系;PC不依赖顺序但依赖独立性检验,样本量大时才稳。两者不是替代关系,真实项目中我会先用PC出一个无向骨架做交叉验证,再用K2定向,两边都对得上的边才敢写进结论。我有一次在本地模拟数据上把顺序排反了,学出来的V结构方向整体反转,要不是用模型平均看到边频率对半开,差点就拿着这张图去交付了。从那之后,我跑结构学习默认加两道工序:先验证后解读。希望帮到你。

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

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

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

立即咨询