ユーニックス総合研究所

  • home
  • archives
  • spacy-sore

spaCyで「手に取れそうなそれ」を抽出する【自然言語処理, Python】

spaCyで「手に取れそうなそれ」を抽出する

日本人が話す日本語ですが、これはカテゴリ的には「自然言語」に分類されます。
そしてこの自然言語を計算機的に解析するのが「自然言語処理」です。

Pythonには自然言語処理ライブラリであるspaCy(スパイシー)があり、これを使うと簡単に日本語を解析することができます。
今回はこのspaCyを使って日本語の文章から「手に取れそうな『それ』」を抽出するスクリプトを作ってみたいと思います。

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

  • spaCyとは?
  • 自然言語処理の工程
  • 「手に取れるモノ」の定義
  • スクリプトの実行結果
  • スクリプトの設計
  • スクリプトのソースコード
  • ソースコードの解説

spaCyとは?

今回使用するspaCy(スパイシー)はオープンソースで開発されている自然言語処理ライブラリです。
MITライセンスで利用することが出来て、さまざまな言語の学習済み統計モデルを使用することができます。

spaCyによる日本語の解析で、spaCyと組み合わせて使われるのがGiNZA(ギンザ)です。

GiNZAはリクルートと国立国語研究所が共同開発した自然言語処理ライブラリです。
こちらもオープンソースでMITライセンスで利用することができます。
GiNZAで作成されたspaCy用の統計モデルを、spaCyからロードして使うことができます。

自然言語処理の工程

自然言語処理の工程についてです。
自然言語処理は複数の工程に分かれていて、それらの工程を1つずつ行うことでより高度な解析をすることができます。
具体的には↓のような工程に分かれています。

  1. 字句解析(形態素解析)
  2. 構文解析
  3. 意味解析
  4. 文脈解析

字句解析

最初に行われる字句解析は文章を単語のリストに分割する解析です。
たとえば「太郎が歩く」という文章を「太郎 / が / 歩く」という単語のリストに変換するのが字句解析です。
このとき分割された単語はトークンと呼ばれ、トークンにはその単語の持つ情報、たとえば読み方や品詞などが保存されます。

構文解析

次に行われるのが構文解析です。
構文解析は字句解析で分割された単語のリストにおいて、その単語間の依存関係を解析します。
たとえば「太郎が歩く」という文章を構文解析すると↓のような依存構造が構築されます。

太郎 PROPN ═╗<╗ iobj  
が   ADP   <╝ ║ case  
歩く VERB  ═══╝ ROOT  

「歩く」という単語は「太郎」に係(かか)っているので、↑の構造では「歩く」から「太郎」に矢印が伸びています。
単語間の依存構造の解析は係り受け解析とも呼ばれます。
この解析によってどの単語がどの単語に係っているかがわかるので、構文解析以上の解析で役立てることができます。

意味解析

その次に行われるのが意味解析です。
意味解析とは文章の意味を解析する解析です。

🦝 < そのまんまだね

たとえば「男前な太郎と自転車」という文章では、「太郎」が「男前」なのはわかりますが「自転車」が「男前」なのは文章として変です。
この時、意味解析は「男前」なのはどっちなのか、依存構造ツリーから正しい文脈を選択します。
例で言うと「男前」なのは「太郎」であって「自転車」ではありません。この選択をプログラム的に行うのが意味解析です。

文脈解析

構文解析が単一の文を解析するのに対し、複数の文を構文解析して意味解析するのが文脈解析です。
複数の文脈を上手いこと解析して、文章の意味を正確に把握することを目指します。
今回のスクリプトはこの文脈解析にチャレンジしてます(?)。

🦝 < しかし、本当に文脈解析と言っていいのか?

🐭 < わからぬ

🦊 < マサカリが飛んでくるかもしれない

「手に取れるモノ」の定義

