spaCyで「~して」という要求に返答する【自然言語処理, Python】

213, 2021-03-25

目次

spaCyで「~して」という要求にこたえる

自然言語処理……それは自然言語をあやつる計算機科学。
自然言語とは私たちが使う日本語や英語など、自然の中で発展してきた言語です。

Pythonの外部ライブラリであるspaCyはこの自然言語処理を簡単に行うことができます。
spaCyはこれからの自然言語処理でスタンダードなライブラリになるかもしれません。

今回はこのspaCyを使って「~して」という要求に答える解析器、疑似AIを作ってみたいと思います・・・!
具体的には↓を見ていきます。

  • spaCyとは

  • 「~して」という要求への応答

  • 解析器の実行結果

  • 解析器の設計

  • 解析器のソースコード

  • ソースコードの解説

spaCyとは

spaCy(スパイシー)とはオープンソース、そしてMITライセンスで開発されている自然言語処理ライブラリです。
さまざまな言語の学習済み統計モデルを搭載しており、これらのモデルを使うことで簡単に自然言語を解析することができます。

日本語の解析でspaCyと一緒に使われるのがGiNZA(ギンザ)です。
これもオープンソース、MITライセンスで開発されている自然言語処理ライブラリで、リクルートと国立国語研究所が共同開発した日本語に特化したライブラリです。
このGiNZAのモデルをspaCyでロードすることで、spaCyからGiNZAの解析を使うことができます。

少し前までspaCyは日本語の解析が弱かったのですが、GiNZAの登場によって構文解析も可能になりました。

「~して」という要求への応答

「~して」という要求は、たとえば「料理して」など人の要求を表す文章です。
今回作成する解析器はこの「~して」という文章をspaCyで解析し、それに対する返答を生成します。

「肩揉みして」とか「皿洗いして」などの要求に対して「肩揉みします」とか「皿洗いします」という返答を生成します。
この要求と返答によって、解析器が人間の要求にこたえているように見えます。

スマートスピーカーという電化製品では、人間の発した自然言語の音声を解析し、自然言語処理で解析します。
そしてそこからプログラム的なロジックを抽出して、人間の要求に答えようとします。
それは電気をつけたりだったり扉の鍵を閉めたりだったりです。

「~して」という要求は人間の持つ原始的な要求と言えます。
これに答えることはAIの仕事と言えそうです。

解析器の実行結果

今回作成するスクリプトは単体テストで動作テストを行っています。
スクリプトのソースコードをsample.pyなどに保存し、python -m unittest sampleを実行するとテストが実行されます。
単体テストを実行すると↓のような結果になります。

.
----------------------------------------------------------------------
Ran 1 test in 1.781s

OK

↑の実行結果は、すべてのテストケースが成功したことを示しています。
実行したテストケースは↓になります。

class Test(unittest.TestCase):
    def eq(self, a, b):
        ai = AI()
        c = ai.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        # 解析に成功するケース
        self.eq('肩揉みして', '肩揉みします')
        self.eq('私のために料理して', '料理します')
        self.eq('たくさん皿洗いしてほしい', '皿洗いします')
        self.eq('料理してくれない?', '料理します')

        # 解析に失敗するケース
        self.eq('肩揉みしたいな', None)  # 「し + たい」の組み合わせになるため失敗
        self.eq('肩揉みしてって', None)  # 「し + てっ」の組み合わせになるため失敗
        self.eq('肩揉みしてーな', None)  # 「し + てー」の組み合わせになるため失敗
        self.eq('肩揉みしてるの?', None)  # 「し + てる」の組み合わせになるため失敗

        # 否定形への対応
        self.eq('料理してほしくないな', '料理しません')
        self.eq('肩揉みしてくれなくていいよ', '肩揉みしません')
        self.eq('料理しないで', None)  # 非対応
        self.eq('料理しなくていいよ', None)  # 非対応

        # 微妙なニュアンス
        self.eq('料理してくれないの', '料理します')  # 「料理しましょうか?」が正しい?返答?
        self.eq('料理してないの?', '料理しません')  # 「料理してません」が正しい返答?

        # 肩揉みがheadではなくchildrenなケース
        # 成立はしてるけど微妙なニュアンス
        self.eq('肩揉みしてないで', '肩揉みしません')

↑のテストケースを見ると「肩揉みして」という要求に対して解析器は「肩揉みします」と返答しています。
また「たくさん皿洗いしてほしい」という要求に対して「皿洗いします」と返答しています。
いっぽう「料理してほしくないな」という要求には「料理しません」と返答しています。

