4、数据层面方法:随机欠采样(Random Undersampling)原理与实现

随机欠采样,名字听着挺唬人,说穿了就一件事——把多数类样本砍掉一部分,让两边数量差不多。我在蛋白配体项目里第一次用这个方法时,总觉得有点「暴力」,心想好不容易收集来的数据,说扔就扔了?但后来发现,有时候简单粗暴反而最管用。

咱们做蛋白配体分类,最常见的问题就是:活性化合物就那么几十个,非活性化合物却堆了几千甚至几万个。模型学来学去,最后只会说「这玩意儿不活性」。你想想看,这种模型你敢用吗?

4.1 核心思想:用「舍弃」换「平衡」

随机欠采样的逻辑链条其实就三步:

  1. 数一数:统计多数类和少数类的样本量
  2. 随机挑:从多数类里随机选出一部分样本
  3. 扔掉剩下的:保留选中的多数类 + 所有少数类,组成新数据集

举个例子,你手上有 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。

流程大致长这样:

随机欠采样在蛋白配体中的工作流 原始不平衡数据 正样本:142 负样本:6236 随机选择多数类 从 6236 个负样本中 随机抽取 142 个 移除未选中样本 丢弃剩余 6094 个负样本 平衡后数据集 正样本:142 负样本:142 ⚠ 注意 随机欠采样会丢弃大量多数类样本 适用于多数类冗余度高、噪声大的场景

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 个),或者每个样本都很珍贵、信息密度高,那随机欠采样就是自废武功。这时候可以考虑集成欠采样或者混合采样。