ユーニックス総合研究所

  • home
  • archives
  • cabocha-question

CaboChaで好き嫌いを質問してくるプログラムを作る【自然言語処理】

好き嫌いを質問するプログラム

人間の話す言語を「自然言語」と言い、その自然言語をプログラム的に解析することを「自然言語処理」と言います。
自然言語処理は複数のレイヤーに処理が分かれていますが、その中でも単語間の係り受けを解析する処理を「構文解析処理」といいます。

プログラミング言語のPythonにはこの構文解析処理を行う「CaboCha」というライブラリがあります。
今回はこのCaboChaを使って、入力に対して「好きか嫌いか」を質問してくるプログラムを作ってみたいと思います。

具体的にこのプログラムについて↓を見ていきます。

  • 構文解析とは?
  • プログラムの仕様
  • プログラムの設計
  • プログラムのソースコード
  • ソースコードの解説
  • 実行結果

構文解析とは?

自然言語処理の工程は↓のようになっています。

  1. 字句解析
  2. 構文解析
  3. 意味解析
  4. 文脈解析

自然言語処理の工程は↑のリストを上から下に向かって進みます。
後半の工程になるほど難易度が上がると言われています。
特に文脈解析については世界の中にまだまともな解析器がない状態だと言われています。

まず字句解析ですが、これは文章を単語のリストに変換する処理です。
特に日本語の文章を単語に分解することを形態素解析(けいたいそかいせき)と言います。
たとえば「花が好きだ」という文章は↓のように分解されます。

花 / が / 好き / だ  

このように文章を単語のリストに分解しておくことで、後続の解析処理をやりやすくします。
この単語1つのことを特にトークンとも呼びます。

字句解析の次の工程である構文解析は、分解された単語同士の「係り受け」を解析します。
係り受けとは、つまり「どの単語がどの単語にかかっているか」という関係です。
たとえば先ほどの例文である「花が好き」では「花」という単語は「好き」という単語にかかっています。

この関係をCaboChaという構文解析ライブラリで出力すると↓のようになります。

花が-D  
  好き  
EOS  

どの単語がどの単語に係っているかがわかれば、単語同士の関係性がわかり、解析がしやすくなるということです。
今回の例で言えば、「花」という単語と「好き」という単語は係り受けの関係になっているので、そこから「好きなのは花である」という意味を導くことが出来ます。

ところで今回のプログラムの処理は工程的にはどの工程に入るのか、実は私もわかっていません。
構文解析の範囲なのかそれとも1つ進んだ意味解析の範囲なのか、不明です。
なんとなく文章の意味を解析しているという点では意味解析の範囲に入りそうな気はするんですが……。

🦝 < 意味解析のサンプルが少ないから、比較しづらいよね

🐭 < ほんまやね

Pythonによる構文解析はいろいろライブラリがあるんですが、今回はCaboCha(カボチャ)という構文解析ライブラリを使います。
CaboChaはもとはC++で記述された構文解析器で、その機能がPythonにバインディングされています。

プログラムの仕様

今回作成するプログラムの仕様は↓のようなものです。

  1. ユーザーが文字列を入力する
  2. プログラムがその文字列を解析する
  3. 文字列の意味を解析して質問文を生成する
  4. ユーザーに質問文を表示する

今回はテストを書いています。ですので↑の一連の処理はテストケースの中で行います。

プログラムの振る舞いの例としてはたとえば↓のような入力があるとします。

綺麗な花が好き  

↑の入力に対してプログラムは↓のような出力を行います。

花がお好きなんですか?  

「綺麗な花が好き」という会話に対して「花がお好きなんですか?」という質問文を生成しています。
プログラム的にユーザーが「何を」「好き」なのか解析しています。
また「嫌い」というワードに対しても反応します。

枯れた花が嫌い  

プログラムは↑の入力に対して↓のような出力を行います。

花がお嫌いなんですか?  

ここでは「花」というワードで会話していますが、このワードは何でも置き換え可能です。
ただ入力に「好き」「嫌い」というワードが入っていない場合は動作は未定義になっています。

プログラムの設計

