意中の相手への告白がうまくいくかどうか過去の事例を解析し成否を占う。with Scala
告白したい!でもうまくいくか不安…だから掲示板にでも相談しよう…こんなケースってよくありがちだけどそれらのデータを蓄積して解析してるシステムってあんまないなーって思う。だからちょっと作ってみよう!と思い立ってのこと。とは言っても迷惑メールフィルターとほぼ一緒の作りだし、使っているのはベイズの基本的な知識とScalaだけ。あとは形態素解析(文章を意味のある最小限の単語に分割する)のライブラリとしてkuromojiにはお世話になってる。具体的にどうやって解析して行くかというと、相談内容の単語の出現頻度をベイズ的に処理して過去のデータからいかにその投稿内容やコメントが成功もしくは失敗に近いかを計る。成功か失敗、より近い方を予測として投稿者に教えてあげる。
1. 投稿者が相談内容を投稿;「告白うまくいくでしょうか?」
2. 相談内容の文字列を意味の通る最小限の単語のリストに分割。
3. 過去のデータからそれぞれの単語の出現頻度を求める
4. ベイズの定理を用いて、その投稿内容がどれほど成功もしくは失敗に近いか求める
5. より確率の高い方を予測として投稿者に知らせる;"成功します"、"失敗します"
ではそもそもベイズの定理とは何か。
P(H|D)=( P(D|H)P(H) ) / P(D)
これだけ。上の関係式は
Dの時にHが起きる確率=
( Hの時にDが起きる確率*Hが起きる確率 ) / Dが起きる確率
を意味しとる。このP(D|H)を尤度、P(H)を事前確率、P(H|D)を事後確率という。なぜこの関係式が得られるかは基本的な確率公式を組み合わせたらすぐわかるからまあ割愛するとして、次にベイズの更新について。ベイズの更新とは同じ事象を繰り返し行う場合に前回の事後確率は今回の事前確率として扱うことができるということ。
例えば、2つの壷A,BがあるとしてAには白い玉が2個と赤い玉が1個、Bには白い玉が1個と赤い玉が2個入っている。A,Bいずれかから玉を2つ取り出してみた。この時点ではA,Bどちらから取り出したかわからないが取り出した玉は赤い玉1個と白い玉1個だった。さて、自分はどちらの壷から玉を取り出していたのかそれぞれの確率を求めたいとする。
※P(D)はすべてのパターン(ここでは壷がAであるパターンとBであるパターン)の尤度P(D|H)の和と同値である。つまり今回は
P(D)=(P(D|A)*P(A)+(P(D|B)*P(B)
壷から玉を一つ取り出してそれが赤い場合
P(A|赤)=
( (P(赤|A)*P(A) ) / ( (P(赤|A)*P(A)+(P(赤|B)*P(B) )
=( (1/3)*(1/2) ) / ( (1/3)*(1/2)+(2/3)*(1/2) )
=0.3333333
P(B|赤)=
( (P(赤|B)*P(B) ) / ( (P(赤|A)*P(A)+(P(赤|B)*P(B) )
=( (2/3)*(1/2) ) / ( (1/3)*(1/2)+(2/3)*(1/2) )
=0.6666667
つまり、赤い玉一つが出て来た段階で壷Aから取り出した確率は3分の1、Bは3分の2ということがわかった。同じことを2つ目の玉にも行う。ただし先ほどは事前確率P(A),P(B)両方とも2分の1としたが現時点でP(A)=0.3333333,P(B)=0.6666667ということが判明しているのでそれらの値を事前確率として活用する。これがベイズの更新。
壷から玉を一つ取り出してそれが赤い場合
P(A|白)=
( (P(白|A)*P(A) ) / ( (P(白|A)*P(A)+(P(白|B)*P(B) )
=( (2/3)*0.3333333 ) / ( (2/3)*0.3333333+(1/3)*0.6666667 )
=0.5
P(B|白)=
( (P(赤|B)*P(B) ) / ( (P(赤|A)*P(A)+(P(赤|B)*P(B) )
=( (1/3)*0.6666667 ) / ( (2/3)*0.3333333+(1/3)*0.6666667 )
=0.5
結果、半々の確率でした〜ということがわかる。ここまででだいたいベイズの基本はおっけ。ベイズの定理の良いところは人間の直感に近いプロセスを踏んでいるところだと思う。
基本的にはこれと同じこと単語レベルで行う。先ほどはDは玉の色、赤か白だったけどこれを今回は単語として捉える。
P(成功|単語が得られたよ)= ( P(単語が得られたよ|成功)P(成功) ) / P(単語が得られたよ)
P(失敗|単語が得られたよ)= ( P(単語が得られたよ|失敗)P(成功) ) / P(単語が得られたよ)
先ほどのベイズのやり方と今回のやり方の唯一違うところは分母を計算する必要がないということ。先程示した通り異なるパターンの間では分子が異なり分母は等しい。故にどちらの確率の方が高いかを比べる上で分母の計算自体は必要なくなる。分子だけを見ればいい。
P(A|D):P(B|D)=P(D|A)*P(A):P(D|B)*P(B)
2つを比べて確率の高い方が真ということになる。この2つの確率を比べるわけだけど全体の単語数同じになるわけでそうするとP(単語が得られたよ)が成功時と失敗時、同じ結果になる。だから今回はそこは省いてしまう。というお話、
P(成功|単語が得られたよ)= P(単語が得られたよ|成功)P(成功)
P(失敗|単語が得られたよ)= P(単語が得られたよ|失敗)P(成功)
まずはそのために形態素解析のライブラリであるkuromojiを活用して文章を単語に分割する必要がある
def mkTokenS(content:String):Stream[Token]
=Tokenizer.builder().build().tokenize(content).toStream.filter( (x:Token)=>x.getBaseForm() != null)
//StringからToken作成
以前形態素解析の記事を書いたので詳しいことは割愛。
先ほどのベイズのやり方と今回のやり方の唯一違うところは分母を計算する必要がないということ。先程示した通り異なるパターンの間でくなる。分子だけを見ればいい。
P(A|D):P(B|D)=P(D|A)*P(A):P(D|B)*P(B)
2つを比べて確率の高い方が真ということになる。
package textmining
import org.atilika.kuromoji._
import scala.collection.mutable.ListBuffer
object Tes extends App{
println(NaiveFilter.data)
println(NaiveFilter.Sdata)
println(NaiveFilter.Fdata)
println(NaiveFilter.stringSdata)
println(NaiveFilter.stringFdata)
NaiveFilter.tokenSdata.map(x=>x.getBaseForm)
NaiveFilter.tokenFdata.map(x=>x.getBaseForm)
println(NaiveFilter.occurS)
println(NaiveFilter.occurF)
println(NaiveFilter.dicF.map(x=>x.getBaseForm))
println(NaiveFilter.proS)
println(NaiveFilter.proF)
println(NaiveFilter.Saft)
println(NaiveFilter.Faft)
NaiveFilter.compare("いっくおー")
}
object NaiveFilter{
def addtest(post:String)={
}
var data:ListBuffer[(Boolean,String)]
=ListBuffer( (true,"成功すると思うよ"),(true,"絶対成功するって!!"),(false,"きついと思うなあ失敗するんじゃない?")
,(true,"君可愛いんだし言っちゃえよ"),(false,"失敗するに一票"))
//事前の値は学習データ。dataのみmutable
var Sdata=data.filter(x=>x._1==true) //成功data
var Fdata=data.filter(x=>x._1==false) //失敗data
val stringSdata=Sdata.map(x=>x._2).mkString //成功例の文字列データ
val stringFdata=Fdata.map(x=>x._2).mkString //失敗例の文字列データ
val tokenSdata=kuro.mkTokenS(stringSdata) //成功例のトークンリスト
val tokenFdata=kuro.mkTokenS(stringFdata) //失敗例のトークンリスト
val numTokenS:Double=tokenSdata.length
val numTokenF:Double=tokenFdata.length
val dicS=tokenSdata.toSet //成功例の重複しないトークンセット
val dicF=tokenFdata.toSet //失敗例の重複しないトークンセット
val occurS=dicS.toList.map(x=>tokenSdata.count(y=>y.getBaseForm==x.getBaseForm)) //dicSの全ての要素の出現回数を算出
val occurF=dicF.toList.map(x=>tokenFdata.count(y=>y.getBaseForm==x.getBaseForm)) //dicF
val proS=occurS.map(x=>x.toDouble/numTokenS) //成功の時の各トークン(重複しない)の出現回数/成功時の全トークン数==>成功時各尤度
val proF=occurF.map(x=>x/numTokenF) //失敗の時の各トークン(重複しない)の出現回数/失敗時の全トークン数==>尤度
val Saft=proS.reduce( (a,b)=>a*b) //成功時の事後確率=成功時の全てのトークン(重複しない)の尤度を掛け合わせたもの
val Faft=proF.reduce( (a,b)=>a*b) //失敗時
def compare(post:String)={
val preS=Saft //学習データに置ける事後確率が事前確率へと変わる。これは成功時の事前確率
val preF=Faft //こちらは失敗時
(true,post)+=:Sdata
(false,post)+=:Fdata
println(preS)
println(Saft)
}
}
さて、ここまででいくつか問題点がある。まず上のコードではvalを多用しているがvalは再利用性がない。
var a=3 //> a : Int = 3
def b=a //> b: => Int
val c=a //> c : Int = 3
a=5
a //> res0: Int = 5
b //> res1: Int = 5
c //> res2: Int = 3
このように変数の中の変数の値を更新したとしてもvalの場合その変数に反映されない。ここの例ではbもcもaの値を返すが、それらを定義した後にaの値を変更したとするとbの値はaと連動して変更されているがcの値は最初のaの値がそのままになっている。つまりvalを使うと似たようなコードを書かないと行けなくなる。というわけでvalを使っているところをdefに書き換えて変数じゃなくて関数にしてしまう。それに加えて、成功事例と失敗事例でほぼ同じコードを書いているのだからこれも部品として再利用出来る形にしたい。あと、事前確率の計算が入ってないの。というかこれじゃあ各尤度を求めるだけになっとる。んで下のが改善版。
package textmining
import org.atilika.kuromoji._
import scala.collection.mutable.ListBuffer
object Tes extends App{
NaiveFilter.examine("可愛い失敗")
}
object NaiveFilter{
var data:ListBuffer[(Boolean,String)]
=ListBuffer( (true,"可愛成功"),(true,"可愛成功"),(false,"失敗"),(false,"失敗"))
//元の値達は学習データ。data,Sdata,Fdataはmutable。falseの学習データには失敗しか入ってないけどtrueの方に可愛と成功が入ってる。これによって失敗という単語の重みは成功や可愛という単語よりも大きくなる。故に可愛いと失敗が1語づつのStringを調査するときは失敗に傾く。
var Sdata=data.filter(x=>x._1==true)
//成功data。mutable
var Fdata=data.filter(x=>x._1==false)
//失敗data。mutable
def preS=(Sdata.length-1).toDouble/data.length //examineでSdataには最初に調べる項目が追加されるがdata自体には関数の最後に追加される。それによって成功事例が多く見積もられるのを防ぐためにSdataに-1をしてる
def preF=1-preS
//成功、失敗それぞれの事前確率。examineの中で使う。ただし追加したpostが影響しないようにvalを使おうと思ったけどdataとSdataとの関係が崩れてしまうからやめた
def getString(list:ListBuffer[(Boolean,String)])
=list.map(x=>x._2).mkString
def stringSdata=getString(Sdata) //成功例の文字列データ
def stringFdata=getString(Fdata) //失敗例の文字列データ
def getToken(stdata:String)=kuro.mkTokenS(stdata)
def tokenSdata=getToken(stringSdata)
//成功例のトークンリスト
def tokenFdata=getToken(stringFdata)
//失敗例のトークンリスト
def getnum(tokens:Stream[Token])=tokens.length
def numTokenS:Double=getnum(tokenSdata)
def numTokenF:Double=getnum(tokenFdata)
def norep(tokens:Stream[Token])=tokens.toSet
def dicS=norep(tokenSdata)
//成功例の重複しないトークンセット
def dicF=norep(tokenFdata)
//失敗例の重複しないトークンセット
def occur(dataset:Set[Token],tokens:Stream[Token])
:List[Int]=dataset.toList.map(x=>tokens.count(y=>y.getBaseForm==x.getBaseForm))
def occurS=occur(dicS,tokenSdata)
//dicSの全ての要素の出現回数を算出
def occurF=occur(dicF,tokenFdata)
//dicF
def prob(oclist:List[Int],numToken:Double)
:List[Double]=oclist.map(x=>x.toDouble/numToken)
def proS=prob(occurS,numTokenS)
//成功の時の各トークン(重複しない)の出現回数/成功時の全トークン数==>成功時各尤度
def proF=prob(occurF,numTokenF)
//失敗の時の各トークン(重複しない)の出現回数/失敗時の全トークン数==>尤度
//ここまでが尤度の算出
def zipDicPro(dic:Set[Token],prolist:List[Double])
=dic.map(x=>x.getBaseForm).zip(prolist).toMap
def zipS=zipDicPro(dicS,proS)
def zipF=zipDicPro(dicF,proF)
//単語リストと各単語の尤度をzipしてMap化。
def aft(prolist:List[Double]):Double
=prolist.reduce( (a,b)=>a*b)
def examine(post:String)={
val Snow=(true,post)
Snow+=:Sdata
val Fnow=(false,post)
Fnow+=:Fdata
//両方にadd
println("成功事例 : "+Sdata)
println("失敗事例 : "+Fdata)
val dic=getToken(post).toSet.toList
//postの重複のなトークン集合
println("重複しない単語リスト : "+dic.map(x=>x.getBaseForm))
val allyudoS=dic.map(x=>zipS(x.getBaseForm))
println("成功時各尤度 : "+allyudoS)
val allyudoF=dic.map(x=>zipF(x.getBaseForm))
println("失敗時各尤度 : "+allyudoF)
println("成功事前確率 : "+preS)
println("失敗事前確率 : "+preF)
val Saft=aft(allyudoS)*preS
val Faft=aft(allyudoF)*preF
println("成功事後確率 : "+Saft+" <=> 失敗事後確率 : "+Faft)
if(Saft>Faft){
println("成功するでしょう")
Fdata-=Fnow
Snow+=:data
}
else{
println("失敗するでしょう")
Sdata-=Snow
Fnow+=:data
}
}
学習データには失敗しか入ってないけどtrueの方に可愛と成功が入ってる。これによって失敗という単語の重みは成功や可愛という単語よりも大きくなる。故に可愛いと失敗が1語づつのStringを調査するときは失敗に傾く。また、examineでSdataには最初に調べる項目が追加されるがdata自体には関数の最後に追加される。それによって成功事例が多く見積もられるのを防ぐためにSdata.lengthに-1をしてる。わかりやすいように短くてわかりやすい単語を学習データにもexamineのパラメータにも使ったけど別にふつーーの口語体の文章を入れたも全然おっけ。今回詰まったとこは尤度を求めるところ。examineのパラメータにあって学習データにない単語が出てきた時にその尤度は当然ながら成功事例では全て0、失敗事例では全て1になってしまった。しかもそれらを最終的に掛け合わせるのだから結果がすごいことに…それを防ぐために若干の誤差が生じるけどexamineのパラメータの値を成功、失敗両方のデータに加えて必ずすべての単語の出現頻度を1以上にしている。この実装は効率が著しく悪いのと、examineのパラメータが長くなると巨大なリストを作成しなくてはならず計算が一定を超えるとできなくなるのでファイルへの読み書きやリストを一定の長さで分割したりくっつけたりする必要がある。それと最も大きな問題は文字数が多くなるにつれて確率がやたらと小さな数字になってしまい桁数溢れを起こしてしまうということ。この問題に対してはすべての尤度と事前確率に対して対数Logを取ることで解決している。
a*b*c*.......*n
Log(a*b)=Log(a)+Log(b)
Log(a*b*c......*n)=Log(a)+Log(b)+.......+Log(n)
本来の尤度の積は一番上のやつ。だがこれは積なのでどんどん桁が小さくなってしまう。故に積の計算をLogの足し算へと変換する。大小関係は変わらないので問題なし。対数と取った場合の最終的なコードはGitHubにおいてる。
StatsBox/src/main/scala/classifier at master · kyu999/StatsBox · GitHub