ユーニックス総合研究所

  • home
  • archives
  • spacy-siritori

spaCyでしりとりアプリを作る【自然言語処理, Python】

spaCyでしりとりアプリを作る

私たちが日常的に使っている日本語は「自然言語」と呼ばれます。
この自然言語を計算機的に解析することを「自然言語処理」と言います。

自然言語の解析には、Pythonでは自然言語処理ライブラリのspaCyを使うことができます。

今回はこのspaCyを使って「しりとりアプリ」を作ってみたいと思います。
具体的には↓を見ていきます。

  • spaCyとは?
  • しりとりとは?
  • しりとりアプリの設計
  • アプリの実行結果
  • しりとりアプリのソースコード
  • ソースコードの解説

spaCyとは?

spaCy(スパイシー)とはPython製の自然言語処理ライブラリです。
オープンソースなソフトウェアで、MITライセンスで利用することができます。
さまざまな言語の学習済み統計モデルを最初から使うことが出来て、字句解析や依存構造の構築などが簡単に行えます。

spaCyで日本語の解析を行うにはGiNZAのモデルを使います。
GiNZAはリクルートと国立国語研究所が共同開発した日本語を扱える自然言語処理ライブラリです。

spaCyもGiNZAもどちらもpipで簡単にインストールできます。

$ # spaCyのインストール  
$ pip install spacy  

$ # GiNZAのインストール  
$ pip install "https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz"  

しりとりとは?

「しりとり」とは日本の言葉遊びです。
相手が言った言葉のお尻の文字を拾って、その文字から別の言葉を連想して言います。
相手もまた、その言葉から別の言葉を繋げて言います。
どちらかの言葉のお尻が「ん」で終わっていたら、その言葉を言った人の負けです。

たとえば「リンゴ」という言葉を相手が言ったとして、別の相手は「リンゴ」の「ゴ」から別の言葉をつなげます。
「ゴリラ」とその人が言ったら、もう一方の人は「ゴリラ」の「ラ」から言葉を繋げて「ラッパ」等と言います。
これが「ランタン」とか語尾に「ン」の付く言葉だったらその言葉を言った人の負けです。

🦝 < リンゴ

🐭 < ゴリラ

🦊 < ラッパ

しりとりアプリの設計

今回作成するしりとりアプリの設計ですが、これは実は過去にJanomeで作ってあります。

基本的には設計はこのJanome版のアプリと同じです。
まずしりとりの語彙に使用するデータをロードします。
このデータは青空文庫で公開されている夏目漱石の「吾輩は猫である」の文章を使います。

↑の文章をファイルwagahai_wa_neko_de_aru.txtに保存しておきます。
そしてアプリ実行時にこのファイルを読み込んで、spaCyを使って解析します。
この解析した結果はクラスの属性sample_docとして持っておきます。

あとはしりとりゲームのゲームルーチンを作り、その中でユーザーから入力を受けます。
入力を受けたら、その入力もspaCyで解析して、トークン列にします。
末尾のトークンの_.reading属性、つまりそのトークンの読み方をチェックして、「ン」が付いてないかチェックします。
_.reading属性にはカタカナで単語の読み方が保存されます)
語尾に「ン」が付いていれば「あなたの負けです。」と表示します。

コンピューターはユーザーの入力を解析して、その語尾にマッチするセンテンスを語彙データから探します。
センテンスが見つかったらそのセンテンスの末尾に「ン」がないかチェックし、「ン」があったら「CPUの負けです。」と表示します。
問題なければそのセンテンスをユーザーに表示します。

基本的な設計はこれだけです。

アプリの実行結果

今回作成するアプリを実行すると↓のような結果になります。

in > 猫  
この書生というのは時々我々を捕えて煮て食うという話である。  
in > ルンバ  
バルザックが或る日自分の書いている小説中の人間の名をつけようと思っていろいろつけて見たが、どうしても気に入らない。  
in > 犬  
沼へでも落ちた人が足を抜こうと焦慮るたびにぶくぶく深く沈むように、噛めば噛むほど口が重くなる、歯が動かなくなる。  
in > ルビイ  
B氏は横膈膜で呼吸して内臓を運動させれば自然と胃の働きが健全になる訳だから試しにやって御覧という。  
in > 馬  
また隣りの三毛君などは人間が所有権という事を解していないといって大に憤慨している。  
in > ルイジアナ  
名前はまだ無い。  
in > イカ  
肝心の母親さえ姿を隠してしまった。  
in > 蛸  
この書生というのは時々我々を捕えて煮て食うという話である。  
in > ルンペン  
あなたの負けです。  
in >  

