第4章 数据清洗基础:缺失值处理

好,咱们正式开始聊数据清洗。说实话,在QSAR建模这件事上,我见过太多团队把精力全花在算法调参上,结果被数据里的缺失值坑得头破血流。我个人习惯把缺失值处理比作盖楼前的地基勘测——你跳过这一步,后面模型再花哨也白搭。

4.1 为什么缺失值会要命?

先讲个我自己的例子。几年前做一个毒性预测项目,数据集里有个关键描述符“LogD”,大约15%的样本是空的。当时年轻,图省事直接把这列删了。结果模型在验证集上表现不错,但一上外部测试集就崩了。后来复盘才发现:缺失的样本恰好是高脂溶性化合物,模型根本没见过这类分布。

缺失值带来的影响,通常有三个层面:

  • 信息丢失:空值本身可能代表某种化学性质不稳定或测量困难
  • 分布偏移:含缺失值的样本往往不是随机出现的
  • 算法失效:大多数机器学习模型不能直接处理NaN值

你想想看,如果缺失值集中在活性较高的化合物上,模型学到的规律就是有偏差的。这不是技术问题,这是实验设计的问题。

特别注意:千万不能在未经分析的情况下直接删除含有缺失值的整行或整列。我曾经接手过一个项目,前一个人一口气删了40%的样本,结果模型R²高达0.95,但根本没法用——因为它只学到了那60%样本的规律。

4.2 先看看缺失值长什么样

动手处理之前,先得搞清楚家底。我一般会用以下几个步骤来摸底:

  1. 统计每列的缺失比例——看哪些描述符该重点关照
  2. 分析缺失模式——是随机缺失还是存在某种规律
  3. 检查缺失与目标变量的关系——是不是活性越高越容易缺失

举个例子,用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后,加一列LogP_was_missing,标记哪些样本是填充的。这样模型在用的时候,还能学到“是否被填充”这个特征。

4.5 缺失值处理的避坑指南

我这些年在缺失值处理上踩过的坑,可以列出一张清单。挑几个最要命的说说:

  • 信息泄露:用目标变量(比如IC50)去预测缺失的描述符,相当于抄答案。我当年就踩过这个坑,模型精度虚高30%。
  • 过度填充:全部用均值填充会导致方差被低估。我建议在均值的基础上加一点随机噪声,保持数据原有的离散程度。
  • 忽视业务背景:曾经有个团队把“未检测到”的活性数据填充成0,结果模型永远预测不出高活性化合物。实际上,“未检测到”应该按实验检测下限来处理。

为什么会这样?因为你把缺失当成一个数字问题,而不是一个化学问题。QSAR建模的根基是化学,数据处理的每个决策都得回归到化学含义上。

4.6 缺失值处理的知识结构

为了让你更直观地理解整个决策流程,我画了一张图,把核心逻辑串起来了。

缺失值处理决策流程 原始数据 步骤1:统计缺失比例与模式 缺失比例 > 50%? 是 删除该列 否 步骤2:判断缺失机制 随机缺失:均值/中位数填充 非随机缺失:模型预测填充 输出:干净的数据集 (含缺失标志列)

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()

如果填充前后的分布形状发生了剧烈变化,那就说明你的填充策略有问题。这时候别硬撑,回头重新判断缺失机制,或者干脆换成更保守的策略。