不均衡データのクラス分類(R Advent Calendar 2011)

これは,R Advent Calendar 2011の担当分の記事です.

機械学習データマイニングの実務への適用が脚光を浴びている今日この頃ですが,現実の問題に機械学習を適用する際は,パラメータのチューニング方法など様々な観点から検討を行う必要があります.今回は,クラス分類における不均衡データ(imbalanced data)の扱いについて考えてみます.

不均衡データとは

クラス分類を実行する際の悩みどころの一つとして,クラス分類の興味の対象となるクラスのサンプル数が他のクラスと比べて少ないケースがよくあることが挙げられます.このようなデータは不均衡データと呼ばれます.

例えば,スパムメールかどうかの判別において,スパムであるサンプル数とスパムではないサンプル数.あるいは,ある重病に罹患する人を特定したい場合,その病気に罹患した人数と罹患していない人数.こうしたケースではクラス間でサンプル数に偏りがあるため,クラス分類の手法をそのまま適用しても十分な分類精度を得られないことが往々にしてあります.

また,Rを使って不均衡データをクラス分類するときに,関数の使い方をマニュアル片手に調べなければなければならないことも問題点の一つだと認識しています.このあたりをRコミュニティで共有したいと思ったことも,この記事を書いた動機の一つです.

まずは不均衡ではないデータの分類

比較のために,まずは不均衡ではないデータに対してSVMを適用して判別を行ってみましょう(ランダムフォレスト等の他の手法については,いずれ書くかもしれません・・・).

Rで学ぶデータサイエンスシリーズの「パターン認識」の第11章「サポートベクタマシン」で使われているデータを例にします.

> install.packages("kernlab")
> install.packages("mlbench")
> library(kernlab)
> library(mlbench)
> # 400点のデータを作成する
> # (> plot(dat$x) とコマンドを打てば渦巻状の2次元データが生成されたことを確認できる)
> dat <- mlbench.spirals(400, cycles=1.2, sd=0.07)
> # クラス1(正例とする),クラス2(負例とする)のサンプル数の確認
> table(dat$classes)

  1   2 
200 200
> # 10-fold クロスバリデーション
> idx <- sample(1:10, length(dat$classes), replace=T)
> pred <- rep(NA, length(dat$classes))
> # 10-foldのクロスバリデーションを実行
> for (i in 1:10) {
+   is.test <- idx == i
+   train <- dat$x[!is.test, ]
+   test <- dat$x[is.test, ]
+   fit.ksvm <- ksvm(train, dat$classes[!is.test])
+   pred[is.test] <- predict(fit.ksvm, test)
+ }
> # 分割表
> table(dat$classes, pred)
   pred
      1   2
  1 196   4
  2   6 194

それでは,この分類器の精度を評価しましょう.評価指標はたくさん提案されていますが,ここではprecision, recallを用いることにします.precisionは「正例と予測したサンプルのうち,どの程度正解したか」という観点から,recallは「実際の正例のうち,どの程度正例と予測してカバーできたか」という観点から識別精度を特徴付ける指標です.すなわち,予測と実績のラベルの分割表

- 正例(予測) 負例(予測)
正例(実績) TP FN
負例(実績) FP TN

において,precisionは  TP/(TP+FP),recallは  TP/(TP+FN)で定義されます.

上記の例では,クラス1を正例とすることにしたので,precisionは0.97(=196/(196+6)), recallは0.98(=196/(196+4))となります.したがって,特にパラメータをチューニングしなくても高い精度の分類器が得られたことが分かります.

本題の不均衡なデータのクラス分類

まずは愚直な分類

続いて,本題である不均衡なデータを分類してみましょう.最初は何も考えずに,不均衡ではないデータと同じ手続きで行います.解析データはUCI Machine Learning Repositoryのabaloneデータセットです.

