薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

ナイーブベイズ分類器を自分で実装してみる

以前薬歴文書の分類にナイーブベイズ分類器を使いました。この分類器は迷惑メールのフィルタやWebニュース記事の分類等で使用されているそうです。特徴として、性能はやや劣ることがあるものの、高速に訓練でき実装も比較的容易だということで、今回はこのナイーブベイズのアルゴリズムを自分で実装してみました。


ベイズの定理で条件付き確率を求める

f:id:enokisaute:20200602190317p:plain
「事象Bという条件下で事象Aが起こる確率」である条件付き確率P(A|B)は次の式で表せます。

 \displaystyle{P(A|B) = \frac{P(A \cap B)}{P(B)}}

この式を書き換えると、

P(A \cap B) = P(A|B) ×P(B)


P(A \cap B)は「事象AとBの両方が起こる確率」を表しますが、P(B \cap A)とも表せるので、上の式は以下のように書き換えられます。

P(B \cap A) = P(B|A) ×P(A)

これらの関係から、ベイズの定理と呼ばれる次の式が得られます。

 \displaystyle{P(A|B) = \frac{P(B|A) ×P(A)}{P(B)}}

この式を使うことで、既にわかっている条件付き確率の逆の条件付き確率を求めることができるというところがポイントです。

ナイーブベイズのアルゴリズム

ナイーブベイズでは上の式の事象A、Bはそれぞれ次のようにcat(カテゴリ)doc(文書)に置き換えて考えます。

\displaystyle{P(cat|doc) = \frac{P(doc|cat)P(cat)}{P(doc)}\propto P(doc|cat)P(cat)
}

P(cat|doc)事後確率
P(doc|cat)尤度(ゆうど)
P(cat)事前確率
この式は、これら統計学の用語を使って言うと「事後確率は尤度と事前確率の積に比例する」ということになります。
文書分類の予測を行う際には、カテゴリ毎のP(cat|doc)を求め、この値が最も高かったカテゴリを予測の結果として返すようにします。
先に示したように、文書分類を行う上では、実際に条件付き確率を求める必要はありません。分母のP(doc)は文書が生成する確率ですが、これはどのカテゴリにおいても同じ値なので計算には用いず、結局必要なのは分子だけです。

では分子の各項についてみていきます。

P(cat)

(文書を得る前の)カテゴリが生成する確率です。これは訓練データ中の「カテゴリ件数/総文書数」で求めます。

P(doc|cat)

あるカテゴリが与えられたときに文書が生成する確率です。

これを求めるには、以下のように考えていきます。
本来であれば、例えば、「糖尿病」という単語が表われる文書には「血糖」などの単語が「python」といった関連の薄い分野の単語よりも現れやすいはずですが、これを無視して単語の出現しやすさは互いに影響しない(独立である)、という仮定を置きます*1。この仮定によって、個々の確率をかけて次のように計算することが可能になります。

 \displaystyle{{P(doc|cat) = P(word_1 \wedge .... \wedge word_n|cat) = \prod_{i=1}^{n} P(word_i|cat)
}}

この P(word_i|cat)はあるカテゴリが与えられたときの単語 word_iが出る確率です。
これは、例えばカテゴリAである文書が単語の集合(word1, word2, ・・・, wordn)で構成されているとき、

 \small{\displaystyle{P(doc|cat) = \frac{word1の出現回数~~~~~~}{Aの総単語数}×\frac{word2の出現回数~~~~~~}{Aの総単語数}×・・・× \frac{wordnの出現回数~~~~~~}{Aの総単語数}}}

で求められるということです。

これでコードを書いていくのに必要な項目が揃いました。
しかし、実装するにあたっては、次の2点を考慮する必要があります。

ゼロ頻度問題

予測時に訓練データにない単語が一つでもあった場合、その単語の出現回数は0となり上のP(doc|cat)の式は掛け算なので結果が0になってしまいます。これをゼロ頻度問題といい、この問題を回避するために、補正操作(スムージング)を行います。ここでは、分子に+1を、分母に訓練データの語彙数を加える加算スムージングという方法を用います。
これにより上の式の各項は次のようになります。

(wordnの出現回数+1) / (Aの総単語数+訓練データの語彙数)

アンダーフロー対策

また、この式は小さい数同士の積になるので、結果がとても小さな数となり、アンダーフロー(コンピュータで表現可能な最小値を下回ってしまう現象)を起こす可能性があります。この問題に対しては、対数をとり積の計算を和の計算に変換することで対応します。

例)0.004 × 0.0001 = 0.0000004
→ log(0.004) + log(0.0001) = -14.73...

