読者です 読者をやめる 読者になる 読者になる

StatsBeginner: 初学者の統計学習ノート

初学者が統計学、機械学習、R、Pythonの勉強の過程をメモっていくノート。

『入門 機械学習』第3章:ベイズスパム分類器の作成

機械学習 『入門 機械学習』 R

けっこうやっかいな教科書

 オライリーの『入門 機械学習』という教科書を、半年ぐらい前に3分の1ぐらい読んで、内容をまとめたりはせずにほっといたのですが、このたび実際にRでコードを写経しながら走らせてみたりしたので、学習ノートとしてエントリを起こしておこうかと思います。


入門 機械学習

入門 機械学習

  • 作者: Drew Conway,John Myles White,萩原正人,奥野陽,水野貴明,木下哲也
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2012/12/22
  • メディア: 大型本
  • 購入: 2人 クリック: 41回
  • この商品を含むブログ (11件) を見る


 機械学習の入門的な教科書としては、理論の本として『わかパタ』『続・わかパタ』をそれぞれ途中まで読んだという中途半端な状態ですがw、コーディングの本としては、RでやるものとPythonでやるものを1冊ずつぐらいやろうかなと思っておりました。


 それでRのものとして本書を買ったわけなんですが、Amazonのレビューを見ると分かる通り、不親切な部分とか細かい間違いがあって*1困った教科書です……。たとえば演習で使われるサンプルデータがどこにあるのか最初わからなかったし、図表の参照番号が間違っていたり、「サンプルコードの◯行目に書いてある通りです」みたいな指示があってもその行数が全然違ったりと、散々です。
 今回のエントリは主に第3章の学習ノートなのですが、教科書どおりにコードを書いても教科書に書かれてあるような結果には絶対ならないという、読者を不安にさせる教科書です。


 ただし、教科書としてのカバー範囲はおもしろくて、ナイーブベイズ分類器でスパムフィルタを作ったり、教師なし学習で株価指標をつくったり、ページビューを予測したり、レコメンドのプログラムを書いたり、ソーシャルグラフを分析したりとテーマが幅広く素晴らしい。また、文章での解説自体は、すごく分かりやすいんです。どういう目的で使われる、どういうアルゴリズムなのかが、書学者でもイメージできるように書いてある。
 だからなおさら、校閲の手を抜きすぎで読者を疲れさせるところがあるのがもったいないですね。忍耐力と暇のない人にはあまりオススメできない教科書かもしれないです。間違いを見つけて正すことも含めて「スキル向上のための試練である」と捉えられるぐらいの前向きな性格が求められます。
 繰り返しですが、文章自体は分かりやすいので、コードを実際に動かしてみるとかせずに単に読みものとしてパラパラみていく分には、いいと思います。


 今後の計画としては、やる気が失せなければ本書を先の章へ進めていくこととし、失せた場合はPythonの『集合知プログラミング』に切り替えようかと思います(『集合知プログラミング』は私の場合、Pythonのコーディングに慣れていないので、1章進むのにすごい時間かかると思いますが)。

第1章と第2章

 第1章の冒頭では、Rで機械学習を学ぶことの利点が説かれていました。まぁ要するに、さすが統計処理に関しては簡単に扱えるからというような話でした。

Pythonのような規模の大きい科学計算のコミュニティで使われる他の言語においてlmと同じ機能を実現するには、データを表現するためのライブラリ(NumPy)、分析を行うライブラリ(SciPy)、結果を可視化するライブラリ(matplotlib)といったように複数のサードパーティライブラリを使わなければならない。本章で見ていくように、Rではこうした高性能な分析をたった1行で書くことができる。(p.3)


 第1章はRの使用方法を解説するために、UFOの目撃情報のデータを分析しています。といっても本書は原題が「Machine Lerning for Hackers」となっているように、もともとプログラマ向けの教科書なので、他の言語をすでに習得しているプログラマ向けにRの雰囲気を伝えるような内容で、R入門といった感じではないです。
 第2章ではRで基本的な統計量を算出したり、データを可視化したりするための操作が紹介されています。1章でも2章でもggplot2が使われていて、後続の章でもグラフを書くときは常にggplot2を使っていますね。

