MarkLogic10で多層パーセプトロンを実装してみる

# はじめに
2019年6月にMarkLogic10がリリースされ、GPU対応およびCNTKが実装されました。今回はこの新機能を使って多層パーセプトロンによる訓練と推論を実装してみようと思います。

例題は定番のアイリス分類とします。言語はXQueryです。

ソースコードは以下に置きました。
https://github.com/t2hk/marklogic-mlp/

# MarkLogicのCNTK
MarkLogicはCNTKを実装しています。APIはオリジナルのCNTKに類似しているものの、XQueryという言語やMarkLogic自体の特性の影響からかCNTKオリジナルとは異なる部分も多々あります。
それでも、XQueryで機械学習のアルゴリズムを実装してモデル構築でき、データベース上のデータを直接学習・推論に利用できるのは便利です。

# 学習データとテストデータをMarkLogicにロードする
アイリスのデータセットを学習用とテスト用にわけます。今回は学習用に120件、テスト用に30件としました。
データ形式はCSVで、以下のようなデータになります（一部抜粋）。

| sepal_length | sepal_width | petal_length | petal_width | species |
| --- | --- | --- | --- | --- |
| 5.1 | 3.5 | 1.4 | 0.2 | setosa |
| 4.9 | 3.0 | 1.4 | 0.2 | setosa |
| 4.7 | 3.2 | 1.3 | 0.2 | setosa |
| 7.0 | 3.2 | 4.7 | 1.4 | versicolor |
| 6.4 | 3.2 | 4.5 | 1.5 | versicolor |
| 6.9 | 3.1 | 4.9 | 1.5 | versicolor |

用意したCSVファイルをMarkLogic用のデータロードツール「MLCP」を使用してXML変換してロードします。
学習データは"/iris/train/"にロードし、テストデータは"/iris/test/"にロードしています。

MLCPのオプションは以下のような感じです。学習データのロード用の設定例です。

```
import \
-mode local \
-host [MarkLogicサーバ] \
-port [XDBCポート番号] \
-username [ユーザ名] -password [パスワード] \
-input_file_type delimited_text \
-input_file_path [学習データのフルパス] \
-delimited_root_name iris \
-document_type xml \
-output_uri_replace "[学習データのフルパス], '/iris/train/iris'" \
-generate_uri true \
-output_uri_suffix .xml
```

# 学習用のXQuery
ソースコードは以下になります。
https://github.com/t2hk/marklogic-mlp/blob/master/ML10_MLP_Train.xqy

## MLPの入力層と隠れ層を関数化
隠れ層のレイヤー数を任意とするため関数化して再帰呼び出し可能としました。
今回はアイリス分類なので、入力層の入力数は4となります（がく片の長さと幅、花びらの長さと幅）。
隠れ層は全結合のDenseを使用しています。

```
(:
  マルチパーセプトロンの入力層と隠れ層を構築する。出力層は含まない。
  input_variable : 入力層の入力数
  num_hidden_layers : 隠れ層のレイヤー数
  dense_map : アクティベーションのアルゴリズムや隠れ層の出力数などのパラメータを保持するmap
:)
declare function local:mlp($input_variable as cntk:variable, $num_hidden_layers as xs:integer, $dense_map as map:map){
  if($num_hidden_layers = 0) then cntk:dense-layer($input_variable, $dense_map)
  else cntk:dense-layer(local:mlp($input_variable, ($num_hidden_layers - 1), $dense_map), $dense_map)
};
```

## パラメータ
MLPの各種パラメータを変数定義しています。隠れ層の数や出力数は可変です。

```
let $input_dims := 4 (:入力数:)
let $num_classes := 3 (:分類数:)
let $num_hidden_layers := 2 (:隠れ層の数:)
let $hidden_layers_dim := 10 (:隠れ層の出力数:)
let $hidden_activation := "relu"
let $minibatch_size := 100 (:ミニバッチの実行回数:)
```

## 学習データの作成
ロードした学習データを1x4の行列として読み込みます。

```
let $input_variable := cntk:input-variable(cntk:shape(( 1, $input_dims)), "float")
let $train_data :=
  for $x in xdmp:directory("/iris/train/", "infinity")
    return ($x/iris/sepal_length/text(), $x/iris/sepal_width/text(), $x/iris/petal_length/text(),  $x/iris/petal_width/text())

let $input_value := cntk:batch(cntk:shape((1, $input_dims)), json:to-array($train_data))
```

## 正解ラベルの作成
同様に正解ラベルを作成します。正解ラベルは分類名そのものではなく、One-Hot表現としています。
Pythonなどなら楽なのですが、XQueryでOne-Hot表現を作り出すのは泥臭く力業になります。
私が良い方法を知らないだけだと思いますが・・・

```
let $label_variable := cntk:input-variable(cntk:shape(( 1, $num_classes)), "float")
let $labels :=
for $x in xdmp:directory("/iris/train/", "infinity")
  let $_label := $x/iris/species/text()
  return if ($_label = "setosa") then (1,0,0)
  else if ($_label = "virginica")  then (0,1,0)
  else (0,0,1)
  
let $label_value := cntk:batch(cntk:shape((1,$num_classes)), json:to-array(($labels)))
```

## MLPを組み立てる
関数定義したMLP関数を使用し、出力層も含めて構築します。

アクティベーションや出力数などはmapにまとめて設定します。

