Apache spark Word2Vec で関係性を加味した類似語を求める

[Apache spark で Word2Vec](https://qiita.com/blueskyarea/items/93024eba7fc5500b77c4)の続きになります。

Word2Vec では単語と単語の関係性をベクトルの演算で表現できます。
例えば、king から man の属性を引き、woman を加えると queen が導かれるようなイメージです。
“king”－“man”＋“woman”=“queen”

#### テーマ

[青空文庫](https://www.aozora.gr.jp/index.html)で公開されている "吾輩は猫である" を読み込ませて Word2Vec を用いてモデルを作成します。
そのモデルに対して、関係性を加味した類似語を求めてみます。

#### 事前準備
テキストには、ルビなどを示す記号が含まれているため、それらを除去しておきます。
そして、/data/mllib/word2vec-2 配下に保存しておきます。

```bash
$ sed -i 's/《.*》\|［＃.*］\|｜\|〔.*〕//g' wagahai.txt
```

#### コアプログラム
実行は spark-shell を用いますが、コアとなるプログラムについては事前にファイルに書いておいて、それをロードして使います。以下がそのプログラムになります。
読み込んだテキストをトークン化（文字列の分割）する tokenize() と、類似語を計算する getSynonymWords() を定義しておきます。

```scala:word2vec.scala
import java.io.StringReader
import org.apache.spark.mllib.feature.{Word2Vec, Word2VecModel}
import org.apache.lucene.analysis.ja.JapaneseTokenizer
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute
import org.apache.lucene.analysis.ja.tokenattributes.BaseFormAttribute
import org.apache.lucene.analysis.ja.tokenattributes.PartOfSpeechAttribute
import org.apache.spark.mllib.linalg.Vectors

import scala.collection.mutable.ArrayBuffer
def tokenize(sentence: String): Seq[String] = {
  val word: ArrayBuffer[String] = new ArrayBuffer[String]()
  lazy val stream = new JapaneseTokenizer(
    new StringReader(sentence),
    null,
    false,
    JapaneseTokenizer.Mode.NORMAL)

  try {
    while(stream.incrementToken()) {
      var charAtt = stream.getAttribute(
        classOf[CharTermAttribute]
      ).toString

      var bfAtt = stream.getAttribute(
        classOf[BaseFormAttribute]
      ).getBaseForm

      var partOfSpeech = stream.getAttribute(
        classOf[PartOfSpeechAttribute]
      ).getPartOfSpeech().split("-")(0)

      (partOfSpeech, bfAtt) match {
        case ("名詞", _)  => word += charAtt
        case ("動詞", null)  => word += charAtt
        case ("動詞", baseForm)  => word += baseForm
        case (_, _)  =>
      }
    }
  } finally {
    stream.close
  }
  word.toSeq
}

def getSynonymWords(r1: String, r2: String, word: String, model: Word2VecModel): Array[(String, Double)] = {
  val src1 = breeze.linalg.Vector(model.getVectors(r1))
  val src2 = breeze.linalg.Vector(model.getVectors(r2))
  val target = breeze.linalg.Vector(model.getVectors(word))
  val targetWithRelation = target + (src2 - src1)
  model.findSynonyms(Vectors.dense(targetWithRelation.toArray.map(_.toDouble)), 10)
}
```

#### spark-shell を起動する
spark(今回は version 2.1.1) を使用しています。
-i オプションで上記の word2vec.scala を読み込みつつ spark-shell を起動します。
これで spark-shell 内で tokenize() と getSynonymWords() を使用することができます。

```bash
/usr/hdp/2.6.2.0-205/spark2/bin/spark-shell --master local --deploy-mode client --packages org.apache.lucene:lucene-kuromoji:3.6.2 --conf spark.serializer=org.apache.spark.serializer.KryoSerializer -i /spark/src/mllib/word2vec.scala
```

#### コマンド実行
以下、spark-shell でのコマンドです。
1.テキストファイル読み込み
予め用意しておいたテキストファイルを読み込み、トークン化します。

```bash
val input = sc.textFile("/data/mllib/word2vec-2").map(line => tokenize(line))
```

2.単語出現数の下限数と生成する単語のベクトルの次元数をセット

```bash
val word2vec = new Word2Vec()
word2vec.setMinCount(3)
word2vec.setVectorSize(30)
```

3.モデルの生成
以上のステップでモデルが完成します。

```bash
val model = word2vec.fit(input)
```

4.関係性を加味した類似語を探す
私⇒人間 の関係性に対して、猫⇒人間 が推測されていることになります。
一般的には 猫⇒動物 と考えられそうですが、猫が人間のように描かれている小説であるため、この結果自体には違和感はないと思います。

```bash
getSynonymWords("私","人間","猫",model)
res2: Array[(String, Double)] = Array((人間,0.9024111003792761), (申す,0.8644184750169329), (者,0.8520097131666761), (もの,0.84971317882835), (打つ,0.8355519738235462), (吾輩,0.8329425382047636), (容赦,0.8295952706147414), (思う,0.8287090333122259), (甲,0.8275915949463694), (事件,0.8273686781030468))
```

他の例を見てみると、男⇒女 の関係性に対して、伯父⇒祖母 は辛うじて分かりますが、それ以外は推測されている意味が分かりません。

```bash
getSynonymWords("男","女","伯父",model)
res15: Array[(String, Double)] = Array((碁,0.8453217654328342), (毎月,0.8363989347717034), (紳士,0.8290600068004638), (祖母,0.828510628102892), (据,0.824090994336325), (もぐる,0.8229206529639936), (いくら,0.8217076493616556), (あれ,0.8202529119250722), (柿,0.8186006480467625), (たぎる,0.8115129920852875))
```

また、男⇒女の関係性に対して、彼⇒女 という結果も得られました。

```bash
getSynonymWords("男","女","彼",model)
res16: Array[(String, Double)] = Array((女,0.8764545716038742), (水,0.8503040881551271), (吾,0.830030327479066), (四つ,0.8224119408842357), (おだやか,0.8138338653641275), (三,0.8058477340727931), (働,0.7940961238401808), (保護,0.7886006533220054), (習慣,0.7885285547520795), (風邪,0.7874742971018587))
```

"彼女"と結果が出ればバッチリでしたが、そもそも"彼女"という単語が小説内で1度しか出てきておらず、モデルに単語が登録されていなかったようです。

```bash
model.findSynonyms("彼女",5)
java.lang.IllegalStateException: 彼女 not in vocabulary
```

その他の主要な単語も調べてみると、"吾輩" の類似語のトップが "事件" や "働" であったり、"猫" に対しても "水" や "ため" であったりしました。
共通して出たのは "保護" という単語ですが、特に小説内で "吾輩" や "猫" が "保護" されるような箇所はないため、どのように推測されているのか理解が難しいところです。

```bash
model.findSynonyms("吾輩",5)
res8: Array[(String, Double)] = Array((事件,0.8872753095002464), (働,0.8790613435155954), (保護,0.8639525373437402), (いう,0.8599642072826214), (芸術,0.8596569602093977))
```

```bash
model.findSynonyms("猫",5)
res9: Array[(String, Double)] = Array((水,0.8945408332617345), (ため,0.8559240176609174), (保護,0.8081953536934757), (吾輩,0.7901985849430482), (一,0.7817463337524215))
```

"主人" という単語は小説内でも出現頻度が高いです。
"うち" という単語がトップの類似スコアを持っていましたが、小説内では"うちの亭主"という表現が何度か出てきていますので、"うち(の亭主)" ＝ "主人" として解釈され、それが "うち" として推測されているのかもしれません。

```bash
model.findSynonyms("主人",5)
res10: Array[(String, Double)] = Array((うち,0.8927250488177164), (これ,0.8305789052095515), (皮,0.8074327990147161), (双方,0.7981844409788807), (違う,0.7853646794971598))
```

以上のように、納得感のある回答もある中で、理解が難しい結果も多々ありました。
小説という題材を元にモデルを生成しているため、表現力が豊かであるが故の学習の難しさがあるような気もします。