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

統計学およびR、Pythonでのプログラミングの勉強の過程をメモっていくノート。たまにMacの話題。

『入門 機械学習』第4章:「重要なメール」を特定する

前置き

 本エントリはオライリーの『入門 機械学習』の学習メモです。

入門 機械学習

入門 機械学習

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

 「この教科書で勉強を始めてみたものの、分からないところがあってやる気が失せてきた人」が将来たまたまググってここを見つけた時に、参考になるかもしれないことを書いているつもりです。ネット上ではこの教科書の解説はあまり見つからないし。
 統計・プログラミングの初心者を想定しており、私も初心者です。とくにコードの意味について、初心者が見て分からないところをゼロにするのが目標です。
 本書の内容は文字通り機械学習に入門するものですが、特徴としてはプログラミングが全てRであることと、理論の説明はほとんどなく数式も出てこなくて、機械学習っぽい処理の初歩的なやつをとにかく色々実行してみようというノリであることですね。Rの初歩ぐらいは分かっている必要があります。
 
 著者のコード等が置かれているGitHub上のサポートページはこちら。
 https://github.com/johnmyleswhite/ML_for_Hackers
 
 この教科書全体に関するレビューはこの記事がいいと思う。
 「入門 機械学習」を献本していただきました - EchizenBlog-Zwei
 
 
 今回は、メールの重要度を評価するという、第4章の演習を振り返ります。
 教科書そのものは8章ぐらいまで読んだんですが、ブログにはまだ3章と4章しか書けていません。こまごま書きすぎてエントリ1本がめっちゃ長くなってるんですよねー。5章以降はもうちょっと簡単に書こうかなw
 
 前回の、第3章に関するエントリはこちら。
 『入門 機械学習』第3章:ベイズスパム分類器の作成 - StatsBeginner: 初学者の統計学習ノート
 
 

第4章のテーマ:「重要なメール」を判別する(優先トレイの作成)

 前の第3章では、スパムであること/ないことが分かっている多数のEメールメッセージが与えられ、そこに含まれる単語の使用傾向を分析してナイーブベイズ分類器を構成し、新たにEメールのメッセージが与えられた時に「それがスパムであるか否か」を判定するフィルターを作成するという演習でした。
 
 第4章は同じEメールのデータを使うんですが、こんどは「重要なメール」を判別して取り出すという演習です。具体的には、送信者、日時、スレッド(件名の「Re:」を頼りに一連のやりとりをまとめたもの)、文中の単語の使用頻度などを分析して、Eメール1通1通に優先度を表すスコアを与え、中央値よりもスコアの高いメールを「重要なメール」と判定するという流れになります。
 本章の冒頭では、Googleの「優先トレイ」に関する論文が紹介されており、一読することを勧めていたので私も一応読みました。4ページしかない短いものです。
 大まかには、
 

Our goal is to predict the probability that the user will interact with the mail within T seconds of delivery, providing the rank of the mail. Informally, we predict p = Pr(aA, t ∈ (T_min, T_max) | f, s); where a is the action performed on the mail, A is the set of actions denoting importance (e.g., opens, replies, manual corrections), t is the delay between delivery and the action, f is the vector of features, and s indicates that user has had an opportunity to see the mail.

 
 というようなことを実現するプログラムになっているらしいです。受信したメール対し、ある期間内にユーザが何らかのアクションを起こす確率を計算していて、それを優先メールのランク付けにしているのであると。
 
 なお、本章の演習はGoogleみたいな高度なことをやるわけではありませんし、そもそも使用するEメールのサンプルデータも「受信データ」のみである、タグ付などの情報は無いといった制約があって、本格的な優先付けフィルタが作成できるわけではないです。
 また本章の演習では教師データが与えられていない*1ので、一応分類はしてみるものの、その分類がどれぐらい妥当だったのかは直接的には評価できない内容となっています。
 
 

第4章の雰囲気と感想

 先に第4章への感想を言っておくと、別に悪い意味ではないのですが、この章の演習で行う処理は「統計学」的でもなければあまり「機械学習」的でもないような気がしました。
 
 まず本章の作業の大半はいわゆる「前処理」で、Eメールのデータから送信者アドレスのように定型的な情報を取り出したり、日時の書式を揃えたり、件名の「Re:」を頼りにスレッドを認識したりといったプログラムを書くことになります。これはこれで大変勉強になります。データ解析は一般に、その前段の準備が大変なもんだと思うので、とてもいい内容だと思いました*2正規表現とgrepで文字列を探してくるとか、日時データを扱ってみるといった、プログラミング初心者として触れておきたい要素もけっこう含まれていたと思います。
 本章の本文中でも、

機械学習の魅力的な部分に取りかかる前に、自分で手を動かしてデータを分割したり、抜き出したり、解析したりする。……だが我々はハッカーであり、データで手を汚すのは望むところである!

 というよくわからないエモーショナルな著者の思いが語られていますw(ちなみに本書の原題は「Machine Learning for Hackers」です。)
 
 で、メールの優先度をスコアリングする仕組みのところは、統計処理というほどの計算はなく、単に送信者の登場回数とかスレッド数とか単語数を数え上げるだけのものなので、「おーなるほど」みたいな感動とかはないといえばないです。数式も一切ありません。
 たとえば出現頻度の高い単語に大きな重みを与えるといったことをやるのですが、この章の演習だと教師データがないので、最適な重みを推定したりすることはないです。一部、職人技的な「加減」で重みを調整する場面もありました(後で出てきますが、指数関数的な分布になる重み値を対数変換でならす際に、底は感覚的に?決めている)。また教師データがないため、分類が成功しているのかどうかは結局分からないし、フィードバックを得て精度を上げていくといった処理も行われません。
 
 ただ、メールのデータから色々な特徴量を取り出すという処理の過程で学べるものは多く、簡易な演習とはいえ色々勉強になりました。
 
 

第4章でつまずいたところ

 本章を勉強しててつまずいたところを挙げておきます。*3
 4章の内容で困ったのは、教科書に書かれてあるコードと、GitHubのサポートページに置いてあるサンプルコード*4で内容が違うところがあるということですね。
 たとえば、教科書ではplyrパッケージのddply関数を使っている箇所があるのですが、サンプルコードではmelt()関数を使っていました。この関数を使うにはreshape2というパッケージを読み込まないといけません*5
 あとは例えば、テキストマイニングで使われるtmパッケージのTermDocumentMatrix()関数で、stopwords*6を集計対象から外すときに、教科書は「stopwords=stopwords()」と書いてるけどサンプルコードでは「stopwords=TRUE」だったりとかですね。
 
 また、教科書本文はコードを書く順番が変なところがあって、ある関数を作ったあとにその関数を前提とした別の関数を書くはずなのに、逆になっていたりします。つまり、コードの中に謎の関数が出てきて、「なんだこれ?」とか思って読み進めると、後で定義されていたりします。サンプルコードは全て処理の順番通りになっているように思いました。
 基本的にこのブログエントリは、教科書を読むためのものなので、教科書の順に書いていきます。
 
 

全体の処理の流れ

 コードの中身をみていく前に、第4章全体としての、処理の流れを箇条書きでまとめておきます。
 
 まず、本章の演習で使うメールのデータがどういうものなのかわからないと話にならないので、1例を掲載しておきます。第3章と同じでSpamAssasinというサイトに公開されているデータを使います。スパム、非スパム等の区別でフォルダが分かれており、フォルダの中には1通1ファイルでメールのデータが数千個ブチ込まれています。
 
 

