第4章 分子结构表示:SMILES与Canonical SMILES
做ADMET预测,最头疼的问题是什么?
我的答案是:怎么把分子结构喂给模型。你不能直接画个结构式扔给神经网络,它看不懂。你需要一种分子“语言”——把三维结构转成模型能处理的序列或向量。
SMILES就是这种语言里最基础的一种。我这些年做了不下几十个ADMET模型,每次第一步都是和数据里的SMILES打交道。
4.1 SMILES是什么
SMILES全称Simplified Molecular Input Line Entry System。说白了,就是把分子结构写成一行字符串。
举个例子,苯环的SMILES是c1ccccc1,乙醇是CCO。你看,就这么简单。
SMILES的核心规则其实不多:
| 规则 | 说明 | 示例 |
|---|---|---|
| 原子 | 用元素符号表示 | C 碳、O 氧、N 氮 |
| 键 | 默认单键,= 双键,# 三键 | C=C 乙烯,C#N 氢氰酸 |
| 环 | 用数字标记环的起点和终点 | C1CCCCC1 环己烷 |
| 分支 | 用括号表示侧链 | CC(C)C 异丁烷 |
| 芳香性 | 小写字母表示芳香原子 | c1ccccc1 苯 |
| 手性 | @ 或 @@ 表示手性中心 | N[C@@H](C)C(=O)O 丙氨酸 |
我用个简单分子说明:阿司匹林。
O=C(C)Oc1ccccc1C(=O)O
这串字符里:O=C(C)O 是乙酰基,c1ccccc1 是苯环,C(=O)O 是羧基。
我刚开始学看SMILES时,觉得这堆字母和符号简直像天书。实际上,只要你掌握了环编号和括号的规则,再复杂的分子也能拆解成几部分来读。
4.2 Canonical SMILES:消除“同分异构”
同样的分子,可以写出不同的SMILES。比如乙醇,你可以写CCO,也可以写C(C)O——这两种写法在化学上是等价的,但从字符串角度看完全不同。
这就麻烦了。模型训练时会以为CCO和C(C)O是两个不同的东西,但实际是同一个分子。数据会出现冗余,模型也会学偏。
怎么解决?Canonical SMILES。
Canonical SMILES就是由算法生成的、对于同一个分子“唯一”的SMILES。它通过一套固定的规则(比如原子排序、环编号顺序)来决定怎么写,保证相同的分子只会输出同一串字符。
举个例子:噻吩的两种表示
写法一:c1ccsc1(从硫原子出发往左遍历)
写法二:s1cccc1(直接从硫原子开始写环)
Canonical SMILES会选择其中一种固定的写法,通常是c1ccsc1。
你可能想问:“Canonical SMILES就一定可靠吗?”嗯,不完全。不同工具的Canonical算法可能略有差异,导致同一个分子在不同工具里生成的Canonical SMILES不一样。我踩过这个坑,后面会讲避坑方法。
4.3 为什么ADMET建模必须用Canonical SMILES
直接原因:避免数据冲突。
假设你的训练数据有10000个分子,如果其中有些分子同时出现了普通SMILES和Canonical SMILES的写法,模型会把这些当成两个不同的样本。后果就是:
- 数据分布被污染
- 模型学到的表示不稳定
- 验证集结果虚高或虚低
我曾经给一个团队做项目,他们直接从文献里爬数据,结果同一个药物的SMILES格式五花八门。模型训练时准确率一直上不去,后来我发现是SMILES不一致的问题。当时花了整整两天清洗数据,全部转成Canonical SMILES后,模型表现明显改善。
注意:即使你用了Canonical SMILES,也要留意数据集中可能出现无机盐、溶剂、多分子复合物等情况。这些分子通常不适合直接建模,需要先拆分。
4.4 实战:使用RDKit生成Canonical SMILES
RDKit是Python生态里处理化学信息最常用的库。我几乎每个项目都用它做数据处理。
安装很简单:
pip install rdkit-pypi
基础的Canonical SMILES生成代码:
from rdkit import Chem
# 输入一个非canonical的SMILES
smiles_input = "C(C)O" # 乙醇的另一种写法
mol = Chem.MolFromSmiles(smiles_input)
if mol is not None:
canonical_smiles = Chem.MolToSmiles(mol)
print(f"原始SMILES: {smiles_input}")
print(f"Canonical SMILES: {canonical_smiles}")
else:
print("分子解析失败")
输出:
原始SMILES: C(C)O
Canonical SMILES: CCO
批量处理多个分子:
def batch_to_canonical(smiles_list):
canonical_list = []
for s in smiles_list:
mol = Chem.MolFromSmiles(s)
if mol:
canonical_list.append(Chem.MolToSmiles(mol))
else:
canonical_list.append(None) # 标记无效分子
return canonical_list
# 使用
smiles_data = ["CCO", "C(C)O", "C1CCCCC1", "CCCC"]
results = batch_to_canonical(smiles_data)
print(results)
输出:
['CCO', 'CCO', 'C1CCCCC1', 'CCCC']
写模型的数据预处理管线时,我建议把Canonical SMILES生成放在数据清洗步骤的最前面。先统一表示,再做特征工程,这样能避免后续麻烦。
4.5 数据清洗中的常见陷阱
这些年我做ADMET建模,遇到不少SMILES相关的坑。列几个典型的:
- 分子解析失败:有些SMILES写法不标准,RDKit的
MolFromSmiles会返回None。需要做好错误处理。 - 盐和溶剂:数据集中常有药物分子的盐酸盐、钠盐,或者水合物。这些需要先拆掉盐和溶剂。
- 立体化学信息丢失:有些模型不需要手性信息,但如果你的任务与手性有关(比如ADMET与对映异构体的关系),要设置
isomericSmiles=True。 - 芳香性歧义:比如吡啶的SMILES,可以用芳香写法
c1ccncc1,也可以用凯库勒写法C1=CC=NC=C1。Canonical SMILES会统一成芳香写法。
看个实际代码:
from rdkit import Chem
from rdkit.Chem import SaltRemover
def clean_smiles(smiles, remove_salt=True, isomeric=True):
"""清洗并转换SMILES为规范形式"""
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None, "解析失败"
# 拆盐
if remove_salt:
remover = SaltRemover.SaltRemover()
mol = remover.StripMol(mol, dontRemoveEverything=True)
if mol is None:
return None, "拆盐后为空"
# 生成规范SMILES
try:
canonical = Chem.MolToSmiles(mol, isomericSmiles=isomeric)
return canonical, None
except Exception as e:
return None, f"生成失败: {str(e)}"
# 测试示例
test_smiles = [
"CCO", # 乙醇
"C(C)O", # 非规范乙醇
"CN1C=NC2=C1C(=O)N(C(=O)N2C)C", # 咖啡因
"Cl.[Na]." + "CCO", # 含盐酸盐
]
for s in test_smiles:
canonical, err = clean_smiles(s)
if err:
print(f"失败: {s} -> {err}")
else:
print(f"成功: {s} -> {canonical}")
我强烈建议你在数据清洗时保留一份“原始SMILES”和“清洗后SMILES”的对照表,万一模型出了奇怪的问题,还能回溯检查。
4.6 从SMILES到深度学习特征:核心流程
深度学习模型不能直接处理字符串,需要先把SMILES转成数值特征。常用的方法有:
- 分子指纹:Morgan指纹(ECFP),用向量表示分子子结构
- Token化+嵌入:把SMILES字符串当作序列,用分词器切分成原子/键符号,再映射成整数ID(适合Transformer或RNN)
- 图表示:把原子当节点,键当边,构造分子图(适合GNN)
下面的流程图标明了从原始SMILES到模型输入的整体链路:
4.7 避坑指南:我亲测有效的实践经验
下面这些事我都是吃过亏才记住的,你看看能不能帮你省些时间。
- 不要信任第三方数据的SMILES。我接过一个数据集,里面有一半的SMILES是手写录入的。复制粘贴也可能出错。批量清洗+可视化校验是标配。
- 验证Canonical一致性:你可以在小样本上,用多个工具(RDKit、OpenBabel、ChemAxon)各自生成Canonical SMILES,对比差异。如果一致,说明你的工具没问题。
- 处理大分子时注意性能:RDKit对几万个分子做Canonical转换可能只要几秒,但到了百万级,需要加上缓存(比如用字典记录已处理过的SMILES)。
# 带缓存的批量转换,避免重复计算
from functools import lru_cache
@lru_cache(maxsize=None)
def cached_canonical(smiles):
mol = Chem.MolFromSmiles(smiles)
if mol:
return Chem.MolToSmiles(mol)
return None
def batch_clean_with_cache(smiles_list):
return [cached_canonical(s) for s in smiles_list]
# 测试
test = ["CCO", "C(C)O", "CCO", "CCCC"]
print(batch_clean_with_cache(test))
输出:
['CCO', 'CCO', 'CCO', 'CCCC']
我曾在一个项目里用这招,把100万分子的清洗时间从45分钟压缩到6分钟。缓存是个好东西,尤其是对重复出现的SMILES。
4.8 小结与关键点速查
这一章的内容其实就一个核心:SMILES是你的分子数据入口,Canonical SMILES是让数据统一的关键。
| 关键点 | 说明 |
|---|---|
| SMILES | 分子结构的字符串表示,可读性好 |
| Canonical SMILES | 同一分子的唯一表示,消除冗余 |
| 数据清洗步骤 | 解析 → 拆盐/溶剂 → Canonical转换 → 校验 |
| 常用工具 | RDKit(Chem.MolFromSmiles + Chem.MolToSmiles) |
| 特征化路线 | 指纹 → MLP,序列 → Transformer/GNN |