4. 数据准备:获取化合物数据集、清洗、标准化、划分

数据准备这一步,说实在的,比后面搭模型还要折腾人。我刚开始做这个项目时,觉得模型结构才是重头戏。结果呢?数据没弄干净,训练出来的LSTM直接变成了“SMILES乱码生成器”。你想想看,模型学了一堆乱糟糟的序列,怎么可能生成像样的分子?

所以,这一节我们来把数据准备的流程理清楚。做好这一步,后面训练才会轻松。

4.1 数据集选择:ChEMBL 还是 ZINC?

常用的化合物数据集就两个:ChEMBL 和 ZINC。我个人习惯用 ChEMBL,因为它数据比较干净,而且有明确的活性标注。ZINC 量更大,但里面不少分子是“虚空”的,也就是理论上能合成但实际没人做过。

数据集 规模 特点 我推荐的用途
ChEMBL ~200万分子 有生物活性数据,质量较高 药物分子生成、活性预测
ZINC ~2亿分子 包含可购买/可合成分子 虚拟筛选、大规模预训练

我的建议:刚开始做 LSTM 生成实验,用 ChEMBL 的一个子集就好。我记得我第一版实验只用了 10 万个分子,一晚上就把模型跑通了。等流程走通后,再考虑上更大的数据集。

4.2 下载与解压数据

以 ChEMBL 为例,我们从官网下载 SDF 格式的化合物文件。下载后解压,得到一个 .sdf 文件。这里用命令行就能搞定:

wget ftp://ftp.ebi.ac.uk/pub/databases/chembl/ChEMBLdb/latest/chembl_31.sdf.gz
gunzip chembl_31.sdf.gz

嗯,这一步很简单,但我还是要提醒一句:下载前先看看网络状况。我一开始没注意,结果等了俩小时发现文件只下了一半……😅

4.3 从 SDF 提取 SMILES

SDF 文件结构比较复杂,我们需要把里面的 SMILES 字符串提取出来。SMILES 是分子的“字符串表示”,LSTM 就是靠这个字符串来学习的。我用的是 rdkit 这个库,它专门用来处理分子数据。

from rdkit import Chem
from rdkit.Chem import AllChem

def extract_smiles_from_sdf(sdf_file):
    mols = []
    with open(sdf_file, 'r') as f:
        supplier = Chem.ForwardSDMolSupplier(f)
        for mol in supplier:
            if mol is not None:
                smiles = Chem.MolToSmiles(mol)
                mols.append(smiles)
    return mols

# 提取了大约 15 万个 SMILES
smiles_list = extract_smiles_from_sdf('chembl_31.sdf')
print(f"提取到 {len(smiles_list)} 个 SMILES")

关键点:一定要过滤掉 mol 为 None 的情况。有些分子结构有问题,rdkit 读不了。我遇到过一批数据里大概有 3% 的分子读不出来,直接抛弃就好,别纠结。

4.4 数据清洗:去掉“脏数据”

提取出来的 SMILES 可不能直接用。里面有大量不符合我们要求的分子。我个人习惯按以下顺序清洗:

  1. 去掉长度过短的 SMILES(少于5个字符的,肯定是异常)
  2. 去掉长度过长的 SMILES(超过200个字符的,训练起来太慢,而且容易出错)
  3. 去掉含不规范原子的 SMILES(比如 Si、B、金属原子,LSTM 不好学)
  4. 去掉含盐、溶剂的分子(只保留纯有机小分子)
  5. 去重(同一个分子可能出现多次)

具体的代码长这样:

from rdkit import Chem

def clean_smiles(smiles_list):
    clean_list = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is None:
            continue
        # 长度过滤
        if len(smi) < 5 or len(smi) > 200:
            continue
        # 只保留 C, N, O, S, F, Cl, Br, I, P 这几种原子
        allowed_atoms = {6, 7, 8, 16, 9, 17, 35, 53, 15}  # 原子序数
        atoms = [atom.GetAtomicNum() for atom in mol.GetAtoms()]
        if any(a not in allowed_atoms for a in atoms):
            continue
        # 去掉盐
        frags = Chem.GetMolFrags(mol, asMols=True)
        if len(frags) > 1:
            continue
        clean_list.append(smi)
    # 去重
    clean_list = list(set(clean_list))
    return clean_list

