RubyとRでRandomForest

この記事は[アクトインディ Advent Calendar 2015](http://qiita.com/advent-calendar/2015/actindi) 2日目になります。
どうぞよろしくお願いします。

## 要旨

前回、1日目ではSolrを使ったお手軽苦情分類器を作りました。お手軽すぎて精度がよくありませんでしたので、次は文書分類ではよく使われているRandomForestを使って分類してみます。
RandomForestでは定評通り高めの精度が得られました。

使用しているデータは実際のものとは異なります。


## rubyで前処理

今回はrubyをつかって文書からBagOfWords(BoW)を作ったあと、RでRandomForestを構築します。rubyを使っているのはRでテキスト処理を書きたくなかったためです。

直接データベースよりテキストを読み込みます。
口コミのratingがlowのものを苦情、hightのものを通常口コミというふうにトレーニングデータに使用します。各500件、計1000件のデータからBoWを作ります。

事前準備として、口コミ文書から名詞を抜き出して、名詞辞書と頻度によるBoWベクトルを作成します。
その後、名詞の出現頻度で足切りしてBoWベクトルの次元数を減らします。次元数が多すぎると計算時間がどんどん伸びてしまい使い物になりません。何度か試した結果、今回は出現回数5回以下、80%の文章にでてくるものをカットしています(Cutoff: Upper 800, Lower 5)
調整したものを辞書とベクトルごとにファイルに出力します。

辞書ができた後は、テストケースとして追加でデータを読み込んで、そこから単語の頻度ベクトルをファイルに出力します。

次にこれらをRから読み込んで分類します。

```rb
require 'MeCab'
require 'unicode'
require 'mysql2'

class BagOfWords
  attr_reader :dict

  def initialize(word_list_list, cutoff_upper: 0.5, cutoff_lower: 5)
    @dict = make_dict(word_list_list, cutoff_upper, cutoff_lower)
  end

  def doc_to_vector(words)
    bow_to_vector(doc_to_bow(words))
  end

  # return [[id, freq], ...]
  def doc_to_bow(words)
    bow = {}
    freq = Hash.new {|h, k| h[k] = 0}
    words.each do |word|
      normalized = normalize(word)
      freq[normalized] += 1
    end

    bow = freq.map do |word, f|
      id = @dict[word]
      unless id
        next
      end
      [id, f]
    end
  end

  def bow_to_vector(bow)
    v = Array.new(@dict.size + 1, 0.0)
    bow.each do |b|
      next unless b
      v[b[0]] = b[1].to_f
    end
    v
  end

  def save_dict(file)
    @dict.each do |word, id|
      file.puts("#{id}\t#{word}\t#{@freq[word]}");
    end
  end

  private

  def make_dict(word_list_list, cutoff_upper, cutoff_lower)
    freq = Hash.new {|h, k| h[k] = 0}
    word_list_list.each do |word_list|
      word_list.each do |word|
        normalized = normalize(word)
        freq[normalized] += 1
      end
    end
    bag = {}
    id = 0
    upper = (word_list_list.size * cutoff_upper).to_i
    puts "Cutoff: Upper #{upper}, Lower #{cutoff_lower}"
    freq.each do |word, fq|
      next if fq < cutoff_lower
      next if fq > upper
      next if bag.has_key?(word)
      bag[word] = id
      id += 1
    end

    @freq = freq
    bag
  end

  def normalize(word)
    Unicode::nfkc(word).upcase
  end
end

@tagger = MeCab::Tagger.new

def text_to_words(text)
  doc = []
  node = @tagger.parseToNode(text)
  until node.next.feature.include?("BOS/EOS")
    node = node.next
    doc << node.surface.force_encoding("UTF-8") if node.feature.force_encoding('UTF-8') =~ /名詞/
  end
  doc
end

cutoff_upper = ARGV[0].to_f * 0.1
cutoff_lower = ARGV[1].to_i
p cutoff_upper
p cutoff_lower

NUM_CLASS_1 = 500
NUM_CLASS_2 = 500

db = Mysql2::Client.new(host: 'localhost', username: 'root', database: 'test')

docs = []
class1 = []
db.query("select body from kuchikomi where rating = 'high' order by id limit #{NUM_CLASS_1}").each do |row|
  doc = text_to_words(row['body'])
  unless doc.empty?
    docs << doc
    class1 << doc
  end
end

class2 = []
db.query("select body from kuchikomi where rating = 'low' order by id limit #{NUM_CLASS_2}").each do |row|
  doc = text_to_words(row['body'])
  unless doc.empty?
    docs << doc
    class2 << doc
  end
end

bow = BagOfWords.new(docs, cutoff_upper: cutoff_upper, cutoff_lower: cutoff_lower)

File.open('ml/dict.txt', 'w') do |f|
  bow.save_dict(f)
end

File.open('ml/vector.txt', 'w') do |f|
  f.print((0..bow.dict.size).map {|n| "i#{n}" }.join("\t"))
  f.puts("\tcv")
  class1.each do |c|
    vec = bow.doc_to_vector(c)
    vec << 'normal'
    f.puts(vec.join("\t"))
  end

  class2.each do |c|
    vec = bow.doc_to_vector(c)
    vec << 'complaint'
    f.puts(vec.join("\t"))
  end
end

# test case
File.open('ml/test_1.txt', 'w') do |f|
  f.print((0..bow.dict.size).map {|n| "i#{n}" }.join("\t"))
  f.puts("\tid")
  db.query("select id, body from kuchikomi where rating = 'high' order by id limit 100 offset #{NUM_CLASS_1+1}").each do |row|
    doc = text_to_words(row['body'])
    vec = bow.doc_to_vector(doc)
    vec << row['id']
    f.puts(vec.join("\t"))
  end
end


File.open('ml/test_2.txt', 'w') do |f|
  f.print((0..bow.dict.size).map {|n| "i#{n}" }.join("\t"))
  f.puts("\tid")
  db.query("select id, body from kuchikomi where rating = 'low' order by id limit 100 offset #{NUM_CLASS_2+1}").each do |row|
    doc = text_to_words(row['body'])
    vec = bow.doc_to_vector(doc)
    vec << row['id']
    f.puts(vec.join("\t"))
  end
end
```

## Rで判定する

Rでは、先ほど出力したBoWベクトルを読み込んで、決定木群を作成します。

tuneRFを使用してBoWベクトルの数を調整しています。これを行うことで2,3%ほど精度の向上がみられました。

predictで分類結果を出力しています。

```r
require("randomForest")
set.seed(20151208)
df <- read.table("ml/vector.txt", header=TRUE)
n <- ncol(df)
tuned <- tuneRF(df[,-n], df[,n], doBest=TRUE)
mtry <- tuned$mtry
rf <- randomForest(formula=cv ~ ., data=df, mtry=mtry)
class1 <- read.table("ml/test_1.txt", header = TRUE)
class2 <- read.table("ml/test_2.txt", header = TRUE)
table(predict(rf, class1))
table(predict(rf, class2))
```

### 結果

できたrandomForestにテストケースを投入、

通常口コミ100件中、2件苦情と誤判定
苦情100件中、4件通常口コミと誤判定

という結果になりました。

## 考察

単純に名詞だけを抜き出して使っていたりと手抜きな実装をしていますが、何度か試しても精度が90%を割ることはなかったので、なかなか良い性能です。

品詞分類やストップワードをちゃんとやったり、寄与度で不要な変数の除去などを行えば、精度の向上や高速化も望めそうです。

一度モデルを構築してしまえば判定にかかる時間は少ないので、プロダクション環境でも使いやすいです。

(やってることが全体的に古めな感じがするのは参照しているのが[テキストデータの統計科学入門](http://www.amazon.co.jp/dp/4000057022)などで少し古めだからですね)