4. RdKit 分子对象操作:核心三板斧

好,我们正式进入实战环节。前面几章我们把图论基础过了一遍,也大致了解了分子图的抽象。但说到底,你得先把一个真实的分子弄进 Python 环境里,才能往下做图构建。RdKit 就是干这个的——它把 SMILES 字符串变成分子对象,反过来也行。

我个人最早接触 RdKit 时,觉得它像个瑞士军刀。随手一行代码,就能把结构式往里塞。但要注意,不是所有字符串都能被解析。我踩过的坑是 SMILES 格式不标准,比如少括号、缺手性标记,RdKit 直接返回 None,你连警告都没有。

核心思路:分子对象是图结构的宿主。你后续所有特征提取、子结构搜索、分子指纹计算,都基于这个对象。搞定它,后面就顺了。

4.1 Chem.MolFromSmiles:把字符串变分子

这是最常用的入口函数。你把一个 SMILES 字符串传进去,它给你返回一个 rdkit.Chem.rdmol.Mol 对象。如果 SMILES 非法,返回 None。

from rdkit import Chem

smiles = "CCO"  # 乙醇
mol = Chem.MolFromSmiles(smiles)
print(type(mol))  # <class 'rdkit.Chem.rdmol.Mol'>

当然,真实场景往往没那么干净。我在项目中处理过百万级的化合物库,里面有大量带空格、带中文注释的 CSV 行。你记得先做清理:

# 常见坑:SMILES 里带了多余引号或括号不匹配
raw = '"CCO"'  
clean = raw.replace('"', '')
mol = Chem.MolFromSmiles(clean)
if mol is None:
    # 记录日志,不要直接跳过
    print(f"不合法 SMILES: {clean}")

为什么会返回 None?我总结了几种情况:

