2017.04.10

Word2Vec で見つけられなかった自分らしさに fastText で速攻出会えた話

D. M. です。昨今はテキスト解析が非常にやりやすい時代になりました。チーム内でも活発に検証・活用されており、私も流れに乗って Word2Vec や Doc2Vec を触りだしましたが、参考になる日本語の記事多いですね。よくあるのはニュース記事・青空文庫・ Wikipedia の解析ですが、各社の独自の文字列データ、しかも結構なサイズのデータを食わせて関連語を出す記事などもあったりして、実利用可能かどうかは関係無しに楽しそうです。

やりたいこと

類語判定について、ウェブ上では既に相当いろんな種類の記事を上げられていて凄いなあと思いつつ、結構簡単に見えたので私も何か検証しようと思いました。ただ同じことをやってもあまり面白みが無いですし小規模でも始められるようなことを考えて、ひとまず自分の Twitter のつぶやきを食わせて類語を見てみることにしました。今日はそんな初歩的な試みの紹介です。

そして結論的には「fastText すげー」になります。

おおまかな流れ

この手の検証においてやることは決まっており、だいたい以下の流れです。

1. データの用意
2. 形態素解析
3. ベクターのモデル化
4. 類似語の判定

この流れでデータを関数に食わせるとベクターでモデルをつくってくれて、モデルに単語を渡すとその元データ特有の関連語を返してくれるようになります。よく分からないけどここにたどり着いてしまった人は、「似てる意味の単語がすぐに判定できる最近の仕組み」ぐらいで理解してください。

言語的には Python で、今回使ったライブラリは Word2Vec の PySpark 版と gensim 版、そして fastText です。

基本的な仕組みの議論は別のページを参照してください。以下は面白かったリンクです。
「FacebookのfastTextでFastに単語の分散表現を獲得する」
http://qiita.com/icoxfog417/items/42a95b279c0b7ad26589
「word2vec, fasttextの差と実践的な使い方」
http://catindog.hatenablog.com/entry/2017/03/31/221644

学習させるデータ

twilog さんに私の Twitter の過去7年以上にわたるくだらないつぶやきが蓄えられており、すぐ使える CSV で用意されています。まさかこんなところで使えるなんて、ほんとありがとうございます。 35000 ツイートほどありましたがサイズにするとたったの 4M でした。世間では数ギガ以上があたりまえなのに、こんな小さいデータを学習させてなんだよというのもありつつ、もしかしたらコイツを解析したら「自分らしさ」が見つかるのでは、という変な期待感が高まります。
http://twilog.org/matsuitter

日本語の文章は形態素解析で単語に区切ってから検証します。仕組み的には MeCab + mecab-ipadic-NEologd が定番なのであまり悩まずこれらを採用してデータを加工します。
mecab-ipadic-NEologd
https://github.com/neologd/mecab-ipadic-neologd
今回 python から呼びたくなったので少しだけ janome を使ってみたりしています。

ここから以下の3種類を実行してその結果を確認していきます。

A. PySpark の Word2Vec
B. gensim の Word2Vec
C. fastText

PySpark の Word2Vec

今回は個人の小規模データでの実験的なのであまり細かいことは考えずに jupyter notebook で janome で形態素化を行い PySpark でベクターのモデルを生成してみます。 jupyter とその関連ライブラリのインストールは pyenv, anaconda から pip, conda で入れてます(インストール手順はググると結構出てくるので割愛します)。ファイルの管理は以前検証のためローカルに設定した Hadoop まわりを使っています。
from pyspark.mllib.feature import Word2Vec
from janome.tokenizer import Tokenizer

