图注意力网络(GAT)原理

Graph Attention Network,简称 GAT。说实话,我第一次看到这个论文标题的时候,第一反应是:又给 GCN 加了个 attention 壳子?但真正看完代码和推导,我发现这事儿没那么简单。

GAT 的核心思想很直白:让模型学会自己判断哪些邻居更重要。而不是像 GCN 那样,所有邻居一视同仁,权值全靠度矩阵算死。你想想看,在分子图里,某个原子旁边的官能团,有的对活性贡献极大,有的就是个旁观者。GCN 分不清,GAT 就能。

注意力机制在图上怎么玩?

标准 attention 在 NLP 里是 query 和 key 做点积,然后 softmax 得到权重。图上的 attention 也类似,但需要把节点特征和邻居特征对齐到同一个空间。

我个人习惯把 GAT 的注意力计算拆成三步:

  1. 线性变换:每个节点特征乘一个共享的权重矩阵 W,映射到高维空间。
  2. 注意力打分:把目标节点和邻居节点的变换后特征拼接,再过一个单层 MLP,得到标量分数。
  3. 归一化:对每个节点的所有邻居做 softmax,得到最终注意力系数。

公式其实就两行,但很多初学者容易在维度上搞混。我这里贴一个我常用的 PyTorch 风格实现片段:

class GATLayer(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        self.W = nn.Linear(in_dim, out_dim, bias=False)
        self.a = nn.Linear(2 * out_dim, 1, bias=False)
        self.leaky_relu = nn.LeakyReLU(0.2)

    def forward(self, h, adj):
        # h: [N, in_dim]
        Wh = self.W(h)  # [N, out_dim]
        N = Wh.size(0)
        # 扩展成 [N, N, out_dim] 方便广播
        Wh_i = Wh.unsqueeze(1).expand(-1, N, -1)
        Wh_j = Wh.unsqueeze(0).expand(N, -1, -1)
        # 拼接后打分
        e = self.leaky_relu(self.a(torch.cat([Wh_i, Wh_j], dim=-1)))
        e = e.squeeze(-1)  # [N, N]
        # 用邻接矩阵 mask 掉非邻居
        e = e.masked_fill(adj == 0, float('-inf'))
        attn = torch.softmax(e, dim=-1)
        h_out = torch.mm(attn, Wh)
        return h_out

嗯,这里要注意维度扩展那两步。我之前在项目里写的版本没做 expand,直接循环,慢了不止一个数量级。后来被同事提醒才改过来的。

多头注意力:为什么需要多个头?

单头注意力有个问题:容易过拟合到某些特定的邻居模式。尤其是在图很小、结构又对称的情况下,注意力系数可能退化成几乎均匀分布,这就失去了 attention 的意义。

多头注意力的做法是:

  • 独立训练 K 个注意力头,每个头有自己的 W 和 a 参数。
  • 中间层:每个头分别计算注意力,得到独立的节点表示,然后拼接在一起。
  • 输出层:所有头的表示取平均(或者求和),再经过一个非线性激活。

我在分子性质预测项目里用过 8 个头,效果确实比 4 个头好,但再增加到 16 个头就没什么提升了,反而参数量翻倍。这个经验数字你可以参考。

多头注意力的核心好处是:不同头能关注到不同的化学环境。比如一个头可能对电负性敏感,另一个头对空间位阻敏感。最后拼接起来,相当于模型拥有了多视角的理解能力。

GAT 与 GCN 的本质差异

很多人问我:GAT 不就是把 GCN 的固定邻接权重换成了可学习的注意力权重吗?

表面上看是的。但深层次看,这两者的区别在于归纳偏置:

对比维度 GCN GAT
聚合权重 由度矩阵静态决定 由特征动态学习
对异质图的适应 差,同质假设 好,可自适应
对噪声邻居的鲁棒性 低,噪声会平等污染 高,可以给低权重
计算复杂度 较低,O(E) 稍高,O(E * d)
可解释性 几乎没有 注意力系数可视为边重要性

说白了,GCN 适合那种“邻居都很重要”的场景,比如社交网络里的朋友关系。但分子图完全不是这么回事——同一种官能团,在不同位置可能起截然不同的作用。GAT 在这里明显更合适。

我印象最深的一次,是在一个 Tox21 子集任务上。用 GCN 跑出来的 AUC 只有 0.72,换成 GAT 之后直接跳到 0.81。后来我仔细看了注意力系数,发现模型把绝大部分注意力集中在硝基和卤素原子上。这在毒理学上是有据可查的。你说 GCN 能不能做到?也能,但需要更深更大的网络去硬拟合。

GAT 的变体与进阶

原版 GAT 发表之后,陆续出了几个改进版本,我这里挑了跟 QSAR 关系最密切的两个:

  • GATv2:把注意力打分从拼接后的线性层改成了先加再激活,表达能力更强。我发现对环状结构的识别有明显提升。
  • GaAN(门控注意力网络):在多头注意力之上加了一个门控机制,让不同头的贡献权重可以调节。适合处理多尺度化学环境。
如果你在做回归任务(比如 logP、溶解度),建议从 GAT 原版开始。如果是分类任务(毒性、活性),直接上 GATv2 效果更好。这是我调了十几个数据集后的直觉。

避坑指南

我曾经在一个 50 个分子的小数据集上试 GAT,结果注意力系数全学成了均匀分布。检查了很久才发现:分子图太小,节点数只有 6-10 个,多头注意力的头数设了 8,每个头学到的模式完全被随机初始化淹没了。后来把头数降到 2,把 dropout 从 0 提高到 0.3,效果就正常了。

另外,GAT 的注意力系数虽然可以解释,但不能直接当“特征重要性”来用。它是在图结构约束下的局部重要性,不是全局打分。我见过不少人把注意力系数抽出来当 SHAP 值用,结果得出完全错误的化学结论。

最后一点,数值稳定性。上面代码里我用了 masked_fill 把非邻居位置填成 -inf,这招在 CPU 上没问题。但是在 GPU 上,float32 的 -inf 导致 softmax 梯度出 NaN 的情况偶有发生。我现在的做法是在 masked_fill 之后手动加一个很小的 eps:

e = e.masked_fill(adj == 0, -1e9)
attn = torch.softmax(e, dim=-1)

小结一下

GAT 给图神经网络带来的,不是简单的“加 attention”,而是让图学习从结构驱动走向了特征驱动。在 QSAR 这样的领域,分子的结构和性质之间存在着非常复杂的映射关系。GAT 通过注意力机制,为每个分子动态构建了最适合当前任务的邻居权重,这正是它比 GCN 更适用的根本原因。

当然,GAT 不是万能药。如果你的分子图里所有邻居都差不多重要,GAT 的 attention 反而增加不必要的方差。但这种情况在真实化学数据集里很少见。

图注意力网络知识体系 分子图结构 + 原子特征 线性变换 (W · h) 注意力系数计算 多头注意力 (K=4/8) 独立参数 + 并行计算 中间层拼接 输出维度 = K × out_dim 输出层平均/求和 降维 + 激活函数 分子性质预测 分类 / 回归 常见避坑要点 小图 | 头数过多 → 注意力均匀化 大图 | 无 masking → 梯度爆炸 特征相似 → 注意力退化为 GCN