4. 数据预处理:缺失值处理、异常值检测、标准化与归一化方法

拿到ADMET肝毒性特征后,别急着往模型里塞。数据预处理这块儿,我踩过的坑比走过的路还多。说白了,原始数据就像没洗的菜——看着有模样,真要下锅得先择干净。

这一章我们集中解决三个硬骨头:缺失值、异常值,还有量纲统一。顺序很重要:先处理缺失,再处理异常,最后做缩放。

数据预处理流程 数据预处理流程框架 原始特征矩阵 缺失值处理 删除/均值/中位数/插补 异常值检测 IQR/Z-score/箱线图 标准化 / 归一化 StandardScaler / MinMaxScaler 干净特征矩阵 注意 顺序不可逆 先处理缺失 再检测异常 最后做缩放

4.1 缺失值处理 —— 决定留还是扔

肝毒性特征数据常常带了「窟窿」。比如某个化合物的LogD没测到,或者某条通路的活性值仪器故障。你可以想想看,一行数据里缺了20%的字段,硬塞进模型是什么后果?轻则训练震荡,重则直接报错。

我先说三种最常用的处理方式:

  • 直接删除:缺得少的行(比如少于5%缺失)直接扔掉。我个人的习惯是,样本量大于5000且缺失比例<1%时,删除最省事。
  • 填补法:用均值、中位数、众数或kNN插补。记得我做肝毒性数据时,有些体外实验的IC50值经常缺失,用中位数填比用均值更稳健,因为IC50分布往往右偏。
  • 模型预测填补:把缺失列当作目标变量,用其他列做回归/分类来补。这个方法计算量大,但对结构化的药化特征很管用。

实战经验:我在一个CYP450抑制数据集里,发现好几个化合物的LogP缺失。直接用均值填充后,模型AUC掉了3%。后来换成kNN插补(k=5),AUC回升到原来的水平。所以,填补方法真不能随便选。

小技巧:用Pandas的df.isnull().sum()快速扫描缺失数量。配合df.isnull().mean()看缺失比例,一目了然。

代码实现简单粗暴:

import pandas as pd
import numpy as np
from sklearn.impute import SimpleImputer, KNNImputer

# 读取原始ADMET特征
df = pd.read_csv('admet_features.csv')

# 查看缺失情况
print(df.isnull().sum())

# 方案1:删除缺失>50%的列
df = df.dropna(thresh=len(df)*0.5, axis=1)

# 方案2:用中位数填补数值列
num_cols = df.select_dtypes(include=[np.number]).columns
imputer_median = SimpleImputer(strategy='median')
df[num_cols] = imputer_median.fit_transform(df[num_cols])

# 方案3:kNN插补(稍微贵一点)
knn_imputer = KNNImputer(n_neighbors=5, weights='distance')
df_imputed = pd.DataFrame(knn_imputer.fit_transform(df[num_cols]), 
                          columns=num_cols)

4.2 异常值检测 —— 揪出捣乱的样本

有些化合物的特征值明显离谱。比如肝毒性评分超过正常范围好几个量级,或者某条ADMET指标出现负值——这在理化性质里根本不该存在。这些异常点不处理,会严重拉偏模型的判断。

我常用的两种方法:

方法原理适用场景缺点
IQR(四分位距)Q1 - 1.5*IQR ~ Q3 + 1.5*IQR 之外的点数据分布偏态明显时正态性假设不强,但可能误删长尾信息
Z-score|Z| > 3 视为异常数据近似正态分布时对离群值敏感,小样本慎用
孤立森林随机划分特征空间,孤立快的点更可能异常高维特征、非线性关系计算成本较高

嗯,这里要注意:异常值不一定都要删除。我曾经在处理肝毒性数据时,发现几个LD50值特别低(毒性很强)的化合物。单独拿出来看反而是重要的阳性样本。所以我会先标记,然后单独决定是保留、删除还是做封顶处理(Winsorization)。

