☰
PC算法手写实现:从假设到Python代码,因果发现避坑指南
2026/10/12 18:00:53 网站建设 项目流程

简介:一份基于 Python 的 PC 算法实现源码,面向从事因果发现、网络结构学习或高维数据条件独立性分析的研究者与开发者。项目围绕偏相关这一核心概念,实现从数据预处理、相关矩阵计算、条件独立检验到边剔除与结果可视化的完整流程,适配典型的表格式数据,便于快速复现并理解基础版本。压缩包共 13 个文件,以 Python 脚本为主,辅以 CSV 样本数据、实验结果示意图、依赖清单和说明文档,整体仅 452KB,结构轻量,适合直接阅读与二次开发。目前已有 298 人学习下载。通过阅读核心源码和注释,可掌握条件独立检验、显著性阈值设置和有向图输出等关键细节,还可以了解如何借助 networkx 绘制因果网络;项目中还给出了向非高斯数据、并行化计算等方向扩展优化的思路,便于进一步改造和落地。

1. PC算法在因果发现里处在什么位置:为什么你最终会回来手写它

你手里有一批观测数据,变量十几个,想从里面找因果关系而不是相关关系——搜索“Python实现PC算法项目源码”的人,大多走到了这一步:传统回归已经在业务上解释不了“为什么两个指标总是同涨同跌”,而实验又做不了。PC算法(Peter-Clark算法)就是在这种情况下被反复提起的名字。它是从观测数据推断有向无环图最经典的入门算法,绝大多数因果发现项目都以它为起点。但很多人第一步就踩进黑匣子:网上教程全是“调用现成库跑通”,遇到输出异常时既不知道调alpha还是换检验方式,更判断不了结果是真结构还是假设被违背的假象。这篇我按自己实际落地的方案讲:先把PC算法的三个假设拆开,再给出最小可运行的手写代码,最后讲参数参考和五个血泪坑。适合正在用Python做因果发现的从业者,尤其是数据来自观测、没有受控实验可做的人。相信我,你迟早会从“调库跑通”回到“手写一遍”的。

2. 先立住理论地基:PC算法的三个假设与两阶段流程

2.1 三个假设:忠实性、因果充分性与正态性,少一个结果都不可信

PC算法输出一张有向无环图的勇气,完全建立在三个假设上。第一个是因果充分性(causal sufficiency):所有变量都被观测到,不存在未观测的共同原因。这在现实里是最难满足的。比如你观测了点击量和订单量,却没记下促销活动这个上游变量,两个变量会被一个隐藏共因推着走,PC算法会把虚假的边误认为直接因果。数据侧的信号是:回归残差之间仍明显相关,或者把数据按时间分段后PC结果剧烈变化。

第二个是忠实性(faithfulness):真实图中存在的每条因果路径,都会在概率上留下可观测的条件依赖;反过来,数据里出现的独立关系必须来自图结构本身,不是概率巧合。这句话很绕,但破坏它的例子很常见——抵消效应:X→Y→Z 和 X→Z 两条路同时存在,如果两条路径的系数恰好大小相等符号相反,X和Z在给定Y时仍然独立,PC算法就把那条真实的边X→Z剪掉了。忠实性假设不允许这种“数值巧合”存在,但真实数据天天发生。

第三个是正态性(或至少可交换性):偏相关检验和Fisher z变换的分布假设建立在数据近似高斯上,这是一条实用性假设。遇到厚尾、多模态或者离散变量,偏相关检验会成片误判。离散数据要换成卡方或互信息检验,这是我强烈建议一开始就决定的——它直接影响你待会儿代码里调用哪个函数。三个假设不是学术摆设,你拿着PC算法的结果给业务方看的时候,能说得清“我们的结论在什么前提下成立”,才不会被一句“是不是两个变量碰巧相关”问倒。

2.2 偏相关检验:PC算法的条件独立引擎

PC算法判断“X和Y是否直接相连”不靠普通相关,靠条件独立检验。给定一组变量S,如果X和Y在控制S后仍然相关,说明S没有完全解释二者之间的依赖,X和Y之间应该有一条边(至少在骨架层保留)。判断方法在高斯数据下就是偏相关。偏相关用精度矩阵算非常简洁:协方差矩阵的逆(precision matrix)归一化后就是偏相关矩阵。具体公式是

ρ_xy|S = -prec_xy / sqrt(prec_xx × prec_yy)