第3章「分類:スパムフィルタ」

 さて本題である第3章は、いわゆる「単純ベイズ分類器」(Naive Bayes Classifier)ってやつで、スパムメールを特定するフィルタのプログラムを書くという演習です。
 ただ、どのような意味でベイズ推定なのかみたいな説明はほとんどなく、ベイズの定理も出てこないので、単純ベイズ分類器というのがどういうものなのかはググるなりして理解した上で読まないと、計算内容(といってもめちゃめちゃ単純ですが)が意味不明になります。
 Wikipediaの解説は分かりやすく、「設計も仮定も非常に単純であるにも関わらず、単純ベイズ分類器は複雑な実世界の状況において、期待よりもずっとうまく働く」と書かれてありました。


 本章の演習の全体としては、「スパムであることが分かっているメールのデータ」と「スパムでないことが分かっているメールのデータ」が大量にあって、スパム・非スパムそれぞれのデータの一部を訓練データとして分類器に学習させ、残ったデータについてスパムかどうかを判定させて性能を確認するという流れになります。

 教科書の中では、演習に使うメールのデータはspamassasinというサイトに載っている公開コーパスのデータを使ってると書いてあるんですが、このサイトからデータを全部ダウンロードして使おうとしても、教科書で使っているデータがどれなのか分からなくなります。
 というのも、教科書では、

  • spam(スパム 500通)
  • spam_2(スパム2 1397通)
  • easy_ham(判別が比較的容易な非スパム 2500通)
  • easy_ham_2(判別が比較的容易な非スパム2 1400通)
  • hard_ham(判別が比較的困難な非スパム 249通)
  • hard_ham_2(判別が比較的困難な非スパム2 248通)


 という6つのメール群が使われるんですが、上記サイトには新しいデータと古いデータが混じっており、「spam」が2つあったりしてどっちを使えばいいかわからないわけです。
 なので、Githubに置かれている本書のサポートのデータを使えばいいです。


 johnmyleswhite/ML_for_Hackers · GitHub


 これをまとめてダウンロードすると、各章のデータとサンプルコードが全て手に入ります。
 メールの中身は↓こんな感じです。


f:id:midnightseminar:20150609122033p:plain


スパム分類器作成の流れ

 上記メール群はそれぞれ1つのフォルダになっていて、中には1メール1ファイルで拡張子なしのテキストファイルが通数分入っています。
 本章で書くプログラムの処理の流れを、以下のとおりまとめておきます。

  1. spamのメールとeasy_hamのメールを訓練データとする。
  2. まず1個1個のメールのファイルにはheaderとbodyが含まれてるので、body(本文)だけを取り出すことを考える。
  3. 具体的には、headerとbodyの間には必ず空行があって、しかもそれはファイル中で最初の空行であるというルールに着目して、「最初の空行の1行下以降の全てのテキスト」を取り出すという処理を行う。
  4. 1つのフォルダ(まずはspamフォルダ)中のすべてのメールの本文を取り出して、1本文1要素としてベクトルに格納する。
  5. 次に、テキストマイニング用の{tm}パッケージを使って、TDM(Term-Document Matrix)という行列を作る。行が単語(term)で列が各メール(document)になっており、各メールにその単語が登場した回数が値として入っているもの
  6. ただし単語は、2回以上登場したものだけを集計している。spamフォルダなので、この単語群が「スパム語」(スパムメールに含まれる単語)の候補となる。
  7. 単語ごとに、登場回数を合計する(つまりTDMを横に足す)。これをfrequencyとする。単純に、スパムメールに多用されている語が何なのかが分かる。
  8. 各スパム語について、それが1つでも登場したメールの数を、メールの総数で割ったものを、occurrenceとする。これは全スパムメール中のどれぐらいのスパムメールにそのスパム語が登場しているかを表し、そのスパム語の「出現確率」として扱われる。
  9. あるスパム語の登場回数を、スパム語全体の合計登場回数で割ったものを、densityとする。
  10. 単語ごとに、frequency、occurrence、densityをまとめて表にしておく。(訓練データ表とでも呼んでおく。)
  11. spamフォルダの次はeasy_ham(判別が比較的容易な非スパムメール)フォルダで同じことをやる。
  12. spamとeasy_hamの訓練データのメール数は揃える。(あとで、スパムである事前確率を0.5とした分析をするので。)
  13. 分類器を作成する。分類器は引数として、判別対象のメール群(フォルダ)の場所、訓練データ表、スパムである事前確率、そして未知語(訓練データ表にない単語)が登場したときにその単語に割り当てる共通の出現確率を設定するようにする。
  14. この分類器の処理としてはまず、判別対象メール群(スパムなのか非スパムなのかを調べたいメール群)のTDMを作成し、それを訓練データ表と照合して、「両方に登場する単語」を特定する。つまり、判別対象メール群に出てくるスパム語を特定する。
  15. 次にメール1通ごとに、事前確率(スパムである確率の事前想定値)と、そのメールに出てくる単語の出現確率をすべてかけあわせる。スパム語については訓練データ表からその出現確率(occurrence)を取得し、未知語(訓練データ表に出てこない単語)については出現確率を0.0001%と仮定する。
  16. こうやって計算された数字を、「そのメールがスパムである確率」とする。
  17. spamの訓練データ表でこの処理をやったら、次はeasy_hamの訓練データ表で同じことをやる。つまり、非スパムである確率を、各メールについて計算する。
  18. 判別対象メール群のそれぞれのメールについて、スパムである確率と非スパムである確率を比べて前者が大きければ、スパムと判定する。


 以上がスパム判定処理の概要で、それ以外は、見やすい表にするとか、グラフを書くといった処理が出てくるだけです。


 上記の内容の中では、「単語の出現確率」(occurrence)のロジックに注意ですね。
 特に未知語の出現確率0.0001%というのは、教科書でも恣意的に設定した値である旨が注記されており、直感的にはなんか気持ち悪いところです。これは、ある本文が得られる確率を計算するときに、その本文に登場している全ての単語に対して全て出現確率を与えないといけないのに対し、訓練データが限られていると単語の出現確率をデータから得ることができないため、決め打ちで設定してしまうという話です。
 教科書の説明によると、テキスト分類のプログラムでは簡便な方法として「未知語の出現確率に非常に小さい値を設定しておく」というのはよくやることだとされています。他に、確率分布をランダムで割り当てるというような方法もあるらしいです。
 なお、densityは判定には全く使わないです。

