第4章 分子特征工程:原子类型、键类型、手性、电荷等特征

好,到了本章,我们正式进入「把手弄脏」的阶段。

很多同学刚开始接触图神经网络分子建模时,总觉得模型够「深」就够了。但实际上,我见过太多项目——包括我自己早期踩过的坑——模型死活不收敛,最后发现不是网络结构的问题,而是输入特征根本没表达清楚分子信息。

说白了,分子的图结构只是骨架。真正让GNN理解化学的,是每个节点和每条边上的特征向量。这一章我们就来聊这个。

4.1 原子特征:拿什么描述一个原子?

我先问个问题。如果让你给一个碳原子编码,你会写哪些属性?

元素种类?肯定要。但只有这个远远不够。在分子里,同一个碳原子可以连四个单键,也可以连一个双键。它可以是sp³杂化,也可以是sp²。这些信息如果不喂给网络,模型就只能瞎猜。

我个人在早期做分子性质预测模型时,吃过一个亏:只用了原子序数one-hot,结果模型把苯环上的六个碳全当成一模一样的东西。其实它们确实一样,但问题是——邻接关系也变了?不,模型因为缺乏杂化信息,始终搞不懂为什么同样的碳会有不同的化学行为。

现在行业内一般推荐的特征组合如下:

特征类别具体项编码方式维度
元素类型H, C, N, O, F, P, S, Cl, Br, I 等One-hot通常 12~20
原子度连接的原子数(0~6)One-hot7
形式电荷-1, 0, +1 等One-hot3~5
自由基电子数0, 1, 2One-hot3
杂化状态sp, sp², sp³One-hot3
芳香性是否在芳香环中二进制1
氢原子数连接的氢个数(0~4)One-hot5
手性标记R/S 或无One-hot3
在环中是否在环上二进制1

你想想看,这加起来也就四十多维度。用RDKit几行代码就能全部算出来。

from rdkit import Chem
from rdkit.Chem import rdMolDescriptors

mol = Chem.MolFromSmiles('CC(=O)O')  # 乙酸

for atom in mol.GetAtoms():
    feats = []
    feats.append(atom.GetAtomicNum())          # 元素序号
    feats.append(atom.GetDegree())             # 度
    feats.append(atom.GetFormalCharge())       # 形式电荷
    feats.append(atom.GetNumRadicalElectrons())# 自由基电子
    hybrid = str(atom.GetHybridization())      # 杂化
    feats.append(1 if 'SP2' in hybrid else 0)
    feats.append(1 if atom.GetIsAromatic() else 0)  # 芳香性
    print(f"原子 {atom.GetIdx()}: {feats}")
注意:特征并不是越多越好。有些论文把原子量、范德华半径、电负性也塞进去。但我建议你从基础组开始,先看看模型效果。特征维度一多,图卷积的参数量会爆炸,而且容易过拟合。

4.2 键特征:边信息不能丢

图神经网络里,节点之间的边不只是「连或不连」。键的类型、是否共轭、是否在环里——这些都是决定性信息。

拿单键和双键来说。在分子里,C-C单键可以自由旋转,C=C双键不行。这个差异,在力场计算和构象预测里是致命的。

我一般用下面这组键特征:

  • 键类型:单键、双键、三键、芳香键,one-hot编码,4维
  • 共轭:是否共轭,二进制,1维
  • 环结构:键是否在环中,二进制,1维
  • 立体构型:顺/反/无,one-hot,3维

加起来就9维。轻量,但够用。

RDKit的提取方式也很直接:

for bond in mol.GetBonds():
    btype = bond.GetBondTypeAsDouble()
    # 1.0 单键, 2.0 双键, 3.0 三键, 1.5 芳香键
    feats = []
    feats.append(1.0 if btype == 1.0 else 0)
    feats.append(1.0 if btype == 2.0 else 0)
    feats.append(1.0 if btype == 3.0 else 0)
    feats.append(1.0 if btype == 1.5 else 0)
    feats.append(1.0 if bond.GetIsConjugated() else 0)
    feats.append(1.0 if bond.IsInRing() else 0)
    # 立体
    stereo = bond.GetStereo()
    feats.append(1.0 if stereo == Chem.rdchem.BondStereo.STEREOZ else 0)
    feats.append(1.0 if stereo == Chem.rdchem.BondStereo.STEREOE else 0)
    print(f"键 {bond.GetIdx()}: {feats}")

