数据预处理与清洗:处理缺失值、标准化、过滤
数据预处理这块,我踩过的坑比其他任何环节都多。你想想看,从公共数据库扒下来的原始数据,哪个不是缺胳膊少腿的?分子描述符算着算着就蹦出个 NaN,生物活性值单位不统一,还有一堆明显是测量错误的数据点。我见过不少新人上来就调模型,结果跑出来全是垃圾——说白了,数据干净了,模型才学得动。
今天咱们就聊聊怎么把那些「脏数据」收拾利索。我一般把它拆成三步走:缺失值处理、标准化、过滤。每一步都有技巧,也有坑,咱们一个一个说。
3.1 缺失值处理
为什么会有缺失?原因多了去了。实验误差、分子不稳定、数据库记录不全……最常见的是生物活性数据里 IC50 标着 NaN。我这几年做过的项目,十个里有八个得面对这个问题。
处理策略我得先摆清楚,别一上来就删,得看缺多少。
| 缺失比例 | 推荐做法 | 适用场景 |
|---|---|---|
| < 5% | 均值/中位数填充 | 连续型描述符,分布较对称 |
| 5% - 20% | KNN 填充 / 回归插补 | 有多个关联特征时效果不错 |
| > 20% | 直接删除该特征 | 特征太多、缺失太离谱,留着只会引入偏差 |
讲个具体的。之前有个 ADME 预测项目,logP 这列缺了 30% 左右。我当时用均值填充跑出来的模型,验证集 R² 只有 0.3。后来改成 KNN 填充(k=5),R² 直接跳到 0.6。所以别图省事,填充方法选对了,效果天差地别。
小提示:我个人习惯先看看缺失模式。如果数据是 MCAR(完全随机缺失),那随便填都行;但要是 MAR(非随机缺失)呢?考虑用多重插补,别死磕简单平均。
看看 Python 里怎么做:
# 先检查缺失比例
import pandas as pd
df = pd.read_csv("molecular_descriptors.csv")
missing_ratio = df.isnull().mean()
print(missing_ratio[missing_ratio > 0])
# 删除缺失 > 20% 的特征
df = df.drop(columns=missing_ratio[missing_ratio > 0.2].index)
# 对保留特征用 KNN 填充(需要 sklearn)
from sklearn.impute import KNNImputer
imputer = KNNImputer(n_neighbors=5)
df_filled = pd.DataFrame(imputer.fit_transform(df), columns=df.columns)
注意:千万别把目标变量(比如活性值)的缺失也用插补填充,那相当于编答案。遇到目标变量缺失,直接删除那一行样本更安全。我曾经这么干过,虽然少了 20% 的样本,但模型稳定性反而提升了。
3.2 标准化
标准化这块我讲个真实遭遇。有次做分子指纹相似度分析,指纹向量的取值是 0/1 二值,但同一数据集里又有分子量(几百到几千)、TPSA(几十到几百)。你拿尺子量身高的时候会跟体重一起算么?不会。但 ML 模型会——如果特征尺度差异大,梯度下降就跟喝醉酒一样,步长难调,模型不收敛。
常见做法就两种:
- Z-score 标准化:减均值除标准差,适用于数据近似正态分布。
- Min-Max 缩放:缩放到 [0,1],适用于分布边界明确的场景(比如 0/1 指纹)。
| 方法 | 公式 | 适用特征类型 |
|---|---|---|
| Z-score | (x - μ) / σ | 连续型描述符(MW、logP) |
| Min-Max | (x - min) / (max - min) | 分布范围已知的特征,比如指纹位点 |
代码实现也很直接:
# Z-score 标准化
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df_filled)
# Min-Max 缩放
from sklearn.preprocessing import MinMaxScaler
min_max_scaler = MinMaxScaler()
X_minmax = min_max_scaler.fit_transform(df_filled)
要点:我建议你先拆分训练集和测试集,再计算训练集的均值和标准差,然后用这个值去缩放测试集。
为什么?因为测试集不应该「偷看」训练集的统计信息,否则会低估过拟合。我曾见过有人把整个数据集合起来做标准化,然后才拆分,结果模型在测试集上的表现异常好——其实是数据泄露了。
3.3 过滤:低方差与异常值
过滤是为了去掉那些「没用的」或者「有害的」数据。比如方差几乎为零的特征——所有分子的 logP 都一样,它对区分活性有贡献么?完全没有。再比如异常值——明显是实验错误导致的 IC50 读了个负值,留着只会搞乱模型。
3.3.1 低方差过滤
低方差特征本质上就是「秃子头上的虱子——明摆着没信息」。我一般设个阈值,比如方差小于 0.01 的就扔掉。注意二进制特征(0/1 指纹)的方差公式不同,得按照伯努利分布来算。
# 过滤方差小于阈值的特征
from sklearn.feature_selection import VarianceThreshold
selector = VarianceThreshold(threshold=0.01)
X_filtered = selector.fit_transform(X_scaled)
# 看看哪些特征被保留了
selected_features = selector.get_support(indices=True)
3.3.2 异常值检测
异常值这块我吃过亏。之前处理一个毒性预测数据集,有个样本的 logP 竟然标了 999。我用 3σ 法——超过均值±3个标准差就标记为异常——直接把这行删了。后来才发现那是那个分子的真实值(高度亲脂的变态分子)。所以 别盲目删除,先确认是不是真实。
| 方法 | 原理 | 适合数据 |
|---|---|---|
| Z-score | 超过 3σ 视为异常 | 近似正态的连续特征 |
| IQR(四分位距) | 小于 Q1-1.5IQR 或大于 Q3+1.5IQR | 偏态分布、含很多离群点 |
| DBSCAN | 基于密度聚类,孤立点标记为噪声 | 高维稠密数据,比如指纹 |
实操的时候我习惯先画箱线图看一眼,再决定用哪种方法:
# 用 IQR 方法检测异常值
Q1 = df_filled.quantile(0.25)
Q3 = df_filled.quantile(0.75)
IQR = Q3 - Q1
outliers = ((df_filled < (Q1 - 1.5 * IQR)) | (df_filled > (Q3 + 1.5 * IQR))).any(axis=1)
print(f"检测到 {outliers.sum()} 个异常样本")
# 删除异常行
df_clean = df_filled[~outliers]
一点经验:对于药物数据,如果有已知的阳性分子(比如上市药物)被你当作异常值删了,那模型会错过重要模式。我通常会保留一份「剔除日志」,方便回溯。
最后我再啰嗦一句:预处理不是一次性工作。你做 EDA 的时候可能会发现新问题,比如某个特征缺失率突然变高了,或者标准化后分布依然不理想。这时候回头再调整参数,很正常。记住,数据预处理占整个 pipeline 的一半精力都不夸张,这块做好了,后面建模就顺了。