MarkLogic10のCNTKでword2vec

# はじめに
[MarkLogic10](https://developer.marklogic.com/products/marklogic-server/10.0)にCNTKが搭載されたこともあり、これを活用して自然言語処理を実装してみたいと考えて、まずは基本のword2vecをやってみました。

[前回の投稿](https://qiita.com/t2hk/items/ee6ef1dd6ffdda77626a)でKeras+CNTKでword2vecを実装してみました。このモデルをONNXで出力し、MarkLogic10で読み込んでいます。

今回のソースコードは以下に公開しております。
https://github.com/t2hk/marklogic_cntk_word2vec

# 環境
| 環境 | バージョン |
| --- | --- |
| MarkLogic | 10.0-1 |
| CentOS | 7.6 |
| CUDA | 10.1 |

# MarkLogic CNTKの処理の流れ
CNTKはdefine and runのフレームワークなので、ネットワーク構造を定義した後に学習・推論するデータを定義する流れになります。MarkLogicでもこの流れは同様です。

  1. 既存モデルを利用する場合、CNTKモデルあるいはONNXモデルを読み込む
  2. 入力データの形式を定義する
  3. ネットワークを構築する
  4. 出力データの形式を定義する
  5. 入力データを組み立てる（学習の場合は出力データも）
  6. モデルを実行し、学習・推論する

ネットワークの構築について、オリジナルのCNTKはSequential APIがありKerasと同様にレイヤーをリストで渡してネットワークを構築できます。しかし、MarkLogicのCNTKはこのAPIが用意されていません。KerasのFunctional APIのように、関数呼び出しでレイヤーを連結していきます。

今回はKeras+CNTKで構築したword2vecのONNXモデルを使用し、word2vecを使用した以下の処理を実装してみます。

* Cosine類似度
* top-k
* 類似単語の抽出
* 単語の類推

# Cosine類似度
word2vecで学習した2つの単語のCosine類似度を算出するモデルです。
関数名はcosine-distanceですがマイナス値を返すこともあり、距離ではなく類似度ではないかと思います。

ソースコードの全体は以下になります。
https://github.com/t2hk/marklogic_cntk_word2vec/blob/master/cosine-distance.xqy

流れは以下の通りです。

＜モデル定義＞
1. 学習済みONNXモデルのロード
2. 比較対象の2つの単語の入力パラメータを定義([cntk:input-variable](https://docs.marklogic.com/cntk:input-variable))
3. 単語のインデックス値をOneHot表現に変換するレイヤーを定義([cntk:one-hot-op](https://docs.marklogic.com/cntk:one-hot-op))
4. ロードしたモデルから単語の分散表現(Weight)を取得([cntk:function-constants](https://docs.marklogic.com/cntk:function-constants)、[cntk:constant-value](https://docs.marklogic.com/cntk:constant-value))
5. 取り出したWeightを元にembeddedレイヤーを定義([cntk:embedding-layer](https://docs.marklogic.com/cntk:embedding-layer))
5. Cosine類似度を算出するレイヤーを定義([cntk:cosine-distance](https://docs.marklogic.com/cntk:cosine-distance))
6. モデルの出力パラメータを定義([cntk:function-output](https://docs.marklogic.com/cntk:function-outputs))

＜入力データ定義＞
7. モデルへの入力データを組み立てる([cntk:batch-of-sequences](https://docs.marklogic.com/cntk:batch-of-sequences))

＜モデル実行＞
8. モデルを実行する([cntk:evaluate](https://docs.marklogic.com/cntk:evaluate))
9. 結果を取得する([cntk:value-to-array](https://docs.marklogic.com/cntk:value-to-array))

# top-k
JSON配列で渡された値の中の上位K位を返します。単語間のCosine類似度が近いものを取得する場合などに利用します。

ソースコードの全体は以下になります。
https://github.com/t2hk/marklogic_cntk_word2vec/blob/master/top-k.xqy

処理の流れは上記のCosine類似度と同じですが、top-kを取得するために使用するレイヤー[cntk:top-k](https://docs.marklogic.com/cntk:top-k)を使用しています。

＜モデル定義＞
1. 入力パラメータを定義([cntk:input-variable](https://docs.marklogic.com/cntk:input-variable))
2. 上位K位までを求めるtop-kレイヤーを定義([cntk:top-k](https://docs.marklogic.com/cntk:top-k))
3. モデルの出力パラメータを定義([cntk:function-output](https://docs.marklogic.com/cntk:function-outputs))

＜入力データ定義＞
4. モデルへの入力データを組み立てる([cntk:batch](https://docs.marklogic.com/cntk:batch))

＜モデル実行＞
5. モデルを実行する([cntk:evaluate](https://docs.marklogic.com/cntk:evaluate))
6. 結果を取得する([cntk:value-to-array](https://docs.marklogic.com/cntk:value-to-array))

出力結果は、上位K位の実際の値とその配列インデックスのペアになります。
例えば数値10～19のうちのトップ5を求めると以下の結果となります。

```
[[19, 18, 17, 16, 15]] # 実際の値
[[9, 8, 7, 6, 5]] # 上記値の配列インデックス
```

# 類似単語
Cosine類似度とtop-kを求められるようになると、類似単語を抽出できるようになります。

ソースコードの全体は以下になります。
https://github.com/t2hk/marklogic_cntk_word2vec/blob/master/most-similar.xqy

処理の流れはCosine類似度とtop-kの組み合わせになります。
入力した単語と学習した全単語のCosine類似度を算出し、そのtop-kを求める流れになります。

しかし、学習した全単語との突き合わせになるため処理に時間がかかります。そのためバッチ処理が必要になります。

バッチ処理用の入力データは[cntk:batch-of-sequences](https://docs.marklogic.com/cntk:batch-of-sequence)を使って組み立てます。
なお、MarkLogicの公式ドキュメントでは、関数名が「cntk:batch-of-sequence」と単数系で記載されていますが「cntk:batch-of-sequences」が正解です。

```xquery
(: バッチサイズを定義し、全単語数からバッチの実行回数を計算する。 :)
let $BATCH_SIZE := 5000
let $BATCH_TIMES := ($VOCAB_SIZE idiv $BATCH_SIZE ) + 1  
  
(: 比較元の単語を、比較先の全単語と同数だけリスト化する。:)
let $source-words := for $i in (0 to ($VOCAB_SIZE)) return $source-word
  
(: 比較先の全単語を用意する。単語はインデックス値なので0から全単語数の連番となる。:)
let $target-words := for $i in (0 to $VOCAB_SIZE) return $i  
 
(: バッチ処理用のデータを組み立て、モデルを実行する。:)
let $results := 
  for $i in (1 to $BATCH_TIMES)
    (: 1回のバッチで処理する単語の開始と終了位置を算出する。:)
    let $start := ($i - 1) * $BATCH_SIZE + 1
    let $_end := $start + $BATCH_SIZE - 1    
    let $end := 
      if ($_end > $VOCAB_SIZE) then $VOCAB_SIZE
      else $_end

    (: バッチ処理用に入力データを組み立てる。:)
    let $source-values := for $i in ($start to $end) return json:to-array(($source-words[$i]))
    let $target-values := for $i in ($start to $end) return json:to-array(($target-words[$i]))
    let $continues := for $i in (1 to ($end - $start + 1)) return fn:true()
      
    (:入力データの形式と値をペアにして、JSON配列として組み立てる。:)
    let $source-bos := cntk:batch-of-sequences(cntk:shape((1)), 
                       json:to-array(($source-values)), $continues, cntk:gpu(0))
    let $target-bos := cntk:batch-of-sequences(cntk:shape((1)), 
                       json:to-array(($target-values)), $continues, cntk:gpu(0))
    let $source-input-pair := json:to-array(($source-input-variable, $source-bos))  
    let $target-input-pair := json:to-array(($target-input-variable, $target-bos)) 
    
    let $input-pair := json:to-array(($source-input-pair, $target-input-pair))

    (: モデルを実行する。指定された単語に対する全単語のCosine類似度が算出される。:)
    let $result := cntk:evaluate($cos-distance-model, $input-pair, $output-variable, cntk:gpu(0))
    return json:array-values(cntk:value-to-array($output-variable, $result) )
```

「東京」の類似語を注した場合の実行結果の例は以下のようになります。類似度とその単語の配列を返します。
ここでは自分自身との比較も含めているため、トップは自分自身となります。

```
[[1, 0.5196232, 0.5111292, 0.4781487, 0.4708132]]
[[東京, 銀座, 大阪, 浅草, 東京都]]
```

# 単語の類推
ここまでできると類推も実装できるようになります。
おなじみの「東京 - 日本 + フランス = パリ」です。

ソースコードの全体は以下になります。
https://github.com/t2hk/marklogic_cntk_word2vec/blob/master/analogy.xqy

全体的な流れはこれまでのモデルと同様になります。
類推モデルのキモは、入力された3つの単語をOneHot表現に変換し、embeddedレイヤーから単語ベクトルを取得して、ベクトルの加減算を行う処理です。この辺りはNumpyなどが使えれば楽なのですが。。。

```xquery
  (:単語をOne-Hot表現に変換するレイヤー:)
  let $a-onehot := cntk:one-hot-op($a-input-variable, $VOCAB_SIZE, fn:false(), cntk:axis(0))
  let $b-onehot := cntk:one-hot-op($b-input-variable, $VOCAB_SIZE, fn:false(), cntk:axis(0))
  let $c-onehot := cntk:one-hot-op($c-input-variable, $VOCAB_SIZE, fn:false(), cntk:axis(0))

  (: 埋め込みレイヤーの定義 :)
  let $emb-input-variable := cntk:input-variable(cntk:shape(($VOCAB_SIZE)), "float")
  let $emb-map := map:map()
  let $__ := map:put($emb-map, "weight", cntk:constant-value(cntk:function-constants($model)))
  let $a-emb-layer := cntk:embedding-layer($a-onehot, $emb-map)
  let $b-emb-layer := cntk:embedding-layer($b-onehot, $emb-map)
  let $c-emb-layer := cntk:embedding-layer($c-onehot, $emb-map)
  
  (:単語ベクトルの加減算のレイヤー定義:)
  let $b-min-a := cntk:minus($b-emb-layer, $a-emb-layer)
  let $b-min-a-plus-c := cntk:plus($b-min-a, $c-emb-layer)
  let $normalize := cntk:element-divide($b-min-a-plus-c,
                                 cntk:sqrt(cntk:reduce-sum-on-axes(
                                 cntk:element-times($b-min-a-plus-c, $b-min-a-plus-c), cntk:axis(0))))
```

実際に類推してトップ5を求めてみたところ、以下のようになりました。
この程度の単語であれば良い感じに学習できているようです。

```
イタリア : ローマ = フランス？
[[0.5787293, 0.5199322, 0.5149671, 0.503202, 0.4992342]]
パリ、ガリア、フランス革命、ブルターニュ、フランス人

王様 : 男 = 女王様？
[[0.5819048, 0.4496543, 0.4346388, 0.4217083, 0.4191965]]
女、男性、女に、女性、美女

映画 : スクリーン = テレビ？
[[0.3959613, 0.3913569, 0.3854758, 0.3813417, 0.3640494]]
差し替え、映し出さ、ハイビジョン、モニター、民放
```

# まとめ
MarkLogic10に搭載されているCNTKを使って、word2vecの代表的なサンプルを実装してみました。
今後はMarkLogic上のコンテンツの文章要約などにも取り組んでみたいと思っています。