在计算化学和药物发现领域,如何把一个分子变成计算机能理解的数字向量,是一切下游任务的基础。长期以来,业内主要依赖分子指纹(如ECFP)和人工描述符来做这件事,但这些方法生成的向量高度稀疏,动辄上万千维却只有少数几位非零,不仅浪费存储和计算,还丢失了大量结构信息。图神经网络(GNN)的出现改变了这一局面:它直接把分子当作图来处理,原子是节点,化学键是边,让模型自己学习紧凑而稠密的表示。本文将系统讲解这套思路的来龙去脉与实战做法。

一、传统分子表示为什么稀疏且低效
先看问题的根源。一个分子最常用的文本表达是SMILES字符串,比如阿司匹林可以写成CC(=O)OC1=CC=CC=C1C(=O)O。SMILES适合存储和检索,但它是线性序列,分子本身却是典型的图结构,同一分子可以对应多个合法的SMILES,这种一对多的映射让序列模型很难学到稳定的表征。
为了解决这个问题,业界设计了分子指纹:把分子的局部子结构哈希到一个固定长度的位向量上。典型代表是ECFP(Morgan指纹),它通过迭代记录每个原子的圆形邻域环境,把出现的子结构映射到向量的某一位上置1。问题在于,一个含有几十个原子的分子,生成的指纹向量动辄1024位甚至2048位,其中非零位通常只有几十个,稀疏度超过95%。更麻烦的是,不同子结构可能哈希冲突到同一位,造成信息互相覆盖。
稀疏向量带来的后果是双重的。第一,向量中没有任何距离和顺序语义,两个结构相似的分子在指纹空间里可能相距很远,模型难以利用结构相似性做泛化。第二,稀疏高维输入迫使下游模型(如随机森林、全连接网络)使用更多参数去拟合噪声,在小数据集的药物场景下极易过拟合。本质上,这是把结构理解问题粗暴地转嫁给了统计模型。
二、图神经网络如何学习稠密分子表示
GNN的核心思想是消息传递(Message Passing)。每个原子节点初始化一个特征向量,包含元素类型、价键数、电荷、芳香性等信息;每条边也带有键类型特征。模型在每一层做三件事:邻居节点向中心节点发送消息,中心节点聚合这些消息,再与自身状态融合更新。经过K层迭代后,每个原子的表示就编码了K跳邻域的化学环境。
经过多层堆叠后,通过读出操作(Readout,比如求和或平均池化)把所有原子向量聚合成一个固定长度的分子级向量。这个向量是稠密的,每一维都由模型在训练中自动学出,不需要人工设计。以GCN为例,其单层更新公式为:
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv, global_mean_pool
class MoleculeGCN(torch.nn.Module):
def __init__(self, num_features, hidden_dim=64, num_classes=1):
super().__init__()
self.conv1 = GCNConv(num_features, hidden_dim)
self.conv2 = GCNConv(hidden_dim, hidden_dim)
self.conv3 = GCNConv(hidden_dim, hidden_dim)
self.fc = torch.nn.Linear(hidden_dim, num_classes)
def forward(self, data):
x, edge_index, batch = data.x, data.edge_index, data.batch
# 三层图卷积,每层扩大一跳的感知范围
x = F.relu(self.conv1(x, edge_index))
x = F.relu(self.conv2(x, edge_index))
x = F.relu(self.conv3(x, edge_index))
# 读出操作:将每个分子的原子向量聚合为分子级表示
x = global_mean_pool(x, batch)
return self.fc(x).squeeze(-1)不同的GNN变体在聚合方式上各有侧重。GCN对邻居做加权平均,权重由图结构决定;GAT引入注意力机制,让模型自己学习邻居的重要性权重,对官能团中关键原子的识别更敏感;MPNN则把消息函数和更新函数显式分离,框架更通用,DeepChem和OpenAI早期不少分子模型都基于它。在实际项目中,三层的GNN配合残差连接和Dropout,通常就足以覆盖药物分子中绝大多数的局部化学环境。
三、实战:用RDKit和PyG构建分子属性预测流水线
理论讲完,下面演示一套可跑通的完整流程。工具链选择RDKit做分子解析,torch_geometric(PyG)做图构建与模型训练。目标是预测分子的溶解度(ESOL数据集),这是药物筛选中的经典回归任务。关键代码如下:
from rdkit import Chem
import torch
from torch_geometric.data import Data
# 原子特征编码:元素、度数、形式电荷、是否芳香
ATOM_FEATURES = {
'atomic_num': [6, 7, 8, 9, 15, 16, 17, 35],
'degree': [1, 2, 3, 4],
'charge': [-1, 0, 1],
}
def atom_features(atom):
feats = []
feats += [1 if atom.GetAtomicNum() == z else 0
for z in ATOM_FEATURES['atomic_num']]
feats += [1 if atom.GetDegree() == d else 0
for d in ATOM_FEATURES['degree']]
feats += [1 if atom.GetFormalCharge() == c else 0
for c in ATOM_FEATURES['charge']]
feats.append(int(atom.GetIsAromatic()))
return feats
def mol_to_graph(smiles):
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None
x = torch.tensor([atom_features(a) for a in mol.GetAtoms()],
dtype=torch.float)
edges = []
for bond in mol.GetBonds():
i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
edges.append([i, j]) # 化学键无方向,双向都加
edges.append([j, i])
edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
return Data(x=x, edge_index=edge_index)这段代码中有几个容易踩坑的细节。第一,化学键是无向的,构建边索引时必须同时加入(i, j)和(j, i),否则GCN这类有向聚合的模型会丢失一半的邻域信息。第二,原子特征的取值范围要先统计好,遇到特征表之外的原子(比如金属元素)要有兜底编码,否则测试阶段会直接报错。第三,多个分子组成batch时,PyG会自动把它们拼接成一个大图并用batch向量记录归属,读出函数一定要配合这个向量,不能简单地对所有节点求平均。
训练阶段与普通PyTorch模型没有区别,回归任务用MSE损失即可。经验上,在ESOL这类千级别样本的数据集上,三层GNN的MAE可以做到0.6左右,明显优于基于ECFP指纹的随机森林基线(通常在0.9上下)。提升的来源正是稠密表示对结构相似性的捕捉能力。
四、预训练与落地:小数据场景的进阶策略
药物发现的真实困境是标注数据稀缺——合成并测定一个分子的活性可能花费数万元。此时仅靠任务数据从头训练GNN远远不够,预训练成为标配做法。常见路线有两类:一类是自监督对比学习,通过随机遮盖原子特征让模型还原,或者在保持性质不变的前提下做子结构替换,让模型学会区分等价与不等价的变换;另一类是直接使用社区发布的大规模预训练模型,如在美国专利分子数据上训练的ChemBERTa风格模型,或者基于八千七百万分子训练的GROVER,把它们当特征提取器,在自己的小数据集上微调最后几层。
落地时还需注意两个问题。其一,GNN的过平滑现象:层数堆太多,所有节点表示会趋同,分子图一般控制在3到5层,必要时加残差连接。其二,三维构象信息:仅用二维拓扑图忽略了分子的空间折叠,对于立体选择性强的靶点,可以把距离矩阵作为额外的边特征引入,或者直接使用SE(3)-Transformer这类等变网络。这些做法在近年的抗体设计和催化剂筛选任务中已经验证了价值。
总结来看,图神经网络把分子表示从人工稀疏指纹推进到了自动学习的稠密嵌入,配合预训练技术,已经成为药物发现、材料筛选等领域的标准工具链。如果你的项目还在用指纹向量加树模型的组合,值得花一周时间迁移到GNN框架上,通常能换来一轮可观的精度提升。