当S为空集时,这个值退化成普通相关系数。用精度矩阵的好处是一次求逆可以反复取任意条件集的偏相关,不用每次都重新回归;坏处是当样本量不足或者变量高度共线时,协方差矩阵接近奇异,求逆就是第一步翻车点。显著性检验用Fisher z变换:z = 0.5 × ln((1+r)/(1-r)),再乘以 sqrt(n − |S| − 3),近似标准正态,p值小于alpha就认为不独立。这套检验有个隐含要求:样本量至少比条件集大小多4,否则方差估计是负的。后面代码里我会对条件集大小做上限保护。

2.3 两阶段流程:骨架学习与边的定向

PC算法分两步。第一步叫骨架学习(skeleton):从完全无向图开始,对每一对相邻节点,用它们的共同邻居做候选条件集,从条件集大小为0开始逐层增大,一旦找到某个条件集使两个节点条件独立,就把这条边删掉,并把该条件集记入d分离集(d-sep set)。关键点是:删掉一条边后,邻居集合立刻变化,后续检验必须基于更新后的图——这也是它在效率和精度上区别于朴素条件独立检验的地方。

第二步叫定向(orientation)。先找v结构:对任意链 i−j−k,如果i和k不直接相邻,且j不在分离i和k的d分离集中,那么j是碰撞器(collider),方向为 i→j←k。紧接着用Meek规则把方向向整个无向骨架传播。做完这些后,图中仍会留下一些无法确定方向的边,PC算法输出的其实是CPDAG(完全部分有向无环图),代表一个等价类,而不是唯一的一张DAG。这个认知特别重要——很多人在复现后看到“还有边没定向”就以为代码错了,其实这才是PC算法的真实输出。明确这一点再动手,后面每一步都不会慌。

3. 从零手写PC算法:三段代码与参数详解

3.1 数据准备与偏相关检验:先算对独立性

我习惯先把数据标准化,再走精度矩阵路线算偏相关。标准化不是必须的,但数值稳定性好很多,尤其在变量量纲差异大时。下面这段是偏相关矩阵和Fisher z检验的最小实现,我常用它替代任何统计库。

import numpy as np import pandas as pd def partial_corr_matrix(data: np.ndarray) -> np.ndarray: """计算偏相关矩阵。 data: shape (n_samples, n_features),行是样本,列是变量。 返回:偏相关矩阵,对角线为1。 """ # 标准化到零均值单位方差,数值更稳 data = (data - data.mean(axis=0)) / data.std(axis=0, ddof=1) corr = np.corrcoef(data, rowvar=False) # 用伪逆而不是逆:协方差接近奇异时伪逆不会直接崩 prec = np.linalg.pinv(corr) diag = np.sqrt(np.abs(np.diag(prec))) pcorr = -prec / np.outer(diag, diag) np.fill_diagonal(pcorr, 1.0) return pcorr def fisher_z_test(r: float, n: int, cond_size: int, alpha: float = 0.05) -> bool: """检验偏相关 r 在给定 n 个样本、条件集大小 cond_size 下是否显著。 返回 True 表示“不独立”(拒绝原假设)。 """ z = 0.5 * np.log((1 + r) / max(1 - r, 1e-12)) stat = np.sqrt(max(n - cond_size - 3, 1)) * z # |stat| 大于标准正态分布 alpha/2 分位数则拒绝独立 return abs(stat) > 1.96 # alpha=0.05 的分位数,可换成 scipy.stats.norm.ppf

逻辑说明:我先把数据标准化,保证协方差矩阵的量纲统一;然后求相关矩阵的伪逆(pinv),它和真实逆在满秩时等价,在秩亏时给出最小二乘意义下的解,不会抛奇异矩阵异常。偏相关由精度矩阵归一化得出,等你后面看其他实现时会发现,也有人用“协方差矩阵求逆后除以对角元勾股”的写法,本质一样。Fisher z 检验里我给了一个 1.96 的硬编码,这是 alpha=0.05 的常用分位数,如果你需要换显著性水平,建议换成 scipy.stats.norm.ppf(1 - alpha / 2),别到处改魔法数字。

