ユーニックス総合研究所

  • home
  • archives
  • spacy-naninani-tsukete

spaCyで「~つけて」という要求に答える解析器を作る【自然言語処理, Python】

spaCyで「~つけて」に応答する

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

Pythonには自然言語処理を行えるライブラリであるspaCy(スパイシー)が存在します。

今回はこのspaCyを使って「電気つけて」や「テレビつけて」、「電気消して」や「テレビ消して」などの要求に「電気をつけます」とか「電気を消します」などと応答する解析器を作ってみたいと思います。
具体的には↓を見ていきます。

  • spaCyとは
  • 「~つけて」という要求
  • 解析器の実行結果
  • 解析器の設計
  • 解析器のソースコード
  • ソースコードの解説

spaCyとは

spaCy(スパイシー)とはオープンソースで開発されているPython製の自然言語処理ライブラリです。
MITライセンスで利用することができます。
さまざまな言語の学習済み統計モデルをロードするだけで使うことができます。

spaCyと合わせて日本語の解析で使われるのがGiNZA(ギンザ)です。
GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリで、日本語の構文解析を行うことができます。

基本的にはspaCyからGiNZAのモデルをロードして、日本語を解析するという流れになります。

「~つけて」という要求

「~つけて」という要求はスマートスピーカーなどの機器の周りでよく見られます。
たとえば「電気つけて」や「テレビつけて」、「クーラー付けて」など、AIにそういった要求を音声で送ることで、AIがその音声を解析して電子的な命令に変換しています。

ユーザーの音声はテキストに変換され、そのテキストが自然言語処理されて機械の命令に変換されるわけですが、今回実装するのはこのテキストを機械の命令に変換する所です。
実際に電気をつけたりするのはリモート機器との通信が必要になりますが、今回はこういった通信処理は省略し、トリガー部分の実装だけにとどめています。

ここまで解説しておいてあれですが、私はスマートスピーカーの実装はやったことありません。
ですので↑のもただの想像です。この辺はご了承ください。まぁ、常識的に考えれば↑のような実装になっているはずです。

これらの要求にこたえるのはAIの大事な仕事の1つと言えます。
人間の欲求に答えることで人間を満足させ、人間の生活レベルを向上させるわけです。
AIの仕事の1つが人間への奉仕であれば、スマートスピーカーの行っている仕事はごく自然なことと言えそうです。
これのオートメーション化がどんどん進めばアンドロイド家政婦さんが登場し、朝ごはんや掃除をしてくれるようになるわけで、ワクテカが止まりませんね。
この記事ではその片鱗に触れるということになります。

🦝 < アンドロイド家政婦さん、欲しい

🐭 < 欲しい

解析器の実行結果

解析器の動作確認は単体テストで行います。
後述のソースコードをsample.pyなどに保存し、端末からpython -m unittest sampleと実行します。
すると↓のような結果が出ます。

.  
----------------------------------------------------------------------  
Ran 1 test in 2.919s  

OK  

↑の表示はテストがすべて正常に実行されたことを示しています。
実行したテストケースは↓になります。

