4. 数据预处理与质量控制:缺失值、异常值与标准化

数据预处理这事儿,怎么说呢,就像做菜前洗菜切菜。菜没洗干净,后面厨艺再好也是白搭。ADMET数据尤其如此——批次效应本来就够头疼了,要是数据质量再出问题,分析结果就完全是误导了。

我做过不少ADMET项目,坦白讲,大部分时间都耗在数据清洗上。拿到手的数据,缺失值、异常值、量纲不统一,几乎成了常态。所以这一章,我把自己的处理套路梳理出来,希望能帮你少踩点坑。

缺失值处理:别急着删,先看原因

缺失值在ADMET数据里太常见了。可能某个化合物的溶解度没测,可能某个重复实验的数据丢了。我个人习惯,第一步不是决定怎么填充,而是先搞清楚缺失原因。

一般来说,缺失分三类:

  • 完全随机缺失(MCAR):没有规律,纯属偶然。比如样品打翻了、仪器临时故障。
  • 随机缺失(MAR):缺失跟观测到的其他变量有关。比如高logD的化合物更容易在实验中丢失。
  • 非随机缺失(MNAR):缺失跟变量本身的值有关。比如毒性高的化合物,实验做到一半被叫停。

不同原因,处理方法完全不同。如果是MCAR,删掉几行影响不大;如果是MNAR,直接删除会引入偏差,得小心。

我手里常用的缺失值处理方式,大致分两类:删除和填充。

方法 适用场景 注意事项
直接删除(行/列) 缺失比例 < 5%,且原因明确 会损失样本量,注意批次平衡
均值/中位数填充 数值型变量,缺失随机 会降低方差,影响下游建模
KNN填充 有相似样本的特征空间 计算量大,但效果更好
MICE(链式方程) 多个变量存在复杂依赖 迭代过程,需要检查收敛
我的经验:对ADMET数据,中位数填充比均值稳健。因为很多药物性质的分布是偏态的——你想想看,活性数据经常有几个异常高的值,均值一下子就被拉偏了。中位数相对抗干扰。
注意:千万别在批次校正之前填充缺失值。我曾经踩过这个坑,填充完再做批次校正,相当于人为引入了批次间的结构差异。后来校正效果极差。正确的顺序是:先标记缺失,做完批次校正,再填充。

异常值检测:Z-score vs. MAD

异常值在ADMET数据里,往往是灾难性的。一个离谱的活性测量值,可能把整个建模结果带歪。

最经典的方法是Z-score。公式很简单:

Z = (x - μ) / σ

其中μ是均值,σ是标准差。一般把|Z| > 3的点视为异常值。但这个方法的毛病在于:均值和标准差本身对异常值非常敏感。

举个例子,我处理过一个Caco-2渗透性数据,里面有两个值特别高,直接把均值拉大了,标准差也膨胀了。结果正常的点反而被标记成异常,有问题的点倒被放过了。这就尴尬了。

所以后来我更多用MAD(Median Absolute Deviation)。这是中位数的忠实粉丝:

MAD = median(|x_i - median(x)|)
修正后的Z-score = 0.6745 * (x - median(x)) / MAD

注意那个0.6745,它是个校正因子,目的是让MAD在正态分布下跟标准差一致。这样阈值就能沿用|Z| > 3的规则。

两种方法对比一下:

指标 Z-score MAD
对异常值敏感度 高 低
计算复杂度 低 低
适用分布 近似正态 正态/偏态均可
实际推荐 大样本、质量控制严格 小样本、有离群倾向

实际操作建议

先跑一遍MAD,把明显异常的标记出来。然后人工审查一下,别急着删。有的异常值其实是批次效应的表现——比如某个批次仪器校准有问题。这时候删除数据是治标不治本,应该回头处理仪器问题。

下面是我常用的Python代码片段,直接复制就能用:

import numpy as np
from scipy import stats

def detect_outliers_zscore(data, threshold=3):
    z = np.abs(stats.zscore(data))
    return np.where(z > threshold)[0]

def detect_outliers_mad(data, threshold=3):
    median = np.median(data)
    mad = np.median(np.abs(data - median))
    modified_z = 0.6745 * (data - median) / mad
    return np.where(np.abs(modified_z) > threshold)[0]