Return-Path: anthony@interlink.com.au
Delivery-Date: Sat Sep  7 04:50:37 2002
From: anthony@interlink.com.au (Anthony Baxter)
Date: Sat, 07 Sep 2002 13:50:37 +1000
Subject: [Spambayes] understanding high false negative rate 
In-Reply-To: <15737.16782.542869.368986@slothrop.zope.com> 
Message-ID: <200209070350.g873obE20720@localhost.localdomain>


>>> Jeremy Hylton wrote
> Then I tried a dirt simple tokenizer for the headers that tokenize the
> words in the header and emitted like this "%s: %s" % (hdr, word).
> That worked too well :-).  The received and date headers helped the
> classifier discover that most of my spam is old and most of my ham is
> new.

Heh. I hit the same problem, but the other way round, when I first
started playing with this - I'd collected spam for a week or two, 
then mixed it up with randomly selected messages from my mail boxes.

course, it instantly picked up on 'received:2001' as a non-ham. 

Curse that too-smart-for-me software. Still, it's probably a good
thing to note in the documentation about the software - when collecting
spam/ham, make _sure_ you try and collect from the same source.


Anthony

-- 
Anthony Baxter     <anthony@interlink.com.au>   
It's never too late to have a happy childhood.

 
 全てのメールのデータ書式が統一されているわけではなく、たとえば日時や送信者の表示形式が違ったり、件名がないものがあったりします。それらの差異を吸収するのも、前処理の一貫です。
 こうしたメールデータに対する処理の流れは以下のとおり。
 

  1. メールのファイルを1個1個開き、中身をテキストとして取得してRのベクトルにしておく。
  2. 送信者を抽出する関数を書く。(書式に複数パターンがあるので工夫が必要。)
  3. 件名を抽出する関数を書く。
  4. 受信日時を抽出する関数を書く。(書式に複数パターンがあり、あとで処理する。)
  5. 本文を抽出する関数を書く。
  6. これらを組み合わせ、「メールのファイルを与えると、送信者、件名、日時、本文(及びファイルのパス)を返してくれる関数」を書く。この関数を使ってメールのファイルを集計しやすい形に整形することを著者は「パース」と呼んでいる。(パースってのは構文を解析することです。XMLのパーサーとか言いますよね。)
  7. 上記関数を使ってメールのファイル群をまとめてパースして、データフレームに変換し、集計しやすいようにしておく。
  8. パースしたメールの日時部分を、テキストデータからPOSIXの日時オブジェクトに変換する。要はシステムが「これは日時データだ」って理解できるようにする。
  9. 検索の便宜のために、件名を全て小文字にする。
  10. メールのデータを時系列に並べ替えて、前半を訓練用データ、後半をテスト用データとして分割する。
  11. 送信者の登場回数をカウントする。「登場回数の多い送信者からのメールは重要」という風に重み(スコア)付けするわけなのだが、登場回数上位と下位の差が激しく、極端なスコアリングになってしまうので、対数変換して差をなだらかにしておく。
  12. メールの「スレッド」を特定する。件名と件名冒頭の「Re: 」に注目して、「このメールとこのメールは同じスレッドだよな」と認識させていく。
  13. 送信者ごとの、スレッドメールを送ってきた回数をカウントする。これもスコアリングに使うのだが、上位下位の差が激しいので対数化しておく。
  14. 各スレッドについて、その一連のやりとりが単位期間あたりに何回行われたかという「密度」を計算し、これを対数化したものをまたスコアに使う。短時間で、つまり高速でやりとりされているメールは重要であるということ。
  15. テキストを与えるとTDM(Term Document Matrix)を作り、単語の登場回数を集計してくれる関数を作る。
  16. スレッドの件名に登場する単語を抽出し、その単語が件名に使われたスレッドの重み(密度)を取得してきて平均し、その単語の(スレッド件名ベースの)重みとする。
  17. 本文の単語をカウントし、TDMを作成して、単純に単語ごとにその登場回数を集計し、その対数化した値も記録する関数をつくる。この対数化された登場回数が、その単語の(本文ベースの)優先度となる。
  18. メールの優先度(rank)を計算する。メールのデータを与えると、①送信者の優先度1(登場頻度ベース)、②送信者の優先度2(スレッドメール数ベース)、③スレッドの優先度(スレッドメールである場合のみ。単位時間あたりのやりとり密度を与える)、④件名中の単語の優先度(スレッドに使われたことのある単語が含まれている場合に、当該スレッドの密度の平均値を与える)、⑤本文中の単語の優先度(登場回数ベース)を取得し、掛けあわせてそのメールの優先度値を計算。
  19. メールデータの前半を訓練データとし、それぞれの優先度値(rank)を計算する。
  20. 訓練データにおいて、優先度値の中央値を「しきい値」とする。しきい値を超えていれば「優先」メールと判別することにする。
  21. 判別結果が正しいかどうかについては教師データがないので分からないが、一応グラフを描いて、しきい値が偏ったところにないことを確認する。
  22. テストデータの方も優先度値を計算し、さきほど決めた訓練データベースの閾値を基準に優先/非優先を判別。

 
 

コードの解説

 では教科書に書かれているコードをみていきます。「これはどういう処理なのか」が初心者にも分かるように(私も初心者ですが)、その意味合いをなるべく詳しくメモしています。
 GutHubのサンプルコードには作図のためのggplot2のコードも出てきますが、ここではすべては取り上げていません(ちなみに教科書ではggplot2のコードはほとんど出てこなくて、結果の図だけ載っています)。
 コードの内容を教科書ではなくサンプルコードに合わせた箇所もあるし、改行とかインデントは自分で勝手に変えています。また、教科書には出てこないけど、処理結果を一応確認しておいたり、関数の動きを確かめたりしているコードも本エントリには含まれています。
 
 
 まず、GitHubからダウンロードした分析対象のメールデータをどっかのディレクトリに格納しておく必要があります。私はRの勉強用のディレクトリ内に本書用のディレクトリを設けて、これを作業ディレクトとし、その下にcode、data、imagesというディレクトリをつくって、codeの中にスクリプトを、dataの中に分析用のメールのデータを入れておきました*7。imagesは、ggplot2でグラフを描いたあとにそれを保存するディレクトリです。
 
 さて作業ディレクトリを設定し、必要なパッケージを読み込んでおきます。
 

# Working Directoryを設定
setwd("/**********/**********/**********/")  # 各自でパスを設定 


# 使用するパッケージを呼び出す
# 必要ならその前に、install.packages("tm", "ggplot2", "plyr", "reshape2")でインストール。
library(tm)
library(ggplot2)
library(plyr)
library(reshape2)

 
 
メールのデータが入っているディレクトリを参照するpathを保存します。
 

data.path <- "data/"
easyham.path <- paste(data.path, "easy_ham/", sep="")

 
 
 第3章と同じで、演習用のメールデータは、1通1ファイルとなっているテキストファイルで、これが大量にディレクトリ内に保存されております。
 eメールのデータを整形するための関数をつくっていくわけですが、教科書ではまず最初に、メールのデータを1通分渡してやると、日付、送信者、件名、本文、そしてファイルのパスを返してくれる関数を定義しています。
 

parse.email <- function (path) {
   full.msg <- msg.full(path)
   date <- get.date(full.msg)
   from <- get.from(full.msg)
   subj <- get.subject(full.msg)
   msg <- get.msg(full.msg)
   return(c(date, from, subj, msg, path))
}

 
 教科書はいきなりこの関数を書いているのですが、順番からいうと、この関数内で呼び出されているget.date()などの別の関数(メールのデータから日付、送信者、件名、本文を抽出する書各関数)を先に定義しておかないといけません。サンプルコードではそれらが先に書かれます。
 まぁあくまでこれは関数の定義を書いているだけなので、parse.emailを先に書いても動作上は構わないので、私も教科書の順番で書いてありますが。
 
 
 さて上記parse.email()の中で使われている諸々の関数のうち、まずは、メールのファイルを開いて、中身をテキストとして読み取るmsg.full()関数を定義します。
 