今回作成するスクリプトは「手に取れそうなもの」を解析するスクリプトです。
「それを手に取る」という文章から「それ」という単語が何を指しているのか解析します。そしてその時に考慮するのが「それ」は「手に取れそうなものである」ということです。
つまり手に取れそうなものは、そのまま手の中に納まるものです。象とかライオンとかは手に取れません。
しかしコンピューターはそういった意味を知らないので、なんでも「それ」に当てはめようとします。
これを自然言語処理的に解決してみようというのが今回の趣旨です。

今回は「手に取れそうなもの」の定義として、物の「重量」を考慮することにします。
文脈に登場するモノの重量が軽ければそれは「手に取れそう」と判断します。
そのためモノの意味を登録しておく意味データベースが必要になります。
この意味データベースを参照することで、モノの重量を把握します。

スクリプトの実行結果

今回のスクリプトは単体テストで動作検証を行っています。
単体テストにはPythonの標準ライブラリであるunittestを使っています。
後述のスクリプトのソースコードをsample.pyなどに保存して、コマンドラインからpython -m unittest sampleと実行するとテストを実行することができます。
テストを実行すると↓のように出力されます。

.  
----------------------------------------------------------------------  
Ran 1 test in 1.835s  

OK  

↑の出力はテストが成功してるときの出力です。
検証しているテストケースは↓です。

class Test(unittest.TestCase):  
    def eq(self, a, b):  
        an = Analyzer()  
        c = an.analyze(a)  
        self.assertEqual(c, b)  

    def test_analyze(self):  
        # うまく行ってるように見えるケース  
        self.eq('本棚に本があった。それを手に取った。', '本')  
        self.eq('浴槽に手鏡があった。これを手に取りたい。', '手鏡')  
        self.eq('浴槽に本と手鏡があった。それを手に取る。', '本')  # これは「それ」が曖昧  
        self.eq('浴槽に本と手鏡があった。それらを手に取る。', '本,手鏡')  
        self.eq('私は本を抱えて浴槽に入った。それを手に取って読んだ。', '本')  

        # 微妙なケース  
        self.eq('手鏡に照らされながら本を置いた。それを手に取った。', '手鏡')  

        # 意味不明なケース  
        # この解析器はそこに存在するかどうかは解析しないので、  
        # あるかどうかは関係ない  
        self.eq('床に本がなかった。それを手に取った', '本')  

        # 処理できないケース  
        self.eq('本棚に本があった。手に取った。', None)  
        self.eq('本棚に本があった。それを取った。', None)  
        self.eq('浴槽に本棚があった。これを手に取りたい。', None)  

↑のテストケースを見ると、「本棚に本があった。それを手に取った。」という文章の「それ」は「本」という解析結果になっています。
これはつまり「本棚」と「本」では、本棚は重くて手に取れないけど本は手に取れるという判断をして、プログラムが本を選択しているということになります。
このようにプログラムは文脈から「それ」を推測して決定します。

スクリプトの設計

繰り返しになりますが、プログラムの動作検証は単体テストで行います。

プログラムの主な処理はAnalyzerというクラスに実装します。
Analyzerは代表的な属性として↓を持ちます。

  • memory
  • imi_db
  • exported_sore

memoryは「記憶」を管理する属性で、Pythonの辞書です。
文脈解析では複数の文をまたいで解析する必要があります。
そのため前の文の情報を記憶しておく必要があります。
このmemoryはその情報を保存する属性です。

imi_dbは意味データベースで、単語の意味が保存されています。
意味データベースと言う仰々しい名前が付いてますが、これもただのPythonの辞書です。
意味データベースは単語をキーにして、そのキーから単語の持つ情報を辿れるようになっています。

exported_soreは最終的に抽出される「それ」です。
これはリストで、「それ」が複数保存される場合があります。

