spaCyで「望遠鏡で~を見る」を解析する【自然言語処理, Python】

216, 2021-03-30

目次

spaCyで「望遠鏡で~を見る」を解析する

日本人は日本語は話しますが、この日本語は「自然言語」にカテゴライズされます。
この自然言語をプログラム的に解析するのが「自然言語処理」です。

Pythonには自然言語処理を行えるライブラリspaCy(スパイシー)があります。

今回はこのspaCyを使って「望遠鏡で泳ぐ彼女を見る」という文章を解析してみたいと思います。
具体的には↓を見ていきます。

  • 自然言語処理の行程

  • 意味解析とは?

  • 今回のプログラムは意味解析と言えるのか?

  • プログラムの実行結果

  • プログラムの設計

  • プログラムのソースコード

  • ソースコードの解説

自然言語処理の行程

自然言語処理は複数の工程に分かれています。
工程を1つずつ経ていくことで複雑な自然言語を解析するというのが一般的な解析の流れです。
まずもっとも基礎的な解析が「字句解析」です。

字句解析は文章を単語の列に分割する解析です。
たとえば「犬が歩く」という文章を字句解析すると

犬 / が / 歩く

という風に分割されます。
分割された単語には解析に使われた辞書の情報が埋め込まれます。
この情報は字句解析以降の解析で参照されます。

字句解析の次に行われるのが構文解析です。
構文解析は単語間の依存関係、係り受け関係を解析する解析です。
これはどの単語がどの単語に係っているのかと言う構造的な解析です。
たとえば「犬が歩く」という文章の依存構造は↓のようになります。

犬   NOUN ═╗<╗ iobj
が   ADP  <╝ ║ case
歩く VERB ═══╝ ROOT

↑を見ると「歩く」は「犬」に係っています。
そのため「歩く」という単語から「犬」という単語を辿ることができます。
構文解析は意味解析以降の解析で使われます。

構文解析の次の解析が意味解析です。
意味解析とは曖昧な文章の意味を決定し、正しい構文木を選択する解析です。
これについては後述します。

最後の解析が文脈解析です。
これは複数の文脈にまたがって意味解析などを行う解析です。

意味解析とは?

今回クローズアップするのがこの意味解析です。
繰り返しになりますが、意味解析とはあいまいな文章の意味を決定し、正しい構文木を選択する解析です。

たとえば「望遠鏡で泳ぐ彼女を見る」という文章があります。
この文章には意味的なあいまいさが含まれています。
それは「望遠鏡」という単語がどこに係っているのか? という点です。

「(望遠鏡で泳ぐ)彼女を見る」のか、それとも「(望遠鏡で)泳ぐ彼女を(見る)」のどちらの意味なのかというあいまいさがあります。
望遠鏡で泳いでいるのか、それとも望遠鏡で見ているのかということですね。

われわれ人間は「望遠鏡で泳ぐのは普通じゃない」ということを知っているので、この文章を見たときに瞬間的に「望遠鏡で見る」という意味を理解できます。
これは私たちが瞬時に意味解析を行っているからです。
しかし、プログラムはこの意味を決定できません。その結果、構文解析の結果は「望遠鏡で泳ぐ」と「望遠鏡で見る」の両方が含まれることになります。

意味解析はプログラム的にこのあいまいな意味を決定し、正しい構文木を選択します。

参考: 自然言語(日本語)処理

今回のプログラムは意味解析と言えるのか?

ところで今回作成するプログラムは意味解析をやっていると言えるのでしょうか?
これは実は筆者もよくわかりません。
やってることは意味解析の範囲内だと思いますが、なにぶん比較するサンプルコードなどがネット上で見つからないため検証できずにいます。

もし「これは意味解析ではない」というツッコミがありましたら、コメントなどで教えて頂けると幸いです。

プログラムの実行結果

今回作成するプログラムは単体テストで動作テストを行っています。
後述するソースコードをsample.pyなどに保存し端末からpython -m unittest sampleと実行すると、↓のような結果になります。

.
----------------------------------------------------------------------
Ran 1 test in 1.267s

OK

