CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 機械学習における不均衡データの問題点と対処法について

機械学習における分類問題では、扱うデータセットに含まれるラベルに偏りのあるケースがある。 これは、例えば異常検知の分野では特に顕著で、異常なデータというのは正常なデータに比べると極端に数が少ない。 正常なデータが 99.99% なのに対し異常なデータは 0.01% なんてこともある。 このようなデータセットは不均衡データ (Imbalanced data) といって機械学習で扱う上で注意を要する。

今回は、不均衡データを扱う上での問題点と、その対処法について見てみる。 なお、登場する分類問題の評価指標については、以前このブログで扱ったことがあるのでそちらを参照のこと。

blog.amedama.jp

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python -V
Python 3.6.7

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install scikit-learn matplotlib

続いて Python のインタプリタを起動しておく。

$ python

不均衡データを用意する

今回は scikit-learnmake_classification() 関数を使って擬似的な不均衡データを用意した。

>>> from sklearn.datasets import make_classification
>>> 
>>> args = {
...   'n_samples': 5000,
...   'n_features': 2,
...   'n_informative': 2,
...   'n_redundant': 0,
...   'n_repeated': 0,
...   'n_classes': 2,
...   'n_clusters_per_class': 1,
...   'weights': [0.99, 0.01],
...   'random_state': 42,
... }
>>> X, y = make_classification(**args)

用意したデータには約 99% の Negative なデータと約 1% の Positive なデータが含まれる。

>>> len(y[y == 0])
4922
>>> len(y[y == 1])
78

可視化してみる

先ほど生成したデータは二次元の特徴量を持っているので二次元の散布図として可視化してみよう。

>>> from matplotlib import pyplot as plt
>>> plt.scatter(X[y == 0, 0], X[y == 0, 1])
<matplotlib.collections.PathCollection object at 0x112d9b390>
>>> plt.scatter(X[y == 1, 0], X[y == 1, 1])
<matplotlib.collections.PathCollection object at 0x112d9b6d8>
>>> plt.show()

すると、次のようなグラフが得られる。 オレンジ色が Positive なデータで青色が Negative なデータになっている。 完全な分離は難しそうな感じ。

f:id:momijiame:20181216155416p:plain

ロジスティック回帰でモデルを作ってみる

まずは不均衡データをそのまま使ってロジスティック回帰でモデルを作ってみよう。 どんなことが起こるだろうか。

まずはモデルを用意する。

>>> from sklearn.linear_model import LogisticRegression
>>> clf = LogisticRegression(solver='lbfgs')

不均衡データをそのまま使って 5-Fold CV で学習・予測する。

>>> from sklearn.model_selection import cross_val_predict
>>> y_pred = cross_val_predict(clf, X, y, cv=5)

まずは、この結果を精度 (Accuracy) で評価してみよう。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y, y_pred)
0.9862

結果は 98.62% となって、なんだか結構よさそうな数値に見える。

ただ、ちょっと待ってもらいたい。 元々のデータは Negative なデータが約 99% だった。 精度 (Accuracy) は、果たして評価指標として適切だろうか? 例えば異常検知の世界であれば 1% の Positive なデータを、ちゃんと見つけられていないと目的は達せていないと考えられる。

そこで、試しに真陽性率 (Recall) を使って結果を評価してみよう。 これは、本当に Positive なデータに対してモデルがどれだけ正解できているかを示している。

>>> from sklearn.metrics import recall_score
>>> recall_score(y, y_pred)
0.11538461538461539

なんと約 11% しか正解できていなかった。 ようするに、ほとんどのデータを Negative と判断していることになる。

念のため適合率 (Precision) についても確認しておく。 これはモデルが Positive と判断したデータの中に、どれだけ本当に Positive なものがあったかを示している。

>>> from sklearn.metrics import precision_score
>>> precision_score(y, y_pred)
1.0

こちらは 100% だった。 つまり、モデルはだいぶ慎重な判断をしていたといえる。 ようするに、なかなか Positive とは判断しないものの、判断したものについてはちゃんと正解していた。

最後に、混同行列 (Confusion Matrix) を確認しておこう。

>>> from sklearn.metrics import confusion_matrix
>>> conf_matrix = confusion_matrix(y, y_pred)
>>> tn, fp, fn, tp = conf_matrix.ravel()
>>> tn, fp, fn, tp
(4922, 0, 69, 9)

Positive なデータのほとんどを誤って Negative と判断 (False Negative) していることが分かる。

データに重みをつける

False Negative を減らすための施策としてはデータの重み付けが考えられる。 これは、特定のラベルをより重要視するということ。

例えば scikit-learn のロジスティック回帰であれば class_weight というオプションでラベルの重みが変更できる。 今回は例として含まれるラベルの割合の逆数を重みにした。

>>> weights = {
...   0: 1 / (len(y[y == 0]) / len(y)),
...   1: 1 / (len(y[y == 1]) / len(y)),
... }
>>> clf = LogisticRegression(solver='lbfgs', class_weight=weights)
>>> y_pred = cross_val_predict(clf, X, y, cv=5)

その上で、また各種の評価指標を確認してみよう。

まず精度 (Accuracy) については、約 98% から約 78% まで大幅に下がってしまった。

>>> accuracy_score(y, y_pred)
0.7816

そして、適合率 (Precision) は 100% だったのが約 4% まで下がってしまった。

>>> precision_score(y, y_pred)
0.046511627906976744

反面、真陽性率 (Recall) については先ほど約 11% だったのが約 66% まで大幅に上昇している。

>>> recall_score(y, y_pred)
0.6666666666666666

最後に、混同行列 (Confusion Matrix) を確認しよう。