解析器の設計

解析器はAIというクラスで作成します。
主要な処理はこのAIクラスのメソッドに実装します。

analyze()メソッドが解析のエントリーポイントです。
このメソッドに文章を渡すと最終的に返答が生成されます。

analyze()メソッド内では引数の文章をspaCyで解析してドキュメントにします。
このドキュメントから文を1つずつ取り出してそれを解析します。

文からトークンを取り出し、「する + て」というトークンの並びがあったら「する」をキーにして解析を分岐します。
「する」の解析では依存構造を参照し、適当なトークンを選びます。
そして「~くれない」などの肯定や「~ない」などの否定を考慮して返答を生成します。

基本的な設計は↑のようになります。

解析器のソースコード

今回作成するスクリプトのソースコードは↓になります。

"""
「~して」という要求に返答するルールベースのAIもどきを作る

License: MIT
Created at: 2021/02/04
"""
import spacy
import unittest


# GiNZAのモデルをロード
nlp = spacy.load('ja_ginza')


class AI:
    """
    「~して」という要求に返答するAIもどき
    """
    def analyze(self, text):
        """
        引数textを解析して返答を生成する
        """
        doc = nlp(text)  # spaCyで解析

        for sent in doc.sents:  # 段落ごとに解析する
            result = self.analyze_sent(sent)
            if result:
                return result  # 返答が生成されたら返す

        return None  # 解析に失敗した

    def analyze_sent(self, sent):
        """
        引数sentを解析して返答を生成する
        """
        root = sent.root  # この文章のルート要素
        i = 0

        while i < len(sent) - 1:
            t1 = sent[i]
            t2 = sent[i + 1]
            i += 1
            # 「為る(する) + て」の組み合わせを探す
            if t1.lemma_ == '為る' and t2.text == 'て':
                result = self.analyze_suru(root, t1)  # 為るをキーに解析する
                if result:
                    return result

        return None

    def analyze_suru(self, root, tok):
        """
        為る(する)をキーに解析する
        """
        parent = tok.head  # 親のトークンを取得
        if tok == parent:
            parent = list(tok.children)[0]

        kurenai = self.find_kurenai(root)  # rootから「くれない」を探す
        if len(kurenai):
            return f'{parent.text}します'

        nai = self.find_nai(root)  # rootから「ない」を検索する
        if nai:  # 「ない」が見つかったら
            return f'{parent.text}しません'  # 「~しません」という返答を生成する
        return f'{parent.text}します'  # 「~します」という返答を生成する

    def collect_children(self, childs, tok):
        """
        引数tokから子要素を再帰的に集める
        """
        for child in tok.children:
            childs.append(child)
            self.collect_children(childs, child)

    def find_kurenai(self, tok):
        """
        引数tokから「くれない」のトークン列を探す
        """
        childs = []
        self.collect_children(childs, tok)  # 子要素を集める
        i = 0
        while i < len(childs) - 1:  # 子要素のリストを走査
            t1 = childs[i]
            t2 = childs[i + 1]
            i += 1
            if t1.text == 'くれ' and t2.text == 'ない':
                return [t1, t2]  # みつかった
        return []  # 見つからなかった

    def find_nai(self, tok):
        """
        引数tokの子要素から「ない」のトークンを探す
        """
        if tok.lemma_ in ('ない', '無い'):  # 原形が「ない」「無い」のいずれかであれば
            return tok  # そのトークンを返す

        for child in tok.children:  # 依存構造上における子要素を参照する
            nai = self.find_nai(child)  # 再帰的に検索する
            if nai:
                return nai  # 「ない」が見つかったら返す

        return None  # 見つからなかった


class Test(unittest.TestCase):
    def eq(self, a, b):
        ai = AI()
        c = ai.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        # 解析に成功するケース
        self.eq('肩揉みして', '肩揉みします')
        self.eq('私のために料理して', '料理します')
        self.eq('たくさん皿洗いしてほしい', '皿洗いします')
        self.eq('料理してくれない?', '料理します')

        # 解析に失敗するケース
        self.eq('肩揉みしたいな', None)  # 「し + たい」の組み合わせになるため失敗
        self.eq('肩揉みしてって', None)  # 「し + てっ」の組み合わせになるため失敗
        self.eq('肩揉みしてーな', None)  # 「し + てー」の組み合わせになるため失敗
        self.eq('肩揉みしてるの?', None)  # 「し + てる」の組み合わせになるため失敗

        # 否定形への対応
        self.eq('料理してほしくないな', '料理しません')
        self.eq('肩揉みしてくれなくていいよ', '肩揉みしません')
        self.eq('料理しないで', None)  # 非対応
        self.eq('料理しなくていいよ', None)  # 非対応

        # 微妙なニュアンス
        self.eq('料理してくれないの', '料理します')  # 「料理しましょうか?」が正しい?返答?
        self.eq('料理してないの?', '料理しません')  # 「料理してません」が正しい返答?

        # 肩揉みがheadではなくchildrenなケース
        # 成立はしてるけど微妙なニュアンス
        self.eq('肩揉みしてないで', '肩揉みしません')

