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

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

Bag of WordsをPythonで書いてみる

文書データを数値表現に変換する手法の1つであるBag of Wordsを一からPythonで書いてみました。

Bag of Words(BoW)とは

単語が含まれているかどうかだけを考え、語順は考慮せずに文書をベクトル(数値表現)に変換する方法です。このベクトル化により、文書データを機械学習アルゴリズムで使用できるようになります。
「ベクトル化」と聞いたときは最初ちょっと身構えてしまいましたが、このやり方自体はそれほど難しいものではありませんでした。

そのやり方とはこのようなものです。

A = "副作用とは主作用ではない作用です"
B = "薬には主作用と副作用があります"
という2つの文があったとき、

1. MeCabなどの形態素解析器を使って分かち書きします。
 A = [副作用、と、は、主、作用、で、は、ない、作用、です]
 B = [薬、に、は、主、作用、と、副作用、が、あり、ます]

2. これらのトークン(字句)から語彙を構築します。
[副作用、と、は、主、作用、で、ない、です、薬、に、が、あり、ます]

3. 個々の文書に対して語彙の単語が現れる回数を数えます。
f:id:enokisaute:20200527114348p:plain
これらの作業によって得られた数値の配列(まとまり)が、文書の特徴を表現したベクトルとなります。

BoWの問題点

このようにBoWはシンプルで効率よく作成できる反面、単語の並びを無視するため文章の構造が失われます。
例えば、
「マニュアルに出来ないことはない」
「マニュアルにないことは出来ない」
のような意味の異なる2つの文章においても、両者のBoW表現は同じベクトル(情報)として扱われます。
この問題点*1を補う方法として、1単語よりも大きい単位(nグラム)でBoWを構築する方法があります。

nグラムによるBoW

nグラムのBoWでは2つ、3つと連続する単語の列を1つのトークンとして考えます。
例えば、n=2の場合は上記の文章の語彙は'マニュアル に'、'に 出来'、'出来 ない'、'ない こと'、・・・のようにしてカウントを割り当てます。これによって部分的にですが、単語の順序を保つことが可能になります。
sklearnのCountVectorizerで使用する際にはパラメータngram_range=(トークンの最小長, 最大長)で設定することが可能です。

from sklearn.feature_extraction.text import CountVectorizer
import MeCab
 
texts = [
    "マニュアルに出来ないことはない",
    "マニュアルにないことは出来ない"
]
 
def text_to_words(text):
    tagger = MeCab.Tagger("-O wakati")
    return tagger.parse(text).split()
 
# ngram_range=(トークンの最小長, 最大長)
vectorizer = CountVectorizer(tokenizer=text_to_words, ngram_range=(1, 2))
print('語彙の内容: ', vectorizer.vocabulary_)
# 語彙の内容:  {'マニュアル': 10, 'に': 4, '出来': 12, 'ない': 2, 'こと': 0, 'は': 7, 'マニュアル に': 11, 'に 出来': 6, '出来 ない': 13, 'ない こと': 3, 'こと は': 1, 'は ない': 8, 'に ない': 5, 'は 出来': 9}
 
print(bag_of_words.toarray())
# 異なるベクトルになる
# [[1 1 2 1 1 0 1 1 1 0 1 1 1 1]
#  [1 1 2 1 1 1 0 1 0 1 1 1 1 1]]

指定するnグラムを大きくすると、区別できるようにはなりますが扱う語彙の数も増えます。訓練データに過剰に適合する可能性が高くなるので、増やしたからといって性能が向上するわけではなさそうです。

sklearnのCountVectorizerのパラメータについて

上で使用したCountVectorizerにはngram_range以外にも多くのパラメータがありますが、自分的にちょっと気になったものだけ書いておきます。

tokenizer

ここにMeCabを使用するなどして作成した分かち書き関数を指定することができる。
analyzer='word'のときに使用される。デフォルトではスペースや一部の記号で分割するので、日本語の文章を与えてもうまく分割できません。

preprocessor

特殊文字の削除や、特定の単語を正規化するなどの独自の前処理を行う関数を指定する。前処理を行うことでノイズや特徴量の次元を減らすことができる。

analyzer

