ユーニックス総合研究所

  • home
  • archives
  • spacy-your-name

spaCyで「あなたの~は?」に答えるAIモドキを作る【自然言語処理, Python】

spaCyで「あなたの~は?」に答える

私たちが日常的に使う言語は「自然言語」と呼ばれています。
この自然言語を計算機的に処理するのが「自然言語処理」です。

PythonにはspaCy(スパイシー)という自然言語処理ライブラリがあり、これを使うと日本語も解析することができます。
今回はこのspaCyを使って、「あなたの~は?」に答えるAIモドキを作ってみたいと思います。

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

  • spaCyとは?
  • AIモドキの実行結果
  • AIモドキの設計
  • 字句解析と構文解析
  • AIモドキのソースコード
  • ソースコードの解説

spaCyとは?

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

今回は日本語の解析にGiNZA(ギンザ)という統計モデルを使います。
これをspaCyからロードすると、日本語の解析をGiNZAで行うことができます。
GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリで、spaCyからはそのモデルを使うことができます。
こちらもオープンソース、MITライセンスで利用することができます。

AIモドキの実行結果

今回作るAIモドキは単体テストで動作検証しています。
AIモドキのソースコードをsample.pyなどに保存し、コマンドラインからpython -m unittest sampleと実行すると、テストを実行できます。
テストは実行すると↓のような結果になります。

.  
----------------------------------------------------------------------  
Ran 1 test in 1.594s  

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('名前と体重を教えて', '私の体重は100キロです')  # 並列なケース  
        self.eq('名前を教えてよ', '私の名前は花子です')  
        self.eq('名前を教えてくれる?', '私の名前は花子です')  
        self.eq('名前を教えろ', 'わかりません')  # 非対応。「教えろ」が1単語になる  

        self.eq('名前を教えてくれなくていいよ', '私の名前は花子です')  # 否定形は非対応  
        self.eq('名前を教えてくれないで', '私の名前は花子です')  # 否定形は非対応  

        # 「あなたの~は?」のケース  
        self.eq('あなたの名前は?', '私の名前は花子です')  
        self.eq('名前は?', '私の名前は花子です')  
        self.eq('あなたの体重は?', '私の体重は100キロです')  
        self.eq('あなたの身長は?', '私の身長は100センチです')  
        self.eq('あなたの視力は?', 'わかりません')  # 非対応。単語が辞書にない  

↑のテストケースを見ると、「教えてよ名前を」でAIモドキが「私の名前は花子です」と返答しているのがわかります。
それから「あなたの身長は?」という問いかけに「私の身長は100センチです」と返答しているのがわかります。

AIモドキの設計

今回作るAIモドキの設計についてです。

繰り返しになりますが、AIモドキの動作検証は単体テストで行います。
これはPythonの標準ライブラリでunittestを使います。

スクリプトのメインとなる処理はAIクラスに実装します。
AIクラスのanalyze()というメソッドに文章を渡すと、analyze()はその文章を解析して返答となる文字列を返します。

analyze()は内部で引数のテキストをspaCyで解析して、トークン列に変換します。
このトークン列を文(sent)ごとにfor文で回して解析します。

文の解析ではトークン列を走査して、「は + ?」と「教え + て」のトークンの並びがないかチェックします。
それらの並びがトークン列の中に見つかったら、処理を分岐します。

「は + ?」の「は」に係っている単語を調べて、その単語から返答を生成したり、「教え + て」の「教え」に係っている単語を調べて、同様のことをします。

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

字句解析と構文解析

spaCyは文章を解析するとトークン列に変換します。
内部的にはこれは「字句解析」と「構文解析」の結果です。

字句解析とは、文を単語の列に変換する解析のことを言います。
たとえば「鳥が歩く」だったら「鳥 / が / 歩く」という単語の列に変換します。
この時、単語にはその単語が持つ情報を保存します。
これは単語の読み方だったり単語の品詞だったりといった情報です。
この字句解析の結果は、その後の工程の解析で使われます。

構文解析は、単語間の依存関係を解析する解析です。
依存関係とは、どの単語がどの単語に係(かか)っているかという関係です。
たとえば「鳥が歩く」という文は↓のような依存構造になります。

鳥   NOUN ═╗<╗ iobj  
が   ADP  <╝ ║ case  
歩く VERB ═══╝ ROOT  

↑の構造を見ると、「歩く」という単語が「鳥」に係っているのがわかります。
このようにどの単語がどの単語に係っているかを解析することで、文の構造を明らかにし、その後の解析を行いやすくするのが構文解析です。

AIモドキのソースコード

今回作成したAIモドキのソースコードは↓になります。
繰り返しになりますが、このソースコードを実行したい場合はソースコードをsample.pyなどに保存し、コマンドラインからpython -m unittest sampleを実行してください。

"""  
「あなたの~は?」に答えるAIモドキを作る  

License: MIT  
Created at: 2021/02/06  
"""  
import spacy  
import unittest  
# import deplacy  