ソースコードの解説

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

モジュールのインポート

冒頭でモジュールをインポートしておきます。
今回はspaCyを使うのでspacyをインポートします。
それから単体テスト用にunittestをインポートします。

import spacy
import unittest

spacypipでインストールが必要です。
↓のようにインストールします。

$ pip install -U spacy

GiNZAのモデルをロード

今回は日本語の解析にGiNZAをspaCyに組み合わせて使います。
GiNZAは↓のようにするとpipでインストールできます。

$ pip install "https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz"

↓のようにspacy.load()ja_ginzaを渡すとGiNZAをロードすることができます。
結果はginza.Japaneseで返ってきます。
この返り値には慣例的にnlpと命名します。

# GiNZAのモデルをロード
nlp = spacy.load('ja_ginza')

AIの作成

AIクラスを作成します。
メソッドについては後述します。

class AI:
    """
    「~して」という要求に返答するAIもどき
    """
    ...

analyze()で文章を解析する

analyze()は解析のエントリーポイントです。
このメソッドは引数textnlp()で解析してdocにします。これはspacy.tokens.doc.Docです。

doc.sentsfor文で回すと1文ずつ取り出すことができます。
sentspacy.tokens.span.Spanです。
analyze_sent()sentを渡して解析し、その結果が真ならそのまま結果をreturnします。

    def analyze(self, text):
        """
        引数textを解析して返答を生成する
        """
        doc = nlp(text)  # spaCyで解析

        for sent in doc.sents:  # 段落ごとに解析する
            result = self.analyze_sent(sent)
            if result:
                return result  # 返答が生成されたら返す

        return None  # 解析に失敗した

analyze_sent()で文を解析

analyze_sent()は引数sentを解析して返答を返します。
sent.rootでその文のルートのトークンを取得できます。これは構文解析で生成される依存構造上におけるルートです。
sentはトークン列なので添え字で参照することができます。sentからトークンを2つ取り出して、そのトークンの並びをチェックします。
トークンのlemma_属性にはそのトークン(単語)の原形が保存されています。「して」という文は「する」という原形と「て」に分けられます。トークンのtext属性はトークンの元の文章のままの表記が保存されています。
1つ目のトークンの原形が「為る(する)」で、2つ目のトークンのtextが「て」だったらanalyze_suru()にルートのトークンと「為る」のトークンを渡します。
その結果が真であればそのままreturnします。

    def analyze_sent(self, sent):
        """
        引数sentを解析して返答を生成する
        """
        root = sent.root  # この文章のルート要素
        i = 0

        while i < len(sent) - 1:
            t1 = sent[i]
            t2 = sent[i + 1]
            i += 1
            # 「為る(する) + て」の組み合わせを探す
            if t1.lemma_ == '為る' and t2.text == 'て':
                result = self.analyze_suru(root, t1)  # 為るをキーに解析する
                if result:
                    return result

        return None

analyze_suru()で「為る」を解析

analyze_suru()は引数tokを解析して返答を生成します。コア部分です。
まずトークンのhead属性を参照します。これは依存構造上におけるトークンの親の要素です。
この親のトークンがトークンと等しい場合、親がいないことになるので、その場合はトークンのchildren属性を参照して1つ目の子トークンを取り出します。children属性にはトークンの依存構造上における子要素が保存されています。

次にrootから「~くれない」があるかどうかfind_kurenai()で探します。
もしこの結果が真であれば、「~してくれない」という要求の文章になるので「~します」という返答を生成します。

