概述与定位
一句话定位: 因果发现算法实现中存在多个常见陷阱,本笔记系统总结典型bug和超参数调优策略。
** prerequisites **
- 因果发现算法基础(PC、GES、NOTEARS)
- 数值优化基础
- Python调试经验
- 线性代数基础
核心直觉
因果发现算法的实现比看起来困难得多——不仅涉及核心算法逻辑,还涉及大量数值细节。忽略这些数值细节会导致:(1)结果违反DAG约束;(2)算法不收敛;(3)假阳性/假阴性率异常高。
为什么需要系统调试的核心直觉:因果发现算法是敏感性较高的算法——数据分布的变化、超参数的选择、随机种子的设置都会显著影响结果。系统化的调试流程和超参数范围知识,是成功复现论文结果的关键。
调参策略的核心直觉:不同算法的超参数有不同物理含义——alpha控制CI测试的严格程度,lambda控制NOTEARS的稀疏性,max_iter控制优化收敛性。理解这些物理含义,才能有针对性地调参。
常见Bug分类
1. DAG约束相关
Bug: NOTEARS返回的W违反DAG约束
原因: 优化未收敛,或约束惩罚系数太小
诊断:
from scipy.linalg import expm
def check_dag(W):
"""检查W是否对应DAG"""
n = W.shape[0]
M = expm(W * W) # matrix exponential
h = np.trace(M) - n
return h, h < 1e-6
h, is_dag = check_dag(W)
print(f"DAG constraint value: {h}, Is DAG: {is_dag}")解决方案:
- 增加迭代次数
max_iter - 增加约束惩罚系数
- 使用更小的学习率
- 添加W的shrinkage:
W = W * (1 - shrinkage)
Bug: PC算法输出包含循环边
原因: 骨架学习和方向确定步骤之间不一致
诊断:
import networkx as nx
def check_cycles(G):
"""检查图是否有循环"""
try:
nx.find_cycle(G, directed=True)
return True # 有循环
except nx.NetworkXNoCycle:
return False # 无循环解决方案: 使用拓扑排序验证,或在方向确定后增加循环检测
2. CI测试相关
Bug: PC算法遗漏真边(假阴性)
原因: alpha太小或样本量不足
诊断:
# 比较不同alpha的结果
for alpha in [0.01, 0.05, 0.1, 0.2]:
skeleton = pc_skeleton(data, alpha=alpha)
print(f"alpha={alpha}, n_edges={count_edges(skeleton)}")解决方案:
- 放宽alpha: 0.05 → 0.1
- 增加样本量
- 使用更鲁棒的CI测试(GP-based)
Bug: PC算法产生过多假边(假阳性)
原因: alpha太大或CI测试不准确
诊断:
# 比较骨架和ground truth
shd = structural_hamming_distance(skeleton, true_dag)
print(f"SHD: {shd}")解决方案:
- 收紧alpha: 0.1 → 0.01
- 使用Fisher’s Z变换进行CI测试
- 增加条件集大小的上限
3. 数值稳定性相关
Bug: 矩阵求逆数值不稳定
原因: 协方差矩阵接近奇异
诊断:
# 检查条件数
cond = np.linalg.cond(cov_matrix)
print(f"Condition number: {cond}")
if cond > 1e10:
print("Warning: Matrix is nearly singular")解决方案:
# 添加正则化
cov_reg = cov_matrix + 1e-6 * np.eye(n)
cov_inv = np.linalg.inv(cov_reg)Bug: NOTEARS梯度爆炸
原因: 学习率太大
诊断:
# 监控W的范数
W_norm = np.linalg.norm(W)
print(f"W norm: {W_norm}")
if np.isnan(W_norm) or W_norm > 1e10:
print("Warning: Gradient explosion")解决方案:
# 使用梯度裁剪
grad = np.clip(grad, -1.0, 1.0)
# 或降低学习率
lr = 0.001 # 降低到 0.00014. 数据相关
Bug: 不同数据规模下算法表现差异大
原因: 算法对数据量敏感度不同
诊断:
# 测试不同数据规模
for n_samples in [100, 500, 1000, 5000]:
data = generate_synthetic(n_samples=n_samples)
skel = pc_skeleton(data)
shd = evaluate(skel, true_dag)
print(f"n={n_samples}, SHD={shd}")解决方案:
- PC算法: 至少需要
样本 - NOTEARS: 样本量越大越稳定
- GES: 对数据量相对鲁棒
超参数调优指南
NOTEARS调参
| 参数 | 范围 | 物理含义 | 调参建议 |
|---|---|---|---|
| lambda1 | 0.01-1.0 | L1正则强度,控制稀疏性 | 从0.1开始,逐步减小 |
| max_iter | 100-10000 | 最大迭代次数 | 设置5000保证收敛 |
| w_threshold | 0.0-0.3 | 边阈值,控制稀疏性 | 从0.2开始调 |
| h_tolerance | 1e-6-1e-3 | DAG约束容差 | 通常默认1e-6即可 |
GES调参
| 参数 | 范围 | 物理含义 | 调参建议 |
|---|---|---|---|
| scoring_func | BIC/MDL | 评分函数 | 通常用BIC |
| max_sz | 3-5 | 最大父节点数 | 从3开始,太大计算慢 |
| epsilon | 1e-8-1e-4 | 插入删除阈值 | 影响搜索精度 |
PC算法调参
| 参数 | 范围 | 物理含义 | 调参建议 |
|---|---|---|---|
| alpha | 0.01-0.2 | CI测试显著性水平 | 从0.05开始 |
| indep_test | ’fisherz’/‘gp’ | CI测试方法 | gp更鲁棒但更慢 |
| max_cond_set | 3-10 | 最大条件集大小 | 太大会组合爆炸 |
调试流程
标准调试流程
1. 数据验证
- 检查数据是否包含缺失值
- 检查数据方差是否接近零
- 检查数据是否标准化
2. 随机种子验证
- 设置所有随机种子
- 多次运行检查结果稳定性
3. 超参数扫描
- 使用默认超参数运行
- 在推荐范围内扫描
- 检查结果是否合理
4. 与baseline比较
- 与gcastle/bnlearn实现比较
- 与ground truth比较
5. 梯度检查(如适用)
- 使用数值梯度验证分析梯度
- 检查梯度是否合理
复现注意事项
关键检查点
- 数据预处理: 标准化通常必要,但需记录方法
- 随机种子: 所有随机操作都需要设置种子
- 结果验证: 检查结果是否满足DAG约束
- 性能基线: 与成熟库的结果对比
常见陷阱
- 遗漏随机种子: 导致结果不可复现
- alpha选择不当: 显著影响PC算法结果
- lambda选择不当: NOTEARS对lambda敏感
- 数据泄露: 训练测试集划分不当
稳定性测试
def stability_test(algorithm, data, n_runs=10):
"""测试算法稳定性"""
results = []
for seed in range(n_runs):
np.random.seed(seed)
result = algorithm(data)
results.append(result)
# 计算结果的一致性
consensus = compute_consensus(results)
return consensus本章要点总结
- DAG约束违反是NOTEARS最常见的问题
- CI测试的alpha显著影响PC算法结果
- 数值稳定性是实现质量的关键指标
- 矩阵条件数检查防止求逆不稳定
- 梯度裁剪防止NOTEARS梯度爆炸
- 超参数范围因算法而异,需针对性调优
- 多次运行验证算法稳定性
- 与成熟库对比验证实现正确性
- 数据预处理标准化需记录方法
- 调试流程应系统化逐步排查