「何が」「好き/嫌い」なのか、という点を解析する必要があるので、単語間の係り受けの解析が必須になります。
先ほどの例では「花」が好き嫌いの対象になっていましたが、この花と言うワードを質問文の中に埋め込むには係り受け解析が必要です。

PythonではCaboChaという構文解析処理を行うライブラリを使うと、単語間の係り受けは簡単に解析することが出来ます。
しかし、実装の過程でいくつか問題点がありました。
今回作成するプログラムは「好き/嫌い」というワードをトリガーにして解析を開始します。
つまり解析の始点が「好き/嫌い」トークンになるわけです。

ここで「好き/嫌い」に係っている単語を検出する必要があるのですが、CaboChaの仕様的にちょっとそれは難しくなっています。
CaboChaはチャンクと言う文節を表現するデータ構造があるのですが、そのチャンクのリンクを辿れば係り先の単語を辿れるようになっています。
しかしたとえば「花が好き」という文に対して、「好き」から「花」を辿ることができません(もしかしたらできるかもしれません。私のライブラリへの理解が浅い可能性もあります)。
「花」から「好き」を辿ることは出来るのですが、今回は「好き/嫌い」をトリガーにしているので、「好き/嫌い」から「花」を辿れる必要がありました。

今回はこの問題を解決するために「Node」というクラスを用意します。
このNodeはCaboChaのトークンのラッパーです。
Nodeは係り先のノードと、係り元のノード、それから同じ文節のノードを持てるようにしておきます。
こうすることで解析にこのノードを使えば、「好き」から「花」を辿れるようになります。

このNodeのリストを作成する仕事を「Linker」というクラスにやらせます。
LinkerはCaboChaのツリーをもとにノードのリストを生成するクラスです。

それから質問文の生成自体は「QuestionMachine」というクラスにやらせます。
QuestionMachineをオブジェクトにして、メソッドquestion()に入力を渡せば質問文を生成できるという仕様です。

qm = QuestionMachine()  
q = qm.question('花が好きだ')  
print(q)  

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

↓がプログラムのソースコードです。
ブログに掲載するにはちょっと長くなってしまいました(そのまま載せます)。

↓のコードを実行するにはコードをsample.pyなどで保存しpython -m unittest sampleなどとしてテストを実行します。

"""  
入力に対して好きか嫌いかの質問文を生成する  

License: MIT  
"""  
import CaboCha  
import unittest  


class Node:  
    """  
    トークンのラッパー  
    双方向リンク可能なノード  
    """  
    def __init__(self, token=None):  
        self.token = token  # トークン  
        self.to_nodes = []  # このノードからかかっている次のノード  
        self.from_node = None  # このノードからかかっている前のノード  
        self.sibling_nodes = []  # このノードの兄弟ノード  


class Linker:  
    """  
    CaboCha.Treeからノードのリストを作成するクラス  
    トークンをノードにラップして双方向リンクを設けることからLinkerと命名  
    """  
    def gen_chunks(self, tree):  
        """  
        チャンクの辞書を生成する  
        """  
        chunks = {}  
        key = 0  
        for i in range(tree.size()):  
            tok = tree.token(i)  
            if tok.chunk:  
                chunks[key] = tok.chunk  
                key += 1  
        return chunks  

    def gen_nodes(self, tree):  
        """  
        ツリー内のトークンをノードのリストに変換する  
        """  
        # トークン列をノード列にする  
        nodes = []  
        for i in range(tree.size()):  
            tok = tree.token(i)  
            node = Node(token=tok)  
            nodes.append(node)  

        return nodes  

    def get_nodes_by_chunk(self, nodes, chunk):  
        """  
        チャンクに繋がっているノードをリストで取得する  
        """  
        beg = chunk.token_pos  
        end = chunk.token_pos + chunk.token_size  
        dst_nodes = []  
        for i in range(beg, end):  
            node = nodes[i]  
            dst_nodes.append(node)  
        return dst_nodes  

    def link_nodes(self, node, to_nodes):  
        """  
        nodeとto_nodesをリンクする  
        """  
        node.to_nodes = to_nodes  
        for n in to_nodes:  
            n.from_node = node  

    def make(self, tree):  
        """  
        ノードのリストを作成する  
        """  
        chunks = self.gen_chunks(tree)  
        nodes = self.gen_nodes(tree)  

        for node in nodes:  
            # 係り先/係り元のノードを設定する  
            if node.token.chunk and node.token.chunk.link > 0:  
                to_chunk = chunks[node.token.chunk.link]  
                to_nodes = self.get_nodes_by_chunk(nodes, to_chunk)  
                self.link_nodes(node, to_nodes)  

            # 兄弟ノードを設定する  
            if node.token.chunk:  
                for n in self.get_nodes_by_chunk(nodes, node.token.chunk):  
                    if id(node) == id(n):  
                        continue  
                    node.sibling_nodes.append(n)  

        return nodes  


