4. 异常值检测与处理:IQR法、Z-score法、DBSCAN
做QSAR模型,最怕的不是算法选错,而是数据本身不干净。
我见过不少新手,拿过来数据直接就跑模型,结果R²看着挺高,一跨批次验证就崩盘。后来一查,原来有3个异常值把回归线硬生生拽偏了。说白了,异常值处理是批次质控的第一道防火墙。今天咱们就聊三种最常用的方法:IQR法、Z-score法,还有不那么常见的DBSCAN。
4.1 为什么异常值检测在批次质控中这么重要?
你想想看,QSAR模型的核心假设是“结构决定活性”。但你的训练数据里如果混进了一个实验错误导致的异常点,模型就会学到错误的规律。而且,不同批次的检测条件往往有差异,某些批次天然就容易产生异常值。
我个人的习惯是:在建模前的数据清洗阶段,必须嵌入异常值检测流程。这不是一次性操作,而是每个批次落地的质检环节。流程不复杂,但容易被忽视。
下面这张图,是我在项目中总结的“异常值检测在批次质控中的角色定位”:
4.2 IQR法:简单粗暴,适合非正态分布
IQR法是我用得最多的一种。原因很简单——它不依赖数据的分布假设。你拿到的化合物活性数据往往呈偏态分布,搞正态性检验还得先折腾一波。IQR法直接看四分位距,很稳。
算法逻辑:
计算第一四分位数 Q1 和第三四分位数 Q3。IQR = Q3 - Q1。通常认为,小于 Q1 - 1.5×IQR 或大于 Q3 + 1.5×IQR 的点是异常值。对于极端异常,可以用 3×IQR 做外重门限。
我在项目中遇到过一件事。有个批次的IC50数据,Q1和Q3都很正常,但是max值特别大。用IQR一算,超过上界2倍多的值有4个。后来查原始记录,果然是稀释步骤搞错了。这种错误靠肉眼根本看不出来。
Python实现很简单:
# Q1 / Q3 / IQR 计算
Q1 = df['activity'].quantile(0.25)
Q3 = df['activity'].quantile(0.75)
IQR = Q3 - Q1
# 定义异常值边界
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
# 标记异常值
df['outlier_iqr'] = (df['activity'] < lower_bound) | (df['activity'] > upper_bound)
print(f"IQR 异常值数量: {df['outlier_iqr'].sum()}")
如果你的数据量很小(比如少于20个样本),1.5×IQR 可能会误伤正常点。我建议把系数放宽到 2.0 甚至 2.5。另外,千万注意,不要把同系列的结构类似物误判为异常,它们只是在活性端有差异而已。
4.3 Z-score法:标准正态假定下的利器
Z-score 适用于数据近似正态分布的场景。它的计算方式是把每个值转换成与该均值的距离(以标准差为单位):
公式:Z = (X - μ) / σ
一般来说,|Z| > 3 的点被认为是异常值。但说实话,这个门限在QSAR里有点严。我记得处理过一批logP数据,分布已经很好了,但用 Z=3 筛不出任何点。后来改成 Z=2.5,才揪出3个明显偏离的测量值。
这里有个坑——Z-score对异常值本身很敏感。如果异常值特别大,它会拉高标准差,从而降低自身的Z值,很难被检测出来。这叫掩蔽效应。我建议先用IQR筛一轮,再用Z-score做精细判断。
标准实现:
from scipy.stats import zscore
# 计算 Z-score
df['z_score'] = zscore(df['activity'])
# 设定阈值
z_threshold = 3.0
df['outlier_z'] = df['z_score'].abs() > z_threshold
print(f"Z-score 异常值数量: {df['outlier_z'].sum()}")
我曾经见过有人对logP这种本应正态分布的数据简单用Z-score筛,结果把脂溶性特别大的阳性化合物都干掉了。为什么?因为数据本身是右偏的,Z-score强行用正态假设,就会把高活性的尾部砍掉。所以要先检查分布,再决定是否用Z-score。
4.4 DBSCAN:基于密度的局部异常检测
前面两种方法本质上是全局方法——它们用统一的规则判断所有点。但QSAR数据经常有局部聚集现象。举个例子,某个系列的化合物活性都很低,但其中有一个特别低,你觉得该不该算异常?
我个人认为,如果它跟同系列的其他点相比没有严重偏离,那就不算异常。这就是DBSCAN擅长的地方:它根据局部密度来判断,密度足够高的区域内的点是正常点,孤立在低密度区域里的点才是异常。
核心参数:
| 参数 | 含义 | 我通常怎么设 |
|---|---|---|
| eps | 邻域半径 | 先做一次K-distance图,找拐点 |
| min_samples | 核心点最小邻域样本数 | 小数据集设为3,大数据集设为5-10 |
代码实现(二维特征)也很直接:
from sklearn.cluster import DBSCAN
# X 是特征矩阵(比如分子描述符 + 活性)
clustering = DBSCAN(eps=2.5, min_samples=5).fit(X)
# -1 标签表示噪声点,即异常值
df['outlier_db'] = clustering.labels_ == -1
print(f"DBSCAN 标记的异常值数量: {df['outlier_db'].sum()}")
DBSCAN 的 eps 参数非常敏感。设大了,啥都聚成一类,找不到异常。设小了,正常点也被当成噪声。
我分享一个技巧:先用PCA或t-SNE把高维描述符降到2维,然后用DBSCAN,这样可以直观验证聚类效果。在批次质控场景下,我建议每个批次单独跑DBSCAN,然后再跨批次比对结果。
4.5 三种方法对比与选型指南
你可能会问:说了这么多,到底该用哪种?我的答案是:三者结合使用,但各有侧重。
| 维度 | IQR法 | Z-score法 | DBSCAN |
|---|---|---|---|
| 分布假设 | 无假设,鲁棒 | 近似正态 | 无假设,但依赖eps |
| 敏感度 | 中等,受尾部分布影响小 | 高,易受异常值本身影响 | 高,依赖局部密度 |
| 适用数据量 | 小样本也适用 | 至少30个以上 | 越多越好,小样本不稳定 |
| 批次质控场景 | 数值型指标(活性、纯度) | 标准化后的描述符 | 多维描述符空间 |
| 我的推荐顺序 | 第一选择 | 第二选择 | 补充验证 |
举个例子。我最近做的一个项目里,先跑IQR法筛掉了2个明显的实验异常点。然后对剩下的数据做Z-score分析,又找出1个边界点。最后用DBSCAN对分子指纹做聚类验证,发现那1个边界点其实是另一个化学亚类的代表,不应该剔除。最终保留了它,模型泛化能力反而提升了。
一句话总结:
- IQR法用来做初级清洗,快速定位可疑点;
- Z-score法用来做精细判断,但前提是数据分布要近似正态;
- DBSCAN用来做局部异常分析,尤其适合需要对多维描述符做批次一致性验证的场景。