> library(kernlab)
> # データの読み込み(データは"../data/"ディレクトリに置いておく)
> abalone <- read.csv("../data/abalone.data", header=FALSE)
> # データの先頭の確認
> head(abalone)
  V1    V2    V3    V4     V5     V6     V7    V8 V9
1  M 0.455 0.365 0.095 0.5140 0.2245 0.1010 0.150 15
2  M 0.350 0.265 0.090 0.2255 0.0995 0.0485 0.070  7
3  F 0.530 0.420 0.135 0.6770 0.2565 0.1415 0.210  9
4  M 0.440 0.365 0.125 0.5160 0.2155 0.1140 0.155 10
5  I 0.330 0.255 0.080 0.2050 0.0895 0.0395 0.055  7
6  I 0.425 0.300 0.095 0.3515 0.1410 0.0775 0.120  8
> # クラスの集計
> table(abalone[, 9])
  1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16  17  18  19  20 
  1   1  15  57 115 259 391 568 689 634 487 267 203 126 103  67  58  42  32  26 
 21  22  23  24  25  26  27  29 
 14   6   9   2   1   1   2   1 
> # 19番目のクラスを正例に,それ以外のクラスを負例とする
> label <- abalone[, 9]
> label[label==19] <- "positive"
> label[label!="positive"] <- "negative"
> label <- factor(label)
> table(label)
label
negative positive 
    4145       32 
> abaloneデータセットの9カラム目のラベルの置換
> abalone <- cbind(abalone[, -9], label)
> head(abalone)
  V1    V2    V3    V4     V5     V6     V7    V8    label
1  M 0.455 0.365 0.095 0.5140 0.2245 0.1010 0.150 negative
2  M 0.350 0.265 0.090 0.2255 0.0995 0.0485 0.070 negative
3  F 0.530 0.420 0.135 0.6770 0.2565 0.1415 0.210 negative
4  M 0.440 0.365 0.125 0.5160 0.2155 0.1140 0.155 negative
5  I 0.330 0.255 0.080 0.2050 0.0895 0.0395 0.055 negative
6  I 0.425 0.300 0.095 0.3515 0.1410 0.0775 0.120 negative
> # 10-foldのクロスバリデーションにおいて各データのクラスが予測されるfold数
> idx <- sample(1:10, nrow(abalone), replace=TRUE)
> # クロスバリデーションの実行(多項式カーネルを用い,次数は2とする)
> for (i in 1:10) {
+   is.test <- idx == i
+   abalone.train <- abalone[!is.test, ]
+   abalone.test <- abalone[is.test, -9]
+   fit.ksvm <- ksvm(label ~., data=abalone.train, kernel="polydot", kpar=list(degree=2))
+   pred[is.test] <- as.character(predict(fit.ksvm, abalone.test))
+ }
> # 予測結果の集計
> table(pred)
pred
negative 
    4177 

この結果を見れば分かるように,全てのデータを負例であると分類してしまっています.よって,precision,recallともに0という無残な結果になってしまいました...orz.

正例の重みの調整

それでは次のステップとして,正例の重みを変えることによりどの程度精度が向上するか確認してみましょう.ソフトマージンのSVMにおけるマージンを超えたときのペナルティ  C について,正例に対しては  C_{+}=w_{+} C,負例に対しては  C_{-}=w_{-} Cとなるように,重み  \mathbf{w}=(w_{+}, w_{-})を指定します.

このことは,ksvm関数では引数class.weightsを指定することに対応します.重みの比は負例と正例のサンプル数の比とします.

> label.table <- table(label)
> # 正例の重み(負例と正例のサンプル数の比とする)
> weight.positive <- as.numeric(label.table[1]/label.table[2])
> # 10-fold クロスバリデーションの実行
> for (i in 1:10) {
+   is.test <- idx == i
+   abalone.train <- abalone[!is.test, ]
+   abalone.test <- abalone[is.test, -9]
+   fit.ksvm <- ksvm(label ~., data=abalone.train, 
+                    class.weights=c("positive"=weight.positive, "negative"=1), 
+                    kernel="polydot", kapr=list(degree=2))
+   pred[is.test] <- as.character(predict(fit.ksvm, abalone.test))
+ }
> table(label, pred)
          pred