msg.full <- function(path) {
   con <- file(path, open="rt", encoding="latin1") #注1
   msg <- readLines(con)  # テキストの中身を読む
   close(con)             # ファイルとのコネクションを閉じる
   return(msg)            # メッセージの中身を出力する
}

 
 注1:file()関数はファイルへのコネクションを開くもので、open="rt"はモードを指定しており、この場合は"reading in text"モードを選択しているということです。要は、読むためにファイルを開いてるんですよということ。encodingは文字通り文字コードです。
 読み取るという作業が単独で存在するのではなく、必ず「ファイルとのコネクションを開く」「閉じる」という操作があることに注意する必要があります。閉じておかないと後で意図しないエラーが起きたりすることがあると思います。
 
 教科書には載ってないですが、メールの中身を見てみるために、テキトーにファイルを開いてみます。たとえば以下のようにして、easyhamフォルダの1つめのメールファイルを開いてみます。
 1行がベクトルの1要素になります。
 

mail.1 <- file(paste(easyham.path, 
               dir(easyham.path)[1], sep=""), 
               open="rt")
mail.1.vec <- readLines(mail.1)
close(mail.1)  # コネクションを閉じるのを忘れないように

 
 
 次に、メールの送信者を取得するget.from()関数を作成します。
 メールのファイルの各行を1要素とするベクトル(msg.vsc)を与えて、送信者のアドレスを探してくる処理を書きます。
 

get.from <- function (msg.vec) {
   from <- msg.vec[grepl("From: ", msg.vec)]    # 注1
   from <- strsplit(from, '[":<> ]')[[1]]       # 注2
   from <- from[which(from != "" & from !=" ")]
   return(from[grepl("@", from)][1])            # 注3
}

 
 注1:"From: "を含む行を拾います。もともと検索対象はメールファイルの1行が1要素となったベクトルであり、grepl()で検索すると、その文字列が含まれる要素を「TRUE」とする論理値のベクトルが返ってきます。
 注2:strsplit()関数で、fromを'[ ]'内に書かれた文字で区切って(最後に半角スペースも区切り文字として入ってるので注意!)リストにし、リスト中1個目の要素を取得しています。1個目を指定するのは、この条件でメールのヘッダではなく本文中の行がヒットしているケースもあり得るためですね。
 注3:@を含む要素を取得しますが、メールファイルの形式によっては送信者アドレスが2回書かれている場合(アドレスと表示名)もあるから、1個目を取得するように書いてあります。
 
 
 次に、本文を取得するget.msg()関数を定義します。
 メッセージの各行を1個の要素とするベクトル(msg.vsc)を与えると、本文の部分だけを取得して、ベクトルの1要素にまとめて返します。
 

get.msg <- function (msg.vec) {
   msg <- msg.vec[seq(which(msg.vec == "")[1] + 1, 
                  length(msg.vec), 1)]  # 注1
   return(paste(msg, collapse="\n"))    # 注2
}

 
 注1:最初の空行の1つ下の行から最後までを取得します。メールのファイル形式上、headerとbodyの間に必ず最初の空行が入るからですね。seq()の最後の引数の1は、1行ごとに(つまり全行)という意味です。
 注2:改行をなくして、ベクトルの要素を全部くっつけて1つにする。
 
 
 次にメールの件名を取得するget.subject()関数を書きます。
 メールのファイル中では件名は「Subject: 」で始まるので、それを探します。
 

get.subject <- function(msg.vec) {
   subj <- msg.vec[grepl("Subject: ", msg.vec)]    # 注1
   if(length(subj) > 0) {                          # 注2
      return(strsplit(subj, "Subject: ")[[1]][2])  # 注3
   } else {
      return("")  # 件名がない場合は空で返す
   }
}

 
 注1:"Subject: "(半角スペースに注意)を含む行を取得。
 注2:件名があればという条件。件名がないメールもあるので。
 注3:リストの第1要素に入っているベクトルの第2要素を取得。ベクトルの第1要素は、strsplit()によって空要素になっている。 
 
 
 次に、日時を取得するget.date()関数です。
  

get.date <- function(msg.vec) {
   date.grep <- grepl("^Date: ", msg.vec)        # 注1
   date.grepl <- which(date.grep == TRUE)        # TRUEである要素番号
   date <- msg.vec[date.grepl[1]]                # 1つめの要素の中身
   date <- strsplit(date, "\\+|\\-|: ")[[1]][2]  #注2
   date <- gsub("^\\s+|\\s+$", "", date)         # 注3
   return(strtrim(date, 25))                     # 注4
}

 
 注1:「^Date: 」というのは、先頭が"Date: "(半角スペースに注意)であるという意味の正規表現です。「Date: 」から始まる行を取得しており、ただしgreplなので返り値はTRUE/FALSEという論理値のベクトルになります。
 注2:"+"と"-"と": "(半角スペースに注意)で分割して、リストの1つめの要素であるベクトルの2つ目の要素を取得しています。strsplit()の第一引数にベクトルを与え、第二引数に区切り文字を与えると、ベクトルの各要素内を区切り文字で分割して、元のベクトルの1要素をリストの1要素とし、そこに分割されたテキストをベクトルで格納していきます。ここではもともと要素が1個しかないベクトルを与えてますが、それでも要素が1個のリストになるので、こういう指定をする必要がある。
 注3:これは正規表現で、Rで使える正規表現についてはググればいいのですが、先頭又は最後のスペース(\s+)を探しています。gsub()は置きかえをやっていて、""で置き換えるということは要するに削除するという意味になります。
 注4:25文字分取るとちょうど日時情報になる。
 
 
 次にいよいよ、作った関数でメールのデータを「パース」します。
 

easyham.docs <- dir(easyham.path)  # 当該ディレクトリ内のファイル名一覧を取得
easyham.docs <- easyham.docs[which(easyham.docs != "cmds")]  # cmdsという名のファイルを除く
easyham.parse <- lapply(easyham.docs, # 注1
                        function(p) {
                        parse.email(paste(easyham.path, p, sep=""))
                        }
                        )
ehparse.matrix <- do.call(rbind, easyham.parse)  # 注2
allparse.df <- data.frame(ehparse.matrix, 
                          stringsAsFactors=FALSE)  #データフレームにする
names(allparse.df) <- c("Date", "From.EMail", "Subject", "Message", "Path")  #列名称をつける

 
 注1:これはファイルを1個ずつ取得しparse.emailをそれぞれ適用していくという処理なのですが、この書き方は何度も登場するので注意が必要です。
 apply系の関数はR独特のもので、これを使いこなせるとイケてるらしいのですが、ここではlapply()の第1引数にまずファイル名一覧をベクトルで与えてますね。このベクトルの要素の1つ1つに対し、第2引数の処理を適用していくという意味なのですが、ここでは第2引数に関数定義が埋め込まれています。これは、(p)のところに第1引数の1つ1つの要素が順に当てはめられていくようなイメージです。
 つまり、paste()でファイルのディレクトリのパス(easyham.path)と(p)に当てはめられるファイル名を区切り文字なし(sep="")でくっつけて、これで1つのメールファイルを指定するパスとし、このパスで指定されたメールファイルに対し順次parse.email()を適用し、結果を1個1個リストに格納していきます。
 
 注2:do.call()関数は、第2引数に与えたリストを、第1引数に指定した関数の引数として、その関数を実行します。ここでは、easyham.parseに格納されている、メールファイル1通1通ごとのパース結果(日付、送信者、件名、本文、パスがベクトルとなったものを束ねたリスト)をrbind()の引数に与えており、要するに1通分を1行として行列の形へと合体していってます。
 
 
 次に、テキストとして入っている日時情報を、POSIXの日時データに変換する処理を行います。
 今回のデータでは日付の書式が2通り含まれていて、具体的にみてみると……
 