「~くれない」が無ければ次に否定形の「~ない」をfind_nai()で検索します。
もし否定形が存在したら「~しません」という返答を生成します。
否定形が無ければ「~します」という返答を生成します。

    def analyze_suru(self, root, tok):
        """
        為る(する)をキーに解析する
        """
        parent = tok.head  # 親のトークンを取得
        if tok == parent:
            parent = list(tok.children)[0]

        kurenai = self.find_kurenai(root)  # rootから「くれない」を探す
        if len(kurenai):
            return f'{parent.text}します'

        nai = self.find_nai(root)  # rootから「ない」を検索する
        if nai:  # 「ない」が見つかったら
            return f'{parent.text}しません'  # 「~しません」という返答を生成する
        return f'{parent.text}します'  # 「~します」という返答を生成する

find_kurenai()で「~くれない」を探す

find_kurenai()は引数tokの子トークンから「~くれない」のトークン列の並びがあるかチェックして、ある場合はそのトークン列を返します。
collect_children()で引数tokの子トークンを集めてそれをwhile文で回し、トークンの並びをチェックします。

    def find_kurenai(self, tok):
        """
        引数tokから「くれない」のトークン列を探す
        """
        childs = []
        self.collect_children(childs, tok)  # 子要素を集める
        i = 0
        while i < len(childs) - 1:  # 子要素のリストを走査
            t1 = childs[i]
            t2 = childs[i + 1]
            i += 1
            if t1.text == 'くれ' and t2.text == 'ない':
                return [t1, t2]  # みつかった
        return []  # 見つからなかった

collect_children()で子トークンを集める

collect_children()は引数tokchildren属性を再帰的に参照して子トークンをchildsに集めます。

    def collect_children(self, childs, tok):
        """
        引数tokから子要素を再帰的に集める
        """
        for child in tok.children:
            childs.append(child)
            self.collect_children(childs, child)

find_nai()で「~ない」を探す

find_nai()は引数tokの子要素を再帰的に探索して、「ない」や「無い」を原形に持つトークンを探し、それを返します。

    def find_nai(self, tok):
        """
        引数tokの子要素から「ない」のトークンを探す
        """
        if tok.lemma_ in ('ない', '無い'):  # 原形が「ない」「無い」のいずれかであれば
            return tok  # そのトークンを返す

        for child in tok.children:  # 依存構造上における子要素を参照する
            nai = self.find_nai(child)  # 再帰的に検索する
            if nai:
                return nai  # 「ない」が見つかったら返す

        return None  # 見つからなかった

テストを書く

テストを書きます。
unittest.TestCaseを継承したクラスを作り、test_で始まるメソッドを定義します。このtest_で始まるメソッドがテスト実行時に実行されます。
eq()メソッドは引数aAI.analyze()解析しその結果cを引数bassertEqual()で比較します。
assertEqual()は第1引数と第2引数が違う物であればエラーを出します。エラーが出たらテストに失敗するということになります。

class Test(unittest.TestCase):
    def eq(self, a, b):
        ai = AI()
        c = ai.analyze(a)
        self.assertEqual(c, b)

    def test_analyze(self):
        # 解析に成功するケース
        self.eq('肩揉みして', '肩揉みします')
        self.eq('私のために料理して', '料理します')
        self.eq('たくさん皿洗いしてほしい', '皿洗いします')
        self.eq('料理してくれない?', '料理します')

        # 解析に失敗するケース
        self.eq('肩揉みしたいな', None)  # 「し + たい」の組み合わせになるため失敗
        self.eq('肩揉みしてって', None)  # 「し + てっ」の組み合わせになるため失敗
        self.eq('肩揉みしてーな', None)  # 「し + てー」の組み合わせになるため失敗
        self.eq('肩揉みしてるの?', None)  # 「し + てる」の組み合わせになるため失敗

        # 否定形への対応
        self.eq('料理してほしくないな', '料理しません')
        self.eq('肩揉みしてくれなくていいよ', '肩揉みしません')
        self.eq('料理しないで', None)  # 非対応
        self.eq('料理しなくていいよ', None)  # 非対応

        # 微妙なニュアンス
        self.eq('料理してくれないの', '料理します')  # 「料理しましょうか?」が正しい?返答?
        self.eq('料理してないの?', '料理しません')  # 「料理してません」が正しい返答?

        # 肩揉みがheadではなくchildrenなケース
        # 成立はしてるけど微妙なニュアンス
        self.eq('肩揉みしてないで', '肩揉みしません')

おわりに

今回はspaCyで「~して」という要求にこたえる解析器を作ってみました。
音声解析とハードウェアと組み合わせればスマートスピーカーも作れるかもしれませんね。

記事を量産して

……