ベイズの定理

 ところで、上述のような流れの処理のどこが「ベイズ分類器」なのかは、教科書を単に読むだけだと説明がなくてわからないので、一応以下にまとめておきます。
 基本的な考え方は、ベイズの定理を用いて、「こういう内容の本文が得られた場合に、そのメールがスパムである確率はですね……」というのを計算していくということになります。(しかし結局、確率を推定するというより、計算された確率らしき値を比較するだけなので、ベイズ推定というほどのものではないわけですが。)


 { \displaystyle P(W) }:ある本文(語=wordsの組み合わせ)の出現確率
 { \displaystyle P(C) }:あるメールがスパムというclass又は非スパムというclassである確率
 { \displaystyle P(W|C) }:あるメールがスパム(又は非スパム)だったときにある本文が得られる確率
 { \displaystyle P(C|W) }:ある本文が得られた時に、そのメールがスパム(又は非スパム)である確率


 だとすると、


 { \displaystyle P(C|W) = \frac{P(W|C)P(C)}{P(W)} }


 となります。これが求めたい値ですね。
 P(W)は、メール本文に登場する各単語の出現確率を全部掛けあわせたものであり*2、P(W|C)は尤度を表します。P(C)はベイズ推定でいうところの事前確率であり、P(C|W)が事後確率です。
 目の前に開封前のメールが1通あったとして、何も情報がなければ、それがスパムである確率を経験等に基づいていったん仮定する(事前確率)が、その後メールの本文が情報として与えられることによりこの仮定は更新されて、事後確率となるわけです。
 本章の演習では、まず事前確率を「スパムである確率も非スパムである確率も0.5である」と仮定して分類器を回した後、「でも実際スパムってメール全体の2割ぐらいだよね」というふうに想定を変えて、スパム0.2、非スパム0.8を事前確率として分類しなおしたりします。
 それで事後確率としてのP(C|W)が算出された後に、スパムである確率と非スパムである確率を比較して、前者が大きければスパムと判定するわけですね。


 なお分類器としては、上の式の分母のP(W)は各クラスで共通になり判定の上では不要です(正規化定数ってやつです)。だから本章の演習では、「事前確率 × 各単語の出現確率」だけを計算するプログラムを書いています。