> allparse.df$Date[573:576]
[1] "12 Sep 2002 12:59:03"      "12 Sep 2002 15:43:00"      "Thu, 12 Sep 2002 16:05:28" "Thu, 12 Sep 2002 22:08:47"

 
 このとおり、曜日が入ってるものと入っていないものがあるので、どっちのパターンで書かれていてもきちんと日時データに変換できるよう書く必要があります。
 
 以下のように処理の関数を書きます。
 

date.converter <- function (dates, pattern1, pattern2){
   pattern1.convert <- strptime(dates, pattern1)  # 文字列を日付型に変える
   pattern2.convert <- strptime(dates, pattern2)
   pattern1.convert[is.na(pattern1.convert)] <- 
   pattern2.convert[is.na(pattern1.convert)]
   return(pattern1.convert)
}

pattern1 <- "%a, %d %b %Y %H:%M:%S"
pattern2 <- "%d %b %Y %H:%M:%S"

allparse.df$Date <- date.converter(allparse.df$Date, pattern1, pattern2)   

 
 これは、allparse.df$Dateの中を、まずpattern1の形式で検索してみて日時データへと型変換します。このとき、pattern2で書かれているテキストは無視されてNAが返ります。その後、pattern1ではヒットせずNAとなった要素番号のものに対しpattern2での型変換を行い、先にpattern1での型変換時に出力されたベクトルのNAの要素を、pattern2の日時データで置き換えていくという処理になります。
 
 参考に、日時のデータをちょっとだけいじってみます。(教科書とは関係ありません。)
 

> # 日付のデータをみてみる
> date <- "Thu, 12 Sep 2002 22:08:47"  # 適当に日時を与える
> class(date) 
[1] "character"
> 
> date <- strptime(date, "%a, %d %b %Y %H:%M:%S")
> print(date)
[1] "2002-09-12 22:08:47 JST"
> 
> class(date)
[1] "POSIXlt" "POSIXt" 
> 
> # 例えば時刻だけ入れると、他の要素はシステム時刻から補われる
> date <- "22:08:47"
> date <- strptime(date, "%H:%M:%S")
> print(date)
[1] "2015-07-05 22:08:47 JST"

 
 
 あと、細かい処理ですが、件名と送信者名を全部小文字にしています。単語を検索するときの便宜のためだと思うのですが、送信者のアドレスを小文字に変換する意味はよく分かりません。
 

allparse.df$Subject <- tolower(allparse.df$Subject) 
allparse.df$From.EMail <- tolower(allparse.df$From.EMail)

 
 
 さてこれで、データの成型が終わりました。
 どういうデータができているかというと、日付、送信者、件名、本文、ファイルのパスを列とし、1通1行とするデータフレームです。本文部分のテキストが長いので表示はやめときますが。
 
 
 分析に入る前に、メールのデータ(全部で2500通分ある)を時系列に並べ、前半を訓練データ、後半をテストデータとして分割します。
 

priority.df <- allparse.df[with(allparse.df, order(Date)),]  # 注1
priority.train <- priority.df[1:(round(nrow(priority.df) / 2)),]  # 注2

 
 注1:with()関数のところは、この書式で、時系列昇順で並び替えた要素番号が返ります。with()というのは、第1引数に与えたデータを「環境」として、その枠内での処理を行うというような使い方をするものなのですが、ここでは並べ替えのために使われています。並べ替えられた行番号が返ってくる(要素ではなく)ので、その順に行を抽出して新しいデータフレームに入れています。
 
 注2:データの前半を訓練用に取り出しています。
 

 さてここからは集計が始まります。メールの優先度を評価していくわけです。
 まずは、送信者の登場回数を数えます。頻繁に登場する送信者からのメールは重要であると評価するわけですね。
 ところで、教科書の以下のコードは作動しませんでした。
 

from.weight <- ddply(priority.train,
                    .(From.EMail),        # この列をキーにグループ化
                    summarise,            #各グループをサマライズ
                    Freq=length(Subject)  # Freqという列を作成して頻度を保存
                    )

 
 サンプルコードの方をみると、代わりにmelt関数が使われておりました。
 

from.weight <- melt(with(priority.train, table(From.EMail)), 
                    value.name="Freq")

 
 この処理は、パースしたメールデータのうち送信者のアドレスが入っているFrom.EMail列をみて、アドレスごとにその登場回数をtable()関数で数え、melt()関数(reshape2パッケージに入っている)でタテヨコを変換したような形にし、登場回数の数値には「Freq」という列名称をつけています。
 with(priority.train, table(From.EMail))
 のところは、
 table(priority.train$From.EMail)
 と同じ意味ですね(たぶん)。
 with()関数の第1引数にデータフレームを入れると、そのデータフレームが「環境」となるので、列名称を指定するときにいちいち「priority.train$」をつける必要はなくなります。
 table()関数は、各値の登場回数を数えてくれるもので、melt()関数はテーブルの列名称を値に入れるような変形を行ったりするもので、ここではデータのタテヨコを変えるために使われています(たぶん)。
 
 こう書いて動きがおくと分かりやすいと思います。
 

> d1 <- table(priority.train$From.EMail)
> head(d1)

      abbo@impression.nu        adam@homeport.org admin@networksonline.com     admin@shellworld.net 
                       1                        1                        1                        1 
          admin@wexoe.dk    aeriksson@fastmail.fm 
                       1                        6 
> d2 <- melt(d1, value.name="Freq")
> head(d2)
                      Var1 Freq
1       abbo@impression.nu    1
2        adam@homeport.org    1
3 admin@networksonline.com    1
4     admin@shellworld.net    1
5           admin@wexoe.dk    1
6    aeriksson@fastmail.fm    6
> 

 
 reshape2やmeltについてはいろいろ解説があるが、今回の使用目的からすれば以下の解説程度でもいいんじゃないでしょうか。。
 http://simudaru.hatenablog.com/entry/2014/06/22/181113
 
 table()関数は、個数を数えてくれるやつとおぼえておけばいいと思います。
 

> t <- c(1, 1, 2, 3, 3, 3, 3, 4, 4, 5, 5, 5, 5, 5)
> table(t)
t
1 2 3 4 5 
2 1 4 2 5 

 
 
 送信者一覧を多い順に並べ替えておきます。
 

from.weight <- from.weight[with(from.weight, order(Freq)), ] 

 
 
 送信者の頻度をヒストグラムにして確認します。
 

from.ex <- subset(from.weight, Freq > 6)  # 頻度7回以上の送信者だけ抽出

from.scales <- ggplot(from.ex) +
  geom_rect(aes(xmin = 1:nrow(from.ex) - 0.5,
                xmax = 1:nrow(from.ex) + 0.5,
                ymin = 0,
                ymax = Freq,
                fill = "lightgrey",
                color = "darkblue")) +
  scale_x_continuous(breaks = 1:nrow(from.ex), labels = from.ex$From.EMail) +
  coord_flip() +
  scale_fill_manual(values = c("lightgrey" = "lightgrey"), guide = "none") +
  scale_color_manual(values = c("darkblue" = "darkblue"), guide = "none") +
  ylab("Number of Emails Received (truncated at 6)") +
  xlab("Sender Address") +
  theme_bw() +
  theme(axis.text.y = element_text(size = 5, hjust = 1))
