基础回顾(一):分子结构与图表示

做药物发现的朋友都知道,分子本质上就是一个图。你想想看,原子是节点,化学键是边——这不就是天然的图结构吗?我刚开始接触这个领域的时候,觉得把分子画成图是多此一举。后来踩了不少坑才明白,图的视角能帮我们用数学语言描述分子,这才是GNN能干QSAR的关键。

分子图构建的基本概念

一个分子图包含两个核心元素:节点和边。节点就是原子,边就是化学键。但这里有个细节要注意:不是所有键都一样。单键、双键、三键、芳香键,它们的性质完全不同。

我在项目中遇到过这样的情况:直接用0/1表示有没有键,结果模型死活学不好。后来加上了键的类型编码,准确率直接提升了8%。所以说,特征编码不是小事。

概念定义我的备注
节点(原子)分子中的每个原子注意区分重原子和氢,氢原子经常被省略
边(化学键)原子间的连接关系单键、双键、三键、芳香键要分开编码
邻接矩阵描述节点连接关系的矩阵对称矩阵,对角线为0
特征矩阵每个节点的属性向量原子类型、杂化方式、电荷等

为什么要构建邻接矩阵?因为GNN的传播机制就是靠它来聚合邻居信息的。没有邻接矩阵,图卷积就没法做。嗯,这里要注意邻接矩阵的稀疏性——一个分子通常只有几十个原子,但完全连接的矩阵会有大量0。用稀疏矩阵存储是基本操作。

原子与键的特征编码

特征编码,说白了就是把原子和键的各种性质转化为向量。我常用的原子特征包括:

  • 原子类型:C、N、O、S、F等,一般用one-hot编码
  • 原子度数:连接的邻居数量
  • 氢原子数:隐式或显式氢的数量
  • 轨道杂化:sp、sp2、sp3等
  • 芳香性:原子是否在芳香环中
  • 形式电荷:原子的净电荷
  • 原子质量:整数原子质量

键的特征相对简单一些:

  • 键的类型:单键(1)、双键(2)、三键(3)、芳香键(1.5),我习惯用one-hot
  • 共轭性:键是否在共轭体系中
  • 环状性:键是否属于环
  • 立体构型:Z/E构型等

你可能会问:这些特征怎么提取?用RDKit就行。

from rdkit import Chem
from rdkit.Chem import AllChem

mol = Chem.MolFromSmiles('c1ccccc1C(=O)O')
# 获取原子特征
for atom in mol.GetAtoms():
    features = [
        atom.GetAtomicNum(),        # 原子序数
        atom.GetDegree(),           # 度数
        atom.GetTotalNumHs(),       # 氢原子数
        atom.GetHybridization(),    # 杂化方式
        atom.GetIsAromatic(),       # 芳香性
        atom.GetFormalCharge(),     # 形式电荷
        atom.GetMass()              # 原子质量
    ]
    print(features)

# 获取键特征
for bond in mol.GetBonds():
    bond_type = bond.GetBondType()
    features = [
        bond.GetBeginAtomIdx(),
        bond.GetEndAtomIdx(),
        int(bond_type),             # 键类型
        bond.GetIsConjugated(),     # 共轭性
        bond.IsInRing()             # 环状性
    ]
    print(features)

这里要注意:每种特征最好归一化到0-1范围。我曾经在特征编码这一步偷懒,直接把原子序数当特征丢进去...结果模型完全收敛不了。为什么呢?因为原子序数差的量级太大(1到80+),而度数只有1到6,模型肯定学偏。

经验分享:我常用的原子特征维度是74维(RDKit默认的摩根特征)+ 自定义的原子属性。代码中用

表示,其实是对特征维度的标准化处理。你可以在预处理阶段统一一下。

小技巧:用RDKit的GetMorganFingerprintAsBitVect可以直接拿到固定长度的特征向量,省去手动编码的麻烦。不过注意,摩根指纹是全局特征,不是节点级别的特征。如果你要每个原子的特征,还是得用上面的方法逐原子提取。

避坑指南:我曾经在一个溶解度预测任务里,没处理好键的类型编码,结果模型把单键和双键当成一样的。后来分析发现,模型根本学不到π电子云的信息。从此以后,我对键特征特别小心——one-hot和数值混合编码是最稳妥的方案。

从分子到图的完整映射

我们来看一个实际例子。水分子H₂O:

# 水分子示例
smiles = 'O'
mol = Chem.MolFromSmiles(smiles)

# 节点:3个原子(1个O,2个H)
# 节点特征矩阵 shape: (3, feature_dim)
# 比如氧的原子序数=8,氢的原子序数=1

# 边:2个O-H单键
# 邻接矩阵 shape: (3, 3)
# [[0, 1, 1],
#  [1, 0, 0],
#  [1, 0, 0]]

# 边特征矩阵 shape: (2, edge_feature_dim)
# 两个键都是单键,one-hot编码为 [1,0,0,0]

这个映射过程看起来简单,但扩展到几十个原子的分子就不一样了。我在做活性预测项目时,经常处理500+原子的分子。邻接矩阵直接变成500x500,特征矩阵也大得吓人。这时候必须用批量处理和填充(padding)技术。

分子到图表示的核心流程 分子结构 SMILES / SDF 3D坐标 / 2D拓扑 节点特征 原子类型 / 度数 杂化 / 电荷 / 质量 边特征 键类型 / 共轭性 环状性 / 立体构型 图表示构建 邻接矩阵 + 特征矩阵 = 分子图 稀疏存储 / 填充对齐 / 批量处理 GNN输入层 图卷积 / 消息传递 / 池化 编码注意事项 特征归一化 键类型区分 稀疏矩阵存储

总结一下,分子图构建就是两件事:提取原子特征、提取键特征、拼成邻接矩阵和特征矩阵。这两件事做好了,GNN才能发挥作用。我在做QSAR项目时,光是特征编码就迭代了5个版本,才找到通用性最强的那一套。

嗯,这里要强调一点:特征编码没有银弹。不同的任务(溶解度 vs 毒性 vs 活性)对特征的敏感度不一样。我的建议是:先跑一个基线模型,然后再逐步加特征,看看每个特征对性能的影响。