class QuestionMachine:  
    """  
    質問文を生成するマシン  
    """  
    def __init__(self):  
        self.cp = CaboCha.Parser()  
        self.tree: CaboCha.Tree = None  

    def q_like(self, s):  
        """  
        ~がお好きなんですか?という質問文を生成する  
        """  
        return s + 'がお好きなんですか?'  

    def q_not_like(self, s):  
        """  
        ~がお嫌いなんですか?という質問文を生成する  
        """  
        return s + 'がお嫌いなんですか?'  

    def find_meisi_node(self, nodes):  
        """  
        nodesから名詞のノードを探す  
        """  
        for node in nodes:  
            if len(node.to_nodes):  
                return self.find_meisi_node(node.to_nodes)  
            if '名詞' in node.token.feature.split(','):  
                return node  
        return None  

    def collect_nais(self, nodes):  
        """  
        「ない」のノードをnodesから集める  
        """  
        nais = []  
        for node in nodes:  
            if node.token.surface == 'ない':  
                nais.append(node)  
        return nais  

    def find_nai(self, node):  
        """  
        「ない」のノードを再帰的に検索する  
        to_nodesを辿りながら最後まで検索し、見つかったらノードを返す  
        """  
        # 最初に兄弟ノードを調べる  
        if len(node.sibling_nodes):  
            nais = self.collect_nais(node.sibling_nodes)  
            if len(nais) and len(nais) % 2 == 0:  # 2重否定  
                return nais[0]  

            if len(node.sibling_nodes) >= 2:  
                two = node.sibling_nodes[-2]  
                if two.token.surface == 'しれ':  
                    return None  # 「しれない」はかもしれない系のニュアンス   

            one = node.sibling_nodes[-1]  
            if one.token.surface == 'ない':  
                return one  

        # かかり先のノードを調べる  
        if len(node.to_nodes):  
            if len(node.to_nodes) >= 2:  
                two = node.to_nodes[-2]  
                if two.token.surface == 'しれ':  
                    return None  # 「しれない」はかもしれない系のニュアンス  

            one = node.to_nodes[-1]  
            if one.token.surface == 'ない':  
                return one  

            n = node.to_nodes[0]  
            return self.find_nai(n)  

    def answer_like(self, node, parse_nai=True):  
        """  
        お好きなんですか?のワーク  

        @param node 「嫌い」を含んだノード  
        @param parse_nai 「ない」を考慮するならTrue  
        """  
        if node.from_node:  
            # 「~が好き」という形  
            # node(好き)から名詞を探す(from_node)  
            fn = node.from_node  
            if parse_nai:  
                nai = self.find_nai(fn)  
                if nai:  
                    return self.answer_not_like(node, False)  
            return self.q_like(fn.token.surface)  
        elif len(node.to_nodes):  
            # 「好きだ~が」という形  
            # node(好き)から名詞を探す(to_nodes)  
            if parse_nai:  
                nai = self.find_nai(node)  
                if nai:  
                    return self.answer_not_like(node, False)  
            meisi = self.find_meisi_node(node.to_nodes)  
            return self.q_like(meisi.token.surface)  
        return None  

    def answer_not_like(self, node, parse_nai=True):  
        """  
        お嫌いなんですか?のワーク  

        @param node 「嫌い」を含んだノード  
        @param parse_nai 「ない」を考慮するならTrue  
        """  
        if node.from_node:  
            # 「~が嫌い」という形  
            # node(嫌い)から名詞を探す(from_node)  
            fn = node.from_node  
            if parse_nai:  
                nai = self.find_nai(fn)  
                if nai:  
                    return self.answer_like(node, False)  
            return self.q_not_like(fn.token.surface)  
        elif len(node.to_nodes):  
            # 「嫌いだ~が」という形  
            # node(嫌い)から名詞を探す(to_nodes)  
            if parse_nai:  
                nai = self.find_nai(node)  
                if nai:  
                    return self.answer_like(node, False)  
            meisi = self.find_meisi_node(node.to_nodes)  
            return self.q_not_like(meisi.token.surface)  
        return None  

    def question(self, s):  
        """  
        sに対する質問文を生成する  
        """  
        self.tree = self.cp.parse(s)  
        linker = Linker()  
        nodes = linker.make(self.tree)  # ツリーをノードのリストに変換  

        for node in nodes:  
            if node.token.surface == '好き':  
                return self.answer_like(node)  # 好きなんですかワークへ  
            elif node.token.surface == '嫌い':  
                return self.answer_not_like(node)  # 嫌いなんですかワークへ  

        return None  