```
(:入力層と隠れ層を組み立てる:)
let $_model_map := map:map()
let $_ := map:put($_model_map, "activation", $hidden_activation)
let $_ := map:put($_model_map, "output-shape", cntk:shape((1, $hidden_layers_dim)))
let $_model := local:mlp($input_variable, ($num_hidden_layers), $_model_map)

(:出力層を組み立てる:)
let $_output_map := map:map()
let $_ := map:put($_output_map, "activation", $hidden_activation)
let $_ := map:put($_output_map, "output-shape", cntk:shape((1, $num_classes)))
let $model := cntk:dense-layer($_model, $_output_map)
```

## 訓練器を組み立てる
評価関数などを設定し、訓練器を組み立てます。

```
(:予測結果と正解の交差エントロピー誤差を求める:)
let $loss := cntk:cross-entropy-with-softmax(
     $model, 
     $label_variable, 
     cntk:axis(-1))
     
(:予測結果の評価:)     
let $err := cntk:classification-error($model, $label_variable, 1, cntk:axis(-1))

(:訓練器の構築:)
let $parameter:=cntk:function-parameters($model)
let $learner := 
     cntk:sgd-learner(($parameter), 
     cntk:learning-rate-schedule-from-constant(0.1))
let $trainer := cntk:trainer($model, ($learner), $loss, $err)
```

## ミニバッチの実行
学習データと正解ラベルを使ってミニバッチを実行します。

```
(:学習データと正解データからミニバッチ用データを組み立てる:)
let $input_pair := json:to-array(($input_variable, $input_value))
let $labels_pair := json:to-array(($label_variable, $label_value))
let $minibatch := json:to-array(($input_pair, $labels_pair))

(:ミニバッチ実行:)
let $train_result :=
    for $i in (1 to $minibatch_size)
      let $__ := cntk:train-minibatch($trainer, $minibatch, fn:false())      
      let $loss := cntk:previous-minibatch-loss-average($trainer)
      let $evaluation := (1.0 - cntk:previous-minibatch-evaluation-average($trainer))       
      return json:to-array(($loss, $evaluation))
```

## モデルの保存
学習したモデルをMarkLogicに保存して学習を終了します。

```
let $model_doc := cntk:function-save($model)
let $__ := xdmp:document-insert("/model/iris_model", $model_doc)
```

## 損失と正解率
学習した結果の損失と正解率を出力すると、以下のようになります。

```
[1.04729131062826, 0.516666666666667]
[0.685380744934082, 0.666666666666667]
[0.591424878438314, 0.808333333333333]
[0.532787577311198, 0.95]
[0.495430056254069, 0.841666666666667]
[0.482377560933431, 0.883333333333333]
[0.554083633422852, 0.666666666666667]
[0.767242622375488, 0.658333333333333]
[0.967457008361816, 0.666666666666667]
[0.577702267964681, 0.666666666666667]
[0.488694985707601, 0.925]
[0.439605490366618, 0.958333333333333]
・・・
```

# 推論用のXQuery
続いて学習したモデルを使用した推論です。

## モデルの読み込み
学習したモデルを読み込み、入出力層のパラメータを設定します。

```
let $input_dims := 4
let $num_classes := 3
let $model := cntk:function(fn:doc("/model/iris_model")/binary())
let $input_variable := cntk:function-arguments($model)
let $output_variable := cntk:function-output($model)
```

## 推論対象のデータの読み込み
推論するデータを読み込み、入力データと正解ラベルを組み立てます。正解ラベルはOneHot表現とします。

```
let $test_data_docs :=
  for $doc in xdmp:directory("/iris/test/", "infinity")
    return $doc

(:入力データの組み立て:)
let $test_data :=
  for $x in $test_data_docs
    let $_test_data := ($x/iris/sepal_length/text(), $x/iris/sepal_width/text(), $x/iris/petal_length/text(),  $x/iris/petal_width/text())
    return $_test_data
    
(:正解ラベルの組み立て:)
let $test_labels :=
  for $x in $test_data_docs
    let $_label := $x/iris/species/text()
    let $_one_hot_label := 
      if ($_label = "setosa") then ("setosa", 1,0,0)
      else if ($_label = "virginica")  then ("virginica", 0,1,0)
      else if ($_label = "versicolor") then ("versicolor", 0,0,1)
      else ("unknown", 0,0,0)

    return json:to-array($_one_hot_label)
```

## 推論実行
推論を実行します。

```
let $test_value := cntk:batch(cntk:shape((1, $input_dims)), json:to-array($test_data))
let $test_pair := json:to-array(($input_variable, $test_value))
let $output_value := cntk:evaluate(
     $model, 
     $test_pair, 
     ($output_variable))
let $result_array := cntk:value-to-array($output_variable, $output_value)
```

推論した結果は以下のようになります。OneHotで表した各分類の確率が出力されます。
確率が一番高い分類が予測結果となります。

```
[0.1775239, 0.4641904, 0.3582857],
[0.1355018, 0.4985289, 0.3659692],
[0.1276607, 0.5236636, 0.3486757],
[0.1344947, 0.5015103, 0.363995],
・・・
```

# おしまい
XQueryで機械学習というもちょっと変化球な感がありますが、DB上でGPUを使用した機械学習を実現できるのはなかなか便利だと思います。
今回はXQueryを使用しましたが、MarkLogicはサーバサイドでJavascriptも実行でき、CNTKのJavascript実装も搭載されています。Javascriptに慣れている方は、こちらを利用した方が良いと思います。