ユーニックス総合研究所

  • home
  • archives
  • spacy-dengen

spaCyで「電源切って」で電源を切るAIモドキを作る【自然言語処理, Python】

spaCyで「電源切って」に答える

「自然言語処理」とは私たちが使う日本語などの「自然言語」を解析する計算処理です。
自然言語処理を行うと日本語の文章をパソコンに理解させることが可能です。

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

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

  • spaCyとは?
  • 電源を切るという要求
  • スクリプトの実行結果
  • スクリプトの設計
  • スクリプトのソースコード
  • ソースコードの解説

spaCyとは?

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

今回、spaCyと一緒に使うのがGiNZA(ギンザ)のモデルです。
GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリです。
これの日本語のための統計モデルをspaCyから使うことが可能です。

spaCyとGiNZAを組み合わせることで日本語の字句解析、構文解析、ベクトル解析、類似性解析などを簡単に行うことが出来るようになります。

電源を切るという要求

「電源を切る」という要求について考えてみます。
私たちは電気に囲まれた生活をしています。
どこもかしこも電気の配線だらけで、(日本の)街には電柱が立ち並んでいます。
私たちは日常的に電源をON/OFFに切り替えており、電源を付ける/切るという要求は日常的なものです。

自然言語処理を行えば、日本語の「電源切って」という要求を解析し、それをプログラム的な命令に変換することができます。
これの応用としてはGoogle Homeなどに代表されるスマートスピーカーへの機能搭載です。
音声を文章化し、その文章を今回のような自然言語処理で解析すれば、音声をプログラム的にパソコンの命令に変換できます。
ということは音声の「電源切って」という要求に答えることができるスマートスピーカーのような製品も作成できるという寸法です。

もちろん電源を切るだけじゃつまらないので他にも機能の搭載は必要ですし、ハードウェア的な課題もありますが、それでも非常に低機能なスマートスピーカーは作成できそうな感じですね。

スクリプトの実行結果

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

.  
----------------------------------------------------------------------  
Ran 1 test in 1.231s  

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('その電源落として', '電源を落とします')  
        self.eq('電源、切って', '電源を切ります')  
        self.eq('電源と大根を切って', '電源を切ります')  
        self.eq('あのー、電源いらない', '電源を切ります')  

        # 電源を切らないケース  
        self.eq('電源切ってくれないで', None)  
        self.eq('電源切らないで', None)  
        self.eq('電源切ってほしくない', None)  
        self.eq('電源切るな', None)  
        self.eq('電源!いらない!', None)  
        self.eq('大根切って', None)  
        self.eq('大根を切って', None)  
        self.eq('大根を急いで切って', None)  

        # 微妙なケース  
        self.eq('電源というか大根を切って', '電源を切ります')  

↑のテストを見ると「電源を切って」や「悪いんだけど電源切って欲しい」という要求に対して、「電源を切ります」という出力をしているのがわかります。
今回作成するAIモドキはこのように自然言語を解析して、それに対する返答を生成します。

スクリプトの設計

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

スクリプトの機能のほとんどはAIクラスに実装します。
AIクラスのanalyze()メソッドが解析のエントリーポイントです。

analyze()に日本語の文章を渡すと、analyze()はその文章をspaCyで解析し、最終的に返答となる文字列を返します。
analyze()は内部でspaCyでロードしたnlp()クラスを使い文章をトークン列(doc)に変換します。
このdocから文を1文ずつ取り出して処理します。

文の解析では「電源切って」や「電源落として」、「電源いらない」などの要求を解析します。
これらの要求が真であれば、「電源を切ります」や「電源を落とします」などの返答を生成します。

要求の解析ではトークン列を解析します。
トークン列からトークンを取り出して、そのトークンの属性を見ます。これはtextだったりlemma_だったりです。これらの属性の意味については後述します。
そして依存構造を辿って要求が既定のフォーマットに合致しているかチェックし、要求が正当なものか判断します。
これは要は文法の確認です。要求が要求の文法かどうか確認します。

基本的な設計は↑のようになります。
詳細についてはソースコードの解説で解説します。

スクリプトのソースコード

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

