spaCyで「あなたの~は?」に答えるAIモドキを作る【自然言語処理, Python】
- 作成日: 2021-02-25
- 更新日: 2023-12-24
- カテゴリ: 自然言語処理
spaCyで「あなたの~は?」に答える
私たちが日常的に使う言語は「自然言語」と呼ばれています。
この自然言語を計算機的に処理するのが「自然言語処理」です。
PythonにはspaCy(スパイシー)という自然言語処理ライブラリがあり、これを使うと日本語も解析することができます。
今回はこのspaCyを使って、「あなたの~は?」に答えるAIモドキを作ってみたいと思います。
具体的には↓を見ていきます。
- spaCyとは?
- AIモドキの実行結果
- AIモドキの設計
- 字句解析と構文解析
- AIモドキのソースコード
- ソースコードの解説
spaCyとは?
spaCy(スパイシー)とはPythonで使える自然言語処理ライブラリです。
オープンソースでMITライセンスで利用することができます。
さまざまな言語の学習済み統計モデルを搭載しており、それらの統計モデルを使った解析を簡単に行うことができます。
今回は日本語の解析にGiNZA(ギンザ)という統計モデルを使います。
これをspaCyからロードすると、日本語の解析をGiNZAで行うことができます。
GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリで、spaCyからはそのモデルを使うことができます。
こちらもオープンソース、MITライセンスで利用することができます。
AIモドキの実行結果
今回作るAIモドキは単体テストで動作検証しています。
AIモドキのソースコードをsample.py
などに保存し、コマンドラインからpython -m unittest sample
と実行すると、テストを実行できます。
テストは実行すると↓のような結果になります。
.
----------------------------------------------------------------------
Ran 1 test in 1.594s
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('名前と体重を教えて', '私の体重は100キロです') # 並列なケース
self.eq('名前を教えてよ', '私の名前は花子です')
self.eq('名前を教えてくれる?', '私の名前は花子です')
self.eq('名前を教えろ', 'わかりません') # 非対応。「教えろ」が1単語になる
self.eq('名前を教えてくれなくていいよ', '私の名前は花子です') # 否定形は非対応
self.eq('名前を教えてくれないで', '私の名前は花子です') # 否定形は非対応
# 「あなたの~は?」のケース
self.eq('あなたの名前は?', '私の名前は花子です')
self.eq('名前は?', '私の名前は花子です')
self.eq('あなたの体重は?', '私の体重は100キロです')
self.eq('あなたの身長は?', '私の身長は100センチです')
self.eq('あなたの視力は?', 'わかりません') # 非対応。単語が辞書にない
↑のテストケースを見ると、「教えてよ名前を」でAIモドキが「私の名前は花子です」と返答しているのがわかります。
それから「あなたの身長は?」という問いかけに「私の身長は100センチです」と返答しているのがわかります。
AIモドキの設計
今回作るAIモドキの設計についてです。
繰り返しになりますが、AIモドキの動作検証は単体テストで行います。
これはPythonの標準ライブラリでunittest
を使います。
スクリプトのメインとなる処理はAI
クラスに実装します。
AI
クラスのanalyze()
というメソッドに文章を渡すと、analyze()
はその文章を解析して返答となる文字列を返します。
analyze()
は内部で引数のテキストをspaCyで解析して、トークン列に変換します。
このトークン列を文(sent
)ごとにfor
文で回して解析します。
文の解析ではトークン列を走査して、「は + ?」と「教え + て」のトークンの並びがないかチェックします。
それらの並びがトークン列の中に見つかったら、処理を分岐します。
「は + ?」の「は」に係っている単語を調べて、その単語から返答を生成したり、「教え + て」の「教え」に係っている単語を調べて、同様のことをします。
基本的な設計は↑のようになります。
字句解析と構文解析
spaCyは文章を解析するとトークン列に変換します。
内部的にはこれは「字句解析」と「構文解析」の結果です。
字句解析とは、文を単語の列に変換する解析のことを言います。
たとえば「鳥が歩く」だったら「鳥 / が / 歩く」という単語の列に変換します。
この時、単語にはその単語が持つ情報を保存します。
これは単語の読み方だったり単語の品詞だったりといった情報です。
この字句解析の結果は、その後の工程の解析で使われます。
構文解析は、単語間の依存関係を解析する解析です。
依存関係とは、どの単語がどの単語に係(かか)っているかという関係です。
たとえば「鳥が歩く」という文は↓のような依存構造になります。
鳥 NOUN ═╗<╗ iobj
が ADP <╝ ║ case
歩く VERB ═══╝ ROOT
↑の構造を見ると、「歩く」という単語が「鳥」に係っているのがわかります。
このようにどの単語がどの単語に係っているかを解析することで、文の構造を明らかにし、その後の解析を行いやすくするのが構文解析です。
AIモドキのソースコード
今回作成したAIモドキのソースコードは↓になります。
繰り返しになりますが、このソースコードを実行したい場合はソースコードをsample.py
などに保存し、コマンドラインからpython -m unittest sample
を実行してください。
"""
「あなたの~は?」に答えるAIモドキを作る
License: MIT
Created at: 2021/02/06
"""
import spacy
import unittest
# import deplacy
# GiNZAのモデルをロード
nlp = spacy.load('ja_ginza')
# deplacy.render(nlp('鳥が歩く'))
class AI:
def analyze(self, text):
"""
引数textを解析して返答を生成する
"""
doc = nlp(text) # spaCyで解析する
# deplacy.render(doc)
for sent in doc.sents: # 文を取り出す
result = self.analyze_sent(sent) # 文ごとに解析する
if result:
return result # 解析に成功した
return None # 解析に失敗した
def analyze_sent(self, sent):
"""
引数sentを解析する
"""
i = 0
while i < len(sent) - 1:
t1 = sent[i] # 1つ目のトークン
t2 = sent[i + 1] # 2つ目のトークン
i += 1
# 「は + ?」の組み合わせにヒットしたら
if (t1.text == 'は' and t1.dep_ == 'case') and t2.text == '?':
# t1の親トークンを解析する
result = self.analyze_target(t1.head)
if result:
return result
# 「教え + て」の組み合わせにヒットしたら
elif t1.text == '教え' and (t2.text == 'て' and t2.dep_ == 'mark'):
# t1を解析する
result = self.analyze_osiete(t1)
if result:
return result
return 'わかりません'
def analyze_osiete(self, tok):
"""
引数tokを解析する(教えて系の解析)
"""
lefts = list(tok.lefts) # 左の子要素を参照する
if len(lefts):
target = lefts[0]
result = self.analyze_target(target)
if result:
return result # 解析に成功
rights = list(tok.rights) # 右の子要素を解析する
if len(rights):
target = rights[0]
result = self.analyze_target(target)
if result:
return result # 解析に成功
if tok != tok.head: # 親の要素を解析する
target = tok.head
result = self.analyze_target(target)
if result:
return result # 解析に成功
return None
def analyze_target(self, tok):
"""
引数tokを解析して返答を生成する
"""
if tok.text == '名前':
return '私の名前は花子です'
elif tok.text == '体重':
return '私の体重は100キロです'
elif tok.text == '身長':
return '私の身長は100センチです'
else:
return None
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('名前と体重を教えて', '私の体重は100キロです') # 並列なケース
self.eq('名前を教えてよ', '私の名前は花子です')
self.eq('名前を教えてくれる?', '私の名前は花子です')
self.eq('名前を教えろ', 'わかりません') # 非対応。「教えろ」が1単語になる
self.eq('名前を教えてくれなくていいよ', '私の名前は花子です') # 否定形は非対応
self.eq('名前を教えてくれないで', '私の名前は花子です') # 否定形は非対応
# 「あなたの~は?」のケース
self.eq('あなたの名前は?', '私の名前は花子です')
self.eq('名前は?', '私の名前は花子です')
self.eq('あなたの体重は?', '私の体重は100キロです')
self.eq('あなたの身長は?', '私の身長は100センチです')
self.eq('あなたの視力は?', 'わかりません') # 非対応。単語が辞書にない
ソースコードの解説
簡単ですがソースコードの解説になります。
必要モジュールのインポート
スクリプトの最初の方で必要モジュールをインポートしておきます。
今回は自然言語処理にspaCyを使うのでspacy
をインポートします。
こちらはpip
でインストールが必要です。
単体テスト用にunittest
もインポートしておきます。
こちらは標準ライブラリです。
deplacy
はspaCyのトークン列の依存構造を視覚的にわかりやすく出力するモジュールです。
スクリプトの改造などで使う場合はコメントアウトを外します。
こちらもpip
でインストールが必要です。
import spacy
import unittest
# import deplacy
GiNZAのモデルをロード
グローバル領域でspaCyにGiNZAをロードします。
spacy.load()
にモデル名を指定すると、そのモデルを読み込むことができます。
GiNZAの場合はja_ginza
を指定します。
spacy.load()
はja_ginza
を読み込んだ場合はginza.Japanese
を返してきます。
この返り値には慣例的にnlp
と命名します。
# GiNZAのモデルをロード
nlp = spacy.load('ja_ginza')
AIクラスの作成
今回のほとんどの処理はAI
クラスに実装します。
メソッドについては後述します。
class AI:
...
analyze()メソッドで文章を解析する
analyze()
メソッドは解析のエントリーポイントとなるメソッドです。
このメソッドに文章を渡すと、その文章が解析されて返答が生成されます。
内部的には引数text
をnlp()
で解析しています。
nlp()
の返り値はdoc
で、これはspacy.tokens.doc.Doc
です。
このdoc
はトークン列を抽象化したオブジェクトで、doc
を参照することでspaCyの解析結果を参照することができます。
doc.sents
を参照すると、文を取り出すことができます。
sent
はspacy.tokens.span.Span
になります。
これは例えば「今日は笑った。明日も笑う。」という文章であれば「今日は笑った。」「明日も笑う。」がそれぞれ1つずつの文(sent
)になります。
doc.sents
をfor
文で回して文をanalyze_sent()
で1つずつ解析します。
この結果があればreturn
します。
解析に失敗した場合はNone
を返します。
def analyze(self, text):
"""
引数textを解析して返答を生成する
"""
doc = nlp(text) # spaCyで解析する
# deplacy.render(doc)
for sent in doc.sents: # 文を取り出す
result = self.analyze_sent(sent) # 文ごとに解析する
if result:
return result # 解析に成功した
return None # 解析に失敗した
analyze_sent()で文を解析する
analyze_sent()
は引数sent
を解析します。
内部的にはsent
をwhile
文で回しています。
走査方法がちょっと変わっていて、添え字を基点にして1つ目と2つ目のトークンを2つ取り出しています(t1
とt2
)。
これはトークンの並びをチェックするためです。
sent
を添え字で参照すると、その結果はspacy.tokens.token.Token
で返ってきます。
これはトークンで、字句解析と構文解析の結果の単語の情報が保存されています。
t1
とt2
をチェックして、「は + ?」の並びだったらanalyze_target()
にt1.head
を渡します。
トークンのtext
属性には元の文章のそのままの表記が保存されています。
dep_
属性には依存構造上におけるトークンの役割が保存されています。case
は格表示でmark
は接続詞になります。
head
属性には依存構造上における親のトークンが保存されています。t1.head
を渡している理由ですが、これは文章の依存構造をdeplacy
で出力するとわかると思います。
トークンが「教え + て」の並びだったらanalyze_osiete()
にt1
を渡します。
いずれも結果が真であればその結果をreturn
します。
解析に失敗した場合は「わかりません」という文字列を返します。
def analyze_sent(self, sent):
"""
引数sentを解析する
"""
i = 0
while i < len(sent) - 1:
t1 = sent[i] # 1つ目のトークン
t2 = sent[i + 1] # 2つ目のトークン
i += 1
# 「は + ?」の組み合わせにヒットしたら
if (t1.text == 'は' and t1.dep_ == 'case') and t2.text == '?':
# t1の親トークンを解析する
result = self.analyze_target(t1.head)
if result:
return result
# 「教え + て」の組み合わせにヒットしたら
elif t1.text == '教え' and (t2.text == 'て' and t2.dep_ == 'mark'):
# t1を解析する
result = self.analyze_osiete(t1)
if result:
return result
return 'わかりません'
analyze_target()で返答を生成する
analyze_target()
は引数tok
から返答を生成します。
内容的にはトークンのtext
属性を参照して、処理を分岐して返答を生成してるだけです。
対応していないtext
の場合はNone
を返します。
def analyze_target(self, tok):
"""
引数tokを解析して返答を生成する
"""
if tok.text == '名前':
return '私の名前は花子です'
elif tok.text == '体重':
return '私の体重は100キロです'
elif tok.text == '身長':
return '私の身長は100センチです'
else:
return None
analyze_osiete()で「~教えて」を解析する
analyze_osiete()
は引数tok
を解析します。
これは「~教えて」という文の形のtok
です。
内容的にはトークンのlefts
とrights
, それからhead
を参照して、返答が生成できるか試行しています。
lefts
には依存構造上における左側の子要素が保存されています。
rights
には右側の子要素です。
lefts
やrights
に子要素があって、その1つ目の子で返答が生成できればそのままreturn
します。
head
についても同様です。
解析できなかった場合はNone
を返します。
def analyze_osiete(self, tok):
"""
引数tokを解析する(教えて系の解析)
"""
lefts = list(tok.lefts) # 左の子要素を参照する
if len(lefts):
target = lefts[0]
result = self.analyze_target(target)
if result:
return result # 解析に成功
rights = list(tok.rights) # 右の子要素を解析する
if len(rights):
target = rights[0]
result = self.analyze_target(target)
if result:
return result # 解析に成功
if tok != tok.head: # 親の要素を解析する
target = tok.head
result = self.analyze_target(target)
if result:
return result # 解析に成功
return None
単体テストを書く
単体テストを書きます。
unittest.TestCase
を継承したクラスを作り、test_
で始まるメソッドを定義します。
このtest_
で始まるメソッドがテスト実行時に実行されます。
テスト内容的にはまだアイデア次第で追加できそうですが、暇な人は追加してみてください。たぶんバグとか出るかもしれません。
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('名前と体重を教えて', '私の体重は100キロです') # 並列なケース
self.eq('名前を教えてよ', '私の名前は花子です')
self.eq('名前を教えてくれる?', '私の名前は花子です')
self.eq('名前を教えろ', 'わかりません') # 非対応。「教えろ」が1単語になる
self.eq('名前を教えてくれなくていいよ', '私の名前は花子です') # 否定形は非対応
self.eq('名前を教えてくれないで', '私の名前は花子です') # 否定形は非対応
# 「あなたの~は?」のケース
self.eq('あなたの名前は?', '私の名前は花子です')
self.eq('名前は?', '私の名前は花子です')
self.eq('あなたの体重は?', '私の体重は100キロです')
self.eq('あなたの身長は?', '私の身長は100センチです')
self.eq('あなたの視力は?', 'わかりません') # 非対応。単語が辞書にない
おわりに
今回はspaCyを使って「あなたの~は?」という質問に返答するAIモドキを作りました。
これが発展すると質問に答えるルールベースのAIが作れそうですね。
興味ある方の参考になれば幸いです。
🦝 < 質問は会話の潤滑油
🐭 < あなたの名前は?