第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到模型输入的整体链路:

SMILES → 深度学习特征流程 原始SMILES字符串 Canonical SMILES(清洗后) 分子指纹生成 SMILES Token化 分子图构建 数值特征矩阵/张量 深度学习模型训练 说明 1. 分子指纹:ECFP等,适合MLP 2. 序列化:SMILES分词+Embedding, 适合Transformer/RNN 3. 分子图:原子为节点,键为边, 适合GNN系列模型

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