Under-sampling
3. 欠采样(Under-sampling)
处理类别不平衡数据集的一种方法是从除少数类之外的所有类别中减少样本数量。少数类指的是观测数量最少的类别。这一类别中最著名的算法是随机欠采样,其做法是以随机方式从目标类别中移除部分样本。
但除此之外还有许多其他算法可以帮助我们减少数据集中样本的数量。根据它们的欠采样策略,这些算法可以分为以下两类:
- 原型生成方法(Prototype generation methods)
- 原型选择方法(Prototype selection methods)
在后者中又可进一步细分为:
- 受控欠采样(Controlled undersampling)
- 清洗方法(Cleaning methods)
我们将在本文档中讨论这些不同的算法。
另请参阅:比较欠采样方法
3.1 原型生成(Prototype generation)
对于一个原始数据集 S,原型生成算法会生成一个新的集合 S′,满足 |S′| < |S| 且 S′ 不是 S 的子集(S′⊄S)。换句话说,原型生成技术会减少目标类别中的样本数量,而保留下来的样本并非直接从原始数据集中选取,而是通过一定方法生成的。
ClusterCentroids
使用 K-means 聚类方法来减少样本数量。因此,每个类别的样本将被 K-means 方法得到的聚类中心所替代,而不是使用原始样本:
>>> from collections import Counter
>>> from sklearn.datasets import make_classification
>>> X, y = make_classification(n_samples=5000, n_features=2, n_informative=2,
... n_redundant=0, n_repeated=0, n_classes=3,
... n_clusters_per_class=1,
... weights=[0.01, 0.05, 0.94],
... class_sep=0.8, random_state=0)
>>> print(sorted(Counter(y).items()))
[(0, 64), (1, 262), (2, 4674)]
>>> from imblearn.under_sampling import ClusterCentroids
>>> cc = ClusterCentroids(random_state=0)
>>> X_resampled, y_resampled = cc.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 64), (2, 64)]
下图展示了这种欠采样的效果。
ClusterCentroids
提供了一种使用较少样本有效表示数据簇的方式。请注意,此方法要求你的数据能够聚类成簇。此外,设置的质心数量应使得欠采样后的簇能够代表原始数据的分布情况。
警告
ClusterCentroids
支持稀疏矩阵。然而,生成的新样本并不特别稀疏。因此,即使最终的矩阵是稀疏的,在这种情况下该算法的效率也不高。
3.2. 原型选择
原型选择算法会从原始集合 S 中选取样本,生成一个新的数据集 S′,其中 |S′| < |S| 且 S′ ⊂ S。换句话说,S′ 是 S 的一个子集。
原型选择算法可分为两大类:(i) 受控欠采样技术 和 (ii) 清理欠采样技术。
受控欠采样方法将多数类(或多个多数类)中的观测数量减少到用户任意指定的样本数量。通常,这些方法会将观测数量减少至与少数类中观测数量相同的水平。
相比之下,清理欠采样技术则通过移除“噪声”或“过于容易分类”的观测(具体取决于所使用的方法),以对特征空间进行“清洗”。每一类最终保留的观测数量取决于具体的清理方法,而无法由用户指定。
3.2.1. 受控欠采样技术
受控欠采样技术会将目标类别中的观测数量减少到用户指定的数量。
3.2.1.1. 随机欠采样
RandomUnderSampler
是一种快速且简便的平衡数据方法,它通过对目标类随机选择一部分样本来实现数据均衡:
>>> from imblearn.under_sampling import RandomUnderSampler
>>> rus = RandomUnderSampler(random_state=0)
>>> X_resampled, y_resampled = rus.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 64), (2, 64)]
通过将 replacement
设置为 True
,RandomUnderSampler
可以对数据进行有放回采样(Bootstrap)。当存在多个类别时,每个目标类别将被独立地进行欠采样:
>>> import numpy as np
>>> print(np.vstack([tuple(row) for row in X_resampled]).shape)
(192, 2)
>>> rus = RandomUnderSampler(random_state=0, replacement=True)
>>> X_resampled, y_resampled = rus.fit_resample(X, y)
>>> print(np.vstack(np.unique([tuple(row) for row in X_resampled], axis=0)).shape)
(181, 2)
RandomUnderSampler
支持处理异构数据类型,例如数值型、类别型、日期型等:
>>> X_hetero = np.array([['xxx', 1, 1.0], ['yyy', 2, 2.0], ['zzz', 3, 3.0]],
... dtype=object)
>>> y_hetero = np.array([0, 0, 1])
>>> X_resampled, y_resampled = rus.fit_resample(X_hetero, y_hetero)
>>> print(X_resampled)
[['xxx' 1 1.0]
['zzz' 3 3.0]]
>>> print(y_resampled)
[0 1]
RandomUnderSampler
同样支持将 pandas 数据框作为输入进行欠采样:
>>> from sklearn.datasets import fetch_openml
>>> df_adult, y_adult = fetch_openml(
... 'adult', version=2, as_frame=True, return_X_y=True)
>>> df_adult.head()
>>> df_resampled, y_resampled = rus.fit_resample(df_adult, y_adult)
>>> df_resampled.head()
NearMiss
则加入了一些启发式规则来选择样本[MZ03]。 NearMiss
实现了三种不同类型的启发式方法,可通过参数 version
进行选择:
>>> from imblearn.under_sampling import NearMiss
>>> nm1 = NearMiss(version=1)
>>> X_resampled_nm1, y_resampled = nm1.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 64), (2, 64)]
正如下一节中所述,NearMiss
的启发式规则基于最近邻算法。因此,参数 n_neighbors
和 n_neighbors_ver3
可接受 scikit-learn 中继承自 KNeighborsMixin
的分类器。前一个参数用于计算到邻居的平均距离,后一个参数则用于对感兴趣的样本进行预选。
3.2.1.2 数学公式
将正样本定义为属于需要进行欠采样的目标类别的样本。而负样本则指代来自少数类(即代表性不足最严重的类别)的样本。
NearMiss-1 方法会选择那些到负类样本中最近的 N 个样本平均距离最小的正类样本。
NearMiss-2 方法会选择那些到负类样本中最远的 N 个样本平均距离最小的正类样本。
NearMiss-3 是一个两步算法。首先,对于每一个负类样本,保留其 M 个最近邻样本;然后选择那些到其 N 个最近邻样本的平均距离最大的正类样本。
在接下来的例子中,我们将在前面提到的简单示例上应用不同的 NearMiss
方法。可以看到,每种方法得到的决策边界是有所区别的。
在对特定类别进行欠采样时,NearMiss-1 方法可能会受到噪声的影响。事实上,这意味着目标类别的样本将会围绕这些噪声样本被选择,如下图中黄色类别所示的情况。然而,在正常情况下,通常会选择靠近类别边界附近的样本。而 NearMiss-2 则不会产生这种效果,因为该方法关注的是距离最远的样本,而非最近的样本。我们可以设想,在存在边缘异常值的情况下,噪声同样可能影响 NearMiss-2 的采样结果。相比之下,由于第一步就进行了样本筛选,NearMiss-3 版本受噪声影响的可能性可能是最小的。
3.2.2. 清洗型欠采样技术
清洗型欠采样方法通过对特征空间进行“清洗”,移除那些被认为是“噪声”的观测点或某些“过于容易分类”的观测点(具体取决于所采用的方法)。最终每个目标类别中的样本数量会因不同的清洗方法而异,并不能由用户自行指定。
3.2.2.1. Tomek’s links(Tomek 链接)
当两个来自不同类别的样本互为彼此最近邻时,就构成了一个 Tomek’s link。
从数学上讲,对于两个来自不同类别的样本 x 和 y,如果对于任意样本 z 均满足:
d(x,y) < d(x,z) 且 d(x,y) < d(y,z)
则称这两个样本之间存在一个 Tomek’s link,其中 d(.) 表示两个样本之间的距离。
TomekLinks
用于检测并移除 Tomek 关联 [Tom76b]。其背后的思想是,Tomek 关联中的样本通常是噪声或难以分类的观测值,对算法寻找合适的判别边界没有帮助。
在下图中,一个连接类别 + 和类别 − 的 Tomek 关联以绿色高亮显示:
当 TomekLinks
找到一个 Tomek 关联时,可以选择仅移除多数类样本,或者同时移除两个样本。参数 sampling_strategy
控制将从关联中移除哪些样本。默认情况下(即 sampling_strategy='auto'
),它会移除来自多数类的样本。若希望同时移除多数类和少数类的样本,则可将 sampling_strategy
设置为 'all'
。
以下图形展示了这种行为:左侧仅移除了多数类的样本,而右侧则移除了整个 Tomek 关联中的两个样本:
3.2.2.2. 使用最近邻编辑数据
3.2.2.2.1. 编辑最近邻
编辑最近邻(Edited Nearest Neighbours)方法通过 K-最近邻算法来识别目标类别样本的邻居,并在某个样本的任意一个或大多数邻居属于不同类别时将其删除 [Wil72]。
EditedNearestNeighbours
执行以下步骤:
- 使用完整的数据集训练一个 K-最近邻模型。
- 查找每个样本的 K 个最近邻(仅针对目标类别)。
- 如果某个样本的任意一个或大多数邻居属于不同类别,则将该样本删除。
以下是代码示例:
>>> sorted(Counter(y).items())
[(0, 64), (1, 262), (2, 4674)]
>>> from imblearn.under_sampling import EditedNearestNeighbours
>>> enn = EditedNearestNeighbours()
>>> X_resampled, y_resampled = enn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 213), (2, 4568)]
换一种方式解释步骤 3,EditedNearestNeighbours
在其大多数或全部邻居都属于同一类别时,会保留多数类中的样本。为了控制这种行为,可以分别设置 kind_sel='mode'
或 kind_sel='all'
。因此,kind_sel='all'
比 kind_sel='mode'
更不保守,会导致更多的样本被删除:
>>> enn = EditedNearestNeighbours(kind_sel="all")
>>> X_resampled, y_resampled = enn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 213), (2, 4568)]
>>> enn = EditedNearestNeighbours(kind_sel="mode")
>>> X_resampled, y_resampled = enn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 234), (2, 4666)]
参数 n_neighbors
接收整数值。该整数表示针对每个样本需要检查的邻居数量。此外,也可以传入一个从 scikit-learn 的 KNeighborsMixin
派生的分类器子类。当传递一个分类器时请注意,如果你传入的是一个 3-近邻分类器,则在清理过程中只会检查其中的两个邻居,因为第三个样本正是当前用于欠采样的样本本身,它已经包含在 fit
方法所提供的数据中。
3.2.2.2.2. 重复编辑近邻方法(Repeated Edited Nearest Neighbours)
RepeatedEditedNearestNeighbours
通过多次重复算法扩展了 EditedNearestNeighbours
[Tom76a]。通常来说,重复使用该算法会删除更多的数据:
>>> from imblearn.under_sampling import RepeatedEditedNearestNeighbours
>>> renn = RepeatedEditedNearestNeighbours()
>>> X_resampled, y_resampled = renn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 208), (2, 4551)]
用户可以通过参数 max_iter
设置编辑最近邻方法应重复的次数。
当满足以下任一条件时,重复过程将停止:
- 达到最大迭代次数,或
- 没有更多的观测值被移除,或
- 某个多数类变为少数类,或
- 在欠采样过程中某个多数类消失。
3.2.2.2.3. AllKNN
AllKNN
是 RepeatedEditedNearestNeighbours
的一种变体,其区别在于在每次 EditedNearestNeighbours
过程中使用的邻居数会逐步增加。它首先基于 1-最近邻进行过滤,并在每次迭代中将邻居数量增加 1 [Tom76a]:
>>> from imblearn.under_sampling import AllKNN
>>> allknn = AllKNN()
>>> X_resampled, y_resampled = allknn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 220), (2, 4601)]
当检查邻居的最大数量(由用户通过参数 n_neighbors
设置)达到上限,或者当多数类变成少数类时,AllKNN
将停止清理过程。
在下面的例子中,我们看到 EditedNearestNeighbours
、RepeatedEditedNearestNeighbours
以及 AllKNN
在清理类别边界处的“噪声”样本时具有类似的效果。
3.2.2.3. Condensed 最近邻方法
CondensedNearestNeighbour
使用一个 1 最近邻规则来迭代地决定是否应移除某个样本 [Har68]。该算法的执行步骤如下:
- 将所有少数类样本放入集合 C 中。
- 从目标类别(需要欠采样的类别)中选取一个样本加入集合 C,并将该类别的其他所有样本放入集合 S 中。
- 基于集合 C 训练一个 1-最近邻分类器。
- 按照顺序逐个遍历集合 S 中的样本,并使用步骤 3 中训练好的 1-最近邻规则对其进行分类。
- 如果某个样本被错误分类,则将其添加到集合 C 中,并进入第 6 步。
- 重复步骤 3 到步骤 5,直到集合 S 中的所有样本都被检查完毕。
最终的数据集为 S,其中包含少数类的所有观测样本以及被连续的 1-近邻算法错误分类的多数类样本。
可以按照以下方式使用 CondensedNearestNeighbour
:
>>> from imblearn.under_sampling import CondensedNearestNeighbour
>>> cnn = CondensedNearestNeighbour(random_state=0)
>>> X_resampled, y_resampled = cnn.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 24), (2, 115)]
CondensedNearestNeighbour
对噪声敏感,并可能会引入一些噪声样本(详见后面的图示)。
3.2.2.3.1. 单边选择(One Sided Selection)
为了消除由 CondensedNearestNeighbour
引入的噪声观测值,OneSidedSelection
首先会找出那些难以分类的观测样本,然后使用 TomekLinks
来移除这些噪声样本[Har68]。其执行流程如下:
- 将所有少数类样本放入集合 C 中。
- 将目标类别(即需要欠采样的类别)中的一个样本加入集合 C,并将该类别的其他所有样本放入集合 S 中。
- 在集合 C 上训练一个 1-近邻分类器(1-Nearest Neighbors)。
- 使用步骤 3 中训练好的 1 近邻分类规则,对集合 S 中的所有样本进行分类。
- 将所有被错误分类的样本加入集合 C。
- 从集合 C 中移除 Tomek Links。
最终的数据集为 S,它包含来自少数类的所有观测样本、随机添加的部分多数类样本,以及被 1-近邻算法错误分类的多数类样本。
请注意,与 CondensedNearestNeighbour
不同, OneSidedSelection
并不会在每次样本被错误分类后重新训练一个 K 近邻模型。它在步骤 3 中使用 1 近邻方法对所有多数类样本进行一次分类。该类可以如下使用:
>>> from imblearn.under_sampling import OneSidedSelection
>>> oss = OneSidedSelection(random_state=0)
>>> X_resampled, y_resampled = oss.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 174), (2, 4404)]
我们的实现提供了一个选项,可通过参数 n_seeds_S
来设定随机放入集合 C 中的观测数量。
NeighbourhoodCleaningRule
更侧重于清洗数据而非压缩数据 [Lau01]。因此,它将结合 EditedNearestNeighbours
所拒绝的样本,并通过一个 3 近邻分类器的输出结果来进行数据清理。该类可以如下使用:
>>> from imblearn.under_sampling import NeighbourhoodCleaningRule
>>> ncr = NeighbourhoodCleaningRule(n_neighbors=11)
>>> X_resampled, y_resampled = ncr.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 193), (2, 4535)]
3.2.3. 其他欠采样技术
3.2.3.1. 实例难度阈值(Instance Hardness Threshold)
实例难度(Instance Hardness)用于衡量正确分类某个样本或观测值的难易程度。换句话说,较难的实例是指那些难以被正确分类的观测。
本质上,难以正确分类的实例是指学习算法或分类器对其预测为正确类别标签的概率较低的那些样本。
我们的思路是,如果从数据集中移除这些难以分类的实例,将有助于分类器更有效地识别不同类别 [SMGC14]。
InstanceHardnessThreshold
会在数据上训练一个分类器,然后移除预测概率较低的样本 [SMGC14]。换句话说,它会保留那些具有较高类别预测概率的观测样本。
在我们的实现中,InstanceHardnessThreshold
几乎是一种受控欠采样方法:它会保留目标类别中的特定数量的观测样本,该数量由用户指定(见下文注意事项)。
此类的使用方式如下:
>>> from sklearn.linear_model import LogisticRegression
>>> from imblearn.under_sampling import InstanceHardnessThreshold
>>> iht = InstanceHardnessThreshold(random_state=0,
... estimator=LogisticRegression())
>>> X_resampled, y_resampled = iht.fit_resample(X, y)
>>> print(sorted(Counter(y_resampled).items()))
[(0, 64), (1, 64), (2, 64)]
InstanceHardnessThreshold
包含两个重要的参数。参数 estimator
可接受任何具有 predict_proba
方法的 scikit-learn 分类器。该分类器将用于识别难以分类的样本。训练过程通过交叉验证完成,交叉验证的方式可通过参数 ``cv` 指定。
注意
InstanceHardnessThreshold
几乎可以被看作一种受控欠采样方法。然而,由于概率输出的原因,有时可能无法精确获得用户指定的样本数量。
下图展示了在一个示例数据集上进行实例硬度欠采样的效果。
le/references/generated/imblearn.under_sampling.InstanceHardnessThreshold.html#imblearn.under_sampling.InstanceHardnessThreshold) 几乎可以被看作一种受控欠采样方法。然而,由于概率输出的原因,有时可能无法精确获得用户指定的样本数量。
下图展示了在一个示例数据集上进行实例硬度欠采样的效果。
[外链图片转存中…(img-gJyBTSER-1751008927739)]
该篇文章由ChatGPT翻译,如有疑问,欢迎提交Issues