原因示例提示
元素符号拼写错误CCCK(钾应写为 K 但大写?括号缺失)检查原子价态
括号不匹配C(CC用 rdkit.Chem.CanonSmiles 校验
手性标记乱用C[C@@H] 没有正确连接参考 IUPAC 格式
太长的链式结构导致递归深层超 10 万个原子分段加载

对了,RdKit 内部还会做氢的自动补全。你得到的 Mol 对象默认带有隐式氢,但显式氢是不会自动加的。如果你做图神经网络需要显式氢,记得调用 Chem.AddHs(mol)。我个人习惯在特征提取前加一次,因为很多图卷积需要氢节点。

4.2 Chem.MolToSmiles:把分子变字符串

反向操作。很多时候你从 RDKit 操作完一个分子、修改完原子或键,需要把它导出为规范 SMILES 来存数据库或做对接。用 MolToSmiles 就行。

mol = Chem.MolFromSmiles("C(=O)O")
smiles_out = Chem.MolToSmiles(mol)
print(smiles_out)  # "C(=O)O" 还是 "OC=O"?

嗯,这就有意思了。输出的 SMILES 不一定是输入的原样。RdKit 会按自己的规则重排——它会把原子编号按某种顺序写出来。如果你想要 canonical(规范)形式,默认就是。但如果你要保留手性或特定分支顺序,得加参数:

# 保留原有顺序(如果可能)
smiles_kek = Chem.MolToSmiles(mol, kekuleSmiles=True)
# 不带芳香性标记
smiles_all = Chem.MolToSmiles(mol, allBondsExplicit=True)

我记得有一次做分子指纹比对,发现两个完全相同结构的分子指纹不一致,查了半天发现是 SMILES 序列化方式不同。所以做指纹或图网络时,建议统一用 canonical SMILES,否则你的图结构看似一样,但节点的顺序变了,图匹配可能出问题。

注意:MolToSmiles 返回的是字符串,不是字节。如果用 Python 2 要考虑编码,但现在一般用 Python 3,直接 str 处理就行。

4.3 分子属性获取:比你想的丰富

一个 Mol 对象里藏着大量数据。除了原子数和键数,还有分子量、LogP、氢键供受体数、环数等等。这些属性在机器学习中直接当标量特征用也很常见。

先看最基础的:

mol = Chem.MolFromSmiles("CC(C)CC(C)C(=O)O")
print(mol.GetNumAtoms())      # 原子总数(包括H?默认不包括)
print(mol.GetNumHeavyAtoms()) # 重原子数(非H)
print(mol.GetNumBonds())      # 键数
print(mol.GetNumConformers()) # 构象数(默认0)

获取单个原子或键的信息,需要遍历:

for atom in mol.GetAtoms():
    print(atom.GetAtomicNum(), atom.GetSymbol(), atom.GetFormalCharge())
    
for bond in mol.GetBonds():
    print(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx(), bond.GetBondType())

这里要注意:原子索引从 0 开始。我犯过的一个低级错误是索引越界——你可能会以为 GetNumAtoms() 返回的是最大索引+1,但小心有些操作会改变原子数(比如加氢),导致之前的索引失效。最好每次访问前重新获取。

如果你想用现有的分子描述符库,RdKit 内置了一堆:

from rdkit.Chem import Descriptors

mol = Chem.MolFromSmiles("CCO")
mw = Descriptors.MolWt(mol)          # 分子量
logp = Descriptors.MolLogP(mol)      # 脂水分配系数
hbd = Descriptors.NumHDonors(mol)    # 氢键供体
hba = Descriptors.NumHAcceptors(mol) # 氢键受体
tpsa = Descriptors.TPSA(mol)         # 极性表面积
print(mw, logp, hbd, hba, tpsa)

这些描述符在 QSAR 模型里简直是标配。但要注意,有些描述符计算很慢(比如超过 500 个原子的分子),你批量处理时建议加 cache。

4.4 子结构搜索:识别关键模式

子结构搜索是药物化学里最常用的功能之一。你想知道分子里有没有某个片段——比如羧基、苯环、酰胺键。RdKit 用 Mol.HasSubstructMatch(substructure) 判断存在性,用 GetSubstructMatch 获取匹配的原子索引。

mol = Chem.MolFromSmiles("CCO")
sub = Chem.MolFromSmiles("CO")  # 甲醇片段
match = mol.HasSubstructMatch(sub)
print(match)  # True,因为乙醇包含 C-O 键

但这里有个细节:子结构匹配默认不考虑立体化学。你如果要做精确匹配,需要设置参数。

实际项目中,我经常需要同时匹配多个片段:

mol = Chem.MolFromSmiles("C1=CC=C(C=C1)C(=O)O")  # 苯甲酸
patterns = {
    "苯环": "c1ccccc1",
    "羧基": "C(=O)O",
    "甲基": "C"
}
for name, smarts in patterns.items():
    subst = Chem.MolFromSmarts(smarts)  # 注意用 SMARTS
    if mol.HasSubstructMatch(subst):
        print(f"包含:{name}")
SMARTS vs SMILES:SMARTS 是子结构查询语言,支持通配符([*])、逻辑式。尽量用 SMARTS,不要用 SMILES 做子结构搜索,否则可能匹配到奇怪的东西。比如 C=O 在 SMILES 里是羰基,但在 SMARTS 里你要写 [CX3]=[OX1] 才能精确匹配酮羰基。

如果你要获取所有匹配的列表,用 GetSubstructMatches(返回二维列表,每个匹配是一组原子索引)。

mol = Chem.MolFromSmiles("C=CC=C")
sub = Chem.MolFromSmarts("[C]=[C]")  # 任意双键碳
matches = mol.GetSubstructMatches(sub)
print(matches)  # [(0,1), (2,3)] 两个匹配

这些索引可以直接用于后续子图提取。比如你想把子结构单独拿出来做分子图,可以配合 Chem.MolFromSmiles 新生成一个对象。

流程图:分子对象的生命周期

好,我们把上面四个操作串起来看。下面这张图展示了分子对象从 SMILES 到特征提取再到子结构搜索的完整流程。你可以把它理解为整个实战教程的第一张地图。

分子对象生命周期流程图 分子对象生命周期 SMILES 字符串 Chem.MolFromSmiles RdKit Mol 对象 原子/键属性抓取 分子描述符计算 子结构匹配 GetSubstructMatch Chem.MolToSmiles 也可回到Mol对象再次处理 应用场景 分子指纹、图神经网络、虚拟筛选、ADMET预测

这张图里的每个节点你都要能用手里的代码调出来。MolToSmiles 那条虚线是来回用的——你改了分子,再转回字符串,看它是不是还能解析成本来的结构。我自己经常这么调试,确保没改坏东西。

避坑指南:我曾经被搞过的地方

  • SMILES 里的芳香性:RdKit 会把芳香键自动识别并记录到分子里。如果你用 GetBondType() 判断,BondType.AROMATIC 和 BondType.SINGLE 是不同的。我在做图网络计算时,一开始用的 bond_type 编码没包含芳香性,结果模型精度一直上不去。
  • 加氢与不加氢:默认不加氢,但图神经网络通常需要氢节点来区分共振结构。一定要在子结构搜索前统一加氢,否则可能漏匹配。我曾经因为忘记 AddHs,导致某个活性片段死活找不到,花了一个下午才排查出来。
  • 子结构搜索的原子索引:GetSubstructMatch 返回的是原分子中的原子索引。如果你对分子做了一些操作(比如删原子),这些索引就会变。建议先备份一份原始分子对象。
  • MolFromSmiles 返回 None 后的处理:不要直接拿 None 去调用 GetNumAtoms(),会崩溃。一定要先判断 if mol is not None。

小结

这一章内容不多,但都是每天要用的基本功。你掌握了 MolFromSmiles 和 MolToSmiles 这对双向转换器,加上属性获取和子结构搜索,基本上就能完成 80% 的分子预处理工作。下一章我们会把这些操作组合起来,批量生成分子图的节点特征和边特征,正式为图神经网络准备数据。

动手试试:打开 Jupyter Notebook,随便写几个分子的 SMILES,用 HasSubstructMatch 检查它们是否含有特定的药效团(比如苯环+羰基)。顺手把 MolToSmiles 的输出和输入对比一下,看看 RdKit 是怎么重排原子的。你会发现有时候它比你自己写的更规范,有时候又会走火入魔(笑)。