class Test(unittest.TestCase):  
    def eq(self, a, b):  
        qm = QuestionMachine()  
        s = qm.question(a)  
        self.assertEqual(s, b)  

    def test_question(self):  
        # 好きなんですか?のテスト  
        self.eq('花が好き', '花がお好きなんですか?')  
        self.eq('花が好きだ', '花がお好きなんですか?')  
        self.eq('花が好きかな?', '花がお好きなんですか?')  
        self.eq('花が好きかもしれない', '花がお好きなんですか?')  
        self.eq('綺麗な花が好き', '花がお好きなんですか?')  
        self.eq('綺麗な花と石が好き', '石がお好きなんですか?')  
        self.eq('好きだ花が', '花がお好きなんですか?')  
        self.eq('好きかな花は', '花がお好きなんですか?')  
        self.eq('好きかもしれない花は', '花がお好きなんですか?')  
        self.eq('嫌いじゃない花は', '花がお好きなんですか?')  
        self.eq('嫌いじゃないかもしれない花は', '花がお好きなんですか?')  

        # 嫌いなんですか?のテスト  
        self.eq('花が嫌い', '花がお嫌いなんですか?')  
        self.eq('花が嫌いだ', '花がお嫌いなんですか?')  
        self.eq('花が嫌いかな?', '花がお嫌いなんですか?')  
        self.eq('花が嫌いかもしれない', '花がお嫌いなんですか?')  
        self.eq('枯れた花が嫌い', '花がお嫌いなんですか?')  
        self.eq('枯れた花と石が嫌い', '石がお嫌いなんですか?')  
        self.eq('花が好きではない', '花がお嫌いなんですか?')  
        self.eq('花が好きじゃない', '花がお嫌いなんですか?')  
        self.eq('花が好きということはない', '花がお嫌いなんですか?')  
        self.eq('好きではない花は', '花がお嫌いなんですか?')  
        self.eq('好きじゃない花は', '花がお嫌いなんですか?')  
        self.eq('好きということはない花は', '花がお嫌いなんですか?')  
        self.eq('好きじゃないかもしれない花は', '花がお嫌いなんですか?')  

        # その他  
        self.eq('花は好きだが石は嫌いだ', '花がお好きなんですか?')  
        self.eq('花は好きだったかもしれない', '花がお好きなんですか?')  
        self.eq('花どころか石も好きだ', '石がお好きなんですか?')  

        # エラー  
        self.eq('花が好きでも嫌いでもない', '嫌いがお嫌いなんですか?')  # TODO: ERROR  

ソースコードの解説

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

ライブラリのインポート

まず必要なライブラリをインポートしておきます。
今回はCaboChaとそれからテスト用のunittestです。

import CaboCha  
import unittest  

Nodeの定義

それからNodeを定義します。
ノードはCaboChaのトークンのラッパーです。
このノードから文節の次のノードや前のノードを辿れるようになっています。

