機械学習×Webアプリ診断：ナイーブベイズ分類器を活用する

O'REILLYの「[実践 機械学習システム](http://www.oreilly.co.jp/books/9784873116983/)」は非常に面白い書籍だ。
この書では種々の機械学習の手法が紹介されているが、その中でも「[ナイーブベイズ(Naive Bayes)](http://ja.wikipedia.org/wiki/%E5%8D%98%E7%B4%94%E3%83%99%E3%82%A4%E3%82%BA%E5%88%86%E9%A1%9E%E5%99%A8)」という手法が興味深かったので、素人ながら**Webアプリケーション診断**(注1)に活かせないか考えてみた。

>注1：Webアプリケーション診断
[この投稿](http://qiita.com/bbr_bbq/items/808b80915b778ea21593)で解説。

#アジェンダ
0.機械学習とは？
1.ナイーブベイズ分類器とは？
2.どの作業に活用するのか？
3.やりかたを考える
4.形態素解析
5.やってみる
6.まとめ

#0.機械学習とは？
[Wikipedia](http://ja.wikipedia.org/wiki/%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92)によると、「人間が自然に行っている学習能力と同様の機能をコンピュータで実現しようとする技術・手法のことである。」とのこと。

人間は「自身の経験」や「他人・書籍などから得た情報」で学習を行い、その学習結果を未来の行動に役立てている。
例えば、美味しい[味噌ラーメン](http://ja.wikipedia.org/wiki/%E5%91%B3%E5%99%8C%E3%83%A9%E3%83%BC%E3%83%A1%E3%83%B3)を食べたいと思ったとき、自身の過去の経験やラーメン好きの友人から聞いた情報を基に"美味しい店"・"不味い店"を分類し、訪れる店を決定する。

こんな思考をコンピュータで実現するのが、機械学習なのだろう(たぶん違う)。

#1.ナイーブベイズ分類器とは？
本投稿では[ナイーブベイズ](http://ja.wikipedia.org/wiki/%E5%8D%98%E7%B4%94%E3%83%99%E3%82%A4%E3%82%BA%E5%88%86%E9%A1%9E%E5%99%A8)と呼ばれる分類手法を使ってみる。

ナイーブベイズ分類器はテキスト分類に使われる手法で、予測したい未知の文章を、事前に定義しておいた**カテゴリ**(注2)に分類することができる。
なお、分類器は事前に訓練(学習)させておく必要がある。

ナイーブベイズの理論については、[id:aidiary氏のブログ](http://aidiary.hatenablog.com/entry/20100613/1276389337)やO'REILLYの「[Think Stats](http://www.oreilly.co.jp/books/9784873115726/)」が非常に参考になるので、より詳しく知りたい方は参照すると良いかもしれない。

>注2：カテゴリ
受信したメールを「スパム」「非スパム」に分類する、または、投稿されたニュース記事を「政治」「技術関連」「スポーツ」に分類する場合、「スパム」「非スパム」や「政治」「技術関連」「スポーツ」がカテゴリになる。

なお、前述のO'REILLYの書では、「ナイーブベイズはおそらく最も洗練された機械学習アルゴリズムのひとつでしょう…(省略)…学習は高速に行うことができ、予測も同様に高速に行えます」と紹介されている。

#2.どの作業に活用するのか？
ナイーブベイズ分類器を使用することで、予測したい未知の文章を"とある"カテゴリに分類できることは分かった。
では、この分類器をWebアプリ診断業務にどう活かせるのか？

結論から言うと、「重点診断対象の画面を選定する」作業に活かしてみようと思う。

診断では、診断対象の**画面**(注3)の内、どこの画面を**重点的に診断(以下、重点診断)**(注4)するのか選定することは非常に重要だ。
なぜなら、**重点診断対象の画面を適切に選定**(注5)することは、診断品質の向上に直結するからだ。

>注3：画面
Webアプリ診断対象の単位。
厳密にはHTTPリクエストとそれに対するHTTPレスポンスの1セットを指す。
話を分かり易くするため”画面”と呼称する。

>注4：重点診断
診断ベンダによって異なるかもしれないが、本投稿では「スキャナに加えて、診断員が手作業で診断を行うこと」と定義する。
スキャナで検出できる脆弱性は限られているため、スキャナで検出できない脆弱性は**診断員が手作業で診断**する。

>注5：重点診断対象の画面を適切に選定
もちろん、全ての画面を重点診断することが理想的だが、重点診断はコストが高いため、顧客の予算に合わせて重点診断対象の画面を選定することが多い。いわば、診断員の腕の見せ所の一つだ。

重点診断対象の選定は、診断員が**自身の経験や知見**に基づいて行うことが多い。
また、選定精度は診断員の熟練度に比例し、適切に選定できるようになるには少なくとも1～2年の診断経験が必要になる(と考えている)。

今回は、この**重点診断対象画面の選定**を、ナイーブベイズ分類器を使って実現してみようと思う。

#3.やりかたを考える
前述したとおり、ナイーブベイズ分類器は**予測したい未知の文章**を、事前に定義しておいた**カテゴリ**に分類(選定)することができる。また、分類前に分類器を**訓練**しておく必要がある。

先ずは、「カテゴリ」「予測したい未知の文章」「訓練」を設計する。

##カテゴリ
今回のテーマは「重点診断対象の画面を選定する」ことだ。
よって、診断対象の画面を以下二つの何れかに分類すれば良い。

* 重点診断対象の画面
* 重点診断対象ではない画面

こういう分類問題を**二値分類**と呼ぶ。
以後、カテゴリ名称を単純化にするため、以下のように定義する。

* 重点診断対象の画面　＝　**重点画面**
* 重点診断対象ではない画面　＝　**基本画面**

これで分類するカテゴリが決定した。

##予測したい未知の文章
今回予測したいのは、診断対象画面が「重点画面」「基本画面」のどちらのカテゴリに分類されるのか、だ。
私が診断対象画面を分類する場合、**画面名**(注6)を手掛かりにすることが多い。

>注6：画面名
診断対象の画面を識別するために付けられる名称。

例えば、以下のような診断対象の画面名が与えられたとする。

1. 商品検索
2. ログイン
3. 会員編集
4. 商品詳細

私ならば、以下のように分類を行う(人によって差はあるかもしれない)。

1. 商品検索　⇒　基本画面
2. ログイン　⇒　重点画面
3. 会員編集　⇒　重点画面
4. 商品詳細　⇒　基本画面

1と4に存在し得る脆弱性は、"SQLi"や"XSS"などだろう。
これらの脆弱性はスキャナでも検出できる(よって、基本画面)。

一方、2と3に存在し得る脆弱性は、"認証の迂回"や"アクセス制御の不備"などだろう。
これらは診断員による手作業でないと検出が難しい(よって、重点画面)。

よって、予測したい未知の文章は「画面名」となる。
これで予測したい未知の文章も決定した。

##訓練データ
分類器を訓練するためのデータを用意する。
訓練データは、分類器が分類の拠り所にするデータであるため、非常に重要だ。
理想は、実業務で熟練診断員が分類した結果(1～2年分くらい)を用いた方が良いが、今回は本投稿用に作成した[訓練データ](https://github.com/13o-bbr-bbq/machinelearn/blob/master/train_data.tsv)を使うことにする。

訓練データは、1列目が画面名、2列名がカテゴリを表している。
なお、「extra　＝　重点画面」「basic　＝　基本画面」を指している。

訓練データを見ると、「ログイン」「パスワード」「クレジットカード」が画面名に含まれている画面は、extraに多く分類されていることが分かる。
一方、「検索」「レビュー」「商品詳細」が画面名に含まれている画面は、basicに多く分類されていることが分かる。

この"熟練診断員の知見"(今回はダミーデータだが)をナイーブベイズ分類器に学習させる。

これで訓練データも用意できた。
それでは、さっそくナイーブベイズ分類器を試してみよう。

が、その前にやるべきことが一つある。
それは、日本語を上手く分類するための下準備だ。

#4.形態素解析
ナイーブベイズ分類器は、文章中の**単語**(注7)が用いられた回数を**特徴量**(注8)として用いている。
例えば、「ログイン」という単語がカテゴリ「extra」で頻繁に使用されていた場合、分類対象の文章「ログイン処理」や「ログイン情報」はカテゴリ「extra」に分類される確率が高くなる。
よって、分類器を上手く学習させるためには、文章(画面名)中の単語数をカウントする必要がある。

>注7：単語
画面名を構成する要素。
「ログイン入力」画面ならば、「ログイン」と「入力」が単語になる。

>注8：特徴量
画面名を基にカテゴリ分類する際、分類の手掛かりとする情報。

ところで、英語の文章であればスペースを区切り子とすれば、単語のカウントは簡単だ。
例)「This is a pen」ならば、スペース区切りで「This」「is」「a」「pen」と分解することで、簡単にカウントできる。

しかし、日本語は[膠着語](http://ja.wikipedia.org/wiki/%E8%86%A0%E7%9D%80%E8%AA%9E)のため、英語のように単純にはいかない。
ではどうやって単語をカウントするのか？

答えは、「[形態素解析](http://ja.wikipedia.org/wiki/%E5%BD%A2%E6%85%8B%E7%B4%A0%E8%A7%A3%E6%9E%90)」を使うこと。
形態素解析を使うことで、文章を形態素(自然言語で意味を持つ最小単位)に分解することができる。
この形態素解析を容易に実行できるのが、形態素解析エンジン「MeCab」だ。

試しにMeCabを使って「私は味噌ラーメンが大好きだ。」を形態素解析してみる。
下記はPythonのコード例だ。

```lang:MeCabTest.py
#coding:utf-8
import MeCab

tagger = MeCab.Tagger("mecabrc")
result = tagger.parse("私は味噌ラーメンが大好きだ。")
print(result)
```

実行結果はこのようになる。

```lang:MeCabのテスト結果
私　　　　名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は　　　　助詞,係助詞,*,*,*,*,は,ハ,ワ
味噌　　　名詞,一般,*,*,*,*,味噌,ミソ,ミソ
ラーメン　名詞,一般,*,*,*,*,ラーメン,ラーメン,ラーメン
が　　　　助詞,格助詞,一般,*,*,*,が,ガ,ガ
大好き　　名詞,形容動詞語幹,*,*,*,*,大好き,ダイスキ,ダイスキ
だ　　　　助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
。　　　　記号,句点,*,*,*,*,。,。,。
```

素晴らしい結果になった。

なお、C#でMeCabを使う場合は、NMeCabというライブラリを使えば良い。
下記はC#のコード例だ。

```lang:MeCabTest.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

//形態素解析用ライブラリ「NMeCab」をusingする
using NMeCab;

namespace MeCabTest
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                //解析対象の文章
                string strSentence = "私は味噌ラーメンが大好きだ。";

                MeCabParam param = new MeCabParam();

                //形態素解析に使用する日本語辞書を指定する
                param.DicDir = System.Environment.CurrentDirectory + @"\dic\ipadic";

                //形態素解析の実行
                MeCabTagger t = MeCabTagger.Create(param);

                //解析結果を形態素毎に表示する
                MeCabNode node = t.ParseToNode(strSentence);
                while (node != null)
                {
                    if (node.CharType > 0)
                    {
                        Console.WriteLine(node.Surface + "\t" + node.Feature);
                    }
                    node = node.Next;
                }
                Console.WriteLine();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            finally 
            {
                Console.Read();
            }
        }
    }
}
```

実行結果はこのようになる。

```lang:NMeCabのテスト結果
私      名詞,代名詞,一般,*,*,*,私,ワタシ,ワタシ
は      助詞,係助詞,*,*,*,*,は,ハ,ワ
味噌    名詞,一般,*,*,*,*,味噌,ミソ,ミソ
ラーメン        名詞,一般,*,*,*,*,ラーメン,ラーメン,ラーメン
が      助詞,格助詞,一般,*,*,*,が,ガ,ガ
大好き  名詞,形容動詞語幹,*,*,*,*,大好き,ダイスキ,ダイスキ
だ      助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
。      記号,句点,*,*,*,*,。,。,。
```

当然だがPythonと同じ結果になった。
ただし、今回はPythonを使って検証を進めることにする。

このように、日本語であっても形態素解析を行うことで、簡単に単語に分解することができる。
すなわち、単語をカウントすることができる。

#5.やってみる
もろもろの準備が完了したので、実際に試してみる。
今回登場するコードは以下の三つとなる。

* MyMeCab.py：画面名を形態素(単語)に分解するコード
* MyNaiveBayes.py：ナイーブベイズ分類器本体のコード
* MyClassify.py：メインコントロールのコード

なお、ソースコードはKatryo氏の[投稿](http://qiita.com/katryo/items/6a2266ffafb7efa9a46c)を参考にさせていただいた。
本投稿はナイーブベイズの実装が非常に分かりやすく解説されているため、興味のある方は参照した方が良いかもしれない。

以下、各コード内容を説明する。

```MyMeCab.py
#coding:utf-8
import math
import sys
import os
import MeCab

class StringAnalyze:
    def split_words(sentence):
        tagger = MeCab.Tagger('mecabrc')
        mecab_result = tagger.parse(sentence)
        info_of_words = mecab_result.split('\n')
        words = []

        for info in info_of_words:
            if info == 'EOS' or info == '':
                break

            info_elems = info.split(',')
            elems = info_elems[0].split('\t')
            words.append(elems[0])

        return words
```
4.形態素解析で説明したとおり、渡された画面名をMeCabを使って単語に分解する。
ただし、MeCabの解析結果には単語の読みや品詞の種類などの情報が付加されているため、単語のみを切り出すように手を加えている。

```MyNaiveBayes.py
#coding:utf-8
import math
import sys
import os
from MyMeCab import StringAnalyze

class NaiveBayes:
    def __init__(self):
        self.vocabularies = set()
        self.word_count = {}
        self.category_count = {}

    #カテゴリ単位で形態素(vacabulary)をカウント(Bag-of-Wordsの作成)
    def word_count_up(self, word, category):
        self.word_count.setdefault(category, {})
        self.word_count[category].setdefault(word, 0)
        self.word_count[category][word] += 1
        self.vocabularies.add(word)

    #カテゴリ数のカウント
    def category_count_up(self, category):
        self.category_count.setdefault(category, 0)
        self.category_count[category] += 1

    #画面名とカテゴリを基に学習
    def train(self, doc, category):
        #訓練データから画面名を形態素単位で取り出す
        words = StringAnalyze.split_words(doc)

        #カテゴリ単位で形態素をカウントする
        for word in words:
            self.word_count_up(word, category)

        #カテゴリ数をカウントする
        self.category_count_up(category)

    #ベイズ定理における事前確率の計算
    def prior_prob(self, category):
        num_of_categories = sum(self.category_count.values())
        num_of_docs_of_the_category = self.category_count[category]

        return num_of_docs_of_the_category / num_of_categories

    def num_of_appearance(self, word, category):
        if word in self.word_count[category]:
            return self.word_count[category][word]

        return 0

    # ベイズ定理の計算
    def word_prob(self, word, category):
        # ラプラス・スムージング
        numerator = self.num_of_appearance(word, category) + 1
        denominator = sum(self.word_count[category].values()) + len(self.vocabularies)

        prob = numerator / denominator

        return prob

    #予測したい文章がbasicとextraに含まれる確率を計算
    def score(self, words, category):
        score = math.log(self.prior_prob(category))
        for word in words:
            score += math.log(self.word_prob(word, category))

        return score

    #分類の実行
    def classify(self, doc):
        best_guessed_category = None
        max_prob_before = -sys.maxsize

        #予測したい文章を形態素に分解
        words = StringAnalyze.split_words(doc)

        #カテゴリ単位で類似度のスコアを算出
        for category in self.category_count.keys():
            #予測したい形態素
            prob = self.score(words, category)

            #予測したい文章を、スコアの最も大きいカテゴリに分類する
            if prob > max_prob_before:
                max_prob_before = prob
                best_guessed_category = category

        return best_guessed_category
```
ナイーブベイズの計算を行っている。
カテゴリやその中に含まれる単語のカウント、ベイズ定理の計算を行い、分類対象の画面名がどのカテゴリに分類される確率が高いのか計算している。

```MyClassify.py
#coding:utf-8
import math
import sys
import os
import pickle
import codecs
import numpy
import scipy
from sklearn.feature_extraction.text import TfidfVectorizer
from MyNaiveBayes import NaiveBayes
from MyMeCab import StringAnalyze

if __name__ == '__main__':
    #訓練済みデータを格納するpklファイルパスを定義
    pkl_nb_path = os.path.join('.\\', 'naive_bayes_classifier.pkl')

    #訓練済みデータ(pkl)が存在する場合、既存の訓練データを使用
    if os.path.exists(pkl_nb_path):
        with open(pkl_nb_path, 'rb') as f:
            nb = pickle.load(f)
    #訓練済みのデータ(pkl)がない場合、学習を行う。
    else:
        nb = NaiveBayes()
        #学習データの読み込み。
        fin = codecs.open('train_data.tsv', 'r', 'utf-8')
        lines = fin.readlines()
        fin.close()

        items = []

        #学習データを一行ずつ学習していく。
        for line in lines:
            words = line[:-2]
            train_words = words.split("\t")
            items.append(train_words[0])
            nb.train(train_words[0], train_words[1])

        #全データの学習が完了したら、訓練済みデータとしてpklファイルに保存する。
        with open(pkl_nb_path, 'wb') as f:
            pickle.dump(nb, f)

    #分類対象の文字列を指定し、学習結果に基づき分類を実施。
    doc = 'パスワード変更'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))
```
訓練データを読み込み、NaiveBayesクラスを使って学習を行っている。
そして、学習結果に基づき、分類対象の画面名をextraまたはbasicに分類している。
※上記コードでは、分類対象の画面名を「パスワード変更」としている。

それでは、MyClassify.pyを実行してみる。
[訓練データ](https://github.com/13o-bbr-bbq/machinelearn/blob/master/train_data.tsv)を見ると分かる通り、「パスワード」はextra(重点画面)に多く分類されているため、実行結果として「extra」に分類されて欲しい。

```lang:実行結果
パスワード変更 => 推定カテゴリ: extra
```
いい感じだ。

次は「商品詳細」を入力してみる。
これは「basic」に分類されて欲しい。

```lang:実行結果
商品詳細 => 推定カテゴリ: basic
```
いいね。

最後に「コメント完了」を入力してみる。
これは「extra」に分類されて欲しい。
しかし、この画面名は少しややこしい。
なぜならば、「コメント入力」「コメント確認」はbasic、「コメント完了」はextraだからだ。

```lang:実行結果
コメント完了 => 推定カテゴリ: extra
```
素晴らしい。

今回は適当な訓練データを使ったが、何となく上手くいっているような気がする。
過去1～2年分の分類データを使って訓練したら、かなり精度が高くなるのだろうか？

#6.まとめ
今回はナイーブベイズ分類器を使って、重点画面の選定を行った。
簡単なコードを書いたが、上手くいっているように見える。

なお、このナイーブベイズ分類器では、TF-IDFやストップワードを使うと、より精度を高めることができるらしい。
これらは次回検証し、その結果を投稿したいと思う。

以上