Ruby Advent Calendar 2021 21日目の記事です。
FastTextを利用して「Ruby」に一番近い単語は何かを調べてみました。
きっかけ
先日行われた RubyWorld Conference 2021 はご覧になられましたでしょうか。そこで発表された話の中にTwitterのトレンドを可視化するという話がありまして、形態素解析をして固有名詞のみを抽出し、共起確率を算出し、関連を図示するというような話だったと思います。これを聞いてみて面白そうだなーと思ったのでまずは形態素解析に手を出してみることにしました。
形態素解析してみる
形態素解析といえばMeCab、それをRubyで扱うならNattoという話をよく聞くので、今回はそれを用いることにします。
というか、実は形態素解析自体はRubyKaigi2019の@youchanさんの発表を聞いて興味を惹かれてやってみたことあるんですよね。ただその時は名詞、特に人名や地名や創作物の固有名詞の抽出がイマイチだったんですよね...。
まずはMeCabのインストールから 辞書には発表でも高性能辞書との説明があったNEologdを利用することにします。
$ brew install mecab
$ git clone [email protected]:neologd/mecab-ipadic-neologd.git
$ bin/install-mecab-ipadic-neologd -n -a
/usr/local/etc/mecabrc に使用する辞書を設定する項目があるので NEologd を設定しておきます。
- dicdir = /usr/local/lib/mecab/dic/ipadic
+ dicdir = /usr/local/lib/mecab/dic/mecab-ipadic-neologd
$ mecab
鬼滅の刃~無限列車編~
鬼滅の刃 名詞,固有名詞,一般,*,*,*,鬼滅の刃,キメツノヤイバ,キメツノヤイバ
~ 記号,一般,*,*,*,*,*
無限 名詞,一般,*,*,*,*,無限,ムゲン,ムゲン
列車 名詞,一般,*,*,*,*,列車,レッシャ,レッシャ
編 名詞,接尾,一般,*,*,*,編,ヘン,ヘン
~ 記号,一般,*,*,*,*,*
EOS
少し前に流行っていた「鬼滅の刃」が固有名詞として抽出できています。すごい。ちなみに辞書にipadicの方を使ってやると...
$ brew install mecab-ipadic
$ mecab -d /usr/local/lib/mecab/dic/ipadic
鬼滅の刃~無限列車編~
鬼 名詞,一般,*,*,*,*,鬼,オニ,オニ
滅 名詞,一般,*,*,*,*,滅,メツ,メツ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
刃 名詞,一般,*,*,*,*,刃,ハ,ハ
~ 名詞,サ変接続,*,*,*,*,*
無限 名詞,一般,*,*,*,*,無限,ムゲン,ムゲン
列車 名詞,一般,*,*,*,*,列車,レッシャ,レッシャ
編 名詞,接尾,一般,*,*,*,編,ヘン,ヘン
~ 名詞,サ変接続,*,*,*,*,*
EOS
ね。
で、まずはこれをNatto使ってRubyでやってみます。
gem install natto
require 'natto'
puts Natto::MeCab.new.parse('鬼滅の刃~無限列車編~')
# 鬼滅の刃 名詞,固有名詞,一般,*,*,*,鬼滅の刃,キメツノヤイバ,キメツノヤイバ
# ~ 記号,一般,*,*,*,*,*
# 無限 名詞,一般,*,*,*,*,無限,ムゲン,ムゲン
# 列車 名詞,一般,*,*,*,*,列車,レッシャ,レッシャ
# 編 名詞,接尾,一般,*,*,*,編,ヘン,ヘン
# ~ 記号,一般,*,*,*,*,*
# EOS
ところでRubyでMeCabを扱うgemはNatto以外にもMecabがあります。NattoがMeCabより優れている点としては、parse
の結果に対してブロックを渡した時に、各形態素について詳細にアクセスできる点にあるんだと思っています。ですので、「固有名詞のみ取り出す」ということがより簡単にできるわけですね。
require 'natto'
Natto::MeCab.new.parse('鬼滅の刃~無限列車編~') do |m|
puts m.surface if m.feature.split(?,)[1] == '固有名詞'
end
# 鬼滅の刃
配列で取り出すためにmapを使いたいですよね、できます。
require 'natto'
Natto::MeCab.new.enum_parse('鬼滅の刃~無限列車編~').filter_map do |m|
m.surface if m.feature.split(?,)[1] == '固有名詞'
end
# ["鬼滅の刃"]
で、形態素解析して次何するの?
発表にあったトレンドの関連図みたいなのは、これをツイートごとに抽出してツイート内に含まれる各形態素の共起確率を算出すればそれなりに形になりそうです。
このまま相関図を頑張って作ってみてもいいんですが、ちょっと投稿まで時間がないので方向性を変えて、単語分散表現で話題(?)になったFastTextを使って「Ruby」に一番近い単語をRubyで出力してみることにしました。
単語分散表現で有名な話は King - Man + Woman = Queen の単語演算でしょうか。これは各単語の位置関係がベクトルで表されるため、ベクトルの計算をすれば対応する単語が算出できるというやつですね。正直あんまり詳しいわけじゃないんですが...。
単語分散表現のモデルを取得するのにFastTextを利用することにしました。これはちょっとググるとお勧めされていたので選んだくらいで特に理由はありません。
これはネタバレなんですが、FastTextをRubyから使えるライブラリが運良くあったので、ここから出力までは一瞬で終わりました。
fasttext-rubyというライブラリがあったのでインストール
gem install fasttext
require 'fasttext'
model = FastText.load_model("cc.ja.300.bin")
model.nearest_neighbors('Ruby')
# {"Python"=>0.7062287330627441,
# "Rails"=>0.6968273520469666,
# "Clojure"=>0.692092776298523,
# "Perl"=>0.6689276695251465,
# "Scala"=>0.6536468863487244,
# "OCaml"=>0.6482565999031067,
# "Erlang"=>0.6389164328575134,
# "JRuby"=>0.6374729871749878,
# "RubyMotion"=>0.6327024102210999,
# "Haskell"=>0.6306641101837158}
はい、おしまい。「Ruby」に一番近い単語は「Python」でした。
おいおい、雑すぎでは?
本当は自分で学習データを用意してモデルを作って算出しようとしたんです。学習データを作る段階で形態素分析も実際に使うしちょうどいいかなと。ただ、それぞれの処理が重すぎて何十時間とめちゃくちゃ時間かかる上に、出来上がったモデル使ってRubyに近い単語を調べてみると...
model.nearest_neighbors('Ruby')
# {"Rugby"=>0.8860356211662292,
# "byebye"=>0.8142902255058289,
# "bys"=>0.8103272914886475,
# "クアンザ・ノルテ州"=>0.8091336488723755,
# "BioRuby"=>0.8083751797676086,
# "井籠"=>0.8072595000267029,
# "宮内美沙子"=>0.8058592677116394,
# "by…"=>0.8054324984550476,
# "ルンダ・ノルテ州"=>0.8051073551177979,
# "Cubby"=>0.8049549460411072}
とまぁこんな感じで散々な結果になってしまったのですよ。結果が出るところまでちゃんと機械学習取り組んだの初めてだったので、機械学習の難しさというか投入するデータの質の重要性を身にしみて感じましたね。
一応、今回の失敗の原因はなんとなくわかっていて、文章をきちんと分類ごとに分割できていなかったことだと思います。ラベルもつけずに適当に段落とかで分割して配列にしちゃったんですよね。文全体として持つ意味があまりにも弱く、そこから算出した単語ベクトルが正しいベクトルを持てなかったのかなと思っています。
結果はこんな感じで残念だったのですが、実際に教育して結果を出すまでの流れをおまけとしてざっと書いておきます。
おまけ
まずは、学習データを作成します。作成した学習データは最終的に文章ごとの配列にして、配列のそれぞれの要素は重要な単語のみを抽出した分かち書きにします。これを下のように教育用メソッドの引数に渡すことで単語ベクトルを教育することができます。ちなみに
配列のそれぞれの要素は重要な単語のみを抽出した分かち書きにします。
というのはめちゃくちゃ適当を言ってます。配列のそれぞれの要素にどういう文章を渡すのがベストなのかはわかってません。
require 'fasttext'
model = FastText::Vectorizer.new
model.fit([sentence1, sentence2, sentence3, ...])
分かち書きと言うのは形態素間にスペースを入れた分のことで、「吾輩は猫である。名前はまだ無い。」であれば「吾輩 は 猫 で ある 。 名前 は まだ 無い 。」のようになります。ちなみに辞書にNEologdを使っていると「吾輩は猫である」が固有名詞として抽出されました。良いのか悪いのか...。
Natto::MeCab.new.enum_parse('吾輩は猫である。名前はまだ無い。').map(&:surface).join(' ')
# "吾輩は猫である 。 名前 は まだ 無い 。 "
「重要な単語のみを抽出」の部分はそもそも適当を言ってるんですが、おそらく目的によって変わってくると思います。今回はとりあえず「名詞と動詞のみ」を抽出しました。本格的にやるなら、単語ごとの頻出度や希少度を算出し、文の意味を象徴するような単語を抽出できるといいのだと思います。これもそんなに詳しいわけではないですが、TF-IDF Cos類似度 とか言われるものだと思います。
学習データの元はなんでもいいです。本当は目的にあったデータがいいんですが、今回はお遊びなのでWikipediaのデータとか使うといいと思います。
wikipediaのデータはxmlになっているので、ここからテキストを抽出します。僕はwp2txtというライブラリを使用しました。数年メンテされてないですが一応動くのと、作者が日本人かつバージョンアップさせるつもりがあるので困ったらプルリクも出しやすいです。
gem install wp2txt
wp2txt --input-file jawiki-latest-pages-articles.xml.bz2
ちなみに wp2txt --input-file
に渡せるファイルは拡張子が.bz2
もしくは.txt
のもののみなので注意です。
処理が終わると、複数個のtxtファイルが生成されていますので、あとはこれを適当な文章ごとに分けて冒頭の形態素解析にかけて必要な単語を抽出し、分かち書きの文章の配列にしてメソッドに渡すだけです。
ここまで頑張っても、おそらくFastTextが配布している教育済みモデルの方が期待する答えを返してくれると思います。配布されているものよりも納得のいく結果を返してくれるモデルを教育してみたいものですね。もしかするとこれが機械学習沼の入り口なのかもしれません。
今回紹介したfasttext-rubyというライブラリは、まだ機能があまりありません。Pythonのライブラリであれば冒頭に話した単語の演算もできますが、このライブラリではまだできないようです。本格的にそういうことをしたければPythonを使うかfasttext-rubyにPRを出してくことになると思います。
私事ですが、社内で特定の業種向けに特化したチャットボットを内製したいという話があり、そのために勉強ついでに遊んでみたんですが、いざやるとなってもなんとかなりそうということがわかったのでやってみてよかったです。
Rubyで機械学習といえばRed Data Toolsが打倒Pythonを掲げて(いるのかは知りませんが)頑張っているので、もしこの領域に本格的に踏み入ることになれば何かのお手伝いをしたいなぁとか思っているので、もし機会があればその時はどうぞよろしくお願いいたします。
どうでもいい話をします
ギリギリまで何書くか全然決まらなくて、Ruby関連の本を読んでるのでそれの感想でも書こうかとすら思っていましたが、ギリギリでRubyのイベントに感化されて、雑ではあるんですがなんとか記事を書きあげることができてよかったです。先日PCが壊れた時はもうダメだと...。
形態素解析とか単語分散表現とか僕自身全然わかってないんですけど、この記事を見たきっかけでやってみたって人が、詳しくなって僕にレクチャーしてくれることを期待しています。詳しくなられたらぜひ@buta_bottiまでご連絡お待ちしております。
ゲームのお誘い
最近「among us engineer」というdiscordのサーバーでAmong Usしたり、麻雀したり、マリオパーティーしたりして遊んでます。メインは毎週金曜日にやるAmong Usなんですが、参加人数が毎回ギリギリなので、参加していただけると嬉しいなぁと思ってます。Ruby勢だと@mametterさん@sinsoku_listyさんもいます。ぜひぜひ。こちらも興味があれば@buta_bottiまでご連絡お待ちしております。
では。