在 PC 算法里,我很少直接用整个偏相关矩阵,而是对每一对节点调用一个“给定条件集后计算偏相关”的函数。上面的矩阵版用来快速看数据概况,逐对计算版则嵌入骨架学习循环中。你写代码时建议先跑一遍矩阵版,确认数据里高分列有没有异常——如果偏相关矩阵对角线外全接近 1 或全接近 0,先回去检查数据清洗,别急着跑算法。

3.2 骨架学习:用邻接矩阵做逐层剪边

骨架学习的核心是维护一个邻接矩阵 G,矩阵里 1 代表当前还有边。对每一对相邻节点,在所有共同邻居中暴力枚举条件集。代码我按最常见的实现方式写,优先保证可读性:

def pc_skeleton(data: np.ndarray, alpha: float = 0.05, max_cond_size: int = 5): n_features = data.shape[1] n_samples = data.shape[0] # 初始化为完全图(对角线为0,自己连自己没有意义) G = np.ones((n_features, n_features)) np.fill_diagonal(G, 0) # dsep[i][j] 记录使 i,j 条件独立的那个条件集(列表) dsep = {} def neighbors(v): return {u for u in range(n_features) if G[v][u] == 1 and u != v} for l in range(max_cond_size + 1): changed = False for i in range(n_features): for j in range(n_features): if G[i][j] == 0 or i == j: continue nbrs = neighbors(i) - {j} if len(nbrs) < l: continue # 在邻居中取大小为 l 的组合做候选条件集 for cond in itertools.combinations(nbrs, l): r = partial_corr_between(data, i, j, list(cond)) if not fisher_z_test(r, n_samples, len(cond), alpha): G[i][j] = G[j][i] = 0 dsep[(i, j)] = list(cond) dsep[(j, i)] = list(cond) changed = True break # 找到一组独立就剪边,不必枚举完 if not changed: break # 这一层没有边被剪掉,可以提前退出 return G, dsep

逻辑说明:最外层循环控制条件集大小 l,从 0 开始逐步加深。内层对每对仍然相邻的节点做检验,候选条件集只从 i 的邻居中取,并且要排除 j 本身,这是 PC 算法的标准做法——条件集里不需要包含它想解释的那对节点。注意我把断边后的邻居集合实时更新,所以每层都在“当前骨架”上做检验,不是原始完全图。dsep字典在定向阶段是命脉,务必保留。

参数说明:alpha是独立性检验的显著性水平,调大(比如 0.1)会让检验更容易判定为不独立,结果图更稠密;调小(比如 0.01)则剪边更狠,图更稀疏。max_cond_size是条件集大小的上限,PC 算法理论上是穷尽所有层的,但暴力枚举组合的开销是组合数级的,实际数据里条件集超过 4~5 就会很慢,而且样本量也撑不住。我一般设 5,数据集变量少且样本充足时可以提到 6 或 7。itertools.combinations是最直观的枚举方式,但如果邻居很多,建议换成按“当前层 l 生成组合”的迭代器写法,否则内存会先爆。

3.3 定向:碰撞器识别与方向规则传播

骨架学习把图剪成无向图后,定向分两步。先找所有 v 结构,再用 Meek 规则把方向传播出去。v 结构是整篇 PC 算法里唯一能从数据里直接“看出”方向的局部结构,其余方向靠不产生环和碰撞器的传播规则推导。

def orient_graph(G, dsep, alpha, data): n = G.shape[0] # 先复制骨架 dir_graph = G.copy().astype(int) # 1 表示无向边,2 表示有向边,用方向信息存储 # 用一个 dict 记录已定向的边: (from, to) -> True oriented = {} # 第一步:v结构识别。对任意 i-j-k,i 与 k 不相邻,且 j 不在 dsep(i,k) 中 for i in range(n): for j in range(n): if G[i][j] == 0 or i == j: continue for k in range(n): if k == i or k == j: continue if G[i][k] == 0 or G[j][k] == 0: continue if G[i][k] == 1 and k in dsep.get((i, k), []): continue # j 不是分离 i,k 的条件,则 j 是碰撞器 if j not in dsep.get((i, k), []): oriented[(i, j)] = True oriented[(k, j)] = True # 第二步:Meek规则传播,这里实现最简单的一条: # 如果 i -> j 且 j - k(无向),且 i 与 k 不相邻,则定向 j -> k changed = True while changed: changed = False for j in range(n): for i in range(n): if (i, j) in oriented: for k in range(n): if G[j][k] == 0 or j == k: continue if (j, k) in oriented or (k, j) in oriented: continue if G[i][k] == 0: # i 与 k 不相邻 oriented[(j, k)] = True changed = True return oriented