最終的には、次の式でP(cat|doc)を求めます。

 \displaystyle{{P(cat|doc) = \log P(cat) + \sum_{i=1}^{n} \log P(word_i|cat)}}

ナイーブベイズを実装する

実装もネットで探すと洗練されたものがたくさん見つかりますが、自分で理解したなりにコードを書いてみました。

import math
import MeCab
 
# 自分で書いたナイーブベイズでカテゴリ分類してみる
 
class NaiveBayes:
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer  # 分かち書き関数
        self.cat_word = {}      # カテゴリ毎の単語と出現回数 {カテゴリ: {単語, 出現回数}}
        self.cat_texts = {}     # カテゴリ毎の文書数 key: カテゴリ, value: そのカテゴリの文書数
        self.wc_total = {}      # カテゴリ毎の単語の総数 + 訓練データの語彙数
        self.num_of_texts = 0   # 訓練データの文書数
 
    def fit(self, x_train, y_train):
        for idx, (text, cat) in enumerate(zip(x_train, y_train), 1):
            self.cat_texts[cat] = self.cat_texts.get(cat, 0) + 1
            for word in self.tokenizer(text):
                self.cat_word.setdefault(cat, {})
                self.cat_word[cat][word] = self.cat_word[cat].get(word, 0) + 1
        self.num_of_texts = idx
 
        # p(doc|cat)の計算に必要なカテゴリ毎の分母を先に求めておく
        # 訓練データの語彙数
        total_vocab = 0
        for cat in self.cat_texts.keys():
            total_vocab += len(self.cat_word[cat])
        # カテゴリ毎の総単語数 + 訓練データの語彙数
        for cat in self.cat_texts.keys():
            self.wc_total[cat] = sum(self.cat_word[cat].values()) + total_vocab
 
    # 与えられた文書のカテゴリを推定する
    def predict(self, text):
        p = {}
        w_list = self.tokenizer(text)
        for cat, cat_num in self.cat_texts.items():
            p[cat] = math.log(cat_num / self.num_of_texts)
            wc_total = self.wc_total[cat]
            for word in w_list:
                # catの中にwordが出てきた回数+1
                num = self.cat_word[cat].get(word, 0) + 1
                p[cat] += math.log(num / wc_total)
 
        # カテゴリ毎のスコアを表示
        # for v in sorted(p.items(), key=lambda x: x[1], reverse=True):
        #     print(v)
        return max(p, key=p.get)
 
    # データの正解率を計算する
    def score(self, x_data, y_data):
        correct_ans = 0
        for text, cat in zip(x_data, y_data):
            pred = self.predict(text)
            if pred == cat:
                correct_ans += 1
        return correct_ans / self.num_of_texts * 100
 
 
if __name__ == '__main__':
    # 訓練データとそのカテゴリ(ウィキペディアより引用)
    texts_data = ["2型糖尿病は、インスリン分泌低下と感受性低下の二つを原因とする糖尿病である。\
                  欧米では感受性低下(インスリン抵抗性が高い状態)のほうが原因として強い影響をしめすが、\
                  日本では膵臓のインスリン分泌能低下も重要な原因である。",
                  "消化性潰瘍は、胃の内面、小腸の最初の部分、ときには食道下部における潰瘍を指す。\
                  胃の損傷は胃潰瘍と呼ばれ、腸の最初の部分の潰瘍は十二指腸潰瘍と呼ぶ。\
                  十二指腸潰瘍の最も一般的な症状は、夜中に目が覚めることで、上腹部痛と上腹部痛があり、\
                  食事をすると改善する。胃潰瘍では、食べると痛みが悪化することがある",
                  "便秘とは、ヒト(または他の動物)において便の排泄が困難になっている状態の総称である。\
                  原因は腫瘍の増殖に伴う消化管の狭窄や閉塞などの器質的な要因による便の通過障害もあれば、\
                  気質的には全く異常を認めない機能性の便秘もあるなど、多岐にわたる。"
                ]
    target = ["糖尿病", "消化性潰瘍", "便秘"]
 
    # 分かち書き関数
    def tokenizer(text):
        tagger = MeCab.Tagger("-O wakati")
        return tagger.parse(text).split()
 
 
    nb = NaiveBayes(tokenizer)
    # 訓練
    nb.fit(texts_data, target)
 
    # テストデータ
    text1 = "(S)良くなってきてるけど、ときどき緩い。(O)改善しているが、下痢になることも。腹痛はない(A)毎晩服用している。頓服であるため様子をみて服用量を調節するよう説明する。"
    text2 = "(O)今回からDPP-4阻害薬追加(A)血糖値はほぼ横ばい状態。低血糖症状とその対処について再確認。(P)血糖値確認"
    text3 = "最近あまり食べられません。胃のあたりがムカムカします。"

 
    texts = [text1, text2, text3]
    # カテゴリの推定
    for txt in texts:
        print(txt)
        print(">推定カテゴリ:", nb.predict(txt) + "\n")
 

