ネコでもテキスト分類器のRubyライブラリが生成できる便利ツールを作った

あまり細かいことは気にせずテキスト分類器のRubyライブラリを1コマンドで自動生成する便利ツールを作りました。
いろいろ迷走している間に。

gem install nekoneko_gen

でインストールできます。

なにをするものなのか、ちょっと分かりにくいので、例で説明します。

2ちゃんねるの投稿からどのスレッドの投稿か判定するライブラリを生成する

例として、2ちゃんねるに投稿されたデータから、投稿(レス)がどのスレッドのレスか判定するライブラリを生成してみます。

準備

まず

gem install nekoneko_gen

でインストールします。
Ruby 1.8.7でも1.9.2でも動きますが1.9.2のほうが5倍くらい速いので1.9.2以降がおすすめです。
環境は、ここではUbuntuを想定しますが、Windowsでも使えます。(WindowsXP, ruby 1.9.3p0で確認)


データは僕が用意しているので、適当にdataというディレクトリを作ってダウンロードします。

% mkdir data
% cd data
% wget -i http://www.udp.jp/misc/2ch_data/
% cd ..

でダウンロードされます。

いろいろダウンロードされますが、とりあえず、ドラクエ質問スレとラブプラス質問スレの2択にしようと思うので、以下のファイルを使用します。
これらを使って、入力された文章がドラクエ質問スレのレスか、ラブプラス質問スレのレスか判定するライブラリを生成します。

dragon_quest.txt
ドラゴンクエストなんでも質問スレのデータ(約3万件)
dragon_quest_test.txt
dragon_quest.txtからテスト用に500件抜いたレス(dragon_quest.txtには含まれない)
dragon_quest_test2.txt
dragon_quest_test.txtの2レスを1行にしたデータ
loveplus.txt
ラブプラス質問スレのデータ(約2.5万件)
loveplus_test.txt
loveplus.txtからテスト用に500件抜いたレス
loveplus_test2.txt
loveplus_test.txtの2レスを1行にしたデータ

入力データのフォーマットは、1行1データです。このデータの場合は、1レス中の改行コードを消して1行1レスにしてしています。
データの整備はアンカー(>>1のようなリンク)を消しただけなので、「サンクス」「死ぬ」「そうです」みたいなどう考えても分類無理だろみたいなデータも含まれています。また突然荒らしが登場してスレと関係ないクソレスを繰り返していたりもします。
*_test.txtと*_test2.txtは生成されたライブラリの確認用です。*_test.txtのうちいくつ正解できるか数えるのに使います。*_test2.txtは、*_test.txtの2レスを1データにしたものです。2ちゃんの投稿は短すぎてうまく判定できないことが多いのでは? と思うので、なら2レスあれば判定できるのか? という確認用です。

生成してみる
% nekoneko_gen -n game_thread_classifier data/dragon_quest.txt data/loveplus.txt

nekoneko_genというコマンドで生成します。
-nで生成する分類器の名前を指定します。これは".rb"を付けてファイル名になるのと、キャピタライズしてモジュール名になります。生成先ディレクトリを指定したい場合は、直接ファイル名でも指定できます。
その後ろに分類(判定)したい種類ごとに学習用のファイルを指定します。最低2ファイルで、それ以上ならいくつでも指定できます。

ちょっと時間がかかるので、待ちます。2分くらい。

% nekoneko_gen -n game_thread_classifier data/dragon_quest.txt data/loveplus.txt
loading data/dragon_quest.txt... 35.5426s
loading data/loveplus.txt... 36.0522s
step   0... 0.879858, 3.7805s
step   1... 0.919624, 2.2018s
step   2... 0.932147, 2.1174s
step   3... 0.940959, 2.0569s
step   4... 0.946985, 1.8876s
step   5... 0.950891, 1.8564s
step   6... 0.953541, 1.8398s
step   7... 0.955464, 1.8204s
step   8... 0.957427, 1.8008s
step   9... 0.959056, 1.7912s
step  10... 0.961098, 1.8027s
step  11... 0.961745, 1.7716s
step  12... 0.962943, 1.7633s
step  13... 0.963610, 1.7477s
step  14... 0.964611, 1.6216s
step  15... 0.965259, 1.7291s
step  16... 0.965730, 1.7271s
step  17... 0.966613, 1.7225s
step  18... 0.967241, 1.5861s
step  19... 0.967712, 1.7113s
DRAGON_QUEST, LOVEPLUS : 71573 features
done nyan!

終わったら -nで指定した名前のファイルにRubyのコードが生成されています。

% ls -la
...
-rw-r--r--  1 ore users 2555555 2012-05-28 08:10 game_thread_classifier.rb
...

2.5MBくらいありますね。結構デカい。
このファイルには、GameThreadClassifier(指定した名前をキャピタライズしたもの)というModuleが定義されていて、self.predict(text)というメソッドを持っています。このメソッドに文字列を渡すと、予測結果としてGameThreadClassifier::DRAGON_QUESTかGameThreadClassifier::LOVEPLUSを返します。この定数名は、コマンドに指定したデータファイル名を大文字にしたものです。

