ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PC算法Python实战:从条件独立到因果发现完整实现

PC算法Python实战:从条件独立到因果发现完整实现

简介:面向数据科学与机器学习开发者的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, alpha=0.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], rcond=None)[0] beta_y = np.linalg.lstsq(z_with_const, data[:, y], rcond=None)[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, alpha=0.05): n_vars = data.shape[1] # 初始化全连接无向图 skeleton = {(i, j) for i in range(n_vars) for j in range(i+1, 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(columns=constant_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(axis=0)) / data.std(axis=0) # 3. 学习骨架 skeleton, sepset = learn_skeleton(data_std, alpha=0.05) # 4. 定向边 graph = orient_edges(skeleton, sepset, data_std) # 5. 输出结果 print("有向边列表:", graph.edges())

这里的alpha=0.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 的选择应该与样本量联动。项目默认的alpha=0.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, alpha=0.05): # 对 Y 关于 X 和条件集做互信息回归 # 通过置换检验估计 p 值 n = data.shape[0] mi_observed = mutual_info_regression( data[:, [x] + cond_set], data[:, y], random_state=42 )[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_state=42 )[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换成自己的数据后,先跑一遍完整流程生成一张基准图,再手动改掉几条边的方向观察结果变化,这样能直观感受到算法对输入的敏感性。希望帮到你 —— 尤其是那些第一次接触因果发现的同学,少走我走过的弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进