ggsave(plot = from.scales,
       filename = file.path("images", "0011_from_scales.pdf"),
       height = 4.8,
       width = 7)

 
 f:id:midnightseminar:20150710130921p:plain
 
 これをみると、頻度の差がけっこう激しくて、指数関数的な分布になっています。これをこのまま「送信者の優先度」として評価してしまうと送信者重要度の影響が非常に大きくなってしまうので、著者は頻度をメールの優先度指標にする上で対数化すべきだとしています
 この後、そういう理由での対数化を何箇所かでやっているのですが、底を何にするかについては明確な基準はなく、いい感じになだらかになるように感覚で決めている感じでした。
 

from.weight <- transform(from.weight,
                         Weight = log(Freq + 1),
                         log10Weight = log10(Freq + 1))

 
 1を足しているのは、頻度が1のものを対数化するとゼロになってしまい、ゼロという重みは(最後に掛け算で総合的な重みを出す上で)不都合なので、ゼロにしないための工夫ですね。
 
 これもグラフにします。
 

from.rescaled <- ggplot(from.weight, aes(x = 1:nrow(from.weight))) +
  geom_line(aes(y = Weight, linetype = "ln")) +
  geom_line(aes(y = log10Weight, linetype = "log10")) +
  geom_line(aes(y = Freq, linetype = "Absolute")) +
  scale_linetype_manual(values = c("ln" = 1,
                                   "log10" = 2,
                                   "Absolute" = 3),
                        name = "Scaling") +
  xlab("") +
  ylab("Number of emails Receieved") +
  theme_bw() +
  theme(axis.text.y = element_blank(), axis.text.x = element_blank())
ggsave(plot = from.rescaled,
       filename = file.path("images", "0012_from_rescaled.pdf"),
       height = 4.8,
       width = 7)

 
 f:id:midnightseminar:20150710130945p:plain
 
 absoluteが頻度の絶対値で、lnは自然対数をとったもの、l10は常用対数をとったものです。
 なだらかになってますね。
 
 
 さてここからは、連続したやりとりになっているメール群を「スレッド」として認識させ、スレッドの活発さ等を指標化していく処理を行います。
  

# スレッドを見つける関数
find.threads <- function(email.df) {
   response.threads <- strsplit(email.df$Subject, "re: ")  # 注1
   is.thread <- sapply(response.threads, 
                       function(subj) ifelse(subj[1]=="", TRUE, FALSE))  #注2
   threads <- response.threads[is.thread]  # TRUEのものを抜き出す
   senders <- email.df$From.EMail[is.thread]  #スレッドメールの送信者を取得
   threads <- sapply(threads, 
                     function(t) paste(t[2:length(t)], collapse="re: ")
                    )               # 注3
   return(cbind(senders, threads))  # 注4
}

threads.matrix <- find.threads(priority.train)  # スレッドを表にまとめる

 注1:前にも出てきましたが、strsplit()を使うと、"re: "で文字列を分割し、リストで返します。今回の場合、Subject列をベクトルとして与えているので、ベクトル全体がリストになり、リストの1要素目には、元のベクトルの1要素目を分割したものがベクトルとして入ります。
 注2:第1要素が空白であるものが、"Re: "で始まる件名です。strsplit()は、区切り文字が存在した場合はその区切り文字の箇所を空白の要素として返すので。逆に、区切り文字が存在しない場合は分割されないので、1要素目に件名がまるごと入っていることになります。
 注3:件名中"re: "より後のテキストを取得する。
 注4:sendersはスレッドの始点になるメールを送ってきた送信者。threadsは「re:」を外した件名。
 
 教科書では、

最初の要素が空であるような文字ベクトルを探すことで、スレッドの開始点を見つけることができる。訓練データ中のどの観測がスレッドの先頭であるかを認識したら、これらのスレッドと件名から送信者を抽出することができる。

 とか書かれているのですが、これはべつにスレッドの開始点となる1通目のメールを見つけているのではなくて、単にスレッド内メールを探しているだけの処理のはずです。英語原文を読んでないのですが、たんにスレッドとなる件名の先頭のRe: のことを言ってるんじゃないですかね。
 この処理では、"hogehoge"という件名のメールが送られてきて、それに自分が"Re: hogehoge"という件名で返信して、そのまた返信も"Re: hogehoge"だった場合、最初の"hogehoge"のメールは捕捉されていないと思います。
 
 なお参考に、strsplit()の動きの例です。
 

> vec.test <-c("Re: あいうえお", "かきくけこ", "Fw: Re: さしすせそ")
> split <- strsplit(vec.test, "Re: ")
> print(split)
[[1]]
[1] ""           "あいうえお"

[[2]]
[1] "かきくけこ"

[[3]]
[1] "Fw: "       "さしすせそ"

 
 
 さて、スレッドの表を確認します。
 

> # 確認します
> head(threads.matrix)
     senders                  threads                                   
[1,] "lance_tt@bellsouth.net" "please help a newbie compile mplayer :-)"
[2,] "robinderbains@shaw.ca"  "please help a newbie compile mplayer :-)"
[3,] "matthias@egwn.net"      "please help a newbie compile mplayer :-)"
[4,] "bfrench@ematic.com"     "prob. w/ install/uninstall"              
[5,] "marmot-linux@shaw.ca"   "prob. w/ install/uninstall"              
[6,] "matthias@egwn.net"      "http://apt.nixia.no/"                    
> nrow(threads.matrix)
[1] 755

 
 
 次に、スレッドメールのやり取り回数によって、送信者を評価するemail.thread()関数をつくります。
 

email.thread <- function(threads.matrix) {
   senders <- threads.matrix[, 1]  # スレッドメールを送ってきた送信者
   senders.freq <- table(senders)  # スレッドメールを送ってきた回数を集計
   senders.matrix <- cbind(names(senders.freq), 
                           senders.freq, 
                           log(senders.freq + 1)  #注1
                           )
   senders.df <- data.frame(senders.matrix, stringsAsFactors=FALSE)
   row.names(senders.df) <- 1:nrow(senders.df)
   names(senders.df) <- c("From.EMail", "Freq", "Weight")
   senders.df$Freq <- as.numeric(senders.df$Freq)      #型変換
   senders.df$Weight <- as.numeric(senders.df$Weight)  #型変換
   return(senders.df) 
}


 注1:スレッドメールを何回送ってきたかを対数化で補正したもので、これを"Weight"、つまり重要度の評価値としていますね。評価値を対数化する理由はすでに述べたのと同じです。
 
 
 内容を確認します。
 

> head(senders.df)
                    From.EMail Freq    Weight
1            adam@homeport.org    1 0.6931472
2        aeriksson@fastmail.fm    5 1.7917595
3 albert.white@ireland.sun.com    1 0.6931472
4          alex@netwindows.org    1 0.6931472
5                andr@sandy.ru    1 0.6931472
6             andris@aernet.ru    1 0.6931472

 
 
 次にスレッドメールの件名ごとに、件名、回数、期間、密度を返すget.threads()関数を作ります。密度というのは、1定時間内に何回やりとりがあったかという指標です。
 