試してみる

生成されたライブラリを使ってみましょう。
注意として、Ruby 1.8.7の場合は、$KCODEを'u'にしておかないと動きません。あと入力の文字コードもutf-8のみです。

# coding: utf-8
if (RUBY_VERSION < '1.9.0')
  $KCODE = 'u'
end
require './game_thread_classifier'

$stdout.sync = true
loop do
  print "> "
  line = $stdin.readline
  label = GameThreadClassifier.predict(line)
  puts "#{GameThreadClassifier::LABELS[label]}の話題です!!!"
end

こんなコードを console.rb として作ります。
GameThreadClassifier.predictは予測されるクラスのラベル番号を返します。
GameThreadClassifier::LABELSには、ラベル番号に対応するラベル名が入っているので、これを表示してみます。

% ruby console.rb
> 彼女からメールが来た
LOVEPLUSの話題です!!!
> 日曜日はデートしてました
LOVEPLUSの話題です!!!
> 金欲しい
DRAGON_QUESTの話題です!!!
> 王様になりたい
DRAGON_QUESTの話題です!!!
> スライム
DRAGON_QUESTの話題です!!!
> スライムを彼女にプレゼント
LOVEPLUSの話題です!!!
>

できてるっぽいですね。CTRL+DとかCTRL+Cとかで適当に終わります。

正解率を調べてみる

*_test.txt、*_test2.txtの何%くらい正解できるか調べてみます。

if (RUBY_VERSION < '1.9.0')
  $KCODE = 'u'
end
require './game_thread_classifier'

labels = Array.new(GameThreadClassifier.k, 0)
file = ARGV.shift
File.open(file) do |f|
  until f.eof?
    l = f.readline.chomp
    label = GameThreadClassifier.predict(l)
    labels[label] += 1
  end
end
count = labels.reduce(:+)
labels.each_with_index do |c, i|
  printf "%16s: %f\n", GameThreadClassifier::LABELS[i], c.to_f / count.to_f
end

引数に指定したファイルを1行ずつpredictに渡して、予測されたラベル番号の数を数えて、クラスごとに全体の何割かを表示するだけのコードです。
GameThreadClassifier.kは、クラス数(この場合、DRAGON_QUESTとLOVEPLUSで2)を返します。

% ruby test.rb data/dragon_quest_test.txt
    DRAGON_QUEST: 0.932000
        LOVEPLUS: 0.068000

data/dragon_quest_test.txtには、ドラクエ質問スレのデータしかないので、すべて正解であれば、DRAGON_QUEST: 1.0になるはずです。
DRAGON_QUEST: 0.932000なので、93.2%は正解して、6.8%はラブプラスと間違えたことが分かります。
同じようにすべて試してみましょう。

% ruby test.rb data/dragon_quest_test.txt
    DRAGON_QUEST: 0.932000
        LOVEPLUS: 0.068000
% ruby test.rb data/loveplus_test.txt
    DRAGON_QUEST: 0.124000
        LOVEPLUS: 0.876000
%
% ruby test.rb data/dragon_quest_test2.txt
    DRAGON_QUEST: 0.988000
        LOVEPLUS: 0.012000
% ruby test.rb data/loveplus_test2.txt
    DRAGON_QUEST: 0.012048
        LOVEPLUS: 0.987952

ラブプラスはちょっと悪くて、87%くらいですね。平均すると、90%くらい正解しています。
また2レスで判定すると98%以上正解することが分かりました。2レスあれば、それがドラクエスレか、ラブプラススレか、ほとんど間違えることなく判定できるっぽいですね。

まとめ

ここまで読んでいただければ、どういうものか分かったと思います。
用意したデータファイルを学習して、指定した文字列がどのデータファイルのデータと似ているか判定するRubyライブラリを生成します。
生成されたライブラリは、Rubyの標準ライブラリ以外では、 json と bimyou_segmenter に依存しています。

gem install json bimyou_segmenter

C Extensionが使えない環境だと、

gem install json_pure bimyou_segmenter

とすれば、いろんな環境で生成したライブラリが使えるようになります。
ちなみに bimyou_segmenter という名前からしてあやしげなライブラリは、これと似たような方法で自動生成した日本語分かち書きのライブラリです。

もっと試す!!

データは他に skyrim.txt (スカイリムの質問スレ)、mhf.txt (モンスターハンターフロンティアオンラインの質問スレ)を用意しているので、これらも学習できます。

% nekoneko_gen -n game_thread_classifier data/dragon_quest.txt data/loveplus.txt data/skyrim.txt data/mhf.txt

単純に指定するファイルを増やすだけです。
生成されるコードも判定結果が増えただけなので、上で作ったconsole.rb、test.rbがそのまま使えます。

