Pythonで「Bag Of Words」を使い文章の類似度を調べる【自然言語処理】

134, 2020-12-14

目次

JanomeでBag of Words

自然言語処理は人間の話している言葉をプログラムで解析しようという処理です。
機械学習や音声処理、機械翻訳などさまざまな分野で使われています。

自然言語処理について調べていたんですが、その基本的な処理に「Bag of Words」というものがあることを知りました。
今回は私の方でもBag of Wordsをやってみたいと思います。

具体的には↓の内容で見ていきます。

  • Bag of Wordsってなに?

  • 形態素解析とJanome

  • JanomeのNumPyのインストール

  • 実際にコードを書いてみる

Bag of Wordsってなに?

Bag of Words(バッグ・オブ・ワーズ)とは自然言語(人間が話す言葉)で書かれた文章を単語ごとに分割し、その単語の出現回数をベクトルで表現したものです。
このベクトルを使うことで文章の類似度を判定することが出来ます。

たとえば↓のような複数の文章があります。

猫はネズミを食べる
猫は猫と人間を食べる
犬は人間に食べられる
犬は犬を食べる

この文章を単語ごとに切り分けると↓のような行列になります。

【0134】corpus.png

これらの文章に現れる単語は↓のような一覧になります。

猫,は,ネズミ,を,食べる,と,人間,犬,に,食べ,られる

これらの単語の出現回数をカウントし、ベクトル化します。
ベクトル化すると↓のような行列になります。

【0134】corpus3.png

ベクトル化に成功したらあとは「cos類似度(コサインるいじど)」という計算方法でベクトルの類似度を求めます。
その結果がイコール、文章の類似度になるわけですね。

形態素解析とJanome

自然言語を単語に分割するには「形態素解析」という技術を使います。
英語はスペースで分割すれば単語のリストにできますが、日本語はそうもいきません。
形態素解析を行うことで日本語でも単語ごとに分割することが可能になります。

形態素解析ライブラリとして有名なのが「MeCab」というライブラリです。
これは非常に有名で広く使われています。
また、「Janome」というライブラリもあります。これは最近出てきたPythonのライブラリです。

今回の実装ではJanomeというライブラリを使いたいと思います。
Janomeはpipでインストールすれば使うことが出来るようになるので、非常にお手軽です。

たとえばJanomeで「猫はネズミを食べる」という日本語を形態素解析すると、Janomeは↓のようなイメージで文章を分割します

猫 / は / ネズミ / を / 食べる

↑のように単語ごとに分割すればあとはBoWを使って文章の類似度を計算することが出来ます。
ちなみに今回のBoWの実装はNumPyを使って自力でやります。BoWの専用ライブラリを使った方法は今回は解説しません。

JanomeとNumPyのインストール

Janomeをインストールするには環境のpipを使って↓のようにインストールします。

pip install Janome

またNumPyも↓のようにインストールしておきます。

pip install numpy

実際にコードを書いてみる

ではBoWの実装を行います。
↓がコード全文です。

from janome.tokenizer import Tokenizer
import numpy as np


def gen_toks_list(sentence_list):
    """
    文章のリストをJanomeのトークン列のリストに変換する
    """
    t = Tokenizer()
    return [list(t.tokenize(s)) for s in sentence_list]


def gen_word_to_index(toks_list):
    """
    トークンの表層形から添え字を得られるようにマップを作成する
    """
    word_to_index = {}

    for toks in toks_list:
        for tok in toks:
            # もしトークンがマップに含まれていなければインデックスを追加する
            if tok.surface not in word_to_index.keys():
                new_index = len(word_to_index)  # 新しいインデックスを作成
                word_to_index[tok.surface] = new_index  # マーク

    return word_to_index


def gen_corpus(toks_list, word_to_index):
    """
    行列の作成
    """
    # len(toks_list) x len(word_to_index) の成分0の行列を作成
    corpus = np.zeros((len(toks_list), len(word_to_index)))

    # 行列を処理する
    for i, toks in enumerate(toks_list):
        # 行を処理する
        for tok in toks:
            # 行の持っているトークンの列に対してマーキング
            corpus[i, word_to_index[tok.surface]] += 1

    return corpus


def gen_bag_of_words(toks_list):
    """
    Bag of Wordsを使って行列を作成
    """
    word_to_index = gen_word_to_index(toks_list)
    corpus = gen_corpus(toks_list, word_to_index)
    return corpus


def cos_sim(x, y):
    """
    類似度の計算
    cos類似度という方法を用いる
    """
    return np.dot(x, y) / (np.sqrt(np.sum(x ** 2)) * np.sqrt(np.sum(y ** 2)))


def show_per(corpus):
    """
    文章AとB, AとC, AとDの類似度を計算して表示する
    """
    for i, v in enumerate(['B', 'C', 'D']):
        i += 1
        per = cos_sim(corpus[0], corpus[i])
        print(f'A and {v}: {per:.2}')


def main():
    sentence_list = [
        '猫はネズミを食べる',  # 文章A
        '猫は猫と人間を食べる',  # 文章B
        '犬は人間に食べられる',  # 文章C
        '犬は犬を食べる',  # 文章D
    ]

    # トークン列のリストの作成
    toks_list = gen_toks_list(sentence_list)

    # Bag of Wordsの作成
    corpus = gen_bag_of_words(toks_list)

    # 結果を出力
    show_per(corpus)


main()

では1つ1つ各関数の仕事を見ていきます。