単語レベル('word')のn-gramを作るか、分かち書きせず文字レベル('char')でn-gramを作るかを指定できる。またはCallableでも指定可。デフォルトは'word'。
スペースで区切られた文字列(言語)で'char_wb'にすると、単語ごとに区切った上でn-gramを作成する。これは日本語の場合はあまり関係ないようです。

stop_words

文書の情報としてあまり価値がない単語を処理の対象から外すことができる。日本語では「。」などの句読点や、「は」「です」「ます」のような助詞や助動詞など。
analyzer='word'のときに適用される。

max_dfとmin_df

出現頻度が高すぎる、または低すぎる単語を無視するために指定する。stop_words(ストップワード)でも無視する単語を指定できますが、こちらはコーパス(解析対象となる文書データ)特有のストップワードを作成できます。

私は最初、ドキュメントをよく読まずに「単語の出現回数(頻度)がこの数字を下回れば(上回れば)その単語は無視する」だと勝手に勘違いしていました*2が、これは間違いでした。
正しくは、「文書頻度がこの数字を下回れば(上回れば)その単語は無視する」です。
例えば、max_df=100なら100を超える文書に出現する単語を無視する、min_dfが5なら5未満の文書に出現する単語を無視、0.01なら1%未満に出現する単語を無視する、という意味です。
1つの文書にしか登場しないのに出現回数が多い単語、どの文書にも登場するのに出現回数がそれほど多くないような単語をストップワードに含めることができます。

BoWを自分で書いてみる

少しずつわかってきたので理解を深めるため、自分でBag of Wordsを計算するコードを書いてみました。CountVectorizerに一部似せて、使用するメソッドはfitとtransform、コンストラクタにはtokenizer(必ず指定)、min_df、max_df、stop_wordsを指定できるようにしました。

なお、min_df、max_dfはfloat(頻度)で指定された場合は次の式で変換してからその単語が含まれている文書数と比較して、ストップワードに含めるかどうかを判定するようにしました。

文書数=文書頻度×全文書数

import re
import numpy as np
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction import stop_words
 
# 自作のBoWクラスをテストする
 
class BagOfWords:
    def __init__(self, tokenizer, min_df=1, max_df=1.0, stop_words=None):
        self.tokenizer = tokenizer
        self.min_df = min_df
        self.max_df = max_df
        self.stop_words = stop_words
        self.vocab = {}     # key:単語, value:id
        self.num_of_doc = None  # データの全文書数
 
    # 語彙の辞書を作成
    def fit(self, text_list):
        dic = self.__count_doc(text_list)
        stop_word = self.__create_stopwords(dic)
        for word in dic:
            if word not in self.vocab and word not in stop_word:
                self.vocab[word] = len(self.vocab)
 
    # BoW, 単語の出現回数を降順にしたリストを返す
    def transform(self, text_list):
        feature = [[0] * len(self.vocab) for _ in range(len(text_list))]
        word_freq = {}
        for col, text in enumerate(text_list):
            for word in self.tokenizer(text_list[col]):
                if word in self.vocab:
                    idx = self.vocab[word]
                    feature[col][idx] += 1
                    word_freq[word] = word_freq.get(word, 0) + 1
        # word_freqはvalueの値で降順に
        return feature, sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
 
    # 単語ごとに, その単語が含まれている文書数をカウントする
    def __count_doc(self, text_list):
        doc_counter = {}  # key: 単語, value: [keyが含まれている文書数, 文書のindex]
        for idx, text in enumerate(text_list, 1):
            for word in self.tokenizer(text):
                # 辞書に登録されていないか、登録されていても同じ文書でない場合にカウントする
                if word not in doc_counter or doc_counter[word][1] != idx:
                    doc_counter[word] = [doc_counter.get(word, [0, 0])[0] + 1, idx]
        self.num_of_doc = idx
        return doc_counter
 
    # max_df, min_dfがfloatの場合は文書数(=文書頻度x全文書数)に変換する
    def __convert_cutoff(self, cutoff):
        if type(cutoff) is int:
            return cutoff
        elif type(cutoff) is float:
            return cutoff * self.num_of_doc
 
    # 指定されたmax_dfとmin_dfに応じてself.vocabに含めない単語のsetを作成する
    def __create_stopwords(self, dic):
        stop_w_set = set()
        _min = self.__convert_cutoff(self.min_df)
        _max = self.__convert_cutoff(self.max_df)
 
        # num[0]: wordが含まれている文書数
        for word, num in dic.items():
            if _min > num[0] or _max < num[0]:
                stop_w_set.add(word)
 
        # コンストラクタで指定されたストップワードがあればここで1つにまとめる
        if self.stop_words is not None:
            return stop_w_set | set(self.stop_words)   # 和集合を取る
        else:
            return stop_w_set
 
 
