Janomeで質問文から回答を連想する【自然言語処理, Python】

187, 2021-02-19

目次

Janomeで連想を行う

人間が話す言語は「自然言語」と言われますが、これを解析するのが「自然言語処理」です。
自然言語処理は「字句解析」「構文解析」などのいくつかの工程にわかれて行われますが、PythonではJanomeという形態素解析ライブラリを使うと簡単に字句解析が行えます。

スポンサーリンク

今回はこのJanomeを使い、質問文を解析してそこから回答を連想するというプログラムを作ってみたいと思います。

具体的には↓を見ていきます。

  • プログラムの出力結果
  • 形態素解析とは?
  • Janomeとは?
  • 「連想」とはどういうものか?
  • プログラムの仕様
  • プログラムのソースコード
  • ソースコードの解説

プログラムの出力結果

今回作成するプログラムを動作させると↓のような出力結果になります。

質問 > 大福って?
食べ物です
質問 > 大福って?
お菓子です
質問 > 大福って?
大福です
質問 > 大福って?
甘いです
質問 > 猫は?
かわいいです
質問 > 猫は?
猫です
質問 > 猫は?
動物です
質問 >

↑の場合「大福って?」という質問文に対して、「大福」というワードから連想を行い「食べ物です」という回答を出力してるのがわかります。
他にも「猫は?」という質問文から「かわいい」という連想を行い、「かわいいです」という回答を出力しています。

形態素解析とは?

形態素解析(字句解析)とは自然言語処理の基礎的な工程の1つです。
簡単に言うと日本語の文章を単語のリストに変換するのが形態素解析です。

たとえば↓のような文章があるとします。

犬が歩いた

↑の文章を形態素解析すると↓のような単語のリストになります。

犬 / が / 歩い / た

↑の単語は解析され、単語の品詞の種類読み方原形などが単語の属性に保存されます。
このように文章を単語に分割して解析を行っておくことで、字句解析の後に続く工程をやりやすくします。

字句解析の後の工程、たとえば構文解析などは、単語間の係り受けの関係を解析しますが、そのときに形態素解析で解析した情報を使って解析を行います。
そして構文解析の後の工程である意味解析では、この係り受けの関係を使ってさらに一歩進んだ解析を行います。
このように解析を積み重ねていって大きな問題を解決しようというのが自然言語処理の基本的な考え方だと思われます。

Janomeとは?

形態素解析を行うライブラリは昔からあるものとしてはMeCab(メカブ)が有名です。
これはいろいろなところで使われていて、今でもよく使われます。

JanomeはMeCabの辞書を使用するPythonの形態素解析ライブラリです。
pipなどのパッケージマネージャーで簡単に導入することが可能で、利便性が高く、さいきんよく使われています。
Python製なので速度的にはそれほど速くはありませんが、学習や実験用途には十分な実用性を備えています。

なによりpipで導入可能と言うのが非常に楽だと思います。
インストールはpipであれば↓のコマンドだけで可能です。

$ pip install janome

「連想」とはどういうものか?

今回プログラムに書き起こしてみる「連想」とはどういうものでしょうか?
goo辞書では連想は↓のように定義されています。

1 ある事柄から、それと関連のある事柄を思い浮かべること。また、その想念。「雲を見て綿菓子を―する」
2 心理学で、ある観念の意味内容・発音・外形の類似などにつれて、他の観念が起きてくること。観念連合。→連合2

つまり連想とは「あるワード」から「別のワード」を思い浮かべることを指します。
たとえば「大福」というワードから「甘い」とか「お菓子」とかを連想するのが連想です。

連想と言えば連想ゲームが有名ですが、みなさんもやったことがあるのではないでしょうか。
今回はあの連想ゲーム的なものをプログラム的にやってみようというのが趣旨です。

プログラムの仕様

今回はルールベースの連想を行います。
ルールベースとは、機械学習などを使わずに代わりに人力で作成した辞書を使い、その辞書にワードをマッチさせ連想させて結果を出そうというものです。
この連想に使う辞書にはPythonのdictlistを組み合わせて使います。
これはassociation_brainという属性にしてクラスに持たせます。

ユーザーが質問文を入力したらプログラムは最初にその入力をJanomeでトークン列にします。

そして次にそのトークン列が質問文かどうか判定します。
この判定は簡易的なもので、単純に文章中に「?」があるかどうか調べるだけです。

そしてトークン列が質問文だと判定したら、その質問文から名詞を取り出します。
名詞とは「大福」とか「猫」とかのワードです。
名詞を取り出したら、その表層形からワードを連想します。
「表層形」とは単語のそのままの表記の文字列のことです。これはJanomeが生成します。

ワードの連想はassociation()というメソッドで行います。
このメソッドでassociation_brainから連想ワードを取り出し、その取り出したワードを元に回答文を生成します。