# GiNZAのモデルをロード  
nlp = spacy.load('ja_ginza')  
# deplacy.render(nlp('鳥が歩く'))  

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

        for sent in doc.sents:  # 文を取り出す  
            result = self.analyze_sent(sent)  # 文ごとに解析する  
            if result:  
                return result  # 解析に成功した  

        return None  # 解析に失敗した  

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  # 1つ目のトークン  
            t2 = sent[i + 1]  # 2つ目のトークン  
            i += 1  

            # 「は + ?」の組み合わせにヒットしたら  
            if (t1.text == 'は' and t1.dep_ == 'case') and t2.text == '?':  
                # t1の親トークンを解析する  
                result = self.analyze_target(t1.head)  
                if result:  
                    return result  
            # 「教え + て」の組み合わせにヒットしたら  
            elif t1.text == '教え' and (t2.text == 'て' and t2.dep_ == 'mark'):  
                # t1を解析する  
                result = self.analyze_osiete(t1)  
                if result:  
                    return result  

        return 'わかりません'  

    def analyze_osiete(self, tok):  
        """  
        引数tokを解析する(教えて系の解析)  
        """  
        lefts = list(tok.lefts)  # 左の子要素を参照する  
        if len(lefts):  
            target = lefts[0]  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        rights = list(tok.rights)  # 右の子要素を解析する  
        if len(rights):  
            target = rights[0]  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        if tok != tok.head:  # 親の要素を解析する  
            target = tok.head  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        return None  

    def analyze_target(self, tok):  
        """  
        引数tokを解析して返答を生成する  
        """  
        if tok.text == '名前':  
            return '私の名前は花子です'  
        elif tok.text == '体重':  
            return '私の体重は100キロです'  
        elif tok.text == '身長':  
            return '私の身長は100センチです'  
        else:  
            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('名前と体重を教えて', '私の体重は100キロです')  # 並列なケース  
        self.eq('名前を教えてよ', '私の名前は花子です')  
        self.eq('名前を教えてくれる?', '私の名前は花子です')  
        self.eq('名前を教えろ', 'わかりません')  # 非対応。「教えろ」が1単語になる  

        self.eq('名前を教えてくれなくていいよ', '私の名前は花子です')  # 否定形は非対応  
        self.eq('名前を教えてくれないで', '私の名前は花子です')  # 否定形は非対応  

        # 「あなたの~は?」のケース  
        self.eq('あなたの名前は?', '私の名前は花子です')  
        self.eq('名前は?', '私の名前は花子です')  
        self.eq('あなたの体重は?', '私の体重は100キロです')  
        self.eq('あなたの身長は?', '私の身長は100センチです')  
        self.eq('あなたの視力は?', 'わかりません')  # 非対応。単語が辞書にない  

ソースコードの解説

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

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

スクリプトの最初の方で必要モジュールをインポートしておきます。

今回は自然言語処理にspaCyを使うのでspacyをインポートします。
こちらはpipでインストールが必要です。

単体テスト用にunittestもインポートしておきます。
こちらは標準ライブラリです。

deplacyはspaCyのトークン列の依存構造を視覚的にわかりやすく出力するモジュールです。
スクリプトの改造などで使う場合はコメントアウトを外します。
こちらもpipでインストールが必要です。

import spacy  
import unittest  
# import deplacy  

GiNZAのモデルをロード

グローバル領域でspaCyにGiNZAをロードします。
spacy.load()にモデル名を指定すると、そのモデルを読み込むことができます。
GiNZAの場合はja_ginzaを指定します。

spacy.load()ja_ginzaを読み込んだ場合はginza.Japaneseを返してきます。
この返り値には慣例的にnlpと命名します。

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

AIクラスの作成

今回のほとんどの処理はAIクラスに実装します。
メソッドについては後述します。

class AI:  
    ...  

analyze()メソッドで文章を解析する

analyze()メソッドは解析のエントリーポイントとなるメソッドです。
このメソッドに文章を渡すと、その文章が解析されて返答が生成されます。

内部的には引数textnlp()で解析しています。
nlp()の返り値はdocで、これはspacy.tokens.doc.Docです。
このdocはトークン列を抽象化したオブジェクトで、docを参照することでspaCyの解析結果を参照することができます。

doc.sentsを参照すると、文を取り出すことができます。
sentspacy.tokens.span.Spanになります。
これは例えば「今日は笑った。明日も笑う。」という文章であれば「今日は笑った。」「明日も笑う。」がそれぞれ1つずつの文(sent)になります。
doc.sentsfor文で回して文をanalyze_sent()で1つずつ解析します。
この結果があればreturnします。

解析に失敗した場合はNoneを返します。

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

        for sent in doc.sents:  # 文を取り出す  
            result = self.analyze_sent(sent)  # 文ごとに解析する  
            if result:  
                return result  # 解析に成功した  

        return None  # 解析に失敗した  

