ユーニックス総合研究所

  • home
  • archives
  • spacy-muno-ai

spaCyで名前を記憶する無能AIを作る【自然言語処理, Python】

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が「リセット」であればmodefirstにリセットします。

modefirstの場合はtextが「こんにちは」以外の場合は無視をして「……。」という台詞を出力します。
「こんにちは」を検出したらmodeask_nameに切り替えます。

modeask_nameの場合はanalyze_name()で解析を分岐します。

modereceived_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を名前について解析します。
内部ではtextnlp()に渡して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の場合はmodereceived_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は性能を隠す

🐭 < 隠してどうする