class Node:  
    """  
    トークンのラッパー  
    双方向リンク可能なノード  
    """  
    def __init__(self, token=None):  
        self.token = token  # トークン  
        self.to_nodes = []  # このノードからかかっている次のノード  
        self.from_node = None  # このノードからかかっている前のノード  
        self.sibling_nodes = []  # このノードの兄弟ノード  

Linkerの定義

次にLinkerを定義します。
LinkerはCaboChaのツリー(構文解析木)からNodeのリストを生成するクラスです。

class Linker:  
    """  
    CaboCha.Treeからノードのリストを作成するクラス  
    トークンをノードにラップして双方向リンクを設けることからLinkerと命名  
    """  
    ...  

make()

ノードのリストを生成するメソッドはLinkerのmakeメソッドです。

内容的にはチャンクの辞書をgen_chunks()で生成し、それからノードのリストをgen_nodes()で生成しています。
for文でノードを回し、ノードのトークンのチャンクのリンクが生きていれば、チャンクの辞書からリンク先のチャンクを得ます。
そのチャンクからノードのリストをget_nodes_by_chunk()で取得します。このリストはいわゆる「係り先」のノードのリストになります。
あとは現在のノードと係り先のノードをlink_nodes()でリンクします。

それからノードのトークンのチャンクが生きている場合はそのチャンクから同じ文節のノードをget_nodes_by_chunk()で得ます。
それを現在のノードのsibling_nodesに設定します。

for文が回りきるとノードのリストの生成が完了するので、それをreturnします。

    def make(self, tree):  
        """  
        ノードのリストを作成する  
        """  
        chunks = self.gen_chunks(tree)  
        nodes = self.gen_nodes(tree)  

        for node in nodes:  
            # 係り先/係り元のノードを設定する  
            if node.token.chunk and node.token.chunk.link > 0:  
                to_chunk = chunks[node.token.chunk.link]  
                to_nodes = self.get_nodes_by_chunk(nodes, to_chunk)  
                self.link_nodes(node, to_nodes)  

            # 兄弟ノードを設定する  
            if node.token.chunk:  
                for n in self.get_nodes_by_chunk(nodes, node.token.chunk):  
                    if id(node) == id(n):  
                        continue  
                    node.sibling_nodes.append(n)  

        return nodes  

gen_chunks()

毎度おなじみのgen_chunks()メソッドです。
このメソッドはツリーからチャンクの辞書を生成します。
内容的にはツリーのトークンを参照し、トークンのチャンクが生きていればchunksにチャンクを追加します。
このチャンクの辞書を参照することでトークンのチャンクから係り先のチャンクを参照できるようになります。

    def gen_chunks(self, tree):  
        """  
        チャンクの辞書を生成する  
        """  
        chunks = {}  
        key = 0  
        for i in range(tree.size()):  
            tok = tree.token(i)  
            if tok.chunk:  
                chunks[key] = tok.chunk  
                key += 1  
        return chunks  

gen_nodes()

gen_nodes()メソッドはツリー内のトークンをノードのリストに変換するメソッドです。
最初にfor文でツリーのトークンを参照し、それらをノードに変換してnodesに追加していきます。
最後にnodesreturnします。

    def gen_nodes(self, tree):  
        """  
        ツリー内のトークンをノードのリストに変換する  
        """  
        # トークン列をノード列にする  
        nodes = []  
        for i in range(tree.size()):  
            tok = tree.token(i)  
            node = Node(token=tok)  
            nodes.append(node)  

        return nodes  

get_nodes_by_chunk()

get_nodes_by_chunk()メソッドは、引数のchunkの文節のノードをnodesから取得してリストにするメソッドです。
このメソッドを使うと特定のチャンク(文節)のノードを得ることが出来ます。
ただしnodesの順番は生成時の順番が維持されている必要があります。途中でnodesの内容が変更されているとバグになります。そういう意味でnodesconstなオブジェクトです。

    def get_nodes_by_chunk(self, nodes, chunk):  
        """  
        チャンクに繋がっているノードをリストで取得する  
        """  
        beg = chunk.token_pos  
        end = chunk.token_pos + chunk.token_size  
        dst_nodes = []  
        for i in range(beg, end):  
            node = nodes[i]  
            dst_nodes.append(node)  
        return dst_nodes  

