二、分子图结构简介
好,我们开始进入正题。讲 Transformer 之前,你得先搞清楚它要处理的对象——分子图长什么样。说白了,分子图就是把一个化学分子表示成计算机能读懂的图结构。我当年刚接触这个方向时,觉得这步挺 trivial 的,不就是画个结构式吗?后来做模型实验才发现,图构建的细节直接决定了后面编码器的上限。嗯,咱们慢慢拆。
2.1 图的基本定义
图(Graph)由两部分组成:顶点集合 V 和边集合 E。通常记作 G = (V, E)。每个顶点可以带有自己的属性,每条边也可以带有属性。在分子场景下,顶点就是原子,边就是化学键。
你可能想问:为什么不直接用 SMILES 字符串?SMILES 本质上是一维序列,丢失了原子间的空间连接关系。图保留了拓扑结构,这是 Transformer 能充分利用分子信息的关键。我个人习惯把分子图看成「带属性标签的拓扑骨架」,后面的特征学习都要基于这个骨架展开。
2.2 节点与边
节点对应原子。每个原子至少需要知道它的元素种类、价态、形式电荷、是否为环成员等。我做过一个项目,只用了元素类型和杂化态两个特征,结果模型对芳香性分子的预测一塌糊涂。后来才意识到,苯环上的碳原子如果不标记环信息和共轭状态,模型根本分不清单双键交替的结构。
边对应化学键。常见的键型有单键、双键、三键、芳香键。有些任务还会把键长、键角也作为边特征,不过主流做法是只保留键型。还有一个容易被忽略的点:键的方向性(有向图 vs 无向图)。分子键本质上是无向的,但有些 Transformer 变体会把边做成双向,分别存储信息。
关键点:分子图通常是无向带标签图。节点和边的特征维度决定了模型容量。特征太少丢信息,特征太多容易过拟合,需要根据任务调整。
2.3 特征矩阵
有了图结构,怎么喂给神经网络?答案是特征矩阵 + 邻接矩阵。
- 邻接矩阵 A:大小为 N×N(N为原子数),A[i][j] 表示 i 和 j 之间有边。对于分子图,通常用 0/1 表示是否有键,也可以把键类型编码成小整数(如 1=单键,2=双键,3=三键,4=芳香键)。
- 节点特征矩阵 X:大小为 N×d,d 是每个原子的特征维度。每一行是该原子的特征向量。
举个例子。乙醇分子 C2H6O,化学式通常写成 CH3CH2OH。它有 9 个原子(2C, 6H, 1O)。但为了画图清晰,我通常省略氢原子(称为重原子图),只用 C、C、O 三个节点。重原子图的邻接矩阵是 3×3,特征矩阵是 3×d。
我早期犯过一个低级错误:把原子序号排布顺序搞乱了,导致邻接矩阵和特征矩阵的行对应不上。训练时损失一直不降,排查了三天才发现是数据预处理时的 bug。所以,构建图时一定要保证原子索引和特征索引严格一致。
避坑经验:我曾经用 RDKit 生成分子图,默认会按照原子的 SMILES 顺序排列。但如果 SMILES 不同写法(如 CCO vs OCC),原子顺序会变。务必在数据集中统一规范排序,比如按原子在分子中的规范拓扑顺序。
2.4 分子图的构建流程
下面是标准构建步骤,我把它分成 4 步:
- 读入分子:从 SMILES、SDF 或 XYZ 文件获取分子对象。
- 选取原子:决定是否包含氢。一般步骤:去掉显式氢(保留隐式氢信息作为原子特征),只保留重原子(C、N、O、S、P 等)。注意:有些反应位点需要氢参与,就得保留。
- 提取原子特征:对每个原子,计算特征向量:
- 原子类型(one-hot 编码,如 C→[1,0,0,…])
- 原子度(连接的键数)
- 形式电荷
- 杂化态(sp, sp², sp³)
- 是否为环成员
- 氢原子数目(隐式氢)
- 提取键特征:对每对化学键,计算特征向量(如键类型 one-hot)。
- 构建张量:组合成邻接矩阵 A 和节点特征矩阵 X,可能还包括边特征矩阵 E(大小为 M×e,M 为边数)。
注意:有些公开数据集已经预处理好了特征,但仍需检查特征维度和取值范围是否一致。我遇到过特征里面出现了 NaN,导致训到一半 loss 变成 inf。解决方法是建一个数据校验 pipeline,每次读入后检查是否有缺失值。
2.5 一个简单的代码示例
下面用 RDKit 和 PyTorch Geometric 演示构建分子图(只列核心步骤,不包含完整依赖)。
from rdkit import Chem
from rdkit.Chem import AllChem
import torch
from torch_geometric.data import Data
def mol_to_graph(smiles):
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None
# 获取原子个数
num_atoms = mol.GetNumAtoms()
# 原子特征矩阵(假设每个原子有5个特征)
atom_features = []
for atom in mol.GetAtoms():
feat = [
atom.GetAtomicNum(),
atom.GetDegree(),
atom.GetFormalCharge(),
atom.GetHybridization(),
1 if atom.IsInRing() else 0
]
atom_features.append(feat)
x = torch.tensor(atom_features, dtype=torch.float)
# 边索引和边特征
edge_index = []
edge_attr = []
for bond in mol.GetBonds():
i = bond.GetBeginAtomIdx()
j = bond.GetEndAtomIdx()
edge_index.append([i, j])
edge_index.append([j, i]) # 无向图增加反向边
bond_type = bond.GetBondTypeAsDouble()
edge_attr.append([bond_type])
edge_attr.append([bond_type])
edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
edge_attr = torch.tensor(edge_attr, dtype=torch.float)
data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr)
return data
这个函数返回一个 PyTorch Geometric 的 Data 对象,可以直接喂给图神经网络。注意我在这里把无向边拆成两条有向边,这是大多数 GNN 的标准做法。
2.6 本章知识结构图
下面这张 SVG 图展示了分子图的整体构建过程。从左边分子结构出发,中间提取节点和边,右边得到特征矩阵和邻接矩阵。
2.7 构建时的常见问题
| 问题 | 后果 | 我踩过的坑 |
|---|---|---|
| 原子特征未归一化 | 梯度爆炸,模型不收敛 | 原子序数范围 1~118,直接喂进去,loss 直接起飞 |
| 忽略键的方向性 | 图注意力机制算不准 | 一开始做无向图只用一条边,消息传递只收不发 |
| 边特征未统一维度 | 维度不匹配报错 | 不同分子键类型编码长度不同,batch 时报错 |
| 氢原子处理不一致 | 模型过拟合或欠拟合 | 同一个分子在不同库中氢数目不同,导致特征波动 |
个人习惯:我会在数据预处理阶段写一个统一的图构建脚本,并且把原子特征和边特征的维度固定下来(比如原子特征固定 10 维,边特征固定 4 维)。这样后续换模型只需调整特征映射层,不用改数据逻辑。