ユーニックス総合研究所

  • home
  • archives
  • spacy-anaume

spaCyで虫食い文章を穴埋めする【自然言語処理, Python】

spaCyで文章を穴埋めする

私たちが話す言葉は「自然言語」と呼ばれます。
この自然言語を計算機的に解析するのが「自然言語処理」です。

Pythonには自然言語処理を行うライブラリとしてspaCyがあります。
今回はこのspaCyを使ってPythonで虫食い文章を穴埋めするスクリプトを作ってみたいと思います。

具体的には↓を見ていきます。

  • 虫食い文章の穴埋めとは?
  • スクリプトの実行結果
  • スクリプトの仕様
  • スクリプトのソースコード
  • ソースコードの解説

虫食い文章の穴埋めとは?

穴埋めとは、文章中の特定のワードを別のワードで置き換えることを指します。
たとえば↓のような虫食いされた文章があるとします。

今日はxxxへ行った。  

↑のxxxが虫食いされている部分です。
このxxxを適当な言葉で置き換えます。たとえば「山」とか「川」です。

今日は山へ行った。  
今日は川へ行った。  

穴埋めすることで文章に違う意味が生まれます。
文章の穴埋めとはこのように行います。

スクリプトの実行結果

今回作成するスクリプトを実行すると↓のような結果になります(見易いように一部整形しています)。

in > 今日はbbbに行った。  
今日は御師匠に行った。  

in > 今日はaaaがbbbだった。  
今日は情ない事が美学上だった。  

in > aaaにbbbした。  
おしいつくつくに一大仮定した。  

in > なんだかんだbbbが好き。  
なんだかんだ注文通りが好き。  

in > おいaaa、bbbはやめろ。  
おいうまい肴、万事積極的はやめろ。  

in > なんかbbbがaaaみたいだ。  
なんか未来記が重い事みたいだ。  

↑の出力を見ると、文章として成立してない文もあります。
穴埋めの精度としては低いことがわかります。

「今日はbbbに行った。」という虫食い文章は「今日は御師匠に行った。」と言う風に穴埋めされています。
「aaaにbbbした。」という虫食い文章は「おしいつくつくに一大仮定した。」という風にです。

文章として成立させるには、依存構造と言葉の選択を考慮する必要がありますが、今回のスクリプトではそこまでやっていません。
aaabbbで表示する文章を変更するという工夫はやっていますが、これは品詞の並びを変えるという工夫です。

スクリプトの仕様

今回作るスクリプトの仕様についてです。

まず虫食い文章のフォーマットですが、これは↓のようなものです。

  • 虫食い部分にはaaaまたはbbbを使う

虫食い部分にはaaaまたはbbbの文字列を使います。
つまり虫食い文章は↓のようなものになります。

今日はaaaとbbbへ行った。  

aaabbbにはそれぞれ違う方法で生成された文章を割り振ります。

割り振る文章を生成する必要がありますが、これの元になるデータは青空文庫で公開されている夏目漱石の「吾輩は猫である」を使います。
このテキストをスクリプトの最初の方でロードし、それを自然言語処理で解析して、語彙データを作ります。
その語彙データから穴埋め用の文章を取り出して、虫食いされた文章に当てはめます。

虫食い文章自体はユーザーからの入力を標準入力から受けます。

つまりスクリプトは↓のような手順で動作します。

  1. 語彙データの生成
  2. ユーザーからの入力を受け取る
  3. ユーザーの入力を解析して穴埋めする
  4. 2, 3をループ

穴埋めする文章自体はランダムに選択します。
そのため今回のスクリプトではテストを書いていません。
あらかじめご了承ください。

スクリプトのソースコード

スクリプトのソースコードは↓になります。
このスクリプトを実行するには↓のソースコードをsample.pyなどに保存し、python sample.pyを実行してください。

"""  
虫食い文章に穴埋めするスクリプト  
虫食い文章の例:  

    aaaはbbbだ  

License: MIT  
Created at: 2021/01/31  
"""  
import spacy  
import random  


# GiNZAのモデルのロード  
nlp = spacy.load('ja_ginza')  