class Test(unittest.TestCase):  
    def eq(self, intext, outtext, states_key, states_bool):  
        kaeru = Kaeru()  
        s = kaeru.analyze(intext)  
        self.assertEqual(s, outtext)  
        if states_key:  
            self.assertEqual(kaeru.states[states_key], states_bool)  

    def test_analyze(self):  
        # 「~つけて」のケース  
        self.eq('カエル、電気つけて', '電気をつけます', '電気', True)  
        self.eq('電気つけて、カエル', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルさん、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルー、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルゥ、電気つけてぇ', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、テレビつけてくれる?', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、電気をつけて', '電気をつけます', '電気', True)  
        self.eq('カエル、急いで電気つけて', '電気をつけます', '電気', True)  
        self.eq('カエル、電気を急いでつけて', '電気をつけます', '電気', True)  
        self.eq('ただいまー。おーいカエル、電気つけてー。疲れたぁ。', '電気をつけます', '電気', True)  

        # 「~消して」のケース  
        self.eq('カエル、電気消して', '電気を消します', '電気', False)  
        self.eq('カエル、テレビ消して', 'テレビを消します', 'テレビ', False)  
        self.eq('カエル、電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、早く電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、電気を早く消して', '電気を消します', '電気', False)  
        self.eq('いってくるねー。電気消してーカエルさーん。ばいばーい。', '電気を消します', '電気', False)  

        # 非対応  
        # キーワードカエルが含まれていないため  
        self.eq('電気つけて', None, None, None)  
        self.eq('電気つけないで', None, None, None)  
        self.eq('カエェル、電気つけて', None, None, None)  

        # 非対応  
        # 微妙なニュアンス  
        self.eq('カエル、電気つけないで', None, None, None)  

        # 解析に失敗するケース  
        # 否定形へは未対応のため失敗する  
        # 妙な日本語  
        self.eq('カエル、電気をつけてくれないで', '電気をつけます', '電気', True)  

        # 「!」で文が分かれるので失敗  
        self.eq('カエルゥ!電気つけてぇ!', None, None, None)  

今回はテストをたくさん書いていて、けっこう色々なテストをしています。

解析器の設計

作成する解析器の名前は今回は「Kaeru」と命名しました。
ユーザーは解析器に要求を送る時は「カエル」と最初に呼びます。
それに続いて要求を送ることで要求が解析され、Kaeruから応答が生成されます。

Kaeruというクラスを作り、メソッドanalyze()を作ります。
このメソッドが解析のエントリーポイントで、引数にテキストを渡すことで解析できます。
analyze()内ではspaCyを使ってテキストをドキュメントに変換します。

そして文を取り出して1文ずつ解析し、「カエル」というワードが見つかったら解析を分岐します。
分岐では「つけて」と「消して」の場合をそれぞれ解析します。
そして結果が真であればそのまま結果を返します。

基本的な設計は↑のようになります。
メソッドの詳細については後述します。

解析器のソースコード

"""  
spaCyで「~つけて」という要求に答える解析器を作る【自然言語処理, Python】  
"""  
import spacy  
import unittest  


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


class Kaeru:  
    """  
    「~つけて」「~消して」という要求に返答するアナライザー  
    """  
    def __init__(self):  
        self.states = {}  # オブジェクトの状態を表す辞書  

    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を解析して返答を生成する  
        """  
        for tok in sent:  
            if (tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN') and \  
               (tok.text == 'カエル' or tok.text == 'カエルー' or tok.text == 'カエルゥ'):  
                # 文中に「カエル」が見つかった  
                result = self.analyze_kaeru(sent)  # 解析を開始  
                if result:  
                    return result  

        return None  

    def analyze_kaeru(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        キーワード「カエル」が見つかっている前提  
        """  
        # 最初に「~つけて」を解析する  
        result = self.analyze_tsukete(sent)  
        if result:  
            return result  

        # 解析に失敗したら「~消して」を解析する  
        result = self.analyze_keshite(sent)  
        if result:  
            return result  

        return None  

    def analyze_tsukete(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        「~つけて」を解析  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            i += 1  
            # 「つける + (て | てー)」の組み合わせを見つけたら  
            if (t1.lemma_ == 'つける' and t1.pos_ == 'VERB') and \  
               ((t2.text == 'て' or t2.text == 'てー' or t2.text == 'てぇ') and (t2.dep_ == 'mark' or t2.dep_ == 'aux')):  
                result = self.analyze_tsukeru(t1)  # 「つける」を解析  
                if result:  
                    return result  

    def analyze_tsukeru(self, tsukeru):  
        """  
        引数tsukeruを解析する  
        名詞を探し、名詞が見つかったらそのオブジェクトをONにする  
        """  
        nouns = []  
        self.collect(nouns, tsukeru, ('NOUN', ))  # 名詞を集める  
        noun = self.choice_near_token(tsukeru, nouns)  # 一番近い名詞を探す  
        if not noun:  
            return None  # 名詞が見つからなかった  

        self.states[noun.text] = True  # オブジェクトの状態をONにする  

        return f'{noun.text}をつけます'  # 返答を生成する  

    def analyze_keshite(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        「~消して」を解析  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            i += 1  
            # 「消す + (て | てー)」の組み合わせを見つけたら  
            if (t1.lemma_ == '消す' and t1.pos_ == 'VERB') and \  
               ((t2.text == 'て' or t2.text == 'てー' or t2.text == 'てぇ') and (t2.dep_ == 'mark' or t2.dep_ == 'aux')):  
                result = self.analyze_kesu(t1)  # 「消す」を解析  
                if result:  
                    return result  

    def analyze_kesu(self, kesu):  
        """  
        引数kesuを解析する  
        名詞を探し、名詞が見つかったらそのオブジェクトをOFFにする  
        """  
        nouns = []  
        self.collect(nouns, kesu, ('NOUN', ))  # 名詞を集める  
        noun = self.choice_near_token(kesu, nouns)  # 一番近い名詞を探す  
        if not noun:  
            return None  

        self.states[noun.text] = False  # 見つかったらオブジェクトの状態をOFFに  

        return f'{noun.text}を消します'  # 返答を生成する  

    def collect(self, dst, tok, poses_):  
        """  
        poses_にマッチする子要素をdstに集める  
        """  
        if tok.pos_ in poses_:  
            dst.append(tok)  

        for child in tok.lefts:  # 左の子から集める  
            self.collect(dst, child, poses_)  

        for child in tok.rights:  # 右の子から集める  
            self.collect(dst, child, poses_)  

    def choice_near_token(self, org, toks):  
        """  
        orgに近いトークンをtoksから抽出する  
        """  
        if not len(toks):  
            return None  

        near_i = abs(org.i - toks[0].i)  # orgからの距離を絶対値で求める  
        near_tok = toks[0]  

        for tok in toks:  
            i = abs(org.i - tok.i)  
            if i < near_i:  # 最も近いトークンが見つかったら  
                near_tok = tok  # トークンを保存  
                near_i = i  

        return near_tok  


class Test(unittest.TestCase):  
    def eq(self, intext, outtext, states_key, states_bool):  
        kaeru = Kaeru()  
        s = kaeru.analyze(intext)  
        self.assertEqual(s, outtext)  
        if states_key:  
            self.assertEqual(kaeru.states[states_key], states_bool)  

    def test_analyze(self):  
        # 「~つけて」のケース  
        self.eq('カエル、電気つけて', '電気をつけます', '電気', True)  
        self.eq('電気つけて、カエル', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルさん、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルー、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルゥ、電気つけてぇ', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、テレビつけてくれる?', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、電気をつけて', '電気をつけます', '電気', True)  
        self.eq('カエル、急いで電気つけて', '電気をつけます', '電気', True)  
        self.eq('カエル、電気を急いでつけて', '電気をつけます', '電気', True)  
        self.eq('ただいまー。おーいカエル、電気つけてー。疲れたぁ。', '電気をつけます', '電気', True)  

        # 「~消して」のケース  
        self.eq('カエル、電気消して', '電気を消します', '電気', False)  
        self.eq('カエル、テレビ消して', 'テレビを消します', 'テレビ', False)  
        self.eq('カエル、電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、早く電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、電気を早く消して', '電気を消します', '電気', False)  
        self.eq('いってくるねー。電気消してーカエルさーん。ばいばーい。', '電気を消します', '電気', False)  

        # 非対応  
        # キーワードカエルが含まれていないため  
        self.eq('電気つけて', None, None, None)  
        self.eq('電気つけないで', None, None, None)  
        self.eq('カエェル、電気つけて', None, None, None)  

        # 非対応  
        # 微妙なニュアンス  
        self.eq('カエル、電気つけないで', None, None, None)  

        # 解析に失敗するケース  
        # 否定形へは未対応のため失敗する  
        # 妙な日本語  
        self.eq('カエル、電気をつけてくれないで', '電気をつけます', '電気', True)  

        # 「!」で文が分かれるので失敗  
        self.eq('カエルゥ!電気つけてぇ!', None, None, None)  

ソースコードの解説

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

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

今回は自然言語処理にspaCyを使うのでspacyをインポートしておきます。
それから単体テスト用にunittestもインポートします。

import spacy  
import unittest  

GiNZAをロード

今回は日本語の解析にGiNZAのモデルを使います。
spacy.load()ja_ginzaを指定し、返り値としてginza.Japaneseを得ます。
これに慣例的にnlpと命名しておきます。

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

Kaeruの作成

Kaeruクラスを作ります。
__init__()内でstatesという辞書を作っておきます。
この辞書にはオブジェクトの状態が真偽値で保存されます。
たとえば「電気」がONだったら電気というキーにTrueが設定されます。

メソッドについては後述します。

class Kaeru:  
    """  
    「~つけて」「~消して」という要求に返答するアナライザー  
    """  
    def __init__(self):  
        self.states = {}  # オブジェクトの状態を表す辞書  

analyze()でテキストを解析して応答を生成

analyze()が解析処理のエントリーポイントです。
引数textnlpで解析してdocにします。これはspacy.tokens.doc.Docです。
doc.sentsfor文で参照すると一文ずつ取り出すことができます。
sentspacy.tokens.span.Spanです。このsentanalyze_sent()に渡して解析を分岐します。

    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()で文を解析します。
sentfor文で回すとトークンを取り出すことができます。
このトークン(tok)はspacy.tokens.token.Tokenです。

トークンのpos_属性には品詞の種類が保存されています。
NOUNは名詞で、PROPNは固有名詞です。
text属性には元の文章のそのままの表記が保存されています。

トークンのpos_textをチェックして、「カエル」や「カエルー」だったら解析を分岐します。
文章に「カエル」というワードが存在しない場合は解析が続行されません。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        """  
        for tok in sent:  
            if (tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN') and \  
               (tok.text == 'カエル' or tok.text == 'カエルー' or tok.text == 'カエルゥ'):  
                # 文中に「カエル」が見つかった  
                result = self.analyze_kaeru(sent)  # 解析を開始  
                if result:  
                    return result  

        return None  

analyze_kaeru()で「つけて」「消して」を解析する

analyze_kaeru()は引数sentanalyze_tsukete()analyze_keshite()で解析します。
どちらかの結果が真であればそのままreturnします。
このメソッドは文章中に「カエル」というワードが見つかっている前提で呼ばれます。

    def analyze_kaeru(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        キーワード「カエル」が見つかっている前提  
        """  
        # 最初に「~つけて」を解析する  
        result = self.analyze_tsukete(sent)  
        if result:  
            return result  

        # 解析に失敗したら「~消して」を解析する  
        result = self.analyze_keshite(sent)  
        if result:  
            return result  

        return None  

analyze_tsukete()で「付けて」を解析

analyze_tsukete()は引数sentを解析して「つけて」というトークンの並びを探し、解析を分岐します。
「つけて」という文は「つけ」と「て」に単語が分かれます。そのためトークンを2つ取り出して参照しています。

トークンのlemma_属性にはトークンの原形が保存されています。これは「つけ」だったら「つける」です。
トークンのdep_属性は依存構造上におけるそのトークンの役割が保存されています。markは接続詞で、auxは助動詞です。
これらを参照して「つけて」が見つかったらanalyze_tsukeruに解析を分岐します。

    def analyze_tsukete(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        「~つけて」を解析  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            i += 1  
            # 「つける + (て | てー)」の組み合わせを見つけたら  
            if (t1.lemma_ == 'つける' and t1.pos_ == 'VERB') and \  
               ((t2.text == 'て' or t2.text == 'てー' or t2.text == 'てぇ') and (t2.dep_ == 'mark' or t2.dep_ == 'aux')):  
                result = self.analyze_tsukeru(t1)  # 「つける」を解析  
                if result:  
                    return result  

analyze_tsukeru()でオブジェクトの状態を変更する

analyze_tsukeru()は引数tsukeruをお解析します。
最初にcollect()tsukeruの子要素の名詞をすべて集めます。
そしてchoice_near_token()でその中からtsukeruに一番近い名詞を見つけます。

名詞が見つかったらその名詞をキーにしてstatesの要素をTrueにします。
この部分がオブジェクトの状態を変更しているとこで、実際に電気をつけたりクーラーをつけたりする部分です。

オブジェクトの状態を変更したら応答を生成してreturnします。

    def analyze_tsukeru(self, tsukeru):  
        """  
        引数tsukeruを解析する  
        名詞を探し、名詞が見つかったらそのオブジェクトをONにする  
        """  
        nouns = []  
        self.collect(nouns, tsukeru, ('NOUN', ))  # 名詞を集める  
        noun = self.choice_near_token(tsukeru, nouns)  # 一番近い名詞を探す  
        if not noun:  
            return None  # 名詞が見つからなかった  

        self.states[noun.text] = True  # オブジェクトの状態をONにする  

        return f'{noun.text}をつけます'  # 返答を生成する  

analyze_keshite()で「消して」を解析

analyze_keshite()は引数sentを解析します。
「消して」、つまり「消す + て」の組み合わせが見つかったらanalyze_kesu()に解析を分岐します。
詳細についてはanalyze_tsukete()を参照してください。

    def analyze_keshite(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        「~消して」を解析  
        """  
        i = 0  
        while i < len(sent) - 1:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            i += 1  
            # 「消す + (て | てー)」の組み合わせを見つけたら  
            if (t1.lemma_ == '消す' and t1.pos_ == 'VERB') and \  
               ((t2.text == 'て' or t2.text == 'てー' or t2.text == 'てぇ') and (t2.dep_ == 'mark' or t2.dep_ == 'aux')):  
                result = self.analyze_kesu(t1)  # 「消す」を解析  
                if result:  
                    return result  

analyze_kesu()でオブジェクトの状態を変更する

analyze_kesu()はオブジェクトの状態を「消す」、つまりOFF(False)にします。
詳細についてはanalyze_tsukeru()を参照してください。

    def analyze_kesu(self, kesu):  
        """  
        引数kesuを解析する  
        名詞を探し、名詞が見つかったらそのオブジェクトをOFFにする  
        """  
        nouns = []  
        self.collect(nouns, kesu, ('NOUN', ))  # 名詞を集める  
        noun = self.choice_near_token(kesu, nouns)  # 一番近い名詞を探す  
        if not noun:  
            return None  

        self.states[noun.text] = False  # 見つかったらオブジェクトの状態をOFFに  

        return f'{noun.text}を消します'  # 返答を生成する  

collect()で子要素を集める

collect()は引数poses_にマッチするpos_を持つ子トークンをdstに集めます。
トークンのleftsには左側の子要素、rightsには右側の子要素が保存されています。
この左右はつまり依存構造における左右です。依存構造は木構造なので、左右があります。

    def collect(self, dst, tok, poses_):  
        """  
        poses_にマッチする子要素をdstに集める  
        """  
        if tok.pos_ in poses_:  
            dst.append(tok)  

        for child in tok.lefts:  # 左の子から集める  
            self.collect(dst, child, poses_)  

        for child in tok.rights:  # 右の子から集める  
            self.collect(dst, child, poses_)  

choice_near_token()で近いトークンを取得する

choice_near_token()は引数orgに近いトークンをtoksから探して返します。
トークンのi属性にはそのトークンの位置が保存されています。これを参照することでorgに近いトークンを探します。
orgからあるトークンまでの距離はabs(org.i - toks[k].i)で求めることができます。
これが最小になるトークンをtoksから探すという処理になります。

    def choice_near_token(self, org, toks):  
        """  
        orgに近いトークンをtoksから抽出する  
        """  
        if not len(toks):  
            return None  

        near_i = abs(org.i - toks[0].i)  # orgからの距離を絶対値で求める  
        near_tok = toks[0]  

        for tok in toks:  
            i = abs(org.i - tok.i)  
            if i < near_i:  # 最も近いトークンが見つかったら  
                near_tok = tok  # トークンを保存  
                near_i = i  

        return near_tok  

テストを書く

テストを書きます。
テストではかなり崩れた口語にも対応しています。
していますが、ここまでやる必要があったのかどうかはわかりません。

unittest.TestCaseを継承したクラスを作りtest_で始まるメソッドを作ります。
このメソッドがテスト実行時に実行されます。

eq()では要求と応答、それからオブジェクトの状態を比較しています。
テストが失敗するとエラーになります。

class Test(unittest.TestCase):  
    def eq(self, intext, outtext, states_key, states_bool):  
        kaeru = Kaeru()  
        s = kaeru.analyze(intext)  
        self.assertEqual(s, outtext)  
        if states_key:  
            self.assertEqual(kaeru.states[states_key], states_bool)  

    def test_analyze(self):  
        # 「~つけて」のケース  
        self.eq('カエル、電気つけて', '電気をつけます', '電気', True)  
        self.eq('電気つけて、カエル', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルさん、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルー、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエルゥ、電気つけてぇ', '電気をつけます', '電気', True)  
        self.eq('カエル、テレビつけて', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、テレビつけてくれる?', 'テレビをつけます', 'テレビ', True)  
        self.eq('カエル、電気をつけて', '電気をつけます', '電気', True)  
        self.eq('カエル、急いで電気つけて', '電気をつけます', '電気', True)  
        self.eq('カエル、電気を急いでつけて', '電気をつけます', '電気', True)  
        self.eq('ただいまー。おーいカエル、電気つけてー。疲れたぁ。', '電気をつけます', '電気', True)  

        # 「~消して」のケース  
        self.eq('カエル、電気消して', '電気を消します', '電気', False)  
        self.eq('カエル、テレビ消して', 'テレビを消します', 'テレビ', False)  
        self.eq('カエル、電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、早く電気を消して', '電気を消します', '電気', False)  
        self.eq('カエル、電気を早く消して', '電気を消します', '電気', False)  
        self.eq('いってくるねー。電気消してーカエルさーん。ばいばーい。', '電気を消します', '電気', False)  

        # 非対応  
        # キーワードカエルが含まれていないため  
        self.eq('電気つけて', None, None, None)  
        self.eq('電気つけないで', None, None, None)  
        self.eq('カエェル、電気つけて', None, None, None)  

        # 非対応  
        # 微妙なニュアンス  
        self.eq('カエル、電気つけないで', None, None, None)  

        # 解析に失敗するケース  
        # 否定形へは未対応のため失敗する  
        # 妙な日本語  
        self.eq('カエル、電気をつけてくれないで', '電気をつけます', '電気', True)  

        # 「!」で文が分かれるので失敗  
        self.eq('カエルゥ!電気つけてぇ!', None, None, None)  

おわりに

今回はspaCyで「~つけて」や「~消して」という要求に応答する解析器を作ってみました。
GiNZAの解析した依存構造を使えばこのような複雑な処理も比較的に簡単に書けます。

🦝 < 電気つけて

🐭 < 電気代払ってください