ユーニックス総合研究所

  • home
  • archives
  • spacy-boke

spaCyでボケにツッコミを入れる【自然言語処理, Python】

spaCyでボケにツッコミを入れる

私たちの話し言葉、日本語や英語などは「自然言語」と呼ばれます。
この自然言語を計算機で解析するのが「自然言語処理」です。

Pythonには自然言語処理を行えるライブラリspaCy(スパイシー)があります。spaCyを使うと簡単に日本語の文章を自然言語処理で解析することが可能です。
今回はこのspaCyを使って、ボケに対してツッコミを入れるスクリプトを作ってみたいと思います。

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

  • spaCyとは?
  • ボケとツッコミとは?
  • スクリプトの実行結果
  • スクリプトの設計
  • スクリプトのソースコード
  • ソースコードの解説

spaCyとは?

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

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

spaCyというフレームワークからGiNZAという日本語の解析器を使って自然言語を解析し、その結果をspaCyの枠組みの中で使えるという感じになります。

ボケとツッコミとは?

「ボケ」とは「とぼけ役」のことで、冗談を言ったり相手を笑わしたりすることを指す役割です。
「ツッコミ」とはボケに対して間違いを訂正したり笑いどころを観客にわかりやすく掲示する役割です。
漫才ではこのボケとツッコミが交互に言葉を発して観客を笑わせます。

今回のスクリプトのボケとは「常識とは違うことを言う」ことを指します。
つまりある単語には常識がありますが、その常識からずれたことを言った場合はそれをボケと見なします。
そしてスクリプトはボケを検知したら「なんでやねん」というツッコミを表す文章を生成します。

ある単語の常識とは例えば「北極」についてです。北極は「寒い」のが常識なので、「北極は寒い」は常識的な文章です。
しかし「北極は暑い」という文章は常識とは外れているので、これはスクリプトはボケだと見なします。
他にも「ハワイは暑い」は常識的な文章ですが、「ハワイは寒い」は非常識な文章なので、これもボケと見なします。

「単語の持つ常識」はつまり単語の持つ「意味」になります。
今回のスクリプトは、この単語の意味を意味データーベースという辞書に保存しておきます。
そして解析時にこの意味データベースを参照してツッコミを入れます。

たとえば北極の「気温」の常識は「寒い」のが常識です。
これを単語の持つ意味として意味データベースに保存します。

スクリプトの実行結果

今回制作したスクリプトですが、動作検証は単体テストで行っています。
ですので出力はちょっと味気ないものになっています。
↓がスクリプトの単体テストを実行したときの出力結果です。

.  
----------------------------------------------------------------------  
Ran 1 test in 1.520s  

OK  

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

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

    def test_analyze(self):  
        # 解析に失敗するケース  
        self.eq('暑いなぁ', None)  # 固有名詞が無い  
        self.eq('寒い寒い', None)  # 同上  
        self.eq('ハワイは暖かいな', None)  # サポートしていない形容詞  
        self.eq('北極は凍えそうだ', None)  # 同上  
        self.eq('寒いな北極は', None)  # 固有名詞からは親しか辿ってないので失敗  
        self.eq('暑いなハワイは', None)  # 同上  

        # 解析に成功するケース  
        self.eq('ハワイは暑いな', 'そうですね')  
        self.eq('いやー、ハワイは本当に寒いな', 'なんでやねん')  
        self.eq('ねぇねぇ、北極って寒いよねぇ', 'そうですね')  
        self.eq('信じられないほど北極は暑いわ', 'なんでやねん')  

後述のソースコード全体をsample.pyなどに保存し、コマンドラインからpython -m unittest sampleと実行するとテストケースが実行されます。
↑のテストを見ると「ハワイは暑いな」という入力に対しては「そうですね」と相づちを打っていますが、「いやー、ハワイは本当に寒いな」という入力に対しては「なんでやねん」とツッコミを入れているのがわかります。
他にも「信じられないほど北極は暑いわ」という入力にも「なんでやねん」と返しています。

