前两篇主要谈类别不平衡问题的评估方法,重心放在各种评估指标以及ROC和PR曲线上,只有在明确了这些后,咱们才能据此选择具体的处理类别不平衡问题的方法。本篇介绍的采样方法是其中比较经常使用的方法,其主要目的是经过改变原有的不平衡样本集,以期得到一个平衡的样本分布,进而学习出合适的模型。html
采样方法大体可分为过采样 (oversampling) 和欠采样 (undersampling) ,虽然过采样和降采样主题思想简单,但这些年来研究出了不少变种,本篇挑一些来具体阐述。见下思惟导图:python
随机过采样顾名思义就是从样本少的类别中随机抽样,再将抽样得来的样本添加到数据集中。然而这种方法现在已经不大使用了,由于重复采样每每会致使严重的过拟合,于是如今的主流过采样方法是经过某种方式人工合成一些少数类样本,从而达到类别平衡的目的,而这其中的鼻祖就是SMOTE。git
SMOTE (synthetic minority oversampling technique) 的思想归纳起来就是在少数类样本之间进行插值来产生额外的样本。具体地,对于一个少数类样本\(\mathbf{x}_i\)使用K近邻法(k值须要提早指定),求出离\(\mathbf{x}_i\)距离最近的k个少数类样本,其中距离定义为样本之间n维特征空间的欧氏距离。而后从k个近邻点中随机选取一个,使用下列公式生成新样本:github
\[ \mathbf{x}_{new}=\mathbf{x}_{i}+(\mathbf{\hat{x}}_{i}-\mathbf{x}_{i}) \times \delta \tag{1.1} \]
其中\(\mathbf{\hat{x}}\)为选出的k近邻点,\(\delta\in[0,1]\)是一个随机数。下图就是一个SMOTE生成样本的例子,使用的是3-近邻,能够看出SMOTE生成的样本通常就在\(\mathbf{x}_{i}\)和\(\mathbf{\hat{x}}_{i}\)相连的直线上:算法
SMOTE会随机选取少数类样本用以合成新样本,而不考虑周边样本的状况,这样容易带来两个问题:dom
总的来讲咱们但愿新合成的少数类样本能处于两个类别的边界附近,这样每每能提供足够的信息用以分类。而这就是下面的 Border-line SMOTE
算法要作的事情。机器学习
这个算法会先将全部的少数类样本分红三类,以下图所示:学习
Border-line SMOTE
算法只会从处于”danger“状态的样本中随机选择,而后用SMOTE算法产生新的样本。处于”danger“状态的样本表明靠近”边界“附近的少数类样本,而处于边界附近的样本每每更容易被误分类。于是 Border-line SMOTE
只对那些靠近”边界“的少数类样本进行人工合成样本,而 SMOTE
则对全部少数类样本一视同仁。测试
Border-line SMOTE
分为两种: Borderline-1 SMOTE
和 Borderline-2 SMOTE
。 Borderline-1 SMOTE
在合成样本时\((1.1)\)式中的\(\mathbf{\hat{x}}\)是一个少数类样本,而 Borderline-2 SMOTE
中的\(\mathbf{\hat{x}}\)则是k近邻中的任意一个样本。fetch
ADASYN名为自适应合成抽样(adaptive synthetic sampling),其最大的特色是采用某种机制自动决定每一个少数类样本须要产生多少合成样本,而不是像SMOTE那样对每一个少数类样本合成同数量的样本。具体流程以下:
首先计算须要合成的样本总量:
\[ G = (S_{maj} - S_{min}) \times \beta \]
其中\(S_{maj}\)为多数类样本数量,\(S_{min}\)为少数类样本数量,\(\beta \in [0,1]\)为系数。G即为总共想要合成的少数类样本数量,若是\(\beta=1\)则是合成后各种别数目相等。
对于每一个少类别样本\(\mathbf{x}_i\),找出其K近邻个点,并计算:
\[ \Gamma_i = \frac{\Delta_i\,/\,K}{Z} \]
其中\(\Delta_i\)为K近邻个点中多数类样本的数量,Z为规范化因子以确保 \(\Gamma\) 构成一个分布。这样若一个少数类样本\(\mathbf{x}_i\)的周围多数类样本越多,则其 \(\Gamma_i\) 也就越高。
最后对每一个少类别样本\(\mathbf{x}_i\)计算须要合成的样本数量\(g_i\),再用SMOTE算法合成新样本:
\[ g_i = \Gamma_i \times G \]
能够看到ADASYN利用分布\(\Gamma\)来自动决定每一个少数类样本所须要合成的样本数量,这等因而给每一个少数类样本施加了一个权重,周围的多数类样本越多则权重越高。ADASYN的缺点是易受离群点的影响,若是一个少数类样本的K近邻都是多数类样本,则其权重会变得至关大,进而会在其周围生成较多的样本。
下面利用sklearn中的 make_classification
构造了一个不平衡数据集,各种别比例为{0:54, 1:946}
。原始数据,SMOTE
,Borderline-1 SMOTE
,Borderline-2 SMOTE
和ADASYN
的比较见下图,左侧为过采样后的决策边界,右侧为过采样后的样本分布状况,能够看到过采样后原来少数类的决策边界都扩大了,致使更多的多数类样本被划为少数类了:
从上图咱们也能够比较几种过采样方法各自的特色。用 SMOTE
合成的样本分布比较平均,而Border-line SMOTE
合成的样本则集中在类别边界处。ADASYN
的特性是一个少数类样本周围多数类样本越多,则算法会为其生成越多的样本,从图中也能够看到生成的样本大都来自于原来与多数类比较靠近的那些少数类样本。
随机欠采样的思想一样比较简单,就是从多数类样本中随机选取一些剔除掉。这种方法的缺点是被剔除的样本可能包含着一些重要信息,导致学习出来的模型效果很差。
EasyEnsemble和BalanceCascade采用集成学习机制来处理传统随机欠采样中的信息丢失问题。
EasyEnsemble将多数类样本随机划分红n个子集,每一个子集的数量等于少数类样本的数量,这至关于欠采样。接着将每一个子集与少数类样本结合起来分别训练一个模型,最后将n个模型集成,这样虽然每一个子集的样本少于整体样本,但集成后总信息量并不减小。
若是说EasyEnsemble是基于无监督的方式从多数类样本中生成子集进行欠采样,那么BalanceCascade则是采用了有监督结合Boosting的方式。在第n轮训练中,将从多数类样本中抽样得来的子集与少数类样本结合起来训练一个基学习器H,训练完后多数类中能被H正确分类的样本会被剔除。在接下来的第n+1轮中,从被剔除后的多数类样本中产生子集用于与少数类样本结合起来训练,最后将不一样的基学习器集成起来。BalanceCascade的有监督表如今每一轮的基学习器起到了在多数类中选择样本的做用,而其Boosting特色则体如今每一轮丢弃被正确分类的样本,进然后续基学习器会更注重那些以前分类错误的样本。
NearMiss本质上是一种原型选择(prototype selection)方法,即从多数类样本中选取最具表明性的样本用于训练,主要是为了缓解随机欠采样中的信息丢失问题。NearMiss采用一些启发式的规则来选择样本,根据规则的不一样可分为3类:
NearMiss-1和NearMiss-2的计算开销很大,由于须要计算每一个多类别样本的K近邻点。另外,NearMiss-1易受离群点的影响,以下面第二幅图中合理的状况是处于边界附近的多数类样本会被选中,然而因为右下方一些少数类离群点的存在,其附近的多数类样本就被选择了。相比之下NearMiss-2和NearMiss-3不易产生这方面的问题。
这类方法主要经过某种规则来清洗重叠的数据,从而达到欠采样的目的,而这些规则每每也是启发性的,下面进行简要阐述:
最后,数据清洗技术最大的缺点是没法控制欠采样的数量。因为都在某种程度上采用了K近邻法,而事实上大部分多数类样本周围也都是多数类,于是能剔除的多数类样本比较有限。
上文中提到SMOTE算法的缺点是生成的少数类样本容易与周围的多数类样本产生重叠难以分类,而数据清洗技术刚好能够处理掉重叠样本,因此能够将两者结合起来造成一个pipeline,先过采样再进行数据清洗。主要的方法是 SMOTE + ENN
和 SMOTE + Tomek
,其中 SMOTE + ENN
一般能清除更多的重叠样本,以下图:
综上,本文简要介绍了几种过采样和欠采样的方法,其实还有更多的变种,可参阅imbalanced-learn
最后列出的的References 。
最后固然还有一个最重要的问题,本文列举了多种不一样的采样方法,那么哪一种方法效果好呢? 下面用两个数据集对各种方法进行比较,一样结论也是基于这两个数据集。本文代码主要使用了imbalanced-learn这个库,算是scikit-learn的姐妹项目。
第一个数据集为 us_crime,多数类样本和少数类样本的比例为12:1。
us_crime = fetch_datasets()['us_crime'] X_train, X_test, y_train, y_test = train_test_split(us_crime.data, us_crime.target, test_size=0.5, random_state=42, stratify=us_crime.target) # 分为训练集和测试集
总共用9个模型,1个 base_model (即不进行采样的模型) 加上8个过采样和欠采样方法。imbalanced-learn
中大部分采样方法均可以使用 make_pipeline
将采样方法和分类模型链接起来,但两种集成方法,EasyEnsemble
和 BalanceCascade
没法使用 make_pipeline
(由于本质上是集成了好几个分类模型),因此须要自定义方法。
sampling_methods = [Original(), SMOTE(random_state=42), SMOTE(random_state=42, kind='borderline1'), ADASYN(random_state=42), EasyEnsemble(random_state=42), BalanceCascade(random_state=42), NearMiss(version=3, random_state=42), SMOTEENN(random_state=42), SMOTETomek(random_state=42)] names = ['Base model', 'SMOTE', 'Borderline SMOTE', 'ADASYN', 'EasyEnsemble', 'BalanceCascade', 'NearMiss', 'SMOTE+ENN', 'SMOTE+Tomek'] def ensemble_method(method): # EasyEnsemble和BalanceCascade的方法 count = 0 xx, yy = method.fit_sample(X_train, y_train) y_pred, y_prob = np.zeros(len(X_test)), np.zeros(len(X_test)) for X_ensemble, y_ensemble in zip(xx, yy): model = LogisticRegression() model.fit(X_ensemble, y_ensemble) y_pred += model.predict(X_test) y_prob += model.predict_proba(X_test)[:, 1] count += 1 return np.where(y_pred >= 0, 1, -1), y_prob/count # 画ROC曲线 plt.figure(figsize=(15,8)) for (name, method) in zip(names, sampling_methods): t0 = time.time() if name == 'EasyEnsemble' or name == 'BalanceCascade': y_pred, y_prob = ensemble_method(method) else: model = make_pipeline(method, LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] fpr, tpr, thresholds = roc_curve(y_test, y_prob, pos_label=1) plt.plot(fpr, tpr, lw=3, label='{} (AUC={:.2f}, time={:.2f}s)'. format(name, auc(fpr, tpr), time.time() - t0)) plt.xlabel("FPR", fontsize=17) plt.ylabel("TPR", fontsize=17) plt.legend(fontsize=14)
# 画PR曲线 plt.figure(figsize=(15,8)) for (name, method) in zip(names, sampling_methods): t0 = time.time() if name == 'EasyEnsemble' or name == 'BalanceCascade': y_pred, y_prob = ensemble_method(method) else: model = make_pipeline(method, LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] precision, recall, thresholds = precision_recall_curve(y_test, y_prob, pos_label=1) plt.plot(recall, precision, lw=3, label='{} (AUC={:.2f}, time={:.2f}s)'. format(name, auc(recall, precision), time.time() - t0)) plt.xlabel("Recall", fontsize=17) plt.ylabel("Precision", fontsize=17) plt.legend(fontsize=14, loc="upper right")
第二个数据集是 abalone,多数类样本和少数类样本的比例为130:1,很是悬殊。
abalone_19 = fetch_datasets()['abalone_19'] X_train, X_test, y_train, y_test = train_test_split(abalone_19.data, abalone_19.target, test_size=0.5, random_state=42, stratify=abalone_19.target) # 画ROC曲线和PR曲线 plt.figure(figsize=(15,8)) for (name, method) in zip(names, sampling_methods): t0 = time.time() if name == 'EasyEnsemble' or name == 'BalanceCascade': y_pred, y_prob = ensemble_method(method) else: model = make_pipeline(method, LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] fpr, tpr, thresholds = roc_curve(y_test, y_prob, pos_label=1) plt.plot(fpr, tpr, lw=3, label='{} (AUC={:.2f}, time={:.2f}s)'. format(name, auc(fpr, tpr), time.time() - t0)) plt.xlabel("FPR", fontsize=17) plt.ylabel("TPR", fontsize=17) plt.legend(fontsize=14) plt.figure(figsize=(15,8)) for (name, method) in zip(names, sampling_methods): t0 = time.time() if name == 'EasyEnsemble' or name == 'BalanceCascade': y_pred, y_prob = ensemble_method(method) else: model = make_pipeline(method, LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] precision, recall, thresholds = precision_recall_curve(y_test, y_prob, pos_label=1) plt.plot(recall, precision, lw=3, label='{} (AUC={:.2f}, time={:.2f}s)'. format(name, auc(recall, precision), time.time() - t0)) plt.xlabel("Recall", fontsize=17) plt.ylabel("Precision", fontsize=17) plt.legend(fontsize=14, loc="best")
从以上几张图中咱们能够得出一些推论:
SMOTE + ENN
和SMOTE + Tomek
)耗时最高,若是追求速度的话这几个可能并不是很好的选择。us_crime
的多数类和少数类样本比例为 12:1,相差不是很悬殊,综合ROC曲线和PR曲线的AUC来看,两种集成方法EasyEnsemble
和BalanceCascade
表现较好。abalone_19
来讲,多数类和少数类样本比例为 130:1,并且少数类样本很是少,于是从结果来看几种过采样方法如Borderline SMOTE, SMOTE+Tomek
等效果较好。可见在类别差别很大的状况下,过采样能必定程度上弥补少数类样本的极端不足。然而从PR曲线上来看,其实结果都不尽如人意,对于这种极端不平衡的数据可能比较适合异常检测的方法,之后有机会详述。def ensemble_method_2(method): # 定义一个简化版集成方法 xx, yy = method.fit_sample(X_train, y_train) y_pred, y_prob = np.zeros(len(X_test)), np.zeros(len(X_test)) for X_ensemble, y_ensemble in zip(xx, yy): model = LogisticRegression() model.fit(X_ensemble, y_ensemble) y_pred += model.predict(X_test) return np.where(y_pred >= 0, 1, -1) us_crime = fetch_datasets()['us_crime'] X_train, X_test, y_train, y_test = train_test_split(us_crime.data, us_crime.target, test_size=0.5, random_state=42, stratify=us_crime.target) class_names = ['majority class', 'minority class'] model = LogisticRegression() model.fit(X_train, y_train) y_pred = model.predict(X_test) print("------------------------Base Model---------------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n') model = make_pipeline(SMOTE(random_state=42), LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) print("--------------------------SMOTE------------------------ \n", classification_report(y_test, y_pred, target_names=class_names), '\n') model = make_pipeline(NearMiss(version=2, random_state=42), LogisticRegression()) model.fit(X_train, y_train) y_pred = model.predict(X_test) print("------------------------NearMiss------------------------ \n", classification_report(y_test, y_pred, target_names=class_names), '\n') y_pred = ensemble_method_2(EasyEnsemble(random_state=42)) class_names = ['majority class', 'minority class'] print("-----------------------EasyEnsemble--------------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n')
------------------------Base Model---------------------- precision recall f1-score support majority class 0.95 0.98 0.97 922 minority class 0.62 0.35 0.44 75 avg / total 0.92 0.93 0.93 997 --------------------------SMOTE------------------------ precision recall f1-score support majority class 0.98 0.90 0.94 922 minority class 0.38 0.75 0.51 75 avg / total 0.93 0.89 0.91 997 ------------------------NearMiss------------------------ precision recall f1-score support majority class 0.97 0.81 0.88 922 minority class 0.24 0.73 0.36 75 avg / total 0.92 0.80 0.84 997 -----------------------EasyEnsemble--------------------- precision recall f1-score support majority class 0.98 0.85 0.91 922 minority class 0.31 0.84 0.45 75 avg / total 0.93 0.85 0.88 997
这里咱们主要关注少数类样本,能够看到 Base Model 的特色是precision高,recall低,而几种采样方法则相反,precision低,recall高。采样方法广泛扩大了少数类样本的决策边界(从上文中的决策边界图就能看出来),因此把不少多数类样本也划为少数类了,致使precision降低而recall提高。固然这些都是分类阈值为0.5的前提下得出的结论,若是进一步调整阈值的话能获得更好的模型。策略是base model的阈值往下调,采样方法的阈值往上调。
def ensemble_method_3(method): xx, yy = method.fit_sample(X_train, y_train) y_pred, y_prob = np.zeros(len(X_test)), np.zeros(len(X_test)) for X_ensemble, y_ensemble in zip(xx, yy): model = LogisticRegression() model.fit(X_ensemble, y_ensemble) y_pred += np.where(model.predict_proba(X_test)[:, 1] >= 0.7, 1, -1) # 阈值 > 0.7 return np.where(y_pred >= 0, 1, -1) us_crime = fetch_datasets()['us_crime'] X_train, X_test, y_train, y_test = train_test_split(us_crime.data, us_crime.target, test_size=0.5, random_state=42, stratify=us_crime.target) model = LogisticRegression() model.fit(X_train, y_train) y_pred = np.where(model.predict_proba(X_test)[:, 1] >= 0.3, 1, -1) print("-----------------Base Model, threshold >= 0.3--------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n') model = make_pipeline(SMOTE(random_state=42), LogisticRegression()) model.fit(X_train, y_train) y_pred = np.where(model.predict_proba(X_test)[:, 1] >= 0.9, 1, -1) print("-----------------SMOTE, threshold >= 0.9--------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n') model = make_pipeline(NearMiss(version=2, random_state=42), LogisticRegression()) model.fit(X_train, y_train) y_pred = np.where(model.predict_proba(X_test)[:, 1] >= 0.7, 1, -1) print("-------------------NearMiss, threshold >= 0.7------------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n') model = EasyEnsemble(random_state=42) y_pred = ensemble_method_3(model) class_names = ['majority class', 'minority class'] print("--------------EasyEnsemble, threshold >= 0.7-------------- \n", classification_report(y_test, y_pred, target_names=class_names), '\n')
-----------------Base Model, threshold >= 0.3------------ precision recall f1-score support majority class 0.96 0.96 0.96 922 minority class 0.53 0.53 0.53 75 avg / total 0.93 0.93 0.93 997 -----------------SMOTE, threshold >= 0.9---------------- precision recall f1-score support majority class 0.96 0.97 0.97 922 minority class 0.60 0.49 0.54 75 avg / total 0.93 0.94 0.93 997 -------------------NearMiss, threshold >= 0.7------------ precision recall f1-score support majority class 0.96 0.90 0.93 922 minority class 0.32 0.59 0.42 75 avg / total 0.92 0.88 0.89 997 --------------EasyEnsemble, threshold >= 0.7------------- precision recall f1-score support majority class 0.97 0.92 0.95 922 minority class 0.42 0.69 0.53 75 avg / total 0.93 0.91 0.92 997
在通过阈值调整后,各方法的总体F1分数都有提升,可见不少单指标如 precision,recall 等都会受到不一样阈值的影响。因此这也是为何在类别不平衡问题中用ROC和PR曲线来评估很是流行,由于它们不受特定阈值变化的影响,反映的是模型的总体预测能力。
不过在这里我不得不得出一个比较悲观的结论:就这两个数据集的结果来看,若是自己数据偏斜不是很厉害,那么采样方法的提高效果很细微。若是自己数据偏斜很厉害,采样方法纵使比base model好不少,但因为base model自己的少数类预测能力不好,因此本质上也不尽如人意。这就像考试原来一直靠10分,采样了以后考了30分,绝对意义上提高很大,但其实仍是差得远了。