if __name__ == '__main__':
    reg = re.compile(r"[<>|:-]")
 
    # テスト用のtokenizer. 一部記号を削除してスペースで区切るだけ.
    def tokenizer(text):
        text = reg.sub('', text)
        return text.split()
 
 
    # skearnのストップワードを取得
    sw = stop_words.ENGLISH_STOP_WORDS
    # データセットの読み込み
    texts = fetch_20newsgroups(subset='train')
 
    # 自前のクラスでBoWを求める
    bow = BagOfWords(tokenizer=tokenizer,
                     min_df=0.01,
                     max_df=0.7,
                     stop_words=sw)
    bow.fit(texts.data)
    # BoWと単語の出現回数のリストを返す
    f_1, wf_1 = bow.transform(texts.data)
 
    print('語彙の数(自作): ', len(bow.vocab))
    print('BoW形状(自作): ',np.array(f_1).shape)
    # 単語の出現回数上位10項目を表示
    for v in wf_1[:10]:
        print(v)
 
    print('=' * 50)
    # sklearnでBoWを求める
    vectorizer = CountVectorizer(tokenizer=tokenizer,
                                 min_df=0.01,
                                 max_df=0.7,
                                 lowercase=False,   # Trueにすると小文字に変換される
                                 stop_words=sw)
    f_2 = vectorizer.fit_transform(texts.data)
 
    print('語彙の数(CountVectorizer): ', len(vectorizer.vocabulary_))
    print('BoW形状(CountVectorizer): ', repr(f_2))
 
    # 列方向に合計して出現回数の配列を作る
    freq_array = np.sum(f_2.toarray(), axis=0)
 
    # {単語, 出現回数}で辞書を作る
    wf_2 = {}
    for word, _id in vectorizer.vocabulary_.items():
        wf_2[word] = freq_array[_id]
 
    # value(出現回数)の値で降順に並べ替えて上位10項目を表示
    for v in sorted(wf_2.items(), key=lambda x: x[1], reverse=True)[:10]:
        print(v)

正しく機能するかを確かめるため、sklearnの英文テキストデータセット(クラス数: 20, 文書数: 11314(trainのみ))を用いて、その他のパラメータtokenizer、min_df、max_df、stop_wordsも同じ条件でテストしました。
CountVectorizerとの比較では、BoWの形状(CountVectorizerは非ゼロの要素のみ保持する疎行列ですが)、単語の出現回数上位10項目とも同じでした。

語彙の数(自作):  1749
BoW形状(自作):  (11314, 1749)
('The', 14870)
('In', 9294)
('Re', 7757)
('writes', 7677)
('article', 6425)
('like', 5235)
('If', 5057)
("don't", 4979)
('X', 4834)
('just', 4726)
==================================================
語彙の数(CountVectorizer):  1749
BoW形状(CountVectorizer):  <11314x1749 sparse matrix of type '<class 'numpy.int64'>'
	with 570712 stored elements in Compressed Sparse Row format>
('The', 14870)
('In', 9294)
('Re', 7757)
('writes', 7677)
('article', 6425)
('like', 5235)
('If', 5057)
("don't", 4979)
('X', 4834)
('just', 4726)

min_df, max_dfの値も変えながらいろいろ試してみたり、ここには書いていませんが、ナイーブベイズでの正解率も両方とも同じだったのでうまく動いていると思います。たぶん。
とりあえず自分なりに納得はできました。

*1:逆に語順が違っても同じベクトルで良いという場合もあります。

*2:dfはdocument frequency(文書頻度)でした。