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
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 从邻居到节点:更新公式
采样 + 聚合后,逐层更新:
- 先聚合得到邻居特征向量 h_neighbor
- 拼接自身特征 h_self 和 h_neighbor
- 过全连接 + 非线性激活
- 归一化(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))
4.3 GCN vs GAT vs GraphSAGE:怎么选?
这三者不是互相替代的关系,而是不同场景的优选项。
| 模型 | 邻居权重 | 计算效率 | 适合场景 |
|---|---|---|---|
| GCN | 基于度,固定 | 高 | 小图、同质图 |
| GAT | 可学习,动态 | 中等 | 异质图、需关注关键邻居 |
| GraphSAGE | 采样 + 聚合 | 高(可控) | 大图、多跳聚合 |
在 QSAR 靶点筛选中,我个人习惯这样用:
- 数据量小于 2000 分子,节点数少(< 100)→ 用 GAT,加多头注意力
- 数据量大于 1 万分子,图很大 → 用 GraphSAGE,小批量训练
- 快速基线或对比实验 → 先上 GCN
4.4 实操建议:在 QSAR 中怎么用?
不管选哪个,别忘了特征工程本身。我来给你一个实用的 checklist:
- 节点特征:原子类型、杂化方式、电荷、药效团标签
- 边特征:键型(单/双/三键、芳香键)、距离、是否可旋转键
- 全局特征:分子量、LogP、TPSA
这些特征喂进 GAT 或 GraphSAGE,模型才能理解“羧基为什么比甲基重要”。