実際の演習コード

 以下、演習用のコードです。ほとんどは教科書(及びサポートサイトのサンプルコード)からの写経ですが、コメントはかなり自分で入れていて、一部コードを改変したところもあります。
 教科書に沿ってコードを書くときは、何も考えずに書き写しても意味が無いので自分でコメントをはさみまくるようにすると、勉強になる気がします。あと、処理の意味をコメントとして記載しておいたので、教科書のコードを読んで意味がわからなかった人の役にも立つかもしれません。読者がどれぐらいいるのか不明ですけど。
 なおワーキングディレクトリの中に"data"というディレクトリを設け、さらにその中に"spam"、"easy_ham"などのディレクトリがあって、メールのデータが保存されています。また、グラフのPDF保存用に"images"というディレクトリを設けてあります。


 まず、必要なパッケージをインポートします。

library(tm)
library(ggplot2)


 各メールのファイルが保存されているディレクトリのパスを、オブジェクトに保存してあとで使います。ちなみに下記のパスの書式はテキストに載っているのと同じですが、Githubのサンプルコードだと、file.path("data", "spam")という書き方をしています(もちろん内容的には同じ)。

spam.path <- "data/spam/"
spam2.path <- "data/spam_2/"
easyham.path <- "data/easy_ham/"
easyham2.path <- "data/easy_ham_2/"
hardham.path <- "data/hard_ham/"
hardham2.path <- "data/hard_ham_2/"


 メールのファイルを開き、最初の空行(headerとbodyの区切りを意味する)より1行下から全てのテキストを抽出する関数を作成します。

get.msg <- function(path) {
   con <- file(path, open="rt", encoding="UTF-8")
   text <- readLines(con)
   msg <- text[seq(from=which(text=="")[1]+1, to=length(text), 1)]  # 最初の空行の1行下から、1行ごとにテキストを取得
   close(con)  # 切断
   return(paste(msg, collapse="\n"))
}


 フォルダに保存されているメールのファイルから本文をまとめて取得します。

spam.docs <- dir(spam.path)  # 当該ディレクトリ内のファイル名一覧を取得
spam.docs <- spam.docs[which(spam.docs!="cmds")]  # cmdsという余分なファイルが入ってるので除く
all.spam <- sapply(spam.docs, function(p) get.msg(paste(spam.path, p, sep="/")))  # ディレクトリ名とファイル名を"/"でつないで、1個1個のファイルの内容を抽出


 このall.spamベクトルの中に、解析対象となるメール本文が1通1要素としてぶちこまれています。ベクトルの要素名がファイル名(「通し番号.ハッシュ値」になっている)で、内容がメール本文です。本文は結構な分量になるのでここには引用しませんが、HTMLのタグなども大量に含まれています。


 取得されたメール本文から、テキスト処理のための準備をします。
 まず、TDM(Term-Document matrix)を作成します。

get.tdm <- function(doc.vec) {
   doc.corpus <- Corpus(VectorSource(doc.vec))  # コーパスをつくる{tm}の関数
   control <- list(stopwords=TRUE, removePunctuation=TRUE, removeNumbers=TRUE, minDocFreq=2)
   doc.tdm <- TermDocumentMatrix(doc.corpus, control)  # TDMをつくる{tm}の関数
   return(doc.tdm)	
}

spam.tdm <- get.tdm(all.spam)  # スパムの訓練データについてTDMを作成。


 controlのところでTermDocumentMatrix()への引数を与えていますが、これは本文中の記号や数字を集計対象外とする処理です。


 ところで、このget.tdm()の処理をやったところ、「Break on __THE_PROCESS_HAS_FORKED_AND_YOU_CANNOT_USE_THIS_COREFOUNDATION_FUNCTIONALITY___YOU_MUST_EXEC__() to debug.」とかいうエラーが出て、意味分からなくてキレそうになったのですが、Rを再起動したら収まりました。他の場面でも同じエラーが出ましたが、とりあえずRを再起動すると治りました。


 さて、スパム語の出現率を計算します。

