4. 去除盐和溶剂分子(Desalting)——给分子“卸妆”

嗯,聊到这一步,我想大家应该已经习惯了 SMILES 里那些“不干净”的东西。说实话,从公开数据库(比如 ChEMBL、PubChem)或者供应商那边拿到的分子结构,很少是“纯”的。它们常常带着盐、溶剂分子、结晶水。这就像你收到一份测序样本,结果里面混着 buffer 和 stabilizer——直接上机跑,结果肯定是一团糟。

为什么要专门讲去盐?因为我吃过这个亏。有一次我跑一个基于指纹的相似性搜索,结果跑出来一堆乱七八糟的分子。查了半天,发现是 SMILES 里带了 NaCl,指纹计算全被那个“Na”带偏了。说白了,盐和溶剂就是噪音。我们必须给分子“卸妆”,把真正的核心结构剥离出来。

核心原则:去盐不是删除所有带电基团。我们需要保留共价键连接的阳离子(如季铵盐),只去掉那些游离的、作为单独组分的离子对或溶剂分子。

4.1 为什么要去盐?——这不是洁癖,是原则

你想想看,如果你要训练一个分子性质预测模型,输入是 CCCO.CC(=O)O(丙醇+醋酸),模型会学到什么?它会学到“这个分子有-OH、有-O-、有-COOH”,但这其实是两个分子。真实活性是基于哪个成分的?不知道。这就引入了不可控的歧义。

去盐的核心价值有三个:

  • 保证结构完整性:只保留药效团,去掉干扰物。
  • 提升数据一致性:同样一个药物分子,可能有盐酸盐、硫酸盐、磷酸盐形式。去掉后统一成游离碱或游离酸,方便对比。
  • 避免计算误差:盐离子会影响 logP、MW 等描述符的计算,也会让分子图变得复杂无比。

4.2 检测盐和溶剂的“定位”方法

我个人习惯在做任何预处理之前,先跑一遍“成分检测”。SMILES 支持通过 .(点号)连接多个组分。如果 SMILES 里包含两个点号连接的片段,那大概率就有盐或溶剂。

# 示例:含盐的 SMILES
Cn1c(=O)c2c(ncn2C)n(C)c1=O.[Na]    # 咖啡因的钠盐?不对,实际上是咖啡因 + Na⁺
O=C(O)c1ccccc1.NC(CO)(CO)CO        # 苯甲酸 + 三羟甲基氨基甲烷(Tris buffer)
CC(C)CC.CCCCCC                      # 异戊烷 + 己烷(纯溶剂混合物)

注意,点号不代表任何化学键,它只是表示“这些分子在同一个体系里共存”。因此,我们可以用正则表达式或者 RDKit 的 SanitizeMol 来拆解这些片段。

小技巧:你可以用 RDKit 的 MolFromSmiles 解析后,调用 rdkit.Chem.rdMolDescriptors.CalcNumRotatableBonds 之类的方法,但如果 SMILES 里有游离盐,直接解析会报错(比如 [Na])。需要先做规范化。

4.3 去盐的常用策略

去盐不是无脑删。我总结了三步策略:

  1. 拆解组分:用 . 切分 SMILES,得到组分列表。
  2. 识别盐/溶剂:根据预定义规则(小分子离子、常见溶剂、金属离子)标记每个组分。
  3. 保留主体:通常保留最重的那个有机片段,或者保留所有非盐非溶剂的片段。

为什么强调“通常”?因为有时候药物分子本身就是电解质(如季铵盐),这时候保留阳离子部分反而会丢失结构。我个人习惯用“分子量 + 元素组成 + 官能团”综合判断。

类型 常见例子 处理方法
无机盐 NaCl, KCl, MgSO₄ 直接移除
有机酸盐 CH₃COO⁻, HCOO⁻ 根据 pH/电荷判断是否移除
质子化溶剂 H₂O, CH₃OH, DMSO 一般移除(除非是结晶溶剂)
目标分子本身就是盐 四甲基铵氯化物 保留阳离子部分,删除反离子

4.4 实战:用 RDKit 和自定义规则去盐

好,我们来写点代码。我最常用的方法是用 RDKit 的 SaltRemover 和 GetMolFrags。

from rdkit import Chem
from rdkit.Chem import SaltRemover

def desalt_smiles(smiles, remove_defn_file=None):
    """
    去盐函数:移除常见无机盐和溶剂
    :param smiles: 输入的 SMILES 字符串
    :param remove_defn_file: 自定义盐/溶剂定义的 SMARTS 文件路径
    :return: 去盐后的 SMILES,如果只剩盐则返回 None
    """
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None, 'Invalid SMILES'

    # 使用 RDKit 内置的 SaltRemover
    remover = SaltRemover.SaltRemover(defnData=remove_defn_file)
    frags = remover.StripMol(mol, dontRemoveEverything=True)
    # StripMol 会返回一个包含所有剩余片段的 Mol 对象
    clean_smi = Chem.MolToSmiles(frags, isomericSmiles=True)
    return clean_smi, 'Desalted'