link_nodes()は引数のnodeto_nodesを双方向にリンクするメソッドです。
内容的にはノードのto_nodes属性とfrom_node属性に値を設定しているだけです。

    def link_nodes(self, node, to_nodes):  
        """  
        nodeとto_nodesをリンクする  
        """  
        node.to_nodes = to_nodes  
        for n in to_nodes:  
            n.from_node = node  

QuestionMachineの定義

QuestionMachineは入力を受け取って実際に質問文を生成するクラスです。
メインとなるメソッドはquestion()で他のメソッドはその補助です。
コンストラクタではCaboChaのパーサーとツリーを初期化しています。

class QuestionMachine:  
    """  
    質問文を生成するマシン  
    """  
    def __init__(self):  
        self.cp = CaboCha.Parser()  
        self.tree: CaboCha.Tree = None  
    ...  

question()

question()は質問文を生成するメソッドです。
引数sがユーザーの入力になります。

内容的には最初にself.cp.parse()でCaboChaの構文木を生成します。
ちなみにself.cpself.treeはどちらもプログラムが終了するまで保持しておく必要があります。
それからLinkerを作成してツリーをノードのリストに変換します。

for文でノードを回し、ノードのトークンのsurfaceが「好き」または「嫌い」だったら処理を分岐して呼び出します。
surface属性はトークンの表層形を表す属性です。表層形とは字句のそのままの文字のことです。

    def question(self, s):  
        """  
        sに対する質問文を生成する  
        """  
        self.tree = self.cp.parse(s)  
        linker = Linker()  
        nodes = linker.make(self.tree)  # ツリーをノードのリストに変換  

        for node in nodes:  
            if node.token.surface == '好き':  
                return self.answer_like(node)  # 好きなんですかワークへ  
            elif node.token.surface == '嫌い':  
                return self.answer_not_like(node)  # 嫌いなんですかワークへ  

        return None  

answer_like() / answer_not_like()

answer_like()は「好き」を処理するサブルーチンです。
answer_not_like()は「嫌い」を処理します。
これらのメソッドは内容が似ているため共通化したほうが良かったかもしれません。今回は共通化していません。

内容的にはノードのfrom_nodeto_nodesの値によって処理が分岐します。
ノードのfrom_nodeが生きている状態はつまり、係り元の単語がある状態です。
ということは文の形は「~が好き」という形になります。
「好き」という単語の係り元の単語はつまり「何が好きか」の「何が」の部分です。
よってこの係り元のノードを参照することで「~が好きなんですか?」という文章を生成できるようになります。

いっぽうto_nodesが生きている状態は、文の形が「好きなんです~が」という形の場合です。
「好き」という単語が先に来ていて、その対象が後続するため「好き」というノードのto_nodesに係り先が格納されている状態です。
このto_nodesから名詞のノードを探し、これを「何が」の部分に当てます。