spam.matrix <- as.matrix(spam.tdm)  # TDMを行列型に変換
spam.counts <- rowSums(spam.matrix)  # 行(単語)ごとに、登場回数を合計する
spam.df <- data.frame(cbind(names(spam.counts), as.numeric(spam.counts)), stringsAsFactors=FALSE)
names(spam.df) <- c("term", "frequency")
spam.df$frequency <- as.numeric(spam.df$frequency)
spam.occurrence <- sapply(1:nrow(spam.matrix), 
   function (i) {
      # 各スパム単語が1つでも登場したメッセージの数を、メッセージの数で割る
      length(which(spam.matrix[i,] > 0)) / ncol(spam.matrix)
      }
   )
spam.density <- spam.df$frequency/sum(spam.df$frequency)
spam.df <- transform(spam.df, density=spam.density, occurrence=spam.occurrence)


 frequencyは、スパム訓練データに(2回以上)登場した単語の、登場回数の合計を表し、単純にスパムメールに多用されている単語が分かるというもの。
 occurrenceは、各スパム語について、それが1つでも登場したメールの数をメールの総数で割ったもの。全メール中のどれぐらいのメールに登場するかを表します。
 densityは、あるスパム語の登場回数を、スパム語全体の登場回数で割ったものです。


 中身をみてみましょう。Occurrenceの高い順に並ぶようソートして最初の6行を見ます。

> head(spam.df[with(spam.df, order(-occurrence)),])
        term frequency     density occurrence
7781   email       813 0.005853680      0.566
18809 please       425 0.003060042      0.508
14720   list       409 0.002944840      0.444
27309   will       828 0.005961681      0.422
3060    body       379 0.002728837      0.408
9457    free       539 0.003880853      0.390


 これ、教科書に書かれてある値(↓の画像)と全然違いますね。


 f:id:midnightseminar:20150609040836j:plain


 Githubのサンプルコードをそのまま走らせても上記の値になるので、私の写経に間違いがあったわけではないです。
 「スパムメールの多くはHTMLメールなので、HTMLという語が最上位に来るのは当然」みたいなことが本文で解説されるのですが、最上位どころか上位6語に入ってないので、解説が成り立ちません。
 ちょっと意味不明ですが、このまま先へ進みます。


 次に、easy_hamで同じことを行います。非スパムのデータで訓練して、非スパム判定にあとで使うことになります。コードとしては、「spam」→「easyham」、「spam.path」→「easyham.path」に書き換えるだけですね。
 easyhamのメールデータはspamよりも件数が多いのですが、スパムの訓練データとの間でメール通数を揃えるために[1:length(spam.docs)]を加えて ています。これでフォルダ内の最初の500件だけが訓練データとして取り込まれました。

easyham.docs <- dir(easyham.path)  # 当該ディレクトリ内のファイル名一覧を取得
easyham.docs <- easyham.docs[which(easyham.docs!="cmds")]  # cmdsというファイルを除く
all.easyham <- sapply(easyham.docs[1:length(spam.docs)]  # 抽出対象を500件に絞る
   , function(p) get.msg(paste(easyham.path, p, sep="/")))  # ディレクトリ名とファイル名を"/"でつないで、1個1個のファイルの内容を抽出

easyham.tdm <- get.tdm(all.easyham)
easyham.matrix <- as.matrix(easyham.tdm)
easyham.counts <- rowSums(easyham.matrix)
easyham.df <- data.frame(cbind(names(easyham.counts), as.numeric(easyham.counts)), stringsAsFactors=FALSE)
names(easyham.df) <- c("term", "frequency")
easyham.df$frequency <- as.numeric(easyham.df$frequency)

easyham.occurrence <- sapply(1:nrow(easyham.matrix), 
   function (i) {
      length(which(easyham.matrix[i,] > 0)) / ncol(easyham.matrix)
      }
   )

easyham.density <- easyham.df$frequency/sum(easyham.df$frequency)
easyham.df <- transform(easyham.df, density=easyham.density, occurrence=easyham.occurrence)


 非スパムの訓練データがどんなものか見てみます。

> head(easyham.df[with(easyham.df, order(-occurrence)),])
       term frequency     density occurrence
5200  group       232 0.003317319      0.388
12891   use       271 0.003874971      0.378
13527 wrote       237 0.003388813      0.378
1633    can       348 0.004975978      0.368
7255   list       248 0.003546099      0.368
8593    one       356 0.005090368      0.336


 次に、分類器を作成します。
 意味の説明のためコード中にコメントを入れまくってます。

