二、分子图结构简介

好,我们开始进入正题。讲 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 步:

  1. 读入分子:从 SMILES、SDF 或 XYZ 文件获取分子对象。
  2. 选取原子:决定是否包含氢。一般步骤:去掉显式氢(保留隐式氢信息作为原子特征),只保留重原子(C、N、O、S、P 等)。注意:有些反应位点需要氢参与,就得保留。
  3. 提取原子特征:对每个原子,计算特征向量:
    • 原子类型(one-hot 编码,如 C→[1,0,0,…])
    • 原子度(连接的键数)
    • 形式电荷
    • 杂化态(sp, sp², sp³)
    • 是否为环成员
    • 氢原子数目(隐式氢)
  4. 提取键特征:对每对化学键,计算特征向量(如键类型 one-hot)。
  5. 构建张量:组合成邻接矩阵 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 图展示了分子图的整体构建过程。从左边分子结构出发,中间提取节点和边,右边得到特征矩阵和邻接矩阵。

分子图构建流程 分子结构 乙醇 (C2H6O) C C O 图构建 节点(N=3) atom 0: C atom 1: C atom 2: O 边(M=3) 0-1: 单键 0-2: 单键 1-2: 单键 矩阵表示 特征矩阵 X (3×d) [6, 2, 0, ...] [6, 2, 0, ...] [8, 1, 0, ...] 邻接矩阵 A (3×3) [0 1 1] [1 0 1] [1 1 0] 说明:上图为乙醇(CH3CH2OH)的重原子图,省略了氢原子。 实际项目中需要根据分子量、任务目标决定是否保留氢。

2.7 构建时的常见问题

问题 后果 我踩过的坑
原子特征未归一化 梯度爆炸,模型不收敛 原子序数范围 1~118,直接喂进去,loss 直接起飞
忽略键的方向性 图注意力机制算不准 一开始做无向图只用一条边,消息传递只收不发
边特征未统一维度 维度不匹配报错 不同分子键类型编码长度不同,batch 时报错
氢原子处理不一致 模型过拟合或欠拟合 同一个分子在不同库中氢数目不同,导致特征波动

个人习惯:我会在数据预处理阶段写一个统一的图构建脚本,并且把原子特征和边特征的维度固定下来(比如原子特征固定 10 维,边特征固定 4 维)。这样后续换模型只需调整特征映射层,不用改数据逻辑。