"""  
「電源を切って」で電源を切るAIモドキ  

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


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


class 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を解析して返答を生成する  
        """  
        if self.analyze_kitte(sent, ('切っ', 'て'), ('電源', )):  
            return '電源を切ります'  

        if self.analyze_kitte(sent, ('落とし', 'て'), ('電源', )):  
            return '電源を落とします'  

        if self.analyze_kitte(sent, ('いら', 'ない'), ('電源', ), False):  
            return '電源を切ります'  

        return None  

    def analyze_kitte(self, sent, words, targets, nai=True):  
        """  
        引数sentにwordsの並びのトークンがあるか調べ、それがtargetsに係っていて、  
        かつ「無い」が無ければTrueを返す  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  # 1つ目のトークン  
            t2 = sent[i + 1]  # 2つ目のトークン  
            i += 1  

            # トークンの並びをチェックする  
            if t1.text == words[0] and t2.text == words[1]:  
                # 子要素からtargetsを探す  
                hastarget = self.find_target_from_children(t1, targets)  

                # naiオプションが有効なら「無い」も考慮する  
                if nai:  
                    # 親要素から「無い」を探す  
                    hasnai = self.find_target_from_head(t1, ('無い', 'ない'))  
                    if not hasnai:  
                        # 子要素から「無い」を探す  
                        hasnai = self.find_target_from_children(t1, ('無い', 'ない'))  

                    # ターゲットが存在しかつ「無い」が無ければTrueを返す  
                    # 「無い」がある場合は否定文と見なす  
                    if hastarget and not hasnai:  
                        return True  
                else:  
                    if hastarget:  
                        return True  

        return False  

    def find_target_from_children(self, tok, targets):  
        """  
        tokの子要素から再帰的にtargets(原形)のトークンを検索する  
        """  
        if tok.lemma_ in targets:  
            return tok  # 見つかった  

        for child in tok.lefts:  
            found = self.find_target_from_children(child, targets)  
            if found:  
                return found # 見つかった  

        for child in tok.rights:  
            found = self.find_target_from_children(child, targets)  
            if found:  
                return found # 見つかった  

        return None  # 見つからなかった  

    def find_target_from_head(self, tok, targets):  
        """  
        tokの親要素から再帰的にtargets(原形)のトークンを検索する  
        """  
        if tok.lemma_ in targets:  
            return tok  # 見つかった  
        if tok == tok.head:  # 現在のトークンと親要素が同じ=行き止まり  
            return None  # 見つからなかった  

        return self.find_target_from_head(tok.head, targets)  


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('その電源落として', '電源を落とします')  
        self.eq('電源、切って', '電源を切ります')  
        self.eq('電源と大根を切って', '電源を切ります')  
        self.eq('あのー、電源いらない', '電源を切ります')  

        # 電源を切らないケース  
        self.eq('電源切ってくれないで', None)  
        self.eq('電源切らないで', None)  
        self.eq('電源切ってほしくない', None)  
        self.eq('電源切るな', None)  
        self.eq('電源!いらない!', None)  
        self.eq('大根切って', None)  
        self.eq('大根を切って', None)  
        self.eq('大根を急いで切って', None)  

        # 微妙なケース  
        self.eq('電源というか大根を切って', '電源を切ります')  

ソースコードの解説

ソースコードの解説は↓からになります。

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

スクリプトの冒頭では必要モジュールをインポートします。
今回は自然言語処理でspaCyを使うのでspacyをインポートします。
それから単体テスト用にunittestをインポートします。

import spacy  
import unittest  

GiNZAのモデルをロード

今回は日本語の解析にGiNZAのモデルを使います。
spacy.load()ja_ginzaを渡すとGiNZAのモデルをロードすることができます。
読み込んだ結果はginza.Japaneseで返ってくるので、慣例的にnlpという名前で保存しておきます。
このnlpに文字列を渡すことで解析することができます。

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

AIクラスの作成

スクリプトの主要な機能はAIクラスに実装します。
メソッドの詳細については後述します。

class AI:  
    ...  

analyze()で文章を解析する

解析のエントリーポイントはanalyze()メソッドです。
このメソッドは引数textを解析して返答を生成します。

内部ではtextnlp()に渡してトークン列docに変換しています。
このdocspacy.tokens.doc.Docです。
doc.sentsを参照するとsent(文)を取り出すことができます。
これは単位的には文章の中の文のことです。
つまり「今日は良い日。明日も良い日。」という文章だったらsentはそれぞれ「今日も良い日。」「明日も良い日。」の2つの文になります。
文を取り出したらanalyze_sent()に処理を任せます。
その結果が真であればそのまま結果をreturnします。
解析に失敗した場合はNoneを返します。

    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を解析します。
内部ではanalyze_kitte()メソッドを呼び出しています。
analyze_kitte()の引数を変更することで「電源切って」や「電源落として」、「電源いらない」などの要求に対応しています。
上から順に処理して行って、結果が真になった時点で返答を生成します。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        """  
        if self.analyze_kitte(sent, ('切っ', 'て'), ('電源', )):  
            return '電源を切ります'  

        if self.analyze_kitte(sent, ('落とし', 'て'), ('電源', )):  
            return '電源を落とします'  

        if self.analyze_kitte(sent, ('いら', 'ない'), ('電源', ), False):  
            return '電源を切ります'  

        return None  

analyze_kitte()で文をパラメーターによって解析する

コア部分となるanalyze_kitte()は引数sentを他の引数に応じて解析し、真偽値を返します。
返り値はsentが要求に合致していればTrue, 合致していなければFalseです。

引数wordsにはトークンの並びを表す文字列をタプルで渡します。
引数targetsにはwordsの係り受けの対象になるトークンの原形をタプルで渡します。
引数naiは否定形を考慮する場合はTrue, 否定形を考慮しない場合はFalseを渡します。

引数の関係ですが、sentからトークンを取り出し、そのトークンの並びがwordsに合致していたら、そのトークンの子要素からtargetsに合致するトークンを探します。
targetsに合致するトークンがあり、さらに否定形が存在しなければTrueを返します
否定形を考量しない場合はtargetsに合致するトークンがあればTrueを返します。