3つのカテゴリ(糖尿病、消化性潰瘍、便秘)についての文章をウィキペディアからお借りして、これを訓練データとしました。そのデータでナイーブベイズ分類器を訓練後、与えた文章をうまくカテゴリ分類できるかテストしています。(テストデータは薬歴の体裁を取っているものもありますが、私の創作です)なお、MeCabで使用している辞書はインストール時のままです。
結果は以下のようになり、思ったとおりのカテゴリに分類できました。

# (S)良くなってきてるけど、ときどき緩い。(O)改善しているが、下痢になることも。腹痛はない(A)毎晩服用している。頓服であるため様子をみて服用量を調節するよう説明する。
>推定カテゴリ: 便秘
 
# (O)今回からDPP-4阻害薬追加(A)血糖値はほぼ横ばい状態。低血糖症状とその対処について再確認。(P)血糖値確認
>推定カテゴリ: 糖尿病
 
# 最近あまり食べられません。胃のあたりがムカムカします。
>推定カテゴリ: 消化性潰瘍


推定カテゴリのスコア

predictメソッドのコメントアウトしている部分(# カテゴリ毎のスコアを表示)では、カテゴリ毎に計算したP(doc|cat)の値を大きい順に表示するようにしています。例えば、テストデータの2つ目の文章で表示させると以下のようになります。

(O)今回からDPP-4阻害薬追加(A)血糖値はほぼ横ばい状態。低血糖症状とその対処について再確認。(P)血糖値確認
('糖尿病', -192.67470310910514)
('便秘', -195.35511631228832)
('消化性潰瘍', -196.054021470746)
>推定カテゴリ: 糖尿病

値はマイナスになっていますが、P(doc|cat)のスコアが最も大きいカテゴリを推定カテゴリとして採用しています。

つまづいたところ

自分で理解したなりに書いたと上に書いたのですが、最初に書いたコードでは2点ほど問題があり、参考文献のコードを参考にしたところがあります。

まず1点目は、加算スムージングのところで最初は分母に訓練データの語彙数を加えずにコードを書いていたことです。もちろん、うまく機能しませんでした。分子に+1はわかるのですが、分母はどうして訓練データの語彙数をプラスするのか、理論的なところがどうもよくわかっていません。

そして2点目は、データの正解率を計算するscoreメソッドの処理時間がすごく遅くなってしまうことでした(私のPCだとsklearnのニュースデータセットで1分くらいかかった)。これはP(doc|cat)を求めるところで、分母(カテゴリの全単語数+訓練データの語彙数)の値をループ内で毎回.values()で取得していたことが原因でした。
この値は与えられる文書に関係ないので、前もって計算して保持しておくことができます。これによって処理時間は半分以下の26秒くらいまで改善しました。

他にscoreメソッドを速くする方法についてやってみたことを書きます。
予測と正解ラベルが等しければcorrect_ans(正解)を加算するので、最初に正解ラベルのカテゴリでP(doc|cat)を計算しておき、他のカテゴリのP(doc|cat)が一つでもそれを上回れば不正解として、残りのカテゴリでは計算せず次のデータに移るというふうにすれば多少はスピードが上がるかなと思い、試してみました。
結果、sklearnのニュースデータセットでsubset='all'(trainとtest両方で計18846)として5回計測しましたが、平均1.6秒程度しか速くはなりませんでした。このやり方は正解率が悪いときに速くなる方法なので、あまり数字には表れなかったのかもしれません。
もっとも、sklearnのナイーブベイズでは事前にベクトル化したデータを与えるという違いはあれど、scoreメソッドの処理時間はコンマ何秒のレベルですから、数秒速くなったところで比較になりませんが。

まとめ

  • ナイーブベイズは訓練も予測も高速。アルゴリズムも何をしているかわかりやすい。
  • 他のモデルで時間がかかるような大規模なデータセットにおいては、最初に試すことで分類器の性能のベースラインとなる。
  • 今回の実装はナイーブベイズの多項モデルといい、語彙数が多い場合に分類の精度が高くなる。


*1:この単純化のことを指して「ナイーブ」ベイズというそうです。