前兩篇主要談類別不平衡問題的評估方法,重心放在各種評估指標以及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分,絕對意義上提高很大,但其實仍是差得遠了。