4.3 手性特征:R/S构型怎么编码?

做药物设计项目时,我吃过手性的亏。

有一次模型预测某个分子的生物活性,结果总是比实验值低一个数量级。查了好久才发现,RDKit读入的SMILES不带立体信息,所有手性中心全被当成了平面结构。我赶紧补上了CIP标记,模型才恢复正常。

手性标记通常分成两级:

  1. 是否存在手性中心:二进制标记,1维
  2. R/S或None:one-hot编码,3维

在RDKit中:

from rdkit.Chem import rdMolDescriptors

mol = Chem.MolFromSmiles('C[C@H](O)CO')
for atom in mol.GetAtoms():
    chiral_tag = atom.GetChiralTag()
    if chiral_tag == Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CW:
        print(f"原子 {atom.GetIdx()}: 手性 R")
    elif chiral_tag == Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CCW:
        print(f"原子 {atom.GetIdx()}: 手性 S")
警告:如果输入数据是SMILES格式,一定要确认它包含@或@@标记。很多公共数据集里的SMILES是没手性信息的,直接用会导致模型完全学不到立体选择性。

4.4 电荷与其它重要特征

形式电荷这个东西,我建议分两种情况来看:

  • 如果分子是中性,形式电荷基本都是0,可以不做特征
  • 如果分子是离子(比如[NH4]+、[CH3COO]-),那形式电荷就是非常强的信号

另外,还有一个容易被忽略的特征——隐式氢数。一个碳连三个氢还是连一个氢,完全决定了它的反应活性。

我习惯把隐式氢数当作单独一组特征,而不是直接用原子序数推导:

total_h = atom.GetTotalNumHs()  # 隐式 + 显式氢
# 输出 0~4,做 one-hot

4.5 特征组合的工程实践

说了这么多,最终的特征向量长什么样?

举一个具体的例子——苯环上的碳原子:

原子特征 (43维):
  [C one-hot: 0,1,0,0,0,...]
  [度: 0,0,1,0,0,0,0]  # degree=3
  [形式电荷: 0,1,0]
  [自由基: 1,0,0]
  [杂化: 0,1,0]  # sp²
  [芳香性: 1]
  [隐式氢: 0,1,0,0,0]
  [手性: 1,0,0,0]
  [在环中: 1]

键特征 (9维):
  [单,双,三,芳香: 0,0,0,1]
  [共轭: 1]
  [在环中: 1]
  [顺/反/无: 0,0,1]

整个分子打包成一个异构图,节点特征矩阵 shape 为 (N_atoms, 43),边特征矩阵为 (N_bonds, 9)。

4.6 本章知识结构图

分子特征工程全景图 原始分子 (SMILES / MOL) 原子级特征 (43维) 元素 / 度 / 电荷 杂化 / 芳香性 手性 / 隐式氢 键级特征 (9维) 键类型 (单/双/三/芳香) 共轭 / 环 / 立体 全局分子特征 分子量 / LogP HBA / HBD 拼接 → 完整特征向量 (N_atoms × 43, N_bonds × 9) GNN 节点/边更新 分子性质预测 构效关系分析 核心实践:先用 RDKit 原子/键迭代器提取低维特征,在送入 GNN 前做 Batch Normalization 手性信息必须从带 @ 标记的 SMILES 或 SDF 文件中读取,否则模型自动忽略立体化学

4.7 特征预处理的一些坑

最后聊几个实战中容易翻车的地方:

1. 归一化问题。 原子序数、形式电荷这些特征的量级差异很大。我一般用原子序数做 one-hot,但有时候也会用归一化的连续值。直接裸送整型数容易让网络训练不稳定。建议至少做个 min-max 或 z-score 归一化。
2. 特征顺序。 同一个数据集里,不同样本的原子数量不一样。特征拼接的顺序必须保持一致。我习惯按原子索引从小到大拼,然后在图数据里配上邻接矩阵。这样数据管道清晰,debug 也容易。
3. 缺失值处理。 有些原子没有手性标记,有些键不在环里。one-hot 里全补 0 向量就行,但千万别漏掉维度。我在一个项目里因为少写了一列「芳香性」特征,结果模型把所有苯环都当成了孤立双键系统…嗯,那种教训一次就够了。