「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('ロボットを見る', None)  # 望遠鏡が無いため失敗
        self.eq('ロボットを叩いた', None)  # 同上
        self.eq('双眼鏡と望遠鏡で叩いた', None)  # 双眼鏡に係ってしまうため失敗

        # 解析に失敗するケース
        # 変な日本語
        self.eq('望遠鏡が泳ぐロボットを見た', None)

        # 解析に成功するケース
        self.eq('望遠鏡で叩いた', '望遠鏡で叩く')
        self.eq('望遠鏡で見たロボット', '望遠鏡で見る')
        self.eq('望遠鏡で見たロボットを叩いた', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを見た', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを叩いた', '望遠鏡で叩く')

        # 変な日本語
        self.eq('望遠鏡と泳ぐロボットを見た', '望遠鏡と見る')

↑のテストケースを見ると「望遠鏡で泳ぐロボットを見た」という入力に対して「望遠鏡で見る」という出力が得られているのがわかります。
これはプログラムが入力文章を意味解析し、「望遠鏡」がどこに係っているのか解析した結果です。
また「望遠鏡で泳ぐロボットを叩いた」という文章では「望遠鏡で叩く」という出力を得ています。これも同様です。

今回は「望遠鏡」の「見る」と「叩く」について選択できるようにしています。
「叩く」については検証のために無理やり付け足しました。

プログラムの設計

今回作成するプログラムの動作検証はunittestモジュールの単体テストで行っています。

プログラムのコア部分の実装はAnalyzerというクラスを作成してそのメソッドに実装します。
Analyzeranalyze()メソッドが解析のエントリーポイントで、これに文章を渡すことで解析を行うことができます。

analyze()は内部で文章をspaCyで解析してドキュメントにします。
そしてそのドキュメントから文を取り出して1文ずつ解析します。

文の中に動詞が見つかったら、その動詞をキーに解析を行います。
そしてAnalyzerの持つ意味データベース(ただの辞書)を参照して、意味解析します。

意味解析に成功したら結果を文字列で返して終了です。
基本的な設計は↑のようになります。

プログラムのソースコード

プログラムのソースコードの全文は↓になります。

"""
spaCyで「望遠鏡で~を見る」を解析する
"""
import spacy 
import unittest


nlp = spacy.load('ja_ginza')


class Analyzer:
    def __init__(self):
        # 単語の意味が保存された意味データベース
        self.imi_db = {
            '望遠鏡': {
                '活用': ['見る', '叩く']  # 望遠鏡は見る、叩くの活用が可能
            },
        }

    def analyze(self, text):
        """
        引数textを解析して結果を返す
        """
        doc = nlp(text)  # ドキュメントの生成

        for sent in doc.sents:  # 1文ずつ取り出す
            result = self.analyze_sent(sent)  # 文を解析
            if result:
                return result  # 解析に成功

        return None  # 解析に失敗

    def analyze_sent(self, sent):
        """
        文(sent)を解析して結果を返す
        """
        for tok in sent:  # トークンを取り出す
            if tok.pos_ == 'VERB':  # トークンが動詞だったら
                result = self.analyze_bouenkyo(tok)  # 望遠鏡ライクの解析
                if result:
                    return result  # 解析に成功

        return None  # 解析に失敗

    def analyze_bouenkyo(self, verb):
        """
        引数verbをキーに望遠鏡ライクの解析を行う
        """
        # 左の子(NOUN)を集める
        nouns = []
        self.collect_lefts(nouns, verb.head, ('NOUN', ))
        if not len(nouns):
            return None
        noun = nouns[-1]  # 最も左の子を取り出す

        # 右の子からADPを探す
        adp = self.find_rights(noun, ('ADP', ))
        if adp is None:
            return None

        # 意味を探す
        imi = self.find_imi(noun.text)
        if imi is None:
            return None

        # 意味の活用を調べる
        katsuyou = imi['活用']
        if verb.lemma_ not in katsuyou:
            return None

        # 結果を生成
        return f'{noun.text}{adp.text}{verb.lemma_}'

    def find_rights(self, tok, poses_):
        """
        右の子を再帰的に検索し、poses_にマッチするpos_を持つトークンを返す
        """
        if tok.pos_ in poses_:
            return tok  # 見つかった

        for child in tok.rights:
            found = self.find_rights(child, poses_)  # 再帰呼び出し
            if found:
                return found  # 見つかった

        return None  # 見つからなかった

    def collect_lefts(self, dst, tok, poses_):
        """
        poses_にマッチするpos_を持つ左の子を再帰的に集める
        """
        if tok.pos_ in poses_:
            dst.append(tok)

        for child in tok.lefts:
            self.collect_lefts(dst, child, poses_)

    def find_imi(self, key):
        """
        引数keyをキーに意味データベースを参照する
        """
        if key in self.imi_db.keys():
            return self.imi_db[key]
        return None


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('ロボットを見る', None)  # 望遠鏡が無いため失敗
        self.eq('ロボットを叩いた', None)  # 同上
        self.eq('双眼鏡と望遠鏡で叩いた', None)  # 双眼鏡に係ってしまうため失敗

        # 解析に失敗するケース
        # 変な日本語
        self.eq('望遠鏡が泳ぐロボットを見た', None)

        # 解析に成功するケース
        self.eq('望遠鏡で叩いた', '望遠鏡で叩く')
        self.eq('望遠鏡で見たロボット', '望遠鏡で見る')
        self.eq('望遠鏡で見たロボットを叩いた', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを見た', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを叩いた', '望遠鏡で叩く')

        # 変な日本語
        self.eq('望遠鏡と泳ぐロボットを見た', '望遠鏡と見る')

