数据预处理:SMILES标准化、去除盐类、去除重复、长度截断

拿到原始的 SMILES 数据后,别急着往模型里灌。我见过不少新手,下载完 ChEMBL 或 PubChem 的数据,直接就做 token 化然后开训。结果模型生成出一堆「[Na+].CC(=O)O」这种带盐的玩意,或者同一个分子出现几百次,白白浪费算力。

说白了,数据预处理做不好,模型再先进也白搭。这一章我们就来聊聊如何把「脏」SMILES 洗成「干净」SMILES。

核心目标:从原始 SMILES 到标准化、无盐、无重复、长度可控的干净序列。

SMILES 标准化

同一个分子可以写出多种 SMILES。比如苯环,有人写 c1ccccc1,有人写 C1=CC=CC=C1,还有人写 c1ccc2ccccc2c1。模型要是都见过,它就学迷糊了。

标准化的目的就是让同一个分子只有一个表示。我个人的习惯是用 RDKit 的 Chem.MolFromSmiles 和 Chem.MolToSmiles 组合拳。它在转换时会自动做凯库勒化、芳香性检测,还会把原子按规范顺序排列。

来看代码:

from rdkit import Chem

def canonicalize_smiles(smiles: str) -> str | None:
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None  # 无效 SMILES,直接扔掉
    return Chem.MolToSmiles(mol, canonical=True, isomericSmiles=False)

参数 isomericSmiles=False 意味着我们丢掉立体化学信息。如果您的任务需要区分手性(比如药物对映体),千万记得把这里改成 True。我曾经有个项目忘记设这个参数,模型生成出来的分子对映体比例完全失调,排查了好几天才发现。

经验提示:标准化后建议再过一遍 Chem.SanitizeMol,它能检查化合价的合理性。违反价态的分子(比如五价碳)直接过滤掉,以免模型学到错误的知识。

去除盐类

公共数据库里的 SMILES 很多都带盐。比如「CC(=O)O.[Na+]」代表醋酸钠,但我们的生成模型只需要有机骨架。盐类会引入金属离子和不相关的阴离子,极大干扰模型学习。

我的做法是分两步:先用点号(.)把分子拆开,然后挑出有机部分。具体来说,用 RDKit 的 MolFromSmiles 解析后,找出不含金属原子的片段。

这里有一个技巧:别硬编码金属列表。直接用 RDKit 的 GetAtomWithIdx(0).GetAtomicNum() 判断原子序数,原子序数大于 20 的基本都是金属或类金属,直接排除。

代码片段:

from rdkit import Chem
from rdkit.Chem import Descriptors

def remove_salt(smiles: str) -> str:
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    frags = Chem.GetMolFrags(mol, asMols=True)
    organic_frags = []
    for frag in frags:
        # 所有原子的原子序数都 <= 20 才保留
        if all(atom.GetAtomicNum() <= 20 for atom in frag.GetAtoms()):
            # 并且分子量大于 50(排除水、氨等小分子)
            if Descriptors.MolWt(frag) > 50:
                organic_frags.append(frag)
    if len(organic_frags) == 0:
        return None
    # 取分子量最大的片段(通常是主成分)
    main_frag = max(organic_frags, key=lambda m: Descriptors.MolWt(m))
    return Chem.MolToSmiles(main_frag, canonical=True)

注意:这个规则不绝对。有些含氟、含磷的药物可能被误伤。我建议跑完去除盐类后,人工抽检 1000 条数据,看看有没有误删的情况。如果有,就调整阈值或者用白名单兜底。

去除重复

公开数据集的重复率往往高得吓人。ChEMBL 里同一个分子可能以不同盐形式、不同 tautomer(互变异构体)多次出现。标准化之后,这些都会变成同一个规范 SMILES。

去重逻辑很简单:建一个集合,遍历标准化后的 SMILES,没见过的就保留。但我建议保留重复计数,可以当做数据增强的权重参考。

高效实现:

