Bayesian t-testを書いてみたい
IBM SPSS Statisticsにベイズ推論が実装されるらしい。
これ以外にも最近は身の回りでやけにBayesian t-testに関する話題を耳にすることが多いので、t-検定をベイズ化することのどこにニーズがあるのかを考えていたのだけれど、どうやらA/B testにおいて結果を途中で確認(Peeking)したり、サンプルサイズを変更したいという状況が結構多いそうだ。
古典的な、そして計画立てられたt-検定では分析の事前に必要なサンプルサイズを求めておく。通常は実験の開始以降にデータを追加したりなどしないのだが、ビジネスではA/Bテストの結果の如何によってデータを追加したり、あるいは途中経過を見て早めに施策の実施判断を行いたいと状況がよくあるため、そういったニーズにマッチしたのだろう(実際のところ伝統的なt-検定を使う場合であってもデータの追加や結果の覗き見は行われていると思うが)。
さて、Rにおけるベイジアンモデリングのためのパッケージとしては{MCMCpack}があり、より複雑なモデルを構築したいのであればStanを呼び出す方法があげられる。しかしt-検定ぐらい自分で書いてみようと思ったので、以下にスクリプトを示す。
データの準備
# パッケージの読み込み library(tidyverse) library(ggplot2)
サンプルデータとして以下のような正規分布に従う2標本を用意する。数値に意味はないので関心があれば標本サイズなどをそれぞれ変更してみると良いが、目的は標本の平均の差の分布について確認することである。
N_01 <- 100 mu_01 <- 100 sig_01 <- 10 N_02 <- 100 mu_02 <- 103 sig_02 <- 10 set.seed(123) Pop_01 <- rnorm(N_01, mu_01, sig_01) Pop_02 <- rnorm(N_02, mu_02, sig_02)
以下のようなサンプルが得られるだろう:
> Pop_01 %>% as_data_frame() %>% mutate("Group" = "01") %>% bind_rows(as_data_frame(Pop_02) %>% mutate("Group" = "02")) %>% ggplot(aes(value, fill = Group)) + geom_histogram(alpha = 0.5, position = "identity") + theme_bw()
このデータに対して古典的なt-検定を実施すると、平均値の差が0であるという帰無仮説はp = 0.44で棄却されない。ちなみにt.testはデフォルトでは等分散性を仮定しないWelchのt-検定となる。
> t.test(Pop_01, Pop_02) Welch Two Sample t-test data: Pop_01 and Pop_02 t = -0.7674, df = 197.35, p-value = 0.4438 alternative hypothesis: true difference in means is not equal to 0 95 percent confidence interval: -3.642862 1.601916 sample estimates: mean of x mean of y 100.9041 101.9245
関数の定義
このデータに対して何らかのパラメータ(ここでは平均と標準偏差)を与えた時の尤度を求める関数を以下のように定義する:
likelihood <- function(pars) { mu_01 <- pars[1] sig_01 <- pars[2] mu_02 <- pars[3] sig_02 <- pars[4] sum( dnorm(Pop_01, mu_01, sig_01, log = TRUE), dnorm(Pop_02, mu_02, sig_02, log = TRUE) ) }
データの尤度は0 ~ 1の数字の乗算となるため標本サイズが大きい場合には極めて小さな数値となり、桁あふれが発生してしまう。そのためdnormにおいてlog = TRUEとし、対数尤度の総和を求めることとする。
この関数を用いて最尤法で平均と分散を求めてみる。optimを使用するが、デフォルトは最小化なのでfnscaleを負の数として最大化を行う:
> pars <- c(100, 10, 100, 20) > optim(par = optim(par = pars, fn = likelihood, control=list(fnscale = -1))$par, + fn = likelihood, control=list(fnscale = -1)) $par [1] 100.904769 9.082697 101.923277 9.620816 $value [1] -730.8205 $counts function gradient 101 NA $convergence [1] 0 $message NULL
続いて事前分布を定義する。事前分布の設定は色々と工夫すべきポイントではあるが、簡単のため-10000 ~ 10000の一様分布とした。尤度はデータと同じく対数としてある。
prior <- function(pars) { mu_01 <- pars[1] sig_01 <- pars[2] mu_02 <- pars[3] sig_02 <- pars[4] sum( dunif(mu_01, -10000, 10000, log = TRUE), dunif(mu_02, -10000, 10000, log = TRUE), dunif(sig_01, -10000, 10000, log = TRUE), dunif(sig_02, -10000, 10000, log = TRUE) ) }
事後分布は事前分布と尤度の積であるが、対数を取ってあるためここでは和となる。
posterior <- function(pars) { likelihood(pars) + prior(pars) }
MCMCの実行
以上で準備ができたので以下のようにMCMCを実行する。なおサンプラーとしてGibbs Samplerを実装したつもりだが、Gibbs Samplingにおいてパラメータの採択確率があったかは記憶が不確かなので、この辺りは後日確認しておくとしてひとまず進めることとする。サンプルの数は30,000としたが、実際には割と早く収束しているようなのでこんなには必要ない。
N_iter <- 30000 pars <- c(50, 30, 20, 50) results <- matrix(0, nrow = N_iter, ncol = 8) results[1, ] <- c(pars, 0, 0, 0, 0) for (i in 2:N_iter) { for (j in 1:4) { candidate <- pars candidate[j] <- candidate[j] + rnorm(1, sd = 0.5) ratio <- exp(posterior(candidate) - posterior(pars)) t <- runif(1) if (t < ratio) { pars <- candidate } } results[i, ] <- c(pars, ratio, t, posterior(pars), posterior(candidate)) }
以下のような結果となる。
> par(mfrow = c(2, 2)) > plot(results[, 1]) > plot(results[, 2]) > plot(results[, 3]) > plot(results[, 4])
要約統計量も見ておこう。burn-inとして最初の15,000サンプルを切り捨て、自己回帰性を小さくするために10刻みでサンプルを取得する。
> Res_MCMC <- results[seq(15000, N_iter, 10), ] > summary(Res_MCMC[, c(1:4)]) V1 V2 V3 V4 Min. : 97.79 Min. : 7.198 Min. : 98.41 Min. : 7.997 1st Qu.:100.24 1st Qu.: 8.732 1st Qu.:101.34 1st Qu.: 9.266 Median :100.86 Median : 9.191 Median :101.98 Median : 9.753 Mean :100.86 Mean : 9.233 Mean :101.98 Mean : 9.774 3rd Qu.:101.45 3rd Qu.: 9.675 3rd Qu.:102.63 3rd Qu.:10.218 Max. :104.16 Max. :11.959 Max. :105.20 Max. :12.632
単純な推定なので精度よく推定できているようである。
平均値の差の分布
では最後に、この分析の目的であった2標本の平均の差の分布について確認してみよう。
par(mfrow = c(1, 1)) hist(Res_MCMC[, 1] - Res_MCMC[, 3])
これを見ると、平均値の差の分布が比較的中心に近い位置で0を跨いでおり、伝統的なt-検定で言う所の有意であるとは言えなさそうだ。実際に平均値の差の経験分布を確認すると、95%確信区間は-3.76 ~ 1.62である。
> quantile(Res_MCMC[, 1] - Res_MCMC[, 3], seq(0, 1, 0.025)) 0% 2.5% 5% 7.5% 10% 12.5% 15% 17.5% -5.62647895 -3.76661174 -3.35462735 -3.11314731 -2.89345678 -2.70960554 -2.56386382 -2.43985754 20% 22.5% 25% 27.5% 30% 32.5% 35% 37.5% -2.30487729 -2.18744684 -2.07270632 -1.98450427 -1.89198288 -1.77273198 -1.67356108 -1.57205147 40% 42.5% 45% 47.5% 50% 52.5% 55% 57.5% -1.47598439 -1.40161516 -1.30787797 -1.21558957 -1.12424331 -1.03598088 -0.95253546 -0.85994024 60% 62.5% 65% 67.5% 70% 72.5% 75% 77.5% -0.78004294 -0.68704363 -0.59508922 -0.49850858 -0.42349299 -0.30716666 -0.19718374 -0.10822330 80% 82.5% 85% 87.5% 90% 92.5% 95% 97.5% 0.01189818 0.14609443 0.30742495 0.45296435 0.69891710 0.92152549 1.18895282 1.62042273 100% 3.58972078
改めてt-検定の結果を見てみると、95%信頼区間として同様の結果が得られていることがわかるだろう。
> t.test(Pop_01, Pop_02) Welch Two Sample t-test data: Pop_01 and Pop_02 t = -0.7674, df = 197.35, p-value = 0.4438 alternative hypothesis: true difference in means is not equal to 0 95 percent confidence interval: -3.642862 1.601916 sample estimates: mean of x mean of y 100.9041 101.9245
おまけ
以上でt-検定のベイズ化は終わりだが、ベイズ化した分析のメリットとして以下のような疑問に答えることが可能となる:1つ目の標本の平均が2つ目の標本の平均よりも大きい確率はどの程度だろうか?
> mean(Res_MCMC[, 3] - Res_MCMC[, 1] < 0) [1] 0.2031979
上記のような質問は、古典的な仮説検定(頻度論的アプローチ)では答えることができない。仮説検定は常に「帰無仮説が棄却されるか否か」(例えば平均値と平均値に差が0であるか否か)であり、「何%の確率で棄却される」とは表現できないのだ。しかしこれをベイズ化することでより直感的な表現が可能となる。これはビジネスへの応用において確かにメリットになると感じた。
Random Forestの結果をSQLに落としたい
Random Forestは数ある機械学習アルゴリズムの中でも、高い精度を得やすく分散処理が容易であることから頻繁に用いられるものの一つであると思う。
Rでは{randomForest}パッケージが有名だが、最近では{ranger}や{Rborist}といった新しいパッケージが出ていたり、{edarf}のようなRandom Forestによるデータの解析や解釈を主眼にしたものが出ていたりして動きとして面白い。
そんなRandom Forestであるが、せっかくRで良いモデルを作ることができたとしても実際には使えないことがある。これは、例えばSQLに落とすことが容易ではないためシステムに組み込めないからだ。
ビジネスにおいてデータをRで分析する場面は数多くあるが、Rの結果を(文字通り)そのままシステムに埋め込むことはほとんどないと言って良いと思う。Rを使って得られたモデルは、SQLなどの他の環境に合わせて再構築した上で利用することが多いだろう。そこで今回はRの{randomForest}パッケージで作成したモデルをSQLに落とすための方法を紹介したい。
なおこの記事は基本的に以下の記事を参考に作成したが、ひとまずRの{randomForest}で作成したモデルからSQLを得るための流れを示すことが目的であったため大幅に簡略化してある。またSQLのインデントも私の好みに合わせたので、元記事も参照することをお薦めする。
https://gist.github.com/shanebutler/96f0e78a02c84cdcf558
処理の流れ
Random Forestというアルゴリズムは、大雑把に言えば簡易な決定木を大量に作成し、その結果の多数決によって最終的な判断を行うというものである。よってSQLに落とす場合も手順としては以下のようになる:
ただし実際には1と2は同時に行われるだろう。
1. 決定木の結果をSQLとして書き下す
それでは実装に移ろう。まずはSQLに書き下すところから。サンプルとしてirisを用い、簡単のためrandomForestの木の数は2としたモデルを作成する。
library(randomForest) set.seed(123) res <- randomForest(Species ~ ., data = iris, ntree = 2)
この処理で得られた結果はresオブジェクトに保存されるが、特にSQLを書くために必要となる決定木の分岐はgetTreeで取り出すことができる。取り出したい木が何番目であるかを指定し、ラベルの表示をTRUEにすることで:
> getTree(res, k = 1, labelVar = TRUE) left daughter right daughter split var split point status prediction 1 2 3 Petal.Width 0.80 1 <NA> 2 0 0 <NA> 0.00 -1 setosa 3 4 5 Petal.Width 1.65 1 <NA> 4 6 7 Petal.Width 1.35 1 <NA> 5 8 9 Sepal.Length 6.05 1 <NA> 6 0 0 <NA> 0.00 -1 versicolor 7 10 11 Petal.Length 4.95 1 <NA> 8 12 13 Petal.Length 4.85 1 <NA> 9 0 0 <NA> 0.00 -1 virginica 10 0 0 <NA> 0.00 -1 versicolor 11 0 0 <NA> 0.00 -1 virginica 12 14 15 Sepal.Length 5.40 1 <NA> 13 0 0 <NA> 0.00 -1 virginica 14 0 0 <NA> 0.00 -1 virginica 15 0 0 <NA> 0.00 -1 versicolor
このようなテーブルが得られる。決定木は親となるノードから次々に分岐していく形で木が形成されるが、このテーブルはそのためのルールを示したものとなっており、「left daughter」が該当する場合に次に参照する行を、「right daughter」が該当しなかった場合に参照する行を示す。
例えばこの場合、1行目を読むことで「Petal.Widthが0.8未満であるか」を基準に分岐が発生することがわかり、また該当する場合(left daughter)には2行目を参照することでsetosaになることを示している。
このようなテーブルを各決定木について得ることができるため、SQLに落とすための流れとしては:
- テーブルをSQLにするための処理を書く
- 決定木の数だけループを回す
ことが考えられるだろう。そこでこのテーブルをSQLに落とすための関数を以下のように定義する:
Make_SQL <- function(Rule_Table, cnt, ind = 1) { Rule <- Rule_Table[cnt, ] Indent <- paste(rep(" ", ind), collapse = "") var <- as.character(Rule[, "split var"]) val <- Rule[, "split point"] if(Rule[, "status"] != -1) { cat(paste0("\n", Indent, "CASE\n", Indent, " WHEN ", var, " <= ", val, " THEN")) Make_SQL(Rule_Table, Rule[, "left daughter"], ind = (ind + 2)) cat(paste0("\n", Indent, " ELSE")) Make_SQL(Rule_Table, Rule[, "right daughter"], ind = (ind + 2)) cat(paste0("\n", Indent, "END")) } else { cat(paste0(" '", Rule[, "prediction"], "'")) } }
ここでRule_Tableは先ほどのテーブルを示している。また関数の序盤で定義しているIndentはSQLを書くときのインデントを私の好みに合わせるために修正してある。
全体的に、SQLはCASE文で形成されることがわかる。これは決定木がIf THEN Elseで表現可能なルールによって作成されることからも自明だろう。なおRule_Tableのstatusには、当該ノードが末端(ターミナルノード)であるかが示されているため、ここを参照することで予測値が決まるか(それ以上の分岐がないか)がわかる。もしターミナルノードでないなら分岐した先で同様の処理を行うため、再帰的な呼び出しが必要であることがわかる。分岐した場合、該当するものについてはleft daughterの行を参照し、そうでないものについてはright daughterを参照するようになっている。
これを実行すると、例えば以下のようなSQL文が得られる:
> Make_SQL(Rule_Table, 1, 1) CASE WHEN Petal.Width <= 0.75 THEN 'setosa' ELSE CASE WHEN Petal.Width <= 1.75 THEN CASE WHEN Sepal.Width <= 2.25 THEN CASE WHEN Petal.Length <= 4.75 THEN 'versicolor' ELSE 'virginica' END ELSE CASE WHEN Petal.Width <= 1.35 THEN 'versicolor' ELSE CASE WHEN Sepal.Width <= 2.65 THEN CASE WHEN Petal.Width <= 1.45 THEN 'virginica' ELSE 'versicolor' END ELSE 'versicolor' END END END ELSE CASE WHEN Sepal.Length <= 5.95 THEN CASE WHEN Sepal.Width <= 3 THEN 'virginica' ELSE 'versicolor' END ELSE 'virginica' END END END
これをRandom Forestのモデル作成に用いた決定木の数だけ作成すれば良いということになる。なお決定木の数を大きなものとした場合にSQL文が大きくなってしまうため、実行の際には注意が必要である。
このようにして得られたSQLはこのままだと表示して終わりであるため、何らかのテーブルに格納しておく必要がある。そこでINSERT文を前に置いておくことにする。すなわち:
cat(paste0("INSERT INTO tbl_rf \nSELECT\n id, ")) Make_SQL(Rule_Table, 1, 1)
このような書き方になる。ここでidは予測する場合の粒度を示すことになる。irisの場合は1行が1データ(個体)になるため行ごとに連番を振れば良いが、ここでは省略する。
これで一つの決定木についてSQLによる結果をテーブルに格納することができるようになったため、上記のスクリプトをループで回すと以下のようになるだろう:
for (i in 1:(res$ntree)) { Rule_Table <- getTree(res, k = i, labelVar = TRUE) cat(paste0("INSERT INTO tbl_rf \nSELECT\n id, ")) Make_SQL(Rule_Table, 1, 1) cat(paste0(" as tree", i, "\nFROM\n", " input_data", ";\n\n")) }
res$ntreeにはモデルの作成に用いた決定木の数が格納されているため、その分だけループを回すことになる。またFROM句では決定木を当てはめるためのデータ(この例ではiris)を指定することになる。
1つのテーブルに各SQLの結果を集約する
上記の処理によりtbl_rfには各決定木の結果が格納されているだろう。これを用いて最終的な判断を行うには、各決定木の結果を集約する必要がある。以下のようになるだろう:
> cat(paste0("INSERT INTO rf_predictions\n", + "SELECT\n a.id,\n a.pred\n", + "FROM\n (\n SELECT id as id, pred, COUNT(*) as cnt \n", + " FROM tbl_rf\n GROUP BY id, pred\n ) a\n", + " INNER JOIN\n (\n ", + "SELECT id, MAX(cnt) as cnt\n", + " FROM\n", + " (\n", + " SELECT id as id, pred, COUNT(*) as cnt\n", + " FROM tbl_rf\n", + " GROUP BY id, pred\n", + " )\n", + " GROUP BY id\n ) b\n", + " ON a.id = b.id AND a.cnt = b.cnt;\n\n")) INSERT INTO rf_predictions SELECT a.id, a.pred FROM ( SELECT id as id, pred, COUNT(*) as cnt FROM tbl_rf GROUP BY id, pred ) a INNER JOIN ( SELECT id, MAX(cnt) as cnt FROM ( SELECT id as id, pred, COUNT(*) as cnt FROM tbl_rf GROUP BY id, pred ) GROUP BY id ) b ON a.id = b.id AND a.cnt = b.cnt;
irisのSpeciesを用いる場合はclassificationとなるため、各決定木の判断を集約し、最も多かったクラスを最終的な予測とする。上記はそのためのクエリとなっていることがわかる。
最後に、これまでのスクリプトを一つにまとめたものを以下に示す:
library(randomForest) set.seed(123) res <- randomForest(Species ~ ., data = iris, ntree = 2) Make_SQL <- function(Rule_Table, cnt, ind = 1) { Rule <- Rule_Table[cnt, ] Indent <- paste(rep(" ", ind), collapse = "") var <- as.character(Rule[, "split var"]) val <- Rule[, "split point"] if(Rule[, "status"] != -1) { cat(paste0("\n", Indent, "CASE\n", Indent, " WHEN ", var, " <= ", val, " THEN")) Make_SQL(Rule_Table, Rule[, "left daughter"], ind = (ind + 2)) cat(paste0("\n", Indent, " ELSE")) Make_SQL(Rule_Table, Rule[, "right daughter"], ind = (ind + 2)) cat(paste0("\n", Indent, "END")) } else { cat(paste0(" '", Rule[, "prediction"], "'")) } } for (i in 1:(res$ntree)) { Rule_Table <- getTree(res, k = i, labelVar = TRUE) cat(paste0("INSERT INTO tbl_rf \nSELECT\n id, ")) Make_SQL(Rule_Table, 1, 1) cat(paste0(" as tree", i, "\nFROM\n", " input_data", ";\n\n")) } cat(paste0("INSERT INTO rf_predictions\n", "SELECT\n a.id,\n a.pred\n", "FROM\n (\n SELECT id as id, pred, COUNT(*) as cnt \n", " FROM tbl_rf\n GROUP BY id, pred\n ) a\n", " INNER JOIN\n (\n ", "SELECT id, MAX(cnt) as cnt\n", " FROM\n", " (\n", " SELECT id as id, pred, COUNT(*) as cnt\n", " FROM tbl_rf\n", " GROUP BY id, pred\n", " )\n", " GROUP BY id\n ) b\n", " ON a.id = b.id AND a.cnt = b.cnt;\n\n"))
最後に
Random ForestはDeep Learningが注目されるようになった現在においても強力なアルゴリズムの一つである。またDeep Forestのように多段構造とすることでDeep Learningを上回る性能を得る場合もある。分散処理に強く、人による解釈も容易であるためビジネスにおける実用性は相当に高いアルゴリズムであるため、実装への障害によって敬遠されるのは非常に勿体無いと思う。この記事がそれらの障害を乗り越えるための一助となれば幸いである。
現状ではDeep LearningよりもRandom Forestを使いこなせる方が実用においては有益だろう。曲線的な分類に弱いなどの欠点もあるが、まだまだ発展のポテンシャルが高いアルゴリズムだと思うので今後も注目していきたい。
ggvisによるRで可視化
面白いパッケージを発見したので紹介。可視化のための機能を提供してくれるもので、{ggvis}というもの。もしかすると{ggplot2}を超えるかもしれない。
使い方が非常にシンプルで、思想として{ggplot2}に似ているので慣れた人にはスイッチしやすいと思う。dplyrユーザーにはお馴染みの%>%演算子を使用可能というのもよい。
ひとまず試してみる。
## Library installation install.packages("ggvis") ## Load library library(ggvis)
ggvisの実行。
mtcars %>% ggvis(~mpg, ~wt, fill = ~factor(cyl)) %>% layer_points()
このように可視化に用いるデータを%>%で後続の処理に渡し、後から可視化に用いるグラフを指定する形式は{ggplot2}と同じ。
では{ggvis}の何が面白いかというと、Shinyのようなインタラクティブな操作が可能なグラフを以下のように簡単に作成できること。
以下をお試しあれ。
data.frame(x = rnorm(1000)) %>% ggvis(~x, fill := input_select(c("red", "blue", "green"))) %>% layer_histograms(width = input_slider(0.01, 3, 0.1, 0.01, label="bin_width"))
連続量のヒストグラムを作成するとき、ビンの幅を決めるのは以外に難しい。R標準のパッケージである{graphics}のhistや、{MASS}のtruehistは所定のアルゴリズム(ScottとかFDとか)に基づいて自動的に決定してくれるが、実際にデータを分析する立場からするとビンを様々に変えながら眺めたいところ。
そのような時にShinyのようにインタラクティブにビン幅を変えられると嬉しいのだが、Shinyは準備に手間がかかるのでなかなか手が出ない。それを{ggvis}では先ほどのように簡単なコマンドでインタラクティブなグラフを作成できる。
他にも
mtcars %>% ggvis(x = ~wt, y = ~mpg) %>% layer_points() %>% layer_smooths(span = input_slider(0, 1, 0.5, 0.1, label = "span"))
このような使い方ができる。
このパッケージはまだ開発の途上のようで、demoで見ようとしたいくつかの機能がエラーで見れなかったり、作成したグラフが保存できなかったりする(だからこのブログでもグラフの画像を貼っていない)。
今後の修正に大きく期待できる、面白いパッケージだと思う。
Anscombeの例
二変数間の関連性の強さを評価する上で最も良く使われる指標は、間違いなくPearsonの積率相関係数、いわゆる相関だと思う。Excelでも関数により算出でき、関連の強さを-1〜1で評価できるというお手軽さが受け入れられやすいのだと思う。
さて、この相関は直線的な関係性しか評価できないため、必ず散布図とセットで見なくてはならない。表題の「Anscombeの例」とは、統計量が同じであってもデータセットが示す内容は大きく異なることがあるということを明確に教えてくれる良い教材である。実際に見てみよう。
## Load libraries library(tidyverse) library(broom) library(forcats) library(ggplot2)
まずはRに標準で格納されているデータセットanscombeを使う。
> anscombe x1 x2 x3 x4 y1 y2 y3 y4 1 10 10 10 8 8.04 9.14 7.46 6.58 2 8 8 8 8 6.95 8.14 6.77 5.76 3 13 13 13 8 7.58 8.74 12.74 7.71 4 9 9 9 8 8.81 8.77 7.11 8.84 5 11 11 11 8 8.33 9.26 7.81 8.47 6 14 14 14 8 9.96 8.10 8.84 7.04 7 6 6 6 8 7.24 6.13 6.08 5.25 8 4 4 4 19 4.26 3.10 5.39 12.50 9 12 12 12 8 10.84 9.13 8.15 5.56 10 7 7 7 8 4.82 7.26 6.42 7.91 11 5 5 5 8 5.68 4.74 5.73 6.89
anscombeにはこのように、4つのデータセットそれぞれがxとyを持つ。
このままだと少し使いにくいので整形する。
## Anscombe dataAns <- select(anscombe, "x" = x1, "y" = y1) %>% bind_rows(select(anscombe, "x" = x2, "y" = y2)) %>% bind_rows(select(anscombe, "x" = x3, "y" = y3)) %>% bind_rows(select(anscombe, "x" = x4, "y" = y4)) %>% mutate("dataset" = c(rep("I", nrow(anscombe)), rep("II", nrow(anscombe)), rep("III", nrow(anscombe)), rep("IV", nrow(anscombe))))
出来上がったデータに対して平均と標準偏差を求める。
dataAns %>% group_by(dataset) %>% summarise_each(funs(mean, sd), vars = c(x, y)) # A tibble: 4 × 5 dataset vars1_mean vars2_mean vars1_sd vars2_sd <chr> <dbl> <dbl> <dbl> <dbl> 1 I 9 7.500909 3.316625 2.031568 2 II 9 7.500909 3.316625 2.031657 3 III 9 7.500000 3.316625 2.030424 4 IV 9 7.500909 3.316625 2.030579
平均と標準偏差が小数点以下2位まで揃っている。それぞれのデータセットについて相関を求めてみると:
dataAns %>% group_by(dataset) %>% summarise("COR" = cor(x, y)) # A tibble: 4 × 2 dataset COR <chr> <dbl> 1 I 0.8164205 2 II 0.8162365 3 III 0.8162867 4 IV 0.8165214
これもほぼ同じ値となる。平均、標準偏差、相関が等しいデータセットなのでxとyも同じような値と取っているのだろうと思うのだが、プロットしてみると:
ggplot(dataAns, aes(x = x, y = y, label = dataset)) + facet_wrap(~ dataset, nrow = 2) + geom_point() + geom_smooth(method = "lm", formula = y ~ x, se = FALSE)
このように曲線を描くものや外れ値に影響を受けているものがあることがわかる。
なお相関が同じなので当然と言えば当然だが、回帰係数も同じような値となる:
dataAns %>% split(.$dataset) %>% map(., ~ lm(y ~ x, data = .) %>% tidy()) %>% bind_rows() term estimate std.error statistic p.value 1 (Intercept) 3.0000909 1.1247468 2.667348 0.025734051 2 x 0.5000909 0.1179055 4.241455 0.002169629 3 (Intercept) 3.0009091 1.1253024 2.666758 0.025758941 4 x 0.5000000 0.1179637 4.238590 0.002178816 5 (Intercept) 3.0024545 1.1244812 2.670080 0.025619109 6 x 0.4997273 0.1178777 4.239372 0.002176305 7 (Intercept) 3.0017273 1.1239211 2.670763 0.025590425 8 x 0.4999091 0.1178189 4.243028 0.002164602
このanscombeと同様に、統計量が同程度で全く内容が異なるデータセットとしてdatasaurusというものがある。これを用いて同様に見てみよう。
## Datasaurus datasaurus <- read_table2("../Data/DatasaurusDozen.tsv", col_names = TRUE, col_types = "cnn") %>% mutate(dataset = as_factor(dataset))
> print(datasaurus) # A tibble: 1,846 × 3 dataset x y <fctr> <dbl> <dbl> 1 dino 55.3846 97.1795 2 dino 51.5385 96.0256 3 dino 46.1538 94.4872 4 dino 42.8205 91.4103 5 dino 40.7692 88.3333 6 dino 38.7179 84.8718 7 dino 35.6410 79.8718 8 dino 33.0769 77.5641 9 dino 28.9744 74.4872 10 dino 26.1538 71.4103 # ... with 1,836 more rows
このデータをプロットしてみるとどうか。
ggplot(datasaurus, aes(x = x, y = y)) + facet_wrap(~ dataset, nrow = 3) + geom_point()
なんと、全く違う!
これらの全てが本当に同様の統計量を持っているのだろうか?
datasaurus %>% group_by(dataset) %>% summarise_each(funs(mean, sd), vars = c(x, y)) # A tibble: 13 × 5 dataset vars1_mean vars2_mean vars1_sd vars2_sd <fctr> <dbl> <dbl> <dbl> <dbl> 1 dino 54.26327 47.83225 16.76514 26.93540 2 away 54.26610 47.83472 16.76982 26.93974 3 h_lines 54.26144 47.83025 16.76590 26.93988 4 v_lines 54.26993 47.83699 16.76996 26.93768 5 x_shape 54.26015 47.83972 16.76996 26.93000 6 star 54.26734 47.83955 16.76896 26.93027 7 high_lines 54.26881 47.83545 16.76670 26.94000 8 dots 54.26030 47.83983 16.76774 26.93019 9 circle 54.26732 47.83772 16.76001 26.93004 10 bullseye 54.26873 47.83082 16.76924 26.93573 11 slant_up 54.26588 47.83150 16.76885 26.93861 12 slant_down 54.26785 47.83590 16.76676 26.93610 13 wide_lines 54.26692 47.83160 16.77000 26.93790
anscombe同様に、平均と分散が小数点以下2位まで同じとなっている。
このように、データから得られる統計量が同一であっても内容が同じ傾向を示すとは限らない。そのため統計量を求める場合には必ずデータをプロットすることが重要である。平均や分散ならヒストグラムを、相関なら散布図を描くことでデータの分布について大まかに把握することができるだろう。
データの可視化というものは特に分析の後半に入ってくると忘れられがちであるが、データの特性を教えてくれる重要な手法である。分析を実施する時にはまず可視化を行うことを大切にしよう。
Ad-Stock効果を推定しつつ回帰を回したい③
Marketing Mix Modelingにおける広告の累積効果の推定について、以下の記事を書いた。
ushi-goroshi.hatenablog.com
ushi-goroshi.hatenablog.com
本日はこの続きで、同じような条件で作成したデータに対してstanを用いたベイズ推定を実施したので、その内容を紹介する。
なお先日の記事でoptimによりAd-Stock効果の回帰係数および減衰率を十分に精度よく推定できそうなことはわかっているが、
- 将来的なモデルの高度化
- 多様な推定方法の確立(手持ちの武器を増やす)
ことを目的としている。
シミュレーションデータの作成 〜 stanによるフィッティングの実行
まずは事前準備として、必要なライブラリを読み込む。当然ながらstanを使うので、事前にインストールを済ませた上で{rstan}を読み込む。
## load libraries library(dplyr) library(tidyr) library(tibble) ## load rstan library(rstan) rstan_options(auto_write = TRUE) options(mc.cores = parallel::detectCores())
続いてシミュレーションデータ作成用の関数を用意する。これは先日の記事で紹介したものと大きな変更はないが、拡張性を考慮して変数の順序を入れ替え、また説明変数の作成に用いていたrnormの代わりにrgammaを使用している。
set.seed(123) simulate_y <- function(pars) { n <- pars[1] # num of observation mu <- pars[2] # intercept var_e <- pars[3] # residual variance beta_01 <- pars[4] # regression coefficient of X1 to be esitmated lambda_01 <- pars[5] # decay rate of Ad-Stock effect of X1 beta_02 <- pars[6] # regression coefficient of X2 to be esitmated lambda_02 <- pars[7] # decay rate of Ad-Stock effect of X2 X_01_raw <- rgamma(n, 3) * ifelse(runif(n) > 0.7, 0, 1) X_01_fil <- stats::filter(X_01_raw, lambda_01, "recursive") X_02_raw <- rgamma(n, 2) * ifelse(runif(n) > 0.8, 0, 1) X_02_fil <- stats::filter(X_02_raw, lambda_02, "recursive") error <- rnorm(n, 0, sqrt(var_e)) y <- mu + beta_01 * X_01_fil + beta_02 * X_02_fil + error dat <- data.frame( "Y" = y, "X_01" = X_01_raw, "X_02" = X_02_raw, "X_01_Fil" = X_01_fil, "X_02_Fil" = X_02_fil, "Y_lag" = dplyr::lag(y, 1), "True_Error" = error) return(dat) } init_par <- array(c(100, 5, 2, 0.5, 0.6, 0.8, 0.5)) dat_Ana <- na.omit(simulate_y(init_par))
シミュレーションデータが作成できたので、stanを実行するための準備を実行する。stanに渡すためのパラメータを定義し、事前に準備したstanのスクリプトを用いてMCMCによるフィッティングを行う。
サンプルは10,000とし、そのうち最初の5,000サンプルをwarm-upとして破棄した。この辺りの設定は結果を見ながらの試行錯誤によるもので、明確な根拠に基づくものではない。traceプロットやヒストグラムを見る限り、もう少しサンプルを減らしても問題ないように思う。
dat_Ord <- list(N = nrow(dat_Ana), Y = dat_Ana$Y, X_01 = dat_Ana$X_01, X_02 = dat_Ana$X_02) ## fitting fit_01 <- stan(file = './AdStock_Simulation.stan', data = dat_Ord, iter = 10000, chains = 4, seed = 1234, warmup = 5000)
stanコードの記述
stan()で参照しているstanのコードは以下のように記述した。dataブロックおよびparameterブロックは大した工夫もないが、減衰率を示すlambda_X_01およびlambda_X_02はパラメータスペースを(0, 1)に制限している。
このコードで特筆すべきはtransformed parametersブロックで、ここでlambda_X_0xの値に基づきAd-Stock変数を作成している。Rであればstats::filterで簡単に作成できるのだが、今回の分析では減衰率も推定の対象であるためどうしてもstan内で記述する必要があった。ここで作成したX_01_filおよびX_02_filをmodelブロックで用いている。
data { int N; vector[N] Y; vector[N] X_01; vector[N] X_02; } parameters { real mu; real<lower=0> var_error; real beta_X_01; real<lower = 0, upper = 1> lambda_X_01; real beta_X_02; real<lower = 0, upper = 1> lambda_X_02; } transformed parameters { vector[N] X_01_fil; vector[N] X_02_fil; X_01_fil[1] = X_01[1]; X_02_fil[1] = X_02[1]; ## Create Ad-stock variable for(j in 2:N) { X_01_fil[j] = X_01[j] + X_01_fil[j-1] * lambda_X_01; X_02_fil[j] = X_02[j] + X_02_fil[j-1] * lambda_X_02; } } model { ## Sampling for(i in 1:(N)) { Y[i] ~ normal(mu + beta_X_01 * X_01_fil[i] + beta_X_02 * X_02_fil[i], var_error); } }
結果
stanを実行した結果は以下のように取得できる。
## Plot pars <- c("mu", "var_error", "beta_X_01", "lambda_X_01", "beta_X_02", "lambda_X_02") print(fit_01) stan_trace(fit_01, pars = pars) stan_hist(fit_01, pars = pars) stan_dens(fit_01, separate_chains = T, pars = pars) stan_ac(fit_01, separate_chains = T, pars = pars)
結果を見てみよう。まずは推定されたパラメータの事後分布から。
> print(fit_01) Inference for Stan model: AdStock_Simulation. 4 chains, each with iter=10000; warmup=5000; thin=1; post-warmup draws per chain=5000, total post-warmup draws=20000. mean se_mean sd 2.5% 25% 50% 75% 97.5% n_eff Rhat mu 4.70 0.01 0.63 3.41 4.29 4.72 5.13 5.91 13079 1 var_error 1.45 0.00 0.11 1.26 1.37 1.44 1.52 1.68 17554 1 beta_X_01 0.50 0.00 0.08 0.35 0.44 0.50 0.55 0.65 15821 1 lambda_X_01 0.63 0.00 0.08 0.44 0.58 0.63 0.69 0.77 12174 1 beta_X_02 0.71 0.00 0.10 0.51 0.64 0.70 0.77 0.91 13692 1 lambda_X_02 0.56 0.00 0.07 0.41 0.52 0.57 0.61 0.69 14090 1 X_01_fil[1] 4.74 0.00 0.00 4.74 4.74 4.74 4.74 4.74 2 1 X_01_fil[2] 3.51 0.00 0.40 2.63 3.27 3.55 3.79 4.18 12174 1 X_01_fil[3] 4.94 0.00 0.52 3.87 4.59 4.96 5.30 5.91 12453 1 X_01_fil[4] 9.09 0.01 0.73 7.65 8.60 9.09 9.58 10.49 12477 1 … 省略 …
X_01_filは本来推定すべきパラメータではないが、stanでは値が決まっていない変数(確率変数と見なされるもの)は全てパラメータとして扱われるため、特に分布に関心はないものの結果として含まれている。
では実際に関心のあるパラメータに着目すると、muが設定した値からやや低く推定されているものの、50%ベイズ信頼区間には真の値が含まれている。またX_01の回帰係数および減衰率も同様に設定した値に近いものを得られている。
一方で残差分散およびX_02の回帰係数・減衰率については、大きく離れてはいないものの50%信頼区間からは外れてしまっている。特に残差分散は何度か試行を繰り返したものの、そのいずれにおいても過少に推定されてしまった。この原因についてはよくわからない。
なおX_02については、回帰係数および減衰率を高めに設定することでより収束しやすい傾向があった。
プロットにより作成されるグラフは、それぞれ以下の通り。
> stan_trace(fit_01, pars = pars)
> stan_hist(fit_01, pars = pars)
> stan_dens(fit_01, separate_chains = T, pars = pars)
> stan_ac(fit_01, separate_chains = T, pars = pars)
traceプロットを見ると安定した形となっており、Rhatの数値と合わせて考えても収束していると判断して良さそうだ。分布の形をchainごとで比較しても一部で歪んでいる様子は見当たらない。
なおヒストグラムを確認すると基本的には正規分布に近い形となっているが、減衰率については左に裾を引く形の分布となっている。
まとめ
上記のシミュレーションの結果をまとめると、
- stanを用いることでAd-Stock効果をそれなりに精度よく推定できる
- 減衰率のような非線形の効果を素直にモデルに取り込むことができ、また分布の形もKoyckに限定されることなく柔軟なモデリングが可能
- しかしパルダ・モデルやoptimによる推定と比較すると精度に劣り、また処理時間およびコーディングに要する時間も比較的多くなる
と言えそうだ。分析準備に要するコストは習熟度が上がれば削減されるだろうが、一回の分析に要する処理時間が相対的に大きいため試行錯誤する場合には向いていないと思われる。
一方でベイズモデリングの大きなメリットである「モデルの柔軟性」は非常に魅力的に感じ、Marketing Mix Modelingの高度化を目指すに当たって積極的に使っていきたい技術である。
上記を踏まえてこれまでの分析結果をまとめると、
- Ad-Stockの分布としてKoyck型を仮定でき、残存の規模(減衰率)に関心はない場合 → パルダ・モデル
- 残存の規模や分布に関心があり、試行錯誤が必要 → optimによる推定
- より柔軟なモデリング(階層ベイズや状態空間モデルへの拡張)を実施したい → stanによるモデリング
といった使い分けが良さそうだ。なおstanによるモデリングは分析者のアイディアおよび対象領域に対する知見の深さにかなり依存すると思われるため、少なくともMMM構築の初期では全く適していないだろう。
逆に言えば、当該領域について十分な経験を持った分析者がいるのであれば、アイディアの限りを用いて色々なモデルを試すことが出来るため大きな価値を提供することが出来るようになるだろう。
最後に、この分析に限らずMCMCによるもの全般に言えることだが、やはりパラメータの分布という形で結果を見せることができるというのは大きな強みであると感じた。統計解析に馴染みのない相手であっても、「パラメータがバラつきを持ち、その範囲を分布として示す」というのは直感的にわかりやすいだろうと思う。頻度論的なアプローチであってもパラメータの(誤差)分布を標準誤差として示すことは出来るが、理解の容易さという点では比較にならないだろう。
唯一の懸念は、「パラメータが分布を持つ」という仮定に耐えうるか、ということだろうか。
Ad-Stock効果を推定しつつ回帰を回したい②
先日、Marketing Mix Modelingにおける広告の累積効果(Ad-Stock効果)の推定について以下のような記事を書いた。
その後も推定方法について調べていたところ、以下のような記事を発見した:
要するに一期前の目的変数そのもの(y_{t-1})を説明変数として含めることで、過去の累積効果、すなわちAd-Stock効果を説明することができるらしい。これを発表者の名前からパルダモデルと呼ぶとのこと。
数式を用いた説明は以下で発見できた:
http://www-cc.gakushuin.ac.jp/~20130021/ecmr/chapter6.pdf
この記事において、コイック型の分布ラグを仮定したモデルはARX(1)と等価であることが解説されている。
それでは、このパルダ・モデルと先日のoptimによる推定ではどちらがより精度よく推定できるのだろうか?
シミュレーションの実行:パルダ・モデル
パルダ・モデルの推定精度を検証するためにシミュレーションを実行した。
まずは先日の記事同様にシミュレーション用のサンプルデータを発生させるための関数を作成する。真のモデルはstats::filterによって生成された説明変数にbetaを乗じたもので、このbetaを元データから推定したい。
simulate_y <- function(pars) { n <- pars[1] # num of observation mu <- pars[2] # intercept beta_01 <- pars[3] # regression coefficient of X1 to be esitmated beta_02 <- pars[4] # regression coefficient of X2 to be esitmated lambda_01 <- pars[5] # decay rate of Ad-Stock effect of X1 lambda_02 <- pars[6] # decay rate of Ad-Stock effect of X2 var_e <- pars[7] # residual variance X_01_raw <- rnorm(n, 100, 2) * ifelse(runif(n) > 0.7, 0, 1) X_01_fil <- stats::filter(X_01_raw, lambda_01, "recursive") X_02_raw <- rnorm(n, 100, 2) * ifelse(runif(n) > 0.2, 0, 1) X_02_fil <- stats::filter(X_02_raw, lambda_02, "recursive") error <- rnorm(n, 0, sqrt(var_e)) y <- mu + beta_01 * X_01_fil + beta_02 * X_02_fil + error dat <- data.frame( "Y" = y, "X_01" = X_01_raw, "X_02" = X_02_raw, "Y_lag" = dplyr::lag(y, 1)) return(na.omit(dat)) }
seedを固定し、500回の反復シミュレーションを実行する。lmの中にY_lagが含められていることに注意。betaの真の値は0.5および0.3と設定した。また(今回は推定の対象ではないが)減衰率はそれぞれ0.9および0.2である。
## simulation set.seed(123) init_par <- array(c(100, 5, 0.5, 0.3, 0.9, 0.2, 5)) k <- 500 results_palda <- matrix(0, k, 4) for (i in 1:k) { dat <- simulate_y(init_par) res <- lm(Y ~ X_01 + X_02 + Y_lag, data = dat) results_palda[i, ] <- coef(res) }
> summary(results_palda) V1 V2 V3 V4 Min. :-15.4731 Min. :0.4322 Min. :0.2117 Min. :0.8300 1st Qu.: -1.8896 1st Qu.:0.4851 1st Qu.:0.2900 1st Qu.:0.8741 Median : 0.8618 Median :0.5000 Median :0.3049 Median :0.8820 Mean : 1.0207 Mean :0.5002 Mean :0.3037 Mean :0.8820 3rd Qu.: 4.0467 3rd Qu.:0.5146 3rd Qu.:0.3175 3rd Qu.:0.8907 Max. : 16.4760 Max. :0.5597 Max. :0.3624 Max. :0.9254
V2およびV3に注目すると、それぞれ0.43〜0.56、0.21〜0.36の範囲で分布しており、Medianおよび平均は両方とも一致している。どうやらパルダ・モデルは過去の累積による影響をうまく取り除き、広告の当期に対する影響を精度よく推定できるようだ。
なおV4は一期前の目的変数に対するyの回帰係数であり、先に示したpdfによるとこの値は広告の減衰率λに相当するはずである。しかし変数が2つあるためか一致していない。
シミュレーションの実行:optimによる推定
では先日と同様にoptimを使いながら減衰率を同時に推定する方法はどうか。まずは先日と同じく、optimの目的関数を作成する。
return_AIC <- function(param) { a <- param[1] b <- param[2] dat$X_01_fil <- stats::filter(dat$X_01, a, "recursive") dat$X_02_fil <- stats::filter(dat$X_02, b, "recursive") AIC(lm(Y ~ X_01_fil + X_02_fil, dat)) }
続いてシミュレーションを実行する。反復回数はパルダ・モデルと同じく500回で、betaの値も同じである。実行にやや時間がかかったので進捗を見られるように100回ごとにRoundを表示した。
set.seed(456) init_par <- array(c(100, 5, 0.5, 0.3, 0.9, 0.2, 5)) decay_par <- array(c(0.5, 0.5)) k <- 500 results_optim <- matrix(0, k, 5) for (i in 1:k) { dat <- simulate_y(init_par) res <- optim(par = optim(par = decay_par, fn = return_AIC)$par, fn = return_AIC)$par dat$X_01_fil <- stats::filter(dat$X_01, res[1], "recursive") dat$X_02_fil <- stats::filter(dat$X_02, res[2], "recursive") lm_out <- lm(Y ~ X_01_fil + X_02_fil, data = dat) results_optim[i, ] <- c(res, coef(lm_out)) if (i %% 50 == 0) cat("Round: ", i, "\n") }
> summary(results_optim) V1 V2 V3 V4 V5 Min. :0.8866 Min. :0.06162 Min. : 1.983 Min. :0.4608 Min. :0.2649 1st Qu.:0.8916 1st Qu.:0.17331 1st Qu.: 6.280 1st Qu.:0.4838 1st Qu.:0.2941 Median :0.8941 Median :0.19419 Median :35.109 Median :0.4923 Median :0.3001 Mean :0.8949 Mean :0.19289 Mean :27.462 Mean :0.4908 Mean :0.3002 3rd Qu.:0.8996 3rd Qu.:0.21262 3rd Qu.:38.388 3rd Qu.:0.4989 3rd Qu.:0.3059 Max. :0.9010 Max. :0.29186 Max. :46.506 Max. :0.5200 Max. :0.3286
V4およびV5を見ると、それぞれの範囲は0.46〜0.52および0.26〜0.33と、パルダ・モデルと比較して精度よく推定できている。加えてoptimを用いる方法では各変数の減衰率λの値も推定することができ、その精度も十分高い。
まとめ
上記のシミュレーションの結果をまとめると:
- 目的変数の一期前の数値を説明変数として用いる(パルダ・モデル)ことで、Ad-Stock効果をモデルに取り入れることができる
- 簡単なシミュレーションではパルダ・モデルの有効性が示され、モデルを非線形にすることなく精度の良い回帰係数を推定することができた
- ただしoptimによる推定の方が回帰係数の推定精度が高く、またパルダ・モデルでは各変数の減衰率λを同時に得ることができない
となった。
パルダ・モデルではモデルを非線形にする必要がないためlmにより簡単に推定でき、またその推定精度も高い。さらに過去の累積効果をまとめて説明することができるため、Marketing Mix Modelの構築において最適なLagを気にする必要がなくなることは大きなメリットである。そのため一般的なMMMの構築においてはパルダ・モデルの方が望ましいのではないか。
一方で、パルダ・モデルは各変数のλを得ることができないため詳細な考察が必要な場合には向いておらず、また分布ラグの形もコイック型に限られてしまう。例えば広告間の効率を比較するためにλを示す必要があったり、使用期限が定められたクーポンのように幾何級数的な減衰が適当でない(期限ギリギリになると効果が上昇する)ことが想定されるような場合にはoptimによる推定の方が適当であると考えられる。
したがって分析者が必要とする数値および対象領域における仮定を踏まえて推定方法を選択する必要があるだろう。
Keras for R
RstudioがR上でKerasによるディープラーニングのモデルを構築するためのライブラリ{keras}を公開した。
以前から{tensorflow}を使えばtensorflow::import(module = "keras")でKerasを導入することができたようだが、{keras}を先にインストールすることでpythonさえ入っていればtensorflowのインストールもできるらしい。随分と楽にtensorflowを動かせることができそうなので早速試してみた。なお類似のプロジェクトがRstudio以外でも行われているようで、以下でもKerasをRから動かすためのライブラリを提供している。
実行環境はAWS上のWindowsマシン。Rのバージョンは3.3.2だけれど、Microsoft R Clientがインストールされている。
実行したスクリプトは上で紹介したページの内容なのだけれど、サンプルデータであるx_trainやx_testなどは自前で用意する必要があったのでirisを使って適当に作成した。なおこちらのページはKerasの公式ドキュメントをR用に書き換えただけの様子。
{Keras}のインストール
早速{keras}のインストールとtensorflowのインストールを実行してみる。なお私の環境では{keras}のインストールの際に{reticulate}が一緒に入らず、{keras}のインストールに失敗したため{reticulate}だけ先にインストールしている。
## library install & load devtools::install_github("rstudio/reticulate") devtools::install_github("rstudio/keras") library(keras) library(dplyr) # for data handling ## install tensorflow install_tensorflow() Error: Installing TensorFlow requires a 64-bit version of Python 3.5 Please install 64-bit Python 3.5 to continue, supported versions include: - Anaconda Python (Recommended): https://www.continuum.io/downloads#windows - Python Software Foundation : https://www.python.org/downloads/release/python-353/ Note that if you install from Python Software Foundation you must install exactly Python 3.5 (as opposed to 3.6 or higher).
{keras}のインストールがうまくいけばinstall_tensorflow()という関数一つでtensorflowをインストールすることができる。とはいえpythonのインストールまではやってくれないようで私の環境ではエラーが出てしまった。指示に従い、Anaconda経由でpythonをインストールする。なお現時点でのpythonのバージョンは3.6だった。
Anacondaのインストールが済んだので再度tensorflowのインストールを試してみる。
## install tensorflow install_tensorflow()
今度はうまくいった。
サンプルデータの作成
サンプルスクリプトでは分析用のデータが提供されていなかったため、irisを使ってサンプルデータを作成する。特別なことはしておらず、sampleによって乱数を振った上で学習用とテスト用にデータを分割した。なおデータはmatrixにする必要がある。
set.seed(123) learn_id <- sample(1:nrow(iris), 100, replace = FALSE) train_data <- iris %>% filter(row.names(.) %in% learn_id) test_data <- iris %>% filter(!row.names(.) %in% learn_id) ## training data x_train <- train_data %>% select(-Species) %>% as.matrix() y_train <- as.matrix(model.matrix(~ train_data$Species - 1)) ## test data x_test <- test_data %>% select(-Species) %>% as.matrix() y_test <- as.matrix(model.matrix(~ test_data$Species - 1))
Kerasによるmodelオブジェクトの生成
サンプルデータが出来上がったので、{keras}によるフィッティングを行う。まずはmodelオブジェクトの生成から。
## generate model object model <- keras_model_sequential() > Model Model _______________________________________________________________________________ Layer (type) Output Shape Param # =============================================================================== Total params: 0 Trainable params: 0 Non-trainable params: 0 _______________________________________________________________________________
入力層 → 隠れ層 → 出力層という簡単なニューラルネットワークを構築する。layer_denseで簡単にレイヤーを積み上げることができるようで、入力層のみinput_shapeを指定する必要がある。これはpython上でKerasを実行する際のmodel.addに相当するものと思われる。
model %>% layer_dense(units = 10, input_shape = 4) %>% # Output & Input dimension layer_activation(activation = 'relu') %>% layer_dense(units = 3) %>% layer_activation(activation = 'softmax') > model Model _______________________________________________________________________________ Layer (type) Output Shape Param # =============================================================================== dense_3 (Dense) (None, 10) 50 _______________________________________________________________________________ activation_3 (Activation) (None, 10) 0 _______________________________________________________________________________ dense_4 (Dense) (None, 3) 33 _______________________________________________________________________________ activation_4 (Activation) (None, 3) 0 =============================================================================== Total params: 83.0 Trainable params: 83.0 Non-trainable params: 0.0 _______________________________________________________________________________
パラメータの数は層ごとに(入力データの次元数+1) * 出力データの次元で決まる。+1は切片。
ここで出来上がったmodelオブジェクトの属性や構造などを見てみようとstrを実行してみたのだが、print(model)と結果は変わらなかった。やはりR的なオブジェクトではない様子。
ニューラルネットワークの構造が決まれば、最適化の際の条件を指定する。
## model parameter model %>% compile( loss = 'categorical_crossentropy', optimizer = optimizer_sgd(lr = 0.02), metrics = c('accuracy'))
フィッティング
以上で準備が整ったため、フィッティングを行う。
## fitting model %>% fit(x_train, y_train, epochs = 1000, batch_size = 32) ## 省略 # Epoch 990/1000 # 100/100 [==============================] - 0s - loss: 0.0432 - acc: 0.9800 # Epoch 991/1000 # 100/100 [==============================] - 0s - loss: 0.0442 - acc: 0.9800 # Epoch 992/1000 # 100/100 [==============================] - 0s - loss: 0.0455 - acc: 0.9800 # Epoch 993/1000 # 100/100 [==============================] - 0s - loss: 0.0508 - acc: 0.9800 # Epoch 994/1000 # 100/100 [==============================] - 0s - loss: 0.0602 - acc: 0.9800 # Epoch 995/1000 # 100/100 [==============================] - 0s - loss: 0.0437 - acc: 0.9800 # Epoch 996/1000 # 100/100 [==============================] - 0s - loss: 0.0433 - acc: 0.9800 # Epoch 997/1000 # 100/100 [==============================] - 0s - loss: 0.0432 - acc: 0.9800 # Epoch 998/1000 # 100/100 [==============================] - 0s - loss: 0.1129 - acc: 0.9600 # Epoch 999/1000 # 100/100 [==============================] - 0s - loss: 0.0529 - acc: 0.9700 # Epoch 1000/1000 # 100/100 [==============================] - 0s - loss: 0.0647 - acc: 0.9600
サンプルデータがものの100行程度しかないため一瞬で終わる。Accuracyが0.96とまずまず。続いてこちらのモデルを用いて、テストデータに対する精度評価を実行する。
## test loss_and_metrics <- model %>% evaluate(x_test, y_test, batch_size = 128) > loss_and_metrics [[1]] [1] 0.1853937 [[2]] [1] 0.96
テストデータに対しても精度が0.96と高く、過学習を起こしている様子はない。
テストデータに対するラベルの予測は以下のように実行する。
## predict classes <- model %>% predict(x_test, batch_size = 128) > classes [,1] [,2] [,3] [1,] 9.983064e-01 1.693639e-03 6.461022e-20 [2,] 9.971897e-01 2.810343e-03 7.951551e-19 [3,] 9.983315e-01 1.668578e-03 3.816257e-20 [4,] 9.984826e-01 1.517428e-03 4.521292e-20 [5,] 9.994913e-01 5.087039e-04 3.988174e-23 ## 省略
サンプルスクリプトは以上である。
注意点として、パイプ演算子(%>%)が使えるからといってdplyrが読み込まれている訳ではないので、データ加工を行うのであれば別途読み込む必要がある。
Rでディープラーニングを簡単に実行できる環境が整ってきているので、これを機会にRユーザーが増えると良いな。