spaCyで「君は~?」に返答するAIもどきを作る【自然言語処理, Python】
目次
spaCyで「君は~?」に返答する
私たちが使っている言語、これは日本語ですが、カテゴリ的には「自然言語」に入っています。
自然言語をプログラムを使ってあーでもないこーでもないと解析するのが「自然言語処理」です。
Pythonの外部ライブラリにspaCy(スパイシー)がありますが、このspaCyを使うと日本語の文章を自然言語処理で解析することができます。
今回はspaCyを使って「君は~?」とか「あなたは~?」という質問に返答するAIもどきを作ってみたいと思います。
ルールベースのAIもどきで、非常に低機能です。AIと呼んでいいのか筆者もよくわかりません。
具体的には↓を見ていきます。
spaCyとは?
プログラムの実行結果
プログラムの設計
プログラムのソースコード
ソースコードの解説
spaCyとは?
spaCy(スパイシー)はPythonで動作する自然言語処理のライブラリです。
オープンソース、MITライセンスで最近はやりのライブラリの1つです。
さまざまな言語の学習済み統計モデルを搭載しており、簡単に自然言語を学習することができます。
spaCyは最近まで日本語の処理が弱かったのですが、GiNZAの登場によってその機能が強化されました。
GiNZAはリクルート、それから国立国語研究所が共同で作成したライブラリで、オープンソースで開発されています。
MITライセンスなので商用にも利用することができます。
spaCyでGiNZAの統計モデルをロードすることによって、GiNZAを利用した形態素解析や構文解析を行うことができます。
つまりspaCyというフレームワークからGiNZAという機能を使うという感じになります。
プログラムの実行結果
今回作成するプログラムの動作検証は単体テストで行っています。
単体テストは今回のプログラムのソースコードをsample.py
などに保存してコマンドラインからpython -m unittest sample
と実行するとテストを実行することができます。
テストを実行すると↓のような出力になります。
. ---------------------------------------------------------------------- Ran 1 test in 1.170s OK
↑の表示はテストが全て成功したことを示しています。「OK」って書いてありますね。
実行したテストケースは↓になります。
class Test(unittest.TestCase): def eq(self, a, b): ai = AI() c = ai.analyze(a) # 引数aを解析する self.assertEqual(c, b) def test_analyze(self): # 素っ気ない返答 self.eq('君って猫じゃん', 'そうですね') self.eq('本当に君って犬だよね', 'そうですね') self.eq('君は何者?', '私はAIです') self.eq('ところで君は本当に何者?', '私はAIです') self.eq('君は人間?', '私はAIです') self.eq('君は男?', '私はAIです') self.eq('君は民主主義者?', '私はAIです') # 変な回答 self.eq('君はベジタリアン?', '私はAIです') # 変な回答 # ルールベースの返答 self.eq('あなたは猫か?', '私は猫ではありません') self.eq('あなたは猫じゃないよね?', '私は猫ではありません') self.eq('あなたが猫なの?', '私は猫ではありません') self.eq('あなたって猫なの?', '私は猫ではありません') self.eq('あなたは本物の猫か?', '私は猫ではありません') self.eq('あなたは犬か?', '私は犬ではありません') self.eq('あなたは鳥か?', '私は鳥ではありません') self.eq('君は天才か?', 'そうかもしれません') self.eq('君は秀才か?', 'そうかもしれません')
↑のテストケースを見ると「君って猫じゃん」という問いかけには「そうですね」と返しています。
「君は何者?」という問いかけには「私はAIです」、「あなたは猫か?」という問いかけには「私は猫ではありません」と返答しています。
このように今回作成するプログラムは、↑のような質問に返答します。
プログラムの設計
今回作成するプログラムの設計についてです。
繰り返しになってしまいますが、プログラムの動作検証は単体テストで行います。
これはPythonの標準ライブラリであるunittest
を使います。
プログラムの主な処理は「AI
」というクラスに実装します。
(^ _ ^) | AIもどきなのに名前詐欺だ |
(・ v ・) | AIModokiのほうがよかった? |
AI
クラスのanalyze()
メソッドに質問となる文章を渡すと、analyze()
は返り値として返答を表す文字列を返します。
analyze()
内では質問文をspaCyで解析し、トークン列にします。そしてそのトークン列を操作して、キーとなるトークンの並びを探します。
キーとなるトークンの並びは「代名詞 + 設置詞」です。これは「君 + は」の部分です。
このキーを見つけたらそこから解析を分岐します。
見つけた代名詞をキーにして解析します。
文末に「?」があるか調べ、「猫」や「天才」などの単語が含まれているか調べます。
それらが含まれていたらさらに処理を分岐し、それに対応した回答文を返します。
プログラムのソースコード
今回作成するプログラムのソースコードは↓になります。
""" 質問に返答するルールベースのAIもどき License: MIT Created at: 2021/01/30 """ import spacy # 文章解析のためのモジュール import unittest # 単体テスト用のモジュール # 日本語の解析のためにGiNZAのモデルをロード nlp = spacy.load('ja_ginza') class AI: """ 質問に返答するAIもどき """ def analyze(self, sentence): """ 文章sentenceを解析して返答を生成する """ doc = nlp(sentence) # sentenceを解析してdocに toks = list(doc) # doc(トークン列)をリストに i = 0 # トークン列内から代名詞と設置詞の組み合わせを探す while i < len(toks) - 1: t1 = toks[i] # 1つ目のトークン t2 = toks[i + 1] # 2つ目のトークン i += 2 if t1.pos_ == 'PRON' and t2.pos_ == 'ADP': # 代名詞 + 設置詞の組み合わせを見つけた # 「君は猫?」の「君 + は」の部分 # 代名詞をキーにして解析を続行する result = self.analyze_pron(t1) if result: return result # 解析できたので結果を返す return 'そうですね' # 解析できなかったら適当な返事をしておく def is_neko(self, tok): """ 引数tokが「猫」かその類ならTrueを返す """ return tok.text in ('猫', '犬', '鳥') def is_tensai(self, tok): """ 引数tokが「天才」かその類ならTrueを返す """ return tok.text in ('天才', '秀才') def analyze_pron(self, pron): """ 文章中の代名詞(PRON)をキーにして解析を行う """ parent = pron.head # PRONの依存構造上における親を取得 hatena = self.find_hatena(parent) # ?があるか if hatena is None: return None # 「?がない=疑問文じゃない」ならreturn if self.is_neko(parent): # 猫の類なら return f'私は{parent.text}ではありません' # 否定した返答を返す if self.is_tensai(parent): # 天才の類なら return 'そうかもしれません' # 肯定の返答を返す return '私はAIです' # その他の問いかけには無能な返事をしておく def find_hatena(self, tok): """ tokに「?」が含まれるか再帰的に検索する 含まれていたらそのトークンを返す """ if tok.text in ('?', '?'): return tok # 見つかった for child in tok.children: # 依存構造上における子供を参照する result = self.find_hatena(child) # 再帰的に検索 if result: return result # 見つかった return None # 見つからなかった class Test(unittest.TestCase): def eq(self, a, b): ai = AI() c = ai.analyze(a) # 引数aを解析する self.assertEqual(c, b) def test_analyze(self): # 素っ気ない返答 self.eq('君って猫じゃん', 'そうですね') self.eq('本当に君って犬だよね', 'そうですね') self.eq('君は何者?', '私はAIです') self.eq('ところで君は本当に何者?', '私はAIです') self.eq('君は人間?', '私はAIです') self.eq('君は男?', '私はAIです') self.eq('君は民主主義者?', '私はAIです') # 変な回答 self.eq('君はベジタリアン?', '私はAIです') # 変な回答 # ルールベースの返答 self.eq('あなたは猫か?', '私は猫ではありません') self.eq('あなたは猫じゃないよね?', '私は猫ではありません') self.eq('あなたが猫なの?', '私は猫ではありません') self.eq('あなたって猫なの?', '私は猫ではありません') self.eq('あなたは本物の猫か?', '私は猫ではありません') self.eq('あなたは犬か?', '私は犬ではありません') self.eq('あなたは鳥か?', '私は鳥ではありません') self.eq('君は天才か?', 'そうかもしれません') self.eq('君は秀才か?', 'そうかもしれません')
ソースコードの解説
簡単ですがソースコードの解説になります。
必要モジュールのインポート
今回は自然言語処理でspaCyを使うのでspacy
をインポートしておきます。
それから単体テスト用にunittest
もインポートしておきます。
import spacy # 文章解析のためのモジュール import unittest # 単体テスト用のモジュール
GiNZAのモデルをロード
spacy
をインポートしたらグローバル領域でspacy.load()
を実行します。
これはspaCyにモデルをロードするメソッドです。
spacy.load()
にja_ginza
を指定するとGiNZAのモデルをロードすることができます。
ロードに成功すると、GiNZAのモデルを読み込んだ場合はspacy.load()
はginza.Japanese
を返します。
これをnlp
という変数に保存します。この変数の命名は慣例になっています。
# 日本語の解析のためにGiNZAのモデルをロード nlp = spacy.load('ja_ginza')
AIクラスを作る
今回のプログラムの主要な処理はAI
というクラスに実装します。
メソッドについては後述します。
class AI: """ 質問に返答するAIもどき """ ...
analyze()で質問文を解析する
analyze()
メソッドは引数sentence
をspaCyで解析して、返答を生成して返すメソッドです。
内容的には引数sentence
をnlp()
に渡してspaCyで解析します。
その結果はdoc
になります。これはspacy.tokens.doc.Doc
です。
このdoc
はトークン列を抽象化したオブジェクトで、このdoc
を参照することで解析した結果のトークンを参照することができます。
spaCyは内部的には形態素解析と構文解析を行っています。
その解析の結果は単語の列であるトークン列に保存されます。
たとえば形態素解析を行うと「猫が歩く」という文章であればこれは「猫 / が / 歩く」というトークン列に分割されます。
そして「猫」というトークンには「猫」という単語の情報が保存されます。これは単語の読み方(_.reading
)や品詞の種類(pos
, pos_
)などです。
構文解析を行うと形態素解析されたトークン列にさらに依存構造の情報を埋め込みます。
依存構造とは、どの単語がどの単語に係(かか)っているか、という情報です。
たとえば「あなたは猫か?」という文章の「猫」は「あなた」に係っているため、この依存構造を辿れば「猫」と「あなた」を行き来することができます。
依存構造の情報(head
, children
)はトークンに保存されます。
このdoc
をlist()
に渡すとトークン列のリストtoks
に変換します。doc
はジェネレーターを返すのでここでは明示的にリストに変換しています。
あとはwhile
文でこのtoks
を参照します。
このtoks
はspacy.tokens.token.Token
のリストです。
トークンのpos_
属性をチェックして、これがPRON
やADP
だったら処理を分岐します。
pos_
属性にはそのトークンの品詞の種類が保存されています。
これがPRON
だったらそのトークンは代名詞、ADP
だったら設置詞になります。
キーとなるトークンの並び、つまりPRON
とADP
の並びが見つかったらanalyze_pron()
に代名詞のトークンを渡して解析を行います。
この結果がNone
でなければそのままreturn
して終わりです。
解析が失敗した場合、つまりwhile
文が最後まで実行された場合は適当な返答「そうですね」を返しておきます。
def analyze(self, sentence): """ 文章sentenceを解析して返答を生成する """ doc = nlp(sentence) # sentenceを解析してdocに toks = list(doc) # doc(トークン列)をリストに i = 0 # トークン列内から代名詞と設置詞の組み合わせを探す while i < len(toks) - 1: t1 = toks[i] # 1つ目のトークン t2 = toks[i + 1] # 2つ目のトークン i += 2 if t1.pos_ == 'PRON' and t2.pos_ == 'ADP': # 代名詞 + 設置詞の組み合わせを見つけた # 「君は猫?」の「君 + は」の部分 # 代名詞をキーにして解析を続行する result = self.analyze_pron(t1) if result: return result # 解析できたので結果を返す return 'そうですね' # 解析できなかったら適当な返事をしておく
analyze_pron()で代名詞を解析する
analyze_pron()
は引数pron
をキーに解析を行います。
引数pron
は代名詞のトークンです。
トークンの依存構造上における親はトークンの属性head
で参照することができます。
代名詞に係っている親のトークンとはつまりどういうことかと言いますと、例文で解説します。
たとえば「あなたは猫か?」という文章は↓のような依存構造になります。
あなた PRON ═╗<╗ iobj は ADP <╝ ║ case 猫 NOUN ═╗═╝═╗ ROOT か PART <╝ ║ aux ? PUNCT <════╝ punct
↑の場合、「あなたは」の「あなた」が代名詞です。そしてこれの親は「猫」になります。
矢印が「猫」から「あなた」に伸びてますが、これを逆に参照するのがhead
です。
つまり代名詞がわかればその代名詞に係っている単語がわかるということになります。
この単語がわかれば、あとはその単語が猫なのかとか天才なのかとか解析すれば、返答を生成することができるという話です。
この依存構造ツリーはdeplacy
を使うと出力できます。
import deplacy # pipでインストール必要 print(deplacy.render(doc)) # docは自分で用意してください
親のトークンparent
を取得したら、あとはそのparent
をキーにして解析を続行します。
find_hatena()
でparent
の文脈に「?」があるか調べます。
「?」が無ければ質問文が疑問文じゃないと判断し、None
を返します。
is_neko()
でparent
が猫の類か調べます。
猫の類だったら「私は~ではありません」という返答を生成します。
is_tensai()
でparent
が天才の類か調べて、そうだったら「そうかもしれません」という返答を生成します。
上記の処理のどれにもマッチしなかったら私はAIです
という返答を生成します。
def analyze_pron(self, pron): """ 文章中の代名詞(PRON)をキーにして解析を行う """ parent = pron.head # PRONの依存構造上における親を取得 hatena = self.find_hatena(parent) # ?があるか if hatena is None: return None # 「?がない=疑問文じゃない」ならreturn if self.is_neko(parent): # 猫の類なら return f'私は{parent.text}ではありません' # 否定した返答を返す if self.is_tensai(parent): # 天才の類なら return 'そうかもしれません' # 肯定の返答を返す return '私はAIです' # その他の問いかけには無能な返事をしておく
find_hatena()で「?」を探す
find_hatena()
は引数tok
の文脈から「?」があるかどうか調べるメソッドです。
「?」のトークンが見つかったらそのトークンを返します。
tok.text
に「?」が含まれていたらそのトークンを返すようにしています。
トークンのtext
属性には元の文章のそのままの表記の単語が保存されています。
あとはfor
文でtok.children
を走査します。
children
属性にはトークンの依存構造上における子要素が保存されています。
子要素とはつまり、かかっているトークンのことです。
このトークンを参照してfind_hatena()
に渡して再帰呼び出しを行います。
つまりfind_hatena()
はchildren
を再帰的に辿って検索していくメソッドです。
こうすることでトークンの末尾の子要素まですべて検索することができます。
def find_hatena(self, tok): """ tokに「?」が含まれるか再帰的に検索する 含まれていたらそのトークンを返す """ if tok.text in ('?', '?'): return tok # 見つかった for child in tok.children: # 依存構造上における子供を参照する result = self.find_hatena(child) # 再帰的に検索 if result: return result # 見つかった return None # 見つからなかった
is_neko()で猫の類か判定する
is_neko()
は引数tok
が猫の類か調べます。
内容的にはトークンのtext
を参照してるだけです。
def is_neko(self, tok): """ 引数tokが「猫」かその類ならTrueを返す """ return tok.text in ('猫', '犬', '鳥')
is_tensai()で天才の類か判定する
is_tensai()
も同様です。
def is_tensai(self, tok): """ 引数tokが「天才」かその類ならTrueを返す """ return tok.text in ('天才', '秀才')
テストケースを書く
テストを書きます。
unittest
を使った単体テストを書くにはunittest.TestCase
を継承したクラスを作ります。
そしてクラスにtest_
で始まるメソッドを定義します。
このtest_
で始まるメソッドがテスト実行時に実行されます。
eq()
メソッドは引数a
とb
を比較するメソッドです。
内部ではAI
クラスをオブジェクトにしてそのanalyze()
メソッドに引数a
を渡します。
そしてその結果c
をb
と比較します。
比較にはassertEqual()
を使っています。これはTestCase
のメソッドです。
assertEqual()
は第1引数と第2引数が違う場合、エラーを出します。
エラーが出るとテストには失敗します。
class Test(unittest.TestCase): def eq(self, a, b): ai = AI() c = ai.analyze(a) # 引数aを解析する self.assertEqual(c, b) def test_analyze(self): # 素っ気ない返答 self.eq('君って猫じゃん', 'そうですね') self.eq('本当に君って犬だよね', 'そうですね') self.eq('君は何者?', '私はAIです') self.eq('ところで君は本当に何者?', '私はAIです') self.eq('君は人間?', '私はAIです') self.eq('君は男?', '私はAIです') self.eq('君は民主主義者?', '私はAIです') # 変な回答 self.eq('君はベジタリアン?', '私はAIです') # 変な回答 # ルールベースの返答 self.eq('あなたは猫か?', '私は猫ではありません') self.eq('あなたは猫じゃないよね?', '私は猫ではありません') self.eq('あなたが猫なの?', '私は猫ではありません') self.eq('あなたって猫なの?', '私は猫ではありません') self.eq('あなたは本物の猫か?', '私は猫ではありません') self.eq('あなたは犬か?', '私は犬ではありません') self.eq('あなたは鳥か?', '私は鳥ではありません') self.eq('君は天才か?', 'そうかもしれません') self.eq('君は秀才か?', 'そうかもしれません')
おわりに
今回はspaCyを使って「君は~?」などの質問に答えるAIもどきを作ってみました。
spaCyとGiNZAを使うと比較的に簡単にこのようなプログラムを作ることができます。
みなさんもぜひチャレンジしてみてください。
(・ v ・) | 君は猫? |
(^ _ ^) | 吾輩は猫である |