4、基础回顾(三):图注意力网络(GAT)与 GraphSAGE:注意力机制与邻居采样

好,前面我们搞定了 GCN 的邻域聚合。说白了就是把邻居特征平均一下。

但这里有个问题:所有邻居都同等重要吗?

在我做过的 QSAR 项目里,可不是这样。一个靶点蛋白的某些残基,对配体结合贡献极大;另一些呢,基本就是背景板。GCN 的简单平均,等于把这些差异抹平了。今天要讲的 GAT 和 GraphSAGE,就是来解决这个问题的。

4.1 图注意力网络(GAT):给邻居排个序

4.1.1 为什么要加注意力?

你想想看,药物分子里有一个苯环连着三个取代基。其中一个是羧基(-COOH),另外两个是甲基。那模型聚合信息时,应该多关注羧基还是甲基?

显然是羧基,因为它决定了化合物的酸碱性,是重要的药效团。GCN 做不到这种区分。而 GAT 引入了一个注意力系数,它会动态学习:每个邻居应该分得多重的权重。

我在处理一个激酶抑制剂的 QSAR 数据时遇到过这种情况。用 GCN 做出来的特征,对某些关键疏水相互作用几乎不敏感。换成 GAT 后,模型自动把权重聚焦在那些关键的疏水残基上。嗯,效果提升很明显。

4.1.2 注意力系数怎么算?

简单来说,GAT 做了一件很直观的事:

  • 对节点 i 和节点 j,先把它们的特征分别乘以一个权重矩阵 W
  • 然后拼接起来,过一个小型神经网络(单层全连接 + LeakyReLU)
  • 算出 raw coefficient(原始系数)
  • 最后用 softmax 在所有邻居上归一化,得到最终的注意力权重

公式不复杂,我直接贴一下片段,这是 PyG 里的实现思路:

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, x, edge_index):
        # 线性变换
        h = self.W(x)  # [N, out_dim]
        # 边索引
        row, col = edge_index
        # 拼接两个端点特征
        h_cat = torch.cat([h[row], h[col]], dim=-1)  # [E, 2*out_dim]
        # 计算注意力分数
        attention_score = self.leaky_relu(self.a(h_cat)).squeeze(-1)
        # softmax 归一化(按邻居维度)
        attention_weight = softmax(attention_score, row, num_nodes=x.size(0))
        return attention_weight

实际用的时候,我会习惯加上多头注意力(multi-head)。就像 Transformer 那样,几个独立的注意力头各自学,然后拼接或平均。

4.1.3 多头注意力的价值

为啥要用多头?

我记得有次做毒性预测,单一注意力头太过关注分子中某个重原子的亲电性,忽略了其他全局特征。换成 8 头注意力后,每个头关注不同的化学环境:一个负责电负性,一个负责氢键供体,一个负责空间位阻……最终结果稳健很多。

PyG 调用 GAT 就一行:

from torch_geometric.nn import GATConv

conv = GATConv(in_channels=128, out_channels=64, heads=8, concat=True)
# 输入: (x, edge_index)  -> 输出维度: 64 * 8 = 512
小技巧:如果你数据量不大(比如几百个分子),heads 设 4 或 6 就够了。8 头容易过拟合,尤其 QSAR 数据集通常样本少。

4.2 GraphSAGE:大图生存指南

4.2.1 全图?算不过来!

GCN 和 GAT 有个共同的软肋:它们需要全图的邻接矩阵。如果分子图很大,或者你需要聚合到多跳邻居(比如蛋白质-配体复合物的残基图),显存就直接爆掉了。

我遇到过一个案例:一个蛋白-配体对接的图,节点数大概 5000,边数 15 万。直接用 GCN 做 3 层聚合,单张 V100 显卡都不够。这就是 GraphSAGE 出现的场景。

4.2.2 核心思想:采样,而不是遍历

GraphSAGE 不计算所有邻居。它每一层只随机采样固定数量的邻居。具体来说:

  • 每层设置一个采样数 S(比如 10 或 25)
  • 当前节点的邻居超过 S 个,随机抽 S 个
  • 邻居不够 S 个,就重复采样补齐