警惕:不要一股脑把异常点全删掉。先做领域检查——如果特征是LogP,异常值可能是结构特殊的化合物(比如超大分子),那些恰恰是模型要预测的边界情况。

from scipy import stats

# IQR方法
Q1 = df.quantile(0.25)
Q3 = df.quantile(0.75)
IQR = Q3 - Q1
outliers = (df < (Q1 - 1.5 * IQR)) | (df > (Q3 + 1.5 * IQR))
outlier_count = outliers.sum()
print("异常值数量:", outlier_count)

# Z-score方法
z = np.abs(stats.zscore(df))
df_no_outliers = df[(z < 3).all(axis=1)]

# 封顶处理(Winsorization)
from scipy.stats.mstats import winsorize
df_winsorized = df.apply(lambda x: winsorize(x, limits=[0.01, 0.01]))

4.3 标准化与归一化 —— 让量纲闭嘴

肝毒性特征集里什么都有:分子量(几百)、LogP(-2~7)、溶解度(mg/mL小数)、CYP抑制百分比(0~100)。单位不同,数值范围差几倍甚至几十倍。直接用原始值扔进PCA或距离模型,大数值的特征会主宰结果。说白了,就是在欺负小数值特征。

两种主流方法对比:

方法公式输出范围适用模型
Z-score标准化x' = (x-μ)/σ均值0,标准差1SVM、PCA、线性回归、神经网络
Min-Max归一化x' = (x-min)/(max-min)[0, 1]距离模型(KNN、K-Means)、神经网络

我自己更倾向于标准化。为什么?因为标准化对异常值没那么敏感——你用Min-Max时,如果有一个特征的最大值特别离谱,归一化后其他值全被压到零点几,信息损失大。你想想看,是不是这个理?

案例提醒:我做过一个肝毒性分类项目,用KNN做基准模型。一开始用原始特征,准确率只有0.72。换成Min-Max归一化后涨到0.81。后来改回标准化,结果又涨到0.83。原因就是标准化保留了特征间的相对距离关系,更适合欧氏距离。

from sklearn.preprocessing import StandardScaler, MinMaxScaler

# 标准化
scaler_std = StandardScaler()
X_standardized = scaler_std.fit_transform(df)

# 归一化
scaler_mm = MinMaxScaler()
X_normalized = scaler_mm.fit_transform(df)

# 还原回去(用于检验)
df_standard = pd.DataFrame(X_standardized, columns=df.columns)
print("标准化后各特征均值:", df_standard.mean().round(2))  # 接近0
print("标准化后各特征标准差:", df_standard.std().round(2))  # 接近1

4.4 整体流水线 —— 别漏了顺序

我再强调一遍流程顺序:缺失值处理 → 异常值检测 → 标准化/归一化。顺序乱了,结果就串味了。比如你先标准化,再检测异常值——标准化后原始数据的物理意义已经变了,你没法判断这个Z-score是结构异常还是缩放引入的。

实际项目中,我习惯把这三个步骤封装成一个sklearn Pipeline,方便调参和交叉验证时保持数据一致性。

from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler

# 自定义异常值封顶(作为transformer)
class Winsorizer:
    def __init__(self, limits=[0.01, 0.01]):
        self.limits = limits
    def fit(self, X, y=None):
        self.lower_ = np.percentile(X, self.limits[0]*100, axis=0)
        self.upper_ = np.percentile(X, 100 - self.limits[1]*100, axis=0)
        return self
    def transform(self, X):
        X_clipped = np.clip(X, self.lower_, self.upper_)
        return X_clipped

preprocess_pipe = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('winsorizer', Winsorizer(limits=[0.01, 0.01])),
    ('scaler', StandardScaler())
])

# 一条命令搞定所有预处理
X_clean = preprocess_pipe.fit_transform(df)

预处理完后,我们的特征矩阵应该干净、无量纲、无离群干扰。接下来做可视化探索或特征筛选时,就不会被脏数据带偏了。记得用preprocess_pipe.named_steps可以查看每一步的状态,调试很方便。