分子图构建原理

说实话,分子图这个概念我第一次听的时候觉得挺玄乎的。图?不就是原子连原子嘛。但真正动手把分子转成 GNN 能吃的数据时,才发现里头有不少细节讲究。我个人习惯先把分子拆成两个最核心的东西:原子(节点)和化学键(边)。然后呢,再分别给它们配上对应的矩阵——特征矩阵和邻接矩阵。

原子作为节点——节点特征怎么定?

每个原子就是一个节点。咱们写个分子图构建函数,第一步就是拿原子序号和对应的属性。举个最简单的例子,甲烷 CH₄:一个碳连四个氢。碳和氢都是节点,总共 5 个节点。

那节点特征是什么?我早期踩过一个坑:只把原子序数塞进去,结果模型完全分不清同分异构体。后来我加了电荷、手性标签、杂化类型、度(连接的键数)这些特征,效果立马不一样。

节点特征常用字段

  • 原子序数(整数,1~118)
  • 形式电荷(默认 0)
  • 手性中心(True/False 或 CHI_TETRAHEDRAL_CCW 等)
  • 杂化类型(SP, SP², SP³ …)
  • 在环中(True/False)
  • 连接的氢原子数(隐含氢)

构建特征矩阵的代码片段大概长这样:

import numpy as np
from rdkit import Chem

def atom_feature_vector(atom):
    features = [
        atom.GetAtomicNum(),
        atom.GetFormalCharge(),
        int(atom.GetHybridization()),
        atom.GetTotalDegree(),
        int(atom.IsInRing())
    ]
    return np.array(features, dtype=np.float32)

# 对甲烷分子
mol = Chem.MolFromSmiles('C')
atom_features = [atom_feature_vector(atom) for atom in mol.GetAtoms()]
X = np.stack(atom_features)  # shape: (num_atoms, feature_dim)

你看,每个原子变成一个固定长度的向量。不同分子的原子数不一样,特征矩阵的行数也就不一样——这就是 GNN 能处理变长输入的基础。

化学键作为边——邻接矩阵藏着的信息

原子之间靠化学键相连。在分子图中,每条化学键就是一条边。我习惯用无向图表示,因为键没有方向性。邻接矩阵 A 是一个 N×N 的 0-1 矩阵,A[i][j]=1 表示原子 i 和原子 j 之间有一条键。

不过有一点很重要:邻接矩阵默认只告诉你“有没有键”,但不告诉你“什么键”。我会在边特征里补充键的类型(单键、双键、芳香键……),但本章先只讲基础。

def build_adjacency_matrix(mol):
    n = mol.GetNumAtoms()
    A = np.zeros((n, n), dtype=np.int8)
    for bond in mol.GetBonds():
        i = bond.GetBeginAtomIdx()
        j = bond.GetEndAtomIdx()
        A[i, j] = 1
        A[j, i] = 1  # 无向图
    return A

A = build_adjacency_matrix(mol)

嗯,这里要注意:如果分子有环,邻接矩阵里就会出现环。比如苯环的邻接矩阵是六边形结构,对角线全是 0(自己连自己算自环,通常不加)。

特征矩阵与邻接矩阵——GNN 的两个输入基石

把前面两个矩阵合在一起,就构成了 GNN 的输入图。我一个标准的分子图可以写成:

  • 节点特征矩阵 X ∈ ℝN×F,F 是每个原子的特征维度。
  • 邻接矩阵 A ∈ {0,1}N×N,通常加上自环(A + I)以便消息传递时保留自身信息。

下表列出了几个常见分子的维度对比:

分子SMILES原子数 N特征维度 F邻接矩阵非零数
甲烷C554
乙醇CCO958
苯c1ccccc112512
咖啡因CN1C=NC2=C1C(=O)N(C(=O)N2C)C24526

你看,分子图的大小完全不一样。GNN 的好处就是甭管 N 是 5 还是 24,消息传递照样跑。

我的一个小建议

构建特征矩阵时,尽量把每个维度归一化到 [-1,1] 或 [0,1] 区间。原子序数从 1 到 118,直接扔进去权重会畸形。我习惯用 one-hot 对前 30 号元素编码,其余统一归为 “other”。

分子图构建流程全景

下面这张图总结了从分子结构到 GNN 输入的完整路线。我画了四个主要步骤:

分子图构建流程 分子结构 (SMILES/mol) 原子特征提取 键连接提取 构建邻接矩阵 & 特征矩阵 GNN 输入 (X, A) N 随分子变化,GNN 自适应

我曾经踩过的坑

用 RDKit 构造邻接矩阵时,如果直接遍历原子序号,别忘了跳过 mol.GetBonds() 返回的 Bond 对象里的 BeginAtomIdx 和 EndAtomIdx 都是基于当前分子对象的索引,不是全局索引。同一原子在不同分子里索引肯定不一样,这没问题,但跨分子做 embedding 时要小心映射对齐。

做完这一步,你的分子图就齐活了。特征矩阵 X 和邻接矩阵 A 是 GNN 计算最原始的输入。后面所有的消息传递、图卷积、池化操作都在这个基础上构建。