这样一来,计算量被严格限制住了。你在训练时看到的内存占用是固定的,不随图大小剧烈波动。

4.2.3 聚合函数怎么选?

GraphSAGE 不止一种聚合方式。我常用的是这几种:

聚合方式 做法 适合场景
Mean 邻居特征取平均 基础情况,特征分布均匀
Max Pool 邻居特征过全连接 + max 存在少量强特征邻居(如关键药效团)
LSTM 邻居按顺序过 LSTM 邻居有顺序关系(如配体序列)

在 QSAR 筛选中,我建议你优先试 Max Pool。原因很简单:一个分子里往往只有一两个关键官能团决定药效,max 操作能突出它们。

4.2.4 从邻居到节点:更新公式

采样 + 聚合后,逐层更新:

  1. 先聚合得到邻居特征向量 h_neighbor
  2. 拼接自身特征 h_self 和 h_neighbor
  3. 过全连接 + 非线性激活
  4. 归一化(L2 norm)
class SAGEConvLayer(nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        self.linear = nn.Linear(in_dim * 2, out_dim)
        self.aggr = 'max'  # 可选: 'mean', 'max', 'lstm'

    def forward(self, x, edge_index):
        # 这里需要采样邻居(PyG 里会自动处理)
        # 聚合
        x_neighbor = scatter_max(x[edge_index[1]], edge_index[0], dim=0)  # 简化示意
        # 拼接自身
        x_concat = torch.cat([x, x_neighbor], dim=-1)
        # 全连接 + 激活
        return F.relu(self.linear(x_concat))
避坑指南:我曾经犯过这样的错——在 GraphSAGE 里把采样数设得太大(比如一层 50 个邻居,三层就是 1250)。算下来还是炸显存。建议第一层采样 10~15,第二层 20~25,最多不超过三层。

4.3 GCN vs GAT vs GraphSAGE:怎么选?

这三者不是互相替代的关系,而是不同场景的优选项。

模型 邻居权重 计算效率 适合场景
GCN 基于度,固定 高 小图、同质图
GAT 可学习,动态 中等 异质图、需关注关键邻居
GraphSAGE 采样 + 聚合 高(可控) 大图、多跳聚合

在 QSAR 靶点筛选中,我个人习惯这样用:

  • 数据量小于 2000 分子,节点数少(< 100)→ 用 GAT,加多头注意力
  • 数据量大于 1 万分子,图很大 → 用 GraphSAGE,小批量训练
  • 快速基线或对比实验 → 先上 GCN
经验之谈:GAT 虽然灵活,但训练不稳定。我记得有次调了三天注意力头数和 Dropout,才收敛。GraphSAGE 反而更“皮实”,采样策略选好就能跑。初学者建议先从 GraphSAGE 入手,稳定后再挑战 GAT。
GAT vs GraphSAGE 核心组件对比 图注意力网络(GAT) 注意力系数计算 a(Wh_i || Wh_j) → softmax → α_ij 多头注意力 Head 1..K,拼接或平均 邻居权重 可学习,每对节点独立评分 小图 / 异质图适用 GraphSAGE 邻居采样 每层固定采样 S 个邻居 聚合函数 Mean / Max Pool / LSTM 节点更新 Concat(自身, 邻居) → Linear → Norm 大图 / 多跳聚合适用 核心差距:GAT 关注“哪个邻居更重要”,GraphSAGE 关注“如何高效获取邻居信息”

4.4 实操建议:在 QSAR 中怎么用?

不管选哪个,别忘了特征工程本身。我来给你一个实用的 checklist:

  • 节点特征:原子类型、杂化方式、电荷、药效团标签
  • 边特征:键型(单/双/三键、芳香键)、距离、是否可旋转键
  • 全局特征:分子量、LogP、TPSA

这些特征喂进 GAT 或 GraphSAGE,模型才能理解“羧基为什么比甲基重要”。

一个小技巧:如果你用 GAT,可以把学到的注意力权重可视化出来。我做过几次,发现模型自动学会关注那个最强的氢键供体。这种可解释性在 QSAR 里非常加分。