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
というクラスに機能のほとんどを実装します。
Analyzer
のanalyze()
メソッドが解析のエントリーポイントになります。
このメソッドに入力文章を渡すと、その文章を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()
メソッドは解析のエントリーポイントです。
引数text
をnlp()
に渡して解析しています。
nlp()
はその解析結果をspacy.tokens.doc.Doc
で返してきます。このdoc
から解析結果を参照することができます。これはトークン列を抽象化したオブジェクトです。
doc.sents
を参照するとsent
(文)を取り出すことができます。
sent
はspacy.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()
は引数a
をAnalyzer
で解析しその結果c
を引数b
と比較します。c
とb
が同一のもじゃなければエラーになりテストが失敗します。
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です |
(・ v ・) | いやー、最近GiNZAでシースー食ってばっかりで |
(^ _ ^) | シースーやなくて自然言語やないかーい |