# Graphormer实战:用Python从零搭建分子性质预测模型(附完整代码)
如果你正在处理分子、社交网络或者任何图结构数据,并且对传统图神经网络(GNN)在捕捉长程依赖或精确空间关系上的力不从心感到头疼,那么这篇文章正是为你准备的。我们不再停留在理论层面的探讨,而是直接切入工程实践,手把手带你用PyTorch从零构建一个基于Graphormer架构的分子性质预测模型。目标读者是那些已经熟悉机器学习基础,并希望将前沿图Transformer技术落地到具体项目中的工程师和研究者。我们将以经典的`ogbg-molhiv`数据集为战场,完整覆盖从环境搭建、数据预处理、模型构建、训练优化到结果可视化的全链路。你会发现,将Transformer的全局注意力机制与图的结构化信息相结合,并非遥不可及的理论,而是一系列可以逐步实现的代码模块。
## 1. 环境准备与核心概念锚点
在开始敲代码之前,我们需要确保工具链就绪,并快速厘清Graphormer解决的核心问题。传统消息传递式GNN(如GCN、GAT)就像在一个聚会中,你只能和你直接认识的人交谈,信息需要经过多轮传递才能到达远处。对于分子而言,这意味着一个原子很难直接“感知”到分子另一端的原子,即使它们在三维空间中距离很近或者存在重要的远程相互作用(如氢键、空间位阻)。Graphormer的核心思想是引入Transformer的自注意力机制,让分子中的每个原子都能与所有其他原子进行“直接对话”,同时,通过一系列**结构化位置编码**,为这种全局对话注入关键的图拓扑和几何空间信息。
### 1.1 搭建Python环境
我推荐使用`conda`来管理环境,它能很好地处理科学计算包的依赖。以下命令将创建一个名为`graphormer-lab`的新环境。
```bash
conda create -n graphormer-lab python=3.9 -y
conda activate graphormer-lab
```
接下来,安装核心的深度学习与图学习库。这里我们固定一些主要库的版本以确保兼容性。
```bash
pip install torch==1.13.1+cu117 torchvision==0.14.1+cu117 torchaudio==0.13.1 --extra-index-url https://download.pytorch.org/whl/cu117
pip install torch-geometric -f https://data.pyg.org/whl/torch-1.13.0+cu117.html
pip install ogb
pip install numpy pandas matplotlib scikit-learn tqdm
```
> 注意:上述PyTorch版本对应CUDA 11.7。请根据你本地的CUDA版本(可通过`nvcc --version`查询)调整安装命令。如果使用CPU,请访问PyTorch官网获取对应的安装命令。
### 1.2 理解Graphormer的三大编码
在编写模型前,必须理解Graphormer如何将图的结构信息注入Transformer。这主要依靠三种编码,它们将作为模型额外的输入信号:
1. **中心性编码**:衡量节点在图中的重要性。简单来说,一个连接数很多(度中心性高)的原子,比如分子中的碳原子,可能比一个边缘的氢原子更重要。我们通过节点的入度和出度(对于无向图就是度)来表征。
2. **空间编码**:捕获节点之间的拓扑距离。我们使用最短路径距离(SPD)。如果两个原子在分子图上的最短路径需要经过很多条键,那么它们的SPD值就大。Graphormer将连续的SPD值离散化到不同的“桶”中,每个桶对应一个可学习的嵌入向量。
3. **边编码**:在注意力计算中融入边的特征。对于分子图,边代表化学键(单键、双键等)。当计算两个节点i和j的注意力时,Graphormer不仅考虑它们各自的节点特征,还会考虑连接它们的最短路径上所有边的特征聚合。
**这三种编码是Graphormer区别于原生Transformer和普通GNN的关键**,它们确保了模型在获得全局视野的同时,没有丢失图本身最宝贵的结构性约束。
## 2. 数据加载与预处理实战
我们将使用Open Graph Benchmark (OGB) 中的`ogbg-molhiv`数据集。这是一个经典的分子性质预测任务,目标是判断一个分子是否具有抑制HIV病毒活性的能力(二分类任务)。OGB已经帮我们处理好了分子的图表示:节点是原子,节点特征是原子类型等;边是化学键,边特征是键类型等。
### 2.1 加载并探索数据集
首先,让我们看看数据长什么样。
```python
from ogb.graphproppred import PygGraphPropPredDataset
import torch
from torch_geometric.loader import DataLoader
# 加载数据集
dataset = PygGraphPropPredDataset(name='ogbg-molhiv', root='./data')
print(f'数据集: {dataset}')
print(f'任务类型: {dataset.task_type}')
print(f'图数量: {len(dataset)}')
print(f'节点特征维度: {dataset.num_node_features}')
print(f'边特征维度: {dataset.num_edge_features}')
# 查看第一张图的数据结构
data = dataset[0]
print(f'\n单图数据结构:')
print(f' 节点数: {data.num_nodes}')
print(f' 边数: {data.num_edges}')
print(f' 节点特征形状: {data.x.shape}')
print(f' 边索引形状: {data.edge_index.shape}')
print(f' 边特征形状: {data.edge_attr.shape}')
print(f' 图标签: {data.y}')
```
运行这段代码,你会看到数据集包含数万个分子图,每个图的节点和边数各不相同。`data.x`是节点特征矩阵,`data.edge_index`是边的连接关系(邻接表的COO格式),`data.edge_attr`是边特征矩阵。
### 2.2 实现Graphormer专属的数据预处理
对于Graphormer,我们需要为每个图预先计算好之前提到的三大编码。由于全图注意力机制需要所有节点对的信息,我们计算一个`N x N`的矩阵,其中`N`是当前图的节点数。为了避免在数据加载时重复计算,我们最好将计算好的编码作为图的属性存储起来。
下面是一个关键的预处理函数,用于计算**最短路径距离(SPD)矩阵**和**边路径编码**。这里我们使用`torch_geometric`中的`dense_diff_pool`附带的`dense_to_sparse`等工具,但核心是Floyd-Warshall算法思想。
```python
import torch
from torch_geometric.utils import to_dense_adj, dense_to_sparse
from torch_scatter import scatter_add
import numpy as np
def preprocess_graph_for_graphormer(data, max_spd=10):
"""
为单个图数据计算Graphormer所需的结构化编码。
Args:
data: PyG Data 对象
max_spd: 最短路径距离的最大值,超过此值将被截断。
Returns:
补充了编码属性的data对象
"""
num_nodes = data.num_nodes
edge_index, edge_attr = data.edge_index, data.edge_attr
# 1. 计算最短路径距离矩阵 (SPD)
# 首先构建稠密邻接矩阵(无权,存在边则为1)
adj = to_dense_adj(edge_index, max_num_nodes=num_nodes)[0]
# 初始化距离矩阵:相邻为1,不相邻为无穷大,自身为0
spd = torch.full((num_nodes, num_nodes), float('inf'), device=adj.device)
spd[torch.eye(num_nodes, dtype=torch.bool)] = 0
spd[adj > 0] = 1
# 简单的Floyd-Warshall算法实现 (小图可用,对于大图需优化)
for k in range(num_nodes):
dist_via_k = spd[:, k:k+1] + spd[k:k+1, :]
spd = torch.min(spd, dist_via_k)
spd = spd.clamp(max=max_spd).long() # 截断并转为整数,用于索引嵌入表
data.spd_matrix = spd
# 2. 计算中心性编码(这里使用度中心性作为简单示例)
# 对于有向图,可分别计算入度和出度
row, col = edge_index
deg = scatter_add(torch.ones_like(row), row, dim_size=num_nodes).long()
data.node_degree = deg # 形状 [num_nodes]
# 3. 计算边编码(边路径编码的预处理)
# 我们需要为每一对节点(i,j)计算其最短路径上的边特征聚合。
# 这里简化处理:先计算最短路径上的边索引,实际训练时动态聚合。
# 我们存储一个映射:对于每对(i,j),存储其最短路径经过的边索引列表(用于后续查找)。
# 注意:此部分计算较复杂,为简化演示,我们后续在模型forward中采用近似方法。
# 此处先存储边索引和边特征,供模型使用。
data.edge_index_orig = edge_index
data.edge_attr_orig = edge_attr
return data
# 对数据集中的每个图应用预处理(示例,实际使用时可能需要分批处理或缓存)
processed_dataset = []
for i in range(min(100, len(dataset))): # 先处理前100个图作为演示
processed_data = preprocess_graph_for_graphormer(dataset[i])
processed_dataset.append(processed_data)
print(f"已处理 {len(processed_dataset)} 个图。")
print(f"新增属性: spd_matrix shape: {processed_dataset[0].spd_matrix.shape}")
print(f"新增属性: node_degree shape: {processed_dataset[0].node_degree.shape}")
```
> 提示:在实际项目中,对于大规模数据集,上述`O(N^3)`的Floyd-Warshall算法会成为瓶颈。你需要考虑使用更高效的算法(如针对稀疏图的Johnson算法),或者使用近似方法。也可以将预处理结果缓存到磁盘,避免每次训练重复计算。
## 3. 构建Graphormer模型层
现在进入核心部分:用PyTorch模块组装Graphormer。我们将它分解为几个子模块:编码模块、Graphormer层、以及预测头。
### 3.1 定义结构化编码模块
这个模块负责将计算好的SPD、中心性等离散索引,映射为可学习的嵌入向量。
```python
import torch.nn as nn
class StructuralEncodings(nn.Module):
"""
生成Graphormer所需的各类结构化编码嵌入。
"""
def __init__(self, hidden_dim=256, max_spd=10, num_edge_types=5):
super().__init__()
self.hidden_dim = hidden_dim
self.max_spd = max_spd
# 中心性编码:入度和出度(对于无向图,入度=出度,但我们区分开以增加容量)
self.in_degree_enc = nn.Embedding(128, hidden_dim, padding_idx=0) # 假设度不超过127
self.out_degree_enc = nn.Embedding(128, hidden_dim, padding_idx=0)
# 空间编码(最短路径距离编码)
# 距离0(自身)到max_spd,外加一个用于超过max_spd或无穷远的桶
self.spd_enc = nn.Embedding(max_spd + 2, hidden_dim, padding_idx=0) # 索引0用作padding
# 边类型编码(用于边路径编码)
self.edge_encoder = nn.Embedding(num_edge_types, hidden_dim//2) # 边特征维度减半,后续拼接
def forward(self, data):
"""
Args:
data: 包含预处理信息的Data对象
Returns:
node_feat: 融合了中心性编码的初始节点特征 [N, D]
spatial_bias: 空间编码偏置矩阵,用于加到注意力logits上 [N, N, 1] -> 广播到 [heads, N, N]
edge_feat: 边特征嵌入 [E, D/2]
"""
num_nodes = data.x.size(0)
# 1. 初始节点特征:原子特征 + 中心性编码
atom_feat = data.x.float()
# 确保度索引在合理范围内
in_deg = data.node_degree.clamp(max=127)
out_deg = data.node_degree.clamp(max=127) # 无向图,入出度相同
centrality_feat = self.in_degree_enc(in_deg) + self.out_degree_enc(out_deg)
node_feat = atom_feat + centrality_feat # 简单相加,也可用投影后拼接
# 2. 空间编码偏置
# spd_matrix中的值范围是[0, max_spd],将inf或>max_spd的值设为max_spd+1
spd_idx = data.spd_matrix.clamp(max=self.max_spd+1).long() # 形状 [N, N]
spatial_bias = self.spd_enc(spd_idx) # 形状 [N, N, D]
# 在注意力中,我们只需要一个标量偏置,通常对D维取平均或使用一个线性投影
spatial_bias = spatial_bias.mean(dim=-1, keepdim=True) # [N, N, 1]
# 3. 边特征嵌入
edge_attr_idx = data.edge_attr_orig.argmax(dim=-1).long() # 假设边特征是one-hot,转为索引
edge_feat = self.edge_encoder(edge_attr_idx) # [E, D/2]
return node_feat, spatial_bias, edge_feat
```
### 3.2 实现Graphormer注意力层
这是最关键的层,它修改了标准的多头自注意力机制,融入了空间偏置和边信息。
```python
class GraphormerAttentionLayer(nn.Module):
def __init__(self, hidden_dim=256, num_heads=8, dropout=0.1):
super().__init__()
self.hidden_dim = hidden_dim
self.num_heads = num_heads
self.head_dim = hidden_dim // num_heads
assert self.head_dim * num_heads == hidden_dim, "hidden_dim必须能被num_heads整除"
# Q, K, V 投影
self.q_proj = nn.Linear(hidden_dim, hidden_dim)
self.k_proj = nn.Linear(hidden_dim, hidden_dim)
self.v_proj = nn.Linear(hidden_dim, hidden_dim)
self.out_proj = nn.Linear(hidden_dim, hidden_dim)
# 用于边编码的投影(将边特征融入注意力)
self.edge_proj = nn.Linear(hidden_dim//2, num_heads) # 将边特征投影为每个头的标量
self.dropout = nn.Dropout(dropout)
self.scale = self.head_dim ** -0.5
def forward(self, node_feat, spatial_bias, edge_index, edge_feat, attention_mask=None):
"""
Args:
node_feat: [N, D]
spatial_bias: [N, N, 1]
edge_index: [2, E]
edge_feat: [E, D/2]
attention_mask: [N, N] 布尔张量,True表示需要mask的位置(如填充)
Returns:
attn_output: [N, D]
"""
N, D = node_feat.shape
H = self.num_heads
# 1. 投影Q, K, V 并重塑为多头的形式
q = self.q_proj(node_feat).view(N, H, self.head_dim).transpose(0, 1) # [H, N, head_dim]
k = self.k_proj(node_feat).view(N, H, self.head_dim).transpose(0, 1)
v = self.v_proj(node_feat).view(N, H, self.head_dim).transpose(0, 1)
# 2. 计算注意力分数 (原始点积)
attn_scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale # [H, N, N]
# 3. 加入空间编码偏置
# spatial_bias: [N, N, 1] -> 转置并广播到所有头 [H, N, N]
attn_scores = attn_scores + spatial_bias.squeeze(-1).unsqueeze(0) # 广播到H个头
# 4. 加入边编码偏置 (这是Graphormer的关键创新之一)
# 我们需要为每对节点(i,j)计算其最短路径上的边特征聚合。
# 简化版:我们只考虑直接相连的边(即邻接边),将边特征投影为标量加到对应的attn_scores上。
row, col = edge_index
# 将边特征投影为每个头的偏置 [E, H]
edge_bias_per_head = self.edge_proj(edge_feat) # [E, H]
# 将边偏置加到attn_scores的对应位置。注意:这是对注意力图的稀疏更新。
# 我们遍历所有头
for h in range(H):
attn_scores[h, row, col] += edge_bias_per_head[:, h]
# 5. 应用注意力掩码(如填充掩码)
if attention_mask is not None:
attn_scores = attn_scores.masked_fill(attention_mask.unsqueeze(0), float('-inf'))
# 6. 计算注意力权重和输出
attn_weights = torch.softmax(attn_scores, dim=-1) # [H, N, N]
attn_weights = self.dropout(attn_weights)
attn_output = torch.matmul(attn_weights, v) # [H, N, head_dim]
# 7. 合并多头,输出投影
attn_output = attn_output.transpose(0, 1).contiguous().view(N, D) # [N, D]
attn_output = self.out_proj(attn_output)
return attn_output
```
### 3.3 组装完整的Graphormer层与模型
一个完整的Graphormer层包含注意力、前馈网络(FFN)、层归一化和残差连接。
```python
class GraphormerEncoderLayer(nn.Module):
def __init__(self, hidden_dim=256, num_heads=8, ffn_dim=1024, dropout=0.1):
super().__init__()
self.self_attn = GraphormerAttentionLayer(hidden_dim, num_heads, dropout)
self.norm1 = nn.LayerNorm(hidden_dim)
self.dropout1 = nn.Dropout(dropout)
# FFN
self.ffn = nn.Sequential(
nn.Linear(hidden_dim, ffn_dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(ffn_dim, hidden_dim),
nn.Dropout(dropout),
)
self.norm2 = nn.LayerNorm(hidden_dim)
def forward(self, node_feat, spatial_bias, edge_index, edge_feat, attention_mask=None):
# 自注意力子层
attn_output = self.self_attn(node_feat, spatial_bias, edge_index, edge_feat, attention_mask)
node_feat = node_feat + self.dropout1(attn_output)
node_feat = self.norm1(node_feat)
# FFN子层
ffn_output = self.ffn(node_feat)
node_feat = node_feat + ffn_output
node_feat = self.norm2(node_feat)
return node_feat
```
现在,我们可以创建整个Graphormer模型,它由多个编码层堆叠而成,最后接一个图池化层和预测头。
```python
class GraphormerForGraphClassification(nn.Module):
def __init__(self,
node_feat_dim,
edge_feat_dim,
hidden_dim=256,
num_layers=6,
num_heads=8,
ffn_dim=1024,
max_spd=10,
num_edge_types=5,
dropout=0.1,
num_classes=1):
super().__init__()
self.hidden_dim = hidden_dim
self.node_feat_dim = node_feat_dim
self.edge_feat_dim = edge_feat_dim
# 1. 结构化编码器
self.structural_enc = StructuralEncodings(hidden_dim, max_spd, num_edge_types)
# 2. 节点特征投影(如果原始节点特征维度不等于hidden_dim)
self.node_proj = nn.Linear(node_feat_dim, hidden_dim) if node_feat_dim != hidden_dim else nn.Identity()
# 3. Graphormer编码器堆叠
self.layers = nn.ModuleList([
GraphormerEncoderLayer(hidden_dim, num_heads, ffn_dim, dropout)
for _ in range(num_layers)
])
# 4. 图池化与输出层
self.pooling = nn.AdaptiveAvgPool1d(1) # 全局平均池化
self.output_layer = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim // 2),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim // 2, num_classes)
)
def forward(self, data):
# 计算结构化编码
node_feat, spatial_bias, edge_feat = self.structural_enc(data)
# 投影节点特征
node_feat = self.node_proj(node_feat) # [N, D]
# 准备注意力掩码(这里主要针对不同大小的图进行批处理时的填充,单图训练可暂不考虑)
# 假设我们处理单图,无需掩码
attention_mask = None
# 逐层通过Graphormer编码器
for layer in self.layers:
node_feat = layer(node_feat, spatial_bias, data.edge_index_orig, edge_feat, attention_mask)
# 图级表示:对节点维度进行全局平均池化
# node_feat: [N, D] -> [D, N] -> 池化 -> [D, 1] -> [1, D]
graph_feat = self.pooling(node_feat.transpose(0, 1)).squeeze(-1).unsqueeze(0)
# 预测
logits = self.output_layer(graph_feat)
return logits.squeeze(0) # 输出形状 [num_classes]
```
## 4. 模型训练、评估与优化技巧
有了模型,我们需要设计训练循环,并解决Graphormer应用中的实际挑战,尤其是**显存消耗**。
### 4.1 设计训练循环与评估函数
我们将使用OGB提供的标准数据分割(训练/验证/测试集)和评估指标(对于`ogbg-molhiv`是ROC-AUC)。
```python
from ogb.graphproppred import Evaluator
import torch.optim as optim
from tqdm import tqdm
def train(model, device, loader, optimizer, loss_fn):
model.train()
total_loss = 0
for step, batch in enumerate(tqdm(loader, desc="Training")):
batch = batch.to(device)
# 对batch中的每个图单独处理(因为我们的模型目前是单图输入的)
# 注意:这里需要实现一个批处理版本的前处理,为了简化,我们先按单图循环(效率低,仅演示)
# 在实际中,你需要编写支持批处理的preprocess函数,并调整模型以处理批处理的稀疏注意力。
optimizer.zero_grad()
# 这里跳过批处理细节,假设`batch`是一个图
pred = model(batch)
y = batch.y.view(pred.shape).to(torch.float32)
loss = loss_fn(pred, y)
loss.backward()
optimizer.step()
total_loss += loss.item()
return total_loss / len(loader)
def evaluate(model, device, loader, evaluator):
model.eval()
y_true = []
y_pred = []
with torch.no_grad():
for batch in tqdm(loader, desc="Evaluating"):
batch = batch.to(device)
pred = model(batch)
y_true.append(batch.y.view(pred.shape))
y_pred.append(pred)
y_true = torch.cat(y_true, dim=0).cpu().numpy()
y_pred = torch.cat(y_pred, dim=0).cpu().numpy()
input_dict = {"y_true": y_true, "y_pred": y_pred}
return evaluator.eval(input_dict) # 返回评估结果字典,如 {'rocauc': 0.75}
# 初始化模型、优化器等
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GraphormerForGraphClassification(
node_feat_dim=processed_dataset[0].x.size(1),
edge_feat_dim=processed_dataset[0].edge_attr_orig.size(1),
hidden_dim=128, # 为了节省显存,使用较小的维度
num_layers=4,
num_heads=4,
max_spd=5
).to(device)
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5)
loss_fn = nn.BCEWithLogitsLoss() # 二分类任务
evaluator = Evaluator(name='ogbg-molhiv')
# 创建数据加载器 (这里使用原始数据集,需要适配批处理预处理)
# 注意:我们需要一个自定义的collate_fn来批量处理图并计算批处理版本的结构化编码。
# 由于实现较复杂,此处省略。这通常是Graphormer工程化中最具挑战的部分之一。
# 一个策略是使用“虚拟节点”将多个小图连接成一个大图,并利用注意力掩码隔离不同子图。
```
### 4.2 显存优化关键策略
全连接注意力机制导致`O(N^2)`的复杂度,对于大分子图(>100个原子)显存消耗巨大。以下是一些实战中必须考虑的优化技巧:
* **梯度检查点**:在训练深层Graphormer时,使用`torch.utils.checkpoint`可以以计算时间换取显存,只保存部分中间激活值。
```python
from torch.utils.checkpoint import checkpoint
# 在forward中,可以将某些层包装在checkpoint中
def custom_forward(layer, *inputs):
return layer(*inputs)
# 在循环中
node_feat = checkpoint(custom_forward, layer, node_feat, spatial_bias, edge_index, edge_feat, attention_mask)
```
* **混合精度训练**:使用`torch.cuda.amp`进行自动混合精度训练,能显著减少显存占用并可能加速训练。
```python
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
# 在训练循环中
with autocast():
pred = model(batch)
loss = loss_fn(pred, y)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
```
* **稀疏注意力或分块注意力**:这是最根本的优化。可以尝试:
* **局部注意力**:限制每个节点只关注其k跳邻居。
* **BigBird式稀疏模式**:结合局部、全局和随机注意力。
* **线性注意力变体**:研究如Performer、Linear Transformer等,将复杂度降至`O(N)`。
* **高效的批处理**:如前所述,设计一个支持批处理的预处理和模型forward流程,避免对单图循环,充分利用GPU并行能力。
### 4.3 结果可视化与模型诊断
训练完成后,理解模型学到了什么至关重要。
```python
import matplotlib.pyplot as plt
import seaborn as sns
def visualize_attention(model, data_sample, device, layer_idx=0, head_idx=0):
"""
可视化某个特定层和头的注意力权重图。
注意:需要修改模型以返回注意力权重。
"""
model.eval()
data_sample = data_sample.to(device)
# ... (前向传播,并钩取指定注意力层的权重)
# attn_weights shape: [num_heads, N, N]
# 假设我们获取到了attn_weights
attn_map = attn_weights[head_idx].cpu().detach().numpy()
plt.figure(figsize=(10,8))
sns.heatmap(attn_map, cmap='viridis', square=True)
plt.title(f'Attention Map - Layer {layer_idx}, Head {head_idx}')
plt.xlabel('Key Atom Index')
plt.ylabel('Query Atom Index')
plt.show()
def plot_training_curves(train_losses, val_aucs):
fig, ax1 = plt.subplots(figsize=(10,5))
ax1.plot(train_losses, 'b-', label='Training Loss')
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss', color='b')
ax1.tick_params(axis='y', labelcolor='b')
ax2 = ax1.twinx()
ax2.plot(val_aucs, 'r-', label='Validation AUC')
ax2.set_ylabel('ROC-AUC', color='r')
ax2.tick_params(axis='y', labelcolor='r')
fig.tight_layout()
plt.title('Training Loss and Validation AUC over Epochs')
fig.legend(loc='upper left')
plt.show()
```
在分子性质预测任务上,一个训练良好的Graphormer模型在`ogbg-molhiv`验证集上的ROC-AUC有望超过0.78,这已经比许多传统的GNN基线模型有显著提升。当然,要达到论文中报告的最高水平(>0.86),还需要更精细的调参、更深的模型、可能的三维坐标信息以及在大规模数据集上的预训练。
构建这个模型的过程让我深刻体会到,将前沿论文转化为可运行的代码,最大的挑战往往不是理解数学公式,而是处理工程上的“脏活累活”:高效的数据预处理、巧妙的批处理设计、严峻的显存限制,以及大量的调试和实验。当你看到自己搭建的模型开始从分子结构中学习到有意义的规律,并做出不错的预测时,这些努力都是值得的。