スクリプトの設計

今回作成するスクリプトはAnalyzerというクラスに機能のほとんどを実装します。
Analyzeranalyze()メソッドが解析のエントリーポイントになります。
このメソッドに入力文章を渡すと、その文章をspaCyで解析してドキュメントにします。
そして文を1つずつ取り出して1文ずつ解析を行います。

文からトークンを取り出して、そのトークンが固有名詞かどうかチェックします。
固有名詞だったら解析を分岐します。
気温に関する解析では依存構造から形容詞のトークンを探します。
形容詞があったらそれが「寒い」あるいは「暑い」のいずれかであるかチェックします。

固有名詞をキーにして意味データベースを参照し、その言葉の意味を求めます。
「気温」についてその言葉の意味、つまり常識を参照し、それが形容詞の値と違っていたら「常識と違っている」と判断します。
常識と違っている場合は「なんでやねん」という返答を生成します。

基本的な設計は↑のようになります。
この設計に汎用性があるかどうかはちょっと不明です。テストの量も少ないので、あまり汎用性は無いかもしれません。
つまり単語が変わったり文法が増えたりしたら上手く動作しないかもしれません。

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

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

"""  
ボケに対してツッコミを入れる解析器を作る  

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


# spaCyにGiNZAをロード  
nlp = spacy.load('ja_ginza')  


class Analyzer:  
    """  
    ボケに対してツッコミを入れる解析器  
    """  
    def __init__(self):  
        # 単語の意味が保存された意味データベース  
        self.imi_db = {  
            '北極': {  
                '気温': '寒い',  # 北極は寒いのが常識  
            },  
            'ハワイ': {  
                '気温': '暑い',  # ハワイは暑いのが常識  
            },  
        }  

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

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

        return None  

    def analyze_sent(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        """  
        for tok in sent:  
            if tok.pos_ != 'PROPN':  # 固有名詞以外はスルー  
                continue  

            # 固有名詞が見つかったらそれをキーに解析開始  
            result = self.analyze_kion(tok)  
            if result:  
                return result  

        return None  

    def analyze_kion(self, tok):  
        """  
        引数tokを気温について解析する  
        """  
        adj = self.find_head(tok.head, ('ADJ', ))  # 親を辿り形容詞を探す  
        if adj is None:  
            return None  # 見つからなかった  

        if adj.lemma_ not in ('寒い', '暑い'):  
            return None  # 非対応の形容詞  

        imi = self.find_imi(tok.text)  # 意味を参照する  
        if imi is None:  
            return None  # 見つからなかった  

        imi_val = imi['気温']  # 単語の気温について参照する  

        if adj.lemma_ != imi_val:  # 形容詞の原型と意味値が違ったら  
            return 'なんでやねん'  # ツッコむ  
        return 'そうですね'  # それ以外は適当な相づち  

    def find_imi(self, key):  
        """  
        意味データベースから引数keyの意味を探す  
        """  
        if key in self.imi_db.keys():  
            return self.imi_db[key]  
        return None  

    def find_head(self, tok, poses_):  
        """  
        引数tokの親(head)を再帰的にたどり、poses_にマッチするpos_(品詞)を持つトークンを返す  
        """  
        if tok.pos_ in poses_:  
            return tok  
        if tok.head == tok:  
            return None  

        return self.find_head(tok.head, poses_)  


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

    def test_analyze(self):  
        # 解析に失敗するケース  
        self.eq('暑いなぁ', None)  # 固有名詞が無い  
        self.eq('寒い寒い', None)  # 同上  
        self.eq('ハワイは暖かいな', None)  # サポートしていない形容詞  
        self.eq('北極は凍えそうだ', None)  # 同上  
        self.eq('寒いな北極は', None)  # 固有名詞からは親しか辿ってないので失敗  
        self.eq('暑いなハワイは', None)  # 同上  

        # 解析に成功するケース  
        self.eq('ハワイは暑いな', 'そうですね')  
        self.eq('いやー、ハワイは本当に寒いな', 'なんでやねん')  
        self.eq('ねぇねぇ、北極って寒いよねぇ', 'そうですね')  
        self.eq('信じられないほど北極は暑いわ', 'なんでやねん')  

