spaCyで名前を記憶する無能AIを作る【自然言語処理, Python】
- 作成日: 2021-04-11
- 更新日: 2023-12-24
- カテゴリ: 自然言語処理
AIを作りたい
AIを作りたいんですが、AIは技術的要件が大きいので中々難しいところがあります。
しかし自然言語処理でユーザーの入力を解析して、その解析に対して出力を出すといったことは、非常に小さいものから制作可能です。
今回はPythonの自然言語処理ライブラリであるspaCy(スパイシー)を使って無能AIを作ってみたいと思います。
具体的には↓を見ていきます。
- 無能AIとは?
- 無能AIの設計
- スクリプトのソースコード
- ソースコードの解説
無能AIとは?
無能AIは無能なので何も出来ません。
唯一出来ることは名前の記憶です。
名前を聞いて、その名前をディスクに保存して記憶します。
無能AIを実行すると↓のような結果になります。
in > あばば
……。
in > こんにちは
こんにちは。あなたの名前は?
in > 知らない
名前を教えてくれないの?
in > 僕は太郎って言います
太郎さん、はじめまして。
in > はい、はじめまして
そうですね。
in > え?
そうですね。
in >
↑の「in >
」で始まる行がユーザーの入力です。
その直後の行がAIの出力です。
↑の出力を見るとなんとなく会話が成立してるように見えますが、これは半分プロレスです。
実際には汎用性は無く、ごくごく一部のケースにしか対応しません。
というのも100行ちょっとのコードではそれが限界だからです。
🦝 < まさに無能
無能AIはユーザーが「こんにちは」と入力すると会話をスタートします。
会話では最初に名前を聞くようにしています。
そして相手が名前を入力したらその名前を記憶します。
「太郎」という名前を記憶した状態の無能AIの挙動は↓のようになります。
in > こんにちは
こんにちは。あなたの名前は?
in > 僕は太郎って言います
太郎さん、またお会いしましたね。
in > はい、また会ったね
そうですね。
in >
無能AIは基本的には無能ですが、↑のように名前は記憶できます。
ユーザーが名前を教えてくれないと延々と名前を聞いてくるあたりはさすがと言う感じですが。
無能AIの設計
無能AIはMuno
というクラスに実装します。
Muno
クラスのanalyze()
というメソッドが解析のエントリーポイントで、このメソッドにテキストを渡すとメソッドは結果を返します。
analyze()
内では状態によって解析処理を分岐します。
解析処理は「名前を聞くルーチン」、「名前を解析するルーチン」、「名前を受信したルーチン」に別れています。
これらのルーチンを状態によって使い分けます。
「名前を聞くルーチン」では名前を聞く質問文を生成します。
「名前を解析するルーチン」ではspaCyを使って入力を解析して名前を取り出し、その名前を記憶します。
「名前を受診したルーチン」では名前を受信後の振る舞いを行います。
基本的な設計は↑になります。
スクリプトのソースコード
今回作成するスクリプトのソースコードは↓になります。
"""
spaCyで名前を記憶する人口無能を作る
"""
import spacy
import json
import os
nlp = spacy.load('ja_ginza')
class Muno:
"""
ルールベースの無能AI
"""
def __init__(self):
self.mode = 'first' # AIの状態。 first | ask_name | received_name
self.memory = {} # 記憶を表現する辞書
def load(self, jsonfname):
"""
jsonfnameを読み込んでmemoryに保存する
"""
if not os.path.exists(jsonfname):
self.memory = {} # ファイルが存在しない
return
with open(jsonfname, 'rt') as fin:
self.memory = json.load(fin) # ファイルをJSONに変換
def save(self, jsonfname):
"""
jsonfnameにmemoryを保存する
"""
with open(jsonfname, 'wt') as fout:
data = json.dumps(self.memory) # 辞書を文字列に変換
fout.write(data) # 書き込む
def analyze(self, text):
if text == 'リセット': # デバッグ用
self.mode = 'first'
# 以降は状態(mode)によって解析方法が変わる
if self.mode == 'first':
if text == 'こんにちは': # 「こんにちは」がトリガー
self.mode = 'ask_name' # 状態を遷移
return 'こんにちは。あなたの名前は?'
else:
return '……。' # 「こんにちは」以外は無視
elif self.mode == 'ask_name': # 名前を聞く
return self.analyze_name(text)
elif self.mode == 'received_name': # 名前を受信した
return 'そうですね。' # TODO: もっと詳しい実装
def analyze_name(self, text):
"""
textを解析して名前を取り出す
"""
doc = list(nlp(text))
result = self.analyze_full_name(doc) # フルネームの解析
if result:
return result # 解析成功
result = self.analyze_single_name(doc) # 単一ネームの解析
if result:
return result # 解析成功
return '名前を教えてくれないの?' # 解析に失敗
def analyze_full_name(self, doc):
"""
docを解析してフルネームを取り出す
"""
i = 0
while i < len(doc) - 1:
t1 = doc[i]
t2 = doc[i + 1]
if t1.pos_ == 'PROPN' and t2.pos_ == 'PROPN': # フルネームっぽい並びを見つけた
self.mode = 'received_name' # 状態を遷移
full_name = t1.text + t2.text
if full_name in self.memory.keys(): # 名前が記憶にあれば
return f'{full_name}さん、またお会いしましたね。'
else: # 名前が記憶にない
self.memory[full_name] = {}
return f'{full_name}さん、はじめまして。'
i += 1
return None
def analyze_single_name(self, doc):
"""
docを解析し単一ネームを取り出す
"""
for tok in doc:
if tok.pos_ == 'PROPN' or tok.pos_ == 'NOUN': # 名前っぽい
self.mode = 'received_name' # 状態を遷移
single_name = tok.text
for key in self.memory.keys():
if single_name in key: # 名前が記憶にあれば
return f'{key}さん、またお会いしましたね。'
else: # 名前が記憶にない
self.memory[single_name] = {}
return f'{single_name}さん、はじめまして。'
return None
def main():
ai = Muno()
ai.load('memory.json') # ディスクに保存してある記憶を読み込む
while True:
try:
text = input('in > ') # ユーザーの入力を受けて
except (KeyboardInterrupt, EOFError):
break
result = ai.analyze(text) # 解析する
print(result) # 結果を出力
ai.save('memory.json') # ループが終了したら記憶を保存する
main()
ソースコードの解説
ソースコードの解説は↓からになります。
必要モジュールのインポート
名前の解析に自然言語処理を使うのでspacy
をインポートしておきます。
これがspaCyのライブラリです。
記憶した名前はディスクに保存しますが、その時のテキストファイルはJSON形式にして保存します。
そのためPythonの辞書をテキストに変換するためにjson
モジュールをインポートしておきます。
ファイルの存在有無をチェックするためにos
をインポートしておきます。
import spacy
import json
import os
GiNZAのロード
グローバル領域でspacy.load()
を実行してGiNZAのモデルをロードします。
ja_ginza
をロードするとginza.Japanese
が返ってきます。
これには慣例的にnlp
と命名しておきます。
🦝 < 銀座でシースー食いねぇ
nlp = spacy.load('ja_ginza')
spaCyはGiNZAのモデルを使って日本語を解析します。
GiNZAはリクルートと国立国語研究所が開発した自然言語処理ライブラリです。
main関数の作成
スクリプトはmain
関数から始まります。
まずMuno
クラスをオブジェクトにして、load()
で外部記憶を読み込みます。
そして無限ループ内でユーザーからの入力を受け取り、その入力をanalyze()
に渡して解析し、結果を出力します。
ループが終了したらsave()
で記憶をディスクに保存します。
def main():
ai = Muno()
ai.load('memory.json') # ディスクに保存してある記憶を読み込む
while True:
try:
text = input('in > ') # ユーザーの入力を受けて
except (KeyboardInterrupt, EOFError):
break
result = ai.analyze(text) # 解析する
print(result) # 結果を出力
ai.save('memory.json') # ループが終了したら記憶を保存する
main()
Munoクラスの作成
Muno
クラスを作成します。
mode
属性はAIの状態を保存します。
状態は「first
」、「ask_name
」、「received_name
」のいずれかになります。
first
は初期状態です。
ask_name
は名前を聞く状態です。
received_name
は名前を聞いた状態です。
memory
は名前の記憶領域です。
これのキーに名前が保存されます。
class Muno:
"""
ルールベースの無能AI
"""
def __init__(self):
self.mode = 'first' # AIの状態。 first | ask_name | received_name
self.memory = {} # 記憶を表現する辞書
load()で記憶をロードする
load()
メソッドはディスクからファイルを読み込んで記憶を復元します。
ファイルが存在しない場合は記憶を初期化するだけです。
json.load()
にファイルオブジェクトを渡すと、json.load()
はそのファイルをパースして辞書に変換します。
ファイルはJSON形式のテキストファイルである必要があります。
def load(self, jsonfname):
"""
jsonfnameを読み込んでmemoryに保存する
"""
if not os.path.exists(jsonfname):
self.memory = {} # ファイルが存在しない
return
with open(jsonfname, 'rt') as fin:
self.memory = json.load(fin) # ファイルをJSONに変換
save()で記憶をディスクに保存する
save()
は引数jsonfname
のファイルに記憶(memory
)を保存します。
json.dumps()
に辞書を渡すとJSON形式のテキストが生成されます。
これをファイルオブジェクトに書き込んで保存します。
def save(self, jsonfname):
"""
jsonfnameにmemoryを保存する
"""
with open(jsonfname, 'wt') as fout:
data = json.dumps(self.memory) # 辞書を文字列に変換
fout.write(data) # 書き込む
analyze()でテキストを解析する
analyze()
は引数text
を解析して出力を生成します。
出力はつまりAIの台詞のことです。
デバッグ用としてtext
が「リセット」であればmode
をfirst
にリセットします。
mode
がfirst
の場合はtext
が「こんにちは」以外の場合は無視をして「……。」という台詞を出力します。
「こんにちは」を検出したらmode
をask_name
に切り替えます。
mode
がask_name
の場合はanalyze_name()
で解析を分岐します。
mode
がreceived_name
の場合は「そうですね。」という台詞を出力します。
def analyze(self, text):
if text == 'リセット': # デバッグ用
self.mode = 'first'
# 以降は状態(mode)によって解析方法が変わる
if self.mode == 'first':
if text == 'こんにちは': # 「こんにちは」がトリガー
self.mode = 'ask_name' # 状態を遷移
return 'こんにちは。あなたの名前は?'
else:
return '……。' # 「こんにちは」以外は無視
elif self.mode == 'ask_name': # 名前を聞く
return self.analyze_name(text)
elif self.mode == 'received_name': # 名前を受信した
return 'そうですね。' # TODO: もっと詳しい実装
analyze_name()で名前を解析する
analyze_name()
は引数text
を名前について解析します。
内部ではtext
をnlp()
に渡してspacy.tokens.doc.Doc
に変換し、これをlist()
に渡してトークン列にします。
最初にanalyze_full_name()
で解析し、次にanalyze_single_name()
で解析します。
解析にすべて失敗した場合は「名前を教えてくれないの?」という出力を生成します。
def analyze_name(self, text):
"""
textを解析して名前を取り出す
"""
doc = list(nlp(text))
result = self.analyze_full_name(doc) # フルネームの解析
if result:
return result # 解析成功
result = self.analyze_single_name(doc) # 単一ネームの解析
if result:
return result # 解析成功
return '名前を教えてくれないの?' # 解析に失敗
analyze_full_name()でトークン列を解析
analyze_full_name()
は引数doc
を解析してフルネームを抽出します。
トークン列のdoc
を添え字で参照しt1
, t2
を取り出します。これらはspacy.tokens.token.Token
です。
このトークンの属性pos_
には単語の品詞が保存されており、PROPN
の場合は固有名詞になります。
PROPN
が並んでいるトークン列を見つけたらモードをreceived_name
に遷移し、名前がmemory
に保存されているかチェックします。
memory
に名前が保存されている場合は「(名前)さん、またお会いしましたね。」という台詞を生成します。
名前が保存されていなければ「(名前)さん、はじめまして。」という台詞を生成します。
def analyze_full_name(self, doc):
"""
docを解析してフルネームを取り出す
"""
i = 0
while i < len(doc) - 1:
t1 = doc[i]
t2 = doc[i + 1]
if t1.pos_ == 'PROPN' and t2.pos_ == 'PROPN': # フルネームっぽい並びを見つけた
self.mode = 'received_name' # 状態を遷移
full_name = t1.text + t2.text
if full_name in self.memory.keys(): # 名前が記憶にあれば
return f'{full_name}さん、またお会いしましたね。'
else: # 名前が記憶にない
self.memory[full_name] = {}
return f'{full_name}さん、はじめまして。'
i += 1
return None
analyze_single_name()で名前を解析する
analyze_full_name()
がフルネームを解析するのに対して、analyze_single_name()
は単一の名前を解析します。
これは「太郎」や「花子」などの名前です。
トークンのpos_
がPROPN
またはNOUN
の場合はmode
をreceived_name
に遷移します。
そして名前がmemory
に保存されているか調べて存在している場合と存在していない場合とで処理を分岐します。
この解析を見るとわかるように、名前の判定にはpos_
が代名詞、または名詞かどうかのチェックだけをしています。
これは色々なテストをしてみるとわかりますが誤検出が多いため、あまり実用性はありません。
def analyze_single_name(self, doc):
"""
docを解析し単一ネームを取り出す
"""
for tok in doc:
if tok.pos_ == 'PROPN' or tok.pos_ == 'NOUN': # 名前っぽい
self.mode = 'received_name' # 状態を遷移
single_name = tok.text
for key in self.memory.keys():
if single_name in key: # 名前が記憶にあれば
return f'{key}さん、またお会いしましたね。'
else: # 名前が記憶にない
self.memory[single_name] = {}
return f'{single_name}さん、はじめまして。'
return None
おわりに
今回はspaCyで名前を記憶する無能AIを作ってみました。
無能は無能でも作ってみるとなかなか楽しいので、気が向いた方は作ってみてください。
これを作りこんでいくと無能から有能になるかもしれません。
🦝 < 能あるAIは性能を隠す
🐭 < 隠してどうする