しりとりとは言っても、今回のアプリは単語ではなく文章を返して来る仕様になっています。
Janome版では単語を返してましたが、こっちも面白いかと思います。
ただ「る」で終わる文章が多いので、ユーザーはけっこうハードな思考を求められます。

しりとりアプリのソースコード

しりとりアプリのソースコードは↓になります。
このアプリを実行するには↓のコードをsample.pyなどに保存し、python sample.pyと実行します。

"""  
spaCyを使ったしりとりアプリ  

Lisence: MIT  
Created at: 2021/01/28  
"""  
import spacy  


nlp = spacy.load('ja_ginza')  


class Siritori:  
    def __init__(self):  
        # しりとりに使う語彙のデータ  
        # load()で構築する  
        self.sample_doc = None  

    def load(self, fname):  
        """  
        fnameのファイルを読み込み解析してsample_docに変換する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            content = fin.read()  
            self.sample_doc = nlp(content)  

    def siritori(self, doc):  
        """  
        docの末尾の読み方にヒットするセンテンスを探しそれを返す  
        """  
        last = doc[-1]  

        for sent in self.sample_doc.sents:  
            tok = sent[0]  
            if last._.reading[-1:] == tok._.reading[:1]:  
                return sent  

        return None  

    def update(self):  
        """  
        ゲームルーチン  
        継続する場合はTrueを返し、終了する場合はFalseを返す  
        """  
        try:  
            sentence = input('in > ')  
        except (KeyboardInterrupt, EOFError):  
            return False  

        doc = nlp(sentence)  
        if doc[-1]._.reading[-1:] == 'ン':  
            print('あなたの負けです。')  
            return True  

        sent = self.siritori(doc)  
        if sent is None:  
            print('パス')  
            return True  
        if sent[-1]._.reading[-1:] == 'ン':  
            print('CPUの負けです。')  
            return True  

        print(sent)  
        return True  


def main():  
    siritori = Siritori()  
    siritori.load('../../assets/wagahai_wa_neko_de_aru.txt')  

    while siritori.update():  
        pass  


main()  

ソースコードの解説

簡単ですがソースコードの解説になります。

spacyのインポート

今回のアプリではspaCyを使います。
そのためspacyモジュールをインポートしておきます。

import spacy  

GiNZAモデルのロード

グローバル変数nlpを作成します。
spaCyのお約束として、最初に学習済みの統計モデルをロードしておきます。
今回は日本語の文章を解析するのでGiNZAのモデルであるja_ginzaspacy.load()でロードします。
spacy.load()ja_ginzaを読み込んだ場合、ginza.Japaneseを返してきます。
このnlpは文章の解析に使われます。このオブジェクトに文章を渡すことで文章の解析を行えます。

nlp = spacy.load('ja_ginza')  

main関数の作成

このアプリはmain関数からはじまります。
main関数ではSiritoriクラスをオブジェクトにして、語彙データ(wagahai_wa_neko_de_aru.txt)を読み込みます。
そして無限ループでsiritori.update()を実行します。

def main():  
    siritori = Siritori()  
    siritori.load('../../assets/wagahai_wa_neko_de_aru.txt')  

    while siritori.update():  
        pass  


main()  

Siritoriクラスの作成

アプリの主な仕事はSiritoriクラスで行います。
このクラスは__init__()では属性の初期化のみを行います。

class Siritori:  
    def __init__(self):  
        # しりとりに使う語彙のデータ  
        # load()で構築する  
        self.sample_doc = None  

load()で語彙データを読み込み

load()メソッドは引数fnameのファイルを開いて内容を読み込みます。
そして読み込んだ内容をnlp()に渡し、spaCyで文章を解析します。
解析した結果はクラスの属性sample_docに保存します。
このsample_docのタイプはspacy.tokens.doc.Docになります。

    def load(self, fname):  
        """  
        fnameのファイルを読み込み解析してsample_docに変換する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            content = fin.read()  
            self.sample_doc = nlp(content)  

