第4章:分子清洗:空值、重复与无效SMILES

数据来了,别急着往模型里塞。

说实话,我早期做QSAR项目时,拿到一个几万条的数据集,心想“直接跑吧”。结果模型训练出来,R²只有0.3。排查了半天,发现罪魁祸首是数据里有50多条空值的SMILES,还有一堆重复的分子和吃了乱码的无效结构。嗯,从那以后,我养成了一个习惯:清洗先行。

这一章咱们就来聊聊分子清洗的三个关键动作:空值处理、重复分子去除、无效SMILES过滤。你想想看,这三步要是没做好,后面所有的数据增强和模型训练都是白费功夫。

一句话总结:分子清洗的目标不是让数据变完美,而是让数据“可信”。相信我,干净的数据直接决定了模型的性能上限。

4.1 空值处理:别让NaN毁了你的模型

空值,就是数据集中那些“缺失”的SMILES字符串。它在表格里就是NaN、None或空字符串。我最开始没太当回事,结果模型在推理时遇到空值直接报错,整个pipeline都崩了。

怎么做?

我个人习惯是先做一遍空值检测,然后根据业务场景决定怎么处理。常用的有两种策略:

  • 直接删除:如果空值行只占整体数据的一小部分(<5%),直接扔掉最省事。
  • 填充或标记:如果空值影响较大,可以用“UNKNOWN”等占位符填充,或者在训练时做一个mask处理。

实战代码示例:

我用的是Pandas处理,简单几行就搞定。

import pandas as pd

# 读入数据
df = pd.read_csv('molecules.csv')

# 检测SMILES列的空值
print(f"空值数量: {df['smiles'].isnull().sum()}")

# 方式一:删除空值行
df_clean = df.dropna(subset=['smiles'])

# 方式二:填充为'UNKNOWN'(只在不得已时用)
# df['smiles'] = df['smiles'].fillna('UNKNOWN')

注意:千万不要用均值或中位数这种东西去填充SMILES。

SMILES是字符串,不是数值。我见过有人把空值填充为“C”,理由是“最常见的原子是碳”。结果模型学了一堆假样本,输出全变成甲烷了。你想想看,这能对吗?

4.2 重复分子去除:别让你的模型“过拟合”到重复数据

重复分子是数据清洗里的重灾区。很多人觉得“重复就重复嘛,多学一次”。错了。如果你的数据里有大量重复分子,模型会不自觉地去记忆这些样本,导致泛化能力变差。

去重方法:

我建议分两步走。

  • 第一步:基于SMILES字符串去重。简单,速度快,但有个坑——SMILES本身不是规范的。比如“CCO”和“OCC”都是乙醇,但字符串不同。
  • 第二步:基于分子指纹或规范SMILES去重。这才是靠谱的做法。我们用RDKit先把SMILES转成规范形式,再做去重。

为什么第二步更靠谱?

你想想看,同一个分子可以写成多种SMILES形式,比如苯环可以写“c1ccccc1”或“C1=CC=CC=C1”。字符串去重会保留这两个,但分子指纹去重会认为它们是同一个分子。

代码实现:

from rdkit import Chem

def canonical_smiles(smiles):
    """将SMILES转为规范形式,避免同一个分子因表示不同而被保留多次"""
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    return Chem.MolToSmiles(mol)

# 添加规范列
df['canonical_smiles'] = df['smiles'].apply(canonical_smiles)

# 去重:基于规范SMILES
df_dedup = df.drop_duplicates(subset=['canonical_smiles'])

print(f"去重前: {len(df)} 条, 去重后: {len(df_dedup)} 条")

小技巧:如果数据集很大,你可以考虑将SMILES拆分为不同的哈希桶,或者按长度分组去重,这样可以显著提升速度。我在处理百万级分子库时就是这么干的。

4.3 无效SMILES过滤:守住化学信息学的底线

这是最要命的。无效SMILES就是那些无法被RDKit解析的字符串。原因很多:手写错误、编码问题、或者是来自老旧数据库的遗留格式。

我记曾经处理过一个从公开平台爬下来的数据集,里面有条SMILES长这样:“C1CC1C(=O)[O-].[Na+]”。看起来没问题,但实际上它表示的是分子+反离子——这本身是有效的,但很多下游任务并不需要反离子。类似的还有溶剂分子、同位素标记、错误化合价等问题。

硬过滤规则:

我一般用三把“筛子”:

  1. 正则表达式预检查:快速过滤掉明显不合法的SMILES,比如包含非法字符、括号不匹配等。
  2. RDKit解析验证:核心步骤。如果Chem.MolFromSmiles返回None,直接丢掉。
  3. 原子类型和价态检查:有些SMILES虽然能被解析,但分子存在反常价态(比如碳连了5个键)。这类数据也建议剔除。