classify.email <- function (path, training.df, prior=0.5, c=1e-6) {
   # pathが判定対象のメールのディレクトリで、trainingは訓練データ表
   # priorはスパムである事前確率
   # cは未知語(単語一覧にない単語)の出現確率(デフォルトで0.0001%としているが厳密な根拠はない)

   msg <- get.msg(path)  # pathに該当するディレクトリ内のメールのメッセージ本文を取得する
   msg.tdm <- get.tdm(msg)  # TDM(単語 × メッセージの表)を作成
   msg.freq <- rowSums(as.matrix(msg.tdm))  # 各単語の出現回数を合計する
 
   # ↓重複している語を見つける。intersect()は2つのベクトルの共通要素を返す
   msg.match <- intersect(names(msg.freq), training.df$term)
   if(length(msg.match) < 1) {
   	return(prior * c^(length(msg.freq)))  # 共通のものがない場合はスパムである確率がほぼゼロと判定
   } else {

   # 今回判定しているメール群の単語一覧中、訓練データと共通に存在したものが、
   # 訓練データのterm列の何行目にあるかをmatch()関数で特定し、それぞれの出現率を
   # 取得します。出現率は、繰り返しますが、訓練データにおいてその単語が1つでも登場したメール
   # の数を、メールの総数で割ったもの。
   	match.probs <- training.df$occurence[match(msg.match, training.df$term)]

   	# 事前確率と、すべての単語の出現確率(未知語については0.0001%)の積を求めます。
   	# ここで未知語とは、判定対象メール群の中に登場する単語で、トレーニングデータの単語一覧
   	# に載っていないものを意味しますが、登場数が1回のものも一覧からは除外されている点に注意。
   	return(prior * prod(match.probs) * c^(length(msg.freq)-length(msg.match)))
   }
   }


 分類器ができたので、実際にhardhamのメールデータを対象に、スパムかどうか判定してみて性能を確認します。
 まず、判定対象のメールの本文を取得します。

hardham.docs <- dir(hardham.path)  # hard_hamフォルダのファイル名を取得
hardham.docs <- hardham.docs[which(hardham.docs != "cmds")]  # ファイル名「cmds」を除く


 次に、スパムである確率を算出します。なお「sep = "/"」のところ、教科書では「=""」となっていたんですが、「="/"」の間違いですね。

hardham.spamtest <- sapply(hardham.docs, # 各ファイル名について、
   function(p) classify.email(paste(hardham.path, p, sep = "/"),  # パスを組み合わせてファイルを指定し、classify.emailを適用
   training.df = spam.df  # spamを訓練データとして指定
   )
   )


 続いて、非スパムである確率を計算します。training.dfのところを変えるだけです。

hardham.hamtest <- sapply(hardham.docs, 
   function(p) classify.email(paste(hardham.path, p, sep = "/"), 
   training.df = easyham.df  # easyhamを訓練データとして指定
   )
   )


 スパムである確率のほうが高ければTRUE、非スパムである確率のほうが高ければFALSEを出力して、表にまとめます。

> hardham.res <- ifelse(hardham.spamtest > hardham.hamtest, TRUE, FALSE)
> summary(hardham.res)
   Mode   FALSE    TRUE    NA's 
logical     240       9       0 


 非スパムのメール249通のうち9通について、誤ってスパム判定してしまっています。性能としては、「単純な分類器のわりには高い」という評価になるようです。


 さて、残りのメールデータセットに対して、一気にスパム判定するための関数を準備します。スパムである確率と非スパムである確率を並べて、さらにスパム判定されたら「1」、非スパムなら「0」を表示するものです。

spam.classifier <- function(path){
   pr.spam <- classify.email(path, spam.df)  # スパムである確率の算出
   pr.ham <- classify.email(path, easyham.df)  # 非スパムである確率の算出
   return(c(pr.spam, pr.ham, ifelse(pr.spam > pr.ham, 1, 0)))  # 判定結果
}


 判定を行う残りのメールデータ(easyham2, hardham2, spam2)について、ファイル名を取得しておきます。