# HDFSから取り出し
lines = sc.textFile('hdfs://localhost:9000/user/hadoop/data.txt')
lines_noempty = lines.filter( lambda x: len(x) > 0 )
parts = lines_noempty.map(lambda l: l.split("\") )

# jenome の形態素解析でトークンに分ける
df = parts.toDF()
dfr=df.toPandas()
t = Tokenizer()
words = []
for index, row in dfr.iterrows():
  for token in t.tokenize(row[0]):
    words.append(token.surface)

rdd = sc.parallelize(words).map(lambda row: row.split("\"))
#モデル化。次元数150です。これ以上でやると落ちてしまったw
word2vec = Word2Vec()
model = word2vec.setVectorSize(150).setSeed(20871).fit(rdd)
model.save(sc, "hdfs://localhost:9000/user/hadoop/model")

# ここからが類語判定
synonyms = model.findSynonyms('筋肉', 10)

for word, cosine_distance in synonyms:
    print("{}: {}".format(word, cosine_distance))
類語判定対象は私が継続的につぶやいている「仕事」「筋肉」「富士山」「ラーメン」を選びます。

処理の結果、出できた類語は以下ですが、全然ダメな雰囲気w 誰が読んでもピンと来ないのでは。やっぱりデータが小さすぎたか。。

1.仕事
MUSE: 0.3783616021594515 ← ?
文章: 0.3100311801528407 ← たまにブログを書いたりするから関係アリ
キートン: 0.2847044793350634 ← ?
尊敬: 0.27174352458234424 ← 仕事で尊敬できる人の話がよくあるので関係アリ
延長: 0.27041955036762577 ← 残業とかで関係アリ
夫: 0.26663998576948944 ← ?
音響: 0.25603812189450015 ← ?
嵐: 0.2552942749580388 ← ?

2.筋肉
グラビティ: 0.28605407887658746 ← ?
can: 0.2798945957644566 ← ?
かみ: 0.2512155396044685 ← ?
ベルト: 0.24959395171514098 ← 筋トレ用ベルトがあるので、ちょっと関係ある
ジョーク: 0.24934157750223407 ← ?
su: 0.2456519141627834 ← ?
909: 0.24560422183573816 ← ?
界隈: 0.24130234180112695 ← ?

3.富士山
咽頭: 0.30112144672673397 ← 喉は痛かったので関係アリ
イガイ: 0.2879281185330836 ← 同上
新木場: 0.28326818911511703 ← ?
tinyurl: 0.27515938192371514 ← ?
なきゃ: 0.27343430637697624 ← ?
Perfume: 0.2515631822737706 ← ?
配布: 0.24948580822046948 ← ?
セカイ: 0.2483281097082898 ← ?
あした: 0.247031003574834 ← ?

4.ラーメン
叩く: 0.31538816668561653 ← ?
than: 0.2969228373614542 ← ?
ぶり: 0.29669673514745104 ← ?
地震: 0.28502703382291794 ← ?
落ち込ん: 0.26641921819098957 ← ?
選ば: 0.2627711553887713 ← ?
たぶん: 0.26270020547136835 ← ?
移転: 0.2626358831968759 ← ラーメン屋の移転はよくあるので関係アリ
BAR: 0.2624064407496862 ← 飲んだ後にラーメンはよくあるので関係アリ
我々: 0.25963329249425315 ← ?

janome がいけなかったのか、次元数が少なかったのかなど考えましたが、先に進まなくなったのでやり方を変えてみます。

gensim の Word2Vec

gensim ライブラリにも Word2Vec の実装があるので、素の Python から呼ぶことができます。今度はあらかじめ MeCab で形態を行ったデータを食わせて解析してみます。

Word2Vec の MeCab + gensim でミニマムで何かデータを検証したいときは以下の手順が簡潔でよいです。
http://tjo.hatenablog.com/entry/2014/06/19/233949

実装も上記サイトのほとんどコピペですがこんな感じです。次元数だけ500にしています。
from gensim.models import word2vec
data = word2vec.Text8Corpus('data.txt')
model = word2vec.Word2Vec(data, size=500)

print('1.仕事')
out=model.most_similar(positive=[u'仕事'])
for x in out:
  print(x[0],x[1])
で、出てきた結果ですが、少し改善。でもまだよく分からない単語があり微妙な感じです。

1.仕事
せい 0.935478925704956 ← ?
友達 0.9344775676727295 ← ?
早く 0.9251697659492493 ← そんなに急かしているのだろうか。。
体調 0.9193316102027893 ← 体調を仕事に絡めて書くので関係アリ
意識 0.9147225618362427 ← 意識が高いとかで関係アリ
飲ん 0.9118010997772217 ← 仕事の付き合いで飲むので関係アリ
気合い 0.910029947757721 ← 体調を仕事に絡めて書くので関係アリ
結局 0.9096153974533081 ← ?
眠く 0.9077114462852478 ← ?
会社 0.9046440720558167 ← 関係アリ

2.筋肉
痛 0.9620963335037231 ← 筋肉痛!関係アリ
体 0.9554515480995178 ← 肉体!
午後 0.9552392959594727 ← 時間を絡めて書いたりするので関係アリ
頭痛 0.9536514282226562 ← 頭痛があるときは止めるので関係アリ
腫れ 0.9512921571731567 ← 喉の腫れとか怪我の腫れとかで関係アリ
パワー 0.9502456784248352 ← そのままです。関係アリ
リラックス 0.9501956701278687 ← ストレッチしてリラックスするので関係アリ
少し 0.949846625328064 ← ?
気合い 0.949773907661438 ← 仕事も筋トレも気合か!?関係アリ
早起き 0.94791579246521 ← 早朝トレーニングするので関係アリ

3.富士山
線 0.9504705667495728 ← ?
MAX 0.9471280574798584 ← 全力を出しているので関係アリ
セール 0.9462442994117737 ← ?
ジム 0.9449520111083984 ← 富士山向けのトレーニングしたので関係アリ
ゴールド 0.9444513320922852 ← これはジムです。関係アリ
サンダル 0.9444265961647034 ← ?
強風 0.9413601160049438 ← 富士山は強風なので関係アリ
出発 0.9388644695281982 ← 登山は出発時間が早いので関係アリ
ござる 0.9386403560638428 ← ?
受付 0.9385247230529785 ← ?

4.ラーメン
屋 0.830118715763092 ← まあラーメン屋のことでしょう
お 0.8291407823562622 ← ?
カレー 0.8168304562568665 ← ラーメンかカレーかで悩んだりする
喜楽 0.8154903650283813 ← ラーメン屋の名前!
代々木 0.7659465074539185 ← よく代々木でラーメン食べてます
麺 0.7494244575500488 ← そのままです
丸亀 0.7442017793655396 ← 丸亀製麺かラーメンかでよく悩むのです
つけ 0.7441257238388062 ← もちろんつけ麺ですね
526 0.7425122857093811 ← ラーメン屋の名前です!1番よく行く
うまい 0.7418333292007446 ← そのままです。ラーメンはうまい!

正直まだ精度が高いとは言えないです。関連理由の分からない単語が目立ちます。やっぱりデータが少ないとそんな簡単にいい結果出ないんですかね。

fastText にすがりつく

上記結果がやべぇつまんねーどうしようと思っていたら、2016年9月ごろ Facebook が発表した fastText が同様の機能があるようです。何か出るかもしれないと思いこちらも実行してしまいました。
https://github.com/facebookresearch/fastText
fasttext

fastText を Python から呼ぶ方法は以下の URL にあります。ただ私のマシンはメモリ 1G しか当てておらず jupyter だと落ちまくってしまったので仕方なく Vagrant の CentOS 7 でコマンドラインで実行します。メモリあれば jupyter でも動きますし、次元数 dim をデフォルトの 100 から 50 に減らすと大丈夫でした。
https://pypi.python.org/pypi/fasttext

仮に Python にこだわる場合は上のリンクのサンプルコードがそのまま使えます。こんな感じ。
import fasttext

# Skipgram model
model = fasttext.skipgram('data.txt', 'model_fasttext',thread=2,dim=50)
print (model.words) # list of words in dictionary
本命のコマンドラインでの実行方法ですが、インストールは公式どおりですんなりです。 C++ のコンパイル環境が必要で Vagrant の CentOS 7 では手順どおりコマンドを叩けば入ります。
公式サイトの説明を見つつ、以下のチュートリアルにも大変助けられました。感謝。
https://github.com/icoxfog417/fastTextJapaneseTutorial


いよいよ実行です。データは先ほど上のパートで Mecab で分けたものを使います。
で、適当にデフォルトで実行したら即死。理由がよくわからない。。
[root@localhost fastText]# ./fasttext skipgram -input /tmp/data.txt -output /tmp/model_fasttext
Read 0M words
Number of words: 9757
Number of labels: 0
Progress: 0.3% words/sec/thread: 1818 lr: 0.049873 loss: 4.141279 eta: 0h18m 
Killed
Vagrant の cpu 割当が 2 つだったので thread 指定を付けたらうまく実行できました。未指定だとデフォルト 12 スレッドだった模様。処理は大体 10 分ぐらい。
[root@localhost fastText]# ./fasttext skipgram -input /tmp/data.txt -output /tmp/model_fasttext -thread 2
Read 0M words
Number of words: 9757
Number of labels: 0
Progress: 100.0% ...
元データが 4M なのに bin 形式モデルが 700M になったw すごいサイズ!( Word2Vec では両方ともたった数 K でした)
[root@localhost tmp]# du -sh /tmp/model_fasttext*
771M    /tmp/model_fasttext.bin
8.3M    /tmp/model_fasttext.vec
vec はテキストなのでどんな単語が学習されたか grep で確認することができます。公式によると bin のほうは細かいパラメータなどが全部含まれているようで、学習で再利用できるとあります。今回は単純に vec を読み込むます。

いよいよ類語判定の実行です。
[root@localhost fastTextJapaneseTutorial]# python eval.py --path /tmp/model_fasttext.vec [単語]
そして、なんと、そこには自分がいたんです。

1.仕事
タスク, 0.824053106789495 ← 仕事のタスクのことをもやもやとよくつぶやいてます
今期, 0.8166622312380359 ← 仕事の時期の話ですね
調整, 0.813968848998159 ← 仕事は調整の連続です
病気, 0.807290285917658 ← 病気になって病院に行く話を仕事に絡めてよく書きます
支障, 0.8019433708372298 ← 仕事の支障についてよく書いています

2.筋肉
痛, 0.9527062348441334 ← 筋肉痛!関連アリアリ
関節, 0.9150052196059146 ← 筋トレ中はよく関節痛になります!
歯茎, 0.8725194917605528 ← 筋トレ中は歯を食いしばるので歯茎の話をよく書きます!
クレアチン, 0.8597745873839727 ← これは筋トレ用のサプリメントです!
スクワット, 0.858590970433407 ← トレーニング方法!
肉体, 0.8561877790687893 ← トレーニング結果!
食後, 0.8178128938809561 ← トレーニングと食事はセットなのでよくこの話題を書きます!
アレルギー, 0.810840237152749 ← 食事関係のつぶやきと思います。
浅い, 0.809404573099218 ← スクワットが浅いとかトレーニング方法の話ですね
カロリー, 0.8063114156674 ← トレーニング後の食事の話ですね

3.富士山
御殿場, 0.876296379715726 ← 富士山の出発口です!
合, 0.8721735159668427 ← 5合目とかの意味ですね!
梅雨, 0.8714383324644334 ← 7月上旬に登ると梅雨明けするかが結構気になるんですよ!
閉まる, 0.8684450952855024 ← これは登山中の山小屋が閉まっている話ですね
明け, 0.86589095494587 ← 梅雨明けですね
頂上, 0.8524127501747857 ← もちろん富士山の頂上!
ルート, 0.8261120151856047 ← 富士山の登山道は4つあるのでその話です
念願, 0.8192078768627398 ← 登山ルートのパターンを制覇したときの話ですね
週, 0.8090949232450433 ← 7月8月の何週目に登るかっていう話題です。休みの調整とか

4.ラーメン
526, 0.909034563786487 ← 1番行くラーメン屋です
とんこつ, 0.8845549029950073 ← もちろんラーメンの種類です
ラー油, 0.8831427200294618 ← よくいれています
インスパイア, 0.8607999489329696 ← ラーメン屋526が二郎インスパイアです
丼, 0.8588934369039417 ← これはラーメン+丼セットです。よく食べています
家系, 0.8512688808821522 ← もちろんラーメンの種類です
ニンニク, 0.8368661865267447 ← 二郎にはニンニクを入れます
焼きそば, 0.8206831050179679 ← 焼きそばとラーメンはよく悩んでいます
餃子, 0.8121795492701136 ← ラーメン+餃子ですね

非常に個人的なことなのでこの感動を皆さんに伝えられないのは本当に残念ですがこの類似語は完全に私です。過去につぶやいた内容が頭に浮かぶようです。大興奮!!

見やすいパラメータを比較してみます。まず次元数。 Word2Vec の場合、私のマシンだと PySpark + jenome は次元数 300 で落ちてしまったので 150 にしてあの体たらく、 gensim + MeCab は次元数 500 ですが正直マズマズな結果でした。 fastText では次元数どうなってるのかとか思いましたが、デフォルトは 100 でした。またエポック数(イテレート回数)は gensim がデフォルト 5 で fastText も 5 でした。
ほかのパラメータをもうちょっとちゃんと読まないといけないのですが、すいませんまだ調査中です。ただ fastText の場合はノーチューニング、しかも小規模データでも充分納得のいく結果が出たということは言えると思います。

最後にパラメータの日本語訳を記載しておきます。
https://github.com/facebookresearch/fastText

-input training file path インプットするデータのパス
-output output file path 出力するモデルのパス
-lr learning rate [0.05] 学習率または学習係数
-lrUpdateRate change the rate of updates for the learning rate [100] 学習率の変化率
-dim size of word vectors [100] 次元数。デフォルト100
-ws size of the context window [5] ウィンドウサイズ。関連対象となる単語の前後の範囲
-epoch number of epochs [5] 全データを繰り返して学習する回数
-minCount minimal number of word occurences [5] 最低登場回数。少ない単語は無視する
-neg number of negatives sampled [5] ネガティブサンプリングの個数
-wordNgrams max length of word ngram [1] 単語 ngram の最大単位
-loss loss function {ns, hs, softmax} [ns] 損失関数。デフォルトは Negative Sampling
-bucket number of buckets [2000000] ngram の単語と文字をハッシュかするときのサイズ
-minn min length of char ngram [3] 文字 ngram の最小単位
-maxn max length of char ngram [6] 文字 ngram の最大単位
-thread number of threads [12] 平行で処理を動かすスレッド数。デフォルト12
-t sampling threshold [0.0001] どの高頻度ワードがランダムにダウンサンプリングされるかを構成するための閾値
-label labels prefix [__label__] ラベル接頭辞。カテゴライズの一助となります
-verbose verbosity level [2] ログ設定
-pretrainedVectors pretrained word vectors for supervised learning 教師アリ学習の事前トレーニングデータ .vecファイルのこと

分からないパラメータをググりながら動かしている状態ですが、細かい値の意味については以下が詳しいです。
「fastTextの実装を見てみた」
https://www.slideshare.net/shirakiya/fasttext-71760059
「Continuous Skip-Gramモデルのソフトマックス戦略の機能的等価物としてのネガティブ・サンプリング(negative sampling)」
https://media.accel-brain.com/negative-sampling/
“What does the bucket option do?”
https://github.com/facebookresearch/fastText/issues/24
「TensorFlowを使って学習率による動きの違いを確認する」
http://qiita.com/isaac-otao/items/6d44fdc0cfc8fed53657
“FastText for semi supervised text classification”
https://github.com/facebookresearch/fastText/issues/103

最後に

私の根源的な思いとしては、人間が読んで「いい文章」という概念を AI で判定したいと考えていて、今回の検証でそんな未来がすぐ近くにくるんじゃないかという気がしました。ひとまず「その人らしさ」を判定する技術はものすごく近いと肌で感じたところです。

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

Pocket