Analyzeranalyze()メソッドを持ちます。このメソッドが解析の入り口です。
analyze()に文字列の文章を渡すと、analyze()はこの文章を解析してトークン列にします。
そして段落ごとに文を解析し、memoryに名詞のトークンを記録します。
「それ」が文に含まれていたらmemoryimi_dbを参照して適合する単語を見つけて、単語をエクスポートします。

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

↓が今回作成したスクリプトです。

"""  
「手に取れそうなそれ」を文脈から抽出するスクリプト  

License: MIT  
Created at: 2021/02/02  
"""  
import spacy  
import unittest  


nlp = spacy.load('ja_ginza')  


class Analyzer:  
    def __init__(self):  
        # 記憶  
        self.memory = {  
            'NOUNS': [],  # 名詞のリスト  
        }  

        # 意味データベース  
        self.imi_db = {  
            '本棚': {  # 「本棚」の意味として「重い」が含まれる  
                '重量': '重い',  
            },  
            '本': {  # 「本」の意味として「軽い」が含まれる  
                '重量': '軽い',  
            },  
            '浴槽': {  # 「浴槽」の意味として「重い」が含まれる  
                '重量': '重い',  
            },  
            '手鏡': {  # 「手鏡」の意味として「軽い」が含まれる  
                '重量': '軽い'  
            }  
        }  

        # 最終的に抽出される「それ」  
        self.exported_sore = []  

        # 「それ」が「それら」だったらTrue  
        self.need_multi_sore = False  

    def analyze(self, text):  
        """  
        引数textを解析する  
        """  
        doc = nlp(text)  

        # 段落ごとに解析する  
        for sent in doc.sents:  
            self.analyze_sent(sent)  

        # 「それ」がエクスポートされていたら  
        if len(self.exported_sore):  
            # テキストに整形して返す  
            return ','.join([tok.text for tok in self.exported_sore])  
        return None  

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        # 「それを手に取る」を解析する  
        if self.analyze_sore_wo_te_ni_toru(sent):  
            # この時点で文脈に「それ」が存在する  
            # 過去の記憶から「それ」をエクスポート  
            self.exported_sore = self.export_sore_from_memory()  
            return  

        # 名詞を記憶する  
        if self.memory_nouns(sent):  
            return   

    def export_sore_from_memory(self):  
        """  
        「それ」を過去の記憶からエクスポートする  
        """  
        sore = []  

        # 記憶されている名詞を辿る  
        for noun in self.memory['NOUNS']:  
            # 名詞の意味を意味データベースから参照する  
            if noun.text in self.imi_db.keys():  
                imi = self.imi_db[noun.text]  
                if imi['重量'] == '軽い':  # 意味=重量が軽い  
                    # 重量が軽い=手に取れる  
                    sore.append(noun)  # このトークンは「手に取れる重量」なのでエクスポートする  
                    if not self.need_multi_sore:  # 複数必要ないなら  
                        return sore  # さっさとreturnする  

        return sore  

    def analyze_sore_wo_te_ni_toru(self, sent):  
        """  
        「それを手に取る」という文を解析する  
        「手に取る」という文章があった場合Trueを返す  
        """  
        i = 0  
        while i < len(sent) - 2:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            t3 = sent[i + 2]  
            i += 1  
            if t1.text == '手' and t2.text == 'に' and t3.lemma_ == '取る':  
                sore = self.find_sore(t3)  # それを探す  
                if len(sore):  # 見つかった  
                    self.need_multi_sore = len(sore) >= 2  # 複数必要?  
                    return True  

        return False  

    def find_sore(self, toru):  
        """  
        依存構造の子要素から「それ」を見つける  
        """  
        for child in toru.children:  
            if child.text in ('それ', 'これ'):  
                return [child]  # 見つかった  

            # 「それら」「これら」も考慮する  
            if child.text == 'ら':  
                for c in child.children:  
                    if c.text in ('それ', 'これ'):  
                        return [c, child]  # 見つかった  
        return []  # 見つからなかった  

    def memory_nouns(self, sent):  
        """  
        名詞をmemoryに記憶する  
        """  
        for tok in sent:  
            if tok.pos_ == 'NOUN':  # 品詞がNOUNのトークン  
                self.memory['NOUNS'].append(tok)  # 保存  