easyham2.docs <- dir(easyham2.path)
easyham2.docs <- easyham2.docs[which(easyham2.docs != "cmds")]
hardham2.docs <- dir(hardham2.path)
hardham2.docs <- hardham2.docs[which(hardham2.docs != "cmds")]
spam2.docs <- dir(spam2.path)
spam2.docs <- spam2.docs[which(spam2.docs != "cmds")]


 それぞれのデータに、さきほどのspam.classifier関数を適用します。なおサンプルコードではlapplyをsuppressWarnings()でくるんでいたけど、無視してます。

easyham2.class <- lapply(easyham2.docs, function(p)
   {
   	spam.classifier(file.path(easyham2.path, p))  # file.path関数を使った記法
   }
   )

hardham2.class <- lapply(hardham2.docs, function(p)
   {
   	spam.classifier(paste(hardham2.path, p, sep="/"))  # こっちの記法でも同じこと
   }
)

spam2.class <- lapply(spam2.docs, function(p)
   {
   	spam.classifier(paste(spam2.path, p, sep="/"))
   }
)


 結果をまとめた表をつくります。
 do.call()関数は、第一引数の関数について、第二引数のリスト内容を引数として適用していくものです。
 先ほどのspam.classifier()関数によって作成されたeasyham.classその他は、メッセージのそれぞれについてスパムである確率、非スパムである確率、その比較結果の組み合わせが、リストで大量にぶちこまれているものですから、まずこれをrbindして一つの行列にし、次にその右側に、easyhamなどの種別の名称を表示します。

easyham2.matrix <- do.call(rbind, easyham2.class)
easyham2.final <- cbind(easyham2.matrix, "EASYHAM")
hardham2.matrix <- do.call(rbind, hardham2.class)
hardham2.final <- cbind(hardham2.matrix, "HARDHAM")
spam2.matrix <- do.call(rbind, spam2.class)
spam2.final <- cbind(spam2.matrix, "SPAM")


 以下、まとめ表の作成です。

# 3つの種別の表をタテにつなげて、すべてのメールデータとスパム・非スパム判定の結果を表示したデカい行列を作成します。
class.matrix <- rbind(easyham2.final, hardham2.final, spam2.final)

# データフレームに変換します。
class.df <- data.frame(class.matrix, stringsAsFactors = FALSE)

# 列名称をつけます。
names(class.df) <- c("Pr.SPAM" ,"Pr.HAM", "Class", "Type")

# 確率のところをnumericに、分類結果はlogicalに、メール種別はfactorに、データ型を変換する。
class.df$Pr.SPAM <- as.numeric(class.df$Pr.SPAM)
class.df$Pr.HAM <- as.numeric(class.df$Pr.HAM)
class.df$Class <- as.logical(as.numeric(class.df$Class))
class.df$Type <- as.factor(class.df$Type)


 結果をみてみましょう。TRUEになっていると、スパム判定されているということです。

> head(class.df)
  Pr.SPAM Pr.HAM Class    Type
1   0e+00 5e-283 FALSE EASYHAM
2  5e-169  5e-49 FALSE EASYHAM
3  5e-235  5e-55 FALSE EASYHAM
4   0e+00  0e+00 FALSE EASYHAM
5  5e-121  5e-43 FALSE EASYHAM
6   0e+00 5e-313 FALSE EASYHAM


 もとの計算式から明らかな通り、確率のところはとても小さい値になってます。(正規化定数を計算してないので足して1にならない。比較が目的だからこれで良い。)
 上のように個別に判定結果をみてても性能はわからないので、集計しましょう。
 まず、FALSE(非スパム)判定された率と、TRUE(スパム)判定された率を並べる関数をつくります。

get.results <- function(bool.vector)
{
  results <- c(length(bool.vector[which(bool.vector == FALSE)]) / length(bool.vector),
               length(bool.vector[which(bool.vector == TRUE)]) / length(bool.vector))
  return(results)
}


上記関数を適用し、さっきの全体結果データフレームから各種別の行をそれぞれ抜き出してきて、率を比べます。

> easyham2.col <- get.results(subset(class.df, Type == "EASYHAM")$Class)
> hardham2.col <- get.results(subset(class.df, Type == "HARDHAM")$Class)
> spam2.col <- get.results(subset(class.df, Type == "SPAM")$Class)
> 
> # 表にまとめます。
> class.res <- rbind(easyham2.col, hardham2.col, spam2.col)
> colnames(class.res) <- c("NOT SPAM", "SPAM")
> print(class.res)
              NOT SPAM        SPAM