analyze_sent()で文を解析する

analyze_sent()は引数sentを解析します。
内部的にはsentwhile文で回しています。
走査方法がちょっと変わっていて、添え字を基点にして1つ目と2つ目のトークンを2つ取り出しています(t1t2)。
これはトークンの並びをチェックするためです。
sentを添え字で参照すると、その結果はspacy.tokens.token.Tokenで返ってきます。
これはトークンで、字句解析と構文解析の結果の単語の情報が保存されています。

t1t2をチェックして、「は + ?」の並びだったらanalyze_target()t1.headを渡します。
トークンのtext属性には元の文章のそのままの表記が保存されています。
dep_属性には依存構造上におけるトークンの役割が保存されています。caseは格表示でmarkは接続詞になります。
head属性には依存構造上における親のトークンが保存されています。t1.headを渡している理由ですが、これは文章の依存構造をdeplacyで出力するとわかると思います。

トークンが「教え + て」の並びだったらanalyze_osiete()t1を渡します。

いずれも結果が真であればその結果をreturnします。
解析に失敗した場合は「わかりません」という文字列を返します。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  # 1つ目のトークン  
            t2 = sent[i + 1]  # 2つ目のトークン  
            i += 1  

            # 「は + ?」の組み合わせにヒットしたら  
            if (t1.text == 'は' and t1.dep_ == 'case') and t2.text == '?':  
                # t1の親トークンを解析する  
                result = self.analyze_target(t1.head)  
                if result:  
                    return result  
            # 「教え + て」の組み合わせにヒットしたら  
            elif t1.text == '教え' and (t2.text == 'て' and t2.dep_ == 'mark'):  
                # t1を解析する  
                result = self.analyze_osiete(t1)  
                if result:  
                    return result  

        return 'わかりません'  

analyze_target()で返答を生成する

analyze_target()は引数tokから返答を生成します。
内容的にはトークンのtext属性を参照して、処理を分岐して返答を生成してるだけです。
対応していないtextの場合はNoneを返します。

    def analyze_target(self, tok):  
        """  
        引数tokを解析して返答を生成する  
        """  
        if tok.text == '名前':  
            return '私の名前は花子です'  
        elif tok.text == '体重':  
            return '私の体重は100キロです'  
        elif tok.text == '身長':  
            return '私の身長は100センチです'  
        else:  
            return None   

analyze_osiete()で「~教えて」を解析する

analyze_osiete()は引数tokを解析します。
これは「~教えて」という文の形のtokです。
内容的にはトークンのleftsrights, それからheadを参照して、返答が生成できるか試行しています。
leftsには依存構造上における左側の子要素が保存されています。
rightsには右側の子要素です。
leftsrightsに子要素があって、その1つ目の子で返答が生成できればそのままreturnします。
headについても同様です。

解析できなかった場合はNoneを返します。

    def analyze_osiete(self, tok):  
        """  
        引数tokを解析する(教えて系の解析)  
        """  
        lefts = list(tok.lefts)  # 左の子要素を参照する  
        if len(lefts):  
            target = lefts[0]  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        rights = list(tok.rights)  # 右の子要素を解析する  
        if len(rights):  
            target = rights[0]  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        if tok != tok.head:  # 親の要素を解析する  
            target = tok.head  
            result = self.analyze_target(target)  
            if result:  
                return result  # 解析に成功  

        return None  

単体テストを書く

単体テストを書きます。
unittest.TestCaseを継承したクラスを作り、test_で始まるメソッドを定義します。
このtest_で始まるメソッドがテスト実行時に実行されます。
テスト内容的にはまだアイデア次第で追加できそうですが、暇な人は追加してみてください。たぶんバグとか出るかもしれません。

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('名前と体重を教えて', '私の体重は100キロです')  # 並列なケース  
        self.eq('名前を教えてよ', '私の名前は花子です')  
        self.eq('名前を教えてくれる?', '私の名前は花子です')  
        self.eq('名前を教えろ', 'わかりません')  # 非対応。「教えろ」が1単語になる  

        self.eq('名前を教えてくれなくていいよ', '私の名前は花子です')  # 否定形は非対応  
        self.eq('名前を教えてくれないで', '私の名前は花子です')  # 否定形は非対応  

        # 「あなたの~は?」のケース  
        self.eq('あなたの名前は?', '私の名前は花子です')  
        self.eq('名前は?', '私の名前は花子です')  
        self.eq('あなたの体重は?', '私の体重は100キロです')  
        self.eq('あなたの身長は?', '私の身長は100センチです')  
        self.eq('あなたの視力は?', 'わかりません')  # 非対応。単語が辞書にない  

おわりに

今回はspaCyを使って「あなたの~は?」という質問に返答するAIモドキを作りました。
これが発展すると質問に答えるルールベースのAIが作れそうですね。
興味ある方の参考になれば幸いです。

🦝 < 質問は会話の潤滑油

🐭 < あなたの名前は?