class Test(unittest.TestCase):  
    def eq(self, a, b):  
        an = Analyzer()  
        c = an.analyze(a)  
        self.assertEqual(c, b)  

    def test_analyze(self):  
        # うまく行ってるように見えるケース  
        self.eq('本棚に本があった。それを手に取った。', '本')  
        self.eq('浴槽に手鏡があった。これを手に取りたい。', '手鏡')  
        self.eq('浴槽に本と手鏡があった。それを手に取る。', '本')  # これは「それ」が曖昧  
        self.eq('浴槽に本と手鏡があった。それらを手に取る。', '本,手鏡')  
        self.eq('私は本を抱えて浴槽に入った。それを手に取って読んだ。', '本')  

        # 微妙なケース  
        self.eq('手鏡に照らされながら本を置いた。それを手に取った。', '手鏡')  

        # 意味不明なケース  
        # この解析器はそこに存在するかどうかは解析しないので、  
        # あるかどうかは関係ない  
        self.eq('床に本がなかった。それを手に取った', '本')  

        # 処理できないケース  
        self.eq('本棚に本があった。手に取った。', None)  
        self.eq('本棚に本があった。それを取った。', None)  
        self.eq('浴槽に本棚があった。これを手に取りたい。', None)  

ソースコードの解説

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

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

必要モジュールをインポートします。
今回は自然言語処理でspaCyを使いますので、spacyをインポートします。
それから単体テスト用にunittestもインポートします。

import spacy  
import unittest  

GiNZAモデルのロード

グローバル領域でGiNZAのモデルをspacy.load()でロードします。
GiNZAのモデルはja_ginzaを指定することで読み込むことができます。
spacy.load()ja_ginzaを読み込んだ場合、ginza.Japaneseを返してきます。
この返り値には慣例としてnlpと命名します。

nlp = spacy.load('ja_ginza')  

Analyzerクラスの作成

メインの処理はAnalyzerクラスを行います。
見ての通りmemoryimi_dbは内容的にはただの辞書です。
memoryにはNOUNSという名詞(NOUN)のリストを持たせています。

imi_dbの初期値には「本棚」や「本」などの情報を持たせています。
本棚の重量は重くて、本の重量は軽いという具合です。
imi_dbを充実させれば、色々な単語に対応できるプログラムになりますが、これを手作業で定義していくのはかなり大変だと予想されます。

class Analyzer:  
    def __init__(self):  
        # 記憶  
        self.memory = {  
            'NOUNS': [],  # 名詞のリスト  
        }  

        # 意味データベース  
        self.imi_db = {  
            '本棚': {  # 「本棚」の意味として「重い」が含まれる  
                '重量': '重い',  
            },  
            '本': {  # 「本」の意味として「軽い」が含まれる  
                '重量': '軽い',  
            },  
            '浴槽': {  # 「浴槽」の意味として「重い」が含まれる  
                '重量': '重い',  
            },  
            '手鏡': {  # 「手鏡」の意味として「軽い」が含まれる  
                '重量': '軽い'  
            }  
        }  

        # 最終的に抽出される「それ」  
        self.exported_sore = []  

        # 「それ」が「それら」だったらTrue  
        self.need_multi_sore = False  

analyze()で文章を解析する

analyze()メソッドは引数textを解析して「それ」を抽出します。

nlp()に文章を渡すとspaCyが解析してくれます。
その結果はspacy.tokens.doc.Docで返ってくるので、docで保存しておきます。

このdocsents属性は複数の文が保存されています(正確にはジェネレーター)。
これをfor文で回すことで一文ずつ解析することができます。
sentspacy.tokens.span.Spanです。
analyze_sent()で文を解析します。