easyham2.col 0.9921429 0.007857143
hardham2.col 0.9556452 0.044354839
spam2.col    0.3335719 0.666428060


 これをみると、スパムメールの3分の1ぐらいは、非スパム判定されてしまっていますね。非スパムの判定はミスが少ないですが、これは教科書にも書かれているように、要するに決定境界がスパム側に寄っていたという話です。


 判別の結果をグラフにしてみます。
 横軸に非スパムである確率、縦軸にスパムである確率をとり、y=xの直線を引いておけば、この線より右下であれば非スパム判定、左上であればスパム判定されたことになります。
 なお下のコードはサンプルコードそのまんまですが(コメントは私のメモですが)、グラフはPDFで保存するところまでやっています。

class.plot <- ggplot(class.df, aes(x = log(Pr.HAM), log(Pr.SPAM))) +  # 軸は対数目盛に
    geom_point(aes(shape = Type, alpha = 0.5)) +  # 散布図を描画
    stat_abline(yintercept = 0, slope = 1) +      # 斜めの線を引く
    scale_shape_manual(values = c("EASYHAM" = 1,  # メール種別ごとに記号を指定
                                  "HARDHAM" = 2,
                                  "SPAM" = 3),
                       name = "Email Type") +  # 凡例の名前
    scale_alpha(guide = "none") +  # この設定の使い方よく分かってない
    xlab("log[Pr(HAM)]") +  # 軸のラベル
    ylab("log[Pr(SPAM)]") +
    theme_bw() +  # テーマカラーの設定
    theme(axis.text.x = element_blank(), axis.text.y = element_blank())

# PDF保存のコードです。「images」というフォルダがないとダメです。
ggsave(plot = class.plot,
       filename = file.path("images", "03_final_classification.pdf"),
       height = 10,
       width = 10)


 f:id:midnightseminar:20150609040952p:plain


 図で見ると、まぁまぁ分類できているような気もしますね。

分類器の性能の改善

 分類器の性能改善のため、非スパムの訓練をやりなおすことを考えます。
 どういうことかというと、まず事前確率としてスパムであるか非スパムであるかを50%:50%にセットして分析していたわけですが、現実にはスパムは2割ぐらいだろうと考えて、分類器の引数にセットする事前確率をいじる方法が考えられます。
 その場合、それと合わせて訓練データの比率もスパム2割・非スパム8割になるよう調整するのが望ましいので、非スパム訓練メールデータは「最初の500件」に絞らず全て使うことにします。


 コードのどこが変わるかというと、

all.easyham <- sapply(easyham.docs  # 限定を外した
   , function(p) get.msg(paste(easyham.path, p, sep="/")))


 というふうに使うデータを限定していた[]書きのところを削除しました。
 また下記のとおり、すべてのメールデータセットに対して一気に判定するための関数を書いた箇所で、事前確率をいじって変えておきます。

spam.classifier <- function(path){
   pr.spam <- classify.email(path, spam.df, prior = 0.2)
   pr.ham <- classify.email(path, easyham.df, prior = 0.8)
   return(c(pr.spam, pr.ham, ifelse(pr.spam > pr.ham, 1, 0)))
}


 結果をみてみましょう。

> print(class.res)
              NOT SPAM         SPAM
easyham2.col 0.9992857 0.0007142857
hardham2.col 0.9879032 0.0120967742
spam2.col    0.5340014 0.4659985684


 非スパムについてはいいんですが、スパムメールの半分以上が、非スパム判定されてしまっておりますね。

参考

 http://kentandx.blogspot.jp/2014/09/blog-post.html
 このページをみると、上述のように「0.0001%」なんていう値を何乗もする処理をしたら、ゼロになってしまって正確に判断できないと書かれています。
 ただこれをやってみたところ、動かなかったので、あとで時間あったら見なおしておきます。

反省

 1エントリ2万字は長すぎですね。

*1:実際に自分でプログラムを走らせず教科書に書かれたコードを「読書」してるだけだと「ふむふむ」とか言って読み進めてたんですが

*2:本章の演習は単純な分類器を作成するもので、各単語の出現は相互に独立であると仮定されており、単語の共起関係や、文法・文章構成といった構造は無視。