clean_smiles = clean_smiles(smiles_list)
print(f"清洗后剩余 {len(clean_smiles)} 个 SMILES")

曾经有过的坑:我一开始没做原子类型过滤,结果 LSTM 学会了生成含 Ru、Pd 的分子。看起来挺像那么回事,但仔细一看都是催化剂残留……完全不是我们想要的药物分子。

4.5 数据标准化:让 SMILES 格式统一

同一个分子可以有多种 SMILES 写法。比如“苯环”可以写成 c1ccccc1,也可以写成 C1=CC=CC=C1。LSTM 看到两种写法,会当成不同分子去学。这会导致语法混乱。

标准化方法很简单:用 rdkit 的 CanonSmiles 函数。

def canonicalize_smiles(smiles_list):
    can_smiles = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is not None:
            can_smi = Chem.MolToSmiles(mol, canonical=True)
            can_smiles.append(can_smi)
    return can_smiles

# 标准化后再次去重
can_smiles = canonicalize_smiles(clean_smiles)
can_smiles = list(set(can_smiles))
print(f"标准化后剩余 {len(can_smiles)} 个 SMILES")

经过清洗和标准化,我通常会损失掉 30%-50% 的原始数据。别心疼,留下来的都是精华。我遇到过有人把 200 万分子直接扔给模型训练,结果训练了三天,生成的全是无效分子……数据清洗花两小时,能省三天训练时间,这笔账很划算。

4.6 数据划分:训练/验证/测试

划分比例我通常按 80% / 10% / 10% 来。注意,这里要随机打乱,不然 LSTM 可能会学到数据的“排列顺序”。

from sklearn.model_selection import train_test_split

def split_data(smiles_list, train_ratio=0.8, val_ratio=0.1, test_ratio=0.1):
    # 先划分训练集
    train_data, temp_data = train_test_split(
        smiles_list, test_size=(1 - train_ratio), random_state=42
    )
    # 再均匀划分验证集和测试集
    val_data, test_data = train_test_split(
        temp_data, test_size=test_ratio/(val_ratio + test_ratio), random_state=42
    )
    return train_data, val_data, test_data

train_smiles, val_smiles, test_smiles = split_data(can_smiles)
print(f"训练集: {len(train_smiles)}")
print(f"验证集: {len(val_smiles)}")
print(f"测试集: {len(test_smiles)}")

我个人的经验:验证集和测试集一定要和训练集保持同样的分布。有一次我不小心用了两个不同年份的 ChEMBL 版本做划分,结果分布差异很大,验证集上的 Loss 一直比训练集高不少,折腾了两天才发现是数据划分的问题。

4.7 数据准备全流程概览

下面这张流程图把所有步骤串起来了,方便你整体把握:

数据准备全流程 获取数据集 提取 SMILES 数据清洗 标准化 数据划分 为什么是这个顺序? 1. 先有数据,才能提取 2. 提取后再清洗 3. 清洗完统一格式 4. 最后划分 每一步都依赖上一步 不能跳步

4.8 最终数据保存

清洗好的数据建议存成文本文件,每行一个 SMILES。方便后面加载。我习惯用 .txt 格式:

import json

def save_smiles(smiles_list, filepath):
    with open(filepath, 'w') as f:
        for smi in smiles_list:
            f.write(smi + '\n')

# 保存数据
save_smiles(train_smiles, 'chembl_train.txt')
save_smiles(val_smiles, 'chembl_val.txt')
save_smiles(test_smiles, 'chembl_test.txt')

数据准备这块就讲完了。你手里现在应该有三份文件:chembl_train.txt、chembl_val.txt、chembl_test.txt。接下来,我们会把这些 SMILES 字符串转成 LSTM 能理解的数字序列。嗯,那个部分也挺有意思的,到时候我们来聊聊 Tokenization 和 Vocabulary 的构建。