简介:这份资源围绕K2算法从数据中学习贝叶斯网络结构这一主题,面向已具备概率图模型基础、希望动手实现结构学习的研究生与算法开发者,帮助解决从样本数据推断有向无环图并给出评分的问题。压缩包共7个文件,约10KB,包含4个m脚本、1个C源文件、1个mat数据文件与1个txt说明,脚本承担对象构造、评分函数与主流程调用,C文件对应K2核心实现,mat文件提供可直接加载的样例数据,便于对照验证。已有2169人学习下载,说明其在结构学习入门实践中具有一定参考价值。读者可据此理解K2算法以节点顺序和父节点上限为输入、逐步搜索最优父节点集合的流程,借助样例数据复现实验,并在此基础上扩展到自己的数据集或与其他评分准则对比,形成从理论到代码落地的完整认识。
1. K2算法:为什么它敢在指数级的结构空间里“贪心”搜索
贝叶斯网络结构学习是个组合爆炸问题。n 个节点最多能组成 n(n-1)/2 条有向边,每条边存在与否构成 2 的幂次规模的可能结构,节点数上到二十几个,穷举就已经不现实。K2 算法给出的答案很反直觉:不穷举、不做全局优化,只按一个事先定好的节点顺序,逐个节点贪心地往前找父节点,找到一个“分数不再涨”就停手。它凭什么敢这么干,又凭什么结果还能用?搞懂这件事,等于搞懂了贝叶斯网络结构学习里“评分 + 搜索”这条主线的起点。K2 的价值不在于它多精确,而在于它把“结构学习”这个黑匣子拆成了两个可以分别讨论的部件:一个给候选结构打分的函数,和一个决定往哪走的搜索策略。后面所有基于评分的方法,几乎都是在这两个部件上做替换和改良。读懂 K2,后面看爬山法、看各种评分准则,就不会觉得是玄学。
2. 评分函数:K2 到底在给一个结构打什么分
2.1 从联合概率到边缘似然
K2 的评分不是拍脑袋定的,它来自一个很干净的贝叶斯立场。给定数据 D,我们想比较的是不同结构 S 的后验概率 P(S|D),而 P(S|D) ∝ P(D|S)P(S)。如果假设所有结构先验相同,比较结构就退化成比较边缘似然 P(D|S)。这个量要对所有可能的参数取值做积分,直接算是不可行的,但贝叶斯网络有个关键性质:在参数独立先验的假设下,整个网络的边缘似然可以按节点拆成连乘,每个节点只依赖它自己和它的父节点集合。这就是 K2 评分能够“局部计算”的根本原因,也是它能在搜索中高效复用的底层机制。
拆开之后,单个节点的贡献可以写成一个 Gamma 函数比值的形式。设节点 i 的父节点集合为 Pa_i,节点 i 有 r_i 种取值,父节点组合有 q_i 种,N_ijk 表示父节点取第 j 种组合时节点 i 取第 k 种值的样本数,N_ij 是对 k 求和。那么节点 i 的局部评分是:
import math from scipy.special import gammaln def local_score(node_i, parents, data, r_i): """ 计算 K2 评分中单个节点的局部贡献(对数形式)。 node_i: 当前节点索引 parents: 父节点索引列表 data: shape=(样本数, 节点数) 的离散数据矩阵 r_i: 节点 i 的取值个数 """ n_samples = data.shape[0] # 枚举父节点的所有取值组合,q_i 为组合数 if parents: parent_configs = data[:, parents] # 用元组标识每一种父节点组合 configs = [tuple(row) for row in parent_configs] else: configs = [()] * n_samples # 统计 N_ijk:按父组合分组,再按节点取值计数 from collections import defaultdict counts = defaultdict(lambda: [0] * r_i) for cfg, val in zip(configs, data[:, node_i]): counts[cfg][val] += 1 score = 0.0 for cfg, row_counts in counts.items(): N_ij = sum(row_counts) # 先验等价样本量,K2 原始设定每个取值先验为 1 alpha = 1.0 # Gamma(N_ij + alpha*r_i) / Gamma(alpha*r_i) 的对数 score += gammaln(N_ij + alpha * r_i) - gammaln(alpha * r_i) for N_ijk in row_counts: # 每个取值的 Gamma(N_ijk + alpha) / Gamma(alpha) score += gammaln(N_ijk + alpha) - gammaln(alpha) return score这段代码里最值得盯的是alpha。它叫等价样本量,含义是“在没看到任何数据之前,我假装已经见过 alpha 个样本,且每个取值均匀分布”。K2 原始论文把 alpha 固定为 1,这个选择直接决定了小样本下的行为:数据稀疏时,先验把评分往均匀分布拉,避免某个从没出现过的取值把局部评分打成负无穷。代码里用gammaln而不是直接算 Gamma,是因为阶乘量级增长极快,取对数后才不会溢出,这是实现这类评分时几乎必踩的一个坑。
2.2 为什么局部评分可以独立累加
很多人第一次看 K2 会疑惑:每个节点单独算分,加起来就能代表整个网络的好坏?这背后是贝叶斯网络的两个结构性假设在撑腰。第一是参数独立性,每个节点的条件概率表被当作独立参数,先验也是独立的,所以边缘似然能按节点分解。第二是局部性,节点 i 的评分只和它的父节点集合有关,和别的节点怎么连、连到哪,一概无关。这两条合起来,意味着搜索过程中改动一条边,只需要重算受影响的那一两个节点的局部评分,其余节点的分数原封不动。这是 K2 能在搜索里反复试边而不至于慢到不可用的底层原因,也是后来所有基于评分的算法共享的性能基础。
提示:局部可分解性不是 K2 独有的,而是评分函数要能用于搜索的必要条件。判断一个评分准则能不能塞进 K2 框架,先看它能不能按节点拆开。
3. 搜索策略:固定顺序下的贪心加边与它的边界
3.1 节点顺序是 K2 的“命门”
K2 要求调用者事先给出一个节点顺序,并且规定:一个节点只能把排在它前面的节点当作父节点。这个约束把搜索空间从“任意有向无环图”砍成了“与给定顺序一致的图”,直接消灭了成环的可能,因为所有边都从前往后指,拓扑序天然成立。代价也很明显:如果真实结构里存在一条从后往前的边,K2 永远找不到它。所以节点顺序不是随便填的,它本质上是一个强先验,填错了,算法再对也白搭。常见的做法是用领域知识排,或者先用简单的相关性、互信息给节点排个粗序,再交给 K2。
3.2 贪心加边的具体流程
在固定顺序下,K2 对每个节点做一件很朴素的事:从空父集开始,依次考察所有允许的候选父节点,算加上它之后的局部评分增益,挑增益最大的那个加进来;如果最大增益为正就接受,继续下一轮;如果所有候选都让分数下降,就停手,这个节点的父集定稿。伪代码大致如下:
def k2_search(data, order, r, max_parents=None): """ data: 离散数据矩阵 order: 节点顺序,list,前面的节点可作后面节点的父节点 r: 每个节点的取值个数列表 """ n = len(order) parents = {node: [] for node in order} for idx, node in enumerate(order): candidates = order[:idx] # 只允许前面的节点 current_score = local_score(node, [], data, r[node]) while True: best_gain = 0.0 best_parent = None for cand in candidates: if cand in parents[node]: continue if max_parents and len(parents[node]) >= max_parents: break trial = parents[node] + [cand] new_score = local_score(node, trial, data, r[node]) gain = new_score - current_score if gain > best_gain: best_gain = gain best_parent = cand if best_parent is None: break # 没有正增益,停止加边 parents[node].append(best_parent) current_score += best_gain return parentsmax_parents这个参数在实际实现里经常被加上,原因很实际:父节点一多,父组合数 q_i 指数增长,统计表迅速稀疏,评分变得不可靠,计算也变慢。K2 原始设定没有硬性上限,但工程上几乎都会设一个,常见取 3 到 5。best_gain > 0这个停止条件也值得说:它等价于“加这条边让边缘似然变大”,是贪心策略的局部最优判据,不保证全局最优,但计算上极其省。
3.3 贪心的代价:局部最优与顺序敏感
K2 的搜索结果对节点顺序高度敏感,这一点在实现里体现得很直接:换个 order,候选父集就变了,贪心路径跟着变,最后的结构可能差出好几条边。它还会卡在局部最优上,因为一旦某条边在当前步骤看起来没增益,就再也不会被考虑,哪怕加上它之后能解锁后面更有价值的边组合。这不是实现 bug,是贪心策略的固有性质。理解这一点,就不会拿 K2 的结果当“正确答案”,而是当“在给定顺序假设下的一个合理近似”。后来的爬山法引入加边、删边、转边三种操作并允许回退,本质上就是在补 K2 这个只能加不能删的短板。
4. 避坑与排查:K2 落地时最容易翻车的几处
4.1 现象:评分全是负无穷或 NaN
原因通常出在数据里出现了代码没预料到的取值,或者某个父组合下所有样本都落在同一个取值上,导致某个 N_ijk 为 0 而先验 alpha 又被设成了 0。解决方法是检查数据编码是否连续、取值个数 r_i 是否和实际数据对得上,并确认 alpha 保持为正。用gammaln时如果传入了 0 或负数,也会直接返回 inf 或 nan,加一层断言能省很多排查时间。
4.2 现象:跑出来的图边数明显偏多或偏少
先看 alpha。alpha 偏大,先验压得重,数据证据不够时算法倾向于不加边,图会偏稀疏;alpha 偏小甚至接近 0,少量样本就能撑起一条边,图会偏密。再看 max_parents,设得太小会漏掉真实的多父结构,设得太大又会让稀疏的父组合统计噪声主导评分。这两个参数要一起调,单看一个容易误判。
4.3 现象:换个节点顺序结果面目全非
这是 K2 的正常表现,不是 bug。排查时先把两次的 order 打出来对比,确认差异是否集中在某几个节点上;如果领域知识明确知道某条边的方向,就把它对应的先后关系固定进 order。不要指望通过调评分参数来消除顺序敏感性,那是搜索策略层面的问题。
4.4 现象:小样本下结果极不稳定
K2 的评分对样本量敏感,样本少时局部评分的方差大,贪心很容易被偶然的计数带偏。常见的缓解办法是提高 alpha 做平滑、限制父节点数、或者先用 bootstrap 重采样看边出现的频率,把高频边当作稳定结构。直接把一次 K2 的结果当结论,在小数据上翻车概率很高。
5. 设计取舍:K2 把复杂度换到了哪里,以及今天该怎么看它
K2 的核心取舍其实只有一句话:用“固定节点顺序 + 贪心加边”换掉了“全局搜索 + 无环约束检查”。这个交换把原本需要处理成环检测、需要在大结构空间里做全局优化的难题,压缩成了一个逐节点的、单调的加边过程,实现简单、复杂度可控,代价是结果依赖先验顺序、只能加不能删、容易局部最优。放到今天看,K2 更像一个教学标本和基线:它的评分函数形式被后来的 BDeu 等准则继承并改良(主要改的是 alpha 的设定方式),它的搜索框架被爬山法、遗传算法、约束型方法不断替换。真正值得带走的是那个分解思路——把结构学习拆成“可局部计算的评分”加“可控的搜索”,任何新方法都可以放回这个框架里对照:它改的是评分,还是搜索,还是两者都改。
验证一个 K2 实现对不对,我一般会做两件事:一是构造一个已知结构的小规模合成数据,看算法能不能把真实边找回来;二是固定数据只改 order,观察结果变化是否符合“顺序敏感”的预期。这两步能筛掉大部分实现层面的错误。至于参数,alpha 和 max_parents 没有普适最优值,得结合样本量和领域稀疏性去试,别指望一套默认值打天下。从那以后我每次跑这类结构学习,都会先把节点顺序和评分参数单独记一份,出问题第一件事就是回看这两个输入,而不是怀疑算法本身。希望帮到你。
本文还有配套的精品资源,点击获取