4. 数据预处理:去重、标准化、拆分训练/验证集

GAN 生成化合物,数据预处理是第一个“坑”。
我见过不少同学拿到 ChEMBL 或 ZINC 就直接开训,结果模型输出的分子一半是重复的、一半是格式错误的。原因很简单——原始数据根本没法直接用。

这一章我们就搞定三件事:去重、标准化、拆分。做完这三步,你的训练数据才算“干净”。

核心原则:GAN 的生成质量上限由训练数据决定。脏数据 → 脏生成,没有例外。

4.1 去重:别让模型学“复读”

化合物数据库里经常有重复条目。同一个分子,不同批次上传、不同命名习惯、甚至不同手性表示,都会导致重复。GAN 学这些重复结构会有什么后果?生成时疯狂输出那几个热门分子,多样性直接崩盘。

我个人习惯用 RDKit 的 Canonical SMILES 做去重键。为什么呢?因为正则化后的 SMILES 是唯一的,拿它当 key 最稳。

代码示例:

from rdkit import Chem
from rdkit.Chem import rdchem

def canonical_smiles(smi):
    mol = Chem.MolFromSmiles(smi)
    if mol is None:
        return None
    return Chem.MolToSmiles(mol, canonical=True)

def deduplicate(smiles_list):
    seen = set()
    unique = []
    for smi in smiles_list:
        can = canonical_smiles(smi)
        if can is not None and can not in seen:
            seen.add(can)
            unique.append(can)
    return unique

我曾经碰到过一个坑:有些数据集里的 SMILES 包含立体化学标记(@ 或 @@),但 GAN 输出的分子不需要手性信息。我当时没统一去手性,结果生成出来的分子总带着“@”符号,对接工具直接报错。所以去重之前,最好也把立体化学去掉——用 Chem.RemoveStereochemistry(mol)。

去重后的分子数通常能减少 5%~30%,看你用的数据集。我建议记录一下去重前后的数量,作为数据质量报告的一部分。

4.2 标准化:让数据“对齐口径”

去重只是第一步。下一步是标准化——让所有分子用同一种方式表达。为什么需要标准化?

  • SMILES 碎片化:有的数据用了“[Na]”显式写钠离子,有的用“Na+”或省略。
  • 盐和溶剂:很多分子是盐酸盐或水合物,但 GAN 只需要骨架。
  • 局部/全局电荷:部分字段会给分子加形式电荷,实际上中性的。

我在项目里总结了一套“三刀流”标准化:

  1. 去盐:把盐和溶剂分子剥离,只保留有机骨架。
  2. 去手性:移除立体标记(如非必要)。
  3. 去金属:去掉 K⁺、Na⁺、Cl⁻ 等无关离子。

对应 RDKit 代码:

def standardize_smiles(smi):
    mol = Chem.MolFromSmiles(smi)
    if mol is None:
        return None
    # 去盐/溶剂(只保留最大的片段)
    frags = Chem.GetMolFrags(mol, asMols=True)
    if len(frags) > 1:
        mol = max(frags, key=lambda m: m.GetNumAtoms())
    # 去手性
    Chem.RemoveStereochemistry(mol)
    # 去金属(简单正则匹配,常用金属类型)
    smi = Chem.MolToSmiles(mol, canonical=True)
    # 这里再筛选一次金属原子(跳过)
    return smi

一个小技巧:标准化后的 SMILES 可以拿去做分子指纹,然后用 Tanimoto 相似度再筛一遍离群值。这样做能提前发现某些结构非常奇怪的分子,避免它们污染 GAN 的 latent space。

4.3 拆分训练/验证集:别让“亲戚”串门

数据拆分的任务很简单:分出一部分数据不参与训练,用来监控模型是否过拟合。但化合物数据有点特殊——常规的随机拆分容易让相似分子同时出现在训练集和验证集里。

你想想看:如果训练集里有一个“华法林”,验证集里有个结构稍微变了一点的“华法林类似物”,模型可能只是记住了骨架而不是理解了模式。这种“骨架泄露”会让验证指标虚高。

我常用两种拆分策略:

拆分策略适用场景优势劣势
随机拆分大规模、多样性好的数据集简单、快速、验证集代表性强可能导致骨架泄露
骨架拆分药物化学先导优化、相似性控制严格避免结构过度重叠计算量稍大,且骨架定义需谨慎

两种方法我都会在项目中试一次,然后对比验证集 loss 的分布——如果随机拆分的 loss 明显低于骨架拆分,就说明数据里有很多“亲戚”,需要用骨架拆分才公平。

骨架拆分的 Python 实现可以用 RDKit 的 Murcko 骨架:

from rdkit.Chem.Scaffolds import MurckoScaffold

def get_murcko_scaffold(smi):
    mol = Chem.MolFromSmiles(smi)
    if mol is None:
        return None
    scaffold = MurckoScaffold.GetScaffoldForMol(mol)
    return Chem.MolToSmiles(scaffold)

# 按骨架分配 train/val
from collections import defaultdict
scaffold_to_indices = defaultdict(list)
for idx, smi in enumerate(all_smiles):
    scaf = get_murcko_scaffold(smi)
    if scaf:
        scaffold_to_indices[scaf].append(idx)

# 将骨架按分子数排序,用贪心算法 split
sorted_scaffolds = sorted(scaffold_to_indices.items(), key=lambda x: len(x[1]), reverse=True)
train_indices, val_indices = [], []
for scaf, indices in sorted_scaffolds:
    if len(train_indices) < len(all_smiles) * 0.8:
        train_indices.extend(indices)
    else:
        val_indices.extend(indices)

注意:千万别直接在拆分后用 SMILES 字符串打乱顺序代替随机拆分——那样等于没拆分。一定要用索引或 shuffle 后再分区。否则训练时前几个 batch 全是同一个骨架,梯度会乱跳。

拆分比例我常用 80% / 20% 或 85% / 15%,验证集大小以能稳定估计 loss 为基准,不必太大。

4.4 完整预处理流程图

把上面的步骤串起来,就是一条数据清洗流水线。实际项目里我会先用脚本跑一遍,确认没问题再进入 GAN 训练。

数据预处理流程 原始分子集合 Canonical SMILES去重 标准化(去盐/去手性) 骨架拆分 或 随机拆分 训练集 (80%) 验证集 (20%) 避坑提示 1. 去重后记录数量变化 2. 拆分后检查骨架分布 3. 验证集 loss 不应震荡

4.5 预处理后的数据验证

数据跑完流水线,不能直接扔给 GAN。我会额外做几个快速检查:

  • SMILES 合法性:随便挑 100 条,用 RDKit 转 Mol 并画出来,肉眼看看有没有苯环缺角、原子价不对之类。
  • 分子量分布:画个直方图,如果有异常过大的分子(比如分子量 >1000),多半是没去干净。
  • LogP 区间:药物分子通常 LogP 在 -2~6 之间,超出太多说明数据源有问题。

我的一贯做法:开一个 Jupyter Notebook,把预处理封装成函数,然后用 pytest 写几个单元测试(比如检查去重后唯一 SMILES 数量、检查标准化后不含‘@’)。这样每次换数据集都能快速复用。

做完这些,你的数据才算达到“生产级”。别忘了把预处理后的数据存成独立文件,比如 smiles_train.txt 和 smiles_val.txt,后面训练的时候直接读就行。