MarkLogicのSVM(Support Vector Machine)を使ってみる

この記事は[MarkLogic Advent Calendar 2017](https://qiita.com/advent-calendar/2017/marklogic)の1日目です。

#はじめに
最近では、機械学習と言えばディープラーニング、と言っても過言では無いくらいディープラーニングが人気ですが、教師あり学習で簡単にクラス分類できるSVM(Support Vector Machine)もなかなか便利なものです。

MarkLogicにはそのSVMが搭載されています。
MarkLogicのSVMを使えば、XML/JSONドキュメントに対する学習・クラス分類を簡単に実現できます。多クラス分類にも対応しています。
今回はMarkLogicのSVMの使い方について記します。

なお、MarkLogicのSVMに関するドキュメントは[こちら](https://docs.marklogic.com/guide/search-dev/classifier)です。

#MarkLogicのSVMは何が便利か
MarkLogicはドキュメントベースのDBでXMLやJSONを扱えます。MarkLogicのSVMを使えば、ドキュメント全体はもちろん、データ構造を考慮した学習・分類を実現できます。

SVMでクラス分類する場合、ドキュメント自体を数値化する必要があります。例えばTF-IDFのような単語の出現頻度に着目した方法を選択することが多いようです。

MarkLogicのSVMでもドキュメントを数値化して分類します。MarkLogicは自前でトークナイズ(cts:tokenize)やステミング(cts:stem)の関数を搭載しているため、Mecabのような外部の形態素解析器を用意する必要がありません。

また、MarkLogicは多くのインデックス機能を搭載しています。単語単体に対するインデックスだけでなく、複数単語に対するものや、文書構造を対象としたものなど、インデックス機能を活用してドキュメントを分類できます。
MarkLogicのインデックスの詳細は[こちら](https://docs.marklogic.com/guide/concepts/indexing)をご参照下さい。

#SVM用の関数
MarkLogicのSVM用の関数は以下になります。

* [cts:train](https://docs.marklogic.com/cts:train)
指定したドキュメントやノードを学習し、分類器を作成するための関数です。
作成した分類器はXML/JSONで表現されるため、そのままMarkLogicに格納して利用することが可能です。

* [cts:thresholds](https://docs.marklogic.com/cts:thresholds)
学習で算出した閾値や精度、再現率等を計算します。分類器の評価に用います。

* [cts:classify](https://docs.marklogic.com/cts:classify)
cts:trainで作成した分類器を使って指定したドキュメントの分類を行います。

#SVMでの学習、分類の流れ
MarkLogicのSVMによる学習、分類は以下の流れとなります。学習は反復的に行い、分類器を改善していきます。

1. 学習用のドキュメントを複数セット用意します
* 学習用ドキュメントの正解クラスを用意します
* 1つ目のドキュメントセットを学習し、分類器を作成します(cts:train)
* 作成した分類器で、2つ目のドキュメントセットを分類してみます(cts:classify)
* 分類結果を確認します(cts:thresholds)
* 十分な結果が得られるまでチューニングして学習し直します（cts:train)
* 未知のデータセットを分類してみます
 
#何を分類するか
ここからは実際のデータを用いてSVMを試してみます。

MarkLogicのマニュアルでは、[シェークスピアの戯曲のXMLデータ](http://xml.coverpages.org/bosakShakespeare200.html)の分類を例として取り上げています。シェークスピアの37作品がXMLとして公開されており、これをSVMでCOMEDY(喜劇)、HISTORY(史劇)、TRAGEDY(悲劇)の3クラスに分類しています。37作品のうち19作品を学習用データとして用いて、残り18作品を分類しています。

##live doorニュースを分類してみる
マニュアルの事例を試してみてもつまらないので、今回は日本語のニュース記事の分類を行ってみます。
NHN Japan株式会社が運営する「livedoor ニュース」のうち、クリエイティブ・コモンズライセンス「表示 - 改変禁止」のもとに公開して下さっている[livedoor ニュースコーパス](https://www.rondhuit.com/download.html#ldcc)を分類してみます。9分類、7,367記事を提供して下さっています。

なお、本記事ファイルのライセンスは以下の通りです。
>各記事ファイルにはクリエイティブ・コモンズライセンス「表示 – 改変禁止」が適用されます。 クレジット表示についてはニュースカテゴリにより異なるため、ダウンロードしたファイルを展開したサブディレクトリにあるそれぞれの LICENSE.txt をご覧ください。 livedoor はNHN Japan株式会社の登録商標です。

##環境
今回は以下の環境で試しています。
なお、日本語データを扱うため、データベースの設定「language」を「ja」に設定しておきます。

|ソフトウェア|バージョン|
|---|---|
| CentOS7 | 7.4.1708 |
| MarkLogic | 8.0-7 |

# まずはデータロード
live doorニュースをダウンロードしてMarkLogicにロードします。
記事ファイルはカテゴリ毎のディレクトリに格納されています。これを/news/ディレクトリ等の配下にロードします。
なお、本記事ファイルはXMLやJSON形式ではなく通常のテキストデータですが、このようなデータもMarkLogicは扱えます。

今回は以下のようなディレクトリ構造としました。

| ニュース名 | クラス | ロード先ディレクトリ | 記事数 |
|---|---|---|---|
| ITライフハック | it-life-hack | /news/it-life-hack/ | 870 |
| トピックニュース | topic-news | /news/topic-news/ | 770 |
| Sports Watch | sports-watch | /news/sports-watch/ | 900 |
| 家電チャンネル | kaden-channel | /news/kaden-channel/ | 864 |
| MOVIE ENTER | movie-enter | /news/movie-enter/ | 870 |
| 毒女通信 | dokujo-tsushin | /news/dokujo-tsushin/ | 870 |
| エスマックス | smax | /news/smax/ | 870 |
| livedoor HOMME | livedoor-homme | /news/livedoor-homme/ | 511 |
| Peachy | peachy | /news/peachy/ | 842 |

# 1．ロードしたデータを学習用のドキュメントセットにわけます
ロードしたデータをいくつかの学習用セットにわけます。3セット用意してみます。

```
let $docset1 := xdmp:directory("/news/it-life-hack/", "1")[1 to 100] |
                 xdmp:directory("/news/kaden-channel/", "1")[1 to 100] |
                 xdmp:directory("/news/movie-enter/", "1")[1 to 100] | 
                 ・・・
let $docset2 := xdmp:directory("/news/it-life-hack/", "1")[101 to 200] |
                 xdmp:directory("/news/kaden-channel/", "1")[101 to 200] |
                 xdmp:directory("/news/movie-enter/", "1")[101 to 200] | 
                 ・・・
let $docset3 := xdmp:directory("/news/it-life-hack/", "1")[201 to 210] |
                 xdmp:directory("/news/kaden-channel/", "1")[201 to 210] |
                 xdmp:directory("/news/movie-enter/", "1")[201 to 210] | 
                 ・・・
```

# 2．学習用ドキュメントの正解クラスを用意します
用意したドキュメントセット毎に正解クラスのラベルを作成します。
ラベル用のXMLの構造は以下になります。

```
<cts:label>
  <cts:class name="ラベル名" />
</cts:label>
```

上記のラベルをニュース毎に用意します。
今回はニュース毎のディレクトリ名をそのままクラス名とします。

```
(:データセット1の例。同様にデータセット2や3についても実施する。:)
let $correctLabels1 := for $x in $dataset1
  let $label := fn:tokenize(xdmp:node-uri($x), "/")[3]
  return 
    <cts:label>
      <cts:class name="{$label}"/>
    </cts:label>
```

# 3．1つ目のドキュメントセットを学習し、分類器を作成します
さっそく1つ目のドキュメントセットと正解ラベルをcts:trainで学習し、分類器を作成してみます。

cts:trainの引数は以下の通りです。

* 第1引数は学習対象のドキュメントセット
* 第2引数は正解ラベル
* 第3引数は学習に使用するオプション(SVMのパラメータやMarkLogicの設定)

最初の学習では、オプションはデフォルトを用いてみます。学習を繰り返す際に、このオプションを変更することで分類器の精度を調整します。
なお、DBの設定を日本語に変更しているため、学習にDB設定を使用するためのオプション「use-db-config」だけは有効(true)にしておきます。

```
let $classifier1 :=  
    cts:train($dataset1, $correctLabels1, 
      <options xmlns="cts:train">
        <use-db-config>true</use-db-config>
      </options>)

return $classifier1
```

cts:trainの結果である分類器は以下のようなXMLで、optionsエレメントと学習結果のエレメントで構成されています。
optionsエレメントは学習に用いたオプションの詳細になります。
学習結果のエレメント(今回はweights)は、分類するクラス毎に単語の重みベクトルを表しています。
なお、オプションの内容によって学習結果の内容は変わります。詳しくは後述します。

```
<classifier xmlns="http://marklogic.com/cts">
  <options xmlns="cts:train" xmlns:db="http://marklogic.com/xdmp/database">
    <kernel>sqrt</kernel>
    <classifier-type>weights</classifier-type>
    <db:language>ja</db:language>
    <min-weight>0.01</min-weight>
    <max-terms>0</max-terms>
    <max-iterations>500</max-iterations>
    <max-support>1</max-support>
    <tolerance>0.01</tolerance>
    <epsilon>0.01</epsilon>
    <thresholds><threshold>-1.0E30</threshold></thresholds>
    <use-db-config>true</use-db-config>
  </options>
  <weights>
    <class name="dokujo-tsushin" offset="0.6369106">
      <term id="1928252218773241915" val="-0.0103408"></term>
      <term id="9712352132200179854" val="0.02336318"></term>
      ・・・
    </class>
    <class name="movie-enter" offset="0.4150103">
      <term id="15513104456590506042" val="0.01365201"></term>
      <term id="10924360160506202038" val="0.0128992"></term>
      ・・・
    </class>
    <class name="smax" offset="0.9335536">
      <term id="16930649934856086010" val="0.0115606"></term>
      <term id="17072950555205300884" val="0.02623854"></term>
      ・・・
    </class>
    ・・・
  </weights>
</classifier>
```
## cts:trainのオプションについて
最初の学習ではオプションはデフォルトを使用しました。このオプションは分類器のチューニングにとって重要なパラメータとなります。
これらのオプションを調整しながら学習を繰り返します。

主要なものについて以下に概要を記します。詳細は[cts:trainのドキュメント](http://docs.marklogic.com/cts:train)をご参照下さい。

| パラメータ | 概要 | デフォルト値 |
|---|---|---|
| classifier-type | 生成する分類器の種類を指定します。weightsかsupportsのいずれかです。| weights |
| kernel | SVMで使用するカーネル法を指定します。 | sqrt |
| thresholds | 分類に使用する閾値を指定します。初回の学習ではデフォルト値（巨大な負の値 (-1.0e30))を使用します。2回目以降の学習で算出した閾値を指定してチューニングします | -1.0e30 |

### classifier-typeについて(weightsとsupports)
---
MarkLogicが作成するSVMの分類器には、weightsとsupportsの2種類があります。cts:trainのデフォルトの方式はweightsとなっています。MarkLogicのドキュメントには明文化されていないですが、weightsは線形分類用、supportsは非線形分類用の模様です。

上記のcts:trainの実行例はデフォルトのためweightsが選択され、分類器もweightsエレメントとなりました。
supportsを選択した場合は、分類器の結果はsupportsエレメントとなります。

* supportsについて

weightsよりも時間はかかりますが、より精度の高い方式です。
データセット全体を使用した分析が可能であり精度は高いですが、この分類器を使ってクラス分類する際には、学習に使用したデータセットが必要になります。
cts:trainが生成するクラス分類器のデータサイズはweightsと比較して小さくなる傾向があるようです。
クラス分類器に設定される結果は、<supports>タグになります。

```
<supports>
  <class name="it-life-hack" offset="1.364202">
    <doc id="13079089282649174934" val="1" err="0.326858"></doc>
    <doc id="209525567146105999" val="1" err="0.5715606"></doc>
    ・・・
  </class>
  ・・・
</supports>
```

weightsの結果と異なり、ドキュメント単位の分析結果となっており、ドキュメント単位の重みの他に、エラー属性(err)が追加になっています。err属性はそのドキュメントがクラスのどの程度適合しているかを表しており、±1.5より大きい場合は誤分類されている可能性が高いようです。

* weightsについて

supportsと異なり、ドキュメント単体で単語の重みを算出する方式で、supports方式よりも高速に動作しますが、精度は劣ります。
ドキュメント単体毎に学習するため、この分類器を使用したクラス分類の際には、学習に使ったデータセットを保持しておく必要はありません。
クラス分類器のデータサイズはsupportsよりも大きくなる傾向にあるようです。
どうやら線形分類用であり、後述するカーネル法としてgaussianカーネルやgeodesicカーネルは使用できない模様です。

### kernelについて
---
MarkLogicのSVMもカーネル法を使用することができます。搭載しているカーネル法は以下になります。
カーネルのデフォルトはsqrtです。

|カーネル|概要|
|---|---|
| simple | 各用語の有無に応じてドキュメントを0、1に分類する方式 |
| simple-normalized | simpleと同様だが、ドキュメント単位の出現頻度に正規化した方式 |
| sqrt | 単語の出現頻度(TF)の平方根を用いる方式(sqrt(tf))。デフォルトのカーネル方式。 |
| sqrt-normalized | sqrtと同様だが、ドキュメント単位の出現頻度に正規化した方式 |
| linear-normalized | 単語の出現頻度の二乗和の平方根を用いて正規化する方式 |
| gaussian | ドキュメントの比較に単語の出現頻度に対するガウス関数を用いる方式|
| geodesic | 単語の出現頻度に対してリーマン多様体の測地線距離を用いてドキュメントを比較する方式 |

# 4．作成した分類器で、2つ目のドキュメントセットを分類してみます
前項ではcts:trainをデフォルト値で使用して、クラス分類器を作成してみました。
次にこの分類器を使って、2番目のデータセットをクラス分類してみます。

クラス分類はcts:classify関数で行います。引数は以下の通りです。

* 第1引数は分類するデータセット
* 第2引数はクラス分類器(cts:trainの結果)
* 第3引数はクラス分類のチューニングに使用するパラメータ
* 第4引数はクラス分類器の生成に使ったデータセット

今回は初回の学習結果を用いて分類するため、学習時と同様に引数はデフォルトとします。

```
let $classify2 := cts:classify($dataset2, $classifier1)    
    
return $classify2
```

実行結果は以下のようになります。
第1引数で指定した分類対象のデータセットの配列と同じ順番に、labelエレメントの繰り返しで分類結果が出力されます。

labelエレメントは、分類対象のデータがどのクラスに分類されるかを表しています。
classエレメントのval値が0より大きい場合、そのクラスに属している可能性があります。

```
<label xmlns="http://marklogic.com/cts">
  <class name="dokujo-tsushin" val="-0.1112174"></class>
  <class name="movie-enter" val="-0.5422571"></class>
  ・・・
</label>
<label xmlns="http://marklogic.com/cts">
  <class name="dokujo-tsushin" val="-0.3844012"></class>
  <class name="movie-enter" val="-0.6173072"></class>
  ・・・
</label>
・・・
```

# 5．分類結果を確認します
cts:thresholds関数を使って、cts:classifyによる予測と正解ラベルから分類結果を確認します。
この関数は、閾値やPrecision(精度)、Recall(再現率)、F値といった、クラス分類に関する指標値を計算します。
これらの値は分類器のチューニングにおいて最適な閾値の発見に役立ちます。

cts:thresholdsの引数は以下の通りです。

* 第1引数は分類結果(cts:classifyの結果)
* 第2引数は分類結果に対応する正解クラスのラベル(cts:label)
* 第3引数はRecall値の重み(オプション)

第3引数のRecall値の重みはオプションです。本関数の結果のF値を調整するために使用します。詳細は後述します。
まずはオプションの第3引数を指定せずに実行してみます。
この場合、分類結果と正解クラスから、現在の分類器の性能を把握出来ます。

```
let $thresholds2 := cts:thresholds($classify2, $correctLabels2)   
return $thresholds2
```

実行結果は以下のようになります。

```
<thresholds xmlns="http://marklogic.com/cts">
    <class name="movie-enter" threshold="-0.16359" precision="0.969697" recall="0.96" f="0.964824" count="100"/>
    <class name="it-life-hack" threshold="-0.333582" precision="0.919192" recall="0.91" f="0.914573" count="100"/>
    <class name="kaden-channel" threshold="-0.0336719" precision="0.913462" recall="0.95" f="0.931373" count="100"/>
    ・・・
</thresholds>
```

cts:thresholds関数の実行結果の概要は以下の通りです。

| 項目 | 概要 |
|---|---|
| threshold | クラス分類で算出された閾値です。クラスの境界となる超平面からの距離を表します。 |
| precision | 分類結果の精度を表します。1の場合は過学習の可能性があります。 |
| recall | 分類結果の再現率を表します。1の場合は未学習の可能性があります。 |
| f | 精度と再現率のF値を表します。 |
| count | そのクラスの正しいドキュメント数を表します。 |

## Threshold(閾値)について
閾値は0が理想的ですが、0に近い負数ならば良好です。
閾値を0に近い負数に調整するには、カーネルやオプションのパラメータを見直して学習し直します。
また、学習用のデータセットの品質を見直すことも有用です。

## Precision(精度)、Recall(再現率)、F値について
予測した分類結果と、正しい分類の比率を元にした指標です。

あるデータをクラスAとそれ以外に分類する例を見てみます。
分類結果は以下の4パターンとなります。

 * [True-Positive] クラスAのデータを正しくAと判断する 
 * [False-Negative] クラスAのデータを誤ってA以外と判断する（見逃し）
 * [False-Positive] クラスA以外のデータを誤ってAと判断する（誤検知）
 * [True-Negative] クラスA以外のデータを正しくA以外と判断する

### Precision(精度)
クラスAと予測されたもののうち、実際にクラスAであるものの割合です。どの程度の誤分類があるかの指標になります。
1に近いほど正確に分類でき、0に近いほど誤分類であることを示します。なお、この値が1の場合はデータセットに対して過学習している（学習したデータセットに特化しすぎている）可能性があります。


以下で算出します。

```math
Precision=TP/TP+FP
```

例えばスパムメールを検知する例の場合、多少スパムメールが混じったとしても正しいメールをスパムメールと誤検知したくないため、この値は高めに調整します(True-Positiveは高く、False-Positiveは低く)。
一方、不正アクセス検知など、正常なアクセスを犠牲にしても不正アクセスを見逃せないような分類ではこの値を低めに調整します。

### Recall(再現率)
クラスAのうち、正しくクラスAと予測されたものの割合です。どの程度見落としがあるかの指標になります。
1に近いほど正しく分類できており、0に近いほど見落としが多いことを示します。なお、この値が1の場合はデータセットに対して未学習している（正確に分類できていない）可能性があります。

以下で算出します。

```math
Recall=TP/TP+FN
```

スパムメールを検知する例では、スパムメールを見逃しても、正しいメールはスパムメールと判断したくないため、この値は低めに調整します。
逆に不正アクセスのように、見逃しが許されない場合は高めに調整します。

### F値
PrecisionとRecallの調和平均です。この2つの値は誤検知と見逃しに関する割合であり、トレードオフの関係にあります。
この分類器が、Precision寄りなのかRecall寄りなのかの指標がF値になります。

この値が1の場合、PrecisionとRecallは同程度であり、0.5の場合はPrecision寄り、2の場合はRecall寄りとなります。値が0の場合はPrecisionのみ、+∞の場合はRecallのみとなります。

### F値の調整
cts:thresholdsの第3引数はオプションで、Recallの重みでした。
これはF値の算出に使用するパラメータで、分類器のPrecisionやRecallを調整し、最適な閾値を見つけるために使用します。
この値に0を指定すると、F値がPrecision寄りになります。
+∞の場合はF値がRecall寄りになります。
デフォルトは1で、この場合はPrecisionとRecallがバランスしている状態になります。

# 6．十分な結果が得られるまでチューニングして学習し直します
クラス分類の結果やPrecision、Recallなどを確認し、求める品質の分類器が得られるまで学習を繰り返します。
cts:trainのオプションをチューニングし、再学習して分類器を更新します。
初回はデフォルト値を使って学習しましたが、classifier-typeをsupportsに変更し、ガウスカーネルを使用してみます。

```
(:cts:trainのオプションを変更して、1つ目のデータセットを再学習する。:)
let $classifier2 :=  
    cts:train($dataset1, $correctLabels1, 
      <options xmlns="cts:train">
        <classifier-type>supports</classifier-type>
        <kernel>gaussian</kernel>
        <use-db-config>true</use-db-config>
      </options>)

(:再学習した分類器で2つ目のデータセットを分類し直す。:)
let $classify2 :=
    cts:classify($dataset2, $classifier1, <options xmlns="cts:classify"/>, $dataset1)

(:改めて分類結果を確認する。:)    
let $thresholds2 := cts:thresholds($classify2, $correctLabels2)   
```

実行結果は以下のようになります。

```
<thresholds xmlns="http://marklogic.com/cts">
    <class name="movie-enter" threshold="-0.15796" precision="0.990099" recall="1" f="0.995025" count="100"/>
    <class name="it-life-hack" threshold="-0.408419" precision="0.941176" recall="0.96" f="0.950495" count="100"/>
    <class name="kaden-channel" threshold="-0.297048" precision="0.969697" recall="0.96" f="0.964824" count="100"/>
  ・・・
</thresholds>
```

カーネルを変えただけですが、初回よりもF値が1に近づきPrecisionとRecallのバランスが取れている分類器となりました（わずかですが・・・）。

# 7．未知のデータセットを分類してみます
折角なので、学習し直した分類器$classifier2を使って、新たな3番目のデータセットを分類してみます。

```
(:
  分類します。classifier-typeがsupportsのため、
  分類器の作成に使ったデータセット$dataset1も必要となります。
:)
let $classify3 :=
    cts:classify($dataset3, $classifier2, <options xmlns="cts:classify"/>, $dataset1)

(:
  分類した結果を確認します。正しいクラスを<correct_class>に設定し、
  予測したクラスを<classify>に設定します。
  cts:classifyの結果であるcts:classエレメントのval属性値が0より大きいクラスが
  予測された分類クラスになります。
  なお、本例では正常に分類できなていないものも候補を表示するため、
  val属性が最大のものを出力してみます。
:)
for $classify at $i in $classify3
  let $maxVal := fn:max($classify/cts:class/@val)
  return 
    <result>
      <correct_class>{fn:tokenize(xdmp:node-uri($dataset3[$i]), "/")[3]}</correct_class>      
        {for $class in $classify/cts:class
           where $class/@val = $maxVal
             return <classifier val="{$class/@val}" name="{$class/@name}"></classifier>}
    </result>
```

結果は以下のようになります。
valが0未満のものは正常に分類できていない可能性が高いため、チューニングが必要になります。
また、valが0より大きくても誤ったクラスに分類しているケースもあるため、閾値の見直しが必要になることもあります。

```
<result>
  <correct_class>sports-watch</correct_class>
  <classifier val="0.1554182" name="sports-watch"/>
</result>
<result>
  <correct_class>sports-watch</correct_class>
  <classifier val="-0.1478657" name="sports-watch"/>
</result>
<result>
  <correct_class>movie-enter</correct_class>
  <classifier val="-0.580112" name="sports-watch"/>
</result>
<result>
  <correct_class>peachy</correct_class>
  <classifier val="-0.5536792" name="peachy"/>
</result>
<result>
  <correct_class>sports-watch</correct_class>
  <classifier val="0.5576555" name="sports-watch"/>
</result>
<result>
  <correct_class>movie-enter</correct_class>
  <classifier val="-0.1880386" name="movie-enter"/>
</result>
・・・
```

#さいごに
今回はMarkLogicが搭載しているSVMの使い方についてご紹介しました。
通常、SVMでドキュメントを分類する際にはMeCabを導入したり、ドキュメントの数値化方式を考えたり、など多くの準備が必要になるケースが多いようですが、MarkLogicなら簡単にSVMを利用可能となっています。
MarkLogicでのチューニングは独特でドキュメントが少ないのが難点ですが、デフォルトで使用しても比較的、精度高く分類できることが分かりました。
スキーマレスのデータベースのため様々なデータソースからデータを取り込み、スキーマ横断で分類できることは大きな強みとなります！