この記事はアクトインディ Advent Calendar 2015 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から読み込んで分類します。
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で分類結果を出力しています。
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%を割ることはなかったので、なかなか良い性能です。
品詞分類やストップワードをちゃんとやったり、寄与度で不要な変数の除去などを行えば、精度の向上や高速化も望めそうです。
一度モデルを構築してしまえば判定にかかる時間は少ないので、プロダクション環境でも使いやすいです。
(やってることが全体的に古めな感じがするのは参照しているのがテキストデータの統計科学入門などで少し古めだからですね)