[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

Pythonでシンプルな文章の類似度判定をやってみた

会社の後輩に、社内のFAQの利用率を高めたいからなんとかしたいといわれ、自然言語の入力を受け付けて近いFAQのリンクを戻すコードを書いてみました。 後輩に説明しながらという点と元データも70件程度と少なかったことや、そもそも自分がちゃんと理解してない点もあったので、gensimやsklearn等は用いずになんちゃってBoWで実装しました。

コンセプト

こんな感じでやることにしました。計算効率とかはあまり考えずわかりやすさを優先して実装します。

  • 元々あるFAQを単語分割
  • 単語の出現数をそれぞれカウント
  • ユーザが入力した内容も単語に分割し出現数をカウント
  • 出現している単語が近いものをスコア順に提案

対象データについて

こんなイメージのデータでした。中身はイメージです。URLも適当です。

Question Answer link
AWSとOracleCloudの違いはなんですか? 名前が違います。 http://intorasite/qa1.html
専用線はクラウドの利用に必須ですか? 専用線がなくてもクラウドは使えます。 http://intorasite/qa1.html
見積もりはどこにとったらいいですか? 専門の部署があるのでそちらにお願いします。 http://intorasite/qa1.html

実装について

元々あるFAQを単語分割

まずは、文書を単語に区切って行く必要があります。英語は単語がスペースで区切られるので簡単に実現できるのですが、日本語はそうはなっていないので難しいです。 所謂形態素解析といったことを行います。MeCabが使われることが多いですがMeCabはWindowsでは少しつらみがあるのでPythonだけで形態素解析ができるjanomeを使います。

DOS> pip install janome
from janome.tokenizer import Tokenizer

def split_into_words(doc):
    """
    名詞だけを取り出してリストで戻す関数
    """
    try:
        t = Tokenizer(mmap=True)
        word_list = []
        # 形態素して取り出す
        for token in t.tokenize(doc):
            # 品詞の判定をして、名詞か動詞か形容詞だけを取り出す
            if (token.part_of_speech.split(",")[0] in ("名詞","動詞","形容詞")
                and  token.part_of_speech.split(",")[1] != "数"):  # ただし、数詞は使っても意味が薄いので捨てる
                # 表層形を登録する
                word_list.append(token.surface)
        return word_list
    except Exception as ex:
        print(ex)
        return []

雑なtry ..exceptがあるのは、janomeで日本語以外が混じった時に死ぬので暫定処理用です。

split_into_words("本日は晴天なり") 
-> ['本日', '晴天']

意味がありそうな部分だけを取り出せました。

単語の出現数をそれぞれカウント

続いて、文章内の単語の出現数をまとめます。例えば敵の敵は味方なので敵の敵を探すという文章を分割すると以下の結果になります。

split_into_words("敵の敵は味方なので敵の敵を探す")
-> ['敵', '敵', '味方', '敵', '敵', '探す']

敵というキーワードが4回、味方と探すは1回ずつ出ていることがわかります。これをPythonで計算するには、forやらdictなどを使えば…という実装を思いつきますがそんなことは不要です。collections.Counterを使うだけで良いです。

from collections import Counter
Counter(['敵', '敵', '味方', '敵', '敵', '探す'])
-> Counter({'味方': 1, '探す': 1, '敵': 4})

この様に、リスト内のデータについてその出現回数を数えてくれます。結果はDictの様にキーでアクセスできます。

Counter(['敵', '敵', '味方', '敵', '敵', '探す']).get("敵")  # -> 4
Counter(['敵', '敵', '味方', '敵', '敵', '探す']).get("味方")  # -> 1

これが標準ライブラリに含まれているのは楽でいいですね。

では、FAQのデータについて出現数を求めておきます。とりあえずfaq_data.csvというCSVにまとめておいたのでそれを読みながらbow_dataというディクショナリに格納していきます。

bow_data = {}
with open("faq_data.csv", encoding="sjis") as f:
    reader = csv.DictReader(f)
    for row in reader:
        bow_data[row["Question"]] = {
            "link": row["URLlink"],
            "count_data": Counter(split_into_words(row["Question"] + " " + row["Answer"]))
        }

bow_dataは以下のような形式になります。

bow_data = {
    "質問内容1":{
        "link": "http://qa1のURL",
        "count_data": Q+Aの内容について単語の出現数を数えたcollectionc.Counterインスタンス
    },
    "質問内容2":{
        "link": "http://qa2のURL",
        "count_data": Q+Aの内容について単語の出現数を数えたcollectionc.Counterインスタンス
    },
    
}

ユーザが入力した内容も単語に分割し出現数をカウント

bow_dataというディクショナリができたので、あとはユーザからの入力とのマッチングをするだけです。ユーザからの入力についてはこれまでと同じ処理で問題ありません。

例えばGCPとAWSではAWSのほうが安いですか?という入力があった時は以下のようにします。

input_data = "GCPとAWSではAWSのほうが安いですか?"
input_counter = Counter(split_into_words(input_data))

input_counterの中にはCounter({'AWS': 2, 'GCP': 1, 'ほう': 1, '安い': 1})という情報が含まれています。これを元に先程作成したbow_dataの情報と突き合わせをしていきます。

出現している単語が近いものをスコア順に提案

入力された内容との近さを採点していきます。スコアは以下の式でとりあえず求めることにしました。

  • ある単語において、(質問に含まれている回数) * (FAQデータに含まれている回数) = スコア
  • 単語のスコアを合計してそのFAQ自体のスコアを算出

効率は悪いですが以下のような実装になります。

# 結果格納用
result_set = []
for question, data in bow_data.items():
    tmp_score = 0
    # あるFAQについてのCounterを取得
    count_data = data["count_data"]
    # 入力されている質問について1単語ずつ取得
    for word, word_count in input_counter.items():
        # QA内での出現数と質問内の出現数を乗じてスコアに加算
        tmp_score += count_data.get(word, 0) * word_count
    # scoreが0なQAは候補から外す
    if tmp_score == 0:
        continue
    result_set.append({
        "question": question,
        "score": tmp_score
    })
# スコアの降順で整列
result_set.sort(key=lambda x: x["score"], reverse=True)

これで、類似した文章順にresult_setに結果が格納されましたのでこんな感じで結果を取得できます。

# 上位3件を取得
for result in result_set[:3]:
    print(result)
    print(bow_data[result["question"]]["link"])
"""
結果例:
{'question': 'クラウドで一番安いのは?', 'score': 8}
http://xxxxxxx/03.html
{'question': 'クラウド環境での前提条件は?', 'score': 4}
http://xxxxxxx/01.html
{'question': 'クラウドサービスはそれぞれ何が違うのですか?', 'score': 2}
http://xxxxxxx/02.html
"""

まとめ

基礎的な内容ですが、なんとなく動くものができました。実際に作った時はユーザがリンクをクリックした際に追加学習をさせるなど、もう少し遊べる形にしましたが基本的にはこの実装を踏襲しています。外部に公開するシステムではないのであらもありますが、ちょっと動かして動作を理解する分には悪くなかったのかなと思います。