プログラムのソースコード

今回作成するプログラムのソースコードの全文は↓です。
このプログラムを実行するには↓のソースコードをsample.pyなどに保存し、python sample.pyとコマンドを実行します。

"""
質問文の名詞から関連ワードを連想するプログラム

Author: narupo
License: MIT
Created at: 2021/01/13
"""
from janome.tokenizer import Tokenizer, Token
import random


class Analyzer:
    """
    質問文を解析して連想を行うクラス
    """
    def __init__(self):
        # 連想に使う脳みそ
        # キーが連想にマッチするワードで、値のリストが連想するワード
        # 値はキーに関連し、この値を再帰的に参照するとどんどんと連想していくことが可能
        # たとえば「大福」→「お菓子」→「食べ物」というふうに連想が進む
        self.association_brain = {
            '大福': ['お菓子', '甘い'],
            '猫': ['動物', 'かわいい'],
            'お菓子': ['食べ物'],
            '動物': ['生命'],
        }

    def find_hatena(self, toks: list) -> Token:
        """
        toksの中に「?」が含まれていたらそのトークンを返す
        """
        for tok in toks:
            if tok.surface == '?':
                return tok
        return None

    def find_meisi(self, toks: list) -> Token:
        """
        toksの中から名詞のトークンを探し、見つけたらそれを返す
        """
        for tok in toks:
            if '名詞' in tok.part_of_speech.split(','):
                return tok
        return None

    def association(self, word: str, max_dep: int, dep: int=0) -> str:
        """
        wordから関連ワードを連想し、そのワードを返す
        """
        if dep >= max_dep:
            return word

        if word not in self.association_brain.keys():
            return word

        synapse = self.association_brain[word]

        # 連想するワードはランダムに選択する
        index = random.randint(0, len(synapse) - 1)

        # 再帰的に連想を行う(max_depの値まで)
        return self.association(synapse[index], max_dep, dep + 1)

    def analyze(self, question: str) -> str:
        """
        questionを解析して回答を返す
        """
        t = Tokenizer()
        toks = list(t.tokenize(question))

        # 質問文かどうか?
        found_hatena = self.find_hatena(toks)
        if not found_hatena:
            return 'わかりません'

        # 質問文から名詞を取得する
        target = self.find_meisi(toks)
        if not target:
            return 'わかりません'

        # 連想の深さを指定する
        # ここの値が大きければ連想が深く進行する
        max_dep = random.randint(0, 2)

        # 名詞からワードを連想する
        word = self.association(target.surface, max_dep)
        return word + 'です'  # 回答を返す



def main():
    an = Analyzer()

    while True:
        # 質問の入力を受ける
        try:
            question = input('質問 > ')
        except (KeyboardInterrupt, EOFError):
            break

        # 質問を解析して回答を出す
        answer = an.analyze(question)
        print(answer)


main()

スポンサーリンク

ソースコードの解説

簡単ですがソースコードの解説になります。

必要モジュールのインポート

最初に必要モジュールをインポートします。

from janome.tokenizer import Tokenizer, Token
import random

JanomeのtokenizerモジュールからTokenizerクラスとTokenクラスをインポートします。
Tokenizerクラスは実際に形態素解析を行うクラスです。
Tokenはアノテート(型の表示)用にインポートしています。

それから今回のプログラムは乱数を使います。
そのためrandomモジュールもインポートしておきます。

main関数の作成

プログラムのエントリーポイントは↓のmain関数です。
内容的にはAnalyzerクラスをオブジェクトにして無限ループに入ります。
そしてinput()で入力を受けてその入力をAnalyzeranalyzer()メソッドに渡し、回答を得て出力します。

input()Ctrl+CEOFが入力されるとそれに対応する例外を投げてきます。
それらの例外をキャッチしたら無限ループから抜けるようにします。
(Ctrl+Cの場合はKeyboardInterrupt, EOF入力の場合はEOFError)

def main():
    an = Analyzer()

    while True:
        # 質問の入力を受ける
        try:
            question = input('質問 > ')
        except (KeyboardInterrupt, EOFError):
            break

        # 質問を解析して回答を出す
        answer = an.analyze(question)
        print(answer)


main()

Analyzerクラスの作成

今回メインの解析を行うのはAnalyzerという自作クラスです。
このクラスにメソッドなどを定義して使います。

self.association_brainは連想に使用する辞書(脳みそ)です。
詳細はコメントをご覧ください。