すべての文の解析が終わって、exported_soreに「それ」が抽出されていたら、それをテキストに整形して返します。
解析に失敗したらNoneを返します。

    def analyze(self, text):  
        """  
        引数textを解析する  
        """  
        doc = nlp(text)  

        # 段落ごとに解析する  
        for sent in doc.sents:  
            self.analyze_sent(sent)  

        # 「それ」がエクスポートされていたら  
        if len(self.exported_sore):  
            # テキストに整形して返す  
            return ','.join([tok.text for tok in self.exported_sore])  
        return None  

analyze_sent()で文を解析する

analyze_sent()は引数sentを解析します。

最初にanalyze_sore_wo_te_ni_toru()で文に「それを手に取る」が含まれているか解析します。
もし含まれていたらexport_sore_from_memory()で記憶(memory)から「それ」を抽出します。

「それを手に取る」が含まれていなかったらmemory_nouns()で名詞を記録します。

    def analyze_sent(self, sent):  
        """  
        引数sentを解析する  
        """  
        # 「それを手に取る」を解析する  
        if self.analyze_sore_wo_te_ni_toru(sent):  
            # この時点で文脈に「それ」が存在する  
            # 過去の記憶から「それ」をエクスポート  
            self.exported_sore = self.export_sore_from_memory()  
            return  

        # 名詞を記憶する  
        if self.memory_nouns(sent):  
            return   

analyze_sore_wo_te_ni_toru()で「それを手に取る」を解析する

analyze_sore_wo_te_ni_toru()は引数sentを解析して文中に「それを手に取る」が含まれているか判別します。
「それを手に取る」が含まれていたらTrue, 含まれていなかったらFalseを返します。
sentはトークン列を抽象化したオブジェクトなので、sent[i]のように添え字で参照することができます。
これの返り値はspacy.tokens.token.Tokenです。

トークンの属性textにはその単語の文章中の表記がそのまま入っています。
これを3つのトークンで参照して、「手」「に」「取る」のトークンが並んでいるかif文でチェックします。
もし並んでいたらfind_sore()に「取る」のトークンを渡し、「それ」を検索します。
「それ」が見つかったらTrueを返します。
「それら」の場合はneed_multi_soreTrueにしておきます。

    def analyze_sore_wo_te_ni_toru(self, sent):  
        """  
        「それを手に取る」という文を解析する  
        「手に取る」という文章があった場合Trueを返す  
        """  
        i = 0  
        while i < len(sent) - 2:  
            t1 = sent[i]  
            t2 = sent[i + 1]  
            t3 = sent[i + 2]  
            i += 1  
            if t1.text == '手' and t2.text == 'に' and t3.lemma_ == '取る':  
                sore = self.find_sore(t3)  # それを探す  
                if len(sore):  # 見つかった  
                    self.need_multi_sore = len(sore) >= 2  # 複数必要?  
                    return True  

        return False  

find_sore()で「それ」を探す

find_sore()は引数toruをキーにして依存構造から「それ」のトークンを探します。
トークンの依存構造上における子要素はchildren属性で参照することができます。
childrenはトークン列(正確にはジェネレーター)でfor文で回すことができます。

find_sore()は「それ」「これ」などのほかに「それら」「これら」も考慮して検索します。
見つかった場合はリストを返しますが、「それら」の場合はリストの長さが2になります。

    def find_sore(self, toru):  
        """  
        依存構造の子要素から「それ」を見つける  
        """  
        for child in toru.children:  
            if child.text in ('それ', 'これ'):  
                return [child]  # 見つかった  

            # 「それら」「これら」も考慮する  
            if child.text == 'ら':  
                for c in child.children:  
                    if c.text in ('それ', 'これ'):  
                        return [c, child]  # 見つかった  
        return []  # 見つからなかった  

export_sore_from_memory()で「それ」を抽出する