get.threads <- function(threads.matrix, email.df) {
   threads <- unique(threads.matrix[, 2])  # スレッド件名一覧
   thread.counts <- lapply(threads, 
                           function(t) thread.counts(t, email.df)
                           )  # 注1
   thread.matrix <- do.call(rbind, thread.counts)  # 注2
   return(cbind(threads, thread.matrix))
}

 
 注1:thread.counts関数は後で定義されます(ここも教科書は逆順で書かれているので)。件名をベースに、その出現回数を数えています。
 注2:ここでのdo.call()は、リストの1個1個の要素にrbindを適用しています。
 
 
 上記関数の前提となる、スレッドを数える関数です(こっちを先に書けよと)。
 

thread.counts <- function (thread, email.df) {
   thread.times <- email.df$Date[which(email.df$Subject == thread
                    | email.df$Subject == paste("re:", thread))]  # 注1
   freq <- length(thread.times)   # 当該スレッドの長さ(やりとり回数)
   min.time <- min(thread.times)  #スレッドの最初の日時
   max.time <- max(thread.times)  #スレッドの最後の日時
   time.span <- as.numeric(difftime(max.time, min.time, units="secs"))  # 注2
   if(freq < 2) {
      return(c(NA, NA, NA))  # 注3
   } else {
      trans.weight <- freq / time.span  # 時間あたりのやりとり数
      log.trans.weight <- 10 + log(trans.weight, base=10)  # 対数で補正
      return(c(freq, time.span, log.trans.weight))
   }
}

 
 注1:スレッドの始点又はスレッド中のメールを探し、その日時を取得。"re:"の後にスペースを入れていないのは、paste()関数がsep=という引数を設定しない場合は半角スペースを自動的に挟むからですね。だからべつに、スペースを入れた上で、paste("re: ", thread, sep="")としてもよい。
 注2:スレッドの始まり~終わりの間隔を秒単位で取得。
 注3:もともと「Re: 」で始まるメールを探すことから始まったわけですが、今回の訓練データ中のメールファイルはある期間に限ったもので、「Re: 」から始まっていたとしても、その前のメールや後のメールが訓練データ期間外だった場合、1通しか登場しないので、そういうものを除いている。期間あたりの通数を数えることができないので。
 
 
 スレッド関係の集計結果をまとめ、成型します。
 データフレームに変換し、列の名称を付け、型を実数に変換して、NAじゃないものを抽出しています。
 なおNAのものというのは、先ほど定義したとおり、"Re: "がついてるけど1回しか登場しないデータです。
 

thread.weights <- get.threads(threads.matrix, priority.train)
thread.weights <- data.frame(thread.weights, stringsAsFactors=FALSE)
names(thread.weights) <- c("Thread", "Freq", "Response", "Weight")
thread.weights$Freq <- as.numeric(thread.weights$Freq)
thread.weights$Response <- as.numeric(thread.weights$Response)
thread.weights$Weight <- as.numeric(thread.weights$Weight)
thread.weights <- subset(thread.weights, is.na(thread.weights$Freq) == FALSE)  # NAじゃないものを抽出

head(thread.weights)

 
 
 内容を確認します。
 

> head(thread.weights)
                                      Thread Freq Response   Weight
1   please help a newbie compile mplayer :-)    4    42309 5.975627
2                 prob. w/ install/uninstall    4    23745 6.226488
3                       http://apt.nixia.no/   10   265303 5.576258
4         problems with 'apt-get -f install'    3    55960 5.729244
5                   problems with apt update    2     6347 6.498461
6 about apt, kernel updates and dist-upgrade    5   240238 5.318328

 
 教科書と同じ値になっています!
 
 
 次に、単語の出現回数を数えるterm.counts()関数を定義します。あとでこの関数を、件名に対して使ったり本文に対して使ったりします。
 テキストが入っているベクトルと、検索オプション(controlのところに、stopwordはどうするとかの引数を後で入れます)を与えると、TDM(列がドキュメント、行が単語となり、ドキュメントごとに当該単語が登場した回数を値とする表)の作成を経て、単語別にその登場回数を集計した表が出来上がります。
 

term.counts <- function(term.vec, control) {
   vec.corpus <- Corpus(VectorSource(term.vec))  # コーパスを作る関数
   vec.tdm <- TermDocumentMatrix(vec.corpus,  # TDMをつくる関数
                                 control=control)  # 検索オプションを入れる
   return(rowSums(as.matrix(vec.tdm)))  # ヨコ方向に足す
}

 
 
 上記関数を用いて、スレッド件名中の単語を数えます。
 

thread.terms <- term.counts(thread.weights$Thread,  # 件名取得
   control=list(stopwords=TRUE)      # 要素名が単語、要素は登場回数
thread.terms <- names(thread.terms)  # 要素に単語を入れる

head(thread.terms)

 
 stopwordsのところは、教科書では「stopwords=stopwords()」と書いてあって、これでも動きましたが、サンプルコードでは「stopwords=TRUE」であり、こっちのほうが普通のようです。
 stopwordsというのは、aとかtheとかみたいに、一般的過ぎてテキストの特徴付けに使うのは不適切なので処理対象から除かれる語で、単にTRUEとしておくと、デフォルトでセットされている単語群が除かれます。
 controlに与えられるオプションの一覧をみるには、help(termFreq)をみてみるといいです。
 
 
  単語ごとに、それが件名に登場するスレッドの重み(密度)を見ていって、複数のスレッドに出てくる語もあるので、重みの平均値を求めます。
 

term.weights <- sapply(thread.terms, 
   function(t) mean(thread.weights$Weight[grepl(t,
                    thread.weights$Thread, fixed=TRUE)]))

 
 grepl()のところのfixed=TRUEって引数は、たぶん、正規表現を使わずに、指定した文字列と全く同じものを拾ってくるという意味だと思います。
 
 次にこの、スレッド件名で使用されている単語とその重みの一覧をデータフレームにします。
 行の名称は番号にしています。
 

term.weights <- data.frame(list(Term=names(term.weights), Weight=term.weights), stringsAsFactors=FALSE, row.names=1:length(term.weights))

 
 内容確認。
 

> head(term.weights)
      Term   Weight
1   --with 7.109579
2      :-) 6.103883
3      ... 6.050786
4     .doc 5.725911
5 'apt-get 5.729244
6 "holiday 7.197911

 
 
 次に、メール本文を同様にみていって、出現頻度の高い単語に重みを与えていきます。
 本文の単語出現をカウントします。
 

msg.terms <- term.counts(priority.train$Message, 
   control=list(stopwords=TRUE,          #ストップワードを有効にする
                removePunctuation=TRUE,  #句読点を除く
                removeNumbers=TRUE))     #数字を除く

 
 control=のところは、さっきスレッド件名を検索したときよりも、引数のリスト要素が増えています。意味はそのまんまですね。
 
 本文の単語名と出現回数(対数化したもの)をデータフレームとして一覧にします。対数化する理由はこれまでと同じですが、底が10となっているのは単に、著者の感覚のようです。
 

msg.weights <- data.frame(
   list(Term=names(msg.terms), 
        Weight=log(msg.terms, base=10)),  # 常用対数でならす
   stringsAsFactors=FALSE, 
   row.names=1:length(msg.terms))

msg.weights <- subset(msg.weights, Weight > 0)  # 重みが正のもののみ抽出

 
 
 次にちょっとややこしいですが、単語を与え、単語なのかスレッド件名なのかを指定すると、重み一覧表を参照して、単語なら単語の重み、スレッド件名ならそのスレッド件名の重み(密度として定義されたもの)を取得してくる関数を書きます。
 見つからない場合は1を返します。1を返す理由は既述のものと同じで、要するに最後に重みを掛け算で求めるときに「無影響」とするためですね。
 

