組み込み型お手軽全文検索エンジンのRubyライブラリを作って2ちゃんねるQ&A検索の全文検索エンジンを置き換えた
2ちゃんねるQ&A検索(非公式) の全文検索は pg_trgm を使っていたのですが、だんだんとデータが増えてくるとPostgreSQLの共有メモリを増やさないと検索がタイムアウトするようになってきて、最近ではVPSのメモリのほとんどをPostgreSQLに割り当ててもクソ重くてやばかったので、昨日、意を決して組み込み型お手軽全文検索エンジンをテキトウに作って置き換えたら爆軽爆速になった。
qarc.info は意外に月100万訪問者以上あるんですけど、月800円のVPS1台にウェブサーバーからデータベースサーバーから全部突っ込んだ運用をしていて、これでどこまでいけるかというのが個人的な趣味みたいな感じなので、今回もできるだけ軽い実装かつ導入がめんどくさくないものを作ってみました。半日くらいで適当に作って、組み込みながらちょこちょこ直したもので、あまりいいものではないんですけど、その紹介をします。
FtsLite
FtsLiteは組み込み型お手軽全文検索エンジンのRubyライブラリです。
基本的にはSQLite3 FTS4のラッパーで、日本語のbigramやtrigram、wakachi_bigramなどのトークナイザーをRubyのレイヤで実装したものです。
qarc.info で使われています。
Ruby 1.9.2 以降と、FTS4 に対応してる SQLite3 が必要で、SQLite3 はできれば 3.7.7 以降がよいです(FTS4の仮想テーブルに対するINSERT OR REPLACEが実装されているのでsetのパフォーマンスがよい)。
インストール
gem install fts_lite
Usage
# -*- coding: utf-8 -*- require 'fts_lite' FtsLite::Index.open("./index.sqlite3") do |db| # set(docid, text, sort_value = nil) db.set(1, "なぜナポリタンは赤いのだろうか?") db.set(2, "昼飯のスパゲティナポリタンを眺めながら、積年の疑問を考えていた。") # docid_array = search(query, options = {}) docids = db.search("ナポリタン") puts docids.join(",") docids = db.search("赤い ナポリタン") puts docids.join(",") # update_sort_value(docid, sort_value) db.update_sort_value(1, 2) db.update_sort_value(2, 1) docids = db.search("ナポリタン", :order => :asc, :limit => 1) puts docids.join(",") end
1,2 1 2
set(docid, text, sort_value)でデータを登録します。
docidはレコードを表すIDで search ではこのIDの配列を検索結果として返します。
textは全文検索インデックスのためのテキストデータです。取り出すことはできないので、元のデータは別のデータベースにあることを想定しています。
sort_valueはソート用の値で、searchの時にこの値でソートしたり、ソートしたうえで上位N件を取り出したりできます。
search(query, options) で検索できて、queryは空白区切りでAND検索です。このへんの仕様は用途によっていろいろだと思うけど、僕はテキトウにANDだけできればいいやと思っているので、いじりたい人は lib/tokenizer.rb で定義してある query というメソッドをいじってください。
optionsは :order に :asc か :desc を指定すると sort_value で昇順ソートまたは降順ソートします。 :order が指定されない場合は docid の昇順になります。:limit => N を指定すると検索結果の上位N件だけを返します。
Railsで使う
まず config/application.rb あたりで
QUESTION_FTS = FtsLite::Index.open(File.join(Rails.root.to_s, "fts", "index.sqlite3"), :table_name => "questions") THREAD_FTS = FtsLite::Index.open(File.join(Rails.root.to_s, "fts", "index.sqlite3"), :table_name => "threads") # ...
とコネクションを作ってグローバルにアクセスできるようにしておきます。
モデルでしか使わない場合は、モデルの中で定義したほうがいいかもしれません。
:table_name を指定するとひとつのファイルに複数の全文検索インデックスが持てます。
あとは、たとえば、Question という ActiveRecord のモデルがあって、全文検索用のテキストデータ(内容やタイトルなんかを適当に結合した文字列)を作成する make_ft というメソッドとソート用の表示数 view_count というカラムがあるとすると
class Question < ActiveRecord::Base LIMIT = 1000 after_save :set_ft def set_ft QUESTION_FTS.set(id, make_ft, view_count) end def self.search(query) find(:all, :conditions => ["id in (?)", QUESTION_FTS.search(query, :order => :desc, :limit => LIMIT)], :order => "view_count DESC") end # def make_ft ... end
まず after_save でレコードの更新時に全文検索インデックス側も更新するようにしておきます。
Question.search というメソットでは、全文検索を行って、その結果(IDの配列)を含むレコードをさらにDBに問い合わせることで検索結果となるレコードを返しています。
この実装だとレプリケーションしている場合に、別のサーバーでレコードが更新されるとトリガーが効かなくてローカルの全文検索インデックスが更新されないことに注意してください。
トークナイザー
デフォルトでは:bigramになっています。openのオプションで、:tokenizer => :wakachi_bigram などと与えると変えれるのですが、日本語の場合、:bigram以外は微妙なので、変えなくていいと思います。英語の場合は :trigram を指定するとよいと思います。
変えれるようにしてみたものの変えなくていい感じで微妙機能です。
BimyouSegmenter: Rubyだけで書かれた微妙なサイズの分かち書きソフトウェア
BimyouSegmenterはRubyだけで書かれた微妙なサイズの日本語分かち書きソフトウェアです。
約150Kバイトの微妙なサイズのソースコードで、青空文庫にある太宰治、宮沢賢治、夏目漱石、夢野久作作品であれば、MeCab+ipadicによるの分かち書きの結果と94%くらいは同じになります。
というTinySegmenterのパロディです。
TinySegmenterは新聞記事で学習されているのと、空白文字が単語にくっついたりして扱いが難しかったので、TinySegmenterのようなひとつのソースコードに全部つっこんだ感じの分かち書きのライブラリを自分の用意したデータで学習して好きに調節できたらいいなと思ったので作ってみました。
nekoneko_genでもこれを使っています。
TinySegmenterとは特徴ベクトルも学習アルゴリズムも学習データも違いますが、基本的な考え方はTinySegmenterのソースコードから得られる情報を参考にしています。
(ある文字の間で切るか切らないかを、そこを中心とした前後N文字の文字と文字種のN-gram、それまでの分割状態を特徴ベクトルにして線形分類器で判定する)
BimyouSegmenterは、(青空文庫の)太宰治、宮沢賢治、夏目漱石、夢野久作 作品、高田力 ベーシック英語、(Project Gutenbergの) Alice's Adventures in Wonderland, by Lewis Carroll に対するMeCab+ipadicによる分かち書き結果(約800万件)を LIBLINEAR の L1-regularized logistic regression で学習して、学習されたModelファイルからRubyのコードを生成しています。
コマンドラインツール
% bimyou_segmenter 今年もよろしくお願いします。Happy New Year 2013! 今年 も よろしく お願い し ます 。 Happy New Year 2013 ! EOS いろいろあったけど、ぼくはげんきです いろいろ あっ た けど 、 ぼく は げんき です EOS
オプションは、
-dでデリミタを指定するとその文字列で単語間を区切ります。デフォルトは\nです。
-eでEOSの文字列を指定できます。デフォルトはEOSです。
-sを付けると空白文字や改行コードも単語として返します。-sを付け場合、分割後の単語を繋げると元の文字列と一致します。
% bimyou_segmenter -d "|" 今年もよろしくお願いします。Happy New Year 2013! 今年|も|よろしく|お願い|し|ます|。|Happy|New|Year|2013|!|EOS % bimyou_segmenter -s -d "<>" -e "" 今年もよろしくお願いします。Happy New Year 2013! 今年<>も<>よろしく<>お願い<>し<>ます<>。<>Happy<> <>New<> <>Year<> <>2013<>! % bimyou_segmenter --help
ライブラリ
# coding: utf-8 require 'bimyou_segmenter' puts BimyouSegmenter.segment("今年もよろしくお願いします。Happy New Year 2013!").join("|") puts BimyouSegmenter.segment("今年もよろしくお願いします。Happy New Year 2013!", :white_space => true).join("|")
今年|も|よろしく|お願い|し|ます|。|Happy|New|Year|2013|! 今年|も|よろしく|お願い|し|ます|。|Happy| |New| |Year| |2013|!
:white_spaceをtrueにすると(default: false)、空白文字や改行も単語して返します。このオプションを指定した場合は、結果の文字列配列を順に結合すると元の文字列と一致します。
また:symbol => falseにすると(default: true)、記号のみからなる単語(トークン)を返しません。
TinySegmenterとの違い(勘)
TinySegmenterは連続する漢字を繋げやすいですが、BimyouSegmenterは連続する漢字を2個ずつで分けることが多いです。たぶん学習データの違いによるものだと思います。
TinySegmenterは空白文字(スペースタブ改行)も返しますが、BimyouSegmenterはデフォルトでは返しません。またBimyouSegmenterは空白文字をルールベースで分割しているので、空白文字と空白文字以外がひとつの単語として結合されることはありません。
MeCab+ipadicとの違い
MeCab+ipadicは半角スペースやタブ、改行コードを返しませんが全角スペースは返します。BimyouSegmenterは:white_spaceオプションを指定した場合を除いて全角スペースも返しません。
(char.defによりそうですが)MeCabは全角数字を文字ごとに分割しますが、BimyouSegmenterは結合しやすいです。
BimyouSegmenterは辞書を持っていないので、3文字以上連続する漢字の多い文章やひらがなの多い文章は超苦手です(大体うまくいかない)。MeCabが使えるならMeCabを使いましょう。
BimyouSegmenter、MeCab、TinySegmenter(Ruby Version)の結果を目視で比較するコード
TinySegmenterはTinySegmenterをRubyに移植 - llameradaの日記のRuby移植版を1.9.2対応してカレントディレクトリに置いて使っています。
# coding:utf-8 if (RUBY_VERSION <= "1.9.0") $KCODE='u' require 'rubygems' end require './tiny_segmenter' require 'bimyou_segmenter' require 'MeCab' require 'kconv' @@mecab = MeCab::Tagger.new def mecab_segment(s) node = @@mecab.parseToNode(s) wakachi = [] while node wakachi << node.surface.toutf8 node = node.next end wakachi[1 ... -1] end data = <<DATA 昼飯のスパゲティナポリタンを眺めながら、積年の疑問を考えていた。 それは「なぜナポリタンは赤いのだろうか」という問いである。 簡単に見えて、奥の深い問題だ。 「赤いから赤いのだ」などとトートロジーを並べて悦に入る浅薄な人間もいるが、 それは思考停止に他ならず、知性の敗北以外なにものでもない。 「赤方偏移」という現象がある。 宇宙空間において、地球から高速に遠ざかる天体ほどドップラー効果により、 そのスペクトル線が赤色の方に遷移するという現象である。 つまり、本来のナポリタンが何色であろうとも、ナポリタンが我々から 高速で遠ざかっているとすれば、毒々しく赤く見えるはずなのだ。 目の前のナポリタンは高速で動いているか否か? それはナポリタンの反対側に回ってみることでわかる。 運動の逆方向から観察することで、スペクトルは青方遷移し、 青く見えるはずなのだ。 逆に回ってみたところ、ナポリタンは赤かった。 よってこのナポリタンは高速移動をしていないと言える。 DATA data = data.gsub(/\s/, '') puts "\n---- BimyouSegmenter" puts BimyouSegmenter.segment(data).join("|") puts "\n---- MeCab" puts mecab_segment(data).join("|") puts "\n---- TinySegmenter(Ruby Version)" puts TinySegmenter.segment(data).join("|") puts "\n" t = Time.now 1000.times do BimyouSegmenter.segment(data) end puts "BimyouSegmenter 1000 loop #{Time.now - t} sec" t = Time.now 1000.times do mecab_segment(data) end puts "MeCab 1000 loop #{Time.now - t} sec" t = Time.now 1000.times do TinySegmenter.segment(data) end puts "TinySegmenter(Ruby Version) 1000 loop #{Time.now - t} sec "
% ruby hikaku.rb ---- BimyouSegmenter 昼飯|の|スパゲティナポリタン|を|眺め|ながら|、|積年|の|疑問|を|考え|て|い|た|。|それ|は|「|なぜ|ナポリタン|は|赤い|の|だろ|う|か|」|という|問い|で|ある|。|簡単|に|見え|て|、|奥|の|深い|問題|だ|。|「|赤い|から|赤い|の|だ|」|など|と|トートロジー|を|並べ|て|悦|に|入る|浅薄|な|人間|も|いる|が|、|それ|は|思考|停止|に|他|なら|ず|、|知性|の|敗北|以外|なに|もの|で|も|ない|。|「|赤方|偏移|」|という|現象|が|ある|。|宇宙|空間|において|、|地球|から|高速|に|遠ざかる|天体|ほど|ドップラー|効果|に|より|、|その|スペクトル|線|が|赤色|の|方|に|遷移|する|という|現象|で|ある|。|つまり|、|本来|の|ナポリタン|が|何色|で|あろ|う|と|も|、|ナポリタン|が|我々|から|高速|で|遠ざかっ|て|いる|と|すれ|ば|、|毒々しく|赤く|見える|はず|な|の|だ|。|目|の|前|の|ナポリタン|は|高速|で|動い|て|いる|か|否|か|?|それ|は|ナポリタン|の|反対|側|に|回っ|て|みる|こと|で|わかる|。|運動|の|逆方|向|から|観察|する|こと|で|、|スペクトル|は|青方|遷移|し|、|青く|見える|はず|な|の|だ|。|逆|に|回っ|て|み|た|ところ|、|ナポリタン|は|赤かっ|た|。|よっ|て|この|ナポリタン|は|高速|移動|を|し|て|い|ない|と|言える|。 ---- MeCab 昼飯|の|スパゲティナポリタン|を|眺め|ながら|、|積年|の|疑問|を|考え|て|い|た|。|それ|は|「|なぜ|ナポリ|タン|は|赤い|の|だろ|う|か|」|という|問い|で|ある|。|簡単|に|見え|て|、|奥|の|深い|問題|だ|。|「|赤い|から|赤い|の|だ|」|など|と|トートロジー|を|並べて|悦に入る|浅薄|な|人間|も|いる|が|、|それ|は|思考|停止|に|他|なら|ず|、|知性|の|敗北|以外|なに|もの|で|も|ない|。|「|赤|方偏|移|」|という|現象|が|ある|。|宇宙|空間|において|、|地球|から|高速|に|遠ざかる|天体|ほど|ドップラー|効果|により|、|その|スペクトル|線|が|赤色|の|方|に|遷移|する|という|現象|で|ある|。|つまり|、|本来|の|ナポリ|タン|が|何|色|で|あろ|う|とも|、|ナポリ|タン|が|我々|から|高速|で|遠ざかっ|て|いる|と|すれ|ば|、|毒々しく|赤く|見える|はず|な|の|だ|。|目|の|前|の|ナポリ|タン|は|高速|で|動い|て|いる|か|否|か|?|それ|は|ナポリ|タン|の|反対|側|に|回っ|て|みる|こと|で|わかる|。|運動|の|逆|方向|から|観察|する|こと|で|、|スペクトル|は|青|方|遷移|し|、|青く|見える|はず|な|の|だ|。|逆|に|回っ|て|み|た|ところ|、|ナポリ|タン|は|赤かっ|た|。|よって|この|ナポリ|タン|は|高速|移動|を|し|て|い|ない|と|言える|。 ---- TinySegmenter(Ruby Version) 昼飯|の|スパゲティナポリタン|を|眺め|ながら|、|積年|の|疑問|を|考え|て|い|た|。|それは|「なぜ|ナポリタン|は|赤い|の|だろ|う|か|」|という|問い|で|ある|。|簡単|に|見えて|、|奥|の|深い|問題|だ|。|「|赤い|から|赤い|の|だ|」|など|と|トートロジー|を|並べ|て|悦|に|入る|浅薄|な|人間|も|いる|が|、|それ|は|思考停止|に|他|ならず|、|知性|の|敗|北以外|な|に|ものでも|ない|。「|赤方|偏移|」|と|いう|現象|が|ある|。|宇宙空間|に|おいて|、|地球|から|高速|に|遠ざかる|天体|ほど|ドップラー|効果|により|、|その|スペクトル|線|が|赤色|の|方|に|遷移|する|という|現象|で|ある|。つまり|、|本来|の|ナポリタン|が|何|色|で|あろ|うと|も|、|ナポリタン|が|我々|から|高速|で|遠ざかっ|て|いる|とすれ|ば|、|毒々しく|赤く|見える|はず|な|の|だ|。|目|の|前|の|ナポリタン|は|高速|で|動い|て|いる|か|否か|?|それは|ナポリタン|の|反|対側|に|回っ|て|みる|こと|でわかる|。運動|の|逆|方向|から|観察|する|こと|で|、|スペクトル|は|青方遷移|し|、|青く|見える|はず|な|の|だ|。|逆|に|回っ|て|み|た|ところ|、|ナポリタン|は|赤かっ|た|。よって|この|ナポリタン|は|高速移動|を|し|て|い|ない|と|言える|。 BimyouSegmenter 1000 loop 5.426601826 sec MeCab 1000 loop 1.153953701 sec TinySegmenter(Ruby Version) 1000 loop 6.062332638 sec
なかなか良い感じだと思います。
分割の速度は、MeCabより5倍以上遅いです(Ruby 1.9.3の場合。1.8.7の場合は16倍以上遅い)。これはRubyで書いているせいだと思うので、Cの拡張ライブラリを書けばクソ速くなると思いますが、今はないです。
nekoneko_genで20newsgroupを試してみた
文書分類では、20newsgroupsというデータセットがよく使われるようなので、英語テキストの対応をしながら試してみました。
http://people.csail.mit.edu/jrennie/20Newsgroups/
これは20種類のニュースグループに投稿された約2万件のドキュメントを含むデータセットです(学習用が1.1万件、確認用が7.5千件だった)。
ニュースグループというのは、メーリングリストで2ちゃんねるをやっている感じのものだと思います。
20種類の板に投稿されたレスをどの板の投稿か判定するマシンを学習する感じ。
(注意: ここに書かれている作業用のコードはRuby1.9系でしか動きません)
最新のnekoneko_genにアップデート
まず
gem update nekoneko_gen
とアップデートします。
これを書いている時点の最新は0.4.0です。
入っていない場合は、
gem install nekoneko_gen
でインストールされます。
データの準備
サイトを見ると何種類かありますけど、20news-bydate.tar.gz を使います。
% wget http://people.csail.mit.edu/jrennie/20Newsgroups/20news-bydate.tar.gz % tar -xzvf 20news-bydate.tar.gz % ls 20news-bydate-test 20news-bydate-train
train用とtest用に分かれているらしいので、trainで学習して、testで確認します。
構造を見よう。
% ls 20news-bydate-train alt.atheism comp.os.ms-windows.misc comp.sys.mac.hardware misc.forsale rec.motorcycles rec.sport.hockey sci.electronics sci.space talk.politics.guns talk.politics.misc comp.graphics comp.sys.ibm.pc.hardware comp.windows.x rec.autos rec.sport.baseball sci.crypt sci.med soc.religion.christian talk.politics.mideast talk.religion.misc % ls 20news-bydate-train/comp.os.ms-windows.misc 10000 9141 9159 9450 9468 9486 9506
20news-bydate-trainと20news-bydate-testの下に各カテゴリのディレクトリがあって、各カテゴリのディレクトリにドキュメントがファイルに分かれて入っているようです。
nekoneko_genは、1ファイル1カテゴリ1行1データの入力フォーマットなので、まずこんなスクリプトで変換します。
# coding: utf-8 # 20news-conv.rb require 'fileutils' require 'kconv' src = ARGV.shift dest = ARGV.shift unless (src && dest) warn "20news-conv.rb srcdir destdir\n" exit(-1) end FileUtils.mkdir_p(dest) data = Hash.new # 元データの各ファイルについて Dir.glob("#{src}/*/*").each do |file| if (File.file?(file)) # root/category/nに分解 root, category, n = file.split('/')[-3 .. -1] if (root && category && n) data[category] ||= [] # ファイルの内容を改行をスペースに置き換えて(1行にして)カテゴリのデータに追加 data[category] << NKF::nkf("-w", File.read(file)).gsub(/[\r\n]+/, ' ') end end end # 出力側で data.each do |k,v| # カテゴリ名.txtのファイルにデータを行単位で吐く path = File.join(dest, "#{k}.txt") File.open(path, "w") do |f| f.write v.join("\n") end end
train、testというディレクトリに変換。
% ruby 20news-conv.rb 20news-bydate-train train % ruby 20news-conv.rb 20news-bydate-test test % % ls test alt.atheism.txt comp.sys.ibm.pc.hardware.txt misc.forsale.txt rec.sport.baseball.txt sci.electronics.txt soc.religion.christian.txt talk.politics.misc.txt comp.graphics.txt comp.sys.mac.hardware.txt rec.autos.txt rec.sport.hockey.txt sci.med.txt talk.politics.guns.txt talk.religion.misc.txt comp.os.ms-windows.misc.txt comp.windows.x.txt rec.motorcycles.txt sci.crypt.txt sci.space.txt talk.politics.mideast.txt % head alt.atheism.txt
できてる。
学習
1コマンドです。ここまでの作業のことは忘れましょう。分類器の名前はnews20にしました。
trainの下を全部指定します。
% nekoneko_gen -n news20 train/*
ちょっと時間かかります。
% nekoneko_gen -n news20 train/* loading train/alt.atheism.txt... 11.2039s loading train/comp.graphics.txt... 10.0659s loading train/comp.os.ms-windows.misc.txt... 24.7611s loading train/comp.sys.ibm.pc.hardware.txt... 9.1767s loading train/comp.sys.mac.hardware.txt... 8.3413s loading train/comp.windows.x.txt... 13.9806s loading train/misc.forsale.txt... 6.8255s loading train/rec.autos.txt... 9.9041s loading train/rec.motorcycles.txt... 9.4798s loading train/rec.sport.baseball.txt... 9.9481s loading train/rec.sport.hockey.txt... 14.2056s loading train/sci.crypt.txt... 19.5707s loading train/sci.electronics.txt... 9.6204s loading train/sci.med.txt... 13.6632s loading train/sci.space.txt... 14.4867s loading train/soc.religion.christian.txt... 16.4918s loading train/talk.politics.guns.txt... 16.2433s loading train/talk.politics.mideast.txt... 21.8133s loading train/talk.politics.misc.txt... 16.2976s loading train/talk.religion.misc.txt... 10.4111s step 0... 0.953548, 58.1906s step 1... 0.970537, 47.1082s step 2... 0.980550, 41.9248s step 3... 0.985889, 37.6781s step 4... 0.989483, 35.0408s step 5... 0.991824, 33.8357s step 6... 0.993727, 30.1385s step 7... 0.995139, 29.5926s step 8... 0.996107, 29.3976s step 9... 0.997182, 27.9107s step 10... 0.997546, 27.1800s step 11... 0.998004, 26.4783s step 12... 0.998581, 26.7023s step 13... 0.998985, 25.9511s step 14... 0.999145, 24.7697s step 15... 0.999324, 24.7991s step 16... 0.999430, 24.8879s step 17... 0.999569, 24.7246s step 18... 0.999622, 25.2175s step 19... 0.999615, 23.5225s ALT_ATHEISM : 153334 features COMP_GRAPHICS : 153334 features COMP_OS_MS_WINDOWS_MISC : 153334 features COMP_SYS_IBM_PC_HARDWARE : 153334 features COMP_SYS_MAC_HARDWARE : 153334 features COMP_WINDOWS_X : 153334 features MISC_FORSALE : 153334 features REC_AUTOS : 153334 features REC_MOTORCYCLES : 153334 features REC_SPORT_BASEBALL : 153334 features REC_SPORT_HOCKEY : 153334 features SCI_CRYPT : 153334 features SCI_ELECTRONICS : 153334 features SCI_MED : 153334 features SCI_SPACE : 153334 features SOC_RELIGION_CHRISTIAN : 153334 features TALK_POLITICS_GUNS : 153334 features TALK_POLITICS_MIDEAST : 153334 features TALK_POLITICS_MISC : 153334 features TALK_RELIGION_MISC : 153334 features done nyan!
終わったらnews20.rbというRubyのライブラリが生成されています。
% ls -la news20.rb -rw-r--r-- 1 ore users 66599221 2012-06-02 17:10 news20.rb
60MB以上あります。デカい。
確認
20カテゴリもあって前回のスクリプトで1カテゴリずつ見るのはきついので、一気に確認するスクリプトを書きました。
# coding: utf-8 # test.rb # 分類器を読み込む require './news20' # ファイル名をnekoneko_genが返すラベル名に変換する関数 def label_name(file) File.basename(file, ".txt").gsub(/[\.\-]/, "_").upcase end count = 0 correct = 0 # 指定された各ファイルについて ARGV.each do |file| # ファイル名からラベル名を得る name = label_name(file) # ラベル名から正解ラベル(定数)に変換 # (News20::LABELSにラベル番号順のラベル名があるので添え字位置を探す) correct_label = News20::LABELS.each_with_index.select{|v,i| v == name}.flatten.pop file_count = 0 file_correct = 0 # ファイルの各行データについて File.read(file).lines do |l| # 予測 label = News20.predict(l) # ラベルが一致していたら if (label == correct_label) # 正解!! file_correct += 1 end # データ数 file_count += 1 end correct += file_correct count += file_count # ファイルの内での正解率を表示 printf("%26s: %f\n", name, file_correct.to_f / file_count.to_f) end # 全体の正解率を表示 printf("\nAccuracy: %f\n", correct.to_f / count.to_f)
testの下を全部指定します。
% ruby test.rb test/* ALT_ATHEISM: 0.789969 COMP_GRAPHICS: 0.825193 COMP_OS_MS_WINDOWS_MISC: 0.753807 COMP_SYS_IBM_PC_HARDWARE: 0.778061 COMP_SYS_MAC_HARDWARE: 0.867532 COMP_WINDOWS_X: 0.815190 MISC_FORSALE: 0.902564 REC_AUTOS: 0.916667 REC_MOTORCYCLES: 0.969849 REC_SPORT_BASEBALL: 0.957179 REC_SPORT_HOCKEY: 0.984962 SCI_CRYPT: 0.952020 SCI_ELECTRONICS: 0.778626 SCI_MED: 0.881313 SCI_SPACE: 0.936548 SOC_RELIGION_CHRISTIAN: 0.937186 TALK_POLITICS_GUNS: 0.934066 TALK_POLITICS_MIDEAST: 0.914894 TALK_POLITICS_MISC: 0.616129 TALK_RELIGION_MISC: 0.677291 Accuracy: 0.866171
86.6%でした。
ネコでもテキスト分類器の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 とは一体なんだったのかと、今になって考えています。
キャプチャつなぎ職人になる方法
本来何と呼ぶ者なのか知らないので、知っている方がいたら教えてほしいのですが。
2ちゃんねるなどを見ていると、アニメのスクロールシーンの画面キャプチャを謎の技術でつなげている人がいます。
たとえば、次の4つの画像
(http://www.nicovideo.jp/watch/1333619854 より)
(最後の頭のシーンは口が閉じていたけど口は空いていたほうがいいと思ったので、口より上のみ切り出した)
方法を知っていれば簡単なのですが、たまにがんばって手でつなげたような画像を張る人がいるので、簡単な方法を紹介しておきます。
Microsoft ICEによるスティッチング
Microsoft ICE という Microsoft Research が開発したアニメのスクロールシーンをきれいにつなげるソフトを使うだけです。
以下のURLからダウンロードしてインストールします。
http://research.microsoft.com/en-us/um/redmond/groups/ivm/ice/
英語ですが使い方は簡単で、適当な間隔で保存したキャプチャ画像をまとめてドラック&ドロップするだけです。
あとは、Exportで保存画像の設定をして、Export to disk ボタンをクリックすると保存されます。
画像が汚いのはエコノミー画質のキャプチャだからで、きれいなキャプチャからだとそこそこきれいな画像ができます。
感想
個人的にはFlash2期がよかったですね。
クライアントサイドの巨大なクッキーとサーバーサイドのヘッダーサイズ制限の組み合わせによるDoSについて
もう10年以上前のネタなのですが、いまだに有効だし、最近、セッションを扱っていないならXSSがあってもあまり問題ないという意見を見ることがあるので、サービス提供側として案外面倒なことになる場合がある、という話を書きました。
POCです。
http://www.udp.jp/misc/largecookiedos.html
内容としては、
- JavaScriptから巨大なCookieをブラウザに設定できる
- HTTPサーバーは受け取れるHTTPヘッダーサイズの上限を持っていて、それを超えていた場合にBad Requestを返す
- 1によって2を超えるサイズのCookieが設定可能な場合がある(たぶんほとんどの場合可能)
- よってXSSなどによって巨大なCookieを設定されると以降サービスが利用できなくなる
というものです。
Cookieの有効期限を何十年も設定されるとユーザー側で勝手に回復することは期待できないので、なんらか対応が必要になると思います。
昔は、CGIチャットに来る掲示板荒らしがこういうスクリプトを張り付けて利用者を追い出したりしていました。
XSSはつらい問題だと思います。
家宅捜索されることもあるらしいので気を付けましょう。
携帯電話を Android 端末 にした
今後何かやれたらなと思います。