ソースコードの解説

ここからソースコードの解説になります。

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

spaCyで自然言語処理を行うのでspacyをインポートします。
それから単体テスト用にunittestをインポートします。

import spacy 
import unittest

GiNZAのロード

今回、spaCyと組み合わせて使うのがGiNZAです。
GiNZAはリクルートと国立国語研究所が開発した自然言語処理ライブラリで、spaCyではGiNZAのモデルを利用することができます。

spaCyはGiNZAを利用することで日本語の文章を解析することができます。
↓のようにspacy.load()ja_ginzaを指定するとGiNZAのモデルの読み込みが行われます。
その結果はginza.Japaneseで返ってくるので、慣例的にnlpと命名しておきます。

nlp = spacy.load('ja_ginza')

このようにspaCyはモデルをロードして利用するのが一般的な使い方です。
GiNZAを使う場合はpipで↓のようにインストールが必要です。

$ pip install -U ginza

Analyzerクラスの作成

Analyzerクラスを作成します。
このクラスに主要なメソッドを定義していきます。
imi_dbという属性を定義します。
これは意味データベースのことで、単語の意味が保存される辞書です。
↓の場合は「望遠鏡」という単語が登録されています。
「望遠鏡」の活用方法、つまり「見たり叩いたりできる」という意味も登録されています。
これが「スケート靴」だったら活用は「滑る」になるでしょう。

理屈では意味データベースに単語の意味を登録していけば、たくさんの解析が行えるはずですが、この辞書を充実させるのは非常に労力がいることが予想されます。

辞書はデータだからね

データが一番大変ね

class Analyzer:
    def __init__(self):
        # 単語の意味が保存された意味データベース
        self.imi_db = {
            '望遠鏡': {
                '活用': ['見る', '叩く']  # 望遠鏡は見る、叩くの活用が可能
            },
        }

analyze()で文章を解析する

analyze()メソッドは引数textを解析して、結果を返します。
textは内部でnlp()に渡されて解析されます。この結果はspacy.tokens.doc.Docで返ってきます。
doc.sentsfor文で回すと、文を1文ずつ取り出すことができます。
この文をanalyze_sent()で解析します。

    def analyze(self, text):
        """
        引数textを解析して結果を返す
        """
        doc = nlp(text)  # ドキュメントの生成

        for sent in doc.sents:  # 1文ずつ取り出す
            result = self.analyze_sent(sent)  # 文を解析
            if result:
                return result  # 解析に成功

        return None  # 解析に失敗

analyze_sent()で文を解析

analyze_sent()は引数sentを解析して結果を返します。
sentはトークン列です。for文で回すとトークンを取り出せます。
トークンのpos_属性には単語の品詞が保存されています。これがVERBであればその単語は動詞と言うことになります。
動詞が見つかったらanalyze_bouenkyo()でそのトークンをキーに解析を分岐します。

    def analyze_sent(self, sent):
        """
        文(sent)を解析して結果を返す
        """
        for tok in sent:  # トークンを取り出す
            if tok.pos_ == 'VERB':  # トークンが動詞だったら
                result = self.analyze_bouenkyo(tok)  # 望遠鏡ライクの解析
                if result:
                    return result  # 解析に成功

        return None  # 解析に失敗

analyze_bouenkyo()で意味解析する

analyze_bouenkyo()はコア部分です。
引数verbをキーに意味解析します。

まずverb.headを参照します。
headにはそのトークンの依存構造上における親が保存されています。
これをcollect_lefts()に渡して、NOUN(名詞)のpos_を持つトークンを依存構造上の子要素から集めます。
そして最も左の子を取り出して、それをキーにADP(設置詞)をfind_rights()で探します。

