数据预处理: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 标准化 | 不同写法 -> 统一规范 | 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 可能会变成同一个规范形式,最后过一遍集合保证绝对干净。
嗯,数据预处理到这一步,基本就齐活了。接下来就可以放心地喂给模型了。