% nekoneko_gen -n game_thread_classifier data/dragon_quest.txt data/loveplus.txt data/skyrim.txt data/mhf.txt
loading data/dragon_quest.txt... 35.4695s
loading data/loveplus.txt... 36.5006s
loading data/skyrim.txt... 148.8504s
loading data/mhf.txt... 94.2842s
step   0... 0.885344, 29.5712s
step   1... 0.918844, 24.0811s
step   2... 0.927274, 22.0760s
step   3... 0.932804, 20.7306s
step   4... 0.936590, 20.4044s
step   5... 0.939495, 19.2658s
step   6... 0.942164, 19.1920s
step   7... 0.943754, 19.1084s
step   8... 0.945903, 18.9361s
step   9... 0.948293, 18.8840s
step  10... 0.949483, 18.1423s
step  11... 0.950827, 18.6365s
step  12... 0.951693, 18.2945s
step  13... 0.952915, 18.0946s
step  14... 0.953600, 17.9010s
step  15... 0.954284, 17.8173s
step  16... 0.955062, 17.7265s
step  17... 0.956281, 17.0873s
step  18... 0.956424, 17.5843s
step  19... 0.957648, 17.5608s
DRAGON_QUEST : 181402 features
LOVEPLUS : 171552 features
SKYRIM : 199655 features
MHF : 194066 features
done nyan!

% ruby test.rb data/dragon_quest_test.txt
    DRAGON_QUEST: 0.862000
        LOVEPLUS: 0.042000
          SKYRIM: 0.056000
             MHF: 0.040000
% ruby test.rb data/loveplus_test.txt
    DRAGON_QUEST: 0.068000
        LOVEPLUS: 0.836000
          SKYRIM: 0.052000
             MHF: 0.044000
% ruby test.rb data/skyrim_test.txt
    DRAGON_QUEST: 0.044000
        LOVEPLUS: 0.040000
          SKYRIM: 0.844000
             MHF: 0.072000
% ruby test.rb data/mhf_test.txt
    DRAGON_QUEST: 0.052000
        LOVEPLUS: 0.024000
          SKYRIM: 0.058000
             MHF: 0.866000
%
% ruby test.rb data/dragon_quest_test2.txt
    DRAGON_QUEST: 0.964000
        LOVEPLUS: 0.016000
          SKYRIM: 0.012000
             MHF: 0.008000
% ruby test.rb data/loveplus_test2.txt
    DRAGON_QUEST: 0.004016
        LOVEPLUS: 0.987952
          SKYRIM: 0.008032
             MHF: 0.000000
% ruby test.rb data/skyrim_test2.txt
    DRAGON_QUEST: 0.000000
        LOVEPLUS: 0.020000
          SKYRIM: 0.964000
             MHF: 0.016000
% ruby test.rb data/mhf_test2.txt
    DRAGON_QUEST: 0.008032
        LOVEPLUS: 0.000000
          SKYRIM: 0.016064
             MHF: 0.975904

1レスの場合は、選択肢が増えた分悪くなっています。平均すると正解は85%くらいでしょうか。2レスの場合は、まだ97%以上正解しています。


簡単すぎワロタ(自作自演)

なぜこんなものを作ったのか

前の『句読点のない文字列を文単位に区切る』を作ったときにLIBLINEARを使ってて、LIBLINEARは速いし簡単なので、ネコでもわかるLIBLINEARみたいな感じでぜひ紹介したいと思って、その例としてテキスト分類をあげようと思ったのですが、MeCabやKyotoCabinetをインストールしてさまざまな作業スクリプトを書いてLIBLINEARで使えるアルゴリズムの違いやパラメータの意味について理解する必要があったりして……こんなんネコに分かるわけない…にゃん…と思った。
それで、1コマンドで使えて、インターフェースだけ知っていれば中身を知る必要がないブラックボックス的に使えるジェネレーターで依存関係の少ないライブラリコードが生成できれば、ネコでもちょっとしたテキスト分類器が作れるし、便利なのでは? と思ったのでした。

実装は、まず分かち書きのライブラリをTinySegmenter: Javascriptだけで実装されたコンパクトな分かち書きソフトウェアを参考に青空文庫のデータで学習しました。それを使って文章の特徴ベクトルとしてBag of wordsを作って、その特徴ベクトルを [機械学習] AROWのコードを書いてみた - tsubosakaの日記で紹介されているAROWという学習アルゴリズムを多クラスにしたもので学習して、学習されたモデルをRubyのコードテンプレートに埋め込んでいるだけです。いろいろ適当につなげただけなので、これ自体は特に面白いところはないと思いますけど、こういったツールの 便 利 さ の 可 能 性 みたいなものは伝わったのでは、と思います。

ちなみに分かち書きのライブラリからpure rubyで書いてあるので、そのへんをちょっと移植してテンプレートを作れば、JavaScriptやPHPなど他の言語のコードも生成できます。今はできませんが。

最後に

それはともかくとして、LIBLINEARは速いし簡単なのでオススメしたい。
nekoneko_gen でも使っている bimyou_segmenter はLIBLINEARの学習結果(Model)からRubyライブラリを生成するプログラムで生成しているので、今度その話を書けたらと思います。
結局書くので nekoneko_gen とは一体なんだったのかと、今になって考えています。