3. 分子特征表示方法概览
做 ADMET 预测,第一关就是把分子喂给模型。但分子不是一张图片,也不是一段文本,它是一团原子和化学键。怎么把这个三维世界里的东西,变成模型能理解的数字?这就是我们今天要聊的——分子特征表示。
我最早接触这个领域时,踩过一个坑。当时以为直接把 SMILES 字符串扔进 LSTM 就行,结果模型收敛奇慢,预测结果一塌糊涂。后来才明白,不同的表示方法,背后藏着完全不同的归纳偏置。选错了,模型再强也白搭。
3.1 四种主流表示方法
目前业界常用的分子表示方法,大致可以分成四类:传统描述符、图表示、序列表示、3D 构象。每个家族都有自己的优势和短板。
| 方法 | 核心思路 | 典型工具 | 优点 | 缺点 |
|---|---|---|---|---|
| 传统描述符 | 预定义规则计算分子属性 | RDKit、Mordred | 成熟、可解释、低计算量 | 丢失拓扑/构象信息,表达力有限 |
| 图表示 | 原子→节点,键→边 | PyG、DGL、RDKit | 保留局部结构,适合 GNN | 图同构问题,需要消息传递 |
| 序列表示 | 分子编码为字符串 | SMILES、InChI、SELFIES | 方便与 NLP 模型对接 | 语法敏感,空间信息丢失 |
| 3D 构象 | 三维坐标 + 原子类型 | RDKit + ETKDG、Open Babel | 包含空间信息,适合力场 | 计算贵,需要构象采样 |
3.2 传统描述符——老牌稳军
说白了,就是用手工设计的公式去算分子的各种物理化学性质。比如分子量、logP、氢键供体数、拓扑极性表面积(TPSA)等等。RDKit 里内置了 200 多个这样的描述符,我平时做快速基线时最喜欢用它。
举个例子,你要算一个分子的 logP 和 TPSA:
from rdkit import Chem
from rdkit.Chem import Descriptors, Lipinski
mol = Chem.MolFromSmiles('CC(=O)OC1=CC=CC=C1C(=O)O')
logp = Descriptors.MolLogP(mol)
tpsa = Descriptors.TPSA(mol)
hbond_donor = Lipinski.NumHDonors(mol)
print(f"logP: {logp:.2f}, TPSA: {tpsa:.2f}, HBD: {hbond_donor}")
我踩过的第一个坑:描述符之间往往有强烈的相关性。比如 logP 和分子量,在脂肪族化合物里常常正相关。如果你直接把几百个描述符扔进线性模型,多重共线性会让你头大。我的做法是先做特征筛选,或者用 PCA 降维。
Descriptors.descList 查看所有描述符列表,然后结合领域知识选 10-20 个关键特征,效果往往比全量要好。
3.3 图表示——分子的自然语言
分子的本质就是一张图。原子是节点,化学键是边。如果把每个原子用一个特征向量表示(原子类型、度数、电荷等),把键也编码,就得到了分子图。
用图的好处是保留了局部连通性。比如苯环的共轭结构,在图里就是 6 个节点和 6 条边构成的一个环。GNN 通过消息传递机制,能让邻居原子的信息逐步聚合到中心节点上。我做 ADMET 毒副作用预测时,发现图表示对局部的反应性位点非常敏感,比如某个芳环上的取代基是代谢活性的关键。
下面是把一个分子转成 PyG 图数据对象的示例:
from rdkit import Chem
from torch_geometric.data import Data
import torch
def mol_to_graph(smiles):
mol = Chem.MolFromSmiles(smiles)
# 原子特征:one-hot 编码原子类型 + 度数
atom_features = []
for atom in mol.GetAtoms():
feat = [atom.GetAtomicNum(), atom.GetDegree(), ...]
atom_features.append(feat)
x = torch.tensor(atom_features, dtype=torch.float)
# 边索引
edges = []
for bond in mol.GetBonds():
i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
edges.append([i, j])
edges.append([j, i]) # 无向图
edge_index = torch.tensor(edges, dtype=torch.long).t().contiguous()
return Data(x=x, edge_index=edge_index)
CanonicalRankAtoms)或在数据增强时做随机扰动。我曾因为这个在验证集上得到两个不同的预测,排查了好久才发现是编号不一致惹的祸。
3.4 序列表示——分子的语言模型
SMILES 是最常用的分子序列,它用符号和括号表示分支、环和立体化学。比如苯酚就是 Oc1ccccc1。InChI 是另一种标准,它更唯一但不易读。
为什么有人用序列?因为你可以直接用 NLP 的那一套——Transformer、BERT、GPT——来处理分子。我自己做过一个实验:用 100 万 SMILES 预训练一个 BERT 模型,然后在下游 ADMET 任务上微调,效果竟然比随机初始化的 GNN 好一点点。但别高兴太早,SMILES 有个大毛病:同一个分子可以写出多个合法 SMILES(除非用规范化),而且正则化的 SMILES 依赖于 RDKit 的规范算法。
下面是一个 SMILES 解析与规范化的例子:
from rdkit import Chem
smiles = "Oc1ccccc1" # 苯酚的简单表示
mol = Chem.MolFromSmiles(smiles)
canonical_smiles = Chem.MolToSmiles(mol, canonical=True)
print(canonical_smiles) # 输出: c1ccc(cc1)O
嗯,这里要注意:SMILES 的长度差异很大。一个小分子只有 10 个字符,大分子可能有几百。如果你的序列模型需要固定长度,会浪费大量算力在 padding 上。我通常用 SELFIES 替代 SMILES,因为它天生是任务无关的,且对非法字符鲁棒。
3.5 3D 构象——加入空间维度
2D 表示(图、序列)忽略了原子的三维排布。而很多 ADMET 性质,比如 CYP450 的代谢位点、穿过细胞膜的难易程度,都和分子的三维形状直接相关。3D 构象表示就是给每个原子分配一个三维坐标(x, y, z)。
获取 3D 构象最简单的方式是用 RDKit 的力场或者 ETKDG 算法生成一个低能构象:
from rdkit import Chem
from rdkit.Chem import AllChem
mol = Chem.MolFromSmiles('CCO') # 乙醇
mol = Chem.AddHs(mol) # 加氢
AllChem.EmbedMolecule(mol, randomSeed=42) # 生成 3D 坐标
AllChem.MMFFOptimizeMolecule(mol) # 力场优化
conf = mol.GetConformer()
# 获取原子坐标
pos = conf.GetPositions()
print(pos.shape) # (9, 3) # 9个原子,xyz
我踩过的坑:同一个分子的不同构象,能量可能差别很大。如果只用一个构象,相当于忽略了分子的柔性。我的解决方法是采样多个构象(比如 10 个低能构象),然后用图神经网络对每个构象分别编码,最后做池化。这在某些 ADMET 任务上能提高 3-5% 的准确率。
3.6 四种方法的协同与选择
下面这张图梳理了四种表示方法在整个 ADMET 工作流中的位置和互补关系。每种方法都不是万能的,但它们组合在一起就能覆盖大部分场景。
我个人在实际项目中的选型策略:如果 data 量少(<1 万),用传统描述符做 baseline 最快;如果 data 量大(>10 万),优先尝试图 Transformer 或序列 BERT;如果任务对立体化学敏感(比如 hERG 钾通道抑制),一定要加上 3D 构象表示。组合使用往往比单打独斗强 10-15%。