Python3.3で実装したナイーブベイズをBing APIで取得したWebページで学習。文章を分類させる



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

[前回実装した](http://qiita.com/katryo/items/6a2266ffafb7efa9a46c)ナイーブベイズ。せっかく作ったので、これまで書いたコードと組み合わせて実用的なシステムにした。

GithubにAPIキー以外のコードをまるごと上げたので、よかったらそのまま使ってください。 https://github.com/katryo/bing_search_naive_bayes

## 作ったもの概要
1. 与えられた検索クエリで、Bing APIを使ってWeb検索して、50件のWebページを取得する
2. 取得したHTMLファイル内の文章をBag-of-wordsにして、検索クエリをカテゴリとしてナイーブベイズの学習をする。学習を行った分類器はpickle化して保存。
3. 分類器を使って、与えられた文がどのカテゴリーに属するか推測する

以上3つの機能を組み合わせて、学習データ取得と学習と分類を行えるシステムにした。

## 1. Bing APIでWeb検索してくる

@o-tomox の http://o-tomox.hatenablog.com/entry/2013/09/25/191506 をもとに、Python3.3でも動くように改良したラッパーを書いた。

BingのAPIキーはあらかじめmy_api_keys.pyというファイルに書き込んでおく。

```python:my_api_keys.py
BING_API_KEY = 'abcdefafdafsafdafasfaafdaf'
```

なお.gitignoreで無視させておくこと。でないとAPIキーを公開してしまう。

以下がBing APIのラッパーである。

```python:bing.py
# -*- coding: utf-8 -*-
import urllib
import requests
import sys
import my_api_keys


class Bing(object):
    # 同じ階層にmy_api_keys.pyというファイルを作り、そこにBING_API_KEYを書き込んでおく。
    # my_api_keys.pyはgitignoreしておくこと。
    def __init__(self, api_key=my_api_keys.BING_API_KEY):
        self.api_key = api_key

    def web_search(self, query, num_of_results, keys=["Url"], skip=0):
        """
            keysには'ID','Title','Description','DisplayUrl','Url'が入りうる
        """
        # 基本になるURL
        url = 'https://api.datamarket.azure.com/Bing/Search/Web?'
        # 一回で返ってくる最大数
        max_num = 50
        params = {
            "Query": "'{0}'".format(query),
            "Market": "'ja-JP'"
        }
        # フォーマットはjsonで受け取る
        request_url = url + urllib.parse.urlencode(params) + "&$format=json"
        results = []

        # 最大数でAPIを叩く繰り返す回数
        repeat = int((num_of_results - skip) / max_num)
        remainder = (num_of_results - skip) % max_num

        # 最大数でAPIを叩くのを繰り返す    
        for i in range(repeat):
            result = self._hit_api(request_url, max_num, max_num * i, keys)
            results.extend(result)
        # 残り
        if remainder:
            result = self._hit_api(request_url, remainder, max_num * repeat, keys)
            results.extend(result)

        return results

    def related_queries(self, query, keys=["Title"]):
        """
            keysには'ID','Title','BaseUrl'が入りうる
        """
        # 基本になるURL
        url = 'https://api.datamarket.azure.com/Bing/Search/RelatedSearch?'
        params = {
            "Query": "'{0}'".format(query),
            "Market": "'ja-JP'"
        }
        # フォーマットはjsonで受け取る
        request_url = url + urllib.parse.urlencode(params) + "&$format=json"
        results = self._hit_api(request_url, 50, 0, keys)
        return results

    def _hit_api(self, request_url, top, skip, keys):
        # APIを叩くための最終的なURL
        final_url = "{0}&$top={1}&$skip={2}".format(request_url, top, skip)
        response = requests.get(final_url, 
                                auth=(self.api_key, self.api_key), 
                                headers={'User-Agent': 'My API Robot'}).json()
        results = []
        # 返ってきたもののうち指定された情報を取得する
        for item in response["d"]["results"]:
            result = {}
            for key in keys:
                result[key] = item[key]
            results.append(result)
        return results


if __name__ == '__main__':
    # bing_api.pyを単独で使うと、入力した語で50件検索して結果を表示するツールになる
    for query in sys.stdin:
        bing = Bing()
        results = bing.web_search(query=query, num_of_results=50, keys=["Title", "Url"])
        print(results)
```

このBing APIラッパーを使って、検索結果ページ50件をローカルに保存するスクリプトを書いた。

```python:fetch_web_pages.py
from bing_api import Bing
import os
import constants
from web_page import WebPage

if __name__ == '__main__':
    bing = Bing()
    if not os.path.exists(constants.FETCHED_PAGES_DIR_NAME):
        os.mkdir(constants.FETCHED_PAGES_DIR_NAME)
    os.chdir(constants.FETCHED_PAGES_DIR_NAME)
    results = bing.web_search(query=constants.QUERY, num_of_results=constants.NUM_OF_FETCHED_PAGES, keys=['Url'])
    for i, result in enumerate(results):
        page = WebPage(result['Url'])
        page.fetch_html()
        f = open('%s_%s.html' % (constants.QUERY, str(i)), 'w')
        f.write(page.html_body)
        f.close()

```

なお、クエリや検索結果のHTMLを入れておくディレクトリの名前などはconstants.pyというファイルを作ってそこに書き入れておく。今回はまず「骨折」というクエリで検索することにする。

```python:constants.py
FETCHED_PAGES_DIR_NAME = 'fetched_pages'
QUERY = '骨折'
NUM_OF_FETCHED_PAGES = 50
NB_PKL_FILENAME = 'naive_bayes_classifier.pkl'
```

取得したWebページを扱いやすくするためにWebPageというクラスを作った。Bing APIで取得したURLをもとに、HTMLをフェッチして、文字コードをcChardetで調べ、邪魔なHTMLタグを正規表現で消す。

```python:web_page.py
import requests
import cchardet
import re


class WebPage():
    def __init__(self, url=''):
        self.url = url

    def fetch_html(self):
        try:
            response = requests.get(self.url)
            self.set_html_body_with_cchardet(response)
        except requests.exceptions.ConnectionError:
            self.html_body = ''

    def set_html_body_with_cchardet(self, response):
        encoding_detected_by_cchardet = cchardet.detect(response.content)['encoding']
        response.encoding = encoding_detected_by_cchardet
        self.html_body = response.text

    def remove_html_tags(self):
        html_tag_pattern = re.compile('<.*?>')
        self.html_body = html_tag_pattern.sub('', self.html_body)
```

さて、以上のコードを同じディレクトリに入れておき、

```
$ python fetch_web_pages.py
```

を実行する。Bing APIを叩いて50件のURLを取得するまでは一瞬だが、50件それぞれにHTTPリクエストを送ってHTMLを取得して……という処理にちょっと時間がかかる。30秒くらいで終わると思う。

処理が終わったら、fetched_pagesディレクトリを覗いてみよう。骨折_0.htmlから骨折.49.htmlまでのHTMLファイルができているはずだ。

## 2. 50件のHTMLファイルから学習する
さてここでようやく、前回実装したナイーブベイズの出番になる。

```python:naive_bayes
#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):
        # ベイズの法則の計算
        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):
        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 = 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('''Ruby（ルビー）は，まつもとゆきひろ（通称Matz）により開発されたオブジェクト指向スクリプト言語であり，
                従来 Perlなどのスクリプト言語が用いられてきた領域でのオブジェクト指向プログラミングを実現する。
                Rubyは当初1993年2月24日に生まれ， 1995年12月にfj上で発表された。
                名称のRubyは，プログラミング言語Perlが6月の誕生石であるPearl（真珠）と同じ発音をすることから，
                まつもとの同僚の誕生石（7月）のルビーを取って名付けられた。
             ''',
             'Ruby')
    doc = 'グイド・ヴァンロッサムが作ったオープンソース'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Pythonになるはず

    doc = '純粋なオブジェクト指向言語です.'
    print('%s => 推定カテゴリ: %s' % (doc, nb.classify(doc)))  # 推定カテゴリ: Rubyになるはず
```

このナイーブベイズ実装とダウンロードしたHTMLファイルを利用して、NaiveBayesオブジェクトである分類器に学習させる。

なお、学習させたNaiveBayesオブジェクトを毎回使い捨てるのはもったいないので、pickleライブラリを使って保存する。

学習と保存をさせるスクリプトはこちら。

```python:train_with_fetched_pages.py
import os
import pickle
import constants
from web_page import WebPage
from naive_bayes import NaiveBayes


def load_html_files():
    """
    HTMLファイルがあるディレクトリにいる前提で使う
    """
    pages = []
    for i in range(constants.NUM_OF_FETCHED_PAGES):
        with open('%s_%s.html' % (constants.QUERY, str(i)), 'r') as f:
            page = WebPage()
            page.html_body = f.read()
        page.remove_html_tags()
        pages.append(page)
    return pages

if __name__ == '__main__':
    # もういちど別の場所で使うのなら関数にする
    if not os.path.exists(constants.FETCHED_PAGES_DIR_NAME):
        os.mkdir(constants.FETCHED_PAGES_DIR_NAME)
    os.chdir(constants.FETCHED_PAGES_DIR_NAME)
    pages = load_html_files()
    pkl_nb_path = os.path.join('..', constants.NB_PKL_FILENAME)

    # もしすでにNaiveBayesオブジェクトをpickle保存していたらそれを学習させる
    if os.path.exists(pkl_nb_path):
        with open(pkl_nb_path, 'rb') as f:
            nb = pickle.load(f)
    else:
        nb = NaiveBayes()
    for page in pages:
        nb.train(page.html_body, constants.QUERY)
    # せっかく学習させたんだから保存しよう
    with open(pkl_nb_path, 'wb') as f:
        pickle.dump(nb, f)
```

以上のソースコードを同じディレクトリに入れておき、先ほど同様に

```
$ python train_with_fetched_web_pages.py
```

で学習と分類器の保存を実行する。今度は外部とHTTP通信をしないのでそんなに時間はかからない。僕の場合は5秒以下で終わった。

## 3. 保存した分類器でカテゴリ分け
以上の手順で、「骨折」という1つのクエリ＝1カテゴリの学習はできた。しかし1カテゴリだけで分類はできない。そこで、上の手順を、クエリを変えて何度か繰り返すことにする。

### 繰り返し
まず constants.py の QUERYを書き換える。

```python:constants.py
QUERY = ‘胃もたれ’  # ‘骨折’から書き換えた
```

そしてBing APIでHTMLをフェッチする。

```
$ python fetch_web_pages.py
```

フェッチしたHTMLファイル50個で、naive_bayes_classifier.pklという名前で保存したNaiveBayes分類器を学習させる。

```
$ python train_with_fetched_web_pages.py
```

以上の作業を、 constants.QUERY を「花粉症対策」や「虫歯」に書き換えて、何度か行う。

### カテゴリ分け
さて、学習が終わった。とうとうここからが本番だ。標準入力から文字列を受け取って、その文字列がどのカテゴリに振り分けられるべきかカテゴリ分けを分類器にさせよう。

分類をさせるスクリプトは簡単で、以下の通り。まずpickle化していたNaiveBayesオブジェクトをloadして、塩から出す。それからsys.stdinが入力されるたびに、NaiveBayesオブジェクトのclassify()メソッドをかけて結果を表示させるだけ。

```python:classify.py
# -*- coding: utf-8 -*-
import pickle
import constants
import sys

if __name__ == '__main__':
    with open(constants.NB_PKL_FILENAME, 'rb') as f:
        nb = pickle.load(f)
    for query in sys.stdin:
        result = nb.classify(query)
        print('推測されたカテゴリーは %s です' % result)
```

では実行しよう。

```
$ python classify_inputs.py
```

ターミナルツールとして動くので、そのまま文字列を入力する。手始めにWikipediaの[アレルギー](http://ja.wikipedia.org/wiki/%E3%82%A2%E3%83%AC%E3%83%AB%E3%82%AE%E3%83%BC)のページからとってきた文章「アレルギー（独 Allergie）とは、免疫反応が、特定の抗原に対して過剰に起こることをいう。免疫反応は、外来の異物（抗原）を排除するために働く、生体にとって不可欠な生理機能である。」を入れよう。

![allergie.png](https://qiita-image-store.s3.amazonaws.com/0/5960/fb4c6f55-f1e3-ae82-15e7-b882e17b931b.png "allergie.png")

成功！　「花粉症対策」カテゴリーと判断された。

次に[ライオンのクリニカ](http://clinica.lion.co.jp/oralcare/hamigaki.htm)のページの文章「食事やおやつを食べた後などは、プラーク中の細菌が糖分を代謝して酸を作り出すため、プラークに覆われた歯の表面は酸性状態となります。」というのを入れる。

![tooth_brush.png](https://qiita-image-store.s3.amazonaws.com/0/5960/f96e4056-c913-2dfb-cd55-b091d652c28c.png "tooth_brush.png")

こちらも成功。「虫歯」カテゴリだと推測された。

## まとめ
今回は自分で実装したナイーブベイズを使って、Web検索結果を利用した教師あり学習を行った。何度か試したが、それなりに良い性能を出している。

この実装では「は」や「を」など頻出すぎる語を含めて、出現したすべての単語の出現回数を数えている。本来ならtf-idfを使うなどして、頻出すぎる語は価値を下げ、素性を減らして計算コストを低くするといった工夫をすべきだが、今回はしなかった。学習に使ったデータが小さかったこともあり、計算に時間がかからなかったためだ。今後の課題として、tf-idfやLSA (潜在意味解析) のような手法を利用して、計算コストを下げる、あるいは精度を高めるといったことをしたい。

[scikit-learn](http://scikit-learn.org/stable/index.html)のようなライブラリを使えばもっと簡単に、高性能のスムージングやtfidfなど便利に使えるはずなので次はそれでやりたい。

[Githubにpushしてある](https://github.com/katryo/bing_search_naive_bayes)ので、よかったら見ていってください。あとスターください。

## 次回予告
ナイーブベイズからはいったん離れ、語の共起頻度から類似度を計算する機能の追加。次回、君は刻の涙を見る。
 http://qiita.com/katryo/items/b6962facf744e93735bb