main関数

プログラムはこのmain関数ではじまります。

def main():
    sentence_list = [
        '猫はネズミを食べる',  # 文章A
        '猫は猫と人間を食べる',  # 文章B
        '犬は人間に食べられる',  # 文章C
        '犬は犬を食べる',  # 文章D
    ]

    # トークン列のリストの作成
    toks_list = gen_toks_list(sentence_list)

    # Bag of Wordsの作成
    corpus = gen_bag_of_words(toks_list)

    # 結果を出力
    show_per(corpus)

まずsentence_listというリストに比較対象の文章を保存します。
今回は先頭の要素から文章A, 文章Bと記憶しておいてください。

次にその文章のリストからトークン列のリストに変換します。
gen_toks_list()というのがそれです。
トークンとはJanomeが生成するトークンです。

そしてそのトークン列のリストからBoWを生成します。
gen_bag_of_words()がそれです。
corpusというのがBoWで作った行列です。

あとはそのcorpusshow_per()に渡して、類似度を表示します。

ではgen_toks_list()から見ていきます。

gen_toks_list関数

gen_toks_list()は文章のリストをJanomeのトークン列のリストに変換します。
janome.tokenizer.Tokenizerをオブジェクトにして、そのメソッドtokenize()に文章を渡すと、トークン列を得ることが出来ます。

def gen_toks_list(sentence_list):
    """
    文章のリストをJanomeのトークン列のリストに変換する
    """
    t = Tokenizer()
    return [list(t.tokenize(s)) for s in sentence_list]

gen_bag_of_words関数

gen_bag_of_words()では内部で2つの関数を呼び出しています。
gen_word_to_index()gen_corpus()です。

def gen_bag_of_words(toks_list):
    """
    Bag of Wordsを使って行列を作成
    """
    word_to_index = gen_word_to_index(toks_list)
    corpus = gen_corpus(toks_list, word_to_index)
    return corpus

gen_word_to_index関数

gen_word_to_index()ではトークン列の単語をインデックスに変換します。
既出の単語は無視して、word_to_index辞書に保存されていない単語が見つかったら、辞書にその単語とインデックスを保存します。
こうすることで単語からインデックスを参照できるようになります。

janome.tokenizer.Tokenは属性surfaceを持っていて、これを参照することでトークンの表層形を得ることが出来ます。
表層形とは元の文章のそのままの文字列のことです。

def gen_word_to_index(toks_list):
    """
    トークンの表層形から添え字を得られるようにマップを作成する
    """
    word_to_index = {}

    for toks in toks_list:
        for tok in toks:
            # もしトークンがマップに含まれていなければインデックスを追加する
            if tok.surface not in word_to_index.keys():
                new_index = len(word_to_index)  # 新しいインデックスを作成
                word_to_index[tok.surface] = new_index  # マーク

    return word_to_index

gen_corpus関数

gen_corpus()ではtoks_listword_to_indexを使って行列を生成します。
np.zeros()は要素を0で初期化した配列を作成します。
今回は引数にtoks_listの長さとword_to_indexの長さを指定しているので、その長さに対応した行列が作成されます。

for文でトークン列を回し、トークンに対応する行の列に値をマークしていきます。
こうすることで各文章(行)における単語の出現回数がベクトル化されます。

def gen_corpus(toks_list, word_to_index):
    """
    行列の作成
    """
    # len(toks_list) x len(word_to_index) の成分0の行列を作成
    corpus = np.zeros((len(toks_list), len(word_to_index)))

    # 行列を処理する
    for i, toks in enumerate(toks_list):
        # 行を処理する
        for tok in toks:
            # 行の持っているトークンの列に対してマーキング
            corpus[i, word_to_index[tok.surface]] += 1

    return corpus

show_per関数

show_per関数ではA行以外の行を、A行と比較してその結果を出力します。
A行は行列において0行目です。つまりcorpus[0]がA行です。
それ以外の行は添え字iを基点にして計算します。

類似度の計算ではcos_sim関数を使います。

def show_per(corpus):
    """
    文章AとB, AとC, AとDの類似度を計算して表示する
    """
    for i, v in enumerate(['B', 'C', 'D']):
        i += 1
        per = cos_sim(corpus[0], corpus[i])
        print(f'A and {v}: {per:.2}')

cos_sim関数

cos_sim関数は行x, yの類似度を計算します。
計算にはcos類似度(コサインるいじど)という方法を使います。
これはベクトルの類似度を求める式です。

def cos_sim(x, y):
    """
    類似度の計算
    cos類似度という方法を用いる
    """
    return np.dot(x, y) / (np.sqrt(np.sum(x ** 2)) * np.sqrt(np.sum(y ** 2)))

実行結果

このコードを実行すると結果は↓のようになります。

A and B: 0.75
A and C: 0.18
A and D: 0.51

文章ABの類似度は0.75と高くなっています。
いっぽうCは類似度が低くなっています。

おわりに

BoWを使うと比較的にかんたんに文章の類似度を比較することが出来ました。
デメリットとしては文脈を考慮しない判定方法なので精度がそれほど高くないというのがあげられます。
ただの単語の出現数の比較ですからね。
しかし自然言語処理の入門としてはとても良い題材だと思いました。

文章の類似度が比較出来たら次になにをする?

自分と似たような話し方の人を探すかな

生き別れの双子かも

参考

今回のコードは↓の記事を参考にさせていただきました。