get.weights <- function(search.term, weight.df, term=TRUE) {
   if(length(search.term) > 0) {  #検索語に1文字以上の文字列が指定されていれば
      if(term) {  #term=TRUEだったら
      term.match <- match(names(search.term), weight.df$Term)  # 単語一覧を検索
      } else {  #termがFALSEだったら
      term.match <- match(search.term, weight.df$Thread)  # スレッド件名を検索
      }
      match.weights <- weight.df$Weight[which(!is.na(term.match))]  # 注1
      if(length(match.weights) < 1) {  # ヒット件数がゼロだったら
         return(1)  # 1を返す
      } else {  # 1回以上ヒットしているなら
      return(mean(match.weights))  # 平均を取って重みとする
   }
   } else {  # 検索語が0文字の場合は、
   return(1)  # 1を返す(エラー扱い。重みに影響を与えないよう。)
   }
}

 
注1:その重みを取得している。!is.na()というのは、naでないものという意味。
 
 
 次に、これまたややこしいですが、メールのメッセージにランク(優先度の指標)を付ける関数を書きます。
 

rank.message <- function(path) {
   msg <- parse.email(path)  # メールファイルを指定してパースする

   # まずは送信者の優先度を決める。(注1)
   from <- ifelse (
           length(which(from.weight$From.EMail == msg[2])) > 0, 
           from.weight$Weight[which(from.weight$From.EMail == msg[2])], 1
           )

   # スレッドメールの送信者一覧に対して同じことをやります。
   thread.from <- ifelse(
                  length(which(senders.df$From.EMail == msg[2])) > 0, 
                  senders.df$Weight[which(senders.df$From.EMail == msg[2])], 1
                  )

   # スレッドメールの優先度を取得する。(注2)
   subj <- strsplit(tolower(msg[3]), "re: ")
   is.thread <- ifelse(subj[[1]][1] == "", TRUE, FALSE)  # 最初の要素が空だったらTRUE
   if(is.thread) {  # スレッドだった場合・・・
      activity <- get.weights(subj[[1]][2], 
                              thread.weights, 
                              term=FALSE)  # 件名で検索してスレッド優先度(密度)を取得
   } else { # スレッドじゃなかった場合・・・
   activity <- 1  # 1を返す
   }

   # 次にスレッド中の単語に基づく優先度を出す。(注3)
   thread.terms <- term.counts(msg[3], control=list(stopwords=TRUE))
   thread.terms.weights <- get.weights(thread.terms, term.weights)
   
   # 本文の単語に基づく優先度を出す。(注4)
   msg.terms <- term.counts(msg[4], control=list(stopwords=TRUE, 
      removePunctuation=TRUE, removeNumbers=TRUE))
      msg.weights <- get.weights(msg.terms, msg.weights)
      
   # 上記様々な優先度をかけ合わせて、メールの優先度を計算する。
   rank <- prod(from, thread.from, activity, thread.terms.weights, msg.weights)
   return(c(msg[1], msg[2], msg[3], rank))  # 日付、送信者、件名、優先度を返す
}

 注1:from.weight表にもとづいて送信者を検索し、1個もヒットしなかったら1を返し、ヒットした場合は優先度を取得して"from"に格納する。
 注2:件名をみていって、まずRe:によってスレッドかどうかを判断し、スレッドだった場合はその件名でスレッド優先度一覧表を検索し、優先度を取得してthread.fromに格納する。なお先ほどと同じく、件名の先頭が"Re: "だった場合はstrsplitによって出力されるリストの第1要素が空になるので、空であるものを探している。
 注3:件名に含まれる単語を調べて、それがもしスレッドメールの件名にも登場したものだった場合、その優先度(の平均)を取得する。スレッドメールの件名に使われたことない単語であれば1が返る。
 注4:今度は本文に含まれる単語1個1個について、本文単語重みの表をみて単語の優先度(の平均)を取得する。表に載ってない単語については1が返る。
 
 
 ためしに1通だけランク(優先度)を算出してみます。
 

> rank.message(paste(easyham.path, dir(easyham.path)[1], sep=""))
[1] "Thu, 22 Aug 2002 18:26:25" "kre@munnari.OZ.AU"         "Re: New Sequences Window" 
[4] "27.6329742461707"  

 
 ちゃんとランクが算出されてるっぽい雰囲気ですね!
 
 
 ではここから全体のデータを処理していきます。
 まず、データを訓練用とテスト用にわけます。(訓練用メールのパス一覧と、テスト用メールのパス一覧を生成。)
 

train.paths <- priority.df$Path[1:(round(nrow(priority.df) / 2))]  # 前半を訓練用に
test.paths <- priority.df$Path[((round(nrow(priority.df) / 2)) +1):nrow(priority.df)]  # 後半をテスト用に

 
 優先度を算出し、表を成型します。

train.ranks <- lapply(train.paths, rank.message)  # 注1
train.ranks.matrix <- do.call(rbind, train.ranks)  # 行列にまとめる
train.ranks.matrix <- cbind(train.paths, 
                            train.ranks.matrix, 
                            "TRAINING")  # パスとデータ分類を追記
train.ranks.df <- data.frame(train.ranks.matrix, 
                             stringsAsFactors=FALSE)  # データフレームにする
names(train.ranks.df) <- c("Message", "Date", "From", "Subj", "Rank", "Type")  #列名称をつける
train.ranks.df$Rank <- as.numeric(train.ranks.df$Rank)  # 型を変換する
priority.threshold <- median(train.ranks.df$Rank)  # 優先度の閾値として中央値を入力
train.ranks.df$Priority <- ifelse(train.ranks.df$Rank >= priority.threshold, 1, 0)  # 閾値により優先メール・非優先メールにわけ、1・0でマーク

 
 注1:パスを与えたメール全てに対してrank(優先度)を算出します。サンプルコードではsuppressWarnings()でラップして警告を出さないようにしていますが、そうしなくても警告は出なかったです。なおここでの出力はリストになっていて、リストの1要素が1メッセージになっており、それぞれベクトルとして日時、送信者、件名、ランクが格納されているわけです。これを、次の行のdo.call()で一つの行列にまとめます。
 
 
 では結果をみてみましょう。
 

> head(train.ranks.df)
                                               Message                      Date                   From
1 data/easy_ham/01061.6610124afa2a5844d41951439d1c1068 Thu, 31 Jan 2002 22:44:14  robinderbains@shaw.ca
2 data/easy_ham/01062.ef7955b391f9b161f3f2106c8cda5edb      01 Feb 2002 00:53:41 lance_tt@bellsouth.net
3 data/easy_ham/01063.ad3449bd2890a29828ac3978ca8c02ab Fri, 01 Feb 2002 02:01:44  robinderbains@shaw.ca
4 data/easy_ham/01064.9f4fc60b4e27bba3561e322c82d5f7ff  Fri, 1 Feb 2002 10:29:23      matthias@egwn.net
5 data/easy_ham/01070.6e34c1053a1840779780a315fb083057  Fri, 1 Feb 2002 12:42:02     bfrench@ematic.com
6 data/easy_ham/01072.81ed44b31e111f9c1e47e53f4dfbefe3  Fri, 1 Feb 2002 13:39:31     bfrench@ematic.com
                                          Subj       Rank     Type Priority
1     Please help a newbie compile mplayer :-)   3.614003 TRAINING        0
2 Re: Please help a newbie compile mplayer :-) 142.873934 TRAINING        1
3 Re: Please help a newbie compile mplayer :-)  20.348502 TRAINING        0
4 Re: Please help a newbie compile mplayer :-) 277.406565 TRAINING        1
5                   Prob. w/ install/uninstall   3.653047 TRAINING        0
6               RE: Prob. w/ install/uninstall  21.685750 TRAINING        0

 
 それっぽく分類されてますね!
 右端の列が1になっているやつが「重要なメール」です。
 
 
 教科書にはコードが載っておらずサンプルコードに載ってるんですが、優先度の閾値がメールデータ全体の分布のなかでどの辺にあるのかをグラフ化して見てみます。
 