ADPが見つかったら解析を続行しNOUNのトークンのtext属性を参照します。
text属性にはその単語の元の表記がそのまま入っています。
これを参照してfind_imi()で意味データベースを参照して単語の意味を探します。

意味を見つけたらその活用を調べます。
verblemma_属性が活用に含まれているか調べ、含まれていたら結果を生成します。
lemma_属性は単語の原形が保存されています。

このメソッドが最後まで解析されると、「望遠鏡で見る」や「望遠鏡で叩く」などの結果が生成されます。

    def analyze_bouenkyo(self, verb):
        """
        引数verbをキーに望遠鏡ライクの解析を行う
        """
        # 左の子(NOUN)を集める
        nouns = []
        self.collect_lefts(nouns, verb.head, ('NOUN', ))
        if not len(nouns):
            return None
        noun = nouns[-1]  # 最も左の子を取り出す

        # 右の子からADPを探す
        adp = self.find_rights(noun, ('ADP', ))
        if adp is None:
            return None

        # 意味を探す
        imi = self.find_imi(noun.text)
        if imi is None:
            return None

        # 意味の活用を調べる
        katsuyou = imi['活用']
        if verb.lemma_ not in katsuyou:
            return None

        # 結果を生成
        return f'{noun.text}{adp.text}{verb.lemma_}'

collect_lefts()で子トークンを集める

collect_lefts()は引数tokを再帰的に参照します。
tokpos_属性が引数poses_の中に含まれていたらそのtokdstに保存します。
for文でlefts属性を参照します。leftsは依存構造上における左の子要素が保存されていて、これはトークン列です。
再帰的にcollect_lefts()を呼び出して子トークンを集めていきます。

    def collect_lefts(self, dst, tok, poses_):
        """
        poses_にマッチするpos_を持つ左の子を再帰的に集める
        """
        if tok.pos_ in poses_:
            dst.append(tok)

        for child in tok.lefts:
            self.collect_lefts(dst, child, poses_)

find_rights()で子トークンを見つける

find_rightsは引数tokを再帰的に参照して、poses_にマッチするpos_を持つトークンを返します。
トークンのrights属性には依存構造上における右側の子トークンが保存されています。これもトークン列です。
再帰的にfind_rights()を呼び出して子トークンを検索していきます。

    def find_rights(self, tok, poses_):
        """
        右の子を再帰的に検索し、poses_にマッチするpos_を持つトークンを返す
        """
        if tok.pos_ in poses_:
            return tok  # 見つかった

        for child in tok.rights:
            found = self.find_rights(child, poses_)  # 再帰呼び出し
            if found:
                return found  # 見つかった

        return None  # 見つからなかった

find_imi()で意味を参照する

find_imi()は引数keyをキーにしてimi_dbを参照して意味を返します。
見つからなかったらNoneを返します。

    def find_imi(self, key):
        """
        引数keyをキーに意味データベースを参照する
        """
        if key in self.imi_db.keys():
            return self.imi_db[key]
        return None

テストを書く

テストを書きます。
今回はテスト量はあまり多くありません。
そのためテストを追加していくとバグが見つかる可能性がありますが、その辺はご了承ください。

自然言語のテストって大変

ほんまやね

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('ロボットを見る', None)  # 望遠鏡が無いため失敗
        self.eq('ロボットを叩いた', None)  # 同上
        self.eq('双眼鏡と望遠鏡で叩いた', None)  # 双眼鏡に係ってしまうため失敗

        # 解析に失敗するケース
        # 変な日本語
        self.eq('望遠鏡が泳ぐロボットを見た', None)

        # 解析に成功するケース
        self.eq('望遠鏡で叩いた', '望遠鏡で叩く')
        self.eq('望遠鏡で見たロボット', '望遠鏡で見る')
        self.eq('望遠鏡で見たロボットを叩いた', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを見た', '望遠鏡で見る')
        self.eq('望遠鏡で泳ぐロボットを叩いた', '望遠鏡で叩く')

        # 変な日本語
        self.eq('望遠鏡と泳ぐロボットを見た', '望遠鏡と見る')

おわりに

今回はspaCyで「望遠鏡で泳ぐ彼女を見る」という文章を解析してみました。
ルールベースの意味解析ですが、なかなか抽象的なプログラムですよね。

自然言語処理って抽象的なところがあるよね

まぁ自然が相手だから

自然は手ごわい