简介:针对时间序列因果推断中基于约束与基于噪声方法难以融合、复现门槛高的问题,文档完整实现了NBCB(噪声优先再约束)与CBNB(约束优先再噪声)两套混合因果发现算法流程,并给出线性动态结构因果模型数据生成、VarLiNGAM因果顺序搜索及PCMCI+条件独立性剪枝等核心模块的可运行Python代码与逐段注释。内容面向从事因果推断研究的科研人员、数据科学家及需要将混合因果发现落实到时间序列项目中的开发者,既可帮助读代码理解算法数学逻辑,也可直接移植用于实验或分析。压缩包共1个docx正文文件、约25KB,集中封装了环境配置命令、依赖安装清单、代码注释、结果解读和调优思路;文档还延伸探讨了复杂条件独立性检测、并行化加速、非线性因果处理及隐含混淆因素应对等扩展方法。已有71人学习浏览,适合希望快速掌握NBCB/CBNB混合算法实现细节并开展扩展研究的读者。
1. 为什么约束派和噪声派在时序数据上都会“半翻车”:混合才是工程正解
当两列时间序列放到你桌上,第一个问题永远是“谁影响了谁”。直接看交叉相关图,滞后十几阶的相关系数峰值会让你产生“好像互为因果”的错觉;直接跑 PC 算法,输出是一堆无向边,方向得靠人猜;直接跑 LiNGAM,方向倒是给了,但遇到同期相关或者轻微的非平稳,结果就敢乱指。时间序列因果发现绕不开这个矛盾:约束方法擅长剪枝,噪声方法擅长定向,单一方法都有半只脚踩在坑里。
把两者串成一条流水线,让约束先裁出骨架、噪声再定出箭头,是我在项目里验证过最稳的做法。这篇笔记不绕理论,直接讲清楚混合算法为什么能互补、如何用可运行的代码落地,以及参数和踩坑记录。适合做归因分析、根因定位,或者想把因果图喂给下游预测和异常检测模型的工程师,代码可以直接复制跑。
2. 约束与噪声两条主线:谁负责剪枝,谁负责定向
2.1 约束派:条件独立性检验与“骨架优先”哲学
约束方法的核心逻辑是“如果 X 和 Y 在给定 Z 的条件下独立,那么 X 和 Y 之间就没有直接因果边”。这句话翻译成工程动作,就是做条件独立性检验(CIT)。PC 算法、FCI 算法以及后来专为时序改造的 PCMCI,都属于这个流派。
条件独立性检验本身有几种常见实现:线性高斯场景用偏相关检验;变量关系单调但非线性时用秩相关;完全非线性时用 HSIC 或者距离相关。选择哪一个是前置条件,直接影响骨架的稀疏程度。时序数据里有个天然的“时序约束”可以利用:原因是不能晚于结果的。也就是说,过去时刻的节点永远只能指向现在或未来的节点,同一个变量在不同滞后之间也存在确定的先后关系。这个先验让约束派的搜索空间砍掉一大半,这也是时序因果发现和静态因果发现最大的区别。
约束派对工程最大的价值是,不需要假设因果函数的具体形式,也能把大量无关边剪掉。缺点是它的输出只是无向骨架,或者最多是一部分有向边。原因在于马尔可夫等价类:当两个变量之间只有一条直接路径,并且没有副产物扰动时,X→Y 和 Y→X 在条件独立性上完全不可区分。约束派把“方向”这笔账留给了其他方法,这恰恰是噪声派登场的理由。
在时序场景里,约束派还有一个隐性风险:滞后阶数就是隐式超参。如果滞后窗口选小了,远处效应根本不在条件集里,骨架会漏边;窗口选大了,条件集维度暴涨,偏相关检验的统计功效急剧下降,骨架又会多出不少虚假边。所以“先剪枝”不等于“多剪枝”,这也是我在第 4 章花整章讲参数的原因。
2.2 噪声派:残差独立性与“直接定向”的代价
噪声方法的出发点完全不同,它直接回答“方向是什么”。最早的代表是 LiNGAM,假设因果机制是线性函数加上非高斯的独立噪声;后续发展出非线性加性噪声模型(ANM)和非线性非加性的 PNL 模型。
这些模型的共同识别原理是:如果真实方向是 X→Y,设 Y = f(X) + e,那么残差 e 应该与 X 独立;反过来用 Y 回归 X 得到的残差,与 Y 之间会残留依赖关系。谁方向正确,谁的残差就更独立。就这么一个简单的对比,就能把箭头的方向定下来。
这条路的代价是“噪声介入方式”的强假设。加性噪声模型要求噪声是加性的,且噪声与原因变量独立。如果真实机制是乘性噪声,比如 Y = X · e,残差独立性假设被违反,定向就会出错。另一个代价是,函数拟合这一步如果做糙了,残差里全是模型没吃进去的形态,独立性检验的结果就没有任何参考意义。
这里有个常见误区:很多人觉得既然有深度模型了,用 LSTM 和 Transformer 做时间序列预测不也能看出“谁影响谁”吗?不行。预测模型学习的是条件概率分布,它只在给定输入下把输出均值拟合得尽量小,不检验任何独立性约束。预测准不代表因果方向对,两个指标根本不在一个评价体系里。噪声派的核心资产是残差独立性,而不是拟合精度。
2.3 混合逻辑:串行分工为什么比单一方法更稳
把两派混合起来,工程上有三种常见组织方式。第一种是串行两阶段:先让约束派跑出骨架,再让噪声派只对骨架里的边定向。第二种是并行打分:对每一对变量,同时计算约束派的独立性 p 值和噪声派的残差独立性 p 值,然后按某种加权方式排序。第三种是迭代精化:跑完一轮定向后,把已定向的边反馈回约束派,缩小条件集范围再跑一轮。
我在这篇笔记里落地的是串行两阶段。原因很实际:真实项目的目标图节点数通常在十到几十这个量级,骨架边的数量远小于全连接数,噪声定向只需要在候选集上做,计算量省几个量级,而且多重检验的压力也跟着下降。串行分工的核心思想是“让约束派做它擅长的砍边,让噪声派做它擅长的定箭头”,两者不抢活。
这个分工还有一个更深的统计意义。约束派的剪枝可以控制第 I 类错误,把大量假边剔除;噪声派在剩下的候选边里做定向,这时只需要在两个方向之间做选择,比较次数少,判错率低。如果你先上噪声派,它对每条边都要在两方向之间选一次,全图几十条候选边跑下来,误定向累计概率会高得让你怀疑人生。骨架优先,这就是混合和单一方法的本质差别。
3. 混合算法跑通全流程:从模拟数据到带方向的因果图
下面这个实现是完整可运行的,我用一个带滞后效应和同期混杂的二元系统做演示。整个流程分成三段:先造数据,再跑约束骨架,最后做噪声定向。
3.1 数据准备:构造一个带滞后效应的可复现时序系统
模拟系统的设计要覆盖两个现实因素:一是存在滞后因果,二是存在影响两变量的同期公共驱动。这样才能在后面的输出里同时看到“干净定向的边”和“需要二次核验的同期边”。
import numpy as np import pandas as pd def gen_system(n=1500, ar=0.6, link=0.5, seed=7): rng = np.random.default_rng(seed) x = np.zeros(n) y = np.zeros(n) # 非高斯公共驱动:t 分布,自由度 5 u = rng.standard_t(df=5, size=n) for t in range(2, n): x[t] = ar * x[t-1] + 0.3 * u[t] + rng.normal(0, 0.2) y[t] = 0.8 * y[t-1] + link * x[t-1] + 0.2 * u[t] + rng.normal(0, 0.2) return pd.DataFrame({"x": x, "y": y}) df = gen_system(n=1500, seed=7) print(df.head())这套生成机制里,x 存在一阶自回归,系数 ar=0.6;y 存在一阶自回归,系数 0.8;关键因果是 link=0.5 处的 x[t-1]→y[t]。两个方程都受到同期公共驱动 u[t] 的影响,且 u 服从 t 分布,属于非高斯噪声。之所以把噪声设成非高斯,是为了给后面的噪声定向阶段提供识别能力:加性噪声模型在非高斯条件下方向判别更有效。如果你想看高斯噪声下算法的表现,把 rng.standard_t 换成 rng.normal(0, 1) 即可,结果会明显变差。
3.2 约束阶段:偏相关与 Fisher z 变换裁出无向骨架
进入骨架学习之前,先把原始序列展开成“滞后特征矩阵”。这里把当前时刻和滞后 1、2 阶都作为节点,比如 xlag0 表示时刻 t 的 x,xlag1 表示时刻 t-1 的 x。这样“x 的上一时刻影响 y 的当前时刻”这条因果就能转成图上 xlag1 → ylag0 的边。
from scipy import stats from itertools import combinations def build_lag_features(df, p=2): out, names = [], [] for name in df.columns: for lag in range(0, p + 1): out.append(df[name].shift(lag)) names.append(f"{name}lag{lag}") L = pd.concat(out, axis=1).dropna() L.columns = names return L def partial_corr(X, i, j, Z=()): n = X.shape[0] def _rank_resid(v, covs): rv = stats.rankdata(v) if not covs: return rv A = np.column_stack([np.ones(n)] + [stats.rankdata(c) for c in covs]) beta, *_ = np.linalg.lstsq(A, rv, rcond=None) return rv - A @ beta ri = _rank_resid(X.iloc[:, i].values, [X.iloc[:, k].values for k in Z]) rj = _rank_resid(X.iloc[:, j].values, [X.iloc[:, k].values for k in Z]) r = np.corrcoef(ri, rj)[0, 1] z = np.arctanh(np.clip(r, -0.9999, 0.9999)) sigma = 1.0 / np.sqrt(n - len(Z) - 3.0) pval = 2.0 * stats.norm.cdf(-np.abs(z / sigma)) return r, pval def learn_skeleton(X, alpha=0.01, max_cond=2): m = X.shape[1] edges = {(i, j) for i in range(m) for j in range(i + 1, m)} for cond_k in range(max_cond + 1): pruned = [] for i, j in edges: others = [k for k in range(m) if k not in (i, j)] for Z in combinations(others, min(cond_k, len(others))): _, pval = partial_corr(X, i, j, Z) if pval > alpha: pruned.append((i, j)) break edges -= set(pruned) if not edges: break return edgespartial_corr 里做了两层关键处理。第一层是把所有原始值换成秩,用秩做回归和相关系数,这一步对单调非线性关系和边缘离群点都有一定抵抗力,不依赖“变量必须线性相关”的高斯假设。第二层是用 Fisher z 变换把样本相关系数转成近似正态分布下的 z 统计量,再用正态分布尾概率得到 p 值,用来和 alpha 阈值比较。
learn_skeleton 的循环里,“用条件集逐阶变大做检验”这一设计是 PC 思想的工程近似。教科书 PC 要求边必须通过所有条件集检验才保留,我这里只要在某一个条件集下 p 值大于 alpha 就判定条件独立并剪掉,这是保守剪枝策略,会留下一部分“确实不容易解释掉”的边。好处是骨架不会太稀疏,代价是后续定向阶段要多处理几条边。max_cond 控制条件集最大维度,我建议在 2 到 3 之间,再大样本量不够时检验本身就开始失真。
3.3 噪声阶段:残差独立性检验为骨架边定向
骨架边拿到后,先不用噪声,直接应用时序约束:同一个变量内部,滞后大的指向滞后小的;跨变量时,滞后大的也指向滞后小的。只有那些“同时刻跨变量”的边没法靠时序约束定方向,才请噪声派出场。
def dcor(x, y): n = len(x) dx = np.abs(x[:, None] - x[None, :]) dy = np.abs(y[:, None] - y[None, :]) mx, my = dx.mean(), dy.mean() A = dx - dx.mean(axis=0) - dx.mean(axis=1) + mx B = dy - dy.mean(axis=0) - dy.mean(axis=1) + my dcov = (A * B).sum() / (n * n) dv = np.sqrt((A**2).sum() / (n * n)) * np.sqrt((B**2).sum() / (n * n)) return dcov / dv if dv > 0 else 0.0 def orient_edge(X, i, j): a = X.iloc[:, i].values.astype(float) b = X.iloc[:, j].values.astype(float) beta = np.polyfit(a, b, 1) res_ij = b - np.polyval(beta, a) # i -> j 的残差 beta2 = np.polyfit(b, a, 1) res_ji = a - np.polyval(beta2, b) # j -> i 的残差 s_ij = dcor(a, res_ij) s_ji = dcor(b, res_ji) return s_ij, s_ji def orient_skeleton(X, edges, tags): directed = [] for i, j in edges: vi, li = tags[i] vj, lj = tags[j] if vi == vj: directed.append((i, j) if li < lj else (j, i)) elif li != lj: directed.append((i, j) if li > lj else (j, i)) else: s_ij, s_ji = orient_edge(X, i, j) directed.append((i, j) if s_ij < s_ji else (j, i)) return directed lag_df = build_lag_features(df, p=2) edges = learn_skeleton(lag_df, alpha=0.01, max_cond=2) tags = {} for col in lag_df.columns: name, lag = col.split("lag") tags[col] = (name, int(lag)) directed = orient_skeleton(lag_df, edges, tags) for i, j in sorted(directed): print(lag_df.columns[i], "->", lag_df.columns[j])dcor 检验用的是距离相关,它比普通皮尔逊相关更能捕捉非线性残差依赖。计算原理是把变量成对距离矩阵做去均值处理,再算两个去均值距离矩阵的内积归一化值。距离相关为 0 严格等价于独立,这一点在理论上比相关系数只捕捉线性依赖强很多。代码里两个方向的残差分别算一个距离相关分数,分数小的一方说明残差与“原因”独立程度更高,方向就判给它。
预期输出里应该有 xlag1 → ylag0 这条真因果。需要注意,公共驱动 u 的存在会让 xlag0 和 ylag0 之间出现一条同期边,噪声定向阶段未必能给出稳定方向,因为同期公共驱动意味着两个方向拟合出来的残差都与对侧变量相关,这种情况下小分数不一定可靠。这不是 bug,而是混合算法必须正视的局限,第 5 章的坑和最后一章的置换检验都围绕这个现象展开。
4. 四个必调参数:alpha、滞后阶数、独立性检验与样本量
参数调整是本方案最容易被低估的部分。我见过有人把 alpha 从 0.01 调到 0.001,骨架直接稀疏成一条边都没剩下;也有人把滞后阶数从 2 加到 8,条件集维度爆炸,所有 p 值都趋近于 0,最后骨架密到没法看。下面四个参数是我每换一个数据集都要重调一遍的。
4.1 alpha 阈值:控制骨架密度的总开关
alpha 是条件独立性检验的显著性阈值,直接决定骨架的稀疏程度。alpha 越小,剪枝约严格,保留的边越少。节点数在 10 左右的图,0.01 是个稳妥起点;节点数更多时我会放宽到 0.05,因为多重检验会让真实边更容易被误删;如果目标图很稀疏,可以压到 0.001,但要注意样本量低于 500 时 alpha 太小的后果是骨架剩不下几条有效边。
4.2 滞后阶数 p:时序窗口的量程与漏边风险
build_lag_features 里的 p 就是因果窗口的“量程”,它决定你能观察到多远的因果。p 太小,滞后效应落在窗口外,骨架会漏边;p 太大,特征矩阵维度变大,条件集组合数暴涨,偏相关检验的功效下降,虚假边跟着来。
我一般先用 VAR 的 AIC/BIC 选一个候选阶数,再结合业务上的物理周期做微调。例如日粒度数据、预期效应在 3 天内的,从 p=3 开始;之后看骨架结构是否稳定。如果加一阶后骨架多出十几条“看起来没道理”的边,就退回小一阶。这个操作没有玄学,就是一个稳健性检查。
4.3 独立性检验三件套:相关、秩相关还是距离相关
骨架阶段我默认用秩变换后的偏相关,它比线性偏相关更稳健,但本质上还是单调关联。如果你怀疑机制存在非单调关系,比如先抑制后促进,偏相关就不够用了,要换成 HSIC 或距离相关做条件独立性。距离相关在偏相关里的改造不复杂,把 partial_corr 里的秩相关系数换成 dcor 即可,代价是计算量从 O(n) 涨到 O(n²)。
定向阶段的残差独立性检验同理。线性残差加秩相关适合弱非线性场景,样条或高斯过程拟合残差加距离相关适合强非线性场景。我的经验是:先跑线性版本看方向比分差,比分差大于 1.5 时结论可信;接近 1 时换非线性拟合再跑一次。
4.4 样本量纪律:小样本上别硬跑非线性检验
这是最容易被忽视的一条。距离相关检验和非线性残差拟合在高维条件下收敛慢,样本量低于 500 时,我基本不建议对同期边做噪声定向。300 个样本以内,老实一点,只保留跨滞后边,同期边全部标记为“未定向”。另外,统计功效和样本量不是线性关系,在长尾噪声下尤其明显。t 分布自由度小于 5 时,极端值会让距离协方差估计的方差变得很大,这时候建议先对序列做 winsorize 截尾。
| 参数 | 默认值 | 作用 | 典型调整方法 | 调坏的信号 |
|---|---|---|---|---|
| alpha | 0.01 | 剪枝严格度 | 节点多放宽到 0.05,期望稀疏压到 0.001 | 骨架全空或密成完整图 |
| p 滞后阶数 | 2 | 因果窗口量程 | AIC/BIC 选阶,业务周期复核 | 增一阶多出大量无意义边 |
| max_cond | 2 | 条件集最大维度 | 样本量大可加到 3 | p 值集体趋近 0 或 1 |
| 定向检验 | dcor | 残差独立性度量 | 弱非线性用秩相关,强非线性用 HSIC | 两方向分数比接近 1 |
提示:每次换数据,alpha 和 p 必须一起调。单独调 alpha 而不动 p,很容易把漏边误判成剪枝成功,这一步没有后悔药。
5. 混合算法常见的五个坑与排查记录
下面五条全部来自真实项目里的“现象 → 原因 → 解决”复盘,篇幅不长但每条都值得收藏。
5.1 非平稳序列跑出“伪骨架”
现象:骨架图几乎全连通,所有变量之间都有边,而且 p 值小到离谱。原因:非平稳序列的确定性趋势和单位根会让两个完全无关的序列表现出高度相关,约束派的偏相关检验把这种伪相关当天经地义。解决:先对原始序列做平稳性检验,ADF 检验通不过就做一阶差分或对收益率做变换,差分后重新跑骨架。时序因果发现里有一条不成文的规定:先平稳,再谈因果。趋势本身就是一种混淆,不拆掉它所有检验都没意义。
5.2 滞后阶数设太小,弱因果链路断了
现象:已知 a 滞后三期影响 b,结果骨架里根本没有 a 和 b 之间的边。原因:p 阶数不够,特征矩阵里压根没有 lag3 这个节点,后面的定向阶段当然看不到任何候选。解决:建滞后特征之前先用偏自相关函数看截尾位置,如果 ACF 在 lag4 还有尖峰,p 至少设到 4;同时把数据按天级、支付链路等业务周期分层,先粗后细验证。漏边比多边更致命,多边还能靠定向阶段二次确认,漏边是无声的失败。
5.3 两边残差都“显著相关”,方向悬而未决
现象:orient_edge 返回的两个分数都明显大于 0,比分接近 1,方向怎么判都不踏实。原因:线性回归没有吃掉真实函数形态,残差里残留非线性结构,导致两个方向的距离相关都偏大;或者机制本身存在双向交互,正向反向各有独立成分。解决:先换非线性拟合,用三次样条或核脊回归替换 polyfit,再跑距离相关;如果换完还是难分高下,就把这条边标记为“双向/不可识别”,不要让下游模型承担错误方向带来的偏差。
5.4 同期公共驱动让噪声定向失效
现象:定向阶段对 xlag0 和 ylag0 的同期边给出了稳定方向,但换一个随机种子结果方向就反转。原因:两个变量同时受一个不可观测的公共因子影响时,约束派无法通过条件集把它消除,噪声派的残差独立性也不成立,因为共享成分在两边都留下了依赖。解决:把同期边列入“需进一步验证”列表,用第 6 章的置换检验打分;有条件的补充代理变量或引入面板结构。这是两派算法共同的黑匣子,没有什么锦囊妙计能绕过,能做的是不给错误方向高置信度。
5.5 离群点把偏相关和残差估计一起带偏
现象:骨架里出现一些完全违背业务直觉的边,定向分数大幅波动。原因:最小二乘残差对离群点极其敏感,单个极端值可以压过几百个正常样本的贡献,偏相关里用的秩变换虽然缓解了一部分,但对同时出现在多个变量上的联合离群点效果有限。解决:对序列做 winsorize,把上下 1% 分位外的值压到分位值;或者把秩变换改成稳健秩变换,用中位数替代均值做中心化。跑完一遍之后把输出边和业务逻辑对照,发现明显离谱的边,优先回来看数据质量,而不是调 alpha。
6. 进阶验证:置换检验给每条因果边打分
定向阶段给出的是确定性判断,但真实项目里我需要一个置信度,这样才能决定哪些边可以直接喂给下游模型。做法是对每条已定向边做置换检验,原假设是“残差与原因变量独立”,把残差随机打乱多次,重算距离相关,看观测值落在置换分布里的位置。
def perm_score(x, resid, n_perm=500, seed=1): rng = np.random.default_rng(seed) obs = dcor(x, resid) cnt = 0 for _ in range(n_perm): resid_perm = rng.permutation(resid) if dcor(x, resid_perm) >= obs: cnt += 1 pval = (1.0 + cnt) / (1.0 + n_perm) return obs, pval def verify_edge(x, y, n_perm=500): beta = np.polyfit(x, y, 1) resid = y - np.polyval(beta, x) obs, pval = perm_score(x, resid, n_perm=n_perm) return obs, pval用法是:对每条已经定向的边,取原因变量列和结果变量列,算出残差,再调用 verify_edge。p 值越小,说明观测到的残差依赖越不可能是偶然出现的,也就是当前方向越可疑;反过来,p 值越大,残差独立性越可信。多个边同时验证时,对 p 值做 Benjamini-Hochberg 校正,控制错误发现率,避免几十条边里随机蹦出几个“显著异常”。
我给一条边打分后会习惯性地问三个问题:分数是否超过 0.05 阈值,方向比分是否超过 1.5,边界是否存在只有一两个点支撑的情况。三个条件都满足,这条边才敢进落地模型。如果置换检验怀疑方向,但业务逻辑上方向明显,优先检查函数拟合是不是太糙——很多时候换个样条残差结果就正常了。这套混合算法的边界很清楚:样本量够、滞后窗口对、噪声近似加性的场景里,它比纯 PC 和纯 LiNGAM 都稳当;但遇到同期混杂和强反馈系统,任何因果发现都只能在“未定向”上蹲着,别硬给结论。希望这套流程和踩坑记录能帮你在自己的时序数据上少走两趟弯路。
本文还有配套的精品资源,点击获取