threshold.plot <- ggplot(train.ranks.df, aes(x = Rank)) +
  stat_density(aes(fill="darkred")) +
  geom_vline(xintercept = priority.threshold, linetype = 2) +
  scale_fill_manual(values = c("darkred" = "darkred"), guide = "none") +
  theme_bw()
ggsave(plot = threshold.plot,
       filename = file.path("images", "01_threshold_plot.pdf"),
       height = 4.7,
       width = 7)

 
f:id:midnightseminar:20150710131100p:plain
 
 stat_densityというのはggplot2で密度曲線を書くときに使うやつで、geom_densityというのもあります。Rank(優先度)は連続値なので、曲線にしてもらうということですね。使い方は下記のマニュアルを参照。
 http://docs.ggplot2.org/current/stat_density.html
 
 教科書の解説では、閾値が分布のテール部分ではなく、それなりにボリュームがある部分にかぶっているので、概ね望ましい結果になっているとされています。
 
 
 ここから先は、教科書にはコードが載っていないものです。
 
 テストデータに対して同じ処理をします。
 

test.ranks <- lapply(test.paths,rank.message) 
test.ranks.matrix <- do.call(rbind, test.ranks)
test.ranks.matrix <- cbind(test.paths, test.ranks.matrix, "TESTING")
test.ranks.df <- data.frame(test.ranks.matrix, stringsAsFactors = FALSE)
names(test.ranks.df) <- c("Message","Date","From","Subj","Rank","Type")
test.ranks.df$Rank <- as.numeric(test.ranks.df$Rank)
test.ranks.df$Priority <- ifelse(test.ranks.df$Rank >= priority.threshold, 1, 0)
# 閾値は訓練データから算出された閾値を用いている。

head(test.ranks.df)

 
 
 訓練データとテストデータを合体させ(タテにつなげる)、日時をテキストから日時オブジェクトに変換し、全体を時系列順に並べます。 
 

final.df <- rbind(train.ranks.df, test.ranks.df)  #タテにつなげる
final.df$Date <- date.converter(final.df$Date, pattern1, pattern2)  # 日時形式の統一
final.df <- final.df[rev(with(final.df, order(Date))), ]  # 時系列に並べる

 
 みてみます。
 

head(final.df)

                                                  Message                Date                    From
2500 data/easy_ham/00883.c44a035e7589e83076b7f1fed8fa97d5 2028-10-04 12:05:01             sdw@lig.net
2499 data/easy_ham/02500.05b3496ce7bca306bed0805425ec8621 2002-12-04 11:54:45 ilug_gmc@fiachra.ucd.ie
2498 data/easy_ham/02499.b4af165650f138b10f9941f6cc5bce3c 2002-12-04 11:49:23          mwh@python.net
2497 data/easy_ham/02498.09835f512f156da210efb99fcc523e21 2002-12-04 11:48:43            nickm@go2.ie
2496 data/easy_ham/02497.60497db0a06c2132ec2374b2898084d3 2002-12-04 11:44:21       phil@techworks.ie
2495 data/easy_ham/02496.aae0c81581895acfe65323f344340856 2002-12-04 11:41:52           timc@2ubh.com
                                                      Subj      Rank    Type Priority
2500                                       Re: ActiveBuddy  3.511512 TESTING        0
2499                              Re: [ILUG] Linux Install  2.278890 TESTING        0
2498 [Spambayes] Re: New Application of SpamBayesian tech?  4.265954 TESTING        0
2497                              Re: [ILUG] Linux Install  4.576643 TESTING        0
2496                              Re: [ILUG] Linux Install  5.268866 TESTING        0
2495                          [zzzzteana] Surfing the tube 22.290684 TESTING        0

 
 なんか2028年になっているメールがありますがw
 実際、元のメールデータの883番を確認したら、2028年のメールになってました。
 
 
 サンプルコードでは、結果をCSVで保存していました。保存する必要は別にない気もしますが。
 

write.csv(final.df, file.path("data", "final_df.csv"), row.names = FALSE)

 
 パスをこの書き方にする必要はべつになく、普通に"data/final_df.csv"でもいいと思います。
 
 最後にggplot2で、訓練データとテストデータの両方の分布(密度曲線)を重ねてみます。
 

testing.plot <- ggplot(subset(final.df, Type == "TRAINING"), aes(x = Rank)) +
  stat_density(aes(fill = Type, alpha = 0.65)) +
  stat_density(data = subset(final.df, Type == "TESTING"),
               aes(fill = Type, alpha = 0.65)) +
  geom_vline(xintercept = priority.threshold, linetype = 2) +
  scale_alpha(guide = "none") +
  scale_fill_manual(values = c("TRAINING" = "darkred", "TESTING" = "darkblue")) +
  theme_bw()
ggsave(plot = testing.plot,
       filename = file.path("images", "02_testing_plot.pdf"),
       height = 4.7,
       width = 7)

 
f:id:midnightseminar:20150710131125p:plain
 
グラフを見ると、テストデータは「非優先」判定されているものが非常に多いということが分かりますね。これは訓練データに含まれなかった単語とか送信者がたくさんでてきて、全体的に非優先のほうに偏ったんだと思います。まぁ、学習データが足らんということなんでしょうかね。
 
 以上で本章のコード解説終了です。
 
 

今回学んだコーディングのヒント

  • lapplyが何度も出てくるので慣れる。その中に関数定義を埋め込むことで連続処理をするっていうやり方も慣れる。
  • with()関数をよくわかったなかったのですが、今回調べて少しわかりました。
  • grepと正規表現をつかって文字列を検索するっていうのは、基本スキルとして大事ですね。
  • 日付と時刻のデータの扱いに少し触れることができた。まだ詳しくは理解していないのですが、時差の扱いとか、実行されているシステムから取得される情報とか、動きが少しわかってきた。
  • plyrとかreshape2とかは、データの成形に使うパッケージとして使い方を学んでおく必要ありますね。最近はdplyrという新しいパッケージを使う場合が多いようですが。

*1:「このメールは実際に受信者によって重要であると判別された」という、答えになるデータが訓練データ中にないということ。Gmailのタグ付けみたいな情報があれば教師データになるんでしょうけど。

*2:Rで習熟すべきなのか、Pythonでやれよという話なのかとかはおいといて。

*3:私の個人的な問題だと思うので本文には書かなかったけど、こないだRのバージョンを3.2.1に上げたせいなのか何なのか、ggplot2の依存パッケージが自動的にインストールされていなくて、ggplot2を呼び出す前に自分で、gtable、proto、scales、munsellといったパッケージをインストールしなければなりませんでした。また、ggplot2だけでなくtm、plyr、reshape2といったパッケージも必要なのですが、install.packages()ではインストールできなくて、R GUIのInstall Managareからインストールしました。なんでだろ??

*4:こういうのをサンプルコードと呼ぶのが適切かどうかは知りませんが、他のサイトでサンプルコードと言っている人がいたので真似ています。

*5:ggplot2を呼び出す時に依存パッケージとして呼び出されるはずですが、後述のとおり、私の場合なぜか依存パッケージが上手く自動で呼ばれなかったので、自分でやりました。

*6:aやtheなど一般的過ぎて集計してもテキストの特徴付けには付けない単語。

*7:メールのデータ自体はさらに、easy_ham、easy_ham_2、hard_hamなどといったディレクトリに分かれています。