逻辑说明:v 结构识别的核心不是看图,而是查 dsep 集。条件 i 与 k 不相邻,然后问 j 是否在分离 i 和 k 的条件集里。如果 j 不在,说明 j 不能被其他变量解释成中间变量,最合理的解读是 i 和 k 都指向 j,也就是碰撞器。这里最容易错的地方是把“不相邻”误解成“没有路径”,实际是骨架层已经删掉了直接边,才轮到 v 结构出场。

Meek 规则这一段只是四条规则中的第一条,完整实现要处理环和更多方向传播情况,我在小图够用,图大了建议直接看 causal-learn 源码里的meek_rules实现。定向结果里一定有边保持无向,这是 CPDAG 的正常样子,别强加规则把所有边都定向——那反而会违背 PC 的保守前提。

在代码里我刻意返回oriented字典而不是直接改dir_graph,这样方便复现时检查每一步的定向逻辑。你会看到有一类输出极其常见:骨架正确,v 结构正确,但方向传播完之后局部出现“有向环”。这不是 PC 理论错了,多半是 Meek 规则实现漏了一条(通常是规则 2 和规则 3,涉及碰撞器保护和路径检查)。如果你也遇到,去对照规则全集。

4. 用现成库快速落地:causal-learn 的最小复现路径

4.1 为什么有现成库还要自己手写一遍

手写实现的代码价值在于:当你拿着一个结果去和别人讨论时,你能说出“这条边为什么留下了”“那个 v 结构为什么被定向”,而不是“库跑出来就是这样”。现成库的价值则是交付和生产,尤其 causal-learn 这类库把 PC 算法在性能上做了大量优化,包括本地化的条件集剪枝、优先级的节点选择策略,这些我手写的版本都没有。我的习惯是:手写版本跑一遍,理解行为;库跑一遍,确认结果没差;如果手写和库结果明显不同,那问题几乎一定出在偏相关检验的实现细节上,比如是用了相关矩阵还是协方差矩阵求逆、条件集排除方式是不是把目标节点算进了邻居、alpha 被重复使用等。

4.2 最小再现代码与参数配置

causal-learn 是目前 Python 生态里最接近 PC 算法论文的库,我用它做验证已经两年了。最小代码如下,跑通后再逐步调参:

# 安装:pip install causal-learn import pandas as pd from causallearn.search.ConstraintBased.PC import pc from causallearn.utils.GraphUtils import GraphUtils df = pd.read_csv("your_data.csv") # 列名建议给中文加拼音,库对列名没有要求 data = df.values # 核心调用:alpha 是最关键的参数 cg = pc(data, alpha=0.05, indep_test="fisherz", depth=3) # 结果可视化,保存为图片容易复盘 GraphUtils.plot(cg.G, "pc_result.png")

参数说明:alpha和手写版一致,控制独立性检验的阈值;indep_test是独立性检验方式,连续数据用"fisherz",离散数据可以换"chisq"或"gsq",这与你最早选定的统计检验对应;depth相当于我手写版里的max_cond_size,它传 3 表示条件集最大大小 3,传 -1 表示无限制,但样本量不足时无限制会让运行时长涨得离谱。pc()返回的cg.G就是学习到的图对象,cg.G.graph里存邻接矩阵,方向箭头在cg.G.directed_edges()里可以拿出来做业务展示。

注意不同版本的 causal-learn 对pc()的函数签名的写法有细微差异,我建议跑通之后再print(cg.G.graph)看一眼矩阵,核对是不是 n×n 的 0/1/2 矩阵(0 无边,1 无向,2 有向)。我见过直接把cg.G存下来当 pandas DataFrame 用的人,结果列名和顺序全乱了,可视化也错位。

5. PC算法落地避坑:5个我实际踩过的数据与参数问题

5.1 数据侧的三连坑:矩阵奇异、类别错配、样本量虚胖

坑1:协方差矩阵奇异导致偏相关全是假的。现象:代码在求逆那一步直接报 LinAlgError,或者没报错但偏相关矩阵里有大量 NaN。原因:变量数量接近甚至超过样本量,或者有两个变量完全线性相关(比如百分比和它对应的小数)。解决:先np.linalg.matrix_rank查秩,删掉冗余列;再把inv换成pinv;最后实在不行降低max_cond_size,让条件集更小,减少对高维精度矩阵的需求。