class Anaume:  
    def load(self, fname):  
        """  
        fnameのファイルを読み込んで解析して語彙データとして保存する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            content = fin.read()  
            doc = list(nlp(content))  
            self.aaa_db = self.create_aaa_db(doc)  
            self.bbb_db = self.create_bbb_db(doc)  

    def analyze(self, sentence):  
        """  
        sentenceを解析して穴埋めする  
        """  
        doc = nlp(sentence)  # 文章を解析してdocに  

        s = ''  
        for tok in doc:  
            if tok.text == 'aaa':  # 穴埋め対象のトークン(aaa)を見つけた  
                anaume = random.choice(self.aaa_db)  
                s += anaume  # 穴埋めする  
            elif tok.text == 'bbb':  # 穴埋め対象のトークン(bbb)を見つけた  
                anaume = random.choice(self.bbb_db)  
                s += anaume  # 穴埋めする                  
            else:  
                s += tok.text  

        return s  

    def create_aaa_db(self, doc):  
        """  
        docからaaa用の語彙データベースを作る  
        """  
        db = []  # 保存先のDB  
        i = 0  

        while i < len(doc) - 1:  
            t1 = doc[i]  
            t2 = doc[i + 1]  
            i += 2  
            # ADJ + (NOUN | PRON | PROPN)の組み合わせだったらテキストを保存する  
            if t1.pos_ == 'ADJ' and t2.pos_ in ('NOUN', 'PRON', 'PROPN'):  
                db.append(t1.text + t2.text)  

        return db  

    def create_bbb_db(self, doc):  
        """  
        docからbbb用の語彙データベースを作る  
        """  
        db = []  # 保存先のDB  
        i = 0  

        while i < len(doc) - 1:  
            t1 = doc[i]  
            t2 = doc[i + 1]  
            i += 2  
            # NOUN + (NOUN | PRON | PROPN)の組み合わせだったらテキストを保存する  
            if t1.pos_ == 'NOUN' and t2.pos_ in ('NOUN', 'PRON', 'PROPN'):  
                db.append(t1.text + t2.text)  

        return db  


def main():  
    anaume = Anaume()  

    # ファイルを読み込む  
    anaume.load('../../assets/wagahai_wa_neko_de_aru.short.txt')  

    while True:  
        try:  
            sentence = input('in > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        # 穴埋めする  
        result = anaume.analyze(sentence)  
        print(result)  


main()  

ソースコードの解説

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

必要モジュールのインポート

必要モジュールをインポートします。
今回は自然言語処理にspaCyを使うためspacyをインポートします。
それからrandomモジュールもインポートしておきます。これは穴埋めに使う文章をランダムに選択するのに使います。

import spacy  
import random  

GiNZAモデルのロード

スクリプトの最初の方でGiNZAのモデルをロードしておきます。
spaCyで日本語を解析したい場合は、↓のようにspacy.load()でGiNZAのモデルja_ginzaをロードします。

# GiNZAのモデルのロード  
nlp = spacy.load('ja_ginza')  

nlpginza.Japaneseクラスのオブジェクトです。
このnlpに文章を渡すことで解析を行うことができます。

GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリです。
最近までspaCyは構文解析以上のことが日本語の処理でできなかったんですが、このGiNZAの登場によりspaCyでも構文解析以上の解析処理が書けるようになったとのことです。

main()関数を作る

スクリプトはmain()関数から始まります。
main()関数内ではAnaumeクラスからオブジェクトを作って、load()で語彙データを作成しています。
語彙データの作成に使うファイルは夏目漱石の「吾輩は猫である」の文章が保存されたテキストファイルです。

語彙データを作成したらwhile文で無限ループに入ります。
そしてinput()でユーザーからの入力を受けます。
このときinput()から例外KeyboardInterruptまたはEOFErrorが送出された場合はループから抜けるようにしています。
KeyboardInterruptCtrl+Cなどが入力されたとき、EOFErrorEOFが入力されたときに発生します。

ユーザーからの入力はsentenceに保存します。sentenceは「文章」という意味です。
このsentenceanaume.analyze()に渡して虫食い文章への穴埋めを実行します。
analyze()からの結果をresultで受け取り、それを画面に出力してループ内はひと段落します。

🦝 < ひと段落、自然言語だけに

🐭 < うまい

def main():  
    anaume = Anaume()  

    # ファイルを読み込む  
    anaume.load('../../assets/wagahai_wa_neko_de_aru.short.txt')  

    while True:  
        try:  
            sentence = input('in > ')  
        except (KeyboardInterrupt, EOFError):  
            break  

        # 穴埋めする  
        result = anaume.analyze(sentence)  
        print(result)  


main()  

Anaumeクラスの作成

今回のスクリプトでは主な処理はAnaumeというクラスで行います。
このクラスは語彙データのロードと保持、それから実際の穴埋めの解析を行います。

class Anaume:  
    ...  

クラスのメソッドについては後述します。

load()でファイルの読み込みと語彙データの作成

load()メソッドは引数fnameのファイルを開いてその内容を読み込み、その内容をspaCyで解析してdocにします。
docspacy.tokens.doc.Docです。このdocには解析した結果が格納されています。つまりdocを参照すれば解析結果にアクセスできるということになります。内容的にはトークン列になっています。これは単語の列のことです。
nlp()に文章を渡すとspaCyは文章を解析してトークン列にします。そして各トークンには単語の情報が保存されます。
解析ではこの単語の情報を参照することで自然言語処理を行うことができます。

doccreate_aaa_dbに渡してキーワードaaa用のデータベース(語彙データ)をaaa_dbに保存します。
bbbについても同様です。
このaaa_dbbbb_dbは虫食い文章に穴埋めするときに参照されます。
内容的にはただの文字列のリストです。穴埋め用の文字列が保存されます。

    def load(self, fname):  
        """  
        fnameのファイルを読み込んで解析して語彙データとして保存する  
        """  
        with open(fname, 'r', encoding='utf-8') as fin:  
            content = fin.read()  
            doc = list(nlp(content))  
            self.aaa_db = self.create_aaa_db(doc)  
            self.bbb_db = self.create_bbb_db(doc)  

analyze()で入力文章を穴埋めする

analyze()メソッドは引数sentenceの文字列を解析して、虫食い部分に穴埋めするメソッドです。
sentencenlp()に渡してspaCyで解析しdocにします。
docはトークン列として扱うことができます。そのためfor文で回せます。
↓のようにfor tok in doc:でトークンを1つずつ取り出して処理しています。
tokspacy.tokens.token.Tokenです。

トークンの属性textには元の文章のそのままの表記が保存されています。
このtextを参照してaaaだったらaaa_dbから穴埋め用の文字列を取ってきて、sに溜めます。
bbbだったらbbb_dbから穴埋め用の文字列を取ってきます。
それ以外であれば単純にstextを溜めていきます。

たとえば「私はbbbです」という文章であれば、トークン列は「私 / は / bbb / です」になります。
トークン「」のtextは「」になるわけです。

random.choice()はリストからランダムに要素を選択するメソッドです。
aaa_dbはただの文字列のリストなので、これによってランダムに穴埋め用の文字列が取得できます。

ループが完了したらsreturnしてメソッドは終わりです。

    def analyze(self, sentence):  
        """  
        sentenceを解析して穴埋めする  
        """  
        doc = nlp(sentence)  # 文章を解析してdocに  

        s = ''  
        for tok in doc:  
            if tok.text == 'aaa':  # 穴埋め対象のトークン(aaa)を見つけた  
                anaume = random.choice(self.aaa_db)  
                s += anaume  # 穴埋めする  
            elif tok.text == 'bbb':  # 穴埋め対象のトークン(bbb)を見つけた  
                anaume = random.choice(self.bbb_db)  
                s += anaume  # 穴埋めする                  
            else:  
                s += tok.text  

        return s  

create_aaa_db(), create_bbb_db()で語彙データを作る

create_aaa_db()create_bbb_db()はそれぞれキーワードaaabbb用の語彙データを作ります。
内容的には引数docのトークン列を参照し、トークンの品詞を参照します。
トークンt1pos_属性がADJで、トークンt2pos_属性がNOUN, PRON, PROPNのいずれかであればデータベースにトークンのtextを合成して追加します。
トークンのpos_属性には品詞の種類を表す文字列が保存されています。
↑に出てきた品詞については↓の通りです。

  • ADJ ... 形容詞
  • NOUN ... 名詞
  • PRON ... 代名詞
  • PROPN ... 固有名詞

create_aaa_db()create_bbb_db()の違いですが、それぞれキーとなる品詞が一部違っています。
この2つのメソッドはほぼ同じことをやっているため、共通化したほうが良さそうですが、今回は共通化していません。

    def create_aaa_db(self, doc):  
        """  
        docからaaa用の語彙データベースを作る  
        """  
        db = []  # 保存先のDB  
        i = 0  

        while i < len(doc) - 1:  
            t1 = doc[i]  
            t2 = doc[i + 1]  
            i += 2  
            # ADJ + (NOUN | PRON | PROPN)の組み合わせだったらテキストを保存する  
            if t1.pos_ == 'ADJ' and t2.pos_ in ('NOUN', 'PRON', 'PROPN'):  
                db.append(t1.text + t2.text)  

        return db  

    def create_bbb_db(self, doc):  
        """  
        docからbbb用の語彙データベースを作る  
        """  
        db = []  # 保存先のDB  
        i = 0  

        while i < len(doc) - 1:  
            t1 = doc[i]  
            t2 = doc[i + 1]  
            i += 2  
            # NOUN + (NOUN | PRON | PROPN)の組み合わせだったらテキストを保存する  
            if t1.pos_ == 'NOUN' and t2.pos_ in ('NOUN', 'PRON', 'PROPN'):  
                db.append(t1.text + t2.text)  

        return db  

おわりに

今回は虫食い文章を穴埋めするというスクリプトを作ってみました。
文章の生成と言う点で穴埋めのアルゴリズムはなかなか奥が深そうだと思いました。

🦝 < aaaはbbbだ

🐭 < 天気は晴れだ