【Ruby】テキストのカテゴリ分類・類似度判定gem使ってみました

こんにちは。
[プログラミング大好きベーシック Advent Calendar 2015](http://qiita.com/advent-calendar/2015/basicinc)の20日目を担当します、新卒2年目@s-moriです。
普段はRailsメインで[ferret](https://ferret-plus.com/)の中の人をやっています。
もう20日らしいです。今年もあっという間でしたね。

今年は人工知能、機械学習、ディープラーニング系の動きが目立った気がします。
やはり技術的に面白そうということもあって興味はあるのですが、中身を理解して実装、精度向上のためにカスタマイズ、となると、最初の理解の時点で思いっきり時間がかかってしまいます。
自分としては手を動かしながら、ソースコード見ながら、という方が理解は進むと思っているので、ひとまずいくつかのgemを探して使ってみました。
あくまでも目的はさくっと使うことです。

## ナイーブベイズ
- [id774/naivebayes](https://github.com/id774/naivebayes)  
[多項モデル](https://ja.wikipedia.org/wiki/%E5%A4%9A%E9%A0%85%E5%88%86%E5%B8%83)と[ベルヌーイモデル](https://ja.wikipedia.org/wiki/%E3%83%99%E3%83%AB%E3%83%8C%E3%83%BC%E3%82%A4%E5%88%86%E5%B8%83)が選べるようです
- [reddavis/Naive-Bayes](https://github.com/reddavis/Naive-Bayes)  
しばらく更新がないけれど導入はしやすそう
- [yoshiori/baby_face](https://github.com/yoshiori/baby_face)  
railsのモデルのデータを利用するときに便利そう

さくっとできそうなNaive-Bayesを試します。
スポーツニュースの記事を学習データとして、ニュースタイトルから野球のニュースかサッカーのニュースかを判別します。
学習データの保存ができますが、保存時にASCII->UTF-8のエンコードエラーが起きる場合はsaveメソッドの中身を少しいじります。
gemsディレクトリ以下にインストールしたnaive_bayes-0.0.3ディレクトリがあるので、その中にあるnaive_bayes.rbファイルを一部変更します。

```gemsディレクトリ/naive_bayes-0.0.3/lib/naive_bayes.rb
def save
  raise "You haven't set a db_filpath, I dont know where to save" if @db_filepath.nil?
  File.open(@db_filepath, "w+") do |f|
    # f.write(Marshal.dump(self))にエンコード追加
    f.write(Marshal.dump(self).force_encoding("utf-8"))
  end
end
```
ではさっそく。

```rb
require 'natto'
require 'naive_bayes'

# テキストを単語の配列にする
def split_word(text)
  natto = Natto::MeCab.new
  word_arr = Array.new
  natto.parse(text) do |parsed_word|
    # 名詞のみ抽出
    word_arr << parsed_word.surface if parsed_word.feature.split(',')[0] == "名詞"
  end
  word_arr
end

bayes = NaiveBayes.new(:succer, :baseball)
# 本来はディレクトリ以下のテキストデータ読み込み
succer_news = [
  "サッカーニュースのテキストデータ1",
  "サッカーニュースのテキストデータ2",
  ・・・
]
baseball_news = [
  "野球ニュースのテキストデータ1",
  "野球ニュースのテキストデータ2",
  ・・・
]

# succerカテゴリの学習
succer_news.each do |succer|
  word_array = split_word(succer)
  # 単語が複数渡せるので配列を展開した状態で引数に渡す
  bayes.train(:succer, *word_array)
end

# baseballカテゴリの学習
baseball_news.each do |baseball|
  word_array = split_word(baseball)
  # 単語が複数渡せるので配列を展開した状態で引数に渡す
  bayes.train(:baseball, *word_array)
end

# 学習データを保存
bayes.db_filepath = './data/naive_data/train_data.rb'
bayes.save
```
保存した学習データを利用して分類します。

```ruby
# 実際は1つのrakeファイルでtaskに分けて実行したので、split_wordメソッドを共通で利用しています

naive = NaiveBayes.load('./data/naive_data/train_data.rb')
# 同じように名詞のみ配列で抽出
test_data = split_word("関学大が初優勝、出岡ハットトリック　大学サッカー")
puts naive.classify(*test_data)
#=>succer
#  5.340087318989427e-10
---
test_data = split_word("ロッテ育成ドラ１大木貴将、球団新年イベント出席へ")
puts naive.classify(*test_data)
#=>baseball
#  3.855100106735496e-15
---
test_data = split_word("ソフトバンク長谷川驚きの現状維持に３年契約")
puts naive.classify(*test_data)
#=>baseball
#  6.845057778089369e-11
---
test_data = split_word("澤、今日負ければ最終戦「３試合やる」皇后杯Ｖ目標")
puts naive.classify(*test_data)
#=>succer
#  1.6001212893999236e-11
```
あ、値が…。
学習データが甘かったのかぱっとしませんが、かろうじて判別できているようです。

gemではないですが、技評さんのサイトでpythonのサンプルコード付きで説明されています。
[機械学習 はじめよう 第3回　ベイジアンフィルタを実装してみよう](http://gihyo.jp/dev/serial/01/machine-learning/0003)
そしてそれをRubyで実装された方がいます(個人的には今回試したgemよりもこちらの方が使いやすかったような…。)
[Ruby でやってみる『機械学習 はじめよう第3回　ベイジアンフィルタを実装してみよう』メモ](http://cortyuming.hateblo.jp/entry/20140627/p2)

## nekoneko_gen
[nekoneko_gen](https://github.com/nagadomi/nekoneko_gen)は、__「ネコでもテキスト分類器のRubyライブラリが生成できる便利ツール」__だそうです。実行する分にはすごく簡単。

GitのREADMEに記述されている通りに試します。
青空文庫から持ってきた浦島太郎と桃太郎の内容を学習データとして「◯◯なのは浦島太郎と桃太郎どちらか」を判別してもらおうかと思います。
浦島太郎の内容を入れたテキストファイル(urashima.txt)と桃太郎の内容を入れたテキストファイル(momotaro.txt)をdataディレクトリに保存しておいて、コマンドを1回

```
% nekoneko_gen -n taro_classifier data/urashima.txt data/momotaro.txt
```
`-n`で指定したものが生成される分類器の名前になります(ex. `TaroClassifier`)

実行用のファイルを作ります。

```taro_console.rb
# coding: utf-8
if (RUBY_VERSION < '1.9.0')
  $KCODE = 'u'
end
require './taro_classifier'
require 'kconv'

$stdout.sync = true
loop do
  print "> "
  line = $stdin.readline.toutf8
  label = TaroClassifier.predict(line)
  puts "#{TaroClassifier::LABELS[label]}の話題です。"
end
```
いざ実行

```
% ruby taro_console.rb

> 人を助けたのは？
MOMOTAROの話題です。
> 亀を助けたのは？
URASHIMAの話題です。
> 楽しんでいたのは？
URASHIMAの話題です。
> 味方がいたのは？
MOMOTAROの話題です。
> 山での話は？
MOMOTAROの話題です。
> 海での話は？
URASHIMAの話題です。
> 強いのは？
MOMOTAROの話題です。
> 恩返しをしたのは？
URASHIMAの話題です。
```
学習データが残念だったのでこれぐらい簡単なものしかできませんが、簡単に分類することができます。
改めてちゃんとしたデータを投入して試してみたいです。
テストデータも用意してくれているので、「データは持っていないけれどとりあえず試してみたい」ときにも使うことができます。
正解率を求めるコードも記載されているので、確認もお手軽にできます。

作成者blog；
[ネコでもテキスト分類器のRubyライブラリが生成できる便利ツールを作った - デー](http://ultraist.hatenablog.com/entry/20120528/1338228761)

## レーベンシュタイン距離
[schuyler/levenshtein](https://github.com/schuyler/levenshtein)
別名、編集距離とも言われています。
別名そのままで、2つのテキストがどれぐらい異なっているかを表します。
値が0に近いほど距離が近いことになるので、類似テキストとみなすことができそうです。

```ruby
Levenshtein.normalized_distance("浦島太郎", "浦島太郎")
#=> 0.0
Levenshtein.normalized_distance("浦島太郎", "浦島二郎")
#=> 0.25
Levenshtein.normalized_distance("浦島太郎", "桃太郎")
#=> 0.5
Levenshtein.normalized_distance("浦島太郎", "浦河喜助")
#=> 0.75
Levenshtein.normalized_distance("浦島太郎", "石川五右衛門")
#=> 1.0

```
[RubyFish](https://github.com/anjlab/rubyfish)という、pythonのjellyfishという文字列の近似などのライブラリをRubyで使えるようにしたgemもあります。
こちらには[レーベンシュタイン距離](https://ja.wikipedia.org/wiki/%E3%83%AC%E3%83%BC%E3%83%99%E3%83%B3%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%A4%E3%83%B3%E8%B7%9D%E9%9B%A2)、[ハミング距離](https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%9F%E3%83%B3%E3%82%B0%E8%B7%9D%E9%9B%A2)などが含まれているようです。
(※ レーベンシュタイン距離の操作は__「削除、挿入、置換」__に対し、ハミング距離の操作は__「置換」__なので、レーベンシュタイン距離の方が一般的とのこと)

__補足：__
レーベンシュタイン距離だと「削除、挿入、置換」の操作が行われるのですが、これだと入れ替えで済むものも削除と挿入の操作が行われてしまい、2文字異なる場合と同じカウントになります。

```ruby
# 本来入れ替えで済むもの
Levenshtein.normalized_distance("浦島太郎", "浦島郎太")
#=> 0.5
# 2文字異なるもの
Levenshtein.normalized_distance("浦島太郎", "山田太郎")
#=> 0.5
```
この対応として、__Damerau–Levenshtein距離__というのがあります。
下記gemでは対応している模様。

- [GlobalNamesArchitecture/damerau-levenshtein](https://github.com/GlobalNamesArchitecture/damerau-levenshtein)
- [dbalatero/levenshtein-ffi](https://github.com/dbalatero/levenshtein-ffi)



## 3-gram
[milk1000cc/trigram](https://github.com/milk1000cc/trigram)
いわゆる[N-gram](https://ja.wikipedia.org/wiki/%E5%85%A8%E6%96%87%E6%A4%9C%E7%B4%A2#N-Gram)のN=3バージョン
N-gramは、__隣り合ったN文字の文字列/単語の組み合わせを「共起関係」__として、__各共起関係がどの程度出現するかという「共起頻度」__を出すことができます。
このgemでは文字列を3文字ずつに分割して、共起頻度を共起関係の総数で割った重複率を求めて、2つのテキストがどの程度類似しているかを表すことができます。

```
# N-gramの共起関係と共起頻度例(ex."にわにわにわにわとりがいる")

■共起関係
1-gram(uni-gram)
#=>["に", "わ", "に", "わ", "に", "わ", "に", "わ", "と", "り", "が", "い", "る"]
2-gram(bi-gram)
#=>["にわ", "わに", "にわ", "わに", "にわ", "わに", "にわ", "わと", "とり", "りが", "がい", "いる"]
3-gram(tri-gram)
#=>["にわに", "わにわ", "にわに", "わにわ", "にわに", "わにわ", "にわと", "わとり", "とりが", "りがい", "がいる"]

■共起頻度
1-gramの共起頻度
#=>["に" => 4, "わ" => 4, "と" => 1, "り" => 1, "が" => 1, "い" => 1, "る" => 1]
2-gramの共起頻度
#=>["にわ" => 4, "わに" => 4, "わと" => 1, "とり" => 1, "りが" => 1, "がい" => 1, "いる" => 1]
3-gramの共起頻度
#=>["にわに" => 3, "わにわ" => 3, "にわと" => 1, "わとり" => 1, "とりが" => 1, "りがい" => 1, "がいる" => 1]
```
gemを使って重複率を出すと以下のようになります。
レーベンシュタイン距離と異なり重複率なので、数値が高いほど類似していることになります。

```ruby
Trigram.compare(
  "おばあさんは川へ洗濯に、おじいさんは山へ芝刈りに行きました。", 
  "おばあさんは川へ洗濯に、おじいさんは山へ芝刈りに行きました。"
)
#=> 1.0
Trigram.compare(
  "おばあさんは川へ洗濯に、おじいさんは山へ芝刈りに行きました。", 
  "おばあさんは山へ芝刈りに、おじいさんは川へ洗濯に行きました。"
)
#=> 0.8620689655172413
Trigram.compare(
  "おばあさんは川へ洗濯に、おじいさんは山へ芝刈りに行きました。", 
  "桃太郎は鬼退治に行きました。"
)
#=> 0.14705882352941177
Trigram.compare(
  "おばあさんは川へ洗濯に、おじいさんは山へ芝刈りに行きました。", 
  "ある日、浦島はいつものとおりおさかなをつって、帰ってきました。"
)
#=> 0.05660377358490566
```
n-gramのgemもあります。
[tkellen/ruby-ngram](https://github.com/tkellen/ruby-ngram)

作成者blog；
[Trigram という gem を作りました - milk1000cc's blog](http://milk1000cc.hatenablog.com/entry/2014/06/15/225640)


## おまけ
Tensorflowででてくるword2vecですが、すでにいくつかベクトル化対象ライブラリが作られているようですね。こっちも触ってみたいです。

- [sentence2vec](https://github.com/klb3713/sentence2vec)
- [category2vec](https://github.com/rakuten-nlp/category2vec)
- [paragraph2vec](https://github.com/largelymfs/paragraph2vec)

## さいごに
探してみればちゃんと色々とあるんだなあというのが正直な感想です。楽しかった…。
もっとこのあたりの技術も深堀してみたいところです。

※自分自身にわか知識なところもあるので、間違い等あればコメントいただけると嬉しいです。
※「このgemのが良いよ」「このgem面白いよ」も密かに募集しております。