export_sore_from_memory()は過去の記憶から「それ」に該当する単語を抽出するメソッドです。
過去の記憶はmemoryから参照します。memoryNOUNSfor文で回し、名詞を表すトークンを取り出します。
そのトークンのtext属性がimi_dbのキーに格納されているか調べて、格納されていたら意味を取得して「重量」の項目を見ます。
重量が軽ければ、つまり「手に取れる」ということになるので、そのトークンを抽出します。

このメソッドを見るとわかりますが、「それ」の選択の優先度は先に登場した単語が高くなります。
ここら辺は機械的と言えます。

    def export_sore_from_memory(self):  
        """  
        「それ」を過去の記憶からエクスポートする  
        """  
        sore = []  

        # 記憶されている名詞を辿る  
        for noun in self.memory['NOUNS']:  
            # 名詞の意味を意味データベースから参照する  
            if noun.text in self.imi_db.keys():  
                imi = self.imi_db[noun.text]  
                if imi['重量'] == '軽い':  # 意味=重量が軽い  
                    # 重量が軽い=手に取れる  
                    sore.append(noun)  # このトークンは「手に取れる重量」なのでエクスポートする  
                    if not self.need_multi_sore:  # 複数必要ないなら  
                        return sore  # さっさとreturnする  

        return sore  

memory_nouns()で名詞を抽出する

memory_nouns()は引数sentから名詞を抽出します。
トークンの属性pos_には品詞の種類を表すラベルが保存されています。
ラベルがNOUNだった場合、そのトークンは名詞になるので、これを抽出します。

記憶には名詞しか保存してないので、「それ」にマッチさせるトークンは名詞のみになります。
代名詞や固有名詞を追加したい場合はここのメソッドを改造します。

    def memory_nouns(self, sent):  
        """  
        名詞をmemoryに記憶する  
        """  
        for tok in sent:  
            if tok.pos_ == 'NOUN':  # 品詞がNOUNのトークン  
                self.memory['NOUNS'].append(tok)  # 保存  

テストを書く

テストを書きます。

unittest.TestCaseを継承したクラスを作り、test_analyze()メソッドを定義します。
このtest_analyze()がテスト時に実行されます。

eq()メソッドは引数a, bのうちaを解析してその結果をbと比較します。
比較に失敗したらエラーになり、テストが失敗します。

テストケースの量的にはそこまで多くありません。
もっと書けばバグが見つかるかもしれません。

class Test(unittest.TestCase):  
    def eq(self, a, b):  
        an = Analyzer()  
        c = an.analyze(a)  
        self.assertEqual(c, b)  

    def test_analyze(self):  
        # うまく行ってるように見えるケース  
        self.eq('本棚に本があった。それを手に取った。', '本')  
        self.eq('浴槽に手鏡があった。これを手に取りたい。', '手鏡')  
        self.eq('浴槽に本と手鏡があった。それを手に取る。', '本')  # これは「それ」が曖昧  
        self.eq('浴槽に本と手鏡があった。それらを手に取る。', '本,手鏡')  
        self.eq('私は本を抱えて浴槽に入った。それを手に取って読んだ。', '本')  

        # 微妙なケース  
        self.eq('手鏡に照らされながら本を置いた。それを手に取った。', '手鏡')  

        # 意味不明なケース  
        # この解析器はそこに存在するかどうかは解析しないので、  
        # あるかどうかは関係ない  
        self.eq('床に本がなかった。それを手に取った', '本')  

        # 処理できないケース  
        self.eq('本棚に本があった。手に取った。', None)  
        self.eq('本棚に本があった。それを取った。', None)  
        self.eq('浴槽に本棚があった。これを手に取りたい。', None)  

おわりに

今回は「手に取れるそれ」を抽出するスクリプトをPythonとspaCyで作ってみました。
文脈をまたいでAIが返答すると、人間らしさが一気に出てきますね。

🦝 < それ取って

🐭 < どれ?

🦝 < ……