分子图表示:原子节点与化学键边

好,我们正式进入分子图构建的环节。说白了,就是在问一个问题:如何把一瓶化学药品,变成计算机能理解的图结构?

我个人习惯把这件事拆成两步:先确定图上有什么,再确定它们怎么连。对应到分子上,就是原子和化学键。

原子作为节点

每个原子就是图里的一个节点。但原子本身有很多属性,只画个圈不行。我们需要把这些属性编码成节点特征向量。

常见的节点特征包括:

  • 原子类型:C、N、O、S、F 等等,通常用 One-hot 编码。
  • 杂化方式:sp、sp²、sp³,这会影响原子在三维空间里的排布。
  • 形式电荷:带正电还是负电。
  • 是否在环上:这个属性对芳香性判断很重要。
  • 氢原子数量:隐式氢还是显式氢,会影响图的拓扑。
  • 手性标记:R/S 构型,手性分子必须重点处理。

实践中常用的原子特征维度

我在做药物分子筛选时,发现不同力场的原子类型映射是个大坑。有的库把碳原子分成十几种,有的只分两种。我的建议是:如果你不是做量子化学计算,用元素周期表那一套就够了,别给自己找麻烦。

特征太少了,模型学不到东西。特征太多了,模型容易过拟合。业界常用的方案,是使用 RDKit 的 GetMorganFingerprint 配合预先定义的原子特征列表。下面是个示例:

from rdkit import Chem
from rdkit.Chem import AllChem

def atom_features(atom):
    # 常见原子类型
    atomic_num = [1, 5, 6, 7, 8, 9, 15, 16, 17, 35, 53]
    features = [1 if atom.GetAtomicNum() == x else 0 for x in atomic_num]
    # 杂化方式
    hyb = atom.GetHybridization()
    features.append(int(hyb == Chem.rdchem.HybridizationType.SP))
    features.append(int(hyb == Chem.rdchem.HybridizationType.SP2))
    features.append(int(hyb == Chem.rdchem.HybridizationType.SP3))
    # 形式电荷
    features.append(atom.GetFormalCharge())
    # 是否在环上
    features.append(int(atom.IsInRing()))
    # 氢原子数量(隐式)
    features.append(atom.GetTotalNumHs())
    return features

化学键作为边

比原子特征更麻烦的是化学键。为什么?因为化学键的种类多,而且还有单双键共轭、芳香键这种不好描述的情况。

最常见的边特征包括:

  • 键类型:单键、双键、三键、芳香键。
  • 是否共轭:对电子离域很重要。
  • 是否在环上:环内的键和环外的键,力学性质不同。
  • 立体构型:E/Z 构型。

避坑指南

我曾经把一个双键当成两个独立单键图来建,结果模型怎么都学不到芳香性。后来我意识到,芳香键是一种特殊状态,不能简单用单双键组合替代。RDKit 提供了 GetBondType() 的 AROMATIC 枚举,直接用就行。

边特征的提取比较简单,通常也是 One-hot 编码:

def bond_features(bond):
    bt = bond.GetBondType()
    features = [
        int(bt == Chem.rdchem.BondType.SINGLE),
        int(bt == Chem.rdchem.BondType.DOUBLE),
        int(bt == Chem.rdchem.BondType.TRIPLE),
        int(bt == Chem.rdchem.BondType.AROMATIC),
    ]
    features.append(int(bond.GetIsConjugated()))
    features.append(int(bond.IsInRing()))
    return features

图构建流程

有了节点特征和边特征,下一步就是把它们放到一张图里。图的存储方式有很多,最常用的就是邻接矩阵 + 特征矩阵的组合。

具体流程如下:

  1. 解析分子:用 RDKit 读入 SMILES 或 SDF 文件。
  2. 获取原子列表:提取所有原子,按顺序编号。
  3. 构建邻接矩阵:按原子对遍历化学键,如果存在键,则邻接矩阵对应位置为 1(或存储键特征索引)。
  4. 生成节点特征矩阵:每个原子的特征向量拼接成矩阵,形状为 (N, D),N 是原子数,D 是特征维度。
  5. 生成边特征张量:每条边对应一个特征向量,形状为 (E, F),E 是边数,F 是边特征维度。
def mol_to_graph_data(mol):
    atoms = mol.GetAtoms()
    n_atoms = len(atoms)
    # 节点特征
    x = [atom_features(atom) for atom in atoms]
    # 边索引 (邻接列表)
    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))  # 无向图需要双向边
        feat = bond_features(bond)
        edge_attr.append(feat)
        edge_attr.append(feat)    # 双向边特征相同
    return {
        'x': torch.tensor(x, dtype=torch.float),
        'edge_index': torch.tensor(edge_index, dtype=torch.long).t().contiguous(),
        'edge_attr': torch.tensor(edge_attr, dtype=torch.float)
    }

注意边界情况

对于小分子(原子数 < 5),孤立原子(如 He)没有化学键,邻接矩阵全是零。这类分子在图神经网络里表现很差,因为消息传递没有邻居可传。我的经验是:要么补一个自环,要么直接过滤掉。

整体知识结构

下面的 SVG 图展示了从原始分子到图结构的完整映射关系。你可以清晰地看到,分子图本质上是三种信息的组合:节点(原子)、边(化学键)和全局属性(分子量、LogP 等)。

分子图构建流程 输入:SMILES / SDF RDKit 解析 原子节点特征 • 原子类型 (C/N/O...) • 杂化方式 (sp/sp²/sp³) • 形式电荷 / 手性标记 化学键边特征 • 键类型 (单/双/三/芳香) • 是否共轭 • 是否在环上 图结构:邻接矩阵 + 特征矩阵

常见争议与经验之谈

在实际项目中,关于图表示有几个经典争议点:

争议问题 我的建议 理由
是否使用显式氢 显式氢在需要三维构象时用 隐式氢降低图复杂度,但丢失氢键信息
是否考虑立体化学 必须考虑,尤其是药物分子 立体异构体活性差异巨大
邻接矩阵用稀疏还是稠密 稀疏,因为分子图天然稀疏 稠密矩阵浪费存储且理论上有N²条边
边特征如何融入消息传递 直接用边特征更新节点表示 RGCN或GINE都支持这种模式

嗯,我再补充一个我在实际项目中的教训:刚用 GNN 做分子性质预测时,我把所有原子特征都用 One-hot 编码,结果维度高达 80 维,模型收敛极慢。后来我把原子类型限制在 10 种最常用元素,问题就解决了。你想想看,分子里出现超铀元素的概率有多大?

所以我的建议是:特征工程不是越多越好,而是越精越好。多看看你的数据集分布,再决定特征范围。

快速检查表

构建分子图时,可以按这个顺序自查:

  • 原子类型是否覆盖了数据集中 99% 的原子?
  • 边类型是否包含芳香键?
  • 是否有孤立原子?
  • 邻接矩阵是否对称?
  • 特征维度是否在 10-50 之间?