どちらの分岐でもparse_naiという引数によってさらに処理が分岐しています。
parse_naiTrueのとき、find_nai()メソッドを使って「ない」というノードを検索しています。
「ない」というノードが存在する場合、その文は否定文になるので、answer_like()ではanswer_not_like()を、answer_not_like()ではanswer_like()を呼び出します。つまり処理が反転するわけですね。反転する場合はparse_naiFalseにしておきます。そうしないと無限ループになるからです。

    def answer_like(self, node, parse_nai=True):  
        """  
        お好きなんですか?のワーク  

        @param node 「嫌い」を含んだノード  
        @param parse_nai 「ない」を考慮するならTrue  
        """  
        if node.from_node:  
            # 「~が好き」という形  
            # node(好き)から名詞を探す(from_node)  
            fn = node.from_node  
            if parse_nai:  
                nai = self.find_nai(fn)  
                if nai:  
                    return self.answer_not_like(node, False)  
            return self.q_like(fn.token.surface)  
        elif len(node.to_nodes):  
            # 「好きだ~が」という形  
            # node(好き)から名詞を探す(to_nodes)  
            if parse_nai:  
                nai = self.find_nai(node)  
                if nai:  
                    return self.answer_not_like(node, False)  
            meisi = self.find_meisi_node(node.to_nodes)  
            return self.q_like(meisi.token.surface)  
        return None  
    def answer_not_like(self, node, parse_nai=True):  
        """  
        お嫌いなんですか?のワーク  

        @param node 「嫌い」を含んだノード  
        @param parse_nai 「ない」を考慮するならTrue  
        """  
        if node.from_node:  
            # 「~が嫌い」という形  
            # node(嫌い)から名詞を探す(from_node)  
            fn = node.from_node  
            if parse_nai:  
                nai = self.find_nai(fn)  
                if nai:  
                    return self.answer_like(node, False)  
            return self.q_not_like(fn.token.surface)  
        elif len(node.to_nodes):  
            # 「嫌いだ~が」という形  
            # node(嫌い)から名詞を探す(to_nodes)  
            if parse_nai:  
                nai = self.find_nai(node)  
                if nai:  
                    return self.answer_like(node, False)  
            meisi = self.find_meisi_node(node.to_nodes)  
            return self.q_not_like(meisi.token.surface)  
        return None  

find_nai()

find_nai()メソッドはノードから「ない」を表すノードを探していくメソッドです。
このメソッドはノードの兄弟ノードと係り先のノードを両方参照します。

最初に兄弟ノード(sibling_nodes)を調べています。
collect_nais()メソッドで兄弟ノードから「ない」を表すノードを集めます。
これが2の倍数になっていたら2重否定、つまり「これはないかもしれない」などの文になると見なします。
その場合は最初に見つかった「ない」ノードを返します。

それから「~かもしれない」という文には「ない」が含まれていますが、これは否定の意味になりません。
そのため兄弟ノードに「しれ」が含まれている場合はNoneを返します。

兄弟ノードの末尾に「ない」が含まれていたらそのノードを返すようにします。

係り先のノード(to_nodes)を参照する場合も基本的には同じです。
ただこちらは係り先を辿っていく仕様にするため再帰的に検索しています。

    def find_nai(self, node):  
        """  
        「ない」のノードを再帰的に検索する  
        to_nodesを辿りながら最後まで検索し、見つかったらノードを返す  
        """  
        # 最初に兄弟ノードを調べる  
        if len(node.sibling_nodes):  
            nais = self.collect_nais(node.sibling_nodes)  
            if len(nais) and len(nais) % 2 == 0:  # 2重否定  
                return nais[0]  

            if len(node.sibling_nodes) >= 2:  
                two = node.sibling_nodes[-2]  
                if two.token.surface == 'しれ':  
                    return None  # 「しれない」はかもしれない系のニュアンス   

            one = node.sibling_nodes[-1]  
            if one.token.surface == 'ない':  
                return one  

        # かかり先のノードを調べる  
        if len(node.to_nodes):  
            if len(node.to_nodes) >= 2:  
                two = node.to_nodes[-2]  
                if two.token.surface == 'しれ':  
                    return None  # 「しれない」はかもしれない系のニュアンス  

            one = node.to_nodes[-1]  
            if one.token.surface == 'ない':  
                return one  

            n = node.to_nodes[0]  
            return self.find_nai(n)  

collect_nais()

引数のnodesから「ない」が含まれるノードを集めます。

    def collect_nais(self, nodes):  
        """  
        「ない」のノードをnodesから集める  
        """  
        nais = []  
        for node in nodes:  
            if node.token.surface == 'ない':  
                nais.append(node)  
        return nais  

find_meisi_node()

引数のnodesから名詞のノードを探します。
係り先のノードが存在する場合は再帰的に検索します。

    def find_meisi_node(self, nodes):  
        """  
        nodesから名詞のノードを探す  
        """  
        for node in nodes:  
            if len(node.to_nodes):  
                return self.find_meisi_node(node.to_nodes)  
            if '名詞' in node.token.feature.split(','):  
                return node  
        return None  

q_like() / q_not_like()

