渋谷駅前で働くデータサイエンティストのブログ

元祖「六本木で働くデータサイエンティスト」です / 道玄坂→銀座→東京→六本木→渋谷駅前

機械学習分類器ごとに汎化vs.過学習の様子を可視化してみる

以前12回まで続けた「サンプルデータで試す機械学習シリーズ」ですが*1

あれから色々分類器の手法やその実装もバリエーションが増えてきたということもあり、思い立って今回まとめてやり直してみようと思います。そうそう、12回シリーズの頃から愛用している線形分離不可能XORパターンのデータセットがあるんですが、あれってサンプルサイズがたったの100しかないので色々と粗いんですよね。ということで、サンプルサイズを10000まで増やしてみたものを新しく生成しておいたので、こちらで試してみましょう。

> xorc <- read.csv('https://github.com/ozt-ca/tjo.hatenablog.samples/raw/master/r_samples/public_lib/jp/xor_complex_medium.txt',sep='\t')
> xorc$label <- as.factor(xorc$label-1)


データ自体は上記リンク先からもお分かりかと思いますが、xor_complex_XXXXX.txtという名前で僕のGitHubに上げてあります。他にも幾つか追加したシリーズがありますが、それはまたおいおいお見せしますよということで。


そう言えば、決定境界を描くためのグリッドなんですが、よくよく考えたらあれって(特にXORパターン)だと本来想定している「真の決定境界」が規定されているので、グリッドに対応する正解ラベルを与えることも可能なんですよね。ということで、今回はそれもやっておきました。

> px<-seq(-4,4,0.03)
> py<-seq(-4,4,0.03)
> pgrid<-expand.grid(px,py)
> names(pgrid)<-names(xorc)[-3]
> label.grid<-rep(0,nrow(pgrid))
> for(i in 1:nrow(pgrid)){
+     if (pgrid[i,1]>=0 & pgrid[i,2]>=0){label.grid[i]<-0}
+     if (pgrid[i,1]<0 & pgrid[i,2]>=0){label.grid[i]<-1}
+     if (pgrid[i,1]<0 & pgrid[i,2]<0){label.grid[i]<-0}
+     if (pgrid[i,1]>=0 & pgrid[i,2]<0){label.grid[i]<-1}
+ }


今回取り上げる機械学習分類器は、RBF(ガウシアン)カーネルSVM、ランダムフォレスト、Xgboost、Deep Learning (DNN)の4種類です。ちなみに、以前も実験したように本来はXORのような線形分離不可能パターンには使えないロジスティック回帰も交互作用項を入れれば分離できるようになる上、しかもなかなか良いパフォーマンスになるんですが、いかんせん全く汎用性がないので今回は取り上げません。というか、面倒なので非線形分類器だけに今回は絞りました(笑)。悪しからずご了承あれ。


RBF(ガウシアン)カーネルSVM


まずはRBF(ガウシアン)カーネルSVM。低バリアンスの分類器で汎化性能に優れる一方、意外と精度を上げにくいことも良くあるなぁというのが実務で使っていて個人的に持っている印象です。今回は広く使われているLIBSVMのR移植版であるsvm {e1071}で試してみます。

> library(e1071)
> xorc.svm.rbf<-svm(label~.,xorc,scale=F)
> out.svm.rbf<-predict(xorc.svm.rbf,newdata=pgrid)