ソースコードの解説

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

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

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

import spacy  
import unittest  

spacyは外部ライブラリなのでpipなどで↓のようにインストールが必要です。

$ pip install -U spacy  

spaCyにGiNZAをロードする

今回は日本語の解析にGiNZAを使います。
spacy.load()ja_ginzaを渡すとGiNZAのモデルをロードすることができます。
その結果はginza.Japaneseで返ってきますが、これには慣例的にnlpと命名します。

# spaCyにGiNZAをロード  
nlp = spacy.load('ja_ginza')  

GiNZAは↓のようにpipでインストールすることができます。

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

Analyzerクラスの作成

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

imi_dbという辞書が意味データベースを表すデータです。
解析時にこの辞書を参照することで、スクリプトは単語の持つ常識を把握します。
理屈ではこの辞書を充実させれば対応する単語は増やせますが、手作業でやるとなると大変です。
1日5単語の登録でも1人でやる場合は1年で1,825語しか登録できません。

🦝 < ひえー辞書作るのって大変だね

class Analyzer:  
    """  
    ボケに対してツッコミを入れる解析器  
    """  
    def __init__(self):  
        # 単語の意味が保存された意味データベース  
        self.imi_db = {  
            '北極': {  
                '気温': '寒い',  # 北極は寒いのが常識  
            },  
            'ハワイ': {  
                '気温': '暑い',  # ハワイは暑いのが常識  
            },  
        }  

analyze()で入力文章を解析する

analyze()メソッドは解析のエントリーポイントです。
引数textnlp()に渡して解析しています。
nlp()はその解析結果をspacy.tokens.doc.Docで返してきます。このdocから解析結果を参照することができます。これはトークン列を抽象化したオブジェクトです。

doc.sentsを参照するとsent(文)を取り出すことができます。
sentspacy.tokens.span.Spanです。
この文をanalyze_sent()に渡して1文ずつ解析します。
その結果が真であればそのままreturnします。

解析に失敗したらNoneを返します。

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

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

        return None  

analyze_sent()で文を解析

analyze_sent()は引数sentを解析して結果を返します。
sentはトークン列を抽象化したオブジェクトなのでfor文で回すことができます。
for文で回してトークンを1つずつ取り出します。
このトークンのpos_属性はPROPNかどうかチェックします。pos_属性は単語の持つ品詞のことです。そしてPROPNは固有名詞を表します。
固有名詞以外のトークンはcontinueでスルーします。

固有名詞のトークンが見つかったらそのトークンをキーにしてanalyze_kion()で解析を分岐します。
その結果が真であればそのままreturnします。