それぞれ「~がお好きなんですか」と「~がお嫌いなんですか?」という文章を生成します。

    def q_like(self, s):  
        """  
        ~がお好きなんですか?という質問文を生成する  
        """  
        return s + 'がお好きなんですか?'  
    def q_not_like(self, s):  
        """  
        ~がお嫌いなんですか?という質問文を生成する  
        """  
        return s + 'がお嫌いなんですか?'  

テストケース

今回は↓の内容のテストを通過させるように実装しました。

class Test(unittest.TestCase):  
    def eq(self, a, b):  
        qm = QuestionMachine()  
        s = qm.question(a)  
        self.assertEqual(s, b)  

    def test_question(self):  
        # 好きなんですか?のテスト  
        self.eq('花が好き', '花がお好きなんですか?')  
        self.eq('花が好きだ', '花がお好きなんですか?')  
        self.eq('花が好きかな?', '花がお好きなんですか?')  
        self.eq('花が好きかもしれない', '花がお好きなんですか?')  
        self.eq('綺麗な花が好き', '花がお好きなんですか?')  
        self.eq('綺麗な花と石が好き', '石がお好きなんですか?')  
        self.eq('好きだ花が', '花がお好きなんですか?')  
        self.eq('好きかな花は', '花がお好きなんですか?')  
        self.eq('好きかもしれない花は', '花がお好きなんですか?')  
        self.eq('嫌いじゃない花は', '花がお好きなんですか?')  
        self.eq('嫌いじゃないかもしれない花は', '花がお好きなんですか?')  

        # 嫌いなんですか?のテスト  
        self.eq('花が嫌い', '花がお嫌いなんですか?')  
        self.eq('花が嫌いだ', '花がお嫌いなんですか?')  
        self.eq('花が嫌いかな?', '花がお嫌いなんですか?')  
        self.eq('花が嫌いかもしれない', '花がお嫌いなんですか?')  
        self.eq('枯れた花が嫌い', '花がお嫌いなんですか?')  
        self.eq('枯れた花と石が嫌い', '石がお嫌いなんですか?')  
        self.eq('花が好きではない', '花がお嫌いなんですか?')  
        self.eq('花が好きじゃない', '花がお嫌いなんですか?')  
        self.eq('花が好きということはない', '花がお嫌いなんですか?')  
        self.eq('好きではない花は', '花がお嫌いなんですか?')  
        self.eq('好きじゃない花は', '花がお嫌いなんですか?')  
        self.eq('好きということはない花は', '花がお嫌いなんですか?')  
        self.eq('好きじゃないかもしれない花は', '花がお嫌いなんですか?')  

        # その他  
        self.eq('花は好きだが石は嫌いだ', '花がお好きなんですか?')  
        self.eq('花は好きだったかもしれない', '花がお好きなんですか?')  
        self.eq('花どころか石も好きだ', '石がお好きなんですか?')  

        # エラー  
        self.eq('花が好きでも嫌いでもない', '嫌いがお嫌いなんですか?')  # TODO: ERROR  

「花が好きでも嫌いでもない」という入力に対しては「嫌いがお嫌いなんですか?」という変な文章を生成しています。

自然言語処理のテストを書く時にもいつも思うのですが、テストの品質が自分のボキャブラリに依存してしまうところが悲しいところですね。
日本語による表現はまだあると思いますが、それがそのままテスト漏れになってしまうので自然言語処理のテストは大変です。

実行結果

今回作ったプログラムを実行すると↓のような結果になります。
テストがすべて通過してるのがわかります(エラーも含まれます)。

$ python -m unittest sample  
.  
----------------------------------------------------------------------  
Ran 1 test in 0.071s  

OK  

おわりに

今回は好き嫌いを質問するプログラムを作ってみました。
設計としては、トークンをラップするNodeというクラスを作りましたが、このアプローチには手ごたえを感じました。
トークンをノードで抽象化することで係り受けの関係が参照しやすくなっていると感じます。

また実装については、自然言語処理は細かい所を対応させるようにするとかなり泥臭い実装が必要になることもわかりました。
泥臭くなるということはそれだけ複雑だということだと思います。

🦝 < 花がお好きなんですか?

🐭 < 象さんの方がもっと好きです