> plot(c(),type='n',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(out.svm.rbf,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='RBF kernel SVM',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)
> table(label.grid,out.svm.rbf)
          out.svm.rbf
label.grid     0     1
         0 34968   677
         1   942 34702
> sum(diag(table(label.grid,out.svm.rbf)))/nrow(pgrid)
[1] 0.9772896

綺麗な決定境界を描くのですが、一つ問題なのはRBFカーネルを用いると学習データ点の外側から囲い込むような、円形に近い形状の決定境界になってしまうことかなと。一方、真の決定境界に対応するaccuracyは0.977となかなか良いパフォーマンスを示しています。


ところで、RBFカーネルSVMはハイパーパラメータである \gammaを大きくすることで過学習傾向を強めさせることができます。試しに以下のようにやってみましょう。

> xorc.svm.rbf.of <- svm(label~., xorc, gamma = xorc.svm.rbf$gamma * 100)
> out.svm.rbf.of <- predict(xorc.svm.rbf.of, newdata=pgrid)
> table(label.grid, out.svm.rbf.of)
          out.svm.rbf.of
label.grid     0     1
         0 24921 10724
         1  1172 34472
> sum(diag(table(label.grid, out.svm.rbf.of)))/nrow(pgrid)
[1] 0.8331299

> plot(c(),type='n',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(out.svm.rbf.of,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='Overfitted SVM',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)

プロット範囲の外側にはみ出ていた決定境界が内側にまで入ってきて、しかも細かく正例と負例とが入り組んだところにも決定境界が追従してしまい、明らかに過学習の傾向を見せています。真の決定境界に対応するaccuracyも0.833と大きく悪化しています。まさにこれぞ過学習という結果だと思います。


ランダムフォレスト


うっかりすると「とりあえずランダムフォレスト」と言ってしまいそうになるくらいどこの現場でも広く使われている、言わずと知れた決定木ベースのbagging系分類器ですね。分散させやすいこともあり、最近のクラウド機械学習フレームワークにもよく取り入れられているようです。こちらも試してみましょう。

> library(randomForest)
> xorc.rf<-randomForest(label~.,xorc)
> out.rf<-predict(xorc.rf,newdata=pgrid)

> plot(c(),type='n',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(out.rf,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='Random Forest',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)
> table(label.grid,out.rf)
          out.rf
label.grid     0     1
         0 33665  1980
         1  1891 33753
> sum(diag(table(label.grid,out.rf)))/nrow(pgrid)
[1] 0.9456999


全体としてはそれなりに真の決定境界に沿った結果になっているんですが、やはり細部で過学習気味になっている様子が容易に見て取れますね。。。実際、真の決定境界に対するaccuracyは0.946ということであまり芳しくありません。


ランダムフォレストはいじれるパラメータが多くなく、mtry(個々の木でサブサンプルしてくる特徴次元数)も1か2ぐらいしか選びようがないので、SVMのように過学習させる実験はしていません。


Xgboost


もうすっかりKagglerの間ではデフォルトになってきた強力な分類器、Xgboost。こちらも試してみようと思います。ちなみにXgboostの使い方については以前の過去記事をご参照下さい。


> library(xgboost)
> library(Matrix)
> dtrain.mx <- sparse.model.matrix(label~., xorc)
> dtest.mx <- sparse.model.matrix(~., pgrid)
> dtrain <- xgb.DMatrix(dtrain.mx, label = as.numeric(xorc$label)-1)
> dtest <- xgb.DMatrix(dtest.mx)
> xorc.gbdt <- xgb.train(params=list(objective='binary:logistic'), data=dtrain, nrounds=25)
> out.gbdt <- round(predict(xorc.gbdt, newdata=dtest),0)
> table(label.grid,out.gbdt)
          out.gbdt
label.grid     0     1
         0 34703   942
         1  1559 34085
> sum(diag(table(label.grid,out.gbdt)))/nrow(pgrid)
[1] 0.9649174

 par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(out.gbdt,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='Xgboost',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)

同じ木構造の分類器ながら、Xgboostの方がランダムフォレストよりもやや汎化に成功しているように見えますね。真の決定境界に対するaccuracyも0.965と、パフォーマンスでもランダムフォレストを上回っています。


ちなみに、XgboostでもSVMと同じようにパラメータをいじりまくって過学習させられないか試してみたんですが、全然過学習しないのでやめました(笑)。まぁ、特徴量が2次元と小さいのでそもそもいじる余地がないのかもですが。。。


Deep Learning (DNN)


ぶっちゃけ2次元XORパターン程度の簡単な分類にDeep Learningを突っ込むのは完全にオーバーキルなんですが、ここまで色々取り上げておいてやらないのも何なのでやってみます。


以前なら{h2o}でやっているところですが、最近はKerasのような各種パラメータ「だけ」をレイヤーごとに記述すれば動くようなフレームワーク全盛なので、同様のフレームワークであるMXnetのRパッケージである{mxnet}を使ってみます。なお{mxnet}を試してみたという過去記事もあります。


> train<-data.matrix(xorc)
> train.x<-train[,-3]
> train.y<-as.numeric(train[,3])-1
> test<-data.matrix(pgrid)
> data <- mx.symbol.Variable("data")
> fc1 <- mx.symbol.FullyConnected(data, name="fc1", num_hidden=4)
> act1 <- mx.symbol.Activation(fc1, name="tanh1", act_type="tanh")
> fc2 <- mx.symbol.FullyConnected(act1, name="fc2", num_hidden=3)
> act2 <- mx.symbol.Activation(fc2, name="tanh2", act_type="tanh")
> fc3 <- mx.symbol.FullyConnected(act2, name="fc3", num_hidden=3)
> act3 <- mx.symbol.Activation(fc3, name="tanh3", act_type="tanh")
> fc4 <- mx.symbol.FullyConnected(act3, name="fc4", num_hidden=2)
> softmax <- mx.symbol.SoftmaxOutput(fc4, name="softmax")
> devices <- mx.cpu()
> mx.set.seed(71)
> model <- mx.model.FeedForward.create(softmax, X=train.x, y=train.y, ctx=devices, num.round=100, array.batch.size=100, learning.rate=0.03, momentum=0.99,  eval.metric=mx.metric.accuracy, initializer=mx.init.uniform(0.5), array.layout = "rowmajor", epoch.end.callback=mx.callback.log.train.metric(100))
> preds <- predict(model, test, array.layout = "rowmajor")
> pred.label <- max.col(t(preds)) - 1
> table(pred.label)
> table(label.grid,pred.label)
          pred.label
label.grid     0     1
         0 35407   238
         1  1078 34566
> sum(diag(table(label.grid,pred.label)))/nrow(pgrid)
[1] 0.9815399

> plot(c(),type='n',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(pred.label,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='DNN (4-3-3 softmax)',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)

意外にもきちんと汎化して綺麗に分かれるんですね〜。真の決定境界に対するaccuracyも0.982ぐらいと、素晴らしいパフォーマンスを見せています。オーバーキルになるかもと思っていたんですが、使ってみるものですね。


ところで余談なんですが、これは二値分類なので本来ならmx.symbol.LogisticRegressionOutputを使うべきなのですが*2、何故か回るものの全く学習が進まないんですよね。。。なのでmx.symbol.SoftmaxOutputでsoftmax関数として処理してあります*3


DNNはパラメータが多いので、それらを色々いじることで狙って過学習させることもできます。例えば、中間層のユニット数を無駄に何倍にも増やしてみるとこうなります。

> dataof <- mx.symbol.Variable("data")
> fc1of <- mx.symbol.FullyConnected(dataof, name="fc1of", num_hidden=30)
> act1of <- mx.symbol.Activation(fc1of, name="tanh1of", act_type="tanh")
> fc2of <- mx.symbol.FullyConnected(act1of, name="fc2of", num_hidden=20)
> act2of <- mx.symbol.Activation(fc2of, name="tanh2of", act_type="tanh")
> fc3of <- mx.symbol.FullyConnected(act2of, name="fc3of", num_hidden=20)
> act3of <- mx.symbol.Activation(fc3of, name="tanh3of", act_type="tanh")
> fc4of <- mx.symbol.FullyConnected(act3of, name="fc4of", num_hidden=2)
> softmaxof <- mx.symbol.SoftmaxOutput(fc4of, name="softmaxof")
> devices <- mx.cpu()
> mx.set.seed(71)
> model.of <- mx.model.FeedForward.create(softmaxof, X=train.x, y=train.y, ctx=devices, num.round=100, array.batch.size=100, learning.rate=0.03, momentum=0.99,  eval.metric=mx.metric.accuracy, initializer=mx.init.uniform(0.5), array.layout = "rowmajor", epoch.end.callback=mx.callback.log.train.metric(100))
> preds.of <- predict(model.of, test, array.layout = "rowmajor")
> pred.label.of <- max.col(t(preds.of)) - 1
> table(pred.label.of)
pred.label.of
    0     1 
35143 36146 
> table(label.grid,pred.label.of)
          pred.label.of
label.grid     0     1
         0 33926  1719
         1  1217 34427
> sum(diag(table(label.grid,pred.label.of)))/nrow(pgrid)
[1] 0.9588155

> plot(c(),type='n',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> rect(0,0,4,4,col='#dddddd')
> par(new=T)
> rect(-4,0,0,4,col='#ffdddd')
> par(new=T)
> rect(-4,-4,0,0,col='#dddddd')
> par(new=T)
> rect(0,-4,4,0,col='#ffdddd')
> par(new=T)
> plot(xorc[,-3],pch=19,cex=0.5,col=xorc$label,xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> par(new=T)
> contour(px,py,array(pred.label.of,dim=c(length(px),length(py))),drawlabels = F,levels=0.5,lwd=5,col='purple',xlim=c(-4,4),ylim=c(-4,4),xlab='',ylab='')
> legend('topleft',legend='Overfitted DNN',cex=1.5,col='purple',lwd=5,lty=1,ncol=1)

若干ですが、ちらほらノイズと思しきサンプルに決定境界が引っ張られているのが分かります。真の決定境界に対するaccuracyも0.959と、元のDNNよりも悪化しています。

追記

試しにMXnetでユニット数5の中間層1層NNを構築して同じことをやらせてみたら、こうなりました。モデル部は以下の通り。

> data <- mx.symbol.Variable("data")
> fc1 <- mx.symbol.FullyConnected(data, name="fc1", num_hidden=5)
> act1 <- mx.symbol.Activation(fc1, name="tanh1", act_type="tanh")
> fc2 <- mx.symbol.FullyConnected(act1, name="fc2", num_hidden=2)
> softmax <- mx.symbol.SoftmaxOutput(fc2, name="softmax")
> devices <- mx.cpu()
> mx.set.seed(71)
> model <- mx.model.FeedForward.create(softmax, X=train.x, y=train.y, ctx=devices, num.round=100, array.batch.size=100, learning.rate=0.03, momentum=0.99,  eval.metric=mx.metric.accuracy, initializer=mx.init.uniform(0.5), array.layout = "rowmajor", epoch.end.callback=mx.callback.log.train.metric(100))

これを見ても、DNNの方が汎化性能に優れることが見て取れます。

感想


先に予め断っておきますが、PRMLにも書かれているように「2次元など低次元での機械学習分類器の振る舞いがもっと高次元においても同様とは限らない」点にご注意ください。今回の結果も、高次元データでは必ずしも再現しない可能性があります。


その上で改めて実験結果を見てみると、やっぱりSVMの低バリアンス性って偉大だなぁと思いますね。。。ろくすっぽチューニングしなくても、LIBSVMが自動的に合わせる範囲で普通に真の決定境界にほぼ沿う結果を返してくるわけで、とにかく汎化性能を重視したい時はまずSVMという考え方はアリかもしれません。


ランダムフォレストとXgboostというアンサンブル樹木モデル同士の比較はなかなか面白かったんですが、どちらも高次元特徴量データでその特色がはっきり出てくる代物だと思うので、今回のような2次元データでは検証は難しかったのかなと。


その意味で言うと、どう見てもオーバーキルとしか思えないDeep Learning (DNN)が2次元XORデータに対しても綺麗にフィットしてきたのはなかなか興味深かったです。以前{h2o}でやった時はまだパラメータチューニングの仕方が分かっていなかったので、今回の方が信頼出来る結果だと思っています。


そんなわけで、今後も何か新しい機械学習分類器が出てきた時はこの10000サンプルの2次元XORデータセットで可視化してみることにします。そんな機会があれば、の話ですが。。。

*1:元々「糞コードで頑張る機械学習シリーズ」というカテゴリ名だったんですが、この語感もあんまりなので今回変更しました汗

*2:その場合は直前の全結合層のユニット数を1にする必要がある

*3:この場合は直前の全結合層のユニット数をクラス数、つまり2にする必要がある