解析に失敗したらNoneを返します。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析して返答を生成する  
        """  
        for tok in sent:  
            if tok.pos_ != 'PROPN':  # 固有名詞以外はスルー  
                continue  

            # 固有名詞が見つかったらそれをキーに解析開始  
            result = self.analyze_kion(tok)  
            if result:  
                return result  

        return None  

analyze_kion()で気温の常識を調べる

analyze_kion()はコア機能です。
このメソッドでは引数tokをキーにした解析を行います。

まずfind_head()tokの親(head)を再帰的に検索し、ADJ(形容詞)のトークンを探します。
親と言うのは依存構造上における親トークンのことです。
形容詞のトークンが見つかったらそれの原形(lemma_)が「寒い」または「暑い」のいずれかかどうかチェックします。
原形とは単語の原形のことです。これを参照することで表記の違いに関わらず単語を判別できます。

次にtok.textをキーにfind_imi()で単語の意味を検索します。
トークンのtext属性には元の文章のそのままの表記の文字列が保存されています。

単語の意味が見つかったら意味の「気温」の項目を参照します。
そしてそれが形容詞(adj)の原形(lemma_)と一致していなかったら「なんでやねん」と返し、一致していたら「そうですね」と適当な相づちを返します。

    def analyze_kion(self, tok):  
        """  
        引数tokを気温について解析する  
        """  
        adj = self.find_head(tok.head, ('ADJ', ))  # 親を辿り形容詞を探す  
        if adj is None:  
            return None  # 見つからなかった  

        if adj.lemma_ not in ('寒い', '暑い'):  
            return None  # 非対応の形容詞  

        imi = self.find_imi(tok.text)  # 意味を参照する  
        if imi is None:  
            return None  # 見つからなかった  

        imi_val = imi['気温']  # 単語の気温について参照する  

        if adj.lemma_ != imi_val:  # 形容詞の原型と意味値が違ったら  
            return 'なんでやねん'  # ツッコむ  
        return 'そうですね'  # それ以外は適当な相づち  

find_head()で親を辿る

find_head()は引数tokの親を辿ります。
そしてposes_にマッチするpos_を持つトークンがあったらそのトークンを返します。
内部ではfind_head()を再帰的に呼び出しています。spaCyの構築する文章の依存構造は木構造なので、このように再帰処理と相性が良いです。
またheadがトークン自身の場合(tok.head == tok)は、親がないことを表しているので、これを参照して再帰の終了条件にしています。
トークンが見つからなかったらNoneを返します。

    def find_head(self, tok, poses_):  
        """  
        引数tokの親(head)を再帰的にたどり、poses_にマッチするpos_(品詞)を持つトークンを返す  
        """  
        if tok.pos_ in poses_:  
            return tok  
        if tok.head == tok:  
            return None  

        return self.find_head(tok.head, poses_)  

find_imi()で単語の意味を検索

find_imi()は引数keyをキーにして意味データベースを参照します。
意味が見つかったらその意味を返し、見つからなかったらNoneを返します。

    def find_imi(self, key):  
        """  
        意味データベースから引数keyの意味を探す  
        """  
        if key in self.imi_db.keys():  
            return self.imi_db[key]  
        return None  

テストを書く

テストを書きます。
unittest.TestCaseを継承したクラスを作り、test_で始まるメソッドを定義します。このメソッドがテスト時に実行されます。
eq()は引数aAnalyzerで解析しその結果cを引数bと比較します。cbが同一のもじゃなければエラーになりテストが失敗します。

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

    def test_analyze(self):  
        # 解析に失敗するケース  
        self.eq('暑いなぁ', None)  # 固有名詞が無い  
        self.eq('寒い寒い', None)  # 同上  
        self.eq('ハワイは暖かいな', None)  # サポートしていない形容詞  
        self.eq('北極は凍えそうだ', None)  # 同上  
        self.eq('寒いな北極は', None)  # 固有名詞からは親しか辿ってないので失敗  
        self.eq('暑いなハワイは', None)  # 同上  

        # 解析に成功するケース  
        self.eq('ハワイは暑いな', 'そうですね')  
        self.eq('いやー、ハワイは本当に寒いな', 'なんでやねん')  
        self.eq('ねぇねぇ、北極って寒いよねぇ', 'そうですね')  
        self.eq('信じられないほど北極は暑いわ', 'なんでやねん')  

おわりに

今回はspaCyでボケにツッコミを入れてみました。
漫才AIの登場が待ち焦がれる昨今ですが、いつごろ実現するのでしょうか。
AIに人間の笑いを理解させるのはかなり難しいと思いますが、研究してる人いるんでしょうか?

🦝 < どうも~、spaCyです

🐭 < いやー、最近GiNZAでシースー食ってばっかりで

🦝 < シースーやなくて自然言語やないかーい