class Analyzer:
    """
    質問文を解析して連想を行うクラス
    """
    def __init__(self):
        # 連想に使う脳みそ
        # キーが連想にマッチするワードで、値のリストが連想するワード
        # 値はキーに関連し、この値を再帰的に参照するとどんどんと連想していくことが可能
        # たとえば「大福」→「お菓子」→「食べ物」というふうに連想が進む
        self.association_brain = {
            '大福': ['お菓子', '甘い'],
            '猫': ['動物', 'かわいい'],
            'お菓子': ['食べ物'],
            '動物': ['生命'],
        }
    ...

analyze()で質問文を解析

メインの処理はanalyze()で行います。
analyze()は引数questionを取ります。これは入力された質問文です。

内部ではTokenizer()でJanomeのトーカナイザーをオブジェクトにします。
そしてtokenize()メソッドにquestionを渡してトークン列に変換します。
tokenize()はジェネレーターを返してくるのでここでは明示的にlist()でリストに変換しています。

それからfind_hatena()にトークン列を渡し、文章中に「?」が無いか調べます。
「?」が見つからなければ「わかりません」と返します。

次にトークン列から名詞を探します。
名詞が見つからなければ「わかりません」と返します。

名詞が見つかったらmax_depを計算します。
このmax_depは連想の深さの限界値を表す変数です。
この値が大きければ大きいほど、連想を深くすることが可能です。
逆に値が小さいと、連想の深さは浅くなります。

max_depを計算したらassociation()メソッドで連想を行います。
引数には名詞(target)の表層形(target.surface)と、max_depを渡します。

    def analyze(self, question: str) -> str:
        """
        questionを解析して回答を返す
        """
        t = Tokenizer()
        toks = list(t.tokenize(question))

        # 質問文かどうか?
        found_hatena = self.find_hatena(toks)
        if not found_hatena:
            return 'わかりません'

        # 質問文から名詞を取得する
        target = self.find_meisi(toks)
        if not target:
            return 'わかりません'

        # 連想の深さを指定する
        # ここの値が大きければ連想が深く進行する
        max_dep = random.randint(0, 2)

        # 名詞からワードを連想する
        word = self.association(target.surface, max_dep)
        return word + 'です'  # 回答を返す

find_hatena()で「?」を探す

find_hatena()は引数toksから「?」の表層形を持つトークンを探します。
トークンが見つかった場合はそのトークンを返し、トークンが見つからない場合はNoneを返します。

    def find_hatena(self, toks: list) -> Token:
        """
        toksの中に「?」が含まれていたらそのトークンを返す
        """
        for tok in toks:
            if tok.surface == '?':
                return tok
        return None

find_meisi()で名詞を探す

find_meisi()は引数toksから名詞のトークンを探します。
Janomeのトークンはpart_of_speechという属性を持っています。
この属性は品詞のリストで、品詞がカンマ(,)区切りで並べられた文字列です。
これをsplit(',')でリストに分割し、そのリストの中に「名詞」と言う文字列が含まれていないか判定します。
part_of_speechの中に「名詞」が含まれていたらそのトークンは名詞と言うことになります。

    def find_meisi(self, toks: list) -> Token:
        """
        toksの中から名詞のトークンを探し、見つけたらそれを返す
        """
        for tok in toks:
            if '名詞' in tok.part_of_speech.split(','):
                return tok
        return None

association()で連想を行う

association()メソッドは連想を行います。
引数wordは連想を始める文字列、max_depは連想の最大の深さ(再帰上限数)、depは現在の連想の深さ(再帰数)です。

このメソッドは内部で再帰を行います。
そのため最初に再帰の終了条件をチェックします。
depmax_dep以上だったら再帰を終了し、wordreturnします。

次にwordself.association_brainのキーに含まれているかチェックします。
キーに含まれていたらそのwordは連想可能と言うことになります。
含まれていなければ連想できないのでwordreturnします。
連想可能であればそのキーのリスト(synapse)を取り出します。

次にindexを算出します。このindexはリスト(synapse)のインデックスです。
これはランダムに算出します。

そして最後に末尾再帰を行います。
self.association()synapseのワードを渡し、depを増加させて再帰します。
このときdepを増加させないと無限ループになります。終了条件が真にならないからです。

    def association(self, word: str, max_dep: int, dep: int=0) -> str:
        """
        wordから関連ワードを連想し、そのワードを返す
        """
        if dep >= max_dep:
            return word

        if word not in self.association_brain.keys():
            return word

        synapse = self.association_brain[word]

        # 連想するワードはランダムに選択する
        index = random.randint(0, len(synapse) - 1)

        # 再帰的に連想を行う(max_depの値まで)
        return self.association(synapse[index], max_dep, dep + 1)

おわりに

今回はJanomeを使って質問文から回答を連想するというプログラムを作ってみました。
仕組み的にはかなり単純ですが、それっぽい動作はします。

これぐらいシンプルだと記事も書きやすいな

メタ発言禁止

スポンサーリンク

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。