Node.jsでナイーブベイズ分類器を使った分類を行う

#ナイーブベイズ分類器のBayesモジュールを使う
ナイーブベイズ分類器は、次のようなことができます。

* スパムメールの判定
* ニュース記事やブログ記事のカテゴリー判定

ごく簡単にいうと、学習に必要なのはカテゴリーに関連する単語をたくさん登録するだけです。カテゴリーのわかっている文章を単語に分解して登録します。判定するときには、カテゴリーに関わる単語の出現率で判定されます。

もちろん、もっと正しい理解をしたほうがいいですが、[bayesモジュール](https://www.npmjs.com/package/bayes)を使うならこの程度のイメージを持っておくだけで使えて、なかなか有益な結果を得られます。詳しく知りたい方は末尾のリンク先を参照してください。[^1]


##使い方（イメージ）

```javascript
// 学習
classifier.learn('カテゴリーAに関する長文・・・・・', 'カテゴリーA')
classifier.learn('カテゴリーBに関する長文・・・・・', 'カテゴリーB')
classifier.learn('カテゴリーCに関する長文・・・・・', 'カテゴリーC')

// 判定
const category = classifier.categorize('カテゴリーを判定したい文章')
```


##準備（予備知識）

###分かち書き

先に示したように、bayesモジュールのlearnメソッドを使ってカテゴリーのわかっている文章を学習させます。英語であれば自動で単語に分割して登録されるのですが、日本語の場合は単語への分割がうまくいきません。そこで、bayesが日本語の文章の単語への分割＝分かち書きができるようにその機能を持ったメソッドを渡してあげます。分かち書きの機能を提供する[tiny-segmenterモジュール](https://www.npmjs.com/package/tiny-segmenter)を使って次のようにします。（イメージを掴むために、[こちらのサイト](http://chasen.org/~taku/software/TinySegmenter/)でtiny-segmenterの動作を見ておくとよいです。）

```javascript
const segmenter = new TinySegmenter()

var classifier = bayes({
  tokenizer: function (text) { 
    return segmenter.segment(text);
  }
});
```

###async/await

また、最初の使い方イメージでは省略しましたが、bayesのlearnメソッドとcategorizeメソッドはasyncで提供されているため、簡単に使用するにはawaitをつけて呼び出す必要があります。awaitはaysncメソッド内でしか使えません。そのため下記のサンプルコードではasyncが使われています。


###データ準備

学習に使う文章はWikipediaからもってきましょう。以下のURLでアクセスするとXMLデータが得られます。[^2]

```
https://ja.wikipedia.org/wiki/特別:データ書き出し/キーワード
```

得られるデータにはちょっと無駄な情報が多いですが簡単に済ますために今回はこれをこのまま使いましょう。ブラウザで以下の3つのURLにアクセスして、それぞれyoritomo.txt、takauji.txt、ieyasu.txtとして保存してください。

```:yoritomo.txtとして保存
https://ja.wikipedia.org/wiki/特別:データ書き出し/源頼朝
```
```:takauji.txtとして保存
https://ja.wikipedia.org/wiki/特別:データ書き出し/足利尊氏
```
```:ieyasu.txtとして保存
https://ja.wikipedia.org/wiki/特別:データ書き出し/徳川家康
```

これで準備は完了です。


##インストール

```shell:bayesのインストール
npm install bayes
```

```shell:tiny-segmenterのインストール
npm install tiny-segmenter
```

##デモ・コード

実行結果は次の通りです。

```:実行結果
$ node bayes-demo.js 
判定=[源頼朝] -- 日本で最初に幕府を開いた人物で、妻は尼将軍としても有名な北条政子である。
判定=[源頼朝] -- 後鳥羽天皇によって征夷大将軍に任ぜられた。
判定=[源頼朝] -- 奥州を平定した。
判定=[足利尊氏] -- 室町幕府を開いた。
判定=[足利尊氏] -- 鎌倉幕府の滅亡後、鎮守府将軍・左兵衛督に任ぜられた。
判定=[足利尊氏] -- 歌人としても知られる。
判定=[徳川家康] -- 幼少時代を人質として過ごした。
判定=[徳川家康] -- 室町幕府最後の将軍足利義昭が信長包囲網を企てたとき、協力要請を受けたがこれを無視した。
```



これがデモ・コードです。ぐだぐだ説明する必要もないと思います。シンプル。

```javascript:bayes-demo.js
var bayes = require('bayes');
const TinySegmenter = require('tiny-segmenter')
const fs = require('fs')

// 分かち書きの機能を使うため
const segmenter = new TinySegmenter()


// 学習用文章の読み込み
var txt_yoritomo = fs.readFileSync('yoritomo.txt', 'utf-8')
var txt_takauji = fs.readFileSync('takauji.txt', 'utf-8')
var txt_ieyasu = fs.readFileSync('ieyasu.txt', 'utf-8')

// 分かち書き機能の設定
var classifier = bayes({
  tokenizer: function (text) { 
    return segmenter.segment(text);
  }
});

async function demo() {
  // 学習
  await classifier.learn(txt_yoritomo, '源頼朝');
  await classifier.learn(txt_takauji, '足利尊氏');
  await classifier.learn(txt_ieyasu, '徳川家康');
  
  // 判定して結果を表示
  async function categorize(text) {
    // 判定
    var r = await classifier.categorize(text);
    console.log("判定=[" + r + "] -- " + text);
  }

  // 文章のカテゴリーを判定する（分類する）
  categorize('日本で最初に幕府を開いた人物で、妻は尼将軍としても有名な北条政子である。');
  categorize('後鳥羽天皇によって征夷大将軍に任ぜられた。');
  categorize('奥州を平定した。');
  categorize('室町幕府を開いた。');
  categorize('鎌倉幕府の滅亡後、鎮守府将軍・左兵衛督に任ぜられた。');
  categorize('歌人としても知られる。');
  categorize('幼少時代を人質として過ごした。');
  categorize('室町幕府最後の将軍足利義昭が信長包囲網を企てたとき、協力要請を受けたがこれを無視した。');
}

demo()
```




#bayesモジュールのコードを読む（わずか271行！）

[bayesモジュールのソースコード](https://github.com/ttezel/bayes/blob/master/lib/naive_bayes.js)を見てみると、わずか271行しかない比較的簡単な内容となっている。読んで理解するにはさすがに少しナイーブベイズ分類器について理解を深めておいたほうがよい。ナイーブベイズ分類器の良い解説記事はたくさんあるので探して読んでください。[^1]


leanメソッドが学習部分です。

```javascript:naive_bayes.js抜粋
/**
 * textがどのcategoryに対応しているか学習することで、ナイーブベイズ分類器を訓練する
 *
 * @param  {String} text
 * @param  {Promise<String>} class
 */
Naivebayes.prototype.learn = async function (text, category) {
  var self = this

  //はじめてのカテゴリの場合は、カテゴリのデータ構造を初期化する
  self.initializeCategory(category)

  //カテゴリにマップされたドキュメント数をカウントする
  self.docCount[category]++

  //学習したドキュメントの総数をカウント
  self.totalDocuments++

  //テキストを単語に分割して配列にする
  var tokens = await self.tokenizer(text)
```

learnメソッドの後半では、各単語の出現回数をカウントしています。カウントしているのは、カテゴリ中の単語出現回数（wordFreqencyCount）と、カテゴリーの総単語数（wordCount）です。

```javascript:naive_bayes.js抜粋
  //テキスト内の各トークンの頻度カウントを取得します。
  //get a frequency count for each token in the text
  var frequencyTable = self.frequencyTable(tokens)

  /*
      このカテゴリの語彙数と単語数を更新します。
      Update our vocabulary and our word frequency count for this category
   */

  Object
  .keys(frequencyTable)
  .forEach(function (token) {
    //この単語がない場合は、私たちの語彙に追加します。
    //add this word to our vocabulary if not already existing
    if (!self.vocabulary[token]) {
      self.vocabulary[token] = true
      self.vocabularySize++
    }

    var frequencyInText = frequencyTable[token]

    //このカテゴリのこの単語の頻度情報を更新する
    //update the frequency information for this word in this category
    if (!self.wordFrequencyCount[category][token])
      self.wordFrequencyCount[category][token] = frequencyInText
    else
      self.wordFrequencyCount[category][token] += frequencyInText

    //このカテゴリにマップされたすべての単語のカウントを更新します。
    //update the count of all words we have seen mapped to this category
    self.wordCount[category] += frequencyInText
  })

  return self
}
```

categorizeメソッドは与えられたテキストのカテゴリーを判定します。すべてのカテゴリーごとに可能性を調べて、最も高い可能性のカテゴリーを選択します。可能性の算出は、テキスト中の各単語について、各単語の確率を加算するという方法です。非常にシンプルですね。

```javascript:naive_bayes.js抜粋

/**
 * テキストがどのカテゴリに属するかを決定する
 *
 * @param  {String} text
 * @return {Promise<string>} category
 */
Naivebayes.prototype.categorize = async function (text) {
  var self = this
    , maxProbability = -Infinity
    , chosenCategory = null

  var tokens = await self.tokenizer(text)
  var frequencyTable = self.frequencyTable(tokens)

  //カテゴリを反復処理して、最も確率の高いカテゴリを求める
  Object
  .keys(self.categories)
  .forEach(function (category) {

    // このカテゴリの全体的な確率を計算することから始める
    // => 学習したすべての文書のうち、このカテゴリのものはどれくらいあったか
    var categoryProbability = self.docCount[category] / self.totalDocuments

    //アンダーフロー対策に対数（log）を取る
    var logProbability = Math.log(categoryProbability)

    //テキスト中の各単語 `w` について P( w | c ) を決定する
    Object
    .keys(frequencyTable)
    .forEach(function (token) {
      var frequencyInText = frequencyTable[token]
      var tokenProbability = self.tokenProbability(token, category)

      // console.log('token: %s category: `%s` tokenProbability: %d', token, category, tokenProbability)

      //この単語のP( w | c )の対数(log)を求める
      logProbability += frequencyInText * Math.log(tokenProbability)
    })

    if (logProbability > maxProbability) {
      maxProbability = logProbability
      chosenCategory = category
    }
  })

  return chosenCategory
}
```

以上


[^1]: [機械学習ナイーブベイズ分類器のアルゴリズムを理解したのでメモ。そしてPythonで書いてみた。](https://qiita.com/ishizakiiii/items/07cc7e463dceb3efe1a1)
[^2]: [https://ja.wikipedia.org/wiki/特別:データ書き出し/キーワード](https://so-zou.jp/web-app/tech/web-api/wikipedia/)