数据清洗与预处理:从原始数据到标准分子

数据清洗这步,说白了我把大部分时间都花在这里。你从公开数据库、实验室记录或者合作方拿来的原始数据,基本没有直接能用的。我见过太多次因为数据没洗干净,模型训完才发现全是噪声的情况——嗯,所以咱们把这章好好过一遍。

1. 去重:别让重复样本骗了你

同一个分子反复出现在训练集里,会导致模型对这条数据过拟合。你以为模型学得不错,其实它只是把那条重复样本背下来了。

我个人习惯用两个维度去重:

  • SMILES 字符串去重:标准化的 SMILES 直接比较
  • InChI Key 去重:同一分子的不同表示方式也能查出来
import pandas as pd
from rdkit import Chem

def deduplicate_molecules(df, smiles_col='SMILES'):
    # 先把 SMILES 标准化
    df['canonical_smiles'] = df[smiles_col].apply(
        lambda x: Chem.MolToSmiles(Chem.MolFromSmiles(x)) if Chem.MolFromSmiles(x) else None
    )
    # 基于标准 SMILES 去重
    df_clean = df.dropna(subset=['canonical_smiles'])
    df_clean = df_clean.drop_duplicates(subset=['canonical_smiles'])
    return df_clean

我曾经碰到过一个项目,数据显示有12000个分子,去重后发现实际只有7000个不同的结构。剩下那5000条全是不同批次、不同浓度、不同测量方式录入的同一个分子——你想想看,要是直接用,模型一半的精力都白费了。

避坑指南:我曾经用原始 SMILES 直接去重,结果发现同一个分子写成了 "CCO" 和 "C(C)O" 两种形式,根本对不上。所以一定要先标准化再做比较。

2. 标准化:把数据拉到同一把尺子上

ADMET 数据里边有溶解度(logS)、渗透性(logP)、清除率(CL)等等,量级差异巨大。不标准化的话,数值大的特征直接主导模型,小的特征就废了。

我建议分两步走:

  1. 范围标准化(MinMaxScaler):适合已知上下界的指标
  2. Z-score 标准化:适合服从正态分布的指标
from sklearn.preprocessing import StandardScaler, MinMaxScaler

# Z-score 标准化
scaler_standard = StandardScaler()
df['logD_standard'] = scaler_standard.fit_transform(df[['logD']])

# MinMax 归一化
scaler_minmax = MinMaxScaler(feature_range=(0, 1))
df['solubility_norm'] = scaler_minmax.fit_transform(df[['Solubility']])
经验之谈:对于 ADMET 预测任务,我通常优先用 Z-score。原因很简单——大多数生化指标在测量误差范围内呈正态分布。当然,如果你后面要用神经网络,MinMax 更合适一些。

3. 缺失值处理:别往模型里灌空值

现实中很少有数据是完整的。出现缺失值的原因很多:测试没跑完、记录遗漏、样品不够等等。怎么处理?嗯,这取决于缺失率。

我个人的处理规则大致如下:

  • 缺失率 > 50%:直接删除该特征,信息太少,填了也是噪声
  • 缺失率 10%-50%:尝试用中位数或 KNN 填充
  • 缺失率 < 10%:用中位数或平均值填充,简单高效

为什么会这样?因为 ADMET 数据的分布往往有偏,方差大。用中位数比均值更稳健。

from sklearn.impute import SimpleImputer

# 中位数填充
imputer = SimpleImputer(strategy='median')
df_filled = pd.DataFrame(
    imputer.fit_transform(df.select_dtypes(include=[np.number])),
    columns=df.select_dtypes(include=[np.number]).columns
)
特别注意:千万不要用 0 去填充缺失值!我在一个 logP 数据集中试过一次,结果模型全学偏了。logP 为 0 表示亲水性极强,跟你缺失数据完全不是一回事。

4. 异常值检测:揪出那些离谱的值

有些测量值明显违背物理规律。比如 logP 跑到 20 多、溶解度写成负数十几倍——这些基本可以断定是记录错误。我把异常值检测分成两个层次:

  • 统计方法:IQR(四分位距)和 Z-score
  • 领域知识方法:结合化学常识做规则过滤