# 使用默认的盐定义
print(desalt_smiles('CC(=O)O.[Na]'))  # 应该输出 CC(=O)O
print(desalt_smiles('CCO.CCOC'))      # 乙醇+乙醚,两个都是溶剂,怎么办?

注意上面第二个例子:CCO.CCOC(乙醇+乙醚),它们都是溶剂。RDKit 默认不会把它们当作“盐”移除。这时候需要自定义规则。

4.5 构建自定义盐/溶剂字典

我曾经历过一次痛苦:处理一批从 PubChem 下载的分子,里面含有大量的 H₂O、CH₃OH、DMSO。RDKit 的默认盐库不覆盖这些。后来我建了一个自定义 SMARTS 文件。

# 自定义溶剂 / 离子定义(保存为 custom_salts.smarts)
# 格式:SMARTS pattern, 类型注释
[OH2],water
[CH3]O,methanol
CS(=O)C,DMSO
[Na+];[Cl-],salt
[K+];[Cl-],salt
[NH4+];[Cl-],salt

然后用这个文件初始化 SaltRemover:

def desalt_custom(smiles):
    remover = SaltRemover.SaltRemover(defnData='custom_salts.smarts')
    mol = Chem.MolFromSmiles(smiles)
    if mol:
        clean = remover.StripMol(mol, dontRemoveEverything=True)
        return Chem.MolToSmiles(clean)
    return smiles

进阶技巧:对于复杂体系,可以先用 Chem.GetMolFrags 获取所有组分,然后用分子量阈值(比如 < 50)筛选出潜在的盐/溶剂。我常用这个逻辑来捕获那些没有被 SMARTS 覆盖的偶然发现。

from rdkit.Chem import Fragments

def desalt_by_frags(smiles, max_mw=80.0):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    frags = Chem.GetMolFrags(mol, asMols=True)
    kept = []
    for f in frags:
        mw = Descriptors.MolWt(f)
        # 如果分子量大于阈值,或者包含氨基酸残基,保留
        if mw > max_mw or has_organic_core(f):
            kept.append(f)
    if not kept:
        return None
    # 合并保留的片段,通常只有一个主体
    combined = Chem.CombineMols(*kept) if len(kept) > 1 else kept[0]
    return Chem.MolToSmiles(combined)

4.6 避坑指南

我曾经在一个项目中,直接把所有含金属的片段都删了。结果发现有一类分子是金属有机配合物(比如钌催化剂),它们的中心金属是活性必需部分。所以,去盐不能一刀切。

  • 不要删除共价结合的金属:比如 c1ccc([Ru]c2ccccc2)cc1,这个 Ru 是配位在环上的,不是游离盐。
  • 注意异构体标记:去盐后,如果主体结构有手性中心,SMILES 末尾的 @ 或 @@ 不能丢掉。
  • 晶体水 vs 结晶溶剂:晶体水(.[H]O[H])有时候是维持晶型必需的,但在计算模型里通常是噪音。除非你研究晶体结构预测,否则删了没问题。
  • 检查点号数量:一个 SMILES 里点号过多(比如超过 5 个),很可能是聚合反应混合物,不适合用简单去盐处理。

我的经验:我一般会在去盐之后输出一份报告,列出被移除的片段类型和 SMILES,方便回头校对。如果发现重要成分被误删,可以及时调整规则。

4.7 整体流程可视化

下面这张图总结了我平时处理 SMILES 去盐的完整逻辑。你看,从输入到输出,每一步都在做“筛子”。

Desalting 流程:从含盐 SMILES 到纯化结构 输入 SMILES 用 "." 拆分 获取所有碎片片段 匹配盐/溶剂字典? SMARTS / 分子量 / 元素 移除该片段 (丢弃) 保留为主体结构 纯化后 SMILES 注意: • 共价金属配合物保留 • 手性标记不可丢失 • 可能需要迭代去盐

这个流程看着长,其实写成一个函数也就十几行。关键就在于那个“匹配盐/溶剂字典”的判断节点。字典的好坏,直接决定了去盐的精度。

4.8 总结一些实用建议

去盐这件事,说难不难,说容易也容易踩坑。我最后再说几点:

  • 先跑一遍统计:看看你的数据集里有多少 SMILES 包含点号。如果超过 30%,去盐就是绕不过的预处理步骤。
  • 不要迷信默认字典:RDKit 内置的盐库覆盖了常见的无机离子,但溶剂和水需要自己补充。我建议把你自己数据集里出现的、不想要的片段手动加进去。
  • 保留日志:每次去盐后,对比一下去盐前后的分子数。如果损失率超过 5%,可能需要检查是否误删了有效分子。

最后的一个坑:有些数据库会在 SMILES 末尾用 | 或 / 标记盐信息(比如 PubChem 的 CC1=C(C=C(C=C1)Cl)C(=O)O.Cl)。我建议在去盐之前先统一做一次 SMILES 标准化(比如统一成 . 连接),否则正则规则会漏掉。