最近我在整理结构学习这块的资料时又把 DAG-GNN 从头到尾认真看了一遍。很多人对它的第一印象就是“给结构学习套了个图神经网络的壳”这个说法不算全错但会让人忽略它真正的价值。DAG-GNN 解决的核心问题是给定一批观测数据如何推断出变量之间隐藏的有向无环依赖关系也就是我们常说的 DAG 结构学习。它把这个问题放进变分自编码器的框架里用图神经网络同时承担编码、解码和结构表达最后输出一张带权重的邻接矩阵作为候选因果图。如果你做因果发现、贝叶斯网络结构学习或者想从横截面数据里挖出特征之间的依赖方向这篇工作都值得认真对待。本文我会从问题本身讲起再把 DAG-GNN 的建模思路、核心公式、训练逻辑、复现细节和落地经验逐段拆开尽量把那些论文里不会写的坑也一起说清楚。1. 结构学习的卡点组合搜索和不可微约束1.1 我们要学的“图”到底是什么假设手里有一批观测数据总共 n 个样本每个样本有 d 个变量通常表示成一个矩阵 X。DAG 结构学习的任务就是学一个加权邻接矩阵 A其中 A[i][j] 非零表示变量 j 对变量 i 存在直接依赖关系而且所有这样的依赖关系放在一起不能形成有向环。要注意这里的“依赖”不一定等于严格意义上的因果但它是因果发现的重要前置。我习惯把它叫“候选因果图”因为它给出了变量之间可能的方向关系后续是否要用“因果”这个词还取决于你手里有没有干预数据、领域假设是否成立等问题。这类问题在很多场景都会出现基因调控网络构建、供应链故障根因定位、风控特征关系梳理、推荐系统里的用户意图路径建模。说白了只要你想知道“是谁在影响谁”而不是单纯地预测“谁是什么”结构学习就有用武之地。1.2 为什么这个任务长期以来很难做DAG 结构学习难在哪里我拆成两个维度。第一个是组合爆炸。d 个节点上所有可能的 DAG 数量是超指数增长的大概的规模是 O(2^(d(d-1)/2)) 量级。当 d 只有 20 的时候这个数字已经大到天文级别不可能靠枚举搜索。传统的贪婪搜索、爬山法、模拟退火虽然有效但本质上都是在一个巨大的离散空间里碰运气很容易落入局部最优。第二个是方向不可辨识。在纯观测数据下很多统计方法只能告诉你两个变量相关无法告诉你谁影响谁。举个最简单的例子线性高斯模型里X aY ε_XY ε_Y这里的 ε_X 和 ε_Y 都是高斯噪声那么反向写成 Y bX ε_Y 也能得到一模一样的联合分布。只靠观测数据模型会把两个方向都当成可行方案这时候就必须靠额外假设来打破对称性。这两个问题叠加在一起导致传统结构学习算法的链条特别脆弱离散搜索不稳定评分函数也不平滑无法直接做梯度下降。所以 2018 年前后最令人兴奋的方向就是“把离散约束变成连续优化约束”。1.3 连续的 DAG 约束带来了范式转变如果你接触过结构学习大概率听过那个里程碑式的做法在加权邻接矩阵 W 上定义一个平滑函数 h(W) tr(exp(W ⊙ W)) − d。这个函数有个非常好的性质h(W) 0 当且仅当 W 对应的图是有向无环图。于是 DAG 约束就从一个离散的组合性质变成了一个可以放进优化器里的可微约束。这个约束的原理并不复杂我可以拿“路径环”来类比。矩阵 (W ⊙ W) 的 k 次幂的迹统计的是长度为 k 的闭环的加权总和对幂级数求和之后取迹等价于把所有可能的闭环都加在一起。如果 DAG 存在环那沿着环走一圈终会回到起点这种情况下闭包总和必然为正反过来如果没有环闭合路径的权重总和就是 0。所以 h(W) 可以敏锐地嗅出图中的环。但 NOTEARS 那一类方法的生成模型假设是 X ≈ W^T X ε属于线性结构方程模型。真实数据往往是非线性的线性假设一旦不成立学出来的结构就容易出现漏边、错边。DAG-GNN 的思路就是保留这个连续的 DAG 惩罚同时把生成模型换成一个表达能力更强的非线性框架图神经网络。2. DAG-GNN 的建模核心用 GNN 把非线性结构方程装进 VAE2.1 从线性 SEM 到非线性生成方程DAG-GNN 把结构学习重新理解成了一个生成过程。它假设观测变量 x 并不是直接由另一个观测变量线性生成而是有一个潜变量 z在结构矩阵 A 的指引下通过一个非线性函数映射到观测空间x g(A^T z) ε这里 g 是逐元素作用的非线性激活函数比如 tanh 或带平滑的线性单元A 是 d×d 的加权邻接矩阵ε 是噪声项。这个设定比线性 SEM 强在哪儿关键就在于 g 可以描述非线性的数据生成机制。比如基因调控里一个转录因子对下游基因的作用很多时候是饱和型的、开关型的用线性系数很难刻画非线性激活函数则有更大的拟合空间。注意这里 A 同时承担了结构表达和生成传播两个角色。它既是我们最终要学出来的 DAG又是生成数据过程中的传播骨架。这个设计很有意思它让你在重构数据的同时被迫把结构 A 学好因为如果 A 错了重构误差就会大梯度会逼着优化器去修正 A。2.2 编码器和解码器中的图神经网络角色有了生成过程还不够还要有对应的推理过程。DAG-GNN 使用了一个编码器把观测 x 和当前的结构矩阵 A 共同映射到潜变量 z 的分布参数上z ~ q(z | x, A) N(μ(x, A), diag(σ²(x, A)))这里的编码器不是普通的 MLP而是图神经网络。为什么必须用图结构因为在潜变量空间里变量之间不是独立的节点状态需要沿着 A 的关系互相传播信息。你可以把每个变量想象成房间里的人A 就是他们之间的通话线路想知道某个节点当前大概处于什么状态光看它自己是不够的还要把和它相连的邻居信息汇总进来。解码器那侧也是同样的道理。潜变量 z 沿着 A 的转置方向做消息传递再经过非线性变换才能重构出 x。每一步传播都对应结构方程中的一个依赖路径这让模型可以表达多跳的、非线性的依赖关系而不再是一次性乘一个系数矩阵就完事。2.3 为什么要套一个 VAE 框架如果只是把线性 SEM 换成 GNN理论上也可以直接优化最小二乘重构损失。但实际做起来会发现两个问题一是变量之间的随机噪声很复杂单纯的点估计容易过拟合二是潜变量 z 本身就带有不确定性我们需要一个机制来合理处理这种不确定性。VAE 框架恰好能同时解决这两个问题。它通过重构似然项来拟合数据通过 KL 散度项约束潜变量分布不过度偏离先验。这相当于给模型加了一个“概率缓冲”即使某些依赖关系在当前样本里体现得不明显模型也知道不要过度相信某一个确定的 z 值从而降低结构误判的概率。从这个角度看DAG-GNN 不只是一个换壳的 NOTEARS它把结构学习从“最小二乘加惩罚”升级成了“变分推断加结构约束”。这个变化带来的直接好处是在样本量有限、信噪比不高的场景下结构学习的稳定性会明显变好。我后面在复现时也确认了这一点。2.4 和传统连续优化方法的对比我把 DAG-GNN 与纯线性连续优化路线放在一起比较一下。维度线性连续优化路线DAG-GNN基础模型线性结构方程模型非线性生成模型带 GNN 传播学习方式似然最小二乘变分自编码器重构 KL非线性能力几乎无强可学习非线性激活和深层传播对潜变量的处理不显式建模潜变量显式潜变量带不确定性估计高维稳定性依赖稀疏正则和调参依赖 VAE 噪声缓冲相对更好训练代价较低较高需要迭代 E-step 和结构约束交替优化如果你手里的数据基本是线性关系那我诚心建议线性基线就够用了。但现实世界的数据很少这么听话非线性、交互作用、隐藏变量到处都是这种情况 DAG-GNN 的优势就出来了。3. 从 ELBO 到 DAG 惩罚训练过程中真正在算的东西3.1 三个损失组件拼出一个目标函数DAG-GNN 的训练目标可以拆成三块。第一块是重构损失也就是负对数似然项。对于连续数据通常用高斯分布假设等价于最小化 x 和 x_hat 之间的均方误差如果是离散数据可以换成交叉熵形式。这一项决定了模型能不能把观测数据解释好。第二块是 KL 散度项。它度量编码器得到的潜变量分布与标准高斯先验之间的差异对潜变量做正则化防止模型在潜变量空间里走极端。这两块合起来就是变分自编码器里最常说的 ELBO。第三块是 DAG 惩罚项。A 通过 h(A) 进入目标函数用增广拉格朗日法处理等式约束 h(A) 0。整体目标可以写成L ELBO λ h(A) (ρ/2) h(A)²其中 λ 是拉格朗日乘子ρ 是惩罚系数。训练过程中这两个参数会不断更新让模型先把数据重构好再把结构逐步拉向 DAG。3.2 增广拉格朗日法的调参节奏直接上完整代码前先把这个优化过程说透。它的工作方式有点像拧螺丝你不能一次拧到底要一次拧一圈然后看效果再继续。通常会这么设计把训练分成若干轮。在每一轮里先固定当前的 λ 和 ρ用 Adam 优化器去最小化 L 中的变量部分包括 A、编码器参数、解码器参数每优化一定步数之后更新 λ 和 ρ更新规则是 λ ← λ ρ h(A)如果 h(A) 还很大就把 ρ 乘以一个放大因子比如 10 倍。我建议把惩罚系数放大节奏放慢一点比如每 50 到 100 步再更新一次而不是每个 batch 都调整。原因很简单模型需要时间去适应新的惩罚力度你更新得太频繁重构损失和 DAG 惩罚会互相打架训练曲线会一路发抖。收敛条件也不是简单地把 h(A) 降到 0 就收工。我在实战中通常同时看三个指标重构损失是否平稳、h(A) 是否降到 1e-8 量级、A 的非零边比例是否合理。如果重构损失还在明显波动即使 h(A) 很小说明模型还没真正稳定建议继续多跑若干轮。3.3 一个可以跑通的最小 PyTorch 骨架这里我给一个极度简化的骨架代码目的是帮助理解不是直接复现论文里的完整结构。核心模块就三个一个可学习的邻接矩阵参数 A、一个编码器 GNN、一个解码器 GNN。import torch import torch.nn as nn import torch.nn.functional as F def dag_penalty(A): # A 是带权重邻接矩阵先保证对角线为 0 A A * (1 - torch.eye(A.size(0), deviceA.device)) M A * A # 用矩阵指数近似 h(A) tr(exp(M)) - d return torch.trace(torch.linalg.matrix_exp(M)) - A.size(0) class DAGGNN(nn.Module): def __init__(self, dim, hidden_dim64): super().__init__() self.A nn.Parameter(torch.zeros(dim, dim)) self.enc1 nn.Linear(dim dim, hidden_dim) self.enc_mean nn.Linear(hidden_dim, dim) self.enc_logvar nn.Linear(hidden_dim, dim) self.dec1 nn.Linear(dim dim, hidden_dim) self.dec_out nn.Linear(hidden_dim, dim) def reparameterize(self, mu, logvar): std torch.exp(0.5 * logvar) eps torch.randn_like(std) return mu eps * std def forward(self, x): # 掩码对角线避免节点自己影响自己 A self.A * (1 - torch.eye(self.A.size(0), deviceself.A.device)) # 编码器把 x 和 A 拼起来做消息传递的初始输入 inp torch.cat([x, x A], dim-1) h torch.tanh(self.enc1(inp)) mu self.enc_mean(h) logvar self.enc_logvar(h) z self.reparameterize(mu, logvar) # 解码器从潜变量和结构关系重构 x dec_inp torch.cat([z, z A], dim-1) h torch.tanh(self.dec1(dec_inp)) x_hat self.dec_out(h) return x_hat, mu, logvar, A这个骨架里编码器和解码器都只用了一层线性层实际复现时通常会再叠一层 GNN 层并加入残差连接。注意我这里是直接把 x 和 x A 拼起来当作一种粗糙的消息传递你也可以换成图卷积、图注意力等更复杂的算子。训练循环里要把 ELBO 和 DAG 惩罚分开算再合到一起。def elbo_loss(x, x_hat, mu, logvar): recon F.mse_loss(x_hat, x, reductionsum) kl -0.5 * torch.sum(1 logvar - mu.pow(2) - logvar.exp()) return recon kl def train_step(model, x, lam, rho): x_hat, mu, logvar, A model(x) loss elbo_loss(x, x_hat, mu, logvar) lam * dag_penalty(A) (rho / 2) * dag_penalty(A) ** 2 optimizer.zero_grad() loss.backward() optimizer.step() return dag_penalty(A).item(), loss.item()3.4 判断收敛时最容易踩的视觉陷阱很多人会看 h(A) 降到 0 就认为成功了结果发现学出来的图全是一片空白或者一团乱麻。为什么因为 h(A) 只是无环约束它保证无环不保证边都正确。优化器完全可以把 A 里的边大多压到极小值从而在满足无环约束的同时牺牲重构质量。所以我的习惯是把 h(A)、重构损失、有效边数量三个量画在同一张图上观察它们是否同时稳定。如果 h(A) 一直在降但重构损失同时在上升说明惩罚和重构的对抗已经失衡你需要调小 λ 或者 ρ 的更新节奏。如果 A 的有效边比例突然骤降往往意味着 DAG 惩罚过强把边都给压死了。4. 复现 DAG-GNN 最容易翻车的五个细节4.1 数据标准化没有做够损失直接失衡这几乎是新手复现 DAG-GNN 的第一道坎。X 的各个变量如果量纲不同有的在 0 到 1有的在 0 到 10000那么重构损失会被大量纲变量主导模型会优先拟合这些变量结构学习部分基本没法正常运营。我建议在数据进入模型之前先做标准化让每个变量均值为 0、方差为 1。虽然看起来是很基础的一步但很多人会觉得“反正模型能学非线性变换标准化无所谓”实际上这个无所谓会直接毁掉重构和 KL 之间的平衡。用标准化之后重构损失各维度的贡献才相对均匀A 的学习才不会被个别变量带偏。4.2 把 h(A)0 当成训练成功会得到一张空图前面说过这个问题这里详细展开一下。增广拉格朗日法在后期会把 λ 变得非常大这时候模型面临的惩罚极强。如果重构项和 DAG 项没有搭配好模型会发现最简单省事的方法是把 A 的所有边都压到接近 0这样 h(A) 自然满足DAG 惩罚就没了但结构什么都没学到。对付这个问题我最常用的办法是给 A 加一个小幅度的先验比如初始化成带噪声的小值并且在训练过程中对 A 的绝对值做 soft threshold 或 hard threshold。更直接的方法是盯住重构损失一旦发现重构损失异常上升立刻降低 ρ 的放大倍数或者把 λ 重置一下给重构项一点喘息空间。4.3 ρ 和 λ 更新太激进训练曲线全程发抖我把常见症状和解决方法整理成一张表方便你对照着排查。症状可能原因处理办法A 的边不断翻转、训练后期不稳定ρ 放大倍数太大惩罚更新太频繁把 ρ 放大因子调小比如从 10 改成 2拉长更新间隔h(A) 一直波动降不下去λ 更新幅度过大优化器跟不上对 λ 做 clip限制单次最大增量重构损失先降后升增广拉格朗日惩罚过重重建被压制降低初期 λ让模型先充分拟合数据再逐步加结构约束A 几乎所有边都趋近 0DAG 惩罚压过了重构减小 ρ 的峰值上限或引入最小边阈值这组参数本质上没有标准答案跟数据维度、样本量、噪声水平都有关系。我的经验是先跑一个小数据集把 ρ 和 λ 的曲线打出来观察它们与 h(A) 的变化关系找到一个节奏再放到更大数据集上。这个调参过程很繁琐但非常值得做。4.4 图神经网络层数过深反而丢失结构信息结构学习里的图规模通常都不大多数实验在 10 到 50 个节点之间。这个规模下GNN 层数不是越多越好。我试过堆到 5 层以上结果所有节点的表征开始趋同邻接矩阵 A 里的边也变得模模糊糊这就是常说的过度平滑。节点数量少时两层到三层的图传播已经足够覆盖绝大多数依赖路径了。如果你想加深网络来提升非线性表达能力更好的方式是加深每层内部的 MLP而不是增加消息传递的跳数。另外给每层 GNN 加上残差连接也能明显缓解深层的表征退化问题。4.5 评估时阈值选太硬结果波动巨大DAG-GNN 学出来的 A 是连续权重不是二值图。评估时通常要把权重大于某个阈值的边视为存在小于阈值的边视为不存在。这个阈值怎么选直接决定最终 F1 和 SHD 的数值。如果你设成 0.01那么几乎所有的弱边都会被当成真边F1 的精确率急剧下降如果设成 0.5很多真实的弱依赖会被丢掉召回率断崖式下跌。正确的姿势是先看看 A 对训练集重构的影响或者直接在验证集上扫一遍阈值选一个让结构误差最小的点。我通常会把 0.05、0.1、0.2、0.3 都试一遍然后报告一个范围而不是拍脑袋定死一个数。5. 怎么设计一套能说明问题的实验5.1 先在自己生成的合成数据上考试结构学习这件事没有真实结构做对照就谈不上好不好。所以在跑真实数据之前一定要先在合成数据上验证。我的做法是随机生成一个 DAG节点数一般在 5 到 20边的期望数量可以控制比如让平均出度约等于 2。然后给非零边随机赋权重通常在 [-2, -0.5] 和 [0.5, 2] 里均匀采样。接下来分两套数据一套从线性 SEM 采样另一套从非线性 SEM 采样比如把 W^T X 先过一个 tanh 再加噪声。评估指标我用三个指标含义如何解读SHD结构汉明距离计算漏掉、多出、方向错误的边数总和越小越好0 表示完全正确F1边预测的精确率和召回率的调和平均越大越好反映边存在性判断方向准确率在预测存在的边中方向正确的比例越大越好反映方向识别能力这里有个细节要注意SHD 把你“预测了一条边但方向反了”也计为一次错误而 F1 通常只看边存在性方向不算。所以我会同时报告这两个指标防止只报 F1 而掩盖方向错误的问题。5.2 真实数据没有金标准时的打榜策略真实数据通常没有完整的 DAG 作为参考答案尤其像蛋白质表达数据、股票收益率数据这类场景你只知道部分公认的依赖关系不可能拿到全图。这种情况下我建议不要纠结全局指标转而做稳定性分析。把原始数据用 bootstrap 重采样很多次每次都跑一遍 DAG-GNN统计每条边出现的频率。出现频率超过 80% 的边可以看成稳定边在 50% 到 80% 之间的属于候选边低于 50% 的基本可以认为是噪声。这样得到的候选结构比单次运行的结果可信得多。另一个做法是把少量已知的依赖关系作为先验边固定住让模型只去学未知部分然后看学出来的边是否与你所在领域的常识冲突。冲突越多说明模型可能出了问题或者你的先验本身需要调整。5.3 我惯用的一个实验套餐给想快速上手的同学一个参考套餐。首先做数据生成固定节点数 d10样本量取 500、2000 两档设定低噪声和高噪声两档。每种组合生成 10 个随机 DAG并分别在线性和非线性 SEM 下采样。然后把 DAG-GNN 和线性基线同时跑一遍比较 SHD 和 F1。最后把结果画成箱线图看看分布而不是只看均值。这个套餐的意义在于它能让你快速感受 DAG-GNN 在不同条件下的适用边界。在我的经验里线性数据上 DAG-GNN 和线性基线差距不大有时候甚至会因为模型复杂而稍弱但非线性数据上DAG-GNN 的 SHD 往往能下降 20% 到 40%这就证明了引入 GNN 非线性表达是有回报的。如果非线性数据上你的结果和线性基线完全一样那多半是代码或者参数设置出了问题。6. 从“学会一张图”到“用好一张图”的落地经验6.1 先验知识与业务约束怎么塞进去真实业务场景里你不可能完全不知道结构。比如某个流程中A 步骤一定发生在 B 步骤之前或者某两个环节之间明确不允许有依赖关系。这些先验如果不利用等于把模型架在一个完全没约束的环境里瞎猜。DAG-GNN 里有很自然的接口做这件事在邻接矩阵 A 上加 mask。已知存在的边可以直接固定住不让梯度去改它已知不存在的边直接置为 0不参与训练甚至可以限制某些节点的出度或入度为 0比如已知某个变量只能是原因不可能是结果。我强烈建议在实际项目里至少塞一层先验 mask。数据量通常不够支持模型从零学出一张可信的全局图但加上领域约束后搜索空间小很多学出来的结构也会稳很多。6.2 把邻接矩阵转化为下游决策结构学习本身不是终点让人头疼的是后面怎么用它做决策。如果目的是特征选择那可以看 A 的可达性。比如你想知道某个目标变量受哪些上游变量的影响可以顺着 A 的方向反向追溯把所有能到达目标变量的节点都作为候选特征。这比单纯用相关系数筛选更能避免间接依赖造成的假候选。如果目的是根因定位那就找 A 中的“根节点”也就是没有上游依赖、但能通过路径影响很多下游节点的变量。这类似于在一个故障因果图里找源头节点对运维和客服场景非常有用。如果是做反事实解释那就用学到的图作为生成模型骨架干预某个上游节点然后看下游变量分布会发生什么变化。注意这需要更强的因果假设且最好结合领域验证不能只靠模型输出的图就下结论。6.3 我对新入行者的使用建议我的经验是别一上来就把 DAG-GNN 当成万能的因果发现工具。它更适合的场景是变量数量几十个以内样本量在数千到几万之间数据基本上是连续或平滑变换的而且你有一些领域先验可以用来筛选结果。如果节点数上千或者数据里有大量的复杂离散类别DAG-GNN 的计算代价和结果稳定性都不太理想这时候更建议把问题拆小或者改用其他专门面向高维稀疏场景的方法。还要牢记一点结构学习给出的是“结构候选项”不是“终审判决”。我在实际项目里的使用流程通常是先用 DAG-GNN 学一个关系矩阵再结合 bootstrap 稳定边筛选最后把少量关键边交给业务方评估。经过这几轮过滤之后剩下的边才敢进入后续的建模或决策环节。最后分享一个我的个人习惯每次跑完 DAG-GNN不要急着拿一次随机初始化的结果去下结论。我会至少换三到五个随机种子统计边的出现频率和权重波动范围。结构学习本质上是一个缩小怀疑范围的过程而不是替你拍板的工具。理解这一点你在使用 DAG-GNN 时的很多困惑都会自动解开。
阅读完成 · 觉得有帮助?