坑2:离散数据硬上 fisherz 检验。现象:同一份数据,用fisherz跑出 8 条边,换成chisq跑出 4 条,而且业务经验明显支持后者。原因:偏相关的高斯假设在离散变量上系统性失效,尤其当变量是 0/1 编码、取值稀疏时,独立性检验的方差被低估。解决:先看数据类型的分布,离散列超过 2 个就换chisq或gsq;如果数据里混装连续和离散,最好做分箱后的互信息检验,而不是在一个模型里用两种检验。

坑3:样本量“看着多”但条件集撑不住。现象:骨架阶段 l 增到 3 就开始剧烈变慢,增到 4 直接卡死;中途中断后重新跑结果不一样。原因:样本量虽然上万,但某个变量的邻居数很多,组合枚举量在 l=3 时就已经超过百万规模;另外 PC 算法的顺序依懒性导致中断后状态不一致。解决:把max_cond_size设成 3 跑通基线;再用random.sample做多次 bootstrap 重采样,看骨架稳定性,而不是一次跑完再调参。

5.2 算法侧的翻车点:剪边顺序、定向循环与“确定性幻觉”

坑4:剪边顺序和条件集枚举顺序影响了最终结果。现象:我把itertools.combinations换成按变量索引顺序枚举后,结果从 5 条边变成 6 条,另一条边时有时无。原因:PC 算法在理论上顺序无关,但实际偏相关估计和检验有数值误差,先剪哪条边会改变后续邻居集合,放大这个误差。解决:手写版接受“顺序略有影响”是正常的;要稳定结果,就固定随机种子、固定变量名顺序,并在报告中注明采用“按变量顺序枚举”的策略;真对比方法优劣时,用因果结构恢复的 Hamming 距离,不要比单次出行。

坑5:定向后输出里出现了有向环。现象:oriented字典里同时存在 (A,B) 和 (B,A),可视化时直观就是两根箭头,和 DAG 前提直接冲突。原因:我在 v 结构定向后,Meek 规则没有做碰撞器保护;规则 2 和规则 3 的本职就是保证“不引入环、不覆盖已有碰撞器”,我只实现了规则 1 就会漏。解决:要么补齐四条 Meek 规则(推荐看 Koller 教材里的表述),要么在传播循环后加一个校验函数,检测环然后直接删除那两条冲突边。别装作没看见——业务方看到环时会立刻对算法失去信任。

最后还有一个心态上的坑:PC 算法输出里留有大量无向边不是 bug,是数据信息量不足的真实反映。很多人看到 10 条边有 4 条没定向,就开始堆规则强行定向,这是制造“确定性幻觉”。正确做法是把无向边原样交付,在报告里标注“此处需要实验或先验知识干预”。这个习惯帮你至少省掉三次业务方追问。

6. 最后一步:用稳定性检验与可视化验收 PC 算法结果

PC 算法跑完只是起点,交付前我至少做两件事:稳定性检验和方向可信度标注。稳定性检验用 bootstrap 最简单——对原始数据有放回地抽样 100 次,每次只重算骨架(不跑定向),统计每对变量之间出现边的频率,输出边的频次矩阵。频率低于 0.6 的边我会在最终图里画成虚线,并在说明里写清楚“这条边只在 60% 的样本里出现,置信度偏弱”。这比单次跑一版图后使劲解释要专业得多。

方向可信度标注同样重要。对 v 结构定向出来的边,我在图里用实心箭头;对 Meek 规则传播出来的边,我用空心箭头,代表“结构推断,非直接检验”。两者混在一张图里,业务方一眼能看到哪些方向是硬证据,哪些是软推断。我还常用一个小技巧:把数据里最强的共因变量先手动拿出来,单独跑一次 PC,再把它的预测值作为回归残差灌回去跑第二次,两次结果对比能明显暴露因果充分性假设被破坏的位置。

最近一个项目里,我靠这个技巧成功找到一个隐藏的批次效应变量——原始数据跑出来 A 与 B 高度相关,但残差版跑出来两者完全独立。教训是:PC 算法不是“喂数据出真相”的机器,它是“在假设前提下的结构探测器”。你越早承认它的边界,越能把它用出价值。希望帮到你,至少少走我当年翻车的那几步。

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

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

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

立即咨询