标准化方法:Z-score vs. MAD标准化

标准化,说白了就是把不同量纲的数据拉到同一个尺度。ADMET数据里,logD、溶解度、渗透性这些指标,单位都不一样。不标准化,批次效应校正没法做。

Z-score标准化是最常用的——每个值减去均值,除以标准差。结果就是均值为0,方差为1。但前面说了,它对异常值敏感。

所以,我又绕不开MAD了。MAD标准化就是把均值换成中位数,标准差换成MAD:

标准化值 = (x - median) / MAD

我用这个方案处理过一批体外ADMET数据,结果非常稳健。即使某个批次有几条离谱的测量值,也不会把整体分布搞偏。

属性 Z-score标准化 MAD标准化
中心化依据 均值 中位数
尺度依据 标准差 MAD
对异常值鲁棒性 弱 强
数据分布要求 靠近正态 任意分布
推荐场景 质控严格的数据 有离群值的数据
重要提醒:标准化时要按批次独立进行,还是跨批次一起做?这个取决于你的目标。如果是去除批次效应,建议先跨批次标准化,再对比批次间差异。如果只是做分类建模,那就全部数据一起标准化,保持相对关系。

知识结构速览

下面这张图,是我梳理的整个数据预处理流程。核心就是三条主线:缺失值打底、异常值过滤、标准化收尾。

数据预处理与质量控制流程 原始ADMET数据 缺失值处理 删除 · 均值填充 · KNN · MICE 异常值检测 Z-score · MAD · 手动审查 标准化 Z-score标准化 · MAD标准化 干净数据集 工程师提醒 • 先标记缺失值 • 后做批次校正 • 再用MAD检测异常 • 最后标准化 • 流程顺序不能乱

一个完整的预处理流水线

上面讲了这么多,我来整合成一个完整流程。代码我也分享一下:

import pandas as pd
import numpy as np

def preprocess_admet(df, batch_col='batch', value_col='value',
                     fill_method='median', outlier_method='mad'):
    """
    ADMET数据预处理流水线
    """
    data = df.copy()

    # 1. 缺失值标记(暂不填充)
    missing_mask = data[value_col].isna()

    # 2. 按批次标准化(先统一尺度)
    if outlier_method == 'mad':
        median = data.groupby(batch_col)[value_col].transform('median')
        mad = data.groupby(batch_col)[value_col].transform(
            lambda x: np.median(np.abs(x - np.median(x)))
        )
        data['scaled'] = (data[value_col] - median) / (mad + 1e-8)
    else:
        mean = data.groupby(batch_col)[value_col].transform('mean')
        std = data.groupby(batch_col)[value_col].transform('std')
        data['scaled'] = (data[value_col] - mean) / (std + 1e-8)

    # 3. 异常检测(在标准化尺度上做)
    if outlier_method == 'mad':
        global_median = np.median(data['scaled'].dropna())
        global_mad = np.median(np.abs(data['scaled'].dropna() - global_median))
        outlier_mask = np.abs(0.6745 * (data['scaled'] - global_median) / global_mad) > 3
    else:
        z = np.abs(stats.zscore(data['scaled'].dropna()))
        outlier_mask = data.index.isin(data.index[z > 3])

    # 4. 标记异常
    data['is_outlier'] = outlier_mask

    # 5. 填充缺失值(用中位数,每个批次独立填充)
    if fill_method == 'median':
        fill_values = data.groupby(batch_col)[value_col].transform('median')
        data[value_col] = data[value_col].fillna(fill_values)

    return data
实用技巧:你在跑这个流程时,最好每一行都输出一下日志。比如缺失比例、检出多少异常、批次间均值的差异。这样复盘的时候,才知道每一步做了什么。我习惯在流水线里加一个log_dict参数,记录所有中间结果。

嗯,最后再啰嗦一句:数据预处理不是一次性的活儿。你可能会在分析过程中发现新的问题,然后回头重新调整缺失值策略、更换异常检测阈值。这都很正常。记住一点:预处理的目标是让数据更能反映真实信号,而不是让数据变得更「好看」。有时候保留一些异常值,反而能发现批次效应的线索。