def deduplicate_with_counts(smiles_list: list[str]) -> list[tuple[str, int]]:
    from collections import Counter
    counter = Counter()
    for smi in smiles_list:
        canonical = canonicalize_smiles(smi)
        if canonical:
            counter[canonical] += 1
    # 返回 (smiles, count) 列表,按出现次数降序排列
    return counter.most_common()

这样做的好处是:既能拿到纯净的 unique set,又能知道哪些分子在原始数据里特别常见。做负采样或者生成质量评估时,这个计数非常有用。

长度截断

SMILES 序列有长有短。短的像甲烷(C)只有1个 token,长的比如环糊精能有 300 多个 token。如果不加限制,模型会花大量精力学习那些稀有长序列。而且 GPU 显存有限,不定长序列 padding 到最大长度会浪费大量算力。

我的策略:统计训练集 SMILES 长度的中位数和 95 百分位数。然后用 95 百分位数作为截断长度。这样既保留了绝大多数数据,又避免极端长度拖慢训练速度。

具体做法:

def length_stats(smiles_list: list[str]) -> dict:
    lengths = [len(smi) for smi in smiles_list]
    import numpy as np
    return {
        "min": min(lengths),
        "max": max(lengths),
        "median": np.median(lengths),
        "p95": np.percentile(lengths, 95),
        "p99": np.percentile(lengths, 99),
    }

# 假设我们计算得到 p95 = 128
def truncate_smiles(smiles: str, max_len: int = 128) -> str | None:
    if len(smiles) > max_len:
        return None  # 直接丢弃,比截断更安全
    return smiles

为什么不直接截断?因为截断会丢失原子信息,生成一个不完整的分子。与其让模型学残次品,不如直接丢掉。当然,如果你的数据本身很稀缺,可以改成保留前 max_len - 10 个字符,后面用特殊 token 填充。但这个属于进阶技巧,初学者先走「丢弃」路线最稳妥。

实测经验:对 ChEMBL 数据集来说,长度 128 大概能覆盖 95% 的分子。剩余 5% 的长链分子(多肽类居多)丢弃掉,模型性能几乎不受影响。但如果您的领域是抗生素或环肽,请调高到 256。

数据预处理流水线 原始 SMILES 标准化 去除盐类 去除重复 长度截断 干净 SMILES ⚠ 标准化时用 canonical=True ⚠ 去盐注意原子序数阈值 ⚠ 去重后建议保留计数 ⚠ 截断长度取 p95 分位数

完整流程

步骤 输入 -> 输出 关键工具 常见踩坑点
SMILES 标准化 不同写法 -> 统一规范 RDKit Chem.MolToSmiles 忘记设 isomericSmiles 参数
去除盐类 带盐 SMILES -> 有机骨架 GetMolFrags + 原子序数过滤 大分子药物可能含金属原子
去除重复 重复条目 -> 唯一集 collections.Counter 区分 tautomer 可能导致去重不彻底
长度截断 不定长序列 -> 固定最大长度 np.percentile 直接截断可能产生无效分子

把这四个步骤串起来,您就拥有了一个健壮的数据预处理管线。我习惯把它写成一个类,方便复用:

class SMILESPipeline:
    def __init__(self, max_len: int = 128, remove_salt: bool = True):
        self.max_len = max_len
        self.remove_salt_flag = remove_salt

    def process(self, smiles: str) -> str | None:
        smiles = canonicalize_smiles(smiles)
        if smiles is None:
            return None
        if self.remove_salt_flag:
            smiles = remove_salt(smiles)
            if smiles is None:
                return None
        if len(smiles) > self.max_len:
            return None
        return smiles

    def process_batch(self, smiles_list: list[str]) -> list[str]:
        results = []
        for smi in smiles_list:
            res = self.process(smi)
            if res is not None:
                results.append(res)
        return list(set(results))  # 去重

这个类直接拿去做批量处理,跑完一遍就能得到高质量的训练数据。最后再讲一句:别忘了做一次最终的去重。因为去盐和标准化之后,原本不同的 SMILES 可能会变成同一个规范形式,最后过一遍集合保证绝对干净。

嗯,数据预处理到这一步,基本就齐活了。接下来就可以放心地喂给模型了。