Python3.3でナイーブベイズを実装する


**これは[現実逃避アドベントカレンダー2013](http://katryo.hatenablog.com/entry/2013/12/19/001823)の1日目の記事です**

[機械学習 はじめよう 第三回 ベイジアンフィルタを実装してみよう](http://gihyo.jp/dev/serial/01/machine-learning/0003)で紹介されたナイーブベイズを実装した。

ただし、この記事のコードはちょっと読みにくい。具体的には、変数名が word という単数形の単語なのに型がlistであったりと、罠が多い。さらに、[3ページ目のリスト7](http://gihyo.jp/dev/serial/01/machine-learning/0003?page=3)で比較演算子の < と > を間違えるという凡ミスも見つかった。たぶん、自分でコードを実行していないのだと思う。

そこで、この記事で紹介されたベイジアンフィルタを

- Python 3.3で
- Yahoo!デベロッパーズネットワークの日本語形態素解析ではなくMeCabを使って
- よりリーダブルに

実装しなおした。

### 追記
この記事で作ったベイジアンフィルタを使って、Bing APIを利用して取得したWebページを利用した学習と分類を行いました。 http://qiita.com/katryo/items/62291ba328de9d12bd30

## コード

```python:naive_bayes.py
#coding:utf-8
# http://gihyo.jp/dev/serial/01/machine-learning/0003 のベイジアンフィルタ実装をPython3.3向けにリーダブルに改良
import math
import sys
import MeCab


class NaiveBayes():
    def __init__(self):
        self.vocabularies = set()
        self.word_count = {}  # {'花粉症対策': {'スギ花粉': 4, '薬': 2,...} }
        self.category_count = {}  # {'花粉症対策': 16, ...}

    def to_words(self, sentence):
        """
        入力: 'すべて自分のほうへ'
        出力: tuple(['すべて', '自分', 'の', 'ほう', 'へ'])
        """
        tagger = MeCab.Tagger('mecabrc')  # 別のTaggerを使ってもいい
        mecab_result = tagger.parse(sentence)
        info_of_words = mecab_result.split('\n')
        words = []
        for info in info_of_words:
            # macabで分けると、文の最後に’’が、その手前に'EOS'が来る
            if info == 'EOS' or info == '':
                break
                # info => 'な\t助詞,終助詞,*,*,*,*,な,ナ,ナ'
            info_elems = info.split(',')
            # 6番目に、無活用系の単語が入る。もし6番目が'*'だったら0番目を入れる
            if info_elems[6] == '*':
                # info_elems[0] => 'ヴァンロッサム\t名詞'
                words.append(info_elems[0][:-3])
                continue
            words.append(info_elems[6])
        return tuple(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 = self.to_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):
        # ベイズの法則の計算。通常、非常に0に近い小数になる。
        numerator = self.num_of_appearance(word, category) + 1  # +1は加算スムージングのラプラス法
        denominator = sum(self.word_count[category].values()) + len(self.vocabularies)

        # Python3では、割り算は自動的にfloatになる
        prob = numerator / denominator
        return prob

    def score(self, words, category):
        # logを取るのは、word_probが0.000....01くらいの小数になったりするため
        score = math.log(self.prior_prob(category))
        for word in words:
            score += math.log(self.word_prob(word, category))
        return score

    # logを取らないと値が小さすぎてunderflowするかも。
    def score_without_log(self, words, category):
        score = self.prior_prob(category)
        for word in words:
            score *= self.word_prob(word, category)
        return score

    def classify(self, doc):
        best_guessed_category = None
        max_prob_before = -sys.maxsize
        words = self.to_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

if __name__ == '__main__':
    nb = NaiveBayes()
    nb.train('''Python（パイソン）は，オランダ人のグイド・ヴァンロッサムが作ったオープンソースのプログラミング言語。
                オブジェクト指向スクリプト言語の一種であり，Perlとともに欧米で広く普及している。イギリスのテレビ局 BBC が製作したコメディ番組『空飛ぶモンティパイソン』にちなんで名付けられた。
                Python は英語で爬虫類のニシキヘビの意味で，Python言語のマスコットやアイコンとして使われることがある。Pythonは汎用の高水準言語である。プログラマの生産性とコードの信頼性を重視して設計されており，核となるシンタックスおよびセマンティクスは必要最小限に抑えられている反面，利便性の高い大規模な標準ライブラリを備えている。
                Unicode による文字列操作をサポートしており，日本語処理も標準で可能である。多くのプラットフォームをサポートしており（動作するプラットフォーム），また，豊富なドキュメント，豊富なライブラリがあることから，産業界でも利用が増えつつある。
             ''',
             'Python')
    nb.train('''ヘビ（蛇）は、爬虫綱有鱗目ヘビ亜目（Serpentes）に分類される爬虫類の総称。
                体が細長く、四肢がないのが特徴。ただし、同様の形の動物は他群にも存在する。
                ''', 'Snake')
    nb.train('''Ruby（ルビー）は，まつもとゆきひろ（通称Matz）により開発されたオブジェクト指向スクリプト言語であり，
                従来 Perlなどのスクリプト言語が用いられてきた領域でのオブジェクト指向プログラミングを実現する。
                Rubyは当初1993年2月24日に生まれ， 1995年12月にfj上で発表された。
                名称のRubyは，プログラミング言語Perlが6月の誕生石であるPearl（真珠）と同じ発音をすることから，
                まつもとの同僚の誕生石（7月）のルビーを取って名付けられた。
             ''',
             'Ruby')
    nb.train('''ルビー（英: Ruby、紅玉）は、コランダム（鋼玉）の変種である。赤色が特徴的な宝石である。
                天然ルビーは産地がアジアに偏っていて欧米では採れないうえに、
                産地においても宝石にできる美しい石が採れる場所は極めて限定されており、
                3カラットを超える大きな石は産出量も少ない。
             ''', 'Gem')
    doc = 'グイド・ヴァンロッサムが作ったオープンソース'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Pythonになるはず
    print('Pythonカテゴリである確率: %s' % nb.score_without_log(['グイド・ヴァンロッサム', 'が', '作った'], 'Python'))
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['グイド・ヴァンロッサム', 'が', '作った'], 'Ruby'))

    doc = 'プログラミング言語のRubyは純粋なオブジェクト指向言語です.'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Rubyになるはず
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['プログラミング言語', 'の', 'Ruby', 'は', '純粋', 'な', 'オブジェクト指向言語', 'です', '。'], 'Ruby'))
    print('Pythonカテゴリである確率: %s' % nb.score_without_log(['プログラミング言語', 'の', 'Ruby', 'は', '純粋', 'な', 'オブジェクト指向言語', 'です', '。'], 'Python'))

    doc = 'コランダム'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Gemになるはず
    print('Gemカテゴリである確率: %s' % nb.score_without_log(['コランダム'], 'Gem'))
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['コランダム'], 'Ruby'))

```

## 自分なりの解説
このコードが行っていることを、学習 (train) と分類 (classify) に分けて説明する。

### 学習 (train) でやっていること
NaiveBayesのtrain(self, doc, category)メソッドは、文字列docとその文字列が属するcategoryを入力して、NaiveBayesオブジェクトに「どんな単語が出てきたとき、その単語を持つ文書をそのカテゴリーに入れるべきか」を学習させる。

整理すると、以下のようになる。

1. 入力された文字列をMeCabで単語のlistに分解する **to_words()**
2. 各単語が何回出現したか数え上げる。 **word_count_up()**
3. 入力されたカテゴリーがこれまで何度入力されたか数え上げる **category_count_up()**

### 分類 (classify)でやっていること
これまで学習フェイズで入力したカテゴリーのうち、「入力された文章が入るのが一番もっともらしいのはどれか」を計算している。

つまりそれぞれのカテゴリーに対して scoreメソッドを使い、確率（計算しやすくするため対数を取っている）を算出している。

すべてのカテゴリーの中で最高スコアのものが、最も尤度の高いカテゴリであると判断し、最終的にそのカテゴリを出力する。なおこのとき、加算スムージングのラプラス法を用いている。

#### NaiveBayes.scoreでlogを使っていることについて
なぜscore関数内ではlogを使い、prior_probやword_probでせっかく計算した確率を対数で計算してしまうのか？

[技評の元記事](http://gihyo.jp/dev/serial/01/machine-learning/0003?page=3)にも書いてあることだが、ベイズの法則の公式により算出された確率の値は非常に0に近くなる。文書中に出現する単語の総数は莫大なものであり、特定の単語（「Python」や「純粋」や「作った」など）が出現する回数は、比較するととても小さなものになる。なので、0に近くても計算できるよう、eで対数を取っている。

じゃあここで、その確率をlogを取らずに計算したらどうなるか？　試してみよう。

```
    def score(self, words, category):
        # logを取るのは、word_probが0.000....01くらいの小数になったりするため
        score = math.log(self.prior_prob(category))
        for word in words:
            score += math.log(self.word_prob(word, category))
        return score

    # logを取らないと値が小さすぎてunderflowするかも。
    def score_without_log(self, words, category):
        score = self.prior_prob(category)
        for word in words:
            score *= self.word_prob(word, category)
        return score
```

score_without_logは、対数を取らないで確率を計算しているバージョンだ。a * b * cの対数を取ると log(a) + log(b) + log(c)になる。scoreメソッドではfor word in wordsでのループの中でscoreにword_probの計算結果を繰り返し足し算していたが、score_without_logメソッドでは繰り返し掛け算している。

naive_bayes.pyのコードをそのまま実行すれば、logを取った効果がわかるだろう。

ちなみに、コードのこの部分で「Python, Snake, Gem, Ruby」の4つのカテゴリを学習させている。

```python:naive_bayes.py
if __name__ == '__main__':
    nb = NaiveBayes()
    nb.train('''Python（パイソン）は，オランダ人のグイド・ヴァンロッサムが作ったオープンソースのプログラミング言語。
                オブジェクト指向スクリプト言語の一種であり，Perlとともに欧米で広く普及している。イギリスのテレビ局 BBC が製作したコメディ番組『空飛ぶモンティパイソン』にちなんで名付けられた。
                Python は英語で爬虫類のニシキヘビの意味で，Python言語のマスコットやアイコンとして使われることがある。Pythonは汎用の高水準言語である。プログラマの生産性とコードの信頼性を重視して設計されており，核となるシンタックスおよびセマンティクスは必要最小限に抑えられている反面，利便性の高い大規模な標準ライブラリを備えている。
                Unicode による文字列操作をサポートしており，日本語処理も標準で可能である。多くのプラットフォームをサポートしており（動作するプラットフォーム），また，豊富なドキュメント，豊富なライブラリがあることから，産業界でも利用が増えつつある。
             ''',
             'Python')
    nb.train('''ヘビ（蛇）は、爬虫綱有鱗目ヘビ亜目（Serpentes）に分類される爬虫類の総称。
                体が細長く、四肢がないのが特徴。ただし、同様の形の動物は他群にも存在する。
                ''', 'Snake')
    nb.train('''Ruby（ルビー）は，まつもとゆきひろ（通称Matz）により開発されたオブジェクト指向スクリプト言語であり，
                従来 Perlなどのスクリプト言語が用いられてきた領域でのオブジェクト指向プログラミングを実現する。
                Rubyは当初1993年2月24日に生まれ， 1995年12月にfj上で発表された。
                名称のRubyは，プログラミング言語Perlが6月の誕生石であるPearl（真珠）と同じ発音をすることから，
                まつもとの同僚の誕生石（7月）のルビーを取って名付けられた。
             ''',
             'Ruby')
    nb.train('''ルビー（英: Ruby、紅玉）は、コランダム（鋼玉）の変種である。赤色が特徴的な宝石である。
                天然ルビーは産地がアジアに偏っていて欧米では採れないうえに、
                産地においても宝石にできる美しい石が採れる場所は極めて限定されており、
                3カラットを超える大きな石は産出量も少ない。
             ''', 'Gem')
    doc = 'グイド・ヴァンロッサムが作ったオープンソース'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Pythonになるはず
    print('Pythonカテゴリである確率: %s' % nb.score_without_log(['グイド・ヴァンロッサム', 'が', '作った'], 'Python'))
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['グイド・ヴァンロッサム', 'が', '作った'], 'Ruby'))

    doc = 'プログラミング言語のRubyは純粋なオブジェクト指向言語です.'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Rubyになるはず
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['プログラミング言語', 'の', 'Ruby', 'は', '純粋', 'な', 'オブジェクト指向言語', 'です', '。'], 'Ruby'))
    print('Pythonカテゴリである確率: %s' % nb.score_without_log(['プログラミング言語', 'の', 'Ruby', 'は', '純粋', 'な', 'オブジェクト指向言語', 'です', '。'], 'Python'))

    doc = 'コランダム'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Gemになるはず
    print('Gemカテゴリである確率: %s' % nb.score_without_log(['コランダム'], 'Gem'))
    print('Rubyカテゴリである確率: %s' % nb.score_without_log(['コランダム'], 'Ruby'))

```

これが実行結果だ。

```
グイド・ヴァンロッサムが作ったオープンソース => 推定カテゴリ: Python
Pythonカテゴリである確率: 5.05740150710565e-08
Rubyカテゴリである確率: 2.592066486298008e-08
プログラミング言語のRubyは純粋なオブジェクト指向言語です. => 推定カテゴリ: Ruby
Rubyカテゴリである確率: 1.0568043348436783e-20
Pythonカテゴリである確率: 1.4013428667584096e-21
コランダム => 推定カテゴリ: Gem
Gemカテゴリである確率: 0.0018248175182481751
Rubyカテゴリである確率: 0.0008143322475570033
```

 **1.0568043348436783e-20** ……(ﾟДﾟ;)

10のマイナス20乗。ものすごーく0に近い数字だ。

入力文字列が長くなればなるほど、特定のカテゴリに入る確率は低くなる。逆に入力文字列を短くして「コランダム」だけで特定のカテゴリに入る確率を計算したのが最後の例だ。しかしそれでも、最も確率の高いカテゴリ「Gem」に入る確率は **0.0018248175182481751** しかない。0.18%。「Ruby」カテゴリに入る確率の 0.0008143322475570033 に比べればかなり大きいが、それでも、とても小さい。計算しづらい数字であることはわかってもらえると思う。

## 参考
ナイーブベイズについての日本語記事 http://aidiary.hatenablog.com/entry/20100613/1276389337

## 追記
この記事で作ったベイジアンフィルタを使って、Bing APIを利用して取得したWebページを利用した学習と分類を行いました。続編はこちら http://qiita.com/katryo/items/62291ba328de9d12bd30