>>> conf_matrix = confusion_matrix(y, y_pred)
>>> tn, fp, fn, tp = conf_matrix.ravel()
>>> tn, fp, fn, tp
(3856, 1066, 26, 52)

今回は Positive なデータをきちんと正解できた (True Positive) 割合が上がった。 代わりに、Negative なデータを誤って Positive と判断 (False Positive) してしまった割合も上がってしまった。 今回のモデルはだいぶ甘い判断で Positive と判定しており、いわばオオカミ少年といえる。

もちろん、先ほどのモデルと今回のモデルは、どちらかが全面的に優れているというわけではない。 不均衡データにおいては、問題によって適切な評価指標を使い、モデルの味付けをきちんと調整する必要があるということを示している。

サンプリングする

続いてはサンプリングを使った対処方法を試してみる。 ここでいうサンプリングというのは統計における標本抽出 (Sampling) と同じ。

不均衡データをサンプリングする方法としては次の二つがある。

  • アップサンプリング (Up Sampling) : 少ないデータを増やす
  • ダウンサンプリング (Down sampling) : 多いデータを減らす

ようするに、特定のラベルのデータを増やしたり減らしたりすることで、不均衡データを均衡データにできる。 今回は、よく使われるであろう後者のダウンサンプリングを試す。

ちなみに、不均衡データのもうひとつの問題点として計算量がある。 というのも、例えば異常検知において正常なデータが大量にあっても、実はあまり性能には寄与しない。 性能の向上に寄与しやすいのは、識別境界の近くにあるデータのため。 それ以外のデータは、モデルにがんばって学習させても、ほとんど計算コストの無駄になる恐れがある。 ダウンサンプリングは、多いラベルのデータを減らすので計算量の削減になる。

ダウンサンプリングの実装は imbalanced-learn を使うと楽ができる。

$ pip install imbalanced-learn

今回は、無作為にサンプリングする RandomUnderSampler を使う。

>>> from imblearn.under_sampling import RandomUnderSampler
>>> sampler = RandomUnderSampler(random_state=42)
>>> X_resampled, y_resampled = sampler.fit_resample(X, y)

これで Positive と Negative が同数になった均衡データが得られる。

>>> len(X_resampled[y_resampled == 0, 0])
78
>>> len(X_resampled[y_resampled == 1, 0])
78

サンプリングしたデータを試しに可視化してみよう。

>>> plt.scatter(X_resampled[y_resampled == 0, 0], X_resampled[y_resampled == 0, 1])
<matplotlib.collections.PathCollection object at 0x10f19e438>
>>> plt.scatter(X_resampled[y_resampled == 1, 0], X_resampled[y_resampled == 1, 1])
<matplotlib.collections.PathCollection object at 0x10f19e048>
>>> plt.show()

先ほどに比べると、ダウンサンプリングによって青い点が少なくなっていることが分かる。

f:id:momijiame:20181216170115p:plain

試しにダウンサンプリングしたデータを使ってロジスティック回帰で分類させてみよう。

>>> clf = LogisticRegression(solver='lbfgs')
>>> y_sampled_pred = cross_val_predict(clf, X_resampled, y_resampled, cv=5)

いくつかの評価指標を使って結果を確認してみる。

>>> accuracy_score(y_resampled, y_sampled_pred)
0.7307692307692307
>>> recall_score(y_resampled, y_sampled_pred)
0.6923076923076923
>>> precision_score(y_resampled, y_sampled_pred)
0.75

不均衡データをそのまま使ったパターンに比べて精度 (Accuracy) と適合率 (Precision) は下がっているものの真陽性率 (Recall) は改善している。 ちなみに、もちろんサンプリングとラベルの重み付けを併用することもできる。

なお、上記では検証でもダウンサンプリングされたデータを使っているので、念のため自前でも CV を書いておく。 以下のサンプルコードでは、学習にダウンサンプリングしたデータを使って、検証には元のデータを使っている。

>>> from sklearn.model_selection import StratifiedKFold
>>> import numpy as np
>>> recalls = []
>>> skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
>>> for train_index, test_index in skf.split(X, y):
...     X_train, X_test = X[train_index], X[test_index]
...     y_train, y_test = y[train_index], y[test_index]
...     X_resampled, y_resampled = sampler.fit_resample(X_train, y_train)
...     clf = LogisticRegression(solver='lbfgs')
...     _ = clf.fit(X_resampled, y_resampled)  # no echo back
...     y_pred = clf.predict(X_test)
...     recall = recall_score(y_test, y_pred)
...     recalls.append(recall)
... 
>>> np.array(recalls).mean()
0.6775

ちなみに、ダウンサンプリングのときに偏りがでてしまうとモデルの汎化性能が損なわれる恐れがある。 そんなときは、サンプリング方法を工夫するか、あるいは異なるダウンサンプリングをしたデータを複数セット用意して Bagging/Voting すると良いんじゃないかと。

blog.amedama.jp

いじょう。

統計的学習の基礎 ―データマイニング・推論・予測―

統計的学習の基礎 ―データマイニング・推論・予測―

  • 作者: Trevor Hastie,Robert Tibshirani,Jerome Friedman,杉山将,井手剛,神嶌敏弘,栗田多喜夫,前田英作,井尻善久,岩田具治,金森敬文,兼村厚範,烏山昌幸,河原吉伸,木村昭悟,小西嘉典,酒井智弥,鈴木大慈,竹内一郎,玉木徹,出口大輔,冨岡亮太,波部斉,前田新一,持橋大地,山田誠
  • 出版社/メーカー: 共立出版
  • 発売日: 2014/06/25
  • メディア: 単行本
  • この商品を含むブログ (6件) を見る