4. 数据预处理与质量控制:缺失值、异常值与标准化
数据预处理这事儿,怎么说呢,就像做菜前洗菜切菜。菜没洗干净,后面厨艺再好也是白搭。ADMET数据尤其如此——批次效应本来就够头疼了,要是数据质量再出问题,分析结果就完全是误导了。
我做过不少ADMET项目,坦白讲,大部分时间都耗在数据清洗上。拿到手的数据,缺失值、异常值、量纲不统一,几乎成了常态。所以这一章,我把自己的处理套路梳理出来,希望能帮你少踩点坑。
缺失值处理:别急着删,先看原因
缺失值在ADMET数据里太常见了。可能某个化合物的溶解度没测,可能某个重复实验的数据丢了。我个人习惯,第一步不是决定怎么填充,而是先搞清楚缺失原因。
一般来说,缺失分三类:
- 完全随机缺失(MCAR):没有规律,纯属偶然。比如样品打翻了、仪器临时故障。
- 随机缺失(MAR):缺失跟观测到的其他变量有关。比如高logD的化合物更容易在实验中丢失。
- 非随机缺失(MNAR):缺失跟变量本身的值有关。比如毒性高的化合物,实验做到一半被叫停。
不同原因,处理方法完全不同。如果是MCAR,删掉几行影响不大;如果是MNAR,直接删除会引入偏差,得小心。
我手里常用的缺失值处理方式,大致分两类:删除和填充。
| 方法 | 适用场景 | 注意事项 |
|---|---|---|
| 直接删除(行/列) | 缺失比例 < 5%,且原因明确 | 会损失样本量,注意批次平衡 |
| 均值/中位数填充 | 数值型变量,缺失随机 | 会降低方差,影响下游建模 |
| KNN填充 | 有相似样本的特征空间 | 计算量大,但效果更好 |
| MICE(链式方程) | 多个变量存在复杂依赖 | 迭代过程,需要检查收敛 |
异常值检测: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 |
| 对异常值鲁棒性 | 弱 | 强 |
| 数据分布要求 | 靠近正态 | 任意分布 |
| 推荐场景 | 质控严格的数据 | 有离群值的数据 |
知识结构速览
下面这张图,是我梳理的整个数据预处理流程。核心就是三条主线:缺失值打底、异常值过滤、标准化收尾。
一个完整的预处理流水线
上面讲了这么多,我来整合成一个完整流程。代码我也分享一下:
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参数,记录所有中间结果。
嗯,最后再啰嗦一句:数据预处理不是一次性的活儿。你可能会在分析过程中发现新的问题,然后回头重新调整缺失值策略、更换异常检测阈值。这都很正常。记住一点:预处理的目标是让数据更能反映真实信号,而不是让数据变得更「好看」。有时候保留一些异常值,反而能发现批次效应的线索。