機器學習之類別不平衡問題 (1) —— 各種評估指標
機器學習之類別不平衡問題 (2) —— ROC和PR曲線
機器學習之類別不平衡問題 (3) —— 采樣方法
完整代碼
前兩篇主要談類別不平衡問題的評估方法,重心放在各類評估指標以及ROC和PR曲線上,只有在明確了這些后,我們才能據此選擇具體的處理類別不平衡問題的方法。本篇介紹的采樣方法是其中比較常用的方法,其主要目的是通過改變原有的不平衡樣本集,以期獲得一個平衡的樣本分布,進而學習出合適的模型。
采樣方法大致可分為過采樣 (oversampling) 和欠采樣 (undersampling) ,雖然過采樣和降采樣主題思想簡單,但這些年來研究出了很多變種,本篇挑一些來具體闡述。見下思維導圖:

\(\scriptsize{\spadesuit}\) 過采樣
1. 隨機過采樣
隨機過采樣顧名思義就是從樣本少的類別中隨機抽樣,再將抽樣得來的樣本添加到數據集中。然而這種方法如今已經不大使用了,因為重復采樣往往會導致嚴重的過擬合,因而現在的主流過采樣方法是通過某種方式人工合成一些少數類樣本,從而達到類別平衡的目的,而這其中的鼻祖就是SMOTE。
2. SMOTE
SMOTE (synthetic minority oversampling technique) 的思想概括起來就是在少數類樣本之間進行插值來產生額外的樣本。具體地,對於一個少數類樣本\(\mathbf{x}_i\)使用K近鄰法(k值需要提前指定),求出離\(\mathbf{x}_i\)距離最近的k個少數類樣本,其中距離定義為樣本之間n維特征空間的歐氏距離。然后從k個近鄰點中隨機選取一個,使用下列公式生成新樣本:
其中\(\mathbf{\hat{x}}\)為選出的k近鄰點,\(\delta\in[0,1]\)是一個隨機數。下圖就是一個SMOTE生成樣本的例子,使用的是3-近鄰,可以看出SMOTE生成的樣本一般就在\(\mathbf{x}_{i}\)和\(\mathbf{\hat{x}}_{i}\)相連的直線上:

SMOTE會隨機選取少數類樣本用以合成新樣本,而不考慮周邊樣本的情況,這樣容易帶來兩個問題:
- 如果選取的少數類樣本周圍也都是少數類樣本,則新合成的樣本不會提供太多有用信息。這就像支持向量機中遠離margin的點對決策邊界影響不大。
- 如果選取的少數類樣本周圍都是多數類樣本,這類的樣本可能是噪音,則新合成的樣本會與周圍的多數類樣本產生大部分重疊,致使分類困難。
總的來說我們希望新合成的少數類樣本能處於兩個類別的邊界附近,這樣往往能提供足夠的信息用以分類。而這就是下面的 Border-line SMOTE
算法要做的事情。
3. Border-line SMOTE
這個算法會先將所有的少數類樣本分成三類,如下圖所示:
- "noise" : 所有的k近鄰個樣本都屬於多數類
- "danger" : 超過一半的k近鄰樣本屬於多數類
- "safe": 超過一半的k近鄰樣本屬於少數類

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近鄰中的任意一個樣本。
4. ADASYN
ADASYN名為自適應合成抽樣(adaptive synthetic sampling),其最大的特點是采用某種機制自動決定每個少數類樣本需要產生多少合成樣本,而不是像SMOTE那樣對每個少數類樣本合成同數量的樣本。具體流程如下:
- 首先計算需要合成的樣本總量:
其中\(S_{maj}\)為多數類樣本數量,\(S_{min}\)為少數類樣本數量,\(\beta \in [0,1]\)為系數。G即為總共想要合成的少數類樣本數量,如果\(\beta=1\)則是合成后各類別數目相等。
- 對於每個少類別樣本\(\mathbf{x}_i\),找出其K近鄰個點,並計算:
其中\(\Delta_i\)為K近鄰個點中多數類樣本的數量,Z為規范化因子以確保 \(\Gamma\) 構成一個分布。這樣若一個少數類樣本\(\mathbf{x}_i\)的周圍多數類樣本越多,則其 \(\Gamma_i\) 也就越高。
- 最后對每個少類別樣本\(\mathbf{x}_i\)計算需要合成的樣本數量\(g_i\),再用SMOTE算法合成新樣本:
可以看到ADASYN利用分布\(\Gamma\)來自動決定每個少數類樣本所需要合成的樣本數量,這等於是給每個少數類樣本施加了一個權重,周圍的多數類樣本越多則權重越高。ADASYN的缺點是易受離群點的影響,如果一個少數類樣本的K近鄰都是多數類樣本,則其權重會變得相當大,進而會在其周圍生成較多的樣本。
下面利用sklearn中的 make_classification
構造了一個不平衡數據集,各類別比例為{0:54, 1:946}
。原始數據,SMOTE
,Borderline-1 SMOTE
,Borderline-2 SMOTE
和ADASYN
的比較見下圖,左側為過采樣后的決策邊界,右側為過采樣后的樣本分布情況,可以看到過采樣后原來少數類的決策邊界都擴大了,導致更多的多數類樣本被划為少數類了:

從上圖我們也可以比較幾種過采樣方法各自的特點。用 `SMOTE` 合成的樣本分布比較平均,而`Border-line SMOTE`合成的樣本則集中在類別邊界處。`ADASYN`的特性是一個少數類樣本周圍多數類樣本越多,則算法會為其生成越多的樣本,從圖中也可以看到生成的樣本大都來自於原來與多數類比較靠近的那些少數類樣本。
\(\scriptsize{\blacklozenge}\) 欠采樣
1. 隨機欠采樣
隨機欠采樣的思想同樣比較簡單,就是從多數類樣本中隨機選取一些剔除掉。這種方法的缺點是被剔除的樣本可能包含着一些重要信息,致使學習出來的模型效果不好。
2. EasyEnsemble 和 BalanceCascade
EasyEnsemble和BalanceCascade采用集成學習機制來處理傳統隨機欠采樣中的信息丟失問題。
-
EasyEnsemble將多數類樣本隨機划分成n個子集,每個子集的數量等於少數類樣本的數量,這相當於欠采樣。接着將每個子集與少數類樣本結合起來分別訓練一個模型,最后將n個模型集成,這樣雖然每個子集的樣本少於總體樣本,但集成后總信息量並不減少。
-
如果說EasyEnsemble是基於無監督的方式從多數類樣本中生成子集進行欠采樣,那么BalanceCascade則是采用了有監督結合Boosting的方式。在第n輪訓練中,將從多數類樣本中抽樣得來的子集與少數類樣本結合起來訓練一個基學習器H,訓練完后多數類中能被H正確分類的樣本會被剔除。在接下來的第n+1輪中,從被剔除后的多數類樣本中產生子集用於與少數類樣本結合起來訓練,最后將不同的基學習器集成起來。BalanceCascade的有監督表現在每一輪的基學習器起到了在多數類中選擇樣本的作用,而其Boosting特點則體現在每一輪丟棄被正確分類的樣本,進而后續基學習器會更注重那些之前分類錯誤的樣本。
3. NearMiss
NearMiss本質上是一種原型選擇(prototype selection)方法,即從多數類樣本中選取最具代表性的樣本用於訓練,主要是為了緩解隨機欠采樣中的信息丟失問題。NearMiss采用一些啟發式的規則來選擇樣本,根據規則的不同可分為3類:
- NearMiss-1:選擇到最近的K個少數類樣本平均距離最近的多數類樣本
- NearMiss-2:選擇到最遠的K個少數類樣本平均距離最近的多數類樣本
- NearMiss-3:對於每個少數類樣本選擇K個最近的多數類樣本,目的是保證每個少數類樣本都被多數類樣本包圍
NearMiss-1和NearMiss-2的計算開銷很大,因為需要計算每個多類別樣本的K近鄰點。另外,NearMiss-1易受離群點的影響,如下面第二幅圖中合理的情況是處於邊界附近的多數類樣本會被選中,然而由於右下方一些少數類離群點的存在,其附近的多數類樣本就被選擇了。相比之下NearMiss-2和NearMiss-3不易產生這方面的問題。