label      negative positive
  negative     3118     1027
  positive       19       13

この結果,precisionは0.01(=13/(13+1027)),recallは0.41(=13/(13+19))になりました.
class.weightsなどを調整することによりもう少し精度向上は望めると思いますが,ここでは割愛します.

分類器の精度を上げるためには,正例が存在する領域を増やすことが必要ではないかと考えられますが,そのためにはどうすれば良いでしょうか?

SMOTEアルゴリズムによる精度向上

こうした課題を解決するアルゴリズムは様々なものが提唱されていますが,ここでは2002年にChawlaらにより提唱されたSMOTE(Synthetic Minority Over-sampling Technique)を取り上げます.SMOTEは正例を人工的に作り出すことにより増やし,負例をランダムにアンダーサンプリングします.正例の各点に対して,k-最近傍点との間の点をランダムに選んで正例に加えます.

RではDMwRパッケージにSMOTEが入っています.余談ですが,DMwRパッケージの作者は"Data Mining with R"の著者であり,当パッケージと組み合わせると書籍の理解が進むようです.

まず,DMwRパッケージのSMOTE関数を用いて人工的な正例の作成と負例のランダムなアンダーサンプリングを行い,続いてSVMで学習します.

> install.packages("DMwR")
> library(DMwR)
> # 乱数種の固定
> set.seed(123)
> # 元のデータにサンプル名の付値
> rownames(abalone) <- paste("original", 1:nrow(abalone), sep="")
> # SMOTE関数を用いて人工的な正例の生成,負例をアンダーサンプリング
> abalone.smote <- 
+   SMOTE(label ~ ., data=abalone, perc.over=3500, perc.under=600)
> idx <- sample(1:10, nrow(abalone.smote), replace=T)
> pred <- rep(NA, nrow(abalone.smote))
> # 10-fold クロスバリデーションの実行
> for (i in 1:10) {
+   is.test <- idx == i
+   abalone.train <- abalone.smote[!is.test, ]
+   abalone.test <- abalone.smote[is.test, -9]
+   fit.ksvm <- ksvm(label ~., data=abalone.train,
                     kernel="polydot", kpar=list(degree=2))
+   pred[is.test] <- predict(fit.ksvm, abalone.test)
+ }
> # 分割表の表示
> table(abalone.smote[, "label"], pred)
           pred
           negative  positive
  negative       19        13
  positive        1       351
> # 元々のデータ点のみを対象とした分割表の表示
> is.original <- rownames(abalone.smote) %in% rownames(ablone)
> table(pred[original], abalone.smote[is.original, "label"])
           pred
           negative  positive
  negative       19        13
  positive        0        32

負例をアンダーサンプリングしているのでデータ点は減っていますが,SMOTE適用前に比べて判別精度が向上していることが確認できます.

おわりに

以上駆け足ですが,不均衡データのクラス分類について簡単に概観してきました.

データが与えられたときに有力な手法はそのデータの性質に依存する部分も大きいと思われます.上で紹介したSMOTEアルゴリズムも正例間の点は正例であるという仮定を置いているため,この仮定が妥当なデータかにより向き不向きは当然あると思います.不均衡データのクラス分類に関してはSMOTE以降も有力なアルゴリズムがいくつも出てきており,現在でも精力的に研究が行われているようです.最近の研究をキャッチアップするとともに今後の動向もウォッチングしていきたいと思います.

なお,以上では,Abaloneデータに対して多項式カーネルを使用しそのモデルパラメータも固定しましたが,実際の分析ではカーネルやそのパラメータもチューニングしなければならないので結構大変です.並列計算を利用することにより計算時間を減らすことも可能であるため,また別の記事で書いていければと思います.

参考文献