327

清洗 SMILES:从原始数据到可训练数据集

原始 SMILES 必须清洗后才能训练。这篇给出完整的清洗管线代码、每一步的判断依据,以及清洗前后必须做的统计对照。

从数据库拉下来的 SMILES 直接喂给模型,是新手最常见的错误。同一个化合物可能以盐、不同互变异构体、不同电荷状态出现,它们的指纹完全不同,模型会把它们当成不同分子——这既浪费数据,又会造成划分时的隐性泄漏。

完整的清洗管线

from rdkit import Chem, RDLogger
from rdkit.Chem.MolStandardize import rdMolStandardize
import pandas as pd

RDLogger.DisableLog("rdApp.*")          # 关掉刷屏的警告

# 复用同一批标准化器对象,比每次新建快很多
_lfc = rdMolStandardize.LargestFragmentChooser()
_unc = rdMolStandardize.Uncharger()
_te  = rdMolStandardize.TautomerEnumerator()

def clean_smiles(smi, canonical_tautomer=True):
    """返回 (标准化 SMILES, 失败原因);成功时原因为 None"""
    if not isinstance(smi, str) or not smi.strip():
        return None, "empty"
    mol = Chem.MolFromSmiles(smi)
    if mol is None:
        return None, "parse_failed"

    try:
        mol = rdMolStandardize.Cleanup(mol)      # 基础清理与价键规范
        mol = _lfc.choose(mol)                   # 去盐:只留最大片段
        mol = _unc.uncharge(mol)                 # 中和可中和的电荷
        if canonical_tautomer:
            mol = _te.Canonicalize(mol)          # 统一互变异构体
    except Exception as e:
        return None, f"standardize_failed:{type(e).__name__}"

    if mol is None or mol.GetNumAtoms() == 0:
        return None, "empty_after_cleanup"

    return Chem.MolToSmiles(mol, canonical=True), None

每一步在做什么

步骤 作用 例子
Cleanup 规范价键、金属断键、规范化官能团写法 硝基的两种写法统一
LargestFragmentChooser 去盐、去溶剂,保留主片段 CCN.ClCCN
Uncharger 中和可中和的电荷 CC(=O)[O-]CC(=O)O
TautomerEnumerator.Canonicalize 选一个规范互变异构体 酮式/烯醇式统一

互变异构体标准化:要不要开

这一步耗时最长且有争议,需要按场景判断:

  • 建议开启:做去重、算相似性、训练 2D 模型时——不统一会把同一化合物当成不同分子。
  • 建议关闭或谨慎:需要保留特定互变异构态时(如已知某个互变体才是结合构型),或分子量很大、处理速度成为瓶颈时。
  • 注意:RDKit 选的「规范互变异构体」不一定是生理条件下的主导形式,它只保证一致性,不保证正确性

批量处理与统计

from concurrent.futures import ProcessPoolExecutor
from collections import Counter

df = pd.read_csv("egfr_ic50_raw.csv")

with ProcessPoolExecutor(max_workers=8) as ex:
    results = list(ex.map(clean_smiles, df["smiles"].tolist(), chunksize=200))

df["clean_smiles"] = [r[0] for r in results]
df["fail_reason"]  = [r[1] for r in results]

# 关键:一定要统计失败情况,而不是静默丢弃
print("失败统计:", Counter(r for r in df["fail_reason"] if r))
fail_rate = df["clean_smiles"].isna().mean()
print(f"失败率:{fail_rate:.2%}")

if fail_rate > 0.05:
    print("⚠ 失败率偏高,应抽查失败样本找出原因")
    print(df[df["clean_smiles"].isna()][["smiles", "fail_reason"]].head(10))

df = df.dropna(subset=["clean_smiles"])

「统计失败率」这一步经常被跳过,但它是发现数据问题最快的方式。失败率超过 5% 通常意味着数据源有系统性问题(如包含非小分子条目、格式错误)。

清洗后的去重与对照

# 按标准化 SMILES 去重
before = len(df)
agg = df.groupby("clean_smiles").agg(
    pIC50=("pIC50", "median"),
    n_dup=("pIC50", "size"),
    spread=("pIC50", lambda x: x.max() - x.min()),
).reset_index()

print(f"去重前 {before} → 去重后 {len(agg)}(合并了 {before - len(agg)} 条)")

# 检查重复项之间是否一致
inconsistent = agg[(agg["n_dup"] > 1) & (agg["spread"] > 1.0)]
print(f"⚠ {len(inconsistent)} 个化合物的重复测量跨度 > 1 log,建议剔除")
agg = agg[~agg.index.isin(inconsistent.index)]

agg.to_csv("egfr_cleaned.csv", index=False)

去重合并的数量本身是个有用信号:如果清洗后合并掉了大量分子,说明原始数据里有很多盐型/互变异构的重复——而这些如果不合并,就会跨越训练/测试集造成泄漏。

常见坑与提示

  • MolFromSmiles 失败返回 None 而不抛异常,必须显式检查并统计失败率
  • 去盐、中和、互变异构统一三步都做完,才谈得上可靠去重;
  • 去重后检查重复测量的一致性,跨度过大的整条剔除;
  • 锁定 RDKit 版本——不同版本的标准化行为有过变化,会让历史模型的特征对不上。

延伸资源