update()メソッドでゲームを行う

アプリのメインロジックはupdate()メソッド内にあります。
このメソッドでは最初にinput()でユーザーからの入力を読み取ります。
このとき例外KeyboardInterruptEOFErrorが飛んで来たらFalseを返し、無限ループを終了するようにします。
こうするとCtrl+CEOFが入力された場合にゲームが静かに終了します。

入力(sentence)をnlp()に渡して自然言語として解析します。
nlp()の結果(doc)はspacy.tokens.doc.Docとして返ってきますが、これはトークン列として扱うことができます。
そのためdoc[-1]のようにすると、末尾のトークン(spacy.tokens.token.Token)にアクセスすることができます。
末尾のトークンの_.reading属性を参照すると、そのトークンの読み方を参照できます。

よってdoc[-1]._.reading[-1:]という式は「トークン列の末尾のトークンの末尾の読み方を参照する」という意味になります。
readingをスライスで参照していますが、readingはトークンによっては長さがない空になる場合があります。
そのためreading[-1]のようにアクセスするとエラーになることがあるため、スライスを使用しています。

このようにnlp()は解析した文章をトークン列として返します。
トークン列と言うのは単語のリストのことで、たとえば「猫が歩く」という文章であればこれは「猫 / が / 歩く」という単語のリストに変換されます。
その単語(トークン)には↑のようにreadingなどの単語の情報が保存されます。
この単語の情報を参照することでいろいろな自然言語処理ができるという寸法です。

ユーザーの入力(doc)はそのままsiritori()メソッドに渡します。
siritori()メソッドは引数のdocにマッチする文章を語彙データから検索するメソッドです。
その結果がNoneであれば語彙が見つからなかったということになるのでパスします。
語彙が見つかったら「ン」が付いてないかチェックして、画面に出力します。

    def update(self):  
        """  
        ゲームルーチン  
        継続する場合はTrueを返し、終了する場合はFalseを返す  
        """  
        try:  
            sentence = input('in > ')  
        except (KeyboardInterrupt, EOFError):  
            return False  

        doc = nlp(sentence)  
        if doc[-1]._.reading[-1:] == 'ン':  
            print('あなたの負けです。')  
            return True  

        sent = self.siritori(doc)  
        if sent is None:  
            print('パス')  
            return True  
        if sent[-1]._.reading[-1:] == 'ン':  
            print('CPUの負けです。')  
            return True  

        print(sent)  
        return True  

siritori()で語彙を検索する

siritori()メソッドは引数docにマッチする語彙を語彙データから検索します。
内容的には語彙データであるsample_docsents属性を参照してループを回します。
sentspacy.tokens.span.Spanです。
これは「。」などで区切られた文章のことで、1つのまとまりとして扱います。
このsentもトークン列として参照することができるのでsent[0]で先頭のトークンを取り出します。
そしてdocの末尾のトークンの読み方とtokの読み方を比較します。

比較ではlastの読み方は末尾の文字で、tokの読み方は先頭の文字を比較しています。
つまりしりとりにおける言葉のお尻と言葉の頭を比較しているということになります。
言葉のお尻が頭にヒットしたら、その文章はしりとりとして成立するということになります。

siritori()は文章が見つかったらspacy.tokens.span.Spanを返し、見つからなかったらNoneを返します。

    def siritori(self, doc):  
        """  
        docの末尾の読み方にヒットするセンテンスを探しそれを返す  
        """  
        last = doc[-1]  

        for sent in self.sample_doc.sents:  
            tok = sent[0]  
            if last._.reading[-1:] == tok._.reading[:1]:  
                return sent  

        return None  

おわりに

今回はspaCyを使ってしりとりアプリを作ってみました。
ただ、このぐらいのアプリの場合はJanomeなどでも十分かもしれません。

というのも、このアプリでは依存構造を利用していないからです。

速度的にはspaCy版のしりとりアプリはモデルのロードや依存構造の解析に時間がかかっていて、アプリの起動に時間がかかります。
速度的に見ればJanomeなどで実装したほうが速くなります。

ただspaCyの練習にはちょうどいい題材かもしれません。
気が向いた方は改造などして遊んでみてください。

🦝 < しりとりマスターを目指せ

🐭 < おれの「ん」の価値は高いぜ