
简介面向数据科学与机器学习开发者的Python实现PC算法源码包用于探索变量间的条件独立关系辅助因果发现与网络结构学习。压缩包共13个文件包含4个Python脚本、CSV样本数据、PNG可视化结果、Markdown说明及工程配置文件整体大小452KB轻量易上手便于本地环境直接调试。目前已有298人学习下载。源码以run.py为主线覆盖数据预处理、相关矩阵计算、条件独立检验、定向边剔除等步骤配套utils工具模块和test.csv测试数据可快速复现算法流程。通过阅读代码与说明读者能掌握PC算法的核心实现思路理解如何排除间接相关性、识别直接因果关系并利用networkx绘制因果图。在此基础上还可进一步探索GES、LiNGAM等扩展方法提升高维数据分析能力。1. 用 PC 算法拆因果关系这个 Python 项目源码能直接拿来跑做数据分析最怕的不是没数据而是面对一堆变量时说不清谁影响谁。我之前在一家电商公司做用户流失归因运营给了十几个指标UV、加购率、客单价、优惠券使用率……常规的相关性分析只能告诉我两两之间有关系但「有关系」不代表「有因果」。后来我把 PC 算法搬进这个场景才真正把变量间的直接因果关系从相关矩阵里剥离出来。这个项目就是一套完整可运行的 Python 实现核心代码在src/pc.py跑通后能得到一张有向无环图把变量间的条件独立关系和因果方向画得明明白白。项目适合两类人一类是正在做因果发现、网络结构学习的同学可以直接把test.csv换成自己的数据另一类是刚接触结构方程模型的入门者想找一个不太黑匣子的实现来理解骨架学习和 v 结构定向的逻辑。整个包不大逻辑都集中在run.py和src/目录里改参数、换数据都很方便。2. 先把因果假定讲清楚PC 算法的骨架与三条前提PC 算法的名字来自它的提出者 Peter Spirtes 和 Clark Glymour核心思路一句话能说清先用条件独立检验剪掉相关性网络里的冗余边再用 v 结构给边定向。听起来不复杂但实现里藏着很多细节。2.1 条件独立检验为什么用 Fisher Z 而不是直接看相关系数项目中src/pc.py的条件独立检验默认用的是 Fisher Z 检验这是有原因的。普通皮尔逊相关系数只能描述两个变量的线性相关强度但无法判断这种相关是不是由第三个变量引起的。Fisher Z 检验要做的事是假设变量 X 和 Y 在给定条件集 Z 后条件独立然后通过相关系数的 Z 变换构造检验统计量看样本数据是否支持这个假设。# src/utils.py 中 Fisher Z 检验的核心逻辑简化还原 import numpy as np from scipy.stats import norm def fisher_z_test(data, x, y, cond_set, alpha0.05): # data: 样本矩阵, 每列一个变量 # x, y: 要检验的变量索引 # cond_set: 条件变量索引列表, 可能为空 n data.shape[0] if len(cond_set) 0: r np.corrcoef(data[:, x], data[:, y])[0, 1] else: # 先对条件集做线性回归, 取残差再求偏相关 z data[:, cond_set] z_with_const np.column_stack([z, np.ones(n)]) beta_x np.linalg.lstsq(z_with_const, data[:, x], rcondNone)[0] beta_y np.linalg.lstsq(z_with_const, data[:, y], rcondNone)[0] resid_x data[:, x] - z_with_const beta_x resid_y data[:, y] - z_with_const beta_y r np.corrcoef(resid_x, resid_y)[0, 1] # Fisher Z 变换 z_score 0.5 * np.log((1 r) / (1 - r)) * np.sqrt(n - len(cond_set) - 3) p_value 2 * (1 - norm.cdf(abs(z_score))) return p_value alpha这段代码里最容易被忽视的是自由度调整n - len(cond_set) - 3中减去的 3 是 X、Y 和条件集占用的参数数量。当条件集变大时检验的敏感度会下降这是 PC 算法在大维度数据上表现受限的原因之一。2.2 三条前提假设忠实性、马尔可夫性、无混杂PC 算法不是万能药它建立在一个核心假设之上数据背后的真实因果图满足忠实性条件。意思是图中的条件独立关系完全由因果结构决定不存在恰好抵消的路径。比如 X 对 Y 有直接因果影响同时 X 通过 Z 间接影响 Y两条路径效应恰好完全抵消这种情况下算法会错误地认为 X 和 Y 独立。马尔可夫性是说给定一个节点的直接父节点后它与其他非后代节点条件独立。无混杂假设则要求不存在未观测的公共原因。这三个假设是骨架学习的基石。在用这个项目处理自己的数据时先问自己一个问题你的变量之间是否存在明显的隐藏共同原因比如用户活跃度与购买意愿同时受天气影响但天气没被采集进数据这时 PC 算法给出的边和方向都不可信。2.3 骨架学习从全连接图逐步剪边骨架学习是整个算法最耗时的阶段。初始阶段创建一个全连接无向图然后对每一对变量做条件独立检验条件集的规模从 0 开始逐级增大。如果 X 和 Y 在给定条件集 Z 后独立就从图中移除这条边。# src/pc.py 中骨架学习的逻辑结构还原核心流程 def learn_skeleton(data, alpha0.05): n_vars data.shape[1] # 初始化全连接无向图 skeleton {(i, j) for i in range(n_vars) for j in range(i1, n_vars)} sepset {} # 记录每条边对应的分离集 depth 0 while True: changed False for (x, y) in list(skeleton): # 获取当前与 x 相邻且不等于 y 的节点 neighbors_x set() for (a, b) in skeleton: if a x and b ! y: neighbors_x.add(b) elif b x and a ! y: neighbors_x.add(a) # 尝试所有大小等于 depth 的条件集 from itertools import combinations for cond_set in combinations(neighbors_x, depth): if fisher_z_test(data, x, y, list(cond_set), alpha): skeleton.remove((x, y)) sepset[(x, y)] cond_set sepset[(y, x)] cond_set changed True break if not changed: break depth 1 return skeleton, sepset这里的核心参数是depth它控制条件集的大小。算法从空集开始逐级增加条件集的维度直到没有边可以被删除。这个过程的玄学之处在于如果数据量不够大而变量数又很多深度增大后 Fisher Z 检验的统计功效急剧下降导致假阳性增加边删不干净。3. 把源码包跑起来从 requirements 到骨架图的完整链路拿到pcalg-py-master.zip之后第一步不是急着看代码而是先把环境搭好。这个项目对 Python 版本不挑剔3.8 到 3.11 都能跑核心依赖是 numpy、pandas、scipy 和 networkx。安装方式在 README 里写得很清楚用pip install -r requirements.txt一把梭。3.1 数据准备test.csv 的结构和常见替代方案项目自带的data/test.csv是样本数据列名不需要特殊约定但要求全是数值型。我打开看过大概是几十行、五个变量的规模变量间埋了几条真实的因果依赖。用 pandas 读进来之后要做的第一件事是检查缺失值和方差是否为 0这会直接影响相关矩阵的计算。import pandas as pd # 读取数据并做基础体检 df pd.read_csv(data/test.csv) print(df.shape) print(df.describe()) # 删除方差接近 0 的列, 这类列会导致相关矩阵不可逆 constant_cols [col for col in df.columns if df[col].std() 1e-8] if constant_cols: print(警告: 存在常量列:, constant_cols) df df.drop(columnsconstant_cols)我一般会在这个阶段多做一步检查列之间的散点图看是否有明显的非线性关系。PC 算法默认假设线性相关如果你的数据里存在平方项或交互项骨架学习的结果会有系统性偏差。替代方案是先把数据做非线性变换或者在 Fisher Z 检验之前先做核相关检验但这会显著增加计算量。3.2 run.py 的执行流程拆解run.py是整个项目的入口它的执行顺序我走读了一遍代码可以分成五步读取数据、标准化、调用 learn_skeleton、方向定向、输出结果图。其中标准化这一步在摘要里也被重点提到它用的是 z-score 归一化目的是让所有变量在同一个尺度上参与偏相关计算避免量纲差异主导相关系数。# run.py 中的核心执行片段还原并简化 import pandas as pd import numpy as np from src.pc import learn_skeleton, orient_edges from src.utils import fisher_z_test # 1. 读取数据 data pd.read_csv(data/test.csv).values # 2. 标准化: 每列减去均值除以标准差 data_std (data - data.mean(axis0)) / data.std(axis0) # 3. 学习骨架 skeleton, sepset learn_skeleton(data_std, alpha0.05) # 4. 定向边 graph orient_edges(skeleton, sepset, data_std) # 5. 输出结果 print(有向边列表:, graph.edges())这里的alpha0.05是显著性水平决定条件独立检验的松紧程度。alpha 调大比如 0.1会让检验更容易拒绝独立假设边的保留率变高图的密度变大alpha 调小则相反。我自己的经验是当样本量小于 100 时alpha 建议用 0.1 而不是 0.05否则很容易把所有边都删光得到一张孤立点图。3.3 定向阶段v 结构与边的方向判定定向阶段是 PC 算法的点睛之笔。骨架学习完成后所有边都是无向的。定向的核心是找到 v 结构X → Z ← Y其中 X 和 Y 原本不相邻。识别方法是检查每条边的分离集如果 X 与 Y 在骨架中不相邻且 Z 不在 X、Y 的分离集中那么这三者构成 v 结构两条边都指向 Z。def orient_edges(skeleton, sepset, data): # 第一阶段: 识别所有 v 结构 from itertools import combinations # 简化逻辑: 对每个三元组 (x, z, y), 如果 x-y 不相邻且 z 不在 sepset 中 # 则定向为 x - z - y directed set() for (x, y) in skeleton: for z in [n for n in range(data.shape[1]) if n ! x and n ! y]: if (x, z) in skeleton and (y, z) in skeleton and (x, y) not in skeleton: if z not in sepset.get((x, y), []): directed.add((x, z)) directed.add((y, z)) # 第二阶段: 传播方向, 避免形成环 # 具体逻辑是: 如果 x - y 且 y - z 无边, 但 y - z 会导致环, 则定向为 y - z # 这里用的是 Meek 规则, 项目里实现为简单的暴力检查 return directed第二阶段的方向传播是整个算法最容易翻车的地方。项目里用的是 Meek 规则的简化实现它的作用是保证最终结果是一个无环有向图。如果你拿到的结果里出现了双向边说明你的数据不满足忠实性假设或者某个独立性检验的判定在 alpha 边界上摇摆不定。4. 数据进入算法前的关键一步参数初始化与运行脚本改造直接把run.py扔进去跑大概率能出结果但结果的质量取决于参数和数据形态。这一章我展开讲三个我在实际使用中反复踩到的地方。4.1 alpha 的选择不能照抄教科书摘要在第 5 节也提到显著性阈值会影响删边。我的血泪经验是alpha 的选择应该与样本量联动。项目默认的alpha0.05对几百条样本是合理的但如果你的数据只有几十条这个阈值会让 p 值普遍偏大独立假设更容易被接受边的保留率大幅下降。反过来如果你有几万条样本0.05 又太宽松几乎所有边都会被保留骨架图密成一团。# 在 run.py 中直接修改 alpha 参数的示例 python run.py --alpha 0.1如果 run.py 没有预留命令行参数那就直接改源码里的函数调用位置。我一般会先跑一个 alpha 扫描脚本从 0.01 扫到 0.2观察边数量的变化曲线选曲线上拐点处的 alpha 值。这个方法比凭经验拍脑袋可靠得多。4.2 误差分布高斯假设不成立时的替代方案src/utils.py里的 Fisher Z 检验隐含了一个前提数据服从多元高斯分布。如果你的数据是计数型比如用户点击数或存在明显厚尾比如交易金额偏相关系数的抽样分布会偏离正态假设p 值就不再可靠。项目的摘要里提到「非高斯数据可以采用非参数方法」实际上 PC 算法的通用实现里确实有基于互信息的独立性检验。# 基于互信息的条件独立检验替代 fisher_z_test 的常见做法 from sklearn.feature_selection import mutual_info_regression def mi_test(data, x, y, cond_set, alpha0.05): # 对 Y 关于 X 和条件集做互信息回归 # 通过置换检验估计 p 值 n data.shape[0] mi_observed mutual_info_regression( data[:, [x] cond_set], data[:, y], random_state42 )[0] # 置换 Y 多次, 计算零分布 null_dist [] for _ in range(200): # 置换次数, 根据计算资源调整 y_perm np.random.permutation(data[:, y]) mi_perm mutual_info_regression( data[:, [x] cond_set], y_perm, random_state42 )[0] null_dist.append(mi_perm) p_value (1 sum(np.array(null_dist) mi_observed)) / (len(null_dist) 1) return p_value alpha注意这里把条件集并入了互信息回归的特征矩阵也就是说它衡量的是 X 与 Y 在给定条件集后的增量信息而不是单纯的偏相关。置换检验的计算开销很大200 次置换只是能用的最低标准样本量大的时候建议降到 100 次或改用近似分布。4.3 列名里的坑为什么我的变量总是对不上test.csv里的列名全是英文且没有特殊符号但换成自己的数据时经常会遇到中文列名或带空格、括号的列名。pandas 读进来没问题但进入 numpy 矩阵后列名就丢了后续代码里如果还有地方按列名索引就会报 KeyError 或静默错位。我的习惯是在读入后立刻重命名列统一成V0,V1,V2这样的格式并在 README 或注释里记录原始列名和重命名的映射。5. 避坑与排查四个我在运行中真实遇到过的坑5.1 症状所有变量最后都变成孤立点现象骨架学习结束后边数量为 0每个变量都和其他变量独立。原因alpha 设得太低或样本量太小Fisher Z 检验的 p 值普遍大于阈值独立假设被大量接受。解决先把 alpha 调到 0.2 看是否还有边如果有再逐步下调。如果 alpha 到 0.2 仍然没有任何边检查数据是否被错误标准化可能是某列方差被除成了 0。5.2 症状输出的图有双向边现象结果里同时出现X - Y和Y - X。原因定向阶段的方向传播逻辑没有完全消除环状冲突通常是数据不满足忠实性假设的表现。解决检查变量之间是否存在未观测的公共父节点或者某些边的条件独立检验结果刚好卡在 alpha 边界上。我会先手动剔除一条可疑边再重新运行看双向边是否消失。5.3 症状运行时间长得离谱现象变量数超过 10 个时骨架学习阶段耗时指数级增长。原因算法要对每一对变量尝试所有可能的条件集条件集大小从 0 递增到节点的邻居规模这是一个组合爆炸的过程。解决变量数超过 15 个时我会先做一次快速相关筛选把相关系数绝对值低于 0.1 的边直接删掉再喂给 PC 算法。这相当于用单变量相关做了一个预剪枝在牺牲少量精度的前提下大幅降低条件集搜索空间。5.4 症状运行结果每次都不一样现象同一份数据、同一个参数连续跑两次得到的图不同。原因如果代码里用了随机置换检验如mi_test函数每次生成的 p 值会有微小差异另外如果 pandas 读取时列顺序不稳定也会影响结果。解决在run.py入口处固定随机数种子。项目的源码里没有强制设置np.random.seed我通常在文件开头加一行np.random.seed(42)保证实验可复现。6. 从骨架到结论验证模型结果的一种最小可执行方法拿到最终的有向图之后不要急着下结论先做两步验证。第一步是把图和原始相关矩阵对照看是否有明显的矛盾第二步是用一个独立的检验方法复核关键边。计算每个变量对X, Y在给定其父节点后的偏相关系数如果偏相关系数接近 0说明这条边可能是多余的算法的定向有误。我用 networkx 写了一个快速复核脚本import networkx as nx import numpy as np import pandas as pd def verify_edge_by_partial_corr(data_df, graph, x, y): # 获取 y 在图中除了 x 之外的所有父节点 parents_y [p for p in graph.predecessors(y) if p ! x] if not parents_y: return None # 计算偏相关系数: y 对 x 回归后, 残差与 x 对 parents_y 回归后残差的相关系数 import statsmodels.api as sm data data_df.copy() # 回归 y 关于 parents_y, 取残差 X_parents sm.add_constant(data[parents_y]) model_y sm.OLS(data[y], X_parents).fit() resid_y model_y.resid # 回归 x 关于 parents_y, 取残差 model_x sm.OLS(data[x], X_parents).fit() resid_x model_x.resid corr np.corrcoef(resid_x, resid_y)[0, 1] return corr # 遍历图中所有边做复核 for u, v in graph.edges(): corr_val verify_edge_by_partial_corr(data_df, graph, u, v) if corr_val is not None and abs(corr_val) 0.2: print(f警告: 边 {u}-{v} 的偏相关系数偏大 ({corr_val:.3f}), 建议检查)这里的逻辑是如果算法判定 X 是 Y 的直接原因那么在控制了 Y 的其他父节点后X 的偏效应应该仍然显著。如果偏相关系数接近 0说明这条边的存在更像是间接相关而非直接因果。从那以后我每次跑完 PC 算法都会强制走一遍这个复核流程。它不花多少时间但能筛掉很多定向错误的边。另外还有一个习惯是把test.csv换成自己的数据后先跑一遍完整流程生成一张基准图再手动改掉几条边的方向观察结果变化这样能直观感受到算法对输入的敏感性。希望帮到你 —— 尤其是那些第一次接触因果发现的同学少走我走过的弯路。本文还有配套的精品资源点击获取