完整的过滤代码:

from rdkit import Chem
from rdkit.Chem import rdMolDescriptors
import re

def is_valid_smiles(smiles):
    """三步过滤:正则 + 解析 + 价态检查"""
    # 第一步:快速正则过滤
    if not re.match(r'^[A-Za-z0-9@+\-\[\]\(\)=#%\./\\:]+$', str(smiles)):
        return False
    
    # 第二步:RDKit解析
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return False
    
    # 第三步:价态检查(Sanitize时会自动校验)
    try:
        Chem.SanitizeMol(mol)
    except:
        return False
    
    return True

# 应用过滤
df['valid'] = df['smiles'].apply(is_valid_smiles)
df_filtered = df[df['valid']].copy()

print(f"原始数量: {len(df)}, 有效数量: {len(df_filtered)}, 过滤掉: {len(df) - len(df_filtered)}")

重要提醒:RDKit的SanitizeMol默认会做化合价检查、芳香性检测等。但如果你处理的是过渡金属配合物或特殊团簇,你可能需要调整sanitize的参数,否则会误杀有效分子。我在处理有机金属催化剂数据集时就吃了这个亏。

4.4 本章核心知识结构

为了让你对这些清洗步骤有一个整体认知,我画了一张流程框架图。你可以把它当作清洗管线的基本蓝图。

分子清洗管线流程 分子清洗管线(清洗三件套) 原始SMILES数据 阶段一:空值处理 检测NaN → 删除或填充(建议删除) 阶段二:重复分子去除 字符串去重 → 规范SMILES去重 (推荐用RDKit转为规范形式) 阶段三:无效SMILES过滤 正则检查 → RDKit解析 → 化合价校验 (SanitizeMol + 异常捕获) 干净、可用的SMILES数据

4.5 清洗策略对比:一步到位还是循序渐进?

有些人喜欢所有清洗步骤一起做,有些人则习惯分段做。我个人推荐分段执行。你想想看,如果先做合并清洗,一旦出错,你根本不知道是空值误删了有效样本,还是无效过滤把新结构都剔了。

几种清洗策略的对比表:

策略 优点 缺点 适用场景
三步独立串联 每一步都可见、可回溯 代码稍长,需要维护中间结果 小到中型数据集(<10万条)
一步到位函数化 干净利落,适合生产pipeline 调试困难,日志不清晰 高吞吐、完善测试的大规模数据集
先空值→再去重→后过滤 效率最高,去重能顺便减少后续计算量 空值行如果有SMILES被误判为重复,可能多删 绝大多数常规项目

我最常用的是第三种——先处理空值,再去重,最后过滤无效结构。这个顺序的核心逻辑是:先把不完整的数据踢出去,再让重复分子“合并同类项”,最后用化学规则做最终筛选。

个人经验:我在做一个包含150万条化合物库的项目时,用了这种串行清洗策略。清洗完之后,数据还剩126万条。你猜怎么着?空值比例其实只有0.5%,但重复分子占了12%(大部分是不同来源的合并数据),无效SMILES占了4.5%。

这些数字说明:重复分子是最大的“隐形凶手”。很多人只关注SMILES格式对不对,却忽略了重复数据带来的偏差。

4.6 避坑指南:我踩过的那些坑

嗯,最后再和你分享几个真实踩过的坑,希望你别重蹈覆辙。

  • 坑一:把空值填充成了“C”。——前面说过了,不重复吐槽了。
  • 坑二:用字符串去重代替规范SMILES去重。——我早期一个分子库去重后还剩3000条,结果一查,其中有200条其实是同一个分子。规范SMILES一跑,直接砍到2800条。
  • 坑三:无效SMILES过滤里用了太严格的正则。——比如正则里不允许数字出现在特定位置,结果把一些带同位素标记的有效结构(比如[13C])都过滤掉了。那一次我排查了整整一个下午。
  • 坑四:忘记处理None类型的空值。——有的数据源里空值明明是NaN,你用isnull()能检测到。但有些数据源是用字符串“None”来表示缺失的。如果你直接拿去解析,Chem.MolFromSmiles('None')不会报错,它会解析出一个错误的分子。所以,空值判断时记得同时检查pd.isnull和字符串形式的“None”。

推荐检查清单:

清洗完成后,可以按以下清单快速验证:

  1. 是否有任何SMILES看起来像数字或乱码?
  2. 去重后的数据量和原始量相比,减少比例是否合理?
  3. 随机选50条有效SMILES,手动用RDKit验证能否成功解析?

通过了这三步,你的清洗工作基本就稳了。

好了,分子清洗这一步,说白了就是“去伪存真”。别怕麻烦,这一步做得越仔细,后面的数据增强和模型训练就越顺畅。