我这里用 IQR 举个例子:

def detect_outliers_iqr(df, column, multiplier=1.5):
    Q1 = df[column].quantile(0.25)
    Q3 = df[column].quantile(0.75)
    IQR = Q3 - Q1
    lower_bound = Q1 - multiplier * IQR
    upper_bound = Q3 + multiplier * IQR
    # 返回异常值的索引
    outliers = df[(df[column] < lower_bound) | (df[column] > upper_bound)].index
    return outliers

不过要注意,对于 ADMET 数据,有些“极端值”其实是真实的活性化合物。比如某些 P-gp 底物的 logP 特别高——你不能一刀切全删了。我建议先用统计方法标记出来,然后人工复查一遍。

异常类型 统计方法 领域方法
记录错误 Z-score > 3 检查原始记录
测量异常 IQR 法 检查实验条件
真实极端值 标记不删除 保留并验证

5. 分子表示标准化:RDKit 归一化

这是最重要的步骤。同样的分子,不同人写出的 SMILES 可以千差万别。RDKit 就是帮我们把所有写法统一成一种规范形式。

我常用的标准化流程包括:

  • 芳香性感知:把凯库勒式转为芳香式表示
  • 电荷中和:处理带电荷的官能团
  • 同位素移除:对于常规 ADMET 建模,去掉同位素标签
  • 立体化学处理:保留或移除手性信息(根据需求)
from rdkit import Chem
from rdkit.Chem import MolStandardize

def normalize_smiles(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    # 移除立体化学(如果不需要)
    Chem.RemoveStereochemistry(mol)
    # 芳香性归一化
    Chem.Kekulize(mol, clearAromaticFlags=True)
    # 生成标准 SMILES
    canonical_smiles = Chem.MolToSmiles(mol, canonical=True)
    return canonical_smiles
我的血泪教训:有一次做 CYP 抑制模型,两个团队分别标注了同一批化合物。一个用了 "c1ccccc1" 表示苯环,另一个用了 "C1=CC=CC=C1"。结果去重时能对上,但特征编码没做标准化——同一个苯环被当作不同的取代基模式。模型直接崩了。

再加上一步更严格的标准化:

from rdkit.Chem import MolStandardize

# 使用 RDKit 内置标准化器
normalizer = MolStandardize.Standardizer()
def standardize_mol(mol):
    # 标准化共价键、电荷、质子化状态等
    mol = normalizer.normalize(mol)
    return mol

6. 完整的清洗流水线

上面这几步不是分开做的。我通常会把它整合成一个流水线,一条命令跑完所有预处理:

class ADMETPreprocessor:
    def __init__(self, data_path):
        self.data = pd.read_csv(data_path)
        
    def run_pipeline(self):
        # 1. 分子标准化
        self.data['canonical_smiles'] = self.data['SMILES'].apply(normalize_smiles)
        self.data = self.data.dropna(subset=['canonical_smiles'])
        
        # 2. 去重
        self.data = self.data.drop_duplicates(subset=['canonical_smiles'])
        
        # 3. 异常值检测
        for col in self.data.select_dtypes(include=[np.number]).columns:
            outliers = detect_outliers_iqr(self.data, col)
            # 这里我选择标记不删除,留到人工审核
            self.data.loc[outliers, col] = np.nan
            
        # 4. 缺失值填充
        imputer = SimpleImputer(strategy='median')
        numeric_cols = self.data.select_dtypes(include=[np.number]).columns
        self.data[numeric_cols] = imputer.fit_transform(self.data[numeric_cols])
        
        # 5. 标准化
        scaler = StandardScaler()
        self.data[numeric_cols] = scaler.fit_transform(self.data[numeric_cols])
        
        return self.data
最后一个小建议:每做完一步清洗,都用 df.shape 看一眼数据量。我习惯在流水线里加几个检查点——数据量突然减少,说明某些步骤有问题,可以及时回头查。

嗯,数据清洗这部分就这些。下一节咱们开始做特征工程,把分子结构转化成模型能吃的特征向量。

ADMET 数据清洗与预处理流程 原始数据 SMILES 标准化 (RDKit) 去重 异常值检测 (IQR/Z-score) 缺失值填充 标准训练数据