4、数据层面方法:随机欠采样(Random Undersampling)原理与实现
随机欠采样,名字听着挺唬人,说穿了就一件事——把多数类样本砍掉一部分,让两边数量差不多。我在蛋白配体项目里第一次用这个方法时,总觉得有点「暴力」,心想好不容易收集来的数据,说扔就扔了?但后来发现,有时候简单粗暴反而最管用。
咱们做蛋白配体分类,最常见的问题就是:活性化合物就那么几十个,非活性化合物却堆了几千甚至几万个。模型学来学去,最后只会说「这玩意儿不活性」。你想想看,这种模型你敢用吗?
4.1 核心思想:用「舍弃」换「平衡」
随机欠采样的逻辑链条其实就三步:
- 数一数:统计多数类和少数类的样本量
- 随机挑:从多数类里随机选出一部分样本
- 扔掉剩下的:保留选中的多数类 + 所有少数类,组成新数据集
举个例子,你手上有 100 个活性化合物(正样本),8000 个非活性化合物(负样本)。随机欠采样会从 8000 个负样本里随机抽 100 个,然后跟 100 个正样本拼在一起。最终数据集 = 100 正 + 100 负,平衡了。
核心公式:目标多数类样本数 = 少数类样本数 × 目标比率(通常设为 1:1)
我个人习惯把目标比率设成 1:1 到 2:1 之间。设得太高,模型还是会偏;设成 1:1,偶尔会损失太多信息。具体多少合适,得看你数据量和噪声水平。
4.2 在蛋白配体场景里,它怎么跑?
我去年做过一个激酶抑制剂的筛选项目,正样本(IC₅₀ < 1 μM)只有 142 个,负样本堆了 6200 多个。直接训练 XGBoost,F1 分数只有 0.31,模型几乎全判成非活性。后来上了随机欠采样,F1 直接拉到 0.67。
流程大致长这样:
4.3 代码实现:手动写 vs 用工具库
先看手动实现。理解原理比调包更重要,尤其面试时容易问到。
import numpy as np
import pandas as pd
from sklearn.utils import shuffle
def random_undersample(X, y, majority_class=0, minority_class=1, ratio=1.0):
"""
手动实现随机欠采样
ratio: 目标多数类 / 少数类的比例,默认 1.0 表示 1:1
"""
# 分离多数类和少数类
majority_idx = np.where(y == majority_class)[0]
minority_idx = np.where(y == minority_class)[0]
n_minority = len(minority_idx)
n_majority_target = int(n_minority * ratio)
# 随机选择多数类样本
selected_majority = np.random.choice(
majority_idx, n_majority_target, replace=False
)
# 合并索引
selected_idx = np.concatenate([minority_idx, selected_majority])
selected_idx = shuffle(selected_idx)
return X[selected_idx], y[selected_idx]
# 在蛋白配体数据上使用
# X: 分子指纹或描述符矩阵,y: 活性标签(1=活性,0=非活性)
X_balanced, y_balanced = random_undersample(X, y, majority_class=0, minority_class=1, ratio=1.0)
print(f"平衡后正样本数: {np.sum(y_balanced==1)}, 负样本数: {np.sum(y_balanced==0)}")
当然,实际项目里我建议直接用 imbalanced-learn,省事、稳定、不容易手写出 bug。
from imblearn.under_sampling import RandomUnderSampler
from collections import Counter
# 定义采样器
sampler = RandomUnderSampler(
sampling_strategy='auto', # 自动调整到与少数类相同数量
random_state=42 # 固定随机种子,保证可复现
)
# 执行欠采样
X_resampled, y_resampled = sampler.fit_resample(X, y)
print(f"采样后类别分布: {Counter(y_resampled)}")
sampling_strategy='auto' 的意思是:把多数类采样到跟少数类一样多。你也可以传一个字典,比如 {0: 300, 1: 142},手动指定每个类别的目标数量。
我的小习惯:固定 random_state,尤其在做交叉验证时。否则每次跑出来的采样结果不一样,模型复现会疯掉。
4.4 一张表看清优缺点
| 维度 | 优势 | 劣势 |
|---|---|---|
| 计算效率 | 数据量大幅减少,训练速度变快 | — |
| 实现难度 | 逻辑简单,5 行代码搞定 | — |
| 信息保留 | — | 丢弃大量多数类样本,可能丢失重要模式 |
| 过拟合风险 | — | 少数类样本被反复使用,模型容易过拟合 |
| 适用场景 | 多数类噪声大、冗余度高 | 多数类样本稀少且信息密度高时不要用 |
4.5 避坑指南:我曾经踩过的三个坑
坑一:欠采样后验证集分布失真
我曾经在蛋白配体项目里,对整个数据集做了随机欠采样,然后才划分训练集和测试集。结果测试集上的表现虚高,一上线就崩了。为什么呢?因为测试集也被「人工平衡」了,跟真实分布完全不同。
正确的做法:先划分训练集和测试集,只对训练集做欠采样,测试集保持原始分布。
# 正确流程
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
# 只在训练集上做欠采样
X_train_res, y_train_res = sampler.fit_resample(X_train, y_train)
坑二:随机种子没固定,结果无法复现
有一次我在调参时发现,同样一组超参数,两次跑出来的 AUC 差了 0.08。排查了半天,发现是欠采样没设 random_state。你想想看,每次采的样本都不一样,模型学到的分布也不一样,复现个鬼。
坑三:忽略了多数类内部的子结构
随机欠采样是「盲选」的。但多数类里可能本身就包含多个子簇——比如不同骨架类型的非活性化合物。随机采样可能会漏掉某些子簇,导致模型没见过某些类型的负样本。如果条件允许,可以考虑用分层欠采样或聚类欠采样来替代。
重要提醒:随机欠采样不是银弹。如果你的正样本本来就很少(比如 < 50 个),砍完多数类后模型会严重过拟合。这时候可以考虑过采样方法(比如 SMOTE),或者干脆用集成策略。
4.6 什么时候该用随机欠采样?
总结一下我自己的经验判断——满足以下条件时,随机欠采样是个不错的选择:
- 多数类样本量巨大(比如 5000+),且有不少明显是噪声
- 计算资源有限,没法用全部数据训练复杂模型
- 快速迭代探索期,想快速验证某个特征工程或模型是否有效
- 类别比例极度失衡(1:50 甚至更高),其他方法效果不明显
反过来,如果多数类样本量本来就不多(比如总共才 200 个),或者每个样本都很珍贵、信息密度高,那随机欠采样就是自废武功。这时候可以考虑集成欠采样或者混合采样。