第4章 数据清洗基础:缺失值处理
好,咱们正式开始聊数据清洗。说实话,在QSAR建模这件事上,我见过太多团队把精力全花在算法调参上,结果被数据里的缺失值坑得头破血流。我个人习惯把缺失值处理比作盖楼前的地基勘测——你跳过这一步,后面模型再花哨也白搭。
4.1 为什么缺失值会要命?
先讲个我自己的例子。几年前做一个毒性预测项目,数据集里有个关键描述符“LogD”,大约15%的样本是空的。当时年轻,图省事直接把这列删了。结果模型在验证集上表现不错,但一上外部测试集就崩了。后来复盘才发现:缺失的样本恰好是高脂溶性化合物,模型根本没见过这类分布。
缺失值带来的影响,通常有三个层面:
- 信息丢失:空值本身可能代表某种化学性质不稳定或测量困难
- 分布偏移:含缺失值的样本往往不是随机出现的
- 算法失效:大多数机器学习模型不能直接处理NaN值
你想想看,如果缺失值集中在活性较高的化合物上,模型学到的规律就是有偏差的。这不是技术问题,这是实验设计的问题。
4.2 先看看缺失值长什么样
动手处理之前,先得搞清楚家底。我一般会用以下几个步骤来摸底:
- 统计每列的缺失比例——看哪些描述符该重点关照
- 分析缺失模式——是随机缺失还是存在某种规律
- 检查缺失与目标变量的关系——是不是活性越高越容易缺失
举个例子,用Python做初步探测的代码长这样:
import pandas as pd
import numpy as np
# 加载数据
df = pd.read_csv('qsar_data.csv')
# 统计每列缺失比例
missing_ratio = df.isnull().mean().sort_values(ascending=False)
print("缺失比例排名前10的描述符:")
print(missing_ratio[missing_ratio > 0].head(10))
# 看看哪些样本的缺失描述符数量多
df['missing_count'] = df.isnull().sum(axis=1)
print("\n样本缺失描述符数量分布:")
print(df['missing_count'].value_counts().sort_index())
嗯,这里要注意:缺失比例超过50%的描述符,我基本会直接扔掉。除非这个描述符在文献里被反复验证过是建模必须的,但这种情况极少见。
4.3 处理策略:没有银弹,但有套路
说白了,缺失值处理没有万能方案。我根据缺失比例和缺失机制,总结了四套处理策略:
| 缺失比例 | 缺失机制 | 推荐策略 | 适用场景 |
|---|---|---|---|
| < 5% | 完全随机 | 均值/中位数填充 | 理化性质描述符 |
| 5% ~ 20% | 随机或非随机 | 模型预测填充 | 活性值、实验测量值 |
| 20% ~ 50% | 非随机 | 创建缺失指示变量 | 可能存在物理意义缺失 |
| > 50% | 任何机制 | 直接删除该列 | 信息量不足,保留无意义 |
你可能会问:为什么缺失比例低的时候用均值填充就行?其实道理很简单——少量随机缺失对整体分布影响微乎其微,没必要搞得那么复杂。但如果是非随机缺失,哪怕只有2%也不能简单填充。
4.4 实战:处理QSAR数据中的典型缺失
好,咱们拿一个典型的QSAR数据集来实战。假设数据里有这么几类缺失:
- 分子量(MW):缺失3%,随机缺失
- LogP:缺失12%,低溶解度化合物更易缺失
- IC50值:缺失8%,高活性化合物更易缺失
- 拓扑极性表面积(TPSA):缺失60%
我的处理逻辑是这样的:
# 1. MW:缺失少,用均值填充
df['MW'].fillna(df['MW'].mean(), inplace=True)
# 2. LogP:非随机缺失,用回归模型预测
from sklearn.ensemble import RandomForestRegressor
# 分离有值和缺失的样本
has_logp = df[df['LogP'].notna()]
miss_logp = df[df['LogP'].isna()]
# 用其他完整描述符预测LogP
features = [c for c in df.columns
if c not in ['LogP', 'IC50', 'missing_count']
and df[c].isnull().sum() == 0]
model = RandomForestRegressor(n_estimators=100)
model.fit(has_logp[features], has_logp['LogP'])
df.loc[df['LogP'].isna(), 'LogP'] = model.predict(miss_logp[features])
# 3. IC50:非随机缺失,同样用模型预测
# 但这里要注意:预测IC50时不要用与活性相关的描述符,避免信息泄露
# 4. TPSA:缺失过多,直接删除
df.drop(columns=['TPSA'], inplace=True)
我在项目中遇到过最头疼的情况是:缺失的IC50值完全是实验设计造成的——高活性化合物没来得及测就封存了。这种缺失是结构性的,不能简单用模型去猜,得回头找实验人员确认。
LogP_was_missing,标记哪些样本是填充的。这样模型在用的时候,还能学到“是否被填充”这个特征。
4.5 缺失值处理的避坑指南
我这些年在缺失值处理上踩过的坑,可以列出一张清单。挑几个最要命的说说:
- 信息泄露:用目标变量(比如IC50)去预测缺失的描述符,相当于抄答案。我当年就踩过这个坑,模型精度虚高30%。
- 过度填充:全部用均值填充会导致方差被低估。我建议在均值的基础上加一点随机噪声,保持数据原有的离散程度。
- 忽视业务背景:曾经有个团队把“未检测到”的活性数据填充成0,结果模型永远预测不出高活性化合物。实际上,“未检测到”应该按实验检测下限来处理。
为什么会这样?因为你把缺失当成一个数字问题,而不是一个化学问题。QSAR建模的根基是化学,数据处理的每个决策都得回归到化学含义上。
4.6 缺失值处理的知识结构
为了让你更直观地理解整个决策流程,我画了一张图,把核心逻辑串起来了。
4.7 最后再唠叨两句
缺失值处理没有完美答案,核心就两个字:合理。合理的前提是你得理解数据的来源、实验的设计、化学的含义。千万别把清洗当成纯粹的数学题——我见过太多人把缺失值处理做成自动化流水线,结果模型越来越蠢。
在实际项目中,我通常会在处理完缺失值后,做一个简单的「前后对比验证」:
# 验证填充后的分布是否合理
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 填充前
df_original['LogP'].hist(ax=axes[0], bins=30, alpha=0.7)
axes[0].set_title('填充前 LogP 分布')
# 填充后
df['LogP'].hist(ax=axes[1], bins=30, alpha=0.7, color='green')
axes[1].set_title('填充后 LogP 分布')
plt.tight_layout()
plt.show()
如果填充前后的分布形状发生了剧烈变化,那就说明你的填充策略有问题。这时候别硬撑,回头重新判断缺失机制,或者干脆换成更保守的策略。