sentを添え字で参照すると結果はspacy.tokens.token.Tokenで返ってきます。
これはトークンで、このトークンにはspaCyの解析結果が詰まっています。
それは字句解析や構文解析の結果です。このトークンの属性を参照することでそれらの解析結果にアクセスすることができます。

トークンのtext属性には単語の元の文章のそのままの表記が入っています。
これをwordsの要素と比較しています。つまりwordstextの集まりと言うことになります。

子要素や親要素の探索では文の依存構造を参照しています。
依存構造とは構文解析時に構築される構造のことで、単語と単語の係(かか)り受けの関係の構造です。
これを辿ることでどの単語がどの単語に係っているかと言うのがわかります。

    def analyze_kitte(self, sent, words, targets, nai=True):  
        """  
        引数sentにwordsの並びのトークンがあるか調べ、それがtargetsに係っていて、  
        かつ「無い」が無ければTrueを返す  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  # 1つ目のトークン  
            t2 = sent[i + 1]  # 2つ目のトークン  
            i += 1  

            # トークンの並びをチェックする  
            if t1.text == words[0] and t2.text == words[1]:  
                # 子要素からtargetsを探す  
                hastarget = self.find_target_from_children(t1, targets)  

                # naiオプションが有効なら「無い」も考慮する  
                if nai:  
                    # 親要素から「無い」を探す  
                    hasnai = self.find_target_from_head(t1, ('無い', 'ない'))  
                    if not hasnai:  
                        # 子要素から「無い」を探す  
                        hasnai = self.find_target_from_children(t1, ('無い', 'ない'))  

                    # ターゲットが存在しかつ「無い」が無ければTrueを返す  
                    # 「無い」がある場合は否定文と見なす  
                    if hastarget and not hasnai:  
                        return True  
                else:  
                    if hastarget:  
                        return True  

        return False  

find_target_from_children()で子要素を探索する

find_target_from_children()は引数tokの依存構造を探索し、引数targetsに合致する原形を持つトークンを返します。
原形とは単語の原形のことです。これはトークンのlemma_属性を参照することで取得できます。
依存構造上におけるトークンの左の子要素はトークンのlefts属性で参照できます。
右の子要素はrights属性です。これらの属性はfor文で回すと、子要素を1つずつ取り出せます。
取り出した子要素をfind_target_from_children()に渡し再帰的に処理しています。
依存構造は木構造ですので、このように再帰処理を使うことで効率よく構造を走査できます。

    def find_target_from_children(self, tok, targets):  
        """  
        tokの子要素から再帰的にtargets(原形)のトークンを検索する  
        """  
        if tok.lemma_ in targets:  
            return tok  # 見つかった  

        for child in tok.lefts:  
            found = self.find_target_from_children(child, targets)  
            if found:  
                return found # 見つかった  

        for child in tok.rights:  
            found = self.find_target_from_children(child, targets)  
            if found:  
                return found # 見つかった  

        return None  # 見つからなかった  

find_target_from_head()で親要素を探索する

find_target_from_head()は引数tokの親要素を再帰的に探索し、引数targetsに合致する原形を持つトークンを返します。
トークンの依存構造上における親要素はhead属性で参照することができます。
これを再帰的に参照することで親要素を辿っていけます。
親要素が存在しない場合はheadはトークン自体になるので、if文でこれをチェックして再帰の終了条件にしています。

    def find_target_from_head(self, tok, targets):  
        """  
        tokの親要素から再帰的にtargets(原形)のトークンを検索する  
        """  
        if tok.lemma_ in targets:  
            return tok  # 見つかった  
        if tok == tok.head:  # 現在のトークンと親要素が同じ=行き止まり  
            return None  # 見つからなかった  

        return self.find_target_from_head(tok.head, targets)  

単体テストを書く

単体テストを書きます。
テストケースの量はそれほど多くないですが、もう少し頭をひねればテストを追加できるかと思います。
今回はそこまでやっていません。

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('その電源落として', '電源を落とします')  
        self.eq('電源、切って', '電源を切ります')  
        self.eq('電源と大根を切って', '電源を切ります')  
        self.eq('あのー、電源いらない', '電源を切ります')  

        # 電源を切らないケース  
        self.eq('電源切ってくれないで', None)  
        self.eq('電源切らないで', None)  
        self.eq('電源切ってほしくない', None)  
        self.eq('電源切るな', None)  
        self.eq('電源!いらない!', None)  
        self.eq('大根切って', None)  
        self.eq('大根を切って', None)  
        self.eq('大根を急いで切って', None)  

        # 微妙なケース  
        self.eq('電源というか大根を切って', '電源を切ります')  

おわりに

今回はspaCyを使って「電源切って」や「電源落として」、「電源いらない」などの要求にこたえるAIモドキを作ってみました。
これを応用すればスマートスピーカーの開発も可能になりそうです。
興味ある方の参考になれば幸いです。

🦝 < かしこいスピーカーの基礎技術

🐭 < NLPで希望の明日へ