4. 數據清洗方法 (data cleaning tichniques)
這類方法主要通過某種規則來清洗重疊的數據,從而達到欠采樣的目的,而這些規則往往也是啟發性的,下面進行簡要闡述:
- Tomek Link:Tomek Link表示不同類別之間距離最近的一對樣本,即這兩個樣本互為最近鄰且分屬不同類別。這樣如果兩個樣本形成了一個Tomek Link,則要么其中一個是噪音,要么兩個樣本都在邊界附近。這樣通過移除Tomek Link就能“清洗掉”類間重疊樣本,使得互為最近鄰的樣本皆屬於同一類別,從而能更好地進行分類。
下圖左上為原始數據,右上為SMOTE后的數據,左下虛線標識出Tomek Link,右下為移除Tomek Link后的數據集,可以看到不同類別之間樣本重疊減少了很多。

- Edited Nearest Neighbours(ENN):對於屬於多數類的一個樣本,如果其K個近鄰點有超過一半都不屬於多數類,則這個樣本會被剔除。這個方法的另一個變種是所有的K個近鄰點都不屬於多數類,則這個樣本會被剔除。
最后,數據清洗技術最大的缺點是無法控制欠采樣的數量。由於都在某種程度上采用了K近鄰法,而事實上大部分多數類樣本周圍也都是多數類,因而能剔除的多數類樣本比較有限。
\(\scriptsize{\clubsuit}\) 過采樣和欠采樣結合
上文中提到SMOTE算法的缺點是生成的少數類樣本容易與周圍的多數類樣本產生重疊難以分類,而數據清洗技術恰好可以處理掉重疊樣本,所以可以將二者結合起來形成一個pipeline,先過采樣再進行數據清洗。主要的方法是 `SMOTE + ENN` 和 `SMOTE + Tomek` ,其中 `SMOTE + ENN` 通常能清除更多的重疊樣本,如下圖:

綜上,本文簡要介紹了幾種過采樣和欠采樣的方法,其實還有更多的變種,可參閱`imbalanced-learn`最后列出的[ References](https://github.com/scikit-learn-contrib/imbalanced-learn)。

\(\scriptsize{\bigstar}\) 采樣方法的效果
最后當然還有一個最重要的問題,本文列舉了多種不同的采樣方法,那么哪種方法效果好呢? 下面用兩個數據集對各類方法進行比較,同樣結論也是基於這兩個數據集。本文[ 代碼](https://github.com/massquantity/Class-Imbalance)主要使用了[ imbalanced-learn](https://github.com/scikit-learn-contrib/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")


從以上幾張圖中我們可以得出一些推論:
- 就時間開銷而言,BalanceCascade以及兩種過采樣欠采樣結合的方法(
SMOTE + ENN
和SMOTE + Tomek
)耗時最高,如果追求速度的話這幾個可能並非很好的選擇。
- 第一個數據集
us_crime
的多數類和少數類樣本比例為 12:1,相差不是很懸殊,綜合ROC曲線和PR曲線的AUC來看,兩種集成方法EasyEnsemble
和BalanceCascade
表現較好。
對於第二個數據集abalone_19
來說,多數類和少數類樣本比例為 130:1,而且少數類樣本非常少,因而從結果來看幾種過采樣方法如Borderline SMOTE, SMOTE+Tomek
等效果較好。可見在類別差異很大的情況下,過采樣能一定程度上彌補少數類樣本的極端不足。然而從PR曲線上來看,其實結果都不盡如人意,對於這種極端不平衡的數據可能比較適合異常檢測的方法,以后有機會詳述。
- 上篇文章中提到 “ROC曲線通常會呈現一個過分樂觀的效果估計”,這里再一次得到體現。第一個數據集中大部分ROC曲線的AUC都在0.9左右,而PR曲線都在0.5左右。第二個數據集則更誇張,從PR曲線來看其實模型對於少數類的預測准確率是無限接近於0了,但在ROC曲線上卻很難看出這一點。
- 如果單純從ROC曲線和PR曲線上來看,表面上各種采樣方法和base model差別不大,但實際上這其中卻是暗流涌動。下面來看一下 us_